Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions apps/desktop/scripts/ci/hydrate-velopack-history.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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')
);
}
Expand All @@ -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)) {
Expand Down
192 changes: 191 additions & 1 deletion apps/desktop/tests/ci/hydrate-velopack-history.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<typeof fetch>(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',
'bad<name.nupkg',
'bad>name.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<typeof fetch>(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);
Expand Down
Loading