From 14756d30ebcaaaf3c77b0b4168e552e0cc19b703 Mon Sep 17 00:00:00 2001 From: ThunderTr77 Date: Mon, 15 Jun 2026 21:23:24 +0700 Subject: [PATCH 1/3] fix: ignore unsafe hydrated release assets --- .../scripts/ci/hydrate-velopack-history.d.mts | 5 + .../scripts/ci/hydrate-velopack-history.mjs | 21 +++- .../tests/ci/hydrate-velopack-history.test.ts | 108 ++++++++++++++++++ 3 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/scripts/ci/hydrate-velopack-history.d.mts create mode 100644 apps/desktop/tests/ci/hydrate-velopack-history.test.ts 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 }); + } + }); +}); From cde95aff37e0601defb960e50a3d893c31f9adc5 Mon Sep 17 00:00:00 2001 From: ThunderTr77 Date: Tue, 28 Jul 2026 15:17:35 +0700 Subject: [PATCH 2/3] test(release): cover unsafe hydrated asset names --- .../scripts/ci/hydrate-velopack-history.mjs | 21 ++-- .../tests/ci/hydrate-velopack-history.test.ts | 104 +++++++++++++++++- 2 files changed, 116 insertions(+), 9 deletions(-) diff --git a/apps/desktop/scripts/ci/hydrate-velopack-history.mjs b/apps/desktop/scripts/ci/hydrate-velopack-history.mjs index ee8a673d..a6d1cd46 100644 --- a/apps/desktop/scripts/ci/hydrate-velopack-history.mjs +++ b/apps/desktop/scripts/ci/hydrate-velopack-history.mjs @@ -165,14 +165,19 @@ function updateAssetUrl(product, fileName) { } function isSafeAssetFileName(fileName) { - return ( - typeof fileName === 'string' && - fileName.trim() === fileName && - fileName.length > 0 && - !/[\\/]/u.test(fileName) && - fileName !== '.' && - 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) { diff --git a/apps/desktop/tests/ci/hydrate-velopack-history.test.ts b/apps/desktop/tests/ci/hydrate-velopack-history.test.ts index af3d01fb..f5f4b057 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'; @@ -143,6 +143,108 @@ describe('hydrateVelopackHistory', () => { } }); + 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', + 'LPT1.nupkg', + 'lpt9.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); From f292aeeaf51121f1278c7ad4a19f39e55798ba5f Mon Sep 17 00:00:00 2001 From: ThunderTr77 Date: Tue, 28 Jul 2026 15:50:54 +0700 Subject: [PATCH 3/3] fix(release): reject superscript Windows device stems --- apps/desktop/scripts/ci/hydrate-velopack-history.mjs | 2 +- apps/desktop/tests/ci/hydrate-velopack-history.test.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/desktop/scripts/ci/hydrate-velopack-history.mjs b/apps/desktop/scripts/ci/hydrate-velopack-history.mjs index a6d1cd46..49bc918f 100644 --- a/apps/desktop/scripts/ci/hydrate-velopack-history.mjs +++ b/apps/desktop/scripts/ci/hydrate-velopack-history.mjs @@ -177,7 +177,7 @@ function isSafeAssetFileName(fileName) { } const stem = fileName.split('.')[0]?.toUpperCase(); - return !/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/u.test(stem ?? ''); + return !/^(CON|PRN|AUX|NUL|COM[1-9¹²³]|LPT[1-9¹²³])$/u.test(stem ?? ''); } function isVelopackPackage(asset) { diff --git a/apps/desktop/tests/ci/hydrate-velopack-history.test.ts b/apps/desktop/tests/ci/hydrate-velopack-history.test.ts index f5f4b057..8da71485 100644 --- a/apps/desktop/tests/ci/hydrate-velopack-history.test.ts +++ b/apps/desktop/tests/ci/hydrate-velopack-history.test.ts @@ -179,8 +179,14 @@ describe('hydrateVelopackHistory', () => { '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', ];