@mh-gg/git 0.1.1-alpha.20260626T104441232Z

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matterhorn contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # @mh-gg/git
2
+
3
+ Matterhorn Git transport, encrypted chunk filter, and local CAS utilities.
4
+
5
+ This package adds two CLI entry points:
6
+
7
+ - `matterhorn-git` for origins, required Git filters, local epoch keys, encrypted chunk/CAS operations, signing profiles, and outbox inspection.
8
+ - `git-remote-matterhorn` for Git's remote-helper protocol. Git invokes it for origins using the `matterhorn::...` custom transport.
9
+
10
+ ## Native origin
11
+
12
+ Preferred Git-safe origin format:
13
+
14
+ ```bash
15
+ git remote add origin 'matterhorn::matterhorn+my-room+alice/my-repo;backend=http%3A%2F%2F127.0.0.1%3A43741'
16
+ ```
17
+
18
+ The CLI prints that form by default:
19
+
20
+ ```bash
21
+ matterhorn-git origin my-room my-repo --signing alice --backend http://127.0.0.1:43741
22
+ ```
23
+
24
+ The older canonical form is still accepted:
25
+
26
+ ```text
27
+ matterhorn::room=my-room;repo=my-repo;signing=alice;backend=http%3A%2F%2F127.0.0.1%3A43741
28
+ ```
29
+
30
+ ## Encrypted chunk filter
31
+
32
+ Configure Git's required clean/smudge filter and optionally append a `.gitattributes` rule:
33
+
34
+ ```bash
35
+ matterhorn-git install-filter 'matterhorn::matterhorn+my-room+alice/my-repo' --pattern 'secrets/**'
36
+ ```
37
+
38
+ The filter path is:
39
+
40
+ ```text
41
+ plaintext file
42
+ -> fastcdc-js content-defined chunks
43
+ -> per-epoch keyed chunk IDs
44
+ -> XChaCha20-Poly1305 chunk objects with explicit AAD
45
+ -> encrypted manifest object
46
+ -> deterministic Git pointer
47
+ ```
48
+
49
+ Git stores only pointer files for matched paths. The encrypted manifest and chunk objects live under `.git/matterhorn/cas/<room--repo--signing>`. Smudge restores plaintext from that local CAS.
50
+
51
+ Useful commands:
52
+
53
+ ```bash
54
+ matterhorn-git key init 'matterhorn::matterhorn+my-room+alice/my-repo'
55
+ matterhorn-git key export 'matterhorn::matterhorn+my-room+alice/my-repo'
56
+ matterhorn-git key import 'matterhorn::matterhorn+my-room+bob/my-repo' '<epoch-root-base64url>'
57
+ matterhorn-git chunk secrets/plan.md --origin 'matterhorn::matterhorn+my-room+alice/my-repo' > /tmp/plan.pointer
58
+ matterhorn-git restore /tmp/plan.pointer --origin 'matterhorn::matterhorn+my-room+alice/my-repo' --output /tmp/plan.md
59
+ matterhorn-git publish-cas /tmp/plan.pointer --origin 'matterhorn::matterhorn+my-room+alice/my-repo'
60
+ ```
61
+
62
+ Production key distribution should wrap fresh independent epoch roots to authorized Matterhorn devices. The local `key export/import` commands are for development and controlled demos.
63
+
64
+ ## Push behavior
65
+
66
+ `git-remote-matterhorn` bridges Git upload-pack/receive-pack to a local bare mirror. After `git-receive-pack` succeeds, it scans pushed trees for Matterhorn Git pointers, verifies the matching encrypted manifest/chunk metadata exists in the local CAS, uploads encrypted object envelopes to `/matterhorn/git/object` when a backend URL is configured; otherwise it stages them for object-plane upload. It then publishes:
67
+
68
+ ```text
69
+ git.manifest.publish
70
+ git.chunk.advertise
71
+ git.ref.update
72
+ ```
73
+
74
+ When no backend URL is configured, operations are queued under `.git/matterhorn/outbox`. With a backend URL, operations are posted to `/matterhorn/operation`.
75
+
76
+ ## Security profile
77
+
78
+ The implemented v1 profile is `dedup-same-epoch/encrypted-manifest/no-padding`:
79
+
80
+ - Each epoch uses a fresh independent epoch root.
81
+ - Chunk IDs, manifest IDs, and encryption keys derive from the current epoch root.
82
+ - Deduplication is scoped to one epoch, so post-rotation writes do not reuse old-key ciphertext.
83
+ - Git pointers are deterministic only within the same repo, epoch, chunker params, and plaintext sequence.
84
+ - XChaCha20-Poly1305 uses 24-byte random nonces; AAD binds repo, epoch, kind, algorithm version, and chunk/manifest identity.
85
+
86
+ V1 protects chunk content from the Git host/object store. It does not claim strong metadata privacy: filenames, commit graph, pointer churn, object sizes, chunk counts, equality of same-epoch deduped chunks, and access patterns may leak unless you add padding, batching, and stricter object-plane privacy.
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ const { runRemoteHelper } = require("../src/remoteHelper.cjs");
3
+
4
+ runRemoteHelper(process.argv.slice(2)).catch((error) => {
5
+ console.error(`[matterhorn-git] ${error?.stack || error?.message || error}`);
6
+ process.exit(1);
7
+ });
@@ -0,0 +1,297 @@
1
+ #!/usr/bin/env node
2
+ const { spawnSync } = require("node:child_process");
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+ const {
6
+ configuredOrigin,
7
+ createDevSigningProfile,
8
+ encryptBufferToCas,
9
+ epochRootFileFor,
10
+ exportEpochRoot,
11
+ formatCompactMatterhornOrigin,
12
+ formatFriendlyOrigin,
13
+ formatMatterhornOrigin,
14
+ importEpochRoot,
15
+ isMatterhornPointer,
16
+ listOutbox,
17
+ loadOrCreateEpochRoot,
18
+ parseMatterhornOrigin,
19
+ publishPointerMetadata,
20
+ restorePointerFromCas
21
+ } = require("../src/index.cjs");
22
+
23
+ function usage() {
24
+ console.log(`matterhorn-git
25
+
26
+ Commands:
27
+ origin <room> <repo> --signing <profile> [--backend <url>] [--canonical|--friendly]
28
+ Print a Git remote URL. Default is the compact Git-safe form:
29
+ matterhorn::matterhorn+<room>+<userSigning>/<repo>
30
+
31
+ remote-add <name> <room> <repo> --signing <profile> [--backend <url>]
32
+ Run git remote add <name> <matterhorn-url>.
33
+
34
+ install-filter <matterhorn-origin> [--pattern <glob>]
35
+ Configure Git clean/smudge filters for encrypted chunk pointers.
36
+ Use --pattern to append a .gitattributes rule for paths you want encrypted.
37
+
38
+ filter-clean [--origin <matterhorn-origin>] [--epoch <n>]
39
+ Git clean filter: plaintext stdin -> Matterhorn encrypted chunk pointer stdout.
40
+
41
+ filter-smudge [--origin <matterhorn-origin>]
42
+ Git smudge filter: pointer stdin -> plaintext stdout using local CAS.
43
+
44
+ chunk <file> --origin <matterhorn-origin> [--epoch <n>]
45
+ Encrypt/chunk one file into the local CAS and print the pointer.
46
+
47
+ restore <pointer-file> --origin <matterhorn-origin> [--output <file>]
48
+ Restore a pointer from the local CAS.
49
+
50
+ publish-cas <pointer-file> --origin <matterhorn-origin>
51
+ Queue/post git.manifest.publish and git.chunk.advertise for a pointer.
52
+
53
+ key init|export|import <matterhorn-origin> [secret] [--epoch <n>]
54
+ Manage local epoch roots. Production should import keys wrapped by Matterhorn.
55
+
56
+ profile init <profile> --member <memberId> [--display-name <name>] [--role member|moderator|admin]
57
+ Create a development signing profile for the helper.
58
+
59
+ outbox <matterhorn-origin-url>
60
+ List queued Matterhorn control-plane operations.
61
+
62
+ Examples:
63
+ matterhorn-git origin design-room main --signing alice
64
+ git remote add origin 'matterhorn::matterhorn+design-room+alice/main'
65
+ matterhorn-git install-filter 'matterhorn::matterhorn+design-room+alice/main' --pattern 'secrets/**'
66
+ echo '*.secret filter=matterhorn-crypt diff=matterhorn-crypt merge=matterhorn-crypt -text' >> .gitattributes
67
+ `);
68
+ }
69
+
70
+ function parseFlags(argv) {
71
+ const flags = {};
72
+ const args = [];
73
+ for (let i = 0; i < argv.length; i += 1) {
74
+ const arg = argv[i];
75
+ if (!arg.startsWith("--")) {
76
+ args.push(arg);
77
+ continue;
78
+ }
79
+ const key = arg.slice(2).replace(/-([a-z])/g, (_m, ch) => ch.toUpperCase());
80
+ const next = argv[i + 1];
81
+ if (!next || next.startsWith("--")) flags[key] = true;
82
+ else {
83
+ flags[key] = next;
84
+ i += 1;
85
+ }
86
+ }
87
+ return { args, flags };
88
+ }
89
+
90
+ function runGit(args) {
91
+ const result = spawnSync("git", args, { stdio: "inherit", windowsHide: true });
92
+ if (result.status !== 0) process.exit(result.status || 1);
93
+ }
94
+
95
+ function runGitCapture(args) {
96
+ const result = spawnSync("git", args, { encoding: "utf8", stdio: "pipe", windowsHide: true });
97
+ if (result.status !== 0) throw new Error((result.stderr || result.stdout || `git ${args.join(" ")} failed`).trim());
98
+ return result.stdout.trim();
99
+ }
100
+
101
+ function readStdinBuffer() {
102
+ return new Promise((resolve, reject) => {
103
+ const chunks = [];
104
+ process.stdin.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
105
+ process.stdin.on("error", reject);
106
+ process.stdin.on("end", () => resolve(Buffer.concat(chunks)));
107
+ });
108
+ }
109
+
110
+ function originFromFlags(flags, fallback) {
111
+ return parseMatterhornOrigin(flags.origin || flags.matterhornOrigin || fallback || configuredOrigin());
112
+ }
113
+
114
+ function appendAttribute(pattern) {
115
+ if (!pattern) return undefined;
116
+ const file = path.resolve(process.cwd(), ".gitattributes");
117
+ const line = `${pattern} filter=matterhorn-crypt diff=matterhorn-crypt merge=matterhorn-crypt -text`;
118
+ const current = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
119
+ if (!current.split(/\r?\n/).some((existing) => existing.trim() === line)) fs.appendFileSync(file, `${current.endsWith("\n") || !current ? "" : "\n"}${line}\n`);
120
+ return file;
121
+ }
122
+
123
+ async function filterClean(flags) {
124
+ const origin = originFromFlags(flags);
125
+ const input = await readStdinBuffer();
126
+ if (isMatterhornPointer(input.toString("utf8"))) {
127
+ process.stdout.write(input);
128
+ return;
129
+ }
130
+ const result = encryptBufferToCas(input, origin, { epoch: flags.epoch ? Number(flags.epoch) : undefined });
131
+ process.stdout.write(result.pointer);
132
+ }
133
+
134
+ async function filterSmudge(flags) {
135
+ const origin = originFromFlags(flags);
136
+ const input = await readStdinBuffer();
137
+ const text = input.toString("utf8");
138
+ if (!isMatterhornPointer(text)) {
139
+ process.stdout.write(input);
140
+ return;
141
+ }
142
+ const clear = restorePointerFromCas(text, origin);
143
+ process.stdout.write(clear);
144
+ }
145
+
146
+ function originForArgs(args, flags) {
147
+ return { room: args[0], repo: args[1], signing: flags.signing || flags.userSigning || "default", backend: flags.backend, relay: flags.relay };
148
+ }
149
+
150
+ async function commandOrigin(argv) {
151
+ const { args, flags } = parseFlags(argv);
152
+ const origin = originForArgs(args, flags);
153
+ if (flags.canonical) console.log(formatMatterhornOrigin(origin));
154
+ else if (flags.friendly) console.log(formatFriendlyOrigin(origin));
155
+ else console.log(formatCompactMatterhornOrigin(origin));
156
+ }
157
+
158
+ async function commandRemoteAdd(argv) {
159
+ const { args, flags } = parseFlags(argv);
160
+ const name = args[0] || "origin";
161
+ const origin = { room: args[1], repo: args[2], signing: flags.signing || flags.userSigning || "default", backend: flags.backend, relay: flags.relay };
162
+ runGit(["remote", "add", name, flags.canonical ? formatMatterhornOrigin(origin) : formatCompactMatterhornOrigin(origin)]);
163
+ }
164
+
165
+ async function commandInstallFilter(argv) {
166
+ const { args, flags } = parseFlags(argv);
167
+ const origin = parseMatterhornOrigin(args[0] || flags.origin || configuredOrigin());
168
+ const originUrl = formatCompactMatterhornOrigin(origin);
169
+ runGit(["config", "matterhorn.origin", originUrl]);
170
+ runGit(["config", "filter.matterhorn-crypt.clean", "matterhorn-git filter-clean"]);
171
+ runGit(["config", "filter.matterhorn-crypt.smudge", "matterhorn-git filter-smudge"]);
172
+ runGit(["config", "filter.matterhorn-crypt.required", "true"]);
173
+ runGit(["config", "diff.matterhorn-crypt.textconv", "matterhorn-git filter-smudge"]);
174
+ const attributes = flags.pattern ? appendAttribute(flags.pattern) : undefined;
175
+ console.log(`Configured matterhorn-crypt filter for ${originUrl}`);
176
+ if (attributes) console.log(`Updated ${attributes}`);
177
+ }
178
+
179
+ async function commandFilterClean(argv) {
180
+ const { flags } = parseFlags(argv);
181
+ await filterClean(flags);
182
+ }
183
+
184
+ async function commandFilterSmudge(argv) {
185
+ const { flags } = parseFlags(argv);
186
+ await filterSmudge(flags);
187
+ }
188
+
189
+ async function commandChunk(argv) {
190
+ const { args, flags } = parseFlags(argv);
191
+ const file = args[0];
192
+ if (!file) throw new Error("chunk requires a file path");
193
+ const origin = originFromFlags(flags);
194
+ const result = encryptBufferToCas(fs.readFileSync(file), origin, { epoch: flags.epoch ? Number(flags.epoch) : undefined });
195
+ process.stdout.write(result.pointer);
196
+ }
197
+
198
+ async function commandRestore(argv) {
199
+ const { args, flags } = parseFlags(argv);
200
+ const file = args[0];
201
+ if (!file) throw new Error("restore requires a pointer file path");
202
+ const origin = originFromFlags(flags);
203
+ const clear = restorePointerFromCas(fs.readFileSync(file, "utf8"), origin);
204
+ if (flags.output) fs.writeFileSync(flags.output, clear);
205
+ else process.stdout.write(clear);
206
+ }
207
+
208
+ async function commandPublishCas(argv) {
209
+ const { args, flags } = parseFlags(argv);
210
+ const file = args[0];
211
+ if (!file) throw new Error("publish-cas requires a pointer file path");
212
+ const result = await publishPointerMetadata(originFromFlags(flags), fs.readFileSync(file, "utf8"), {});
213
+ for (const item of result.results) {
214
+ if (item.kind === "objects") console.log(`staged objects ${item.dir}`);
215
+ else console.log(`${item.draft.type} ${item.result.mode}${item.result.file ? ` ${item.result.file}` : ""}`);
216
+ }
217
+ }
218
+
219
+ async function commandKey(argv) {
220
+ const sub = argv[0];
221
+ const { args, flags } = parseFlags(argv.slice(1));
222
+ const origin = parseMatterhornOrigin(args[0] || flags.origin || configuredOrigin());
223
+ const epoch = Number(flags.epoch || 1);
224
+ if (sub === "init") {
225
+ const key = loadOrCreateEpochRoot(origin, epoch, { create: true });
226
+ console.log(`${epochRootFileFor(origin, epoch)}\t${key.length} bytes`);
227
+ return;
228
+ }
229
+ if (sub === "export") {
230
+ console.log(exportEpochRoot(origin, epoch));
231
+ return;
232
+ }
233
+ if (sub === "import") {
234
+ const secret = args[1] || flags.secret;
235
+ if (!secret) throw new Error("key import requires a secret");
236
+ const imported = importEpochRoot(origin, epoch, secret);
237
+ console.log(`Imported epoch ${imported.epoch} key to ${imported.file}`);
238
+ return;
239
+ }
240
+ throw new Error("key command must be init, export, or import");
241
+ }
242
+
243
+ async function commandProfile(argv) {
244
+ if (argv[0] !== "init") throw new Error("profile command must be init");
245
+ const { args, flags } = parseFlags(argv.slice(1));
246
+ const profile = args[0] || flags.profile || "default";
247
+ const created = createDevSigningProfile(profile, {
248
+ memberId: flags.member || flags.memberId || profile,
249
+ displayName: flags.displayName || flags.name,
250
+ role: flags.role || "member"
251
+ }, { repo: flags.repo === true });
252
+ console.log(`Created ${created.file}`);
253
+ }
254
+
255
+ async function commandOutbox(argv) {
256
+ const files = listOutbox(parseMatterhornOrigin(argv[0] || configuredOrigin()));
257
+ for (const file of files) {
258
+ const draft = JSON.parse(fs.readFileSync(file, "utf8"));
259
+ console.log(`${file}\t${draft.type}\t${draft.payload?.repoId || ""}\t${draft.payload?.ref || draft.payload?.manifestId || ""}`);
260
+ }
261
+ }
262
+
263
+ async function commandGitConfigOrigin() {
264
+ console.log(runGitCapture(["config", "--get", "matterhorn.origin"]));
265
+ }
266
+
267
+ const COMMAND_HANDLERS = {
268
+ origin: commandOrigin,
269
+ "remote-add": commandRemoteAdd,
270
+ "install-filter": commandInstallFilter,
271
+ "filter-clean": commandFilterClean,
272
+ "filter-smudge": commandFilterSmudge,
273
+ chunk: commandChunk,
274
+ restore: commandRestore,
275
+ "publish-cas": commandPublishCas,
276
+ key: commandKey,
277
+ profile: commandProfile,
278
+ outbox: commandOutbox,
279
+ "git-config-origin": commandGitConfigOrigin
280
+ };
281
+
282
+ async function main(argv) {
283
+ const command = argv[0];
284
+ if (!command || command === "help" || command === "--help" || command === "-h") return usage();
285
+ const handler = COMMAND_HANDLERS[command];
286
+ if (!handler) {
287
+ usage();
288
+ process.exitCode = 1;
289
+ return;
290
+ }
291
+ await handler(argv.slice(1));
292
+ }
293
+
294
+ main(process.argv.slice(2)).catch((error) => {
295
+ console.error(error?.stack || error?.message || error);
296
+ process.exit(1);
297
+ });
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@mh-gg/git",
3
+ "version": "0.1.1-alpha.20260626T104441232Z",
4
+ "description": "Matterhorn Git helper, origin transport bridge, and shared Git manifest utilities.",
5
+ "type": "commonjs",
6
+ "main": "src/index.cjs",
7
+ "exports": {
8
+ ".": "./src/index.cjs",
9
+ "./origin": "./src/originUrl.cjs",
10
+ "./remote-helper": "./src/remoteHelper.cjs",
11
+ "./cas": "./src/cas.cjs",
12
+ "./chunker": "./src/chunker.cjs",
13
+ "./crypto": "./src/crypto.cjs"
14
+ },
15
+ "bin": {
16
+ "matterhorn-git": "./bin/matterhorn-git.cjs",
17
+ "git-remote-matterhorn": "./bin/git-remote-matterhorn.cjs"
18
+ },
19
+ "files": [
20
+ "bin",
21
+ "src",
22
+ "README.md",
23
+ "package.json"
24
+ ],
25
+ "dependencies": {
26
+ "@mh-gg/event": "^0.1.1-alpha.20260626T104441232Z",
27
+ "@mh-gg/protocol": "^0.1.1-alpha.20260626T104441232Z",
28
+ "@mh-gg/host-runtime": "^0.1.1-alpha.20260626T104441232Z"
29
+ },
30
+ "engines": {
31
+ "node": ">=22.12"
32
+ },
33
+ "license": "MIT",
34
+ "scripts": {
35
+ "test": "node --test test/*.test.cjs",
36
+ "coverage": "node --test --experimental-test-coverage --test-coverage-lines=80 --test-coverage-functions=80 --test-coverage-branches=60 --test-coverage-include=src/**/*.cjs test/*.test.cjs"
37
+ }
38
+ }
@@ -0,0 +1,121 @@
1
+ const path = require("node:path");
2
+ const fs = require("node:fs");
3
+ const { parsePointer } = require("./manifest.cjs");
4
+ const { parseMatterhornOrigin } = require("./originUrl.cjs");
5
+ const {
6
+ objectPathFor,
7
+ manifestPathFor,
8
+ casRootFor,
9
+ readCasIndex,
10
+ readJson,
11
+ fileExists,
12
+ writePrivateJson,
13
+ safeId,
14
+ ensurePrivateDir,
15
+ decryptManifestEnvelope,
16
+ decryptChunkEnvelope
17
+ } = require("./cas/storage.cjs");
18
+
19
+ const POINTER_VERSION = "https://matterhorn.gg/git-chunks/v1";
20
+
21
+ function isMatterhornPointer(text) {
22
+ return String(text || "").startsWith(`version ${POINTER_VERSION}\n`);
23
+ }
24
+
25
+ function manifestEnvelopeForPointer(pointerInput, originInput, options = {}) {
26
+ const pointer = typeof pointerInput === "string" ? parsePointer(pointerInput) : pointerInput;
27
+ const index = readCasIndex(originInput, options);
28
+ const manifest = index.manifests?.[pointer.manifestId];
29
+ if (!manifest) throw new Error(`Manifest ${pointer.manifestId} is not present in the local Matterhorn Git CAS`);
30
+ const objectId = manifest.objectId;
31
+ const file = manifestPathFor(originInput, objectId, options);
32
+ const envelope = readJson(file, null);
33
+ if (!envelope) throw new Error(`Encrypted manifest object ${objectId} is missing`);
34
+ return envelope;
35
+ }
36
+
37
+ function restorePointerFromCas(pointerText, originInput, options = {}) {
38
+ const pointer = parsePointer(pointerText);
39
+ const origin = parseMatterhornOrigin(originInput, { repo: pointer.repoId });
40
+ if (origin.repo !== pointer.repoId) throw new Error(`Pointer repo ${pointer.repoId} does not match origin repo ${origin.repo}`);
41
+ const manifestEnvelope = manifestEnvelopeForPointer(pointer, origin, options);
42
+ const manifest = decryptManifestEnvelope(manifestEnvelope, origin, options);
43
+ const parts = [];
44
+ for (const chunk of manifest.chunks || []) {
45
+ const objectId = chunk.objectCandidates?.[0];
46
+ if (!objectId) throw new Error(`Chunk ${chunk.index} has no object candidate`);
47
+ const file = objectPathFor(origin, objectId, options);
48
+ const envelope = readJson(file, null);
49
+ if (!envelope) throw new Error(`Encrypted chunk object ${objectId} is missing`);
50
+ const clear = decryptChunkEnvelope(envelope, origin, options);
51
+ if (clear.length !== Number(chunk.clearLen)) throw new Error(`Chunk ${chunk.index} length mismatch`);
52
+ parts.push(clear);
53
+ }
54
+ const restored = Buffer.concat(parts);
55
+ if (Number.isFinite(Number(manifest.file?.clearSize)) && restored.length !== Number(manifest.file.clearSize)) throw new Error("Restored file length mismatch");
56
+ return restored;
57
+ }
58
+
59
+ function metadataForPointer(pointerText, originInput, options = {}) {
60
+ const pointer = typeof pointerText === "string" ? parsePointer(pointerText) : pointerText;
61
+ const index = readCasIndex(originInput, options);
62
+ const manifest = index.manifests?.[pointer.manifestId];
63
+ if (!manifest) throw new Error(`Manifest ${pointer.manifestId} is not present in the local Matterhorn Git CAS`);
64
+ const chunks = [];
65
+ for (const chunk of manifest.chunks || []) {
66
+ const objectId = chunk.objectCandidates?.[0];
67
+ const object = objectId ? index.objects?.[objectId] : undefined;
68
+ chunks.push({
69
+ chunkPlainId: chunk.chunkPlainId,
70
+ objectId,
71
+ epoch: pointer.epoch,
72
+ clearLen: chunk.clearLen,
73
+ storedLen: object?.storedLen
74
+ });
75
+ }
76
+ return {
77
+ repoId: pointer.repoId,
78
+ epoch: pointer.epoch,
79
+ manifestId: pointer.manifestId,
80
+ objectId: manifest.objectId,
81
+ clearSize: manifest.clearSize,
82
+ chunkCount: manifest.chunkCount,
83
+ pointerProfile: pointer.profile,
84
+ chunks
85
+ };
86
+ }
87
+
88
+ function materializeCasObjectOutbox(originInput, pointerText, options = {}) {
89
+ const origin = parseMatterhornOrigin(originInput);
90
+ const pointer = parsePointer(pointerText);
91
+ const metadata = metadataForPointer(pointer, origin, options);
92
+ const root = path.join(casRootFor(origin, options), "object-outbox", safeId(pointer.manifestId));
93
+ ensurePrivateDir(root);
94
+ const manifestEnvelope = manifestEnvelopeForPointer(pointer, origin, options);
95
+ const manifestFile = path.join(root, `${safeId(metadata.objectId)}.json`);
96
+ writePrivateJson(manifestFile, manifestEnvelope);
97
+ const files = [manifestFile];
98
+ for (const chunk of metadata.chunks) {
99
+ const source = objectPathFor(origin, chunk.objectId, options);
100
+ const target = path.join(root, `${safeId(chunk.objectId)}.json`);
101
+ try {
102
+ fs.copyFileSync(source, target);
103
+ } catch (error) {
104
+ if (error && (error.code === "ENOENT" || error.code === "ENOTDIR")) continue;
105
+ throw error;
106
+ }
107
+ try { fs.chmodSync(target, 0o600); } catch (matterhornIgnoredError) {
108
+ globalThis.__matterhornIgnoredError?.(matterhornIgnoredError, "packages/matterhorn-git/src/cas/pointer.cjs");
109
+ }
110
+ files.push(target);
111
+ }
112
+ return { dir: root, files, metadata };
113
+ }
114
+
115
+ module.exports = {
116
+ isMatterhornPointer,
117
+ manifestEnvelopeForPointer,
118
+ metadataForPointer,
119
+ restorePointerFromCas,
120
+ materializeCasObjectOutbox
121
+ };