@@ -819,49 +819,102 @@ describe('releases/github', () => {
819819 } , 40_000 )
820820 } )
821821
822- describe ( 'downloadGitHubRelease - TOCTOU race protection' , ( ) => {
822+ // .sequential is required because vitest's config sets
823+ // `concurrent: !process.env.CI` (.config/vitest.config.mts). These
824+ // tests share the module-level httpDownload / httpRequest mocks so
825+ // running them in parallel lets one test's call pollute another's
826+ // `toHaveBeenCalledTimes` assertion. Pattern borrowed from
827+ // test/unit/logger-advanced.test.mts which disables concurrency for
828+ // the same reason.
829+ describe . sequential ( 'downloadGitHubRelease - TOCTOU race protection' , ( ) => {
830+ // Pre-populate the on-disk cache state synchronously before calling
831+ // downloadGitHubRelease, so the function under test can only
832+ // observe the state the test prepared. httpDownload is either
833+ // asserted-never-called (cache-hit path) or mocked as a trivial
834+ // no-op that writes the missing file for the re-download path.
835+ // No mock side effects interleave with the call under test.
836+
837+ // All state (temp dir, imports) lives inside each `it` block so
838+ // nothing leaks across tests under `isolate: false`. The previous
839+ // rewrite used `let testDir` at the describe scope and flaked
840+ // because that single binding got overwritten by the next
841+ // test's beforeEach while vitest was still reporting the first
842+ // test's assertion. Using fully local state makes that impossible.
823843 beforeEach ( ( ) => {
824844 vi . clearAllMocks ( )
825845 } )
826846
827- it ( 'should re-check binary existence after reading version file (TOCTOU protection)' , async ( ) => {
828- // This tests the fix for the TOCTOU (Time-of-check-time-of-use) race condition
829- // in releases/github.ts:214-226
830- //
831- // Scenario:
832- // 1. Check version file exists and matches tag
833- // 2. Read version file
834- // 3. Binary gets deleted (by cleanup or concurrent process)
835- // 4. Try to use binary -> FAILURE
836- //
837- // Fix: Re-check binary existence after reading version file
838-
847+ it ( 'uses cache and does not call httpDownload when binary + version file exist and tag matches' , async ( ) => {
839848 const { downloadGitHubRelease } =
840849 await import ( '../../src/releases/github' )
841- const { tmpdir } = await import ( 'node:os' )
842850 const { promises : fs } = await import ( 'node:fs' )
843- const path = await import ( 'node:path' )
844-
845- // mkdtemp gives an OS-guaranteed unique dir. Previous `Date.now()`
846- // naming collided across test runs on the same millisecond and
847- // across parallel vitest workers, occasionally letting a prior
848- // run's cached .version + binary satisfy the cache-hit branch
849- // inside downloadGitHubRelease — so `httpDownload` was called 0
850- // times and the assertion below saw the wrong count.
851- const testDownloadDir = await fs . mkdtemp (
852- path . join ( tmpdir ( ) , 'test-github-dl-' ) ,
851+ const { tmpdir } = await import ( 'node:os' )
852+ const nodePath = await import ( 'node:path' )
853+
854+ const testDir = await fs . mkdtemp (
855+ nodePath . join ( tmpdir ( ) , 'test-github-dl-' ) ,
853856 )
857+ try {
858+ // Pre-populate the cache exactly as a prior successful download
859+ // would have left it: binary + .version file with matching tag.
860+ // downloadGitHubRelease must short-circuit to the cached binary
861+ // path without touching httpDownload.
862+ const binaryFile = nodePath . join ( testDir , 'test-bin' )
863+ const versionFile = nodePath . join ( testDir , '.version' )
864+ await fs . writeFile ( binaryFile , '#!/bin/bash\necho "test"' , 'utf8' )
865+ await fs . writeFile ( versionFile , 'v1.0.0' , 'utf8' )
866+
867+ const result = await downloadGitHubRelease ( {
868+ assetName : 'test-binary' ,
869+ binaryName : 'test-bin' ,
870+ downloadDir : testDir ,
871+ owner : 'test-owner' ,
872+ platformArch : 'test-arch' ,
873+ quiet : true ,
874+ repo : 'test-repo' ,
875+ tag : 'v1.0.0' ,
876+ toolName : 'test-tool' ,
877+ } )
878+
879+ expect ( result ) . toBe ( binaryFile )
880+ expect ( httpDownload ) . not . toHaveBeenCalled ( )
881+ expect ( httpRequest ) . not . toHaveBeenCalled ( )
882+ expect ( existsSync ( binaryFile ) ) . toBe ( true )
883+ expect ( existsSync ( versionFile ) ) . toBe ( true )
884+ } finally {
885+ await fs . rm ( testDir , { force : true , recursive : true } ) . catch ( ( ) => { } )
886+ }
887+ } )
854888
889+ it ( 're-downloads when version file exists but binary is missing (TOCTOU recovery)' , async ( ) => {
890+ const { downloadGitHubRelease } =
891+ await import ( '../../src/releases/github' )
892+ const { promises : fs } = await import ( 'node:fs' )
893+ const { tmpdir } = await import ( 'node:os' )
894+ const nodePath = await import ( 'node:path' )
895+
896+ const testDir = await fs . mkdtemp (
897+ nodePath . join ( tmpdir ( ) , 'test-github-dl-missing-' ) ,
898+ )
855899 try {
856- // Mock successful HTTP download
857- vi . mocked ( httpRequest ) . mockResolvedValue (
900+ // The TOCTOU recovery path: version file claims the right tag
901+ // is cached, but the binary was removed (by OS cleanup, another
902+ // process, manual rm, etc.). The second existsSync check inside
903+ // downloadGitHubRelease must detect the missing binary after
904+ // reading the version file and fall through to re-download.
905+ const binaryFile = nodePath . join ( testDir , 'test-bin' )
906+ const versionFile = nodePath . join ( testDir , '.version' )
907+ await fs . writeFile ( versionFile , 'v1.0.0' , 'utf8' )
908+ // Intentionally do NOT create binaryFile.
909+
910+ vi . mocked ( httpRequest ) . mockResolvedValueOnce (
858911 createMockHttpResponse (
859912 Buffer . from (
860913 JSON . stringify ( {
861914 assets : [
862915 {
863- name : 'test-binary' ,
864916 browser_download_url : 'https://example.com/binary' ,
917+ name : 'test-binary' ,
865918 } ,
866919 ] ,
867920 tag_name : 'v1.0.0' ,
@@ -871,54 +924,24 @@ describe('releases/github', () => {
871924 200 ,
872925 ) ,
873926 )
874- // Mock httpDownload to actually create the file
875- vi . mocked ( httpDownload ) . mockImplementation ( async ( _url , outputPath ) => {
876- // Create directory if it doesn't exist
877- const dir = path . dirname ( outputPath )
878- await fs . mkdir ( dir , { recursive : true } )
879- // Create a dummy binary file
880- const content = '#!/bin/bash\necho "test"'
881- await fs . writeFile ( outputPath , content , 'utf8' )
882- return {
883- headers : { } ,
884- ok : true as const ,
885- path : outputPath ,
886- size : content . length ,
887- status : 200 ,
888- statusText : 'OK' ,
889- }
890- } )
891-
892- // First download - creates cache
893- const result1 = await downloadGitHubRelease ( {
894- assetName : 'test-binary' ,
895- binaryName : 'test-bin' ,
896- downloadDir : testDownloadDir ,
897- owner : 'test-owner' ,
898- platformArch : 'test-arch' ,
899- quiet : true ,
900- repo : 'test-repo' ,
901- tag : 'v1.0.0' ,
902- toolName : 'test-tool' ,
903- } )
904-
905- expect ( result1 ) . toBeDefined ( )
906- expect ( result1 ) . toContain ( 'test-bin' )
907-
908- // Verify version file and binary exist
909- const binaryDir = testDownloadDir
910- const versionFile = path . join ( binaryDir , '.version' )
911- const binaryFile = path . join ( binaryDir , 'test-bin' )
912-
913- // Both should exist after first download
914- expect ( existsSync ( versionFile ) ) . toBe ( true )
915- expect ( existsSync ( binaryFile ) ) . toBe ( true )
927+ vi . mocked ( httpDownload ) . mockImplementationOnce (
928+ async ( _url , outputPath ) => {
929+ await fs . writeFile ( outputPath , '#!/bin/bash\necho "test"' , 'utf8' )
930+ return {
931+ headers : { } ,
932+ ok : true as const ,
933+ path : outputPath ,
934+ size : 22 ,
935+ status : 200 ,
936+ statusText : 'OK' ,
937+ }
938+ } ,
939+ )
916940
917- // Second call - should use cache
918- const result2 = await downloadGitHubRelease ( {
941+ const result = await downloadGitHubRelease ( {
919942 assetName : 'test-binary' ,
920943 binaryName : 'test-bin' ,
921- downloadDir : testDownloadDir ,
944+ downloadDir : testDir ,
922945 owner : 'test-owner' ,
923946 platformArch : 'test-arch' ,
924947 quiet : true ,
@@ -927,42 +950,41 @@ describe('releases/github', () => {
927950 toolName : 'test-tool' ,
928951 } )
929952
930- expect ( result2 ) . toBe ( result1 )
931- // httpDownload should only be called once (first download)
953+ expect ( result ) . toBe ( binaryFile )
932954 expect ( httpDownload ) . toHaveBeenCalledTimes ( 1 )
955+ expect ( existsSync ( binaryFile ) ) . toBe ( true )
933956 } finally {
934- // Cleanup
935- try {
936- await fs . rm ( testDownloadDir , { force : true , recursive : true } )
937- } catch {
938- // Ignore cleanup errors
939- }
957+ await fs . rm ( testDir , { force : true , recursive : true } ) . catch ( ( ) => { } )
940958 }
941- } , 40_000 )
959+ } )
942960
943- it ( 'should re-download if binary is missing despite version file existing ' , async ( ) => {
961+ it ( 're-downloads when version file tag does not match requested tag ' , async ( ) => {
944962 const { downloadGitHubRelease } =
945963 await import ( '../../src/releases/github' )
946- const { tmpdir } = await import ( 'node:os' )
947964 const { promises : fs } = await import ( 'node:fs' )
948- const path = await import ( 'node:path' )
965+ const { tmpdir } = await import ( 'node:os' )
966+ const nodePath = await import ( 'node:path' )
949967
950- // mkdtemp avoids collisions with prior test runs' leftover cache
951- // dirs. See note on the sibling test above.
952- const testDownloadDir = await fs . mkdtemp (
953- path . join ( tmpdir ( ) , 'test-github-dl-missing-' ) ,
968+ const testDir = await fs . mkdtemp (
969+ nodePath . join ( tmpdir ( ) , 'test-github-dl-stale-' ) ,
954970 )
955-
956971 try {
957- // Mock successful HTTP download
958- vi . mocked ( httpRequest ) . mockResolvedValue (
972+ // Cache-invalidation path: both files present but .version
973+ // says a different tag than the caller asked for. Must fall
974+ // through to re-download.
975+ const binaryFile = nodePath . join ( testDir , 'test-bin' )
976+ const versionFile = nodePath . join ( testDir , '.version' )
977+ await fs . writeFile ( binaryFile , 'stale-binary' , 'utf8' )
978+ await fs . writeFile ( versionFile , 'v0.9.0' , 'utf8' )
979+
980+ vi . mocked ( httpRequest ) . mockResolvedValueOnce (
959981 createMockHttpResponse (
960982 Buffer . from (
961983 JSON . stringify ( {
962984 assets : [
963985 {
964- name : 'test-binary' ,
965986 browser_download_url : 'https://example.com/binary' ,
987+ name : 'test-binary' ,
966988 } ,
967989 ] ,
968990 tag_name : 'v1.0.0' ,
@@ -972,50 +994,24 @@ describe('releases/github', () => {
972994 200 ,
973995 ) ,
974996 )
975- // Mock httpDownload to actually create the file
976- vi . mocked ( httpDownload ) . mockImplementation ( async ( _url , outputPath ) => {
977- // Create directory if it doesn't exist
978- const dir = path . dirname ( outputPath )
979- await fs . mkdir ( dir , { recursive : true } )
980- // Create a dummy binary file
981- const content = '#!/bin/bash\necho "test"'
982- await fs . writeFile ( outputPath , content , 'utf8' )
983- return {
984- headers : { } ,
985- ok : true as const ,
986- path : outputPath ,
987- size : content . length ,
988- status : 200 ,
989- statusText : 'OK' ,
990- }
991- } )
992-
993- // First download
994- await downloadGitHubRelease ( {
995- assetName : 'test-binary' ,
996- binaryName : 'test-bin' ,
997- downloadDir : testDownloadDir ,
998- owner : 'test-owner' ,
999- platformArch : 'test-arch' ,
1000- quiet : true ,
1001- repo : 'test-repo' ,
1002- tag : 'v1.0.0' ,
1003- toolName : 'test-tool' ,
1004- } )
1005-
1006- // Simulate TOCTOU scenario: delete binary but leave version file
1007- const binaryDir = testDownloadDir
1008- const binaryFile = path . join ( binaryDir , 'test-bin' )
1009- await fs . unlink ( binaryFile )
1010-
1011- // Reset mock call count
1012- vi . mocked ( httpDownload ) . mockClear ( )
997+ vi . mocked ( httpDownload ) . mockImplementationOnce (
998+ async ( _url , outputPath ) => {
999+ await fs . writeFile ( outputPath , 'fresh-binary' , 'utf8' )
1000+ return {
1001+ headers : { } ,
1002+ ok : true as const ,
1003+ path : outputPath ,
1004+ size : 12 ,
1005+ status : 200 ,
1006+ statusText : 'OK' ,
1007+ }
1008+ } ,
1009+ )
10131010
1014- // Second call - should detect missing binary and re-download
10151011 await downloadGitHubRelease ( {
10161012 assetName : 'test-binary' ,
10171013 binaryName : 'test-bin' ,
1018- downloadDir : testDownloadDir ,
1014+ downloadDir : testDir ,
10191015 owner : 'test-owner' ,
10201016 platformArch : 'test-arch' ,
10211017 quiet : true ,
@@ -1024,16 +1020,12 @@ describe('releases/github', () => {
10241020 toolName : 'test-tool' ,
10251021 } )
10261022
1027- // Should download again due to missing binary
10281023 expect ( httpDownload ) . toHaveBeenCalledTimes ( 1 )
1024+ // Cache updated to the new tag.
1025+ expect ( await fs . readFile ( versionFile , 'utf8' ) ) . toBe ( 'v1.0.0' )
10291026 } finally {
1030- // Cleanup
1031- try {
1032- await fs . rm ( testDownloadDir , { force : true , recursive : true } )
1033- } catch {
1034- // Ignore cleanup errors
1035- }
1027+ await fs . rm ( testDir , { force : true , recursive : true } ) . catch ( ( ) => { } )
10361028 }
1037- } , 40_000 )
1029+ } )
10381030 } )
10391031} )
0 commit comments