amalgm 0.1.239 → 0.1.241

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.
Files changed (87) hide show
  1. package/lib/shared-realtime-tunnel.js +18 -4
  2. package/package.json +1 -1
  3. package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +68 -20
  4. package/runtime/scripts/amalgm-mcp/project-context/store.js +87 -19
  5. package/runtime/scripts/amalgm-mcp/server/routes/state.js +13 -0
  6. package/runtime/scripts/amalgm-mcp/state/attachments.js +40 -12
  7. package/runtime/scripts/amalgm-mcp/state/db.js +216 -17
  8. package/runtime/scripts/amalgm-mcp/state/doc-disk.js +550 -0
  9. package/runtime/scripts/amalgm-mcp/state/docs.js +1101 -301
  10. package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +99 -0
  11. package/runtime/scripts/amalgm-mcp/state/mutations.js +507 -130
  12. package/runtime/scripts/amalgm-mcp/state/promotions.js +21 -1
  13. package/runtime/scripts/amalgm-mcp/state/replicas.js +134 -13
  14. package/runtime/scripts/amalgm-mcp/state/shared-rest.js +14 -8
  15. package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +193 -0
  16. package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +313 -94
  17. package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +1 -0
  18. package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +32 -17
  19. package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +84 -0
  20. package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +376 -13
  21. package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +309 -0
  22. package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +758 -30
  23. package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +72 -3
  24. package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +33 -6
  25. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +27 -0
  26. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +334 -191
  27. package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +24 -0
  28. package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +1999 -0
  29. package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +364 -0
  30. package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +98 -4
  31. package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +156 -8
  32. package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +185 -0
  33. package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +174 -269
  34. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +26 -0
  35. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +2060 -407
  36. package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +119 -0
  37. package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +1446 -0
  38. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +5319 -3
  39. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +191 -1
  40. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +3140 -17
  41. package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +135 -637
  42. package/runtime/scripts/amalgm-mcp/workspace/access-store.js +5 -1
  43. package/runtime/scripts/amalgm-mcp/workspace/card.js +7 -7
  44. package/runtime/scripts/amalgm-mcp/workspace/cards.js +36 -12
  45. package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +436 -173
  46. package/runtime/scripts/amalgm-mcp/workspace/content.js +32 -0
  47. package/runtime/scripts/amalgm-mcp/workspace/durable.js +45 -0
  48. package/runtime/scripts/amalgm-mcp/workspace/git.js +391 -37
  49. package/runtime/scripts/amalgm-mcp/workspace/intent.js +535 -0
  50. package/runtime/scripts/amalgm-mcp/workspace/merge.js +89 -6
  51. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +31 -0
  52. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
  53. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
  54. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +72 -0
  55. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +165 -0
  56. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +5 -0
  57. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +2 -0
  58. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +2201 -0
  59. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +19 -0
  60. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +18 -0
  61. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +18 -0
  62. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +33 -0
  63. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +47 -0
  64. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +43 -0
  65. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +114 -0
  66. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +84 -0
  67. package/runtime/scripts/amalgm-mcp/workspace/objects.js +185 -55
  68. package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +616 -168
  69. package/runtime/scripts/amalgm-mcp/workspace/provenance.js +240 -0
  70. package/runtime/scripts/amalgm-mcp/workspace/reducer.js +1979 -570
  71. package/runtime/scripts/amalgm-mcp/workspace/rest.js +49 -28
  72. package/runtime/scripts/amalgm-mcp/workspace/store.js +77 -4
  73. package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +240 -0
  74. package/runtime/scripts/amalgm-mcp/workspace/transition.js +1729 -192
  75. package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +100 -0
  76. package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +7 -1
  77. package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +73 -61
  78. package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +127 -0
  79. package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +12 -0
  80. package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +51 -6
  81. package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +125 -0
  82. package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +79 -8
  83. package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +76 -0
  84. package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +3480 -165
  85. package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +3766 -0
  86. package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +899 -71
  87. package/runtime/scripts/amalgm-mcp/workspace/wall.js +352 -497
@@ -14,21 +14,36 @@ const fs = require('fs');
14
14
  const path = require('path');
15
15
  const { AMALGM_DIR } = require('../config');
16
16
  const { appendStateEvent } = require('../state/events');
17
- const { originContains } = require('./git');
17
+ const durable = require('./durable');
18
18
  const transition = require('./transition');
19
+ const {
20
+ CHUNK_BYTES,
21
+ describeBytes,
22
+ manifestChecksum,
23
+ sha256,
24
+ } = require('./content');
19
25
 
20
- const CONTRACT = 'git-bundle-chunks@1';
21
- const PAYLOAD_CONTRACT = 'opaque-chunks@1';
22
- const CHUNK_BYTES = 512 * 1024;
26
+ const CONTRACT = 'git-bundle-chunks@2';
27
+ const PAYLOAD_CONTRACT = 'opaque-chunks@2';
23
28
  const ROOT = path.join(AMALGM_DIR, 'state', 'repo-objects');
24
29
  const MANIFESTS = path.join(ROOT, 'manifests');
25
30
  const PAYLOADS = path.join(ROOT, 'payloads');
26
31
  const CHUNKS = path.join(ROOT, 'chunks');
27
32
  const REQUESTS = path.join(ROOT, 'requests');
28
33
  const preparing = new Map();
34
+ const UPLOAD_RETRY_BASE_MS = 1_000;
35
+ const UPLOAD_RETRY_MAX_MS = 60_000;
36
+
37
+ function isProjectId(value) {
38
+ return require('./tree/identity').isUuid(String(value || ''));
39
+ }
40
+
41
+ function locatorChecksum(chunks) {
42
+ return manifestChecksum(chunks);
43
+ }
29
44
 
30
- function sha256(bytes) {
31
- return crypto.createHash('sha256').update(bytes).digest('hex');
45
+ function locatorBytes(chunks) {
46
+ return chunks.reduce((total, chunk) => total + chunk.bytes, 0);
32
47
  }
33
48
 
34
49
  function ensureDirs() {
@@ -56,8 +71,7 @@ function readJson(file) {
56
71
  }
57
72
 
58
73
  function writeJson(file, value) {
59
- fs.mkdirSync(path.dirname(file), { recursive: true });
60
- fs.writeFileSync(file, `${JSON.stringify(value)}\n`, { mode: 0o600 });
74
+ durable.writeJson(file, value);
61
75
  }
62
76
 
63
77
  function publicLocator(manifest) {
@@ -83,14 +97,18 @@ function publicPayloadLocator(manifest) {
83
97
  };
84
98
  }
85
99
 
86
- function writeChunks(bytes) {
87
- const chunks = [];
88
- for (let offset = 0; offset < bytes.length; offset += CHUNK_BYTES) {
89
- const part = bytes.subarray(offset, Math.min(offset + CHUNK_BYTES, bytes.length));
90
- const digest = sha256(part);
91
- const chunkFile = path.join(CHUNKS, `${digest}.bin`);
92
- if (!fs.existsSync(chunkFile)) fs.writeFileSync(chunkFile, part, { mode: 0o600 });
93
- chunks.push({ sha256: digest, bytes: part.length, uploaded: false });
100
+ function describeChunks(bytes) {
101
+ return describeBytes(bytes).map((chunk) => ({ ...chunk, uploaded: false }));
102
+ }
103
+
104
+ function writeChunks(bytes, chunks = describeChunks(bytes)) {
105
+ let offset = 0;
106
+ for (const chunk of chunks) {
107
+ const part = bytes.subarray(offset, offset + chunk.bytes);
108
+ const chunkFile = path.join(CHUNKS, `${chunk.sha256}.bin`);
109
+ const held = fs.existsSync(chunkFile) ? fs.readFileSync(chunkFile) : null;
110
+ if (!held || sha256(held) !== chunk.sha256) durable.writeFile(chunkFile, part);
111
+ offset += chunk.bytes;
94
112
  }
95
113
  return chunks;
96
114
  }
@@ -113,8 +131,8 @@ async function prepareBundle(worktreeRoot, repoKey, resourceId, oid, file) {
113
131
  repoKey,
114
132
  oid,
115
133
  ref,
116
- checksum: sha256(bundle),
117
- bytes: bundle.length,
134
+ checksum: locatorChecksum(chunks),
135
+ bytes: locatorBytes(chunks),
118
136
  chunks,
119
137
  createdAt: new Date().toISOString(),
120
138
  });
@@ -128,23 +146,29 @@ function publishPayload(key, bytesInput, repoKey) {
128
146
  ensureDirs();
129
147
  const bytes = Buffer.isBuffer(bytesInput) ? bytesInput : Buffer.from(bytesInput);
130
148
  const project = require('./wall').projectForRepo(repoKey);
131
- const checksum = sha256(bytes);
132
149
  if (!project?.resourceId) {
133
- return { kind: 'pending', contract: PAYLOAD_CONTRACT, checksum };
150
+ return { kind: 'pending', contract: PAYLOAD_CONTRACT };
134
151
  }
152
+ const chunks = describeChunks(bytes);
153
+ const checksum = locatorChecksum(chunks);
135
154
  // Payload content is its identity. A later checkpoint that still contains
136
155
  // the same large file must reference the already-uploaded bytes instead of
137
156
  // creating another upload merely because the surrounding checkpoint moved.
138
157
  const file = payloadFile(project.resourceId, checksum);
139
158
  let manifest = readJson(file);
140
- if (!manifest || manifest.checksum !== checksum) {
159
+ if (
160
+ !manifest
161
+ || manifest.contract !== PAYLOAD_CONTRACT
162
+ || manifest.checksum !== checksum
163
+ ) {
164
+ writeChunks(bytes, chunks);
141
165
  manifest = {
142
166
  contract: PAYLOAD_CONTRACT,
143
167
  resourceId: project.resourceId,
144
168
  repoKey,
145
169
  checksum,
146
- bytes: bytes.length,
147
- chunks: writeChunks(bytes),
170
+ bytes: locatorBytes(chunks),
171
+ chunks,
148
172
  createdAt: new Date().toISOString(),
149
173
  };
150
174
  writeJson(file, manifest);
@@ -182,18 +206,38 @@ async function bundleSourceFor(worktreeRoot, repoKey, oid) {
182
206
 
183
207
  async function objectSourceFor(worktreeRoot, repoKey, oid) {
184
208
  if (!oid || !repoKey) return null;
185
- if (await originContains(worktreeRoot, oid)) return { kind: 'remote' };
186
209
  return bundleSourceFor(worktreeRoot, repoKey, oid);
187
210
  }
188
211
 
189
212
  function claimUpload() {
190
213
  ensureDirs();
214
+ const now = Date.now();
191
215
  for (const directory of [MANIFESTS, PAYLOADS]) {
192
216
  for (const name of fs.readdirSync(directory).sort()) {
193
- const manifest = readJson(path.join(directory, name));
194
- const chunk = manifest?.chunks?.find((entry) => !entry.uploaded);
217
+ const file = path.join(directory, name);
218
+ const manifest = readJson(file);
219
+ const chunk = manifest?.chunks?.find((entry) => (
220
+ !entry.uploaded
221
+ && !entry.uploadTerminal
222
+ && (
223
+ !entry.uploadRetryAfter
224
+ || !Number.isFinite(Date.parse(entry.uploadRetryAfter))
225
+ || Date.parse(entry.uploadRetryAfter) <= now
226
+ )
227
+ ));
195
228
  if (!chunk) continue;
196
- const bytes = fs.readFileSync(path.join(CHUNKS, `${chunk.sha256}.bin`));
229
+ const chunkFile = path.join(CHUNKS, `${chunk.sha256}.bin`);
230
+ const bytes = fs.existsSync(chunkFile) ? fs.readFileSync(chunkFile) : null;
231
+ if (
232
+ !bytes
233
+ || bytes.length !== chunk.bytes
234
+ || sha256(bytes) !== chunk.sha256
235
+ ) {
236
+ fs.rmSync(chunkFile, { force: true });
237
+ fs.rmSync(file, { force: true });
238
+ wake('delete', chunk.sha256);
239
+ continue;
240
+ }
197
241
  return {
198
242
  resourceId: manifest.resourceId,
199
243
  sha256: chunk.sha256,
@@ -219,6 +263,10 @@ function acknowledgeUpload(resourceId, digest) {
219
263
  for (const chunk of manifest.chunks) {
220
264
  if (chunk.sha256 === digest && !chunk.uploaded) {
221
265
  chunk.uploaded = true;
266
+ delete chunk.uploadAttempts;
267
+ delete chunk.uploadLastError;
268
+ delete chunk.uploadRetryAfter;
269
+ delete chunk.uploadTerminal;
222
270
  touched = true;
223
271
  changed = true;
224
272
  }
@@ -239,21 +287,73 @@ function acknowledgeUpload(resourceId, digest) {
239
287
  return { changed, completed };
240
288
  }
241
289
 
290
+ function failUpload(resourceId, digest, failure = {}) {
291
+ if (!isProjectId(resourceId) || !/^[0-9a-f]{64}$/.test(String(digest || ''))) {
292
+ return { changed: false, terminal: false };
293
+ }
294
+ ensureDirs();
295
+ const failedAt = new Date().toISOString();
296
+ const retryable = failure?.retryable !== false;
297
+ let changed = false;
298
+ let terminal = false;
299
+ let retryAfter = null;
300
+ let attempts = 0;
301
+ for (const directory of [MANIFESTS, PAYLOADS]) {
302
+ for (const name of fs.readdirSync(directory)) {
303
+ const file = path.join(directory, name);
304
+ const manifest = readJson(file);
305
+ if (manifest?.resourceId !== resourceId || !manifest?.chunks) continue;
306
+ const chunk = manifest.chunks.find((entry) => (
307
+ entry.sha256 === digest && !entry.uploaded
308
+ ));
309
+ if (!chunk) continue;
310
+ attempts = Math.max(attempts, Number(chunk.uploadAttempts || 0) + 1);
311
+ chunk.uploadAttempts = attempts;
312
+ chunk.uploadLastError = {
313
+ code: typeof failure?.code === 'string' ? failure.code : 'repo_object_upload_failed',
314
+ message: typeof failure?.message === 'string' ? failure.message : 'repo object upload failed',
315
+ retryable,
316
+ at: failedAt,
317
+ };
318
+ if (retryable) {
319
+ const delayMs = Math.min(
320
+ UPLOAD_RETRY_MAX_MS,
321
+ UPLOAD_RETRY_BASE_MS * (2 ** Math.min(attempts - 1, 16)),
322
+ );
323
+ retryAfter = new Date(Date.now() + delayMs).toISOString();
324
+ chunk.uploadRetryAfter = retryAfter;
325
+ delete chunk.uploadTerminal;
326
+ } else {
327
+ chunk.uploadTerminal = { ...chunk.uploadLastError };
328
+ delete chunk.uploadRetryAfter;
329
+ terminal = true;
330
+ }
331
+ writeJson(file, manifest);
332
+ changed = true;
333
+ }
334
+ }
335
+ if (changed) wake('update', digest);
336
+ return { changed, terminal, retryAfter, attempts };
337
+ }
338
+
242
339
  function validatePayloadLocator(locator) {
243
340
  if (
244
341
  locator?.kind !== 'chunks'
245
342
  || locator?.contract !== PAYLOAD_CONTRACT
246
- || !/^shr_[A-Za-z0-9_-]{16,128}$/.test(String(locator.resourceId || ''))
343
+ || !isProjectId(locator.resourceId)
247
344
  || !/^[0-9a-f]{64}$/.test(String(locator.checksum || ''))
248
345
  || !Number.isSafeInteger(locator.bytes)
249
- || locator.bytes < 0
346
+ || locator.bytes < 1
250
347
  || !Array.isArray(locator.chunks)
348
+ || locator.chunks.length < 1
251
349
  || locator.chunks.some((chunk) => (
252
350
  !/^[0-9a-f]{64}$/.test(String(chunk?.sha256 || ''))
253
351
  || !Number.isSafeInteger(chunk?.bytes)
254
- || chunk.bytes < 0
352
+ || chunk.bytes < 1
255
353
  || chunk.bytes > CHUNK_BYTES
256
354
  ))
355
+ || locatorBytes(locator.chunks) !== locator.bytes
356
+ || locatorChecksum(locator.chunks) !== locator.checksum
257
357
  ) throw new Error('invalid checkpoint payload locator');
258
358
  }
259
359
 
@@ -263,7 +363,11 @@ function readPayload(locator) {
263
363
  let waiting = false;
264
364
  for (const chunk of locator.chunks) {
265
365
  const file = path.join(CHUNKS, `${chunk.sha256}.bin`);
266
- if (fs.existsSync(file) && fs.statSync(file).size === chunk.bytes) continue;
366
+ if (fs.existsSync(file)) {
367
+ const held = fs.readFileSync(file);
368
+ if (held.length === chunk.bytes && sha256(held) === chunk.sha256) continue;
369
+ fs.rmSync(file, { force: true });
370
+ }
267
371
  writeJson(requestFile(locator.resourceId, chunk.sha256), {
268
372
  resourceId: locator.resourceId,
269
373
  sha256: chunk.sha256,
@@ -282,7 +386,7 @@ function readPayload(locator) {
282
386
  return bytes;
283
387
  });
284
388
  const bytes = Buffer.concat(parts);
285
- if (bytes.length !== locator.bytes || sha256(bytes) !== locator.checksum) {
389
+ if (bytes.length !== locator.bytes) {
286
390
  throw new Error('checkpoint payload verification failed');
287
391
  }
288
392
  writeJson(payloadFile(locator.resourceId, locator.checksum), {
@@ -303,21 +407,29 @@ function requestFile(resourceId, digest) {
303
407
 
304
408
  function claimDownload() {
305
409
  ensureDirs();
306
- const name = fs.readdirSync(REQUESTS).filter((entry) => entry.endsWith('.json')).sort()[0];
307
- if (!name) return null;
308
- const request = readJson(path.join(REQUESTS, name));
309
- return request?.sha256 ? request : null;
410
+ for (const name of fs.readdirSync(REQUESTS).filter((entry) => entry.endsWith('.json')).sort()) {
411
+ const file = path.join(REQUESTS, name);
412
+ const request = readJson(file);
413
+ if (
414
+ request
415
+ && Object.keys(request).length === 2
416
+ && isProjectId(request.resourceId)
417
+ && /^[0-9a-f]{64}$/.test(String(request.sha256 || ''))
418
+ ) return request;
419
+ fs.rmSync(file, { force: true });
420
+ }
421
+ return null;
310
422
  }
311
423
 
312
424
  function receiveDownload(resourceId, digest, dataBase64) {
313
- if (!/^shr_[A-Za-z0-9_-]{16,128}$/.test(String(resourceId || ''))) {
425
+ if (!isProjectId(resourceId)) {
314
426
  throw new Error('invalid repo object project');
315
427
  }
316
428
  if (!/^[0-9a-f]{64}$/.test(String(digest || ''))) throw new Error('invalid repo object checksum');
317
429
  ensureDirs();
318
430
  const bytes = Buffer.from(String(dataBase64 || ''), 'base64');
319
431
  if (sha256(bytes) !== digest) throw new Error('repo object checksum mismatch');
320
- fs.writeFileSync(path.join(CHUNKS, `${digest}.bin`), bytes, { mode: 0o600 });
432
+ durable.writeFile(path.join(CHUNKS, `${digest}.bin`), bytes);
321
433
  fs.rmSync(requestFile(resourceId, digest), { force: true });
322
434
  wake('update', digest);
323
435
  require('./reducer').reduceNow().catch(() => {});
@@ -326,17 +438,24 @@ function receiveDownload(resourceId, digest, dataBase64) {
326
438
 
327
439
  function validateLocator(locator) {
328
440
  if (
329
- locator?.contract !== CONTRACT
330
- || !/^shr_[A-Za-z0-9_-]{16,128}$/.test(String(locator.resourceId || ''))
331
- || !/^[0-9a-f]{40,64}$/.test(String(locator.oid || ''))
441
+ locator?.kind !== 'bundle'
442
+ || locator?.contract !== CONTRACT
443
+ || !isProjectId(locator.resourceId)
444
+ || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(String(locator.oid || ''))
332
445
  || !/^[0-9a-f]{64}$/.test(String(locator.checksum || ''))
446
+ || locator.ref !== `refs/amalgm/exports/${locator.oid}`
447
+ || !Number.isSafeInteger(locator.bytes)
448
+ || locator.bytes < 1
333
449
  || !Array.isArray(locator.chunks)
450
+ || locator.chunks.length < 1
334
451
  || locator.chunks.some((chunk) => (
335
452
  !/^[0-9a-f]{64}$/.test(String(chunk?.sha256 || ''))
336
453
  || !Number.isSafeInteger(chunk?.bytes)
337
- || chunk.bytes < 0
454
+ || chunk.bytes < 1
338
455
  || chunk.bytes > CHUNK_BYTES
339
456
  ))
457
+ || locatorBytes(locator.chunks) !== locator.bytes
458
+ || locatorChecksum(locator.chunks) !== locator.checksum
340
459
  ) throw new Error('invalid git bundle locator');
341
460
  }
342
461
 
@@ -346,7 +465,11 @@ function prepareBundleImport(locator) {
346
465
  let waiting = false;
347
466
  for (const chunk of locator.chunks) {
348
467
  const file = path.join(CHUNKS, `${chunk.sha256}.bin`);
349
- if (fs.existsSync(file) && fs.statSync(file).size === chunk.bytes) continue;
468
+ if (fs.existsSync(file)) {
469
+ const held = fs.readFileSync(file);
470
+ if (held.length === chunk.bytes && sha256(held) === chunk.sha256) continue;
471
+ fs.rmSync(file, { force: true });
472
+ }
350
473
  writeJson(requestFile(locator.resourceId, chunk.sha256), {
351
474
  resourceId: locator.resourceId,
352
475
  sha256: chunk.sha256,
@@ -359,19 +482,24 @@ function prepareBundleImport(locator) {
359
482
  }
360
483
 
361
484
  const bundleFile = path.join(ROOT, `${locator.checksum}.bundle`);
362
- if (!fs.existsSync(bundleFile)) {
363
- const parts = locator.chunks.map((chunk) => {
364
- const bytes = fs.readFileSync(path.join(CHUNKS, `${chunk.sha256}.bin`));
365
- if (bytes.length !== chunk.bytes || sha256(bytes) !== chunk.sha256) {
366
- throw new Error('git bundle chunk verification failed');
367
- }
368
- return bytes;
369
- });
370
- const bundle = Buffer.concat(parts);
371
- if (bundle.length !== locator.bytes || sha256(bundle) !== locator.checksum) {
372
- throw new Error('git bundle verification failed');
485
+ const parts = locator.chunks.map((chunk) => (
486
+ fs.readFileSync(path.join(CHUNKS, `${chunk.sha256}.bin`))
487
+ ));
488
+ const bundleMatches = fs.existsSync(bundleFile) && (() => {
489
+ const held = fs.readFileSync(bundleFile);
490
+ let offset = 0;
491
+ for (let index = 0; index < locator.chunks.length; index += 1) {
492
+ const chunk = locator.chunks[index];
493
+ const part = held.subarray(offset, offset + chunk.bytes);
494
+ if (part.length !== chunk.bytes || sha256(part) !== chunk.sha256) return false;
495
+ offset += chunk.bytes;
373
496
  }
374
- fs.writeFileSync(bundleFile, bundle, { mode: 0o600 });
497
+ return offset === held.length && held.length === locator.bytes;
498
+ })();
499
+ if (!bundleMatches) {
500
+ const bundle = Buffer.concat(parts);
501
+ if (bundle.length !== locator.bytes) throw new Error('git bundle verification failed');
502
+ durable.writeFile(bundleFile, bundle);
375
503
  }
376
504
  return { state: 'ready', file: bundleFile };
377
505
  }
@@ -387,10 +515,12 @@ module.exports = {
387
515
  CHUNK_BYTES,
388
516
  CONTRACT,
389
517
  PAYLOAD_CONTRACT,
518
+ _test: { describeChunks, locatorBytes, locatorChecksum },
390
519
  acknowledgeUpload,
391
520
  bundleSourceFor,
392
521
  claimDownload,
393
522
  claimUpload,
523
+ failUpload,
394
524
  importFromLocator,
395
525
  objectSourceFor,
396
526
  prepareBundleImport,