@remnic/core 9.3.519 → 9.3.521

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.
@@ -6,7 +6,7 @@ import path from "node:path";
6
6
  import test from "node:test";
7
7
 
8
8
  import { runBinaryLifecyclePipeline } from "./pipeline.js";
9
- import { writeManifest } from "./manifest.js";
9
+ import { manifestPath, writeManifest } from "./manifest.js";
10
10
  import type { BinaryLifecycleConfig } from "./types.js";
11
11
  import type { BinaryStorageBackend } from "./backend.js";
12
12
 
@@ -129,6 +129,543 @@ test("binary lifecycle blocks cleanup when local hash no longer matches manifest
129
129
  }
130
130
  });
131
131
 
132
+ test("binary lifecycle blocks manifest cleanup paths outside memoryDir", async () => {
133
+ const parentDir = await mkdtemp(path.join(os.tmpdir(), "remnic-binary-escape-parent-"));
134
+ const memoryDir = path.join(parentDir, "memory");
135
+ const victimPath = path.join(parentDir, "victim.png");
136
+ try {
137
+ await mkdir(memoryDir);
138
+ await writeFile(victimPath, "victim", "utf8");
139
+ await writeManifest(memoryDir, {
140
+ version: 1,
141
+ assets: [
142
+ {
143
+ originalPath: "../victim.png",
144
+ mirroredPath: "remote/victim.png",
145
+ contentHash: sha256("victim"),
146
+ sizeBytes: "victim".length,
147
+ mimeType: "image/png",
148
+ mirroredAt: "2026-01-01T00:00:00.000Z",
149
+ redirectedAt: "2026-01-01T00:00:00.000Z",
150
+ status: "redirected",
151
+ },
152
+ ],
153
+ });
154
+
155
+ const result = await runBinaryLifecyclePipeline(memoryDir, baseConfig, noUploadBackend, noopLogger);
156
+ const manifest = JSON.parse(
157
+ await readFile(path.join(memoryDir, ".binary-lifecycle", "manifest.json"), "utf8"),
158
+ ) as { assets: Array<{ originalPath: string; status: string }> };
159
+
160
+ assert.equal(result.cleaned, 0);
161
+ assert.match(result.errors.join("\n"), /manifest path is outside memoryDir/);
162
+ assert.equal(await readFile(victimPath, "utf8"), "victim");
163
+ assert.equal(manifest.assets[0]?.originalPath, "../victim.png");
164
+ assert.equal(manifest.assets[0]?.status, "error");
165
+ } finally {
166
+ await rm(parentDir, { recursive: true, force: true });
167
+ }
168
+ });
169
+
170
+ test("binary lifecycle allows hidden asset names that remain inside memoryDir", async () => {
171
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-binary-hidden-clean-"));
172
+ try {
173
+ await writeFile(path.join(memoryDir, "..hidden.png"), "hidden", "utf8");
174
+ await writeManifest(memoryDir, {
175
+ version: 1,
176
+ assets: [
177
+ {
178
+ originalPath: "..hidden.png",
179
+ mirroredPath: "remote/..hidden.png",
180
+ contentHash: sha256("hidden"),
181
+ sizeBytes: "hidden".length,
182
+ mimeType: "image/png",
183
+ mirroredAt: "2026-01-01T00:00:00.000Z",
184
+ redirectedAt: "2026-01-01T00:00:00.000Z",
185
+ status: "redirected",
186
+ },
187
+ ],
188
+ });
189
+
190
+ const result = await runBinaryLifecyclePipeline(memoryDir, baseConfig, noUploadBackend, noopLogger);
191
+ const manifest = JSON.parse(
192
+ await readFile(path.join(memoryDir, ".binary-lifecycle", "manifest.json"), "utf8"),
193
+ ) as { assets: Array<{ status: string; cleanedAt?: string }> };
194
+
195
+ assert.equal(result.cleaned, 1);
196
+ assert.deepEqual(result.errors, []);
197
+ assert.equal(manifest.assets[0]?.status, "cleaned");
198
+ assert.equal(typeof manifest.assets[0]?.cleanedAt, "string");
199
+ } finally {
200
+ await rm(memoryDir, { recursive: true, force: true });
201
+ }
202
+ });
203
+
204
+ test("binary lifecycle dry-run does not mark missing redirected assets cleaned", async () => {
205
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-binary-dry-clean-"));
206
+ try {
207
+ await writeManifest(memoryDir, {
208
+ version: 1,
209
+ assets: [
210
+ {
211
+ originalPath: "missing.png",
212
+ mirroredPath: "remote/missing.png",
213
+ contentHash: sha256("missing"),
214
+ sizeBytes: "missing".length,
215
+ mimeType: "image/png",
216
+ mirroredAt: "2026-01-01T00:00:00.000Z",
217
+ redirectedAt: "2026-01-01T00:00:00.000Z",
218
+ status: "redirected",
219
+ },
220
+ ],
221
+ });
222
+
223
+ const result = await runBinaryLifecyclePipeline(
224
+ memoryDir,
225
+ baseConfig,
226
+ nestedRemoteBackend,
227
+ noopLogger,
228
+ { dryRun: true },
229
+ );
230
+ const manifest = JSON.parse(
231
+ await readFile(path.join(memoryDir, ".binary-lifecycle", "manifest.json"), "utf8"),
232
+ ) as { assets: Array<{ status: string; cleanedAt?: string }> };
233
+
234
+ assert.equal(result.cleaned, 0);
235
+ assert.equal(manifest.assets[0]?.status, "redirected");
236
+ assert.equal(manifest.assets[0]?.cleanedAt, undefined);
237
+ } finally {
238
+ await rm(memoryDir, { recursive: true, force: true });
239
+ }
240
+ });
241
+
242
+ test("binary lifecycle blocks cleanup when manifest mirroredAt is invalid", async () => {
243
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-binary-invalid-timestamp-"));
244
+ try {
245
+ await writeFile(path.join(memoryDir, "image.png"), "image", "utf8");
246
+ await writeManifest(memoryDir, {
247
+ version: 1,
248
+ assets: [
249
+ {
250
+ originalPath: "image.png",
251
+ mirroredPath: "remote/image.png",
252
+ contentHash: sha256("image"),
253
+ sizeBytes: "image".length,
254
+ mimeType: "image/png",
255
+ mirroredAt: "not-a-date",
256
+ redirectedAt: "2026-01-01T00:00:00.000Z",
257
+ status: "redirected",
258
+ },
259
+ ],
260
+ });
261
+
262
+ const firstResult = await runBinaryLifecyclePipeline(memoryDir, baseConfig, noUploadBackend, noopLogger);
263
+ let manifest = JSON.parse(
264
+ await readFile(path.join(memoryDir, ".binary-lifecycle", "manifest.json"), "utf8"),
265
+ ) as { assets: Array<{ status: string }> };
266
+
267
+ assert.equal(firstResult.cleaned, 0);
268
+ assert.match(firstResult.errors.join("\n"), /manifest mirroredAt is invalid/);
269
+ assert.equal(await readFile(path.join(memoryDir, "image.png"), "utf8"), "image");
270
+ assert.equal(manifest.assets[0]?.status, "error");
271
+
272
+ const secondResult = await runBinaryLifecyclePipeline(memoryDir, baseConfig, noUploadBackend, noopLogger);
273
+ manifest = JSON.parse(
274
+ await readFile(path.join(memoryDir, ".binary-lifecycle", "manifest.json"), "utf8"),
275
+ ) as { assets: Array<{ status: string }> };
276
+
277
+ assert.equal(secondResult.redirected, 0);
278
+ assert.equal(secondResult.cleaned, 0);
279
+ assert.match(secondResult.errors.join("\n"), /manifest mirroredAt is invalid/);
280
+ assert.equal(manifest.assets[0]?.status, "error");
281
+ } finally {
282
+ await rm(memoryDir, { recursive: true, force: true });
283
+ }
284
+ });
285
+
286
+ test("binary lifecycle blocks cleanup when mirrored copy is missing", async () => {
287
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-binary-missing-remote-"));
288
+ try {
289
+ await writeFile(path.join(memoryDir, "image.png"), "image", "utf8");
290
+ await writeManifest(memoryDir, {
291
+ version: 1,
292
+ assets: [
293
+ {
294
+ originalPath: "image.png",
295
+ mirroredPath: "remote/image.png",
296
+ contentHash: sha256("image"),
297
+ sizeBytes: "image".length,
298
+ mimeType: "image/png",
299
+ mirroredAt: "2026-01-01T00:00:00.000Z",
300
+ redirectedAt: "2026-01-01T00:00:00.000Z",
301
+ status: "redirected",
302
+ },
303
+ ],
304
+ });
305
+
306
+ const result = await runBinaryLifecyclePipeline(
307
+ memoryDir,
308
+ baseConfig,
309
+ missingRemoteBackend,
310
+ noopLogger,
311
+ );
312
+ const manifest = JSON.parse(
313
+ await readFile(path.join(memoryDir, ".binary-lifecycle", "manifest.json"), "utf8"),
314
+ ) as { assets: Array<{ status: string; cleanedAt?: string }> };
315
+
316
+ assert.equal(result.cleaned, 0);
317
+ assert.match(result.errors.join("\n"), /mirrored copy is missing/);
318
+ assert.equal(await readFile(path.join(memoryDir, "image.png"), "utf8"), "image");
319
+ assert.equal(manifest.assets[0]?.status, "redirected");
320
+ assert.equal(manifest.assets[0]?.cleanedAt, undefined);
321
+ } finally {
322
+ await rm(memoryDir, { recursive: true, force: true });
323
+ }
324
+ });
325
+
326
+ test("binary lifecycle blocks cleanup when mirrored copy verification fails", async () => {
327
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-binary-remote-error-"));
328
+ try {
329
+ await writeFile(path.join(memoryDir, "image.png"), "image", "utf8");
330
+ await writeManifest(memoryDir, {
331
+ version: 1,
332
+ assets: [
333
+ {
334
+ originalPath: "image.png",
335
+ mirroredPath: "remote/image.png",
336
+ contentHash: sha256("image"),
337
+ sizeBytes: "image".length,
338
+ mimeType: "image/png",
339
+ mirroredAt: "2026-01-01T00:00:00.000Z",
340
+ redirectedAt: "2026-01-01T00:00:00.000Z",
341
+ status: "redirected",
342
+ },
343
+ ],
344
+ });
345
+
346
+ const result = await runBinaryLifecyclePipeline(
347
+ memoryDir,
348
+ baseConfig,
349
+ failingRemoteBackend,
350
+ noopLogger,
351
+ );
352
+ const manifest = JSON.parse(
353
+ await readFile(path.join(memoryDir, ".binary-lifecycle", "manifest.json"), "utf8"),
354
+ ) as { assets: Array<{ status: string; cleanedAt?: string }> };
355
+
356
+ assert.equal(result.cleaned, 0);
357
+ assert.match(result.errors.join("\n"), /failed to verify mirrored copy/);
358
+ assert.equal(await readFile(path.join(memoryDir, "image.png"), "utf8"), "image");
359
+ assert.equal(manifest.assets[0]?.status, "redirected");
360
+ assert.equal(manifest.assets[0]?.cleanedAt, undefined);
361
+ } finally {
362
+ await rm(memoryDir, { recursive: true, force: true });
363
+ }
364
+ });
365
+
366
+ test("binary lifecycle retries partial redirect failures before cleanup", async () => {
367
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-binary-partial-redirect-"));
368
+ const imagePath = path.join(memoryDir, "image.png");
369
+ const firstNote = path.join(memoryDir, "first.md");
370
+ const secondNote = path.join(memoryDir, "second.md");
371
+ try {
372
+ await writeFile(imagePath, "image", "utf8");
373
+ await writeFile(firstNote, "![img](image.png)", "utf8");
374
+ await writeFile(secondNote, "![img](image.png)", "utf8");
375
+ await writeManifest(memoryDir, {
376
+ version: 1,
377
+ assets: [
378
+ {
379
+ originalPath: "image.png",
380
+ mirroredPath: "remote/image.png",
381
+ contentHash: sha256("image"),
382
+ sizeBytes: "image".length,
383
+ mimeType: "image/png",
384
+ mirroredAt: "2026-01-01T00:00:00.000Z",
385
+ status: "mirrored",
386
+ },
387
+ ],
388
+ });
389
+
390
+ const firstResult = await runBinaryLifecyclePipeline(
391
+ memoryDir,
392
+ baseConfig,
393
+ nestedRemoteBackend,
394
+ noopLogger,
395
+ {
396
+ writeMarkdownFile: async (file, data) => {
397
+ if (file === secondNote) {
398
+ throw new Error("injected write failure");
399
+ }
400
+ await writeFile(file, data, "utf8");
401
+ },
402
+ },
403
+ );
404
+ let manifest = JSON.parse(
405
+ await readFile(path.join(memoryDir, ".binary-lifecycle", "manifest.json"), "utf8"),
406
+ ) as { assets: Array<{ status: string }> };
407
+
408
+ assert.equal(firstResult.cleaned, 0);
409
+ assert.match(firstResult.errors.join("\n"), /redirect write failed/);
410
+ assert.equal(await readFile(imagePath, "utf8"), "image");
411
+ assert.equal(await readFile(firstNote, "utf8"), "![img](remote/image.png)");
412
+ assert.equal(await readFile(secondNote, "utf8"), "![img](image.png)");
413
+ assert.equal(manifest.assets[0]?.status, "error");
414
+
415
+ const secondResult = await runBinaryLifecyclePipeline(memoryDir, baseConfig, nestedRemoteBackend, noopLogger);
416
+ manifest = JSON.parse(
417
+ await readFile(path.join(memoryDir, ".binary-lifecycle", "manifest.json"), "utf8"),
418
+ ) as { assets: Array<{ status: string }> };
419
+
420
+ assert.equal(secondResult.errors.length, 0);
421
+ assert.equal(secondResult.redirected, 1);
422
+ assert.equal(secondResult.cleaned, 1);
423
+ assert.equal(await readFile(firstNote, "utf8"), "![img](remote/image.png)");
424
+ assert.equal(await readFile(secondNote, "utf8"), "![img](remote/image.png)");
425
+ assert.equal(manifest.assets[0]?.status, "cleaned");
426
+ } finally {
427
+ await rm(memoryDir, { recursive: true, force: true });
428
+ }
429
+ });
430
+
431
+ test("binary lifecycle rewrites nested asset references before cleanup", async () => {
432
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-binary-nested-redirect-"));
433
+ const assetDir = path.join(memoryDir, "assets");
434
+ const noteDir = path.join(memoryDir, "notes");
435
+ const imagePath = path.join(assetDir, "photo.png");
436
+ const rootNote = path.join(memoryDir, "note.md");
437
+ const nestedNote = path.join(noteDir, "nested.md");
438
+ try {
439
+ await mkdir(assetDir);
440
+ await mkdir(noteDir);
441
+ await writeFile(imagePath, "image", "utf8");
442
+ await writeFile(rootNote, "![img](assets/photo.png)", "utf8");
443
+ await writeFile(
444
+ nestedNote,
445
+ ["![relative](../assets/photo.png)", "![root](/assets/photo.png)"].join("\n"),
446
+ "utf8",
447
+ );
448
+
449
+ const result = await runBinaryLifecyclePipeline(memoryDir, baseConfig, nestedRemoteBackend, noopLogger);
450
+
451
+ assert.equal(result.errors.length, 0);
452
+ assert.equal(result.redirected, 1);
453
+ assert.equal(result.cleaned, 1);
454
+ assert.equal(await readFile(rootNote, "utf8"), "![img](remote/assets/photo.png)");
455
+ assert.equal(
456
+ await readFile(nestedNote, "utf8"),
457
+ ["![relative](remote/assets/photo.png)", "![root](remote/assets/photo.png)"].join("\n"),
458
+ );
459
+ await assert.rejects(() => readFile(imagePath, "utf8"), /ENOENT/);
460
+ } finally {
461
+ await rm(memoryDir, { recursive: true, force: true });
462
+ }
463
+ });
464
+
465
+ test("binary lifecycle rewrites dot-slash hidden asset references before cleanup", async () => {
466
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-binary-hidden-redirect-"));
467
+ try {
468
+ await writeFile(path.join(memoryDir, ".photo.png"), "hidden", "utf8");
469
+ await writeFile(path.join(memoryDir, "note.md"), "![img](./.photo.png)", "utf8");
470
+ await writeManifest(memoryDir, {
471
+ version: 1,
472
+ assets: [
473
+ {
474
+ originalPath: ".photo.png",
475
+ mirroredPath: "remote/.photo.png",
476
+ contentHash: sha256("hidden"),
477
+ sizeBytes: "hidden".length,
478
+ mimeType: "image/png",
479
+ mirroredAt: "2026-01-01T00:00:00.000Z",
480
+ status: "mirrored",
481
+ },
482
+ ],
483
+ });
484
+
485
+ const result = await runBinaryLifecyclePipeline(memoryDir, baseConfig, nestedRemoteBackend, noopLogger);
486
+ const manifest = JSON.parse(
487
+ await readFile(path.join(memoryDir, ".binary-lifecycle", "manifest.json"), "utf8"),
488
+ ) as { assets: Array<{ status: string }> };
489
+
490
+ assert.equal(result.errors.length, 0);
491
+ assert.equal(result.redirected, 1);
492
+ assert.equal(result.cleaned, 1);
493
+ assert.equal(await readFile(path.join(memoryDir, "note.md"), "utf8"), "![img](remote/.photo.png)");
494
+ assert.equal(manifest.assets[0]?.status, "cleaned");
495
+ await assert.rejects(() => readFile(path.join(memoryDir, ".photo.png"), "utf8"), /ENOENT/);
496
+ } finally {
497
+ await rm(memoryDir, { recursive: true, force: true });
498
+ }
499
+ });
500
+
501
+ test("binary lifecycle does not rewrite ambiguous nested bare links for root assets", async () => {
502
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-binary-ambiguous-link-"));
503
+ const subDir = path.join(memoryDir, "sub");
504
+ const nestedNote = path.join(subDir, "note.md");
505
+ try {
506
+ await mkdir(subDir);
507
+ await writeFile(path.join(memoryDir, "image.png"), "root", "utf8");
508
+ await writeFile(path.join(subDir, "image.png"), "nested", "utf8");
509
+ await writeFile(nestedNote, "![img](image.png)", "utf8");
510
+
511
+ const result = await runBinaryLifecyclePipeline(memoryDir, baseConfig, nestedRemoteBackend, noopLogger);
512
+
513
+ assert.equal(result.errors.length, 0);
514
+ assert.equal(result.mirrored, 2);
515
+ assert.equal(result.redirected, 1);
516
+ assert.equal(await readFile(nestedNote, "utf8"), "![img](remote/sub/image.png)");
517
+ } finally {
518
+ await rm(memoryDir, { recursive: true, force: true });
519
+ }
520
+ });
521
+
522
+ test("binary lifecycle resumes errored assets with no remaining local references", async () => {
523
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-binary-resume-error-"));
524
+ try {
525
+ await writeFile(path.join(memoryDir, "image.png"), "image", "utf8");
526
+ await writeFile(path.join(memoryDir, "note.md"), "![img](remote/image.png)", "utf8");
527
+ await writeManifest(memoryDir, {
528
+ version: 1,
529
+ assets: [
530
+ {
531
+ originalPath: "image.png",
532
+ mirroredPath: "remote/image.png",
533
+ contentHash: sha256("image"),
534
+ sizeBytes: "image".length,
535
+ mimeType: "image/png",
536
+ mirroredAt: "2026-01-01T00:00:00.000Z",
537
+ redirectedAt: "2026-01-01T00:00:00.000Z",
538
+ status: "error",
539
+ },
540
+ ],
541
+ });
542
+
543
+ const result = await runBinaryLifecyclePipeline(memoryDir, baseConfig, noUploadBackend, noopLogger);
544
+ const manifest = JSON.parse(
545
+ await readFile(path.join(memoryDir, ".binary-lifecycle", "manifest.json"), "utf8"),
546
+ ) as { assets: Array<{ status: string; cleanedAt?: string }> };
547
+
548
+ assert.equal(result.errors.length, 0);
549
+ assert.equal(result.redirected, 1);
550
+ assert.equal(result.cleaned, 1);
551
+ assert.equal(manifest.assets[0]?.status, "cleaned");
552
+ assert.equal(typeof manifest.assets[0]?.cleanedAt, "string");
553
+ await assert.rejects(() => readFile(path.join(memoryDir, "image.png"), "utf8"), /ENOENT/);
554
+ } finally {
555
+ await rm(memoryDir, { recursive: true, force: true });
556
+ }
557
+ });
558
+
559
+ test("binary lifecycle resumes redirects after verification read failures", async () => {
560
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-binary-verify-read-error-"));
561
+ const notePath = path.join(memoryDir, "note.md");
562
+ let noteReads = 0;
563
+ try {
564
+ await writeFile(path.join(memoryDir, "image.png"), "image", "utf8");
565
+ await writeFile(notePath, "![img](image.png)", "utf8");
566
+
567
+ const firstResult = await runBinaryLifecyclePipeline(
568
+ memoryDir,
569
+ baseConfig,
570
+ nestedRemoteBackend,
571
+ noopLogger,
572
+ {
573
+ readMarkdownFile: async (file) => {
574
+ if (file === notePath && noteReads++ > 0) {
575
+ throw new Error("injected verification read failure");
576
+ }
577
+ return readFile(file, "utf8");
578
+ },
579
+ },
580
+ );
581
+ let manifest = JSON.parse(
582
+ await readFile(path.join(memoryDir, ".binary-lifecycle", "manifest.json"), "utf8"),
583
+ ) as { assets: Array<{ status: string; redirectedAt?: string }> };
584
+
585
+ assert.equal(firstResult.cleaned, 0);
586
+ assert.match(firstResult.errors.join("\n"), /injected verification read failure/);
587
+ assert.equal(await readFile(notePath, "utf8"), "![img](remote/image.png)");
588
+ assert.equal(manifest.assets[0]?.status, "error");
589
+ assert.equal(typeof manifest.assets[0]?.redirectedAt, "string");
590
+
591
+ const secondResult = await runBinaryLifecyclePipeline(memoryDir, baseConfig, nestedRemoteBackend, noopLogger);
592
+ manifest = JSON.parse(
593
+ await readFile(path.join(memoryDir, ".binary-lifecycle", "manifest.json"), "utf8"),
594
+ ) as { assets: Array<{ status: string; cleanedAt?: string }> };
595
+
596
+ assert.equal(secondResult.errors.length, 0);
597
+ assert.equal(secondResult.redirected, 1);
598
+ assert.equal(secondResult.cleaned, 1);
599
+ assert.equal(manifest.assets[0]?.status, "cleaned");
600
+ } finally {
601
+ await rm(memoryDir, { recursive: true, force: true });
602
+ }
603
+ });
604
+
605
+ test("binary lifecycle keeps unreferenced errored assets mirrored-only", async () => {
606
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-binary-error-unreferenced-"));
607
+ try {
608
+ await writeFile(path.join(memoryDir, "image.png"), "image", "utf8");
609
+ await writeManifest(memoryDir, {
610
+ version: 1,
611
+ assets: [
612
+ {
613
+ originalPath: "image.png",
614
+ mirroredPath: "remote/image.png",
615
+ contentHash: sha256("image"),
616
+ sizeBytes: "image".length,
617
+ mimeType: "image/png",
618
+ mirroredAt: "2026-01-01T00:00:00.000Z",
619
+ status: "error",
620
+ },
621
+ ],
622
+ });
623
+
624
+ const result = await runBinaryLifecyclePipeline(memoryDir, baseConfig, noUploadBackend, noopLogger);
625
+ const manifest = JSON.parse(
626
+ await readFile(path.join(memoryDir, ".binary-lifecycle", "manifest.json"), "utf8"),
627
+ ) as { assets: Array<{ status: string; cleanedAt?: string }> };
628
+
629
+ assert.equal(result.errors.length, 0);
630
+ assert.equal(result.redirected, 0);
631
+ assert.equal(result.cleaned, 0);
632
+ assert.equal(manifest.assets[0]?.status, "mirrored");
633
+ assert.equal(manifest.assets[0]?.cleanedAt, undefined);
634
+ assert.equal(await readFile(path.join(memoryDir, "image.png"), "utf8"), "image");
635
+ } finally {
636
+ await rm(memoryDir, { recursive: true, force: true });
637
+ }
638
+ });
639
+
640
+ test("binary lifecycle fails closed without overwriting an invalid manifest", async () => {
641
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-binary-invalid-manifest-"));
642
+ let uploaded = false;
643
+ try {
644
+ await writeFile(path.join(memoryDir, "image.png"), "image", "utf8");
645
+ const mPath = manifestPath(memoryDir);
646
+ await mkdir(path.dirname(mPath), { recursive: true });
647
+ await writeFile(mPath, '{"version":1,"assets":[', "utf8");
648
+ const backend = {
649
+ type: "test",
650
+ upload: async () => {
651
+ uploaded = true;
652
+ return "remote/image.png";
653
+ },
654
+ exists: async () => false,
655
+ delete: async () => {},
656
+ } satisfies BinaryStorageBackend;
657
+
658
+ await assert.rejects(
659
+ () => runBinaryLifecyclePipeline(memoryDir, baseConfig, backend, noopLogger),
660
+ /Invalid binary lifecycle manifest JSON/,
661
+ );
662
+ assert.equal(uploaded, false);
663
+ assert.equal(await readFile(mPath, "utf8"), '{"version":1,"assets":[');
664
+ } finally {
665
+ await rm(memoryDir, { recursive: true, force: true });
666
+ }
667
+ });
668
+
132
669
  const noopLogger = {
133
670
  info: () => {},
134
671
  warn: () => {},
@@ -136,6 +673,15 @@ const noopLogger = {
136
673
  };
137
674
 
138
675
  const noUploadBackend = {
676
+ type: "test",
677
+ upload: async () => {
678
+ throw new Error("upload should not run");
679
+ },
680
+ exists: async () => true,
681
+ delete: async () => {},
682
+ } satisfies BinaryStorageBackend;
683
+
684
+ const missingRemoteBackend = {
139
685
  type: "test",
140
686
  upload: async () => {
141
687
  throw new Error("upload should not run");
@@ -144,6 +690,24 @@ const noUploadBackend = {
144
690
  delete: async () => {},
145
691
  } satisfies BinaryStorageBackend;
146
692
 
693
+ const failingRemoteBackend = {
694
+ type: "test",
695
+ upload: async () => {
696
+ throw new Error("upload should not run");
697
+ },
698
+ exists: async () => {
699
+ throw new Error("remote unavailable");
700
+ },
701
+ delete: async () => {},
702
+ } satisfies BinaryStorageBackend;
703
+
704
+ const nestedRemoteBackend = {
705
+ type: "test",
706
+ upload: async (_localPath: string, remotePath: string) => `remote/${remotePath}`,
707
+ exists: async () => true,
708
+ delete: async () => {},
709
+ } satisfies BinaryStorageBackend;
710
+
147
711
  function sha256(value: string): string {
148
712
  return crypto.createHash("sha256").update(value).digest("hex");
149
713
  }