Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions docs/design/specification-details.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) | `<generation>/` | `schema_version` field | 2 | 2 only |
Expand Down Expand Up @@ -163,17 +163,17 @@ 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)
- Ownership (uid/gid)
- 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.

---

Expand Down
30 changes: 22 additions & 8 deletions src/activation.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand Down
59 changes: 55 additions & 4 deletions src/hash.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -135,15 +135,26 @@ 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(
allocator: std.mem.Allocator,
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(
Expand All @@ -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)) {
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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));
}
26 changes: 20 additions & 6 deletions src/import.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
58 changes: 26 additions & 32 deletions src/install.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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});

Expand Down
33 changes: 30 additions & 3 deletions src/manifest.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
};

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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()));
}
Loading