55 mkdtempSync ,
66 readFileSync ,
77 rmSync ,
8+ statSync ,
89 symlinkSync ,
910 writeFileSync ,
1011} from "node:fs" ;
@@ -20,9 +21,11 @@ import {
2021 materializePlanV2Target ,
2122 PLAN_V2_INTEGRITY_UNRECOVERABLE ,
2223 PlanV2IntegrityError ,
24+ publishPlanV2FromV1 ,
2325 readPlanV2Manifest ,
2426 validatePlanV2MaterializedTarget ,
2527} from "./planV2.js" ;
28+ import { LocalPlanV2ArtifactPublisher , type PlanV2ArtifactPublisher } from "./planV2Publisher.js" ;
2629
2730const tempDirs : string [ ] = [ ] ;
2831
@@ -399,6 +402,149 @@ describe("Plan v2 manifest", () => {
399402 } ) ;
400403} ) ;
401404
405+ describe ( "Plan v2 artifact publisher" , ( ) => {
406+ it ( "publishes the manifest last and hard-links local immutable blobs" , async ( ) => {
407+ const root = tempPath ( "hf-plan-v2-publisher-" ) ;
408+ const v1 = createV1Plan ( root , { audio : true } ) ;
409+ const destination = join ( root , "v2" ) ;
410+ const publisher = new LocalPlanV2ArtifactPublisher ( destination ) ;
411+ const manifest = await publishPlanV2FromV1 ( v1 , publisher ) ;
412+ const artifact = manifest . artifacts . find (
413+ ( candidate ) => candidate . path === "compiled/asset.txt" ,
414+ ) ;
415+ if ( artifact === undefined ) throw new Error ( "test fixture is missing compiled/asset.txt" ) ;
416+ const sourceStat = statSync ( join ( v1 , artifact . path ) ) ;
417+ const blobStat = statSync (
418+ join ( destination , "artifacts" , "sha256" , artifact . sha256 . slice ( 0 , 2 ) , artifact . sha256 ) ,
419+ ) ;
420+
421+ expect ( readPlanV2Manifest ( destination ) ) . toEqual ( manifest ) ;
422+ expect ( { dev : blobStat . dev , ino : blobStat . ino } ) . toEqual ( {
423+ dev : sourceStat . dev ,
424+ ino : sourceStat . ino ,
425+ } ) ;
426+ } ) ;
427+
428+ it ( "falls back to an atomic copy when hard-linking is unavailable" , async ( ) => {
429+ const root = tempPath ( "hf-plan-v2-publisher-copy-" ) ;
430+ const v1 = createV1Plan ( root ) ;
431+ const destination = join ( root , "v2" ) ;
432+ const publisher = new LocalPlanV2ArtifactPublisher ( destination , {
433+ linkFile ( ) {
434+ throw Object . assign ( new Error ( "cross-device link" ) , { code : "EXDEV" } ) ;
435+ } ,
436+ } ) ;
437+ const manifest = await publishPlanV2FromV1 ( v1 , publisher ) ;
438+ const artifact = manifest . artifacts . find (
439+ ( candidate ) => candidate . path === "compiled/asset.txt" ,
440+ ) ;
441+ if ( artifact === undefined ) throw new Error ( "test fixture is missing compiled/asset.txt" ) ;
442+ const sourcePath = join ( v1 , artifact . path ) ;
443+ const blobPath = join (
444+ destination ,
445+ "artifacts" ,
446+ "sha256" ,
447+ artifact . sha256 . slice ( 0 , 2 ) ,
448+ artifact . sha256 ,
449+ ) ;
450+
451+ expect ( readFileSync ( blobPath ) ) . toEqual ( readFileSync ( sourcePath ) ) ;
452+ expect ( { dev : statSync ( blobPath ) . dev , ino : statSync ( blobPath ) . ino } ) . not . toEqual ( {
453+ dev : statSync ( sourcePath ) . dev ,
454+ ino : statSync ( sourcePath ) . ino ,
455+ } ) ;
456+ } ) ;
457+
458+ it ( "produces byte-identical local CAS output through both publication paths" , async ( ) => {
459+ const root = tempPath ( "hf-plan-v2-publisher-parity-" ) ;
460+ const v1 = createV1Plan ( root , { audio : true } ) ;
461+ const directDir = join ( root , "direct" ) ;
462+ const publishedDir = join ( root , "published" ) ;
463+ createPlanV2FromV1 ( v1 , directDir ) ;
464+ const publisher = new LocalPlanV2ArtifactPublisher ( publishedDir ) ;
465+ const manifest = await publishPlanV2FromV1 ( v1 , publisher ) ;
466+
467+ expect ( readFileSync ( join ( publishedDir , "plan.json" ) ) ) . toEqual (
468+ readFileSync ( join ( directDir , "plan.json" ) ) ,
469+ ) ;
470+ for ( const artifact of manifest . artifacts ) {
471+ const suffix = join ( "artifacts" , "sha256" , artifact . sha256 . slice ( 0 , 2 ) , artifact . sha256 ) ;
472+ expect ( readFileSync ( join ( publishedDir , suffix ) ) ) . toEqual (
473+ readFileSync ( join ( directDir , suffix ) ) ,
474+ ) ;
475+ }
476+ } ) ;
477+
478+ it ( "supports a remote publisher contract with no shared destination filesystem" , async ( ) => {
479+ const root = tempPath ( "hf-plan-v2-remote-publisher-" ) ;
480+ const v1 = createV1Plan ( root , { audio : true } ) ;
481+ const blobs = new Map < string , Buffer > ( ) ;
482+ let committedManifest : string | undefined ;
483+ const publisher : PlanV2ArtifactPublisher = {
484+ async putBlob ( blob ) {
485+ blobs . set ( blob . sha256 , readFileSync ( blob . sourcePath ) ) ;
486+ } ,
487+ async commitManifest ( manifestBytes ) {
488+ committedManifest = manifestBytes ;
489+ } ,
490+ async abort ( ) { } ,
491+ } ;
492+
493+ const manifest = await publishPlanV2FromV1 ( v1 , publisher ) ;
494+ expect ( committedManifest ) . toBe ( canonicalJsonStringify ( manifest ) ) ;
495+ expect ( blobs . size ) . toBe ( new Set ( manifest . artifacts . map ( ( artifact ) => artifact . sha256 ) ) . size ) ;
496+ for ( const artifact of manifest . artifacts ) {
497+ expect ( blobs . get ( artifact . sha256 ) ?. byteLength ) . toBe ( artifact . sizeBytes ) ;
498+ }
499+ } ) ;
500+
501+ it ( "rejects malformed digests before constructing a local CAS path" , async ( ) => {
502+ const root = tempPath ( "hf-plan-v2-publisher-digest-" ) ;
503+ const sourcePath = join ( root , "source" ) ;
504+ writeFileSync ( sourcePath , "bytes" ) ;
505+ const publisher = new LocalPlanV2ArtifactPublisher ( join ( root , "v2" ) ) ;
506+
507+ await expect (
508+ publisher . putBlob ( { sourcePath, sha256 : "../escape" , sizeBytes : 5 } ) ,
509+ ) . rejects . toThrow ( "must be a lowercase sha256 digest" ) ;
510+ await publisher . abort ( ) ;
511+ } ) ;
512+
513+ it ( "refuses to commit a manifest until every referenced blob is durable" , async ( ) => {
514+ const root = tempPath ( "hf-plan-v2-publisher-incomplete-" ) ;
515+ const publisher = new LocalPlanV2ArtifactPublisher ( join ( root , "v2" ) ) ;
516+ const digest = "a" . repeat ( 64 ) ;
517+
518+ await expect (
519+ publisher . commitManifest ( JSON . stringify ( { artifacts : [ { sha256 : digest } ] } ) ) ,
520+ ) . rejects . toThrow ( "cannot commit manifest before referenced blob is durable" ) ;
521+ await publisher . abort ( ) ;
522+ } ) ;
523+
524+ it ( "aborts without committing a manifest when a blob publish fails" , async ( ) => {
525+ const root = tempPath ( "hf-plan-v2-publisher-failure-" ) ;
526+ const calls : string [ ] = [ ] ;
527+ const publisher : PlanV2ArtifactPublisher = {
528+ async putBlob ( blob ) {
529+ calls . push ( `blob:${ blob . sha256 } ` ) ;
530+ throw new Error ( "injected blob failure" ) ;
531+ } ,
532+ async commitManifest ( ) {
533+ calls . push ( "manifest" ) ;
534+ } ,
535+ async abort ( ) {
536+ calls . push ( "abort" ) ;
537+ } ,
538+ } ;
539+
540+ await expect ( publishPlanV2FromV1 ( createV1Plan ( root ) , publisher ) ) . rejects . toThrow (
541+ "injected blob failure" ,
542+ ) ;
543+ expect ( calls . at ( - 1 ) ) . toBe ( "abort" ) ;
544+ expect ( calls ) . not . toContain ( "manifest" ) ;
545+ } ) ;
546+ } ) ;
547+
402548describe ( "Plan v2 hash schema" , ( ) => {
403549 it ( "does not reuse a raw artifact digest as its manifest hash" , ( ) => {
404550 const root = tempPath ( "hf-plan-v2-hash-" ) ;
0 commit comments