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
30 changes: 19 additions & 11 deletions docs/design/specification-details.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,9 @@ 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 | manifest format and `schema_version` | v3 | v1, transitional, v2, v3 |
| Package manifest (§17) | `.mere/manifest.v1` through `.mere/manifest.v4` | `schema_version` field and filename | v4 | v1, v2, v3, v4 |
| Manifest signature (§5) | `.mere/manifest.vN.sig` | manifest format; v4 envelope magic/version/algorithm | domain-separated v2 envelope | legacy raw Ed25519, domain-separated v2 |
| Store content hash (§1) | store path name | manifest format and `schema_version` | v4 | v1, transitional, v2, v3, v4 |
| Package manifest (§17) | `.mere/manifest.v1` through `.mere/manifest.v5` | `schema_version` field and filename | v5 | v1, v2, v3, v4, v5 |
| Manifest signature (§5) | `.mere/manifest.vN.sig` | manifest format; v4/v5 envelope magic/version/algorithm | domain-separated v2 envelope | legacy raw Ed25519, domain-separated v2 |
| Key file | `*.pub`, `*.key` | `MEREKEY` magic, version and algorithm bytes | v1 / Ed25519 | v1 / Ed25519 |
| Generation manifest (§6) | `<generation>/` | `schema_version` field | 2 | 2 only |
| Realization manifest | named profile `root/` | `schema_version` field | 1 | 1 only |
Expand All @@ -129,7 +129,7 @@ Requirements:

The store path is `<hash>-<name>-<version>` where hash is BLAKE3 of the realized payload.

**Algorithm**: Use a single incremental BLAKE3 hasher. Walk entries in deterministic (lexicographic) order. For each entry, feed a canonical record with explicit length-prefixed boundaries.
**Algorithm**: Use a single incremental BLAKE3 hasher. V1 and the transitional identity have no domain prefix; v2 begins with `mere-store-content-v2\0`, v3 with `mere-store-content-v3\0`, and v4 with `mere-store-content-v4\0`. Walk entries in deterministic (lexicographic) order. For each entry, feed a canonical record with explicit length-prefixed boundaries.

**Record format per entry**:
```
Expand All @@ -138,6 +138,12 @@ path_len = u32 LE
path = UTF-8 bytes, '/' separators, no leading slash
type_tag = 1 byte: 0x10=file, 0x11=dir, 0x12=symlink

For v3, after type_tag when type is file or directory:
special_bits = 1 byte: (mode & 07000) >> 9

For v4, after type_tag when type is file or directory:
canonical_mode = u16 LE: mode & 07555

If file (0x10):
exec_bit = 1 byte: 0x00 (not executable) or 0x01 (executable)
content_len = u64 LE
Expand All @@ -148,7 +154,7 @@ If symlink (0x12):
target = symlink target bytes

If dir (0x11):
(nothing else - just the entry_tag + path + type_tag)
(no content fields; v3/v4 retain the mode field described above)
```

**Rules**:
Expand All @@ -164,8 +170,7 @@ If dir (0x11):
- Example: `/mere/store/9f2c3a...64chars...-nginx-1.24.0/`

**Metadata location**:
- `.mere/manifest.v1` (binary) and `.mere/manifest.v1.sig` (signature) in the `.mere/` subdirectory
- Both manifest files are **excluded from the content hash** (see spec #4) because the manifest contains the hash it describes and the signature authenticates that manifest
- `.mere/manifest.v1` through `.mere/manifest.v5` and their signatures are excluded from the content hash (see spec #4) because the selected manifest contains the hash it describes and its signature authenticates that manifest
- `.mere/meta.kdl` is canonical package intent metadata and **is included in the content hash**
- `.mere/projection.v1` is a derived index and **is excluded from the content hash**; it is regenerated and validated from the package contents and metadata
- The `content_hash` field in the manifest and the hash in the store path are **identical** (same 32 bytes, displayed as 64 hex chars in path)
Expand All @@ -176,17 +181,18 @@ The store content hash **MUST** incorporate:
- File bytes
- Path names
- File type (file / directory / symlink)
- setuid, setgid, and sticky bits on files and directories (v3)
- setuid, setgid, and sticky bits on files and directories (v3 and v4)
- User/group/other read and execute permission classes on files and directories (v4)

The store content hash **MUST NOT** incorporate:
- Read/write permission bits (other than executable)
- Write permission bits (v4 normalizes them away because admission removes them)
- Ownership (uid/gid)
- Timestamps
- ACLs or extended attributes

**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.
**Normative invariant**: Two v4 payloads that differ only in write permission bits or ownership are considered *identical content*. Two v4 payloads that differ in a read, execute, setuid, setgid, or sticky bit are different content. Earlier variants retain their frozen identity rules.

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.
Implementations MUST preserve extracted permission bits when unpacking archives. V4 authenticates the canonical post-hardening mode `mode & 07555` for files and directories; symlink modes do not participate. Ownership remains outside identity because privileged and unprivileged admission intentionally establish different owners.

---

Expand Down Expand Up @@ -2047,6 +2053,8 @@ The package manifest is the authoritative source of package metadata, using a de

**Field ordering is part of the canonical definition** - do not reorder fields.

The byte layout is shared by manifest formats v1-v5; the filename and `schema_version` select the immutable verification contract. New packages write `.mere/manifest.v5`, use the existing domain-separated signature envelope, and bind `content_hash` to store identity v4. Manifest v4 retains store identity v3 and MUST NOT be reinterpreted.

**Required fields (v1)**:
- `schema_version`: Must be `1`
- `name`: Package name (UTF-8, no null bytes)
Expand Down
42 changes: 10 additions & 32 deletions src/activation.zig
Original file line number Diff line number Diff line change
Expand Up @@ -553,41 +553,19 @@ fn validateGenerationStorePaths(
return ctx.fail(ActivationError.InvalidInput, pkg.store_path, "invalid content hash length in manifest");
}

const format: package_manifest.Format = blk: {
const v4_manifest_path = std.fs.path.join(ctx.allocator, &.{ pkg.store_path, package_manifest.MANIFEST_V4_FILENAME }) catch {
return ctx.fail(ActivationError.OutOfMemory, pkg.store_path, "failed to construct v4 manifest path");
};
defer ctx.allocator.free(v4_manifest_path);
const has_v4 = blk_v4: {
std.Io.Dir.accessAbsolute(path_mod.currentIo(), v4_manifest_path, .{}) catch break :blk_v4 false;
break :blk_v4 true;
};
if (has_v4) break :blk .v4;

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);
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 format = package_manifest.detectFormat(ctx.allocator, pkg.store_path) catch |err| {
return ctx.fail(switch (err) {
package_manifest.ManifestError.OutOfMemory => ActivationError.OutOfMemory,
package_manifest.ManifestError.PermissionDenied => ActivationError.PermissionDenied,
package_manifest.ManifestError.InvalidInput => ActivationError.InvalidInput,
else => ActivationError.FileSystem,
}, pkg.store_path, "failed to detect package manifest format");
};
const computed = switch (format) {
const computed = switch (format.storeHashFormat()) {
.v1 => hash.calculateStoreContentHash(ctx.allocator, pkg.store_path, null),
.v2 => hash.calculateStoreContentHashV2(ctx.allocator, pkg.store_path, null),
.v3, .v4 => hash.calculateStoreContentHashV3(ctx.allocator, pkg.store_path, null),
.v3 => hash.calculateStoreContentHashV3(ctx.allocator, pkg.store_path, null),
.v4 => hash.calculateStoreContentHashV4(ctx.allocator, pkg.store_path, null),
};
const computed_hash = computed catch |err| {
return ctx.fail(switch (err) {
Expand Down
31 changes: 24 additions & 7 deletions src/extract.zig
Original file line number Diff line number Diff line change
Expand Up @@ -638,22 +638,39 @@ fn mergeStagedTree(ctx: *Context, staged_dir: []const u8, target_abs: []const u8
defer ctx.allocator.free(src_path);
var slot = try openDestSlot(ctx, target_root, rel_path, false);
defer slot.close();
try copyDirectoryTimes(ctx, src_path, slot, rel_path);
try copyDirectoryMetadata(ctx, src_path, slot, rel_path);
}
}

fn copyDirectoryTimes(
fn copyDirectoryMetadata(
ctx: *Context,
src_path: []const u8,
dst: DestSlot,
diag_path: []const u8,
) ExtractError!void {
var src_dir = std.Io.Dir.openDirAbsolute(p.currentIo(), src_path, .{}) catch |err| {
return ctx.fail(mapFsError(err), src_path, "failed to open staged directory for timestamp copy");
const io = p.currentIo();
var src_dir = std.Io.Dir.openDirAbsolute(io, src_path, .{}) catch |err| {
return ctx.fail(mapFsError(err), src_path, "failed to open staged directory for metadata copy");
};
defer src_dir.close(io);
const stat = src_dir.stat(io) catch |err| {
return ctx.fail(mapFsError(err), src_path, "failed to stat staged directory for metadata copy");
};

var dst_dir = dst.dir.openDir(io, dst.name, .{
.iterate = true,
.follow_symlinks = false,
}) catch |err| switch (err) {
error.SymLinkLoop => return ctx.fail(ExtractError.InvalidInput, diag_path, "directory metadata target is a symlink"),
else => return ctx.fail(mapFsError(err), diag_path, "failed to open directory for metadata copy"),
};
defer src_dir.close(p.currentIo());
const stat = src_dir.stat(p.currentIo()) catch |err| {
return ctx.fail(mapFsError(err), src_path, "failed to stat staged directory for timestamp copy");
defer dst_dir.close(io);
// The staged merge creates destination directories with process defaults.
// Restore ordinary archive permissions here; special bits remain governed
// by SpecialBitRestorePolicy and are applied separately when requested.
const ordinary_mode = stat.permissions.toMode() & @as(std.posix.mode_t, 0o777);
dst_dir.setPermissions(io, std.Io.File.Permissions.fromMode(ordinary_mode)) catch |err| {
return ctx.fail(mapFsError(err), diag_path, "failed to restore directory permissions");
};

const name_z = ctx.allocator.dupeZ(u8, dst.name) catch {
Expand Down
Loading