diff --git a/apps/desktop/scripts/ci/hydrate-velopack-history.d.mts b/apps/desktop/scripts/ci/hydrate-velopack-history.d.mts new file mode 100644 index 00000000..a0188bd3 --- /dev/null +++ b/apps/desktop/scripts/ci/hydrate-velopack-history.d.mts @@ -0,0 +1,5 @@ +export function hydrateVelopackHistory( + projectRoot: string, + releaseDir: string, + channel: string +): Promise; diff --git a/apps/desktop/scripts/ci/hydrate-velopack-history.mjs b/apps/desktop/scripts/ci/hydrate-velopack-history.mjs index 6b900155..52929320 100644 --- a/apps/desktop/scripts/ci/hydrate-velopack-history.mjs +++ b/apps/desktop/scripts/ci/hydrate-velopack-history.mjs @@ -58,10 +58,22 @@ function updateAssetUrl(product, fileName) { return `${product.services.updates.baseUrl.replace(/\/+$/g, '')}/${encodeURIComponent(fileName)}`; } +function isSafeAssetFileName(fileName) { + return ( + typeof fileName === 'string' && + fileName.trim() === fileName && + fileName.length > 0 && + !/[\\/]/u.test(fileName) && + fileName !== '.' && + fileName !== '..' + ); +} + function isVelopackPackage(asset) { return ( asset && typeof asset.FileName === 'string' && + isSafeAssetFileName(asset.FileName) && asset.FileName.toLowerCase().endsWith('.nupkg') ); } @@ -83,9 +95,14 @@ export async function hydrateVelopackHistory(projectRoot, releaseDir, channel) { const feedText = await feedResponse.text(); const feed = JSON.parse(feedText); - 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 new file mode 100644 index 00000000..50426d42 --- /dev/null +++ b/apps/desktop/tests/ci/hydrate-velopack-history.test.ts @@ -0,0 +1,108 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it, vi } from 'vitest'; + +type HydrateVelopackHistory = ( + projectRoot: string, + releaseDir: string, + channel: string +) => Promise; + +async function loadHydrator(): Promise { + try { + const module = await import('../../scripts/ci/hydrate-velopack-history.mjs'); + return module.hydrateVelopackHistory as HydrateVelopackHistory; + } catch { + return undefined; + } +} + +async function createFixture() { + const root = await mkdtemp(join(tmpdir(), 'touchai-velopack-history-')); + const releaseDir = join(root, 'release'); + await mkdir(releaseDir, { recursive: true }); + await writeFile( + join(root, 'product.json'), + JSON.stringify( + { + schemaVersion: 1, + services: { + updates: { + baseUrl: 'https://updates.example.test/touchai-app/v1', + }, + }, + }, + null, + 4 + ) + ); + return { root, releaseDir }; +} + +function createFetchMock() { + const safeFileName = 'TouchAI-beta-0.2.0-beta.1-windows-full.nupkg'; + const unsafeFileName = '../escape.nupkg'; + const feed = { + Assets: [ + { FileName: safeFileName, Type: 'Full' }, + { FileName: unsafeFileName, Type: 'Full' }, + { FileName: 'release-notes.md', Type: 'Notes' }, + ], + }; + + return { + safeFileName, + unsafeFileName, + fetchMock: vi.fn(async (input: string | URL | Request) => { + const url = input.toString(); + if (url.endsWith('/releases.beta.json')) { + return new Response(JSON.stringify(feed), { + headers: { 'content-type': 'application/json' }, + }); + } + if (url.endsWith(`/${encodeURIComponent(safeFileName)}`)) { + return new Response('safe package'); + } + + return new Response('not found', { status: 404 }); + }) as unknown as typeof fetch, + }; +} + +describe('hydrateVelopackHistory', () => { + it('hydrates only safe package file names from an existing feed', async () => { + const hydrateVelopackHistory = await loadHydrator(); + const { root, releaseDir } = await createFixture(); + const { safeFileName, unsafeFileName, fetchMock } = createFetchMock(); + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock; + + try { + expect(hydrateVelopackHistory).toBeTypeOf('function'); + await hydrateVelopackHistory?.(root, releaseDir, 'beta'); + + await expect(readFile(join(releaseDir, safeFileName), 'utf8')).resolves.toBe( + 'safe package' + ); + await expect(readFile(join(root, 'escape.nupkg'), 'utf8')).rejects.toMatchObject({ + code: 'ENOENT', + }); + + const hydratedFeed = JSON.parse( + await readFile(join(releaseDir, 'releases.beta.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 }); + } + }); +});