From 30e2fe45840c02f53111bb808e4d76dec47cba97 Mon Sep 17 00:00:00 2001 From: Jeremy Huntwork Date: Sat, 22 Aug 2026 19:58:14 -0400 Subject: [PATCH] Record special bits in versioned store identity --- docs/design/specification-details.md | 10 ++--- src/activation.zig | 30 ++++++++++---- src/hash.zig | 59 ++++++++++++++++++++++++++-- src/import.zig | 26 +++++++++--- src/install.zig | 58 ++++++++++++--------------- src/manifest.zig | 33 ++++++++++++++-- src/packaging.zig | 10 ++--- src/verify.zig | 54 ++++++++++++++++--------- 8 files changed, 198 insertions(+), 82 deletions(-) diff --git a/docs/design/specification-details.md b/docs/design/specification-details.md index dcfb227..17ada94 100644 --- a/docs/design/specification-details.md +++ b/docs/design/specification-details.md @@ -88,8 +88,8 @@ Mere persists a number of formats, several of which are signed or content-addres | Format | Location | Discriminator | Written | Accepted | | --- | --- | --- | --- | --- | -| Store content hash (§1) | store path name | variant is implied, not recorded | v2 | v1, transitional, v2 | -| Package manifest (§17) | `.mere/manifest.v1`, `.mere/manifest.v2` | `schema_version` field and filename | v2 | v1, v2 | +| Store content hash (§1) | store path name | manifest format and `schema_version` | v3 | v1, transitional, v2, v3 | +| Package manifest (§17) | `.mere/manifest.v1`, `.mere/manifest.v2`, `.mere/manifest.v3` | `schema_version` field and filename | v3 | v1, v2, v3 | | Manifest signature (§5) | `.mere/manifest.vN.sig` | **none** | raw Ed25519 | raw Ed25519 | | Key file | `*.pub`, `*.key` | `MEREKEY` magic, version and algorithm bytes | v1 / Ed25519 | v1 / Ed25519 | | Generation manifest (§6) | `/` | `schema_version` field | 2 | 2 only | @@ -163,7 +163,7 @@ The store content hash **MUST** incorporate: - File bytes - Path names - File type (file / directory / symlink) -- Executable bit (`+x`) +- setuid, setgid, and sticky bits on files and directories (v3) The store content hash **MUST NOT** incorporate: - Read/write permission bits (other than executable) @@ -171,9 +171,9 @@ The store content hash **MUST NOT** incorporate: - Timestamps - ACLs or extended attributes -**Normative invariant**: Two payloads that differ only in non-executable permission bits or ownership are considered *identical content*. +**Normative invariant**: Two payloads that differ only in non-executable read/write permission bits or ownership are considered *identical content*. Under v3, two payloads that differ in a setuid, setgid, or sticky bit are different content. -Implementations MUST preserve extracted permission bits when unpacking archives, but MUST NOT treat them as part of store identity. +Implementations MUST preserve extracted permission bits when unpacking archives. Special bits on files and directories participate in v3 identity; symlink modes do not. Read/write bits outside executable and ownership remain outside identity because they are environment-dependent. --- diff --git a/src/activation.zig b/src/activation.zig index b1cf2dd..d0f4c93 100644 --- a/src/activation.zig +++ b/src/activation.zig @@ -553,18 +553,32 @@ fn validateGenerationStorePaths( return ctx.fail(ActivationError.InvalidInput, pkg.store_path, "invalid content hash length in manifest"); } - const is_v2 = blk: { + const format: package_manifest.Format = blk: { + const v3_manifest_path = std.fs.path.join(ctx.allocator, &.{ pkg.store_path, package_manifest.MANIFEST_V3_FILENAME }) catch { + return ctx.fail(ActivationError.OutOfMemory, pkg.store_path, "failed to construct v3 manifest path"); + }; + defer ctx.allocator.free(v3_manifest_path); + const has_v3 = blk_v3: { + std.Io.Dir.accessAbsolute(path_mod.currentIo(), v3_manifest_path, .{}) catch break :blk_v3 false; + break :blk_v3 true; + }; + if (has_v3) break :blk .v3; + const v2_manifest_path = std.fs.path.join(ctx.allocator, &.{ pkg.store_path, package_manifest.MANIFEST_V2_FILENAME }) catch { return ctx.fail(ActivationError.OutOfMemory, pkg.store_path, "failed to construct v2 manifest path"); }; defer ctx.allocator.free(v2_manifest_path); - std.Io.Dir.accessAbsolute(path_mod.currentIo(), v2_manifest_path, .{}) catch break :blk false; - break :blk true; + const has_v2 = blk_v2: { + std.Io.Dir.accessAbsolute(path_mod.currentIo(), v2_manifest_path, .{}) catch break :blk_v2 false; + break :blk_v2 true; + }; + break :blk if (has_v2) .v2 else .v1; + }; + const computed = switch (format) { + .v1 => hash.calculateStoreContentHash(ctx.allocator, pkg.store_path, null), + .v2 => hash.calculateStoreContentHashV2(ctx.allocator, pkg.store_path, null), + .v3 => hash.calculateStoreContentHashV3(ctx.allocator, pkg.store_path, null), }; - const computed = if (is_v2) - hash.calculateStoreContentHashV2(ctx.allocator, pkg.store_path, null) - else - hash.calculateStoreContentHash(ctx.allocator, pkg.store_path, null); const computed_hash = computed catch |err| { return ctx.fail(switch (err) { hash.HashError.OutOfMemory => ActivationError.OutOfMemory, @@ -577,7 +591,7 @@ fn validateGenerationStorePaths( if (!std.mem.eql(u8, computed_hash, pkg.content_hash)) { var accepted_transitional = false; - if (!is_v2) { + if (format == .v1) { const transitional = hash.calculateTransitionalMetadataContentHash(ctx.allocator, pkg.store_path, null) catch null; if (transitional) |transitional_hash| { accepted_transitional = std.mem.eql(u8, transitional_hash, pkg.content_hash); diff --git a/src/hash.zig b/src/hash.zig index ebd6829..046bbea 100644 --- a/src/hash.zig +++ b/src/hash.zig @@ -115,7 +115,7 @@ pub fn calculateStoreContentHash( dir_path: []const u8, diag: ?*HashDiag, ) HashError![]const u8 { - return calculateTreeHashInternal(allocator, dir_path, diag, false, false, null); + return calculateTreeHashInternal(allocator, dir_path, diag, false, false, false, null); } /// The transitional v0.18.0 identity: payload plus meta.kdl, without a @@ -126,7 +126,7 @@ pub fn calculateTransitionalMetadataContentHash( dir_path: []const u8, diag: ?*HashDiag, ) HashError![]const u8 { - return calculateTreeHashInternal(allocator, dir_path, diag, false, true, null); + return calculateTreeHashInternal(allocator, dir_path, diag, false, true, false, null); } /// The versioned metadata-aware store identity used by new packages. @@ -135,7 +135,18 @@ pub fn calculateStoreContentHashV2( dir_path: []const u8, diag: ?*HashDiag, ) HashError![]const u8 { - return calculateTreeHashInternal(allocator, dir_path, diag, false, true, "mere-store-content-v2\x00"); + return calculateTreeHashInternal(allocator, dir_path, diag, false, true, false, "mere-store-content-v2\x00"); +} + +/// The v3 metadata-aware store identity includes setuid, setgid, and sticky +/// bits for regular files and directories. Read/write bits, ownership, and +/// symlink modes remain outside identity. +pub fn calculateStoreContentHashV3( + allocator: std.mem.Allocator, + dir_path: []const u8, + diag: ?*HashDiag, +) HashError![]const u8 { + return calculateTreeHashInternal(allocator, dir_path, diag, false, true, true, "mere-store-content-v3\x00"); } pub fn calculateBuildSnapshotHash( @@ -143,7 +154,7 @@ pub fn calculateBuildSnapshotHash( dir_path: []const u8, diag: ?*HashDiag, ) HashError![]const u8 { - return calculateTreeHashInternal(allocator, dir_path, diag, true, false, null); + return calculateTreeHashInternal(allocator, dir_path, diag, true, false, false, null); } fn calculateTreeHashInternal( @@ -152,6 +163,7 @@ fn calculateTreeHashInternal( diag: ?*HashDiag, include_mtime: bool, include_metadata: bool, + include_special_bits: bool, domain: ?[]const u8, ) HashError![]const u8 { if (!path.isValidInputPath(dir_path)) { @@ -252,6 +264,11 @@ fn calculateTreeHashInternal( }; hasher.update(&[_]u8{type_tag}); + if (include_special_bits and entry.kind != .sym_link) { + const special: u8 = @intCast((entry.mode & 0o7000) >> 9); + hasher.update(&[_]u8{special}); + } + if (include_mtime) { const mode_le = std.mem.nativeToLittle(u32, entry.mode); hasher.update(&std.mem.toBytes(mode_le)); @@ -947,3 +964,37 @@ test "hash mapHashFsError preserves actionable classes" { try std.testing.expectEqual(HashError.InvalidInput, mapHashFsError(error.BadPathName)); try std.testing.expectEqual(HashError.FileSystem, mapHashFsError(error.InputOutput)); } + +test "calculateStoreContentHashV3 distinguishes special bits while v2 does not" { + const th = @import("test_helpers.zig"); + var test_env = try th.createTestEnv(); + defer { + test_env.cleanup(); + std.testing.allocator.destroy(test_env); + } + + const file_path = try std.fs.path.join(std.testing.allocator, &.{ test_env.path, "tool" }); + defer std.testing.allocator.free(file_path); + { + var file = try std.Io.Dir.createFileAbsolute(path.currentIo(), file_path, .{}); + try file.writeStreamingAll(path.currentIo(), "payload"); + file.close(path.currentIo()); + } + var file = try path.openExistingFile(file_path); + defer file.close(path.currentIo()); + try file.setPermissions(path.currentIo(), .fromMode(0o755)); + + const v2_plain = try calculateStoreContentHashV2(test_env.ctx.allocator, test_env.path, null); + defer test_env.ctx.allocator.free(v2_plain); + const v3_plain = try calculateStoreContentHashV3(test_env.ctx.allocator, test_env.path, null); + defer test_env.ctx.allocator.free(v3_plain); + + try file.setPermissions(path.currentIo(), .fromMode(0o4755)); + const v2_setuid = try calculateStoreContentHashV2(test_env.ctx.allocator, test_env.path, null); + defer test_env.ctx.allocator.free(v2_setuid); + const v3_setuid = try calculateStoreContentHashV3(test_env.ctx.allocator, test_env.path, null); + defer test_env.ctx.allocator.free(v3_setuid); + + try std.testing.expectEqualStrings(v2_plain, v2_setuid); + try std.testing.expect(!std.mem.eql(u8, v3_plain, v3_setuid)); +} diff --git a/src/import.zig b/src/import.zig index 9e61722..3d9bd54 100644 --- a/src/import.zig +++ b/src/import.zig @@ -209,10 +209,23 @@ pub const ManifestResult = struct { }; fn detectManifestFormat(ctx: *Context, temp_dir: []const u8) !manifest.Format { + const v3_path = try std.fs.path.join(ctx.allocator, &.{ temp_dir, manifest.MANIFEST_V3_FILENAME }); + defer ctx.allocator.free(v3_path); + const has_v3 = blk: { + std.Io.Dir.accessAbsolute(p.currentIo(), v3_path, .{}) catch break :blk false; + break :blk true; + }; + if (has_v3) return .v3; + const v2_path = try std.fs.path.join(ctx.allocator, &.{ temp_dir, manifest.MANIFEST_V2_FILENAME }); defer ctx.allocator.free(v2_path); - std.Io.Dir.accessAbsolute(p.currentIo(), v2_path, .{}) catch return .v1; - return .v2; + const has_v2 = blk: { + std.Io.Dir.accessAbsolute(p.currentIo(), v2_path, .{}) catch break :blk false; + break :blk true; + }; + if (has_v2) return .v2; + + return .v1; } const PreparedImport = struct { extract: ExtractResult, @@ -383,10 +396,11 @@ fn appendJsonEscaped(buf: *std.ArrayList(u8), allocator: std.mem.Allocator, valu } fn computeAndVerifyContentHash(ctx: *Context, temp_dir: []const u8, pkg_manifest: *const manifest.PackageManifestV1, format: manifest.Format) !void { - const computed_hash = if (format == .v2) - try hash.calculateStoreContentHashV2(ctx.allocator, temp_dir, null) - else - try hash.calculateStoreContentHash(ctx.allocator, temp_dir, null); + const computed_hash = switch (format) { + .v1 => try hash.calculateStoreContentHash(ctx.allocator, temp_dir, null), + .v2 => try hash.calculateStoreContentHashV2(ctx.allocator, temp_dir, null), + .v3 => try hash.calculateStoreContentHashV3(ctx.allocator, temp_dir, null), + }; defer ctx.allocator.free(computed_hash); const declared_hash = try pkg_manifest.contentHashHex(ctx.allocator); diff --git a/src/install.zig b/src/install.zig index ea0591b..5944a36 100644 --- a/src/install.zig +++ b/src/install.zig @@ -2035,11 +2035,17 @@ fn preVerifyManifest( ctx.debug("partial-extracting manifest for pre-verification", .{}); var format: manifest.Format = .v1; - const v2_probe_dir = verify_dir; - extract.fileInto(ctx, cache_path, v2_probe_dir, manifest.MANIFEST_V2_FILENAME) catch {}; - const v2_probe_path = try std.fs.path.join(ctx.allocator, &.{ verify_dir, manifest.MANIFEST_V2_FILENAME }); - defer ctx.allocator.free(v2_probe_path); - if (path.fileExists(v2_probe_path)) format = .v2; + extract.fileInto(ctx, cache_path, verify_dir, manifest.MANIFEST_V3_FILENAME) catch {}; + const v3_probe_path = try std.fs.path.join(ctx.allocator, &.{ verify_dir, manifest.MANIFEST_V3_FILENAME }); + defer ctx.allocator.free(v3_probe_path); + if (path.fileExists(v3_probe_path)) { + format = .v3; + } else { + extract.fileInto(ctx, cache_path, verify_dir, manifest.MANIFEST_V2_FILENAME) catch {}; + const v2_probe_path = try std.fs.path.join(ctx.allocator, &.{ verify_dir, manifest.MANIFEST_V2_FILENAME }); + defer ctx.allocator.free(v2_probe_path); + if (path.fileExists(v2_probe_path)) format = .v2; + } try extract.fileInto(ctx, cache_path, verify_dir, format.manifestFilename()); try extract.fileInto(ctx, cache_path, verify_dir, format.signatureFilename()); @@ -2181,34 +2187,22 @@ fn stageAndValidatePayload( // Compute content hash from realized payload and canonical package metadata (spec #1/#4) var hash_diag: hash.HashDiag = .{}; defer hash_diag.deinit(ctx.allocator); - var content_hash: []const u8 = undefined; - if (format == .v2) { - content_hash = hash.calculateStoreContentHashV2(ctx.allocator, staging_dir, &hash_diag) catch |err| { - const action = hash_diag.action orelse "compute content hash"; - const path_label = hash_diag.path orelse staging_dir; - const os_err = if (hash_diag.os_error) |oe| @errorName(oe) else "unknown"; - ctx.setDiagnosticContextFmt(staging_dir, "failed to compute content hash from payload and metadata: {s}: {s} ({s})", .{ action, path_label, os_err }); - return switch (err) { - hash.HashError.OutOfMemory => error.OutOfMemory, - hash.HashError.PermissionDenied => error.PermissionDenied, - hash.HashError.InvalidInput => error.InvalidInput, - else => error.FileSystem, - }; - }; - } else { - content_hash = hash.calculateStoreContentHash(ctx.allocator, staging_dir, &hash_diag) catch |err| { - const action = hash_diag.action orelse "compute content hash"; - const path_label = hash_diag.path orelse staging_dir; - const os_err = if (hash_diag.os_error) |oe| @errorName(oe) else "unknown"; - ctx.setDiagnosticContextFmt(staging_dir, "failed to compute content hash from payload and metadata: {s}: {s} ({s})", .{ action, path_label, os_err }); - return switch (err) { - hash.HashError.OutOfMemory => error.OutOfMemory, - hash.HashError.PermissionDenied => error.PermissionDenied, - hash.HashError.InvalidInput => error.InvalidInput, - else => error.FileSystem, - }; + var content_hash: []const u8 = switch (format) { + .v1 => hash.calculateStoreContentHash(ctx.allocator, staging_dir, &hash_diag), + .v2 => hash.calculateStoreContentHashV2(ctx.allocator, staging_dir, &hash_diag), + .v3 => hash.calculateStoreContentHashV3(ctx.allocator, staging_dir, &hash_diag), + } catch |err| { + const action = hash_diag.action orelse "compute content hash"; + const path_label = hash_diag.path orelse staging_dir; + const os_err = if (hash_diag.os_error) |oe| @errorName(oe) else "unknown"; + ctx.setDiagnosticContextFmt(staging_dir, "failed to compute content hash from payload and metadata: {s}: {s} ({s})", .{ action, path_label, os_err }); + return switch (err) { + hash.HashError.OutOfMemory => error.OutOfMemory, + hash.HashError.PermissionDenied => error.PermissionDenied, + hash.HashError.InvalidInput => error.InvalidInput, + else => error.FileSystem, }; - } + }; errdefer ctx.allocator.free(content_hash); ctx.debug("content hash from payload and metadata: {s}", .{content_hash}); diff --git a/src/manifest.zig b/src/manifest.zig index 75d06ed..2ce5a6e 100644 --- a/src/manifest.zig +++ b/src/manifest.zig @@ -10,28 +10,44 @@ pub const ManifestError = Std.OutOfMemory || Std.FileSystem || Std.PermissionDen pub const MAGIC: *const [8]u8 = "MEREMFST"; pub const SCHEMA_VERSION: u32 = 1; pub const SCHEMA_VERSION_V2: u32 = 2; +pub const SCHEMA_VERSION_V3: u32 = 3; pub const META_DIR = ".mere"; pub const MANIFEST_FILENAME = ".mere/manifest.v1"; pub const MANIFEST_SIG_FILENAME = ".mere/manifest.v1.sig"; pub const MANIFEST_V2_FILENAME = ".mere/manifest.v2"; pub const MANIFEST_V2_SIG_FILENAME = ".mere/manifest.v2.sig"; +pub const MANIFEST_V3_FILENAME = ".mere/manifest.v3"; +pub const MANIFEST_V3_SIG_FILENAME = ".mere/manifest.v3.sig"; pub const META_KDL_FILENAME = ".mere/meta.kdl"; pub const PROJECTION_FILENAME = ".mere/projection.v1"; pub const Format = enum { v1, v2, + v3, pub fn manifestFilename(self: Format) []const u8 { - return if (self == .v1) MANIFEST_FILENAME else MANIFEST_V2_FILENAME; + return switch (self) { + .v1 => MANIFEST_FILENAME, + .v2 => MANIFEST_V2_FILENAME, + .v3 => MANIFEST_V3_FILENAME, + }; } pub fn signatureFilename(self: Format) []const u8 { - return if (self == .v1) MANIFEST_SIG_FILENAME else MANIFEST_V2_SIG_FILENAME; + return switch (self) { + .v1 => MANIFEST_SIG_FILENAME, + .v2 => MANIFEST_V2_SIG_FILENAME, + .v3 => MANIFEST_V3_SIG_FILENAME, + }; } pub fn schemaVersion(self: Format) u32 { - return if (self == .v1) SCHEMA_VERSION else SCHEMA_VERSION_V2; + return switch (self) { + .v1 => SCHEMA_VERSION, + .v2 => SCHEMA_VERSION_V2, + .v3 => SCHEMA_VERSION_V3, + }; } }; @@ -229,6 +245,10 @@ pub fn writeManifestV2(ctx: *Context, dir_path: []const u8, manifest: *const Pac return writeManifestForFormat(ctx, dir_path, manifest, secret_key, .v2); } +pub fn writeManifestV3(ctx: *Context, dir_path: []const u8, manifest: *const PackageManifestV1, secret_key: []const u8) ManifestError!void { + return writeManifestForFormat(ctx, dir_path, manifest, secret_key, .v3); +} + fn writeManifestForFormat(ctx: *Context, dir_path: []const u8, input: *const PackageManifestV1, secret_key: []const u8, format: Format) ManifestError!void { var manifest_copy = input.*; manifest_copy.schema_version = format.schemaVersion(); @@ -479,3 +499,10 @@ test "readManifestFile reports InvalidInput when manifest is missing" { try std.testing.expectError(ManifestError.InvalidInput, readManifestFile(&test_env.ctx, package_dir)); } + +test "manifest v3 has distinct filenames and schema" { + try std.testing.expectEqual(@as(u32, 3), Format.v3.schemaVersion()); + try std.testing.expectEqualStrings(MANIFEST_V3_FILENAME, Format.v3.manifestFilename()); + try std.testing.expectEqualStrings(MANIFEST_V3_SIG_FILENAME, Format.v3.signatureFilename()); + try std.testing.expect(!std.mem.eql(u8, Format.v2.manifestFilename(), Format.v3.manifestFilename())); +} diff --git a/src/packaging.zig b/src/packaging.zig index bd2dee9..b21a3a5 100644 --- a/src/packaging.zig +++ b/src/packaging.zig @@ -329,11 +329,11 @@ pub const Packager = struct { return self.fail(config.staging_dir, "failed to write meta.kdl", PackagingError.FileSystem); }; - // meta.kdl is part of the versioned v2 store identity. Compute the - // v2 hash only after writing canonical metadata. + // meta.kdl is part of the versioned v3 store identity. Compute the + // v3 hash only after writing canonical metadata. self.ctx.allocator.free(content_hash); - content_hash = hash.calculateStoreContentHashV2(self.ctx.allocator, config.staging_dir, null) catch { - return self.fail(config.staging_dir, "failed to compute v2 content hash", PackagingError.CreationFailed); + content_hash = hash.calculateStoreContentHashV3(self.ctx.allocator, config.staging_dir, null) catch { + return self.fail(config.staging_dir, "failed to compute v3 content hash", PackagingError.CreationFailed); }; if (pkg.content_hash.len > 0) self.ctx.allocator.free(pkg.content_hash); pkg.content_hash = self.ctx.allocator.dupe(u8, content_hash) catch |err| { @@ -347,7 +347,7 @@ pub const Packager = struct { return self.fail(content_hash, "invalid content hash hex", PackagingError.InvalidInput); }; pkg_manifest.content_hash = final_content_hash_bytes; - manifest.writeManifestV2(self.ctx, config.staging_dir, &pkg_manifest, &secret_key.key) catch { + manifest.writeManifestV3(self.ctx, config.staging_dir, &pkg_manifest, &secret_key.key) catch { self.ctx.allocator.free(content_hash); return self.fail(config.staging_dir, "failed to write final manifest", PackagingError.FileSystem); }; diff --git a/src/verify.zig b/src/verify.zig index 5ac530a..87f3b17 100644 --- a/src/verify.zig +++ b/src/verify.zig @@ -229,12 +229,18 @@ fn verifyStore( }; const format: manifest.Format = blk: { + const v3_path = std.fs.path.join(ctx.allocator, &.{ entry_path, manifest.MANIFEST_V3_FILENAME }) catch { + return ctx.fail(VerifyError.OutOfMemory, entry_path, "failed to construct v3 manifest path"); + }; + defer ctx.allocator.free(v3_path); + if (std.Io.Dir.accessAbsolute(path_mod.currentIo(), v3_path, .{})) |_| break :blk .v3 else |_| {} + const v2_path = std.fs.path.join(ctx.allocator, &.{ entry_path, manifest.MANIFEST_V2_FILENAME }) catch { return ctx.fail(VerifyError.OutOfMemory, entry_path, "failed to construct v2 manifest path"); }; defer ctx.allocator.free(v2_path); - std.Io.Dir.accessAbsolute(path_mod.currentIo(), v2_path, .{}) catch break :blk .v1; - break :blk .v2; + if (std.Io.Dir.accessAbsolute(path_mod.currentIo(), v2_path, .{})) |_| break :blk .v2 else |_| {} + break :blk .v1; }; const manifest_path = std.fs.path.join(ctx.allocator, &.{ entry_path, format.manifestFilename() }) catch { return ctx.fail(VerifyError.OutOfMemory, entry_path, "failed to construct manifest path"); @@ -317,10 +323,11 @@ fn verifyStore( }; if (full_hash) { - const computed = if (format == .v2) - hash.calculateStoreContentHashV2(ctx.allocator, entry_path, null) - else - hash.calculateStoreContentHash(ctx.allocator, entry_path, null); + const computed = switch (format) { + .v1 => hash.calculateStoreContentHash(ctx.allocator, entry_path, null), + .v2 => hash.calculateStoreContentHashV2(ctx.allocator, entry_path, null), + .v3 => hash.calculateStoreContentHashV3(ctx.allocator, entry_path, null), + }; const computed_hash = computed catch { result.store_issues += 1; try addIssue(ctx, result, .store, entry_path, "failed to compute store content hash"); @@ -510,20 +517,29 @@ fn verifyProfileManifestPackages( } if (full_hash) { - const v2_manifest_path = std.fs.path.join(ctx.allocator, &.{ pkg.store_path, manifest.MANIFEST_V2_FILENAME }) catch { - result.profile_issues += 1; - try addProfileIssue(ctx, result, pkg.store_path, profile_name, realization_name, "failed to construct manifest path"); - continue; + const format: manifest.Format = blk: { + const v3_manifest_path = std.fs.path.join(ctx.allocator, &.{ pkg.store_path, manifest.MANIFEST_V3_FILENAME }) catch { + result.profile_issues += 1; + try addProfileIssue(ctx, result, pkg.store_path, profile_name, realization_name, "failed to construct manifest path"); + continue; + }; + defer ctx.allocator.free(v3_manifest_path); + if (std.Io.Dir.accessAbsolute(path_mod.currentIo(), v3_manifest_path, .{})) |_| break :blk .v3 else |_| {} + + const v2_manifest_path = std.fs.path.join(ctx.allocator, &.{ pkg.store_path, manifest.MANIFEST_V2_FILENAME }) catch { + result.profile_issues += 1; + try addProfileIssue(ctx, result, pkg.store_path, profile_name, realization_name, "failed to construct manifest path"); + continue; + }; + defer ctx.allocator.free(v2_manifest_path); + if (std.Io.Dir.accessAbsolute(path_mod.currentIo(), v2_manifest_path, .{})) |_| break :blk .v2 else |_| {} + break :blk .v1; }; - defer ctx.allocator.free(v2_manifest_path); - const is_v2 = blk: { - std.Io.Dir.accessAbsolute(path_mod.currentIo(), v2_manifest_path, .{}) catch break :blk false; - break :blk true; + const computed = switch (format) { + .v1 => hash.calculateStoreContentHash(ctx.allocator, pkg.store_path, null), + .v2 => hash.calculateStoreContentHashV2(ctx.allocator, pkg.store_path, null), + .v3 => hash.calculateStoreContentHashV3(ctx.allocator, pkg.store_path, null), }; - const computed = if (is_v2) - hash.calculateStoreContentHashV2(ctx.allocator, pkg.store_path, null) - else - hash.calculateStoreContentHash(ctx.allocator, pkg.store_path, null); const computed_hash = computed catch { result.profile_issues += 1; try addProfileIssue(ctx, result, pkg.store_path, profile_name, realization_name, "failed to compute store content hash"); @@ -533,7 +549,7 @@ fn verifyProfileManifestPackages( if (!std.mem.eql(u8, computed_hash, pkg.content_hash)) { var accepted_transitional = false; - if (!is_v2) { + if (format == .v1) { const transitional = hash.calculateTransitionalMetadataContentHash(ctx.allocator, pkg.store_path, null) catch null; if (transitional) |transitional_hash| { accepted_transitional = std.mem.eql(u8, transitional_hash, pkg.content_hash);