diff --git a/apps/desktop/scripts/ci/hydrate-velopack-history.mjs b/apps/desktop/scripts/ci/hydrate-velopack-history.mjs index b8107f74..49bc918f 100644 --- a/apps/desktop/scripts/ci/hydrate-velopack-history.mjs +++ b/apps/desktop/scripts/ci/hydrate-velopack-history.mjs @@ -164,10 +164,27 @@ function updateAssetUrl(product, fileName) { return `${product.services.updates.baseUrl.replace(/\/+$/g, '')}/${encodeURIComponent(fileName)}`; } +function isSafeAssetFileName(fileName) { + if ( + typeof fileName !== 'string' || + fileName.length === 0 || + fileName.trim() !== fileName || + // eslint-disable-next-line no-control-regex -- Asset names must reject ASCII control characters. + /[<>:"/\\|?*\u0000-\u001f]/u.test(fileName) || + /[. ]$/u.test(fileName) + ) { + return false; + } + + const stem = fileName.split('.')[0]?.toUpperCase(); + return !/^(CON|PRN|AUX|NUL|COM[1-9¹²³]|LPT[1-9¹²³])$/u.test(stem ?? ''); +} + function isVelopackPackage(asset) { return ( asset && typeof asset.FileName === 'string' && + isSafeAssetFileName(asset.FileName) && asset.FileName.toLowerCase().endsWith('.nupkg') ); } @@ -189,9 +206,14 @@ export async function hydrateVelopackHistory(projectRoot, releaseDir, channel) { const feedText = await feedResponse.text(); const feed = pruneFeedAssets(JSON.parse(feedText), await retainedHistoryTags(product, channel)); - await writeFile(join(releaseDir, feedName), `${JSON.stringify(feed, null, 4)}\n`, 'utf8'); - const assets = Array.isArray(feed.Assets) ? feed.Assets.filter(isVelopackPackage) : []; + const hydratedFeed = Array.isArray(feed.Assets) ? { ...feed, Assets: assets } : feed; + await writeFile( + join(releaseDir, feedName), + `${JSON.stringify(hydratedFeed, null, 4)}\n`, + 'utf8' + ); + for (const asset of assets) { const outputPath = join(releaseDir, asset.FileName); if (await fileExists(outputPath)) { diff --git a/apps/desktop/tests/ci/hydrate-velopack-history.test.ts b/apps/desktop/tests/ci/hydrate-velopack-history.test.ts index a7fe3668..8da71485 100644 --- a/apps/desktop/tests/ci/hydrate-velopack-history.test.ts +++ b/apps/desktop/tests/ci/hydrate-velopack-history.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -61,6 +61,196 @@ async function exists(path: string) { } describe('hydrateVelopackHistory', () => { + it('hydrates only safe package file names from retained releases', async () => { + const hydrateVelopackHistory = await loadHydrator(); + const product = productWithNightlyRetention(1); + const root = await createFixture(product); + const releaseDir = join(root, 'release'); + const feedUrl = `${product.services.updates.baseUrl}/releases.nightly.json`; + const version = '0.3.0-nightly.20260523.3'; + const safeFileName = `TouchAI-nightly-${version}-windows-full.nupkg`; + const unsafeFileName = '../escape.nupkg'; + const feed = { + Assets: [ + { + PackageId: product.identifier, + Version: version, + Type: 'Full', + FileName: safeFileName, + }, + { + PackageId: product.identifier, + Version: version, + Type: 'Full', + FileName: unsafeFileName, + }, + { + PackageId: product.identifier, + Version: version, + Type: 'Notes', + FileName: 'release-notes.md', + }, + ], + }; + const fetchMock = vi.fn(async (input) => { + const url = input.toString(); + + if (url === feedUrl) { + return new Response(JSON.stringify(feed), { + headers: { 'content-type': 'application/json' }, + }); + } + + if (new URL(url).hostname === 'api.github.com') { + return new Response( + JSON.stringify([release(`v${version}`, '2026-05-23T00:00:00Z')]), + { headers: { 'content-type': 'application/json' } } + ); + } + + if (url.endsWith(`/${encodeURIComponent(safeFileName)}`)) { + return new Response('safe package'); + } + + return new Response(null, { status: 404 }); + }); + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock as unknown as typeof fetch; + + try { + await mkdir(releaseDir, { recursive: true }); + expect(hydrateVelopackHistory).toBeTypeOf('function'); + await hydrateVelopackHistory?.(root, releaseDir, 'nightly'); + + await expect(readFile(join(releaseDir, safeFileName), 'utf8')).resolves.toBe( + 'safe package' + ); + await expect(exists(join(root, 'escape.nupkg'))).resolves.toBe(false); + + const hydratedFeed = JSON.parse( + await readFile(join(releaseDir, 'releases.nightly.json'), 'utf8') + ); + expect( + hydratedFeed.Assets.map((asset: { FileName: string }) => asset.FileName) + ).toEqual([safeFileName]); + expect(fetchMock).not.toHaveBeenCalledWith( + expect.stringContaining(encodeURIComponent(unsafeFileName)), + expect.anything() + ); + } finally { + globalThis.fetch = originalFetch; + await rm(root, { recursive: true, force: true }); + } + }); + + it('rejects Windows-unsafe package file names without fetching, writing, or retaining them', async () => { + const hydrateVelopackHistory = await loadHydrator(); + const product = productWithNightlyRetention(1); + const root = await createFixture(product); + const releaseDir = join(root, 'release'); + const feedName = 'releases.nightly.json'; + const feedUrl = `${product.services.updates.baseUrl}/${feedName}`; + const version = '0.3.0-nightly.20260523.3'; + const safeFileNames = [ + `TouchAI-nightly-${version}-windows-full.nupkg`, + 'trailing-dot..nupkg', + 'CONSOLE.nupkg', + 'COM10.nupkg', + ]; + const unsafeFileNames = [ + '../escape.nupkg', + '..\\escape.nupkg', + ' C.nupkg', + 'C.nupkg ', + 'bad\u0000name.nupkg', + 'bad\u001fname.nupkg', + 'badname.nupkg', + 'bad:name.nupkg', + 'bad"name.nupkg', + 'bad|name.nupkg', + 'bad?.nupkg', + 'bad*.nupkg', + 'trailing-dot.nupkg.', + 'trailing-space.nupkg ', + 'CON.nupkg', + 'prn.NUPKG', + 'AUX.nupkg', + 'nul.nupkg', + 'COM1.nupkg', + 'com9.nupkg', + 'COM¹.nupkg', + 'COM².nupkg', + 'COM³.nupkg', + 'LPT1.nupkg', + 'lpt9.nupkg', + 'LPT¹.nupkg', + 'LPT².nupkg', + 'LPT³.nupkg', + 'CON.backup.nupkg', + 'COM1.release.nupkg', + ]; + const feed = { + Assets: [...safeFileNames, ...unsafeFileNames].map((fileName) => ({ + PackageId: product.identifier, + Version: version, + Type: 'Full', + FileName: fileName, + })), + }; + const fetchMock = vi.fn(async (input) => { + const url = input.toString(); + + if (url === feedUrl) { + return new Response(JSON.stringify(feed), { + headers: { 'content-type': 'application/json' }, + }); + } + + if (new URL(url).hostname === 'api.github.com') { + return new Response( + JSON.stringify([release(`v${version}`, '2026-05-23T00:00:00Z')]), + { headers: { 'content-type': 'application/json' } } + ); + } + + if ( + safeFileNames.some((fileName) => url.endsWith(`/${encodeURIComponent(fileName)}`)) + ) { + return new Response('safe package'); + } + + return new Response(null, { status: 404 }); + }); + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock as unknown as typeof fetch; + + try { + await mkdir(releaseDir, { recursive: true }); + expect(hydrateVelopackHistory).toBeTypeOf('function'); + await expect( + hydrateVelopackHistory?.(root, releaseDir, 'nightly') + ).resolves.toBeUndefined(); + + const hydratedFeed = JSON.parse(await readFile(join(releaseDir, feedName), 'utf8')); + expect( + hydratedFeed.Assets.map((asset: { FileName: string }) => asset.FileName) + ).toEqual(safeFileNames); + const releaseFiles = await readdir(releaseDir); + expect(releaseFiles).toHaveLength(safeFileNames.length + 1); + expect(releaseFiles).toEqual(expect.arrayContaining([feedName, ...safeFileNames])); + for (const unsafeFileName of unsafeFileNames) { + expect(fetchMock).not.toHaveBeenCalledWith( + expect.stringContaining(encodeURIComponent(unsafeFileName)), + expect.anything() + ); + } + } finally { + globalThis.fetch = originalFetch; + await rm(root, { recursive: true, force: true }); + } + }); + it('keeps only retained GitHub release versions from the existing channel feed', async () => { const hydrateVelopackHistory = await loadHydrator(); const product = productWithNightlyRetention(2);