@@ -378,3 +378,152 @@ describe("downloadPackage", () => {
378378 }
379379 } ) ;
380380} ) ;
381+
382+ // ---------------------------------------------------------------------------
383+ // Adversarial regression tests (DD-79)
384+ // ---------------------------------------------------------------------------
385+
386+ describe ( "adversarial: SHA-256 tamper detection" , ( ) => {
387+ test ( "rejects install when registry returns tampered hash" , async ( ) => {
388+ // Scenario: registry is compromised and returns a different SHA-256
389+ // than what the artifact actually contains. arc must reject because
390+ // it recomputes the hash from the downloaded bytes independently.
391+ const realContent = "legitimate package content" ;
392+ const filePath = join ( env . paths . reposDir , "tampered-hash.tar.gz" ) ;
393+ await writeFile ( filePath , realContent ) ;
394+
395+ // Registry claims a hash that doesn't match the actual file
396+ const tamperedHash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" ;
397+
398+ const result = await verifyChecksum ( filePath , tamperedHash ) ;
399+ expect ( result . valid ) . toBe ( false ) ;
400+ expect ( result . expected ) . toBe ( tamperedHash ) ;
401+ expect ( result . actual ) . not . toBe ( tamperedHash ) ;
402+ } ) ;
403+
404+ test ( "rejects when artifact is replaced with different content but same size" , async ( ) => {
405+ // Scenario: attacker replaces artifact with malicious payload of the
406+ // same byte length. SHA-256 must catch this.
407+ const originalContent = "AAAA" ; // 4 bytes
408+ const maliciousContent = "BBBB" ; // 4 bytes — same length
409+
410+ // Compute hash of original
411+ const hasher = new Bun . CryptoHasher ( "sha256" ) ;
412+ hasher . update ( originalContent ) ;
413+ const originalHash = hasher . digest ( "hex" ) ;
414+
415+ // Write the malicious content but verify against original hash
416+ const filePath = join ( env . paths . reposDir , "swapped-same-size.bin" ) ;
417+ await writeFile ( filePath , maliciousContent ) ;
418+
419+ const result = await verifyChecksum ( filePath , originalHash ) ;
420+ expect ( result . valid ) . toBe ( false ) ;
421+ expect ( result . actual ) . not . toBe ( originalHash ) ;
422+ } ) ;
423+
424+ test ( "rejects truncated artifact" , async ( ) => {
425+ // Scenario: artifact is truncated mid-transfer. The partial file has
426+ // a different SHA-256 than the complete artifact.
427+ const fullContent = "full package content with many bytes of data" ;
428+ const truncatedContent = "full pack" ; // cut mid-stream
429+
430+ // Hash of the full content is what the registry advertises
431+ const hasher = new Bun . CryptoHasher ( "sha256" ) ;
432+ hasher . update ( fullContent ) ;
433+ const fullHash = hasher . digest ( "hex" ) ;
434+
435+ // Write truncated version and verify against full hash
436+ const filePath = join ( env . paths . reposDir , "truncated.tar.gz" ) ;
437+ await writeFile ( filePath , truncatedContent ) ;
438+
439+ const result = await verifyChecksum ( filePath , fullHash ) ;
440+ expect ( result . valid ) . toBe ( false ) ;
441+ expect ( result . actual ) . not . toBe ( fullHash ) ;
442+ } ) ;
443+
444+ test ( "rejects artifact with appended bytes" , async ( ) => {
445+ // Scenario: attacker appends malicious payload to a valid artifact
446+ const originalContent = "valid package" ;
447+ const tamperedContent = "valid package\x00MALICIOUS_PAYLOAD" ;
448+
449+ const hasher = new Bun . CryptoHasher ( "sha256" ) ;
450+ hasher . update ( originalContent ) ;
451+ const originalHash = hasher . digest ( "hex" ) ;
452+
453+ const filePath = join ( env . paths . reposDir , "appended.tar.gz" ) ;
454+ await writeFile ( filePath , tamperedContent ) ;
455+
456+ const result = await verifyChecksum ( filePath , originalHash ) ;
457+ expect ( result . valid ) . toBe ( false ) ;
458+ } ) ;
459+ } ) ;
460+
461+ describe ( "adversarial: download path error handling" , ( ) => {
462+ test ( "download returns error on server 500 after retries" , async ( ) => {
463+ const originalFetch = globalThis . fetch ;
464+ let attempts = 0 ;
465+ globalThis . fetch = mockFetch ( async ( ) => {
466+ attempts ++ ;
467+ return new Response ( "Internal Server Error" , { status : 500 } ) ;
468+ } ) ;
469+
470+ try {
471+ const result = await downloadPackage ( "https://example.com/pkg.tar.gz" , env . paths . reposDir ) ;
472+ expect ( result . success ) . toBe ( false ) ;
473+ expect ( result . error ) . toContain ( "500" ) ;
474+ expect ( attempts ) . toBe ( 2 ) ; // original + 1 retry
475+ } finally {
476+ globalThis . fetch = originalFetch ;
477+ }
478+ } ) ;
479+
480+ test ( "download does not write partial file on failure" , async ( ) => {
481+ const originalFetch = globalThis . fetch ;
482+ globalThis . fetch = mockFetch ( async ( ) => new Response ( "Forbidden" , { status : 403 } ) ) ;
483+
484+ try {
485+ const result = await downloadPackage ( "https://example.com/pkg.tar.gz" , env . paths . reposDir ) ;
486+ expect ( result . success ) . toBe ( false ) ;
487+ // No tempPath should be returned on failure
488+ expect ( result . tempPath ) . toBeUndefined ( ) ;
489+ // Also verify no arc-download-* files leaked to disk
490+ const { readdirSync } = await import ( "fs" ) ;
491+ const leaked = readdirSync ( env . paths . reposDir ) . filter ( ( f : string ) => f . startsWith ( "arc-download-" ) ) ;
492+ expect ( leaked ) . toHaveLength ( 0 ) ;
493+ } finally {
494+ globalThis . fetch = originalFetch ;
495+ }
496+ } ) ;
497+ } ) ;
498+
499+ describe ( "adversarial: extract path integrity" , ( ) => {
500+ test ( "extraction cleans up on invalid tarball — no partial files left" , async ( ) => {
501+ const badTarball = join ( env . paths . reposDir , "adversarial-bad.tar.gz" ) ;
502+ await writeFile ( badTarball , "this is not a valid tarball at all" ) ;
503+
504+ const extractDir = "adversarial-test-pkg" ;
505+ const result = await extractPackage ( badTarball , env . paths . reposDir , extractDir ) ;
506+
507+ expect ( result . success ) . toBe ( false ) ;
508+ // The target directory should be cleaned up after failed extraction
509+ const { existsSync } = await import ( "fs" ) ;
510+ expect ( existsSync ( join ( env . paths . reposDir , extractDir ) ) ) . toBe ( false ) ;
511+ } ) ;
512+
513+ test ( "extraction rejects archive without manifest" , async ( ) => {
514+ // Create a valid tarball but without arc-manifest.yaml
515+ const tarDir = join ( env . paths . reposDir , "no-manifest-src" ) ;
516+ const { mkdir : mkdirFs } = await import ( "fs/promises" ) ;
517+ await mkdirFs ( tarDir , { recursive : true } ) ;
518+ await writeFile ( join ( tarDir , "README.md" ) , "# No manifest here" ) ;
519+
520+ const tarball = join ( env . paths . reposDir , "no-manifest.tar.gz" ) ;
521+ Bun . spawnSync ( [ "tar" , "czf" , tarball , "-C" , env . paths . reposDir , "no-manifest-src" ] , {
522+ stdout : "pipe" , stderr : "pipe" ,
523+ } ) ;
524+
525+ const result = await extractPackage ( tarball , env . paths . reposDir , "no-manifest-pkg" ) ;
526+ expect ( result . success ) . toBe ( false ) ;
527+ expect ( result . error ) . toContain ( "arc-manifest.yaml" ) ;
528+ } ) ;
529+ } ) ;
0 commit comments