From f86fb76b33d11dd09ca5b3105ad3c4ac864e6cfa Mon Sep 17 00:00:00 2001 From: Spencer Brower Date: Wed, 29 Apr 2026 17:42:20 -0400 Subject: [PATCH] feat: Implemented basic db operation. --- src/{config.zig => Config.zig} | 0 src/Db.zig | 242 +++++++++++++++++++++++++++++++++ src/age.zig | 6 +- src/db.zig | 76 ----------- src/root.zig | 3 +- 5 files changed, 249 insertions(+), 78 deletions(-) rename src/{config.zig => Config.zig} (100%) create mode 100644 src/Db.zig delete mode 100644 src/db.zig diff --git a/src/config.zig b/src/Config.zig similarity index 100% rename from src/config.zig rename to src/Config.zig diff --git a/src/Db.zig b/src/Db.zig new file mode 100644 index 0000000..8e83504 --- /dev/null +++ b/src/Db.zig @@ -0,0 +1,242 @@ +const std = @import("std"); +const sqlite = @import("sqlite"); + +const age = @import("age.zig"); + +/// The underlying data store. +sql_db: sqlite.Db, + +/// Set to true whenever the data updates. If false when close() is called, +/// the database will be closed without saving +changed: bool = false, + +/// Decrypts the database into a temporary file and opens it in memory +// FIXME: Test me with real file +pub fn open( + io: std.Io, + gpa: std.mem.Allocator, + /// The path to the home directory + home: []const u8, + /// The path to the /tmp directory + tmp: []const u8, +) !@This() { + // TODO: Check if database already exists + const db_path = try std.fs.path.join(gpa, &.{ home, ".envr", "data.age" }); + defer gpa.free(db_path); + + var db = try new(); + + if (db_exists(io, db_path)) { + // const tmp_dir = try std.Io.Dir.cwd().openDir(io, tmp, .{}); + // defer tmp_dir.deleteFile(io, "envr.db"); + + const tmp_db_path = try std.fs.path.join(gpa, &.{ tmp, "envr.db" }); + defer gpa.free(tmp_db_path); + + // TODO: Fix key + try age.decrypt(io, gpa, "~/.ssh/id_ed25519", db_path, tmp_db_path); + + try db.restore(tmp_db_path); + try std.Io.Dir.cwd().deleteFile(io, tmp_db_path); + + return db; + } else { + return db; + } +} + +/// Create a new instance of the database in-memory +fn new() !@This() { + var db = try sqlite.Db.init(.{ + .mode = .Memory, + .open_flags = .{ .write = true, .create = true }, + .threading_mode = .MultiThread, + }); + + try db.exec( + \\create table envr_env_files ( + \\ path text primary key not null + \\, remotes text -- JSON + \\, sha256 text not null + \\, contents text not null + \\) + , .{}, .{}); + + return .{ .sql_db = db }; +} + +/// Returns true if a file exists at ~/.envr/data.age +fn db_exists(io: std.Io, path: []const u8) bool { + if (std.Io.Dir.cwd().access(io, path, .{ .read = true })) { + return true; + } else |_| { + return false; + } +} + +/// Loads the unencrypted sqlite db at filepath path into the datbase +/// FIXME: Test me +fn restore( + self: *@This(), + path: []const u8, +) !void { + try self.sql_db.exec( + "ATTACH DATABASE ? AS source", + .{}, + .{path}, + ); + defer self.sql_db.exec("DETACH DATABASE source", .{}, .{}) catch unreachable; + + try self.sql_db.exec( + "INSERT INTO main.envr_env_files SELECT * FROM source.envr_env_files", + .{}, + .{}, + ); +} + +// TODO: Finish +// pub fn tmpDir(opts: std.fs.Dir.OpenDirOptions) TmpDir { +// var random_bytes: [TmpDir.random_bytes_count]u8 = undefined; +// std.crypto.random.bytes(&random_bytes); +// var sub_path: [TmpDir.sub_path_len]u8 = undefined; +// _ = std.fs.base64_encoder.encode(&sub_path, &random_bytes); +// } +// +// const TmpDir = struct {}; + +/// Close the database +/// FIXME: Test me with data but no changes +/// FIXME: Test me with data and changes +pub fn close( + self: *@This(), + io: std.Io, + gpa: std.mem.Allocator, + home: []const u8, + tmp: []const u8, +) !void { + defer self.sql_db.deinit(); + + if (self.changed) { + const tmp_db_path = try std.fs.path.join(gpa, &.{ tmp, "envr.db" }); + defer gpa.free(tmp_db_path); + + try self.sql_db.exec("VACUUM INTO ?", .{}, .{tmp_db_path}); + + const db_path = try std.fs.path.join(gpa, &.{ home, ".envr", "data.age" }); + defer gpa.free(db_path); + + // FIXME: Use real key + try age.encrypt(io, gpa, "~/.ssh/id_ed25519.pub", tmp_db_path, db_path); + + self.changed = false; + } +} + +test { + std.testing.refAllDecls(@import("age.zig")); +} + +test "simple database can be opened" { + var db = try sqlite.Db.init(.{ + .mode = sqlite.Db.Mode{ .File = "./fixtures/example.db" }, + .open_flags = .{ + .write = false, + .create = false, + }, + .threading_mode = .MultiThread, + }); + + var stmt = try db.prepare("SELECT * FROM hello"); + defer stmt.deinit(); + + const alloc = std.testing.allocator; + + if (try stmt.oneAlloc(struct { text: []const u8 }, alloc, .{}, .{})) |got| { + defer alloc.free(got.text); + + try std.testing.expectEqualSlices(u8, "world!", got.text); + } else { + return error.TestUnexpectedResult; + } +} + +test "encrypted database can be opened" { + const io = std.testing.io; + const gpa = std.testing.allocator; + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const dir_path = try tmp.dir.realPathFileAlloc(io, ".", gpa); + defer gpa.free(dir_path); + + const decrypted_path = try std.fs.path.joinZ(gpa, &.{ dir_path, "example.db" }); + defer gpa.free(decrypted_path); + + try age.decrypt( + io, + gpa, + "./fixtures/insecure-test-key", + "./fixtures/encrypted-example.db.age", + decrypted_path, + ); + + var db = try sqlite.Db.init(.{ + .mode = sqlite.Db.Mode{ .File = decrypted_path }, + .open_flags = .{ + .write = false, + .create = false, + }, + .threading_mode = .MultiThread, + }); + + var stmt = try db.prepare("SELECT * FROM hello"); + defer stmt.deinit(); + + const alloc = std.testing.allocator; + + if (try stmt.oneAlloc(struct { text: []const u8 }, alloc, .{}, .{})) |got| { + defer alloc.free(got.text); + + try std.testing.expectEqualSlices(u8, "world!", got.text); + } else { + return error.TestUnexpectedResult; + } +} + +test "Closing a fresh database does not create a file" { + const io = std.testing.io; + const gpa = std.testing.allocator; + + var tmp_dir = std.testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + // @compileLog(@typeInfo(std.Io.File.Permissions)); + try tmp_dir.dir.createDir(io, "home", .default_dir); + try tmp_dir.dir.createDir(io, "tmp", .default_dir); + + const tmp_dir_path = try tmp_dir.dir.realPathFileAlloc(io, ".", gpa); + defer gpa.free(tmp_dir_path); + + const home = try std.fs.path.join(gpa, &.{ tmp_dir_path, "home" }); + defer gpa.free(home); + const tmp = try std.fs.path.join(gpa, &.{ tmp_dir_path, "tmp" }); + defer gpa.free(tmp); + + var db: @This() = try .open(io, gpa, home, tmp); + + const db_path = try std.fs.path.join(gpa, &.{ home, ".envr", "data.age" }); + defer gpa.free(db_path); + + try std.testing.expectError( + error.FileNotFound, + tmp_dir.dir.access(io, db_path, .{ .read = true }), + ); + + try db.close(io, gpa, home, tmp); + + try std.testing.expectError( + error.FileNotFound, + tmp_dir.dir.access(io, db_path, .{ .read = true }), + ); +} diff --git a/src/age.zig b/src/age.zig index f934173..0ee6239 100644 --- a/src/age.zig +++ b/src/age.zig @@ -125,9 +125,13 @@ test "sample file can be encrypted" { gpa, .unlimited, ); + defer gpa.free(want); const contents = try tmp.dir.readFileAlloc(io, output_path, gpa, .unlimited); defer gpa.free(contents); - try std.testing.expectEqualSlices(u8, want, got); + try std.testing.expectEqual(want.len, got.len); + + // FIXME: Test that decrypted file contents match + // try std.testing.expectEqualSlices(u8, "Hello, World!\n", decrypted_contents); } diff --git a/src/db.zig b/src/db.zig deleted file mode 100644 index da1b749..0000000 --- a/src/db.zig +++ /dev/null @@ -1,76 +0,0 @@ -const std = @import("std"); -const sqlite = @import("sqlite"); - -const age = @import("age.zig"); - -test { - std.testing.refAllDecls(@import("age.zig")); -} - -test "simple database can be opened" { - var db = try sqlite.Db.init(.{ - .mode = sqlite.Db.Mode{ .File = "./fixtures/example.db" }, - .open_flags = .{ - .write = false, - .create = false, - }, - .threading_mode = .MultiThread, - }); - - var stmt = try db.prepare("SELECT * FROM hello"); - defer stmt.deinit(); - - const alloc = std.testing.allocator; - - if (try stmt.oneAlloc(struct { text: []const u8 }, alloc, .{}, .{})) |got| { - defer alloc.free(got.text); - - try std.testing.expectEqualSlices(u8, "world!", got.text); - } else { - return error.TestUnexpectedResult; - } -} - -test "encrypted database can be opened" { - const io = std.testing.io; - const gpa = std.testing.allocator; - - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - - const dir_path = try tmp.dir.realPathFileAlloc(io, ".", gpa); - defer gpa.free(dir_path); - - const decrypted_path = try std.fs.path.joinZ(gpa, &.{ dir_path, "example.db" }); - defer gpa.free(decrypted_path); - - try age.decrypt( - io, - gpa, - "./fixtures/insecure-test-key", - "./fixtures/encrypted-example.db.age", - decrypted_path, - ); - - var db = try sqlite.Db.init(.{ - .mode = sqlite.Db.Mode{ .File = decrypted_path }, - .open_flags = .{ - .write = false, - .create = false, - }, - .threading_mode = .MultiThread, - }); - - var stmt = try db.prepare("SELECT * FROM hello"); - defer stmt.deinit(); - - const alloc = std.testing.allocator; - - if (try stmt.oneAlloc(struct { text: []const u8 }, alloc, .{}, .{})) |got| { - defer alloc.free(got.text); - - try std.testing.expectEqualSlices(u8, "world!", got.text); - } else { - return error.TestUnexpectedResult; - } -} diff --git a/src/root.zig b/src/root.zig index 3271fd9..7ed93c4 100644 --- a/src/root.zig +++ b/src/root.zig @@ -57,7 +57,8 @@ pub const root: Command = .new(.{ }); test { - std.testing.refAllDecls(@import("db.zig")); + std.testing.refAllDecls(@import("Config.zig")); + std.testing.refAllDecls(@import("Db.zig")); } test "enum type" {