diff --git a/src/cli/commands/generation.zig b/src/cli/commands/generation.zig index 89c9b6b..1b4da78 100644 --- a/src/cli/commands/generation.zig +++ b/src/cli/commands/generation.zig @@ -183,7 +183,10 @@ pub fn handleList(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!t defer ctx.allocator.free(gc_roots_dir); // Get current generation - const current = generation_mod.getCurrentGeneration(profile_dir) catch null; + const current = generation_mod.getCurrentGeneration(profile_dir) catch |err| { + ctx.setDiagnosticContextFmt(profile_dir, "failed to read current generation: {s}", .{@errorName(err)}); + return try command.errorResult(ctx, err, "failed to read current generation"); + }; const store_root = std.fs.path.join(ctx.allocator, &.{ ctx.root_path, "mere", "store" }) catch { return MereError.OutOfMemory; diff --git a/src/cli/commands/profile.zig b/src/cli/commands/profile.zig index 0f816e6..a5dde92 100644 --- a/src/cli/commands/profile.zig +++ b/src/cli/commands/profile.zig @@ -166,7 +166,10 @@ pub fn handleList(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!t const kind_str = if (is_system) " [system]" else ""; if (is_system) { - const current_gen = generation_mod.getCurrentGeneration(profile_path) catch null; + const current_gen = generation_mod.getCurrentGeneration(profile_path) catch |err| { + ctx.setDiagnosticContextFmt(profile_path, "failed to read current generation: {s}", .{@errorName(err)}); + return try command.errorResult(ctx, err, "failed to read current generation"); + }; const store_root = std.fs.path.join(ctx.allocator, &.{ ctx.root_path, "mere", "store" }) catch { return MereError.OutOfMemory; }; diff --git a/src/generation.zig b/src/generation.zig index 08445d3..49be281 100644 --- a/src/generation.zig +++ b/src/generation.zig @@ -612,7 +612,7 @@ pub fn getCurrentGeneration(profile_dir: []const u8) GenerationError!?u32 { }; }; - return parseGenerationNumber(target_buf[0..target_len]); + return parseGenerationNumber(target_buf[0..target_len]) orelse GenerationError.InvalidInput; } pub fn listGenerations( @@ -796,6 +796,24 @@ test "getCurrentGeneration returns null when no active generation" { try std.testing.expectEqual(@as(?u32, null), current); } +test "getCurrentGeneration rejects malformed active generation target" { + const th = @import("test_helpers.zig"); + var test_env = try th.createTestEnv(); + defer { + test_env.cleanup(); + std.testing.allocator.destroy(test_env); + } + + const allocator = test_env.ctx.allocator; + const profile_dir = try std.fs.path.join(allocator, &.{ test_env.path, "mere", "profiles", "system" }); + defer allocator.free(profile_dir); + var profile_dir_handle = try path_mod.makePathAndOpenDir(profile_dir); + defer profile_dir_handle.close(path_mod.currentIo()); + try profile_dir_handle.symLink(path_mod.currentIo(), "not-a-generation", CURRENT_SYMLINK, .{}); + + try std.testing.expectError(GenerationError.InvalidInput, getCurrentGeneration(profile_dir)); +} + test "findPreviousGeneration returns previous generation" { const th = @import("test_helpers.zig"); var test_env = try th.createTestEnv(); @@ -932,30 +950,52 @@ pub fn readManifest(allocator: std.mem.Allocator, store_root: []const u8, genera return GenerationManifest.parse(allocator, store_root, buffer); } -pub fn writeManifest(allocator: std.mem.Allocator, generation_dir: []const u8, manifest: *const GenerationManifest) GenerationError!void { - const manifest_path = std.fs.path.join(allocator, &.{ generation_dir, MANIFEST_FILENAME }) catch { +fn writeMetadataFileAtomically(allocator: std.mem.Allocator, destination: []const u8, content: []const u8) GenerationError!void { + const temporary = std.fmt.allocPrint(allocator, "{s}.tmp", .{destination}) catch { return GenerationError.OutOfMemory; }; - defer allocator.free(manifest_path); + defer allocator.free(temporary); - const content = try manifest.encode(allocator); - defer allocator.free(content); + var published = false; + defer if (!published) std.Io.Dir.deleteFileAbsolute(path_mod.currentIo(), temporary) catch {}; const io = path_mod.currentIo(); - var file = std.Io.Dir.createFileAbsolute(io, manifest_path, .{}) catch |err| { - return switch (err) { - error.AccessDenied => GenerationError.PermissionDenied, - else => GenerationError.FileSystem, + { + var file = std.Io.Dir.createFileAbsolute(io, temporary, .{ .truncate = true }) catch |err| { + return switch (err) { + error.AccessDenied => GenerationError.PermissionDenied, + else => GenerationError.FileSystem, + }; }; - }; - defer file.close(io); + defer file.close(io); - file.writeStreamingAll(io, content) catch |err| { + file.writeStreamingAll(io, content) catch |err| { + return switch (err) { + error.AccessDenied => GenerationError.PermissionDenied, + else => GenerationError.FileSystem, + }; + }; + } + + std.Io.Dir.renameAbsolute(temporary, destination, io) catch |err| { return switch (err) { error.AccessDenied => GenerationError.PermissionDenied, else => GenerationError.FileSystem, }; }; + published = true; +} + +pub fn writeManifest(allocator: std.mem.Allocator, generation_dir: []const u8, manifest: *const GenerationManifest) GenerationError!void { + const manifest_path = std.fs.path.join(allocator, &.{ generation_dir, MANIFEST_FILENAME }) catch { + return GenerationError.OutOfMemory; + }; + defer allocator.free(manifest_path); + + const content = try manifest.encode(allocator); + defer allocator.free(content); + + try writeMetadataFileAtomically(allocator, manifest_path, content); } pub fn readRealization(allocator: std.mem.Allocator, generation_dir: []const u8) GenerationError!RealizationData { @@ -1012,21 +1052,7 @@ pub fn writeRealization( const content = try realization.encode(allocator); defer allocator.free(content); - const io = path_mod.currentIo(); - var file = std.Io.Dir.createFileAbsolute(io, realization_path, .{}) catch |err| { - return switch (err) { - error.AccessDenied => GenerationError.PermissionDenied, - else => GenerationError.FileSystem, - }; - }; - defer file.close(io); - - file.writeStreamingAll(io, content) catch |err| { - return switch (err) { - error.AccessDenied => GenerationError.PermissionDenied, - else => GenerationError.FileSystem, - }; - }; + try writeMetadataFileAtomically(allocator, realization_path, content); } // Tests diff --git a/src/install.zig b/src/install.zig index eb904f9..0743c7e 100644 --- a/src/install.zig +++ b/src/install.zig @@ -149,7 +149,7 @@ fn profileMatchesResolution(ctx: *Context, profile_name: []const u8, sorted: []c const profile_dir = getProfileDir(ctx, profile_name) catch return false; defer ctx.allocator.free(profile_dir); - const current = loadCurrentManifest(ctx, profile_name, profile_dir) orelse return false; + const current = (loadCurrentManifest(ctx, profile_name, profile_dir) catch return false) orelse return false; defer { var m = current; m.deinit(); @@ -179,7 +179,7 @@ fn emitResolutionDiff( const profile_dir = getProfileDir(ctx, profile_name) catch return; defer ctx.allocator.free(profile_dir); - const current = loadCurrentManifest(ctx, profile_name, profile_dir) orelse { + const current = (loadCurrentManifest(ctx, profile_name, profile_dir) catch null) orelse { var buf: [32]u8 = undefined; const count_text = std.fmt.bufPrint(&buf, "{d}", .{sorted.len}) catch return; const segments = [_]mere.ui.Segment{ @@ -245,7 +245,7 @@ fn emitProfileDiff( const profile_dir = getProfileDir(ctx, profile_name) catch return; defer ctx.allocator.free(profile_dir); - const current = loadCurrentManifest(ctx, profile_name, profile_dir) orelse { + const current = (loadCurrentManifest(ctx, profile_name, profile_dir) catch null) orelse { var buf: [32]u8 = undefined; const count_text = std.fmt.bufPrint(&buf, "{d}", .{new_packages.len}) catch return; const segments = [_]mere.ui.Segment{ @@ -784,7 +784,9 @@ fn loadRequestedRootsState(ctx: *Context, profile_name: []const u8) !RequestedRo packages.deinit(ctx.allocator); } - const manifest_opt = loadCurrentManifest(ctx, profile_name, profile_dir); + const manifest_opt = loadCurrentManifest(ctx, profile_name, profile_dir) catch |err| { + return failCurrentStateRead(ctx, profile_dir, "failed to read current profile manifest", err); + }; if (manifest_opt) |manifest_data| { var current = manifest_data; defer current.deinit(); @@ -803,20 +805,24 @@ fn loadRequestedRootsState(ctx: *Context, profile_name: []const u8) !RequestedRo }; } -fn loadCurrentManifest(ctx: *Context, profile_name: []const u8, profile_dir: []const u8) ?generation.GenerationManifest { - const store_root = std.fs.path.join(ctx.allocator, &.{ ctx.root_path, "mere", "store" }) catch return null; +fn loadCurrentManifest(ctx: *Context, profile_name: []const u8, profile_dir: []const u8) generation.GenerationError!?generation.GenerationManifest { + const store_root = std.fs.path.join(ctx.allocator, &.{ ctx.root_path, "mere", "store" }) catch return generation.GenerationError.OutOfMemory; defer ctx.allocator.free(store_root); if (std.mem.eql(u8, profile_name, "system")) { - const current_gen = generation.getCurrentGeneration(profile_dir) catch return null; + const current_gen = try generation.getCurrentGeneration(profile_dir); const gen_num = current_gen orelse return null; - const gen_path = generation.getGenerationPath(ctx.allocator, profile_dir, gen_num) catch return null; + const gen_path = try generation.getGenerationPath(ctx.allocator, profile_dir, gen_num); defer ctx.allocator.free(gen_path); - return generation.readManifest(ctx.allocator, store_root, gen_path) catch null; + const loaded_manifest = try generation.readManifest(ctx.allocator, store_root, gen_path); + return @as(?generation.GenerationManifest, loaded_manifest); } else { - const root_path = profile.getRootPath(ctx.allocator, profile_dir) catch return null; + const root_path = profile.getRootPath(ctx.allocator, profile_dir) catch return generation.GenerationError.OutOfMemory; defer ctx.allocator.free(root_path); - return generation.readManifest(ctx.allocator, store_root, root_path) catch null; + return generation.readManifest(ctx.allocator, store_root, root_path) catch |err| switch (err) { + generation.GenerationError.GenerationNotFound => null, + else => err, + }; } } @@ -925,6 +931,11 @@ fn preferredSelectionsFromManifest( }; } +fn failCurrentStateRead(ctx: *Context, subject: []const u8, operation: []const u8, err: generation.GenerationError) anyerror { + ctx.setDiagnosticContextFmt(subject, "{s}: {s}", .{ operation, @errorName(err) }); + return mapGenerationError(err); +} + fn loadCurrentGenerationPreferences(ctx: *Context, profile_name: []const u8) !PreferredSelectionsState { const profile_dir = try getProfileDir(ctx, profile_name); defer ctx.allocator.free(profile_dir); @@ -945,19 +956,7 @@ fn loadCurrentGenerationPreferences(ctx: *Context, profile_name: []const u8) !Pr }; var manifest_data = generation.readManifest(ctx.allocator, store_root, root_path) catch |err| { - return switch (err) { - generation.GenerationError.OutOfMemory => error.OutOfMemory, - generation.GenerationError.PermissionDenied => error.PermissionDenied, - generation.GenerationError.InvalidManifest, - generation.GenerationError.ParseError, - generation.GenerationError.InvalidInput, - generation.GenerationError.GenerationNotFound, - generation.GenerationError.NoCurrentGeneration, - generation.GenerationError.NoPreviousGeneration, - generation.GenerationError.ProfilesNotFound, - => error.InvalidInput, - else => error.FileSystem, - }; + return failCurrentStateRead(ctx, root_path, "failed to read current profile manifest", err); }; errdefer manifest_data.deinit(); @@ -966,10 +965,8 @@ fn loadCurrentGenerationPreferences(ctx: *Context, profile_name: []const u8) !Pr const current_generation = generation.getCurrentGeneration(profile_dir) catch |err| { return switch (err) { - generation.GenerationError.PermissionDenied => error.PermissionDenied, - generation.GenerationError.OutOfMemory => error.OutOfMemory, generation.GenerationError.ProfilesNotFound => PreferredSelectionsState.initEmpty(ctx.allocator), - else => error.FileSystem, + else => failCurrentStateRead(ctx, profile_dir, "failed to read current generation", err), }; } orelse return PreferredSelectionsState.initEmpty(ctx.allocator); @@ -984,19 +981,7 @@ fn loadCurrentGenerationPreferences(ctx: *Context, profile_name: []const u8) !Pr defer ctx.allocator.free(gen_path); var manifest_data = generation.readManifest(ctx.allocator, store_root, gen_path) catch |err| { - return switch (err) { - generation.GenerationError.OutOfMemory => error.OutOfMemory, - generation.GenerationError.PermissionDenied => error.PermissionDenied, - generation.GenerationError.InvalidManifest, - generation.GenerationError.ParseError, - generation.GenerationError.InvalidInput, - generation.GenerationError.GenerationNotFound, - generation.GenerationError.NoCurrentGeneration, - generation.GenerationError.NoPreviousGeneration, - generation.GenerationError.ProfilesNotFound, - => error.InvalidInput, - else => error.FileSystem, - }; + return failCurrentStateRead(ctx, gen_path, "failed to read current generation manifest", err); }; errdefer manifest_data.deinit(); @@ -1623,16 +1608,20 @@ fn applyProfileRealization(ctx: *Context, prof_name: []const u8, installed_packa }; if (std.mem.eql(u8, prof_name, "system")) { - const current_gen = generation.getCurrentGeneration(profile_dir) catch null; + const current_gen = generation.getCurrentGeneration(profile_dir) catch |err| { + return ctx.failFmt(mapGenerationError(err), profile_dir, "failed to read current generation: {s}", .{@errorName(err)}); + }; var previous_manifest: ?generation.GenerationManifest = null; defer if (previous_manifest) |*manifest_data| manifest_data.deinit(); if (current_gen) |gen_num| { - const previous_path = generation.getGenerationPath(ctx.allocator, profile_dir, gen_num) catch null; - if (previous_path) |path_name| { - defer ctx.allocator.free(path_name); - previous_manifest = generation.readManifest(ctx.allocator, store_root, path_name) catch null; - } + const previous_path = generation.getGenerationPath(ctx.allocator, profile_dir, gen_num) catch |err| { + return ctx.failFmt(mapGenerationError(err), profile_dir, "failed to construct current generation path: {s}", .{@errorName(err)}); + }; + defer ctx.allocator.free(previous_path); + previous_manifest = generation.readManifest(ctx.allocator, store_root, previous_path) catch |err| { + return ctx.failFmt(mapGenerationError(err), previous_path, "failed to read current generation manifest: {s}", .{@errorName(err)}); + }; } const gen_num = profile.createGeneration( @@ -4601,6 +4590,38 @@ test "determineInstallTargetBehavior defers non-privileged system installs" { ); } +test "loadCurrentGenerationPreferences reports corrupt current manifest details" { + const th = @import("test_helpers.zig"); + var test_env = try th.createTestEnv(); + defer { + test_env.cleanup(); + std.testing.allocator.destroy(test_env); + } + + const ctx = &test_env.ctx; + const allocator = ctx.allocator; + const profile_dir = try std.fs.path.join(allocator, &.{ ctx.root_path, "mere", "profiles", "system" }); + defer allocator.free(profile_dir); + try path.ensureDirExists(profile_dir); + + const gen_path = try std.fs.path.join(allocator, &.{ profile_dir, "gen-1" }); + defer allocator.free(gen_path); + try path.ensureDirExists(gen_path); + const manifest_path = try std.fs.path.join(allocator, &.{ gen_path, generation.MANIFEST_FILENAME }); + defer allocator.free(manifest_path); + var manifest_file = try std.Io.Dir.createFileAbsolute(path.currentIo(), manifest_path, .{ .truncate = true }); + manifest_file.close(path.currentIo()); + + var profile_handle = try std.Io.Dir.openDirAbsolute(path.currentIo(), profile_dir, .{}); + defer profile_handle.close(path.currentIo()); + try profile_handle.symLink(path.currentIo(), "gen-1", generation.CURRENT_SYMLINK, .{}); + + try std.testing.expectError(error.InvalidInput, loadCurrentGenerationPreferences(ctx, "system")); + const diagnostic = ctx.getDiagnosticContext(); + try std.testing.expectEqualStrings(gen_path, diagnostic.subject.?); + try std.testing.expect(std.mem.containsAtLeast(u8, diagnostic.details.?, 1, "InvalidManifest")); +} + fn writeProjectionForPackageDir(allocator: std.mem.Allocator, package_dir: []const u8) !void { var projection = try projection_index.deriveFromPayload(allocator, package_dir); defer projection.deinit(); diff --git a/src/profile.zig b/src/profile.zig index 1304562..166b7b6 100644 --- a/src/profile.zig +++ b/src/profile.zig @@ -1028,12 +1028,25 @@ pub fn createGeneration( }; defer ctx.allocator.free(gen_path); - path.ensureDirExists(gen_path) catch |err| { + // Build outside the visible gen-N namespace. The final rename is the + // publication point; an interrupted realization can leave only a staging + // directory, never a generation that activation may select. + const stage_path = std.fmt.allocPrint(ctx.allocator, "{s}.staging", .{gen_path}) catch { + return ProfileError.OutOfMemory; + }; + defer ctx.allocator.free(stage_path); + path.deleteTreeAbsolute(stage_path) catch |err| { + if (err != error.FileNotFound) { + return ctx.fail(ProfileError.FileSystem, stage_path, "failed to remove stale generation staging directory"); + } + }; + path.ensureDirExists(stage_path) catch |err| { return ctx.fail(switch (err) { error.AccessDenied => ProfileError.PermissionDenied, else => ProfileError.FileSystem, - }, gen_path, "failed to create generation directory"); + }, stage_path, "failed to create generation staging directory"); }; + errdefer path.deleteTreeAbsolute(stage_path) catch {}; if (isSystemProfile(profile_dir)) { try ensureRootOwnedPackages(ctx, sorted_packages); @@ -1051,7 +1064,7 @@ pub fn createGeneration( var result = try planProfileRealization( ctx.allocator, ctx, - gen_path, + stage_path, store_root, sorted_packages, if (parent_state) |*state| state else null, @@ -1068,7 +1081,7 @@ pub fn createGeneration( const apply_stats = try applyRealization( ctx.allocator, ctx, - gen_path, + stage_path, sorted_packages, &result.realization, if (parent_state) |*state| state else null, @@ -1086,21 +1099,28 @@ pub fn createGeneration( ); defer manifest.deinit(); - generation.writeRealization(ctx.allocator, gen_path, &result.realization) catch |err| { + generation.writeRealization(ctx.allocator, stage_path, &result.realization) catch |err| { return ctx.fail(switch (err) { generation.GenerationError.PermissionDenied => ProfileError.PermissionDenied, generation.GenerationError.OutOfMemory => ProfileError.OutOfMemory, generation.GenerationError.InvalidManifest => ProfileError.InvalidInput, else => ProfileError.FileSystem, - }, gen_path, "failed to write generation realization"); + }, stage_path, "failed to write generation realization"); }; - generation.writeManifest(ctx.allocator, gen_path, &manifest) catch |err| { + generation.writeManifest(ctx.allocator, stage_path, &manifest) catch |err| { return ctx.fail(switch (err) { generation.GenerationError.PermissionDenied => ProfileError.PermissionDenied, generation.GenerationError.OutOfMemory => ProfileError.OutOfMemory, else => ProfileError.FileSystem, - }, gen_path, "failed to write generation manifest"); + }, stage_path, "failed to write generation manifest"); + }; + + std.Io.Dir.renameAbsolute(stage_path, gen_path, path.currentIo()) catch |err| { + return ctx.fail(switch (err) { + error.AccessDenied => ProfileError.PermissionDenied, + else => ProfileError.FileSystem, + }, gen_path, "failed to publish completed generation"); }; ctx.debug( @@ -1732,6 +1752,14 @@ test "createGeneration detects conflicts against retained parent paths" { ProfileError.PathConflict, createGeneration(&test_env.ctx, profile_dir, store_root, &gen2_packages, 1), ); + + const abandoned_generation = try std.fs.path.join(allocator, &.{ profile_dir, "gen-2" }); + defer allocator.free(abandoned_generation); + try std.testing.expect(!path.fileExists(abandoned_generation)); + + const abandoned_staging = try std.fmt.allocPrint(allocator, "{s}.staging", .{abandoned_generation}); + defer allocator.free(abandoned_staging); + try std.testing.expect(!path.fileExists(abandoned_staging)); } test "createGeneration realization indices match manifest package order" { diff --git a/src/repodb.zig b/src/repodb.zig index ec37fac..5b4f87f 100644 --- a/src/repodb.zig +++ b/src/repodb.zig @@ -236,11 +236,21 @@ pub const RepoDB = struct { return RepoDBError.CorruptData; } - // Set initial schema version if not already set - const set_version_sql = "INSERT OR IGNORE INTO schema_version (version) VALUES (1);"; - const version_rc = c.sqlite3_exec(self.db, set_version_sql, null, null, &err_msg); + // Normalize databases created before schema_version had a uniqueness + // constraint, then enforce the invariant for future initializations. + // Keep the first row for each version so this is safe for existing DBs. + const schema_version_migration = + "BEGIN IMMEDIATE;" ++ + "DELETE FROM schema_version WHERE rowid NOT IN (" ++ + "SELECT MIN(rowid) FROM schema_version GROUP BY version);" ++ + "CREATE UNIQUE INDEX IF NOT EXISTS idx_schema_version_version " ++ + "ON schema_version(version);" ++ + "INSERT OR IGNORE INTO schema_version (version) VALUES (1);" ++ + "COMMIT;"; + const version_rc = c.sqlite3_exec(self.db, schema_version_migration, null, null, &err_msg); if (version_rc != c.SQLITE_OK) { if (err_msg != null) c.sqlite3_free(err_msg); + _ = c.sqlite3_exec(self.db, "ROLLBACK;", null, null, null); return RepoDBError.CorruptData; } @@ -1759,6 +1769,55 @@ test "RepoDB basic usage: init, schema, prepareStatement" { try std.testing.expectEqual(@as(c_int, 1), version); } +test "RepoDB init repairs duplicate schema versions and stays idempotent" { + const th = @import("test_helpers.zig"); + + var test_env = try th.createTestEnv(); + defer { + test_env.cleanup(); + std.testing.allocator.destroy(test_env); + } + + const allocator = test_env.ctx.allocator; + const db_path = try std.fs.path.join(allocator, &.{ test_env.path, "duplicate_schema_version.db" }); + defer allocator.free(db_path); + + const c_db_path = try allocator.alloc(u8, db_path.len + 1); + defer allocator.free(c_db_path); + @memcpy(c_db_path[0..db_path.len], db_path); + c_db_path[db_path.len] = 0; + + var sqlite_db: ?*c.sqlite3 = null; + try std.testing.expectEqual(@as(c_int, c.SQLITE_OK), c.sqlite3_open(c_db_path.ptr, &sqlite_db)); + const legacy_schema = + "CREATE TABLE schema_version (version INTEGER NOT NULL);" ++ + "INSERT INTO schema_version (version) VALUES (1);" ++ + "INSERT INTO schema_version (version) VALUES (1);"; + var err_msg: [*c]u8 = null; + try std.testing.expectEqual(@as(c_int, c.SQLITE_OK), c.sqlite3_exec(sqlite_db, legacy_schema, null, null, &err_msg)); + if (err_msg != null) c.sqlite3_free(err_msg); + _ = c.sqlite3_close(sqlite_db); + sqlite_db = null; + + var db = try RepoDB.init(&test_env.ctx, db_path, false); + const count_stmt = try db.prepareStatement("SELECT COUNT(*) FROM schema_version"); + try std.testing.expectEqual(c.SQLITE_ROW, c.sqlite3_step(count_stmt)); + try std.testing.expectEqual(@as(c_int, 1), c.sqlite3_column_int(count_stmt, 0)); + _ = c.sqlite3_finalize(count_stmt); + db.deinit(); + allocator.destroy(db); + + var reopened = try RepoDB.init(&test_env.ctx, db_path, false); + defer { + reopened.deinit(); + allocator.destroy(reopened); + } + const reopened_stmt = try reopened.prepareStatement("SELECT COUNT(*) FROM schema_version"); + defer _ = c.sqlite3_finalize(reopened_stmt); + try std.testing.expectEqual(c.SQLITE_ROW, c.sqlite3_step(reopened_stmt)); + try std.testing.expectEqual(@as(c_int, 1), c.sqlite3_column_int(reopened_stmt, 0)); +} + test "RepoDB init rejects outdated packages schema missing archive_hash" { const th = @import("test_helpers.zig");