feat: release toolbox portal 0.1.0

This commit is contained in:
2026-07-20 18:40:51 +02:00
commit 5d2e466ad6
55 changed files with 11127 additions and 0 deletions

77
scripts/archive.mjs Normal file
View File

@@ -0,0 +1,77 @@
import { createReadStream, createWriteStream } from 'node:fs';
import { mkdir, readdir, stat } from 'node:fs/promises';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import archiver from 'archiver';
import {
canonicalExistingPath,
canonicalPathsOverlap,
canonicalProspectivePath,
} from './path-safety.mjs';
const ARCHIVE_DATE = new Date('1980-01-01T00:00:00.000Z');
function compareCodePoints(left, right) {
const leftPoints = Array.from(left, (value) => value.codePointAt(0));
const rightPoints = Array.from(right, (value) => value.codePointAt(0));
const length = Math.min(leftPoints.length, rightPoints.length);
for (let index = 0; index < length; index += 1) {
if (leftPoints[index] !== rightPoints[index])
return leftPoints[index] - rightPoints[index];
}
return leftPoints.length - rightPoints.length;
}
async function listFiles(root, relative = '') {
const directory = path.join(root, relative);
const entries = await readdir(directory, { withFileTypes: true });
const files = [];
for (const entry of entries.sort((left, right) =>
compareCodePoints(left.name, right.name)
)) {
const child = path.posix.join(
relative.split(path.sep).join('/'),
entry.name
);
if (entry.isDirectory()) files.push(...(await listFiles(root, child)));
else if (entry.isFile()) files.push(child);
else
throw new Error(
`Archive input contains an unsupported file type: ${child}`
);
}
return files;
}
export async function createStaticArchive(inputDirectory, outputFile) {
const source = await canonicalExistingPath(
inputDirectory,
'Archive input directory'
);
const output = await canonicalProspectivePath(outputFile);
if (canonicalPathsOverlap(source, output))
throw new Error('Archive output and input directory must not overlap.');
const sourceStat = await stat(source);
if (!sourceStat.isDirectory())
throw new Error(`Archive input is not a directory: ${source}`);
await mkdir(path.dirname(output), { recursive: true });
const archive = archiver('zip', { zlib: { level: 9 } });
const destination = createWriteStream(output, { flags: 'wx' });
archive.on('warning', (error) => {
if (error.code !== 'ENOENT') destination.destroy(error);
});
archive.on('error', (error) => destination.destroy(error));
const writing = pipeline(archive, destination);
for (const relative of await listFiles(source)) {
archive.append(createReadStream(path.join(source, relative)), {
name: relative,
date: ARCHIVE_DATE,
mode: 0o644,
});
}
await archive.finalize();
await writing;
return output;
}

145
scripts/artifact.mjs Normal file
View File

@@ -0,0 +1,145 @@
import { createHash } from 'node:crypto';
import { constants, createReadStream, createWriteStream } from 'node:fs';
import { copyFile, lstat, mkdir } from 'node:fs/promises';
import path from 'node:path';
import { Readable, Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { fileURLToPath } from 'node:url';
const MAX_DOWNLOAD_SIZE = 1024 * 1024 * 1024;
const MAX_REDIRECTS = 5;
function usesLatestAlias(value) {
let decoded = value;
try {
decoded = decodeURIComponent(value);
} catch {
// The URL parser below reports malformed encodings where relevant.
}
return /(?:^|[^a-z0-9])latest(?:[^a-z0-9]|$)/i.test(decoded);
}
function parseArtifactSource(source, lockDirectory) {
if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(source)) {
const url = new URL(source);
if (url.protocol === 'file:')
return { type: 'local', path: fileURLToPath(url) };
if (url.protocol === 'https:') {
return { type: 'https', url: validateHttpsArtifactUrl(url) };
}
throw new Error(`Artifact URL protocol is not allowed: ${url.protocol}`);
}
return { type: 'local', path: path.resolve(lockDirectory, source) };
}
function validateHttpsArtifactUrl(value, label = 'Artifact HTTPS URL') {
const url = value instanceof URL ? value : new URL(value);
if (url.protocol !== 'https:')
throw new Error(`${label} must use HTTPS: ${url.href}`);
if (url.username || url.password)
throw new Error(`${label} must not contain credentials.`);
if (usesLatestAlias(url.href))
throw new Error(`${label} must not use a latest alias.`);
return url;
}
async function fetchArtifact(url) {
let current = validateHttpsArtifactUrl(url);
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) {
const response = await fetch(current, {
redirect: 'manual',
headers: { Accept: 'application/zip, application/octet-stream' },
});
if ([301, 302, 303, 307, 308].includes(response.status)) {
if (redirects === MAX_REDIRECTS)
throw new Error(
`Artifact download exceeded ${MAX_REDIRECTS} redirects.`
);
const location = response.headers.get('location');
if (!location)
throw new Error('Artifact redirect did not include a Location header.');
current = validateHttpsArtifactUrl(
new URL(location, current),
'Artifact redirect URL'
);
continue;
}
if (response.url) {
const finalUrl = validateHttpsArtifactUrl(
new URL(response.url),
'Artifact final response URL'
);
if (finalUrl.href !== current.href) {
throw new Error(
`Artifact fetch unexpectedly changed URL from ${current.href} to ${finalUrl.href}.`
);
}
}
return { response, finalUrl: current };
}
throw new Error(`Artifact download exceeded ${MAX_REDIRECTS} redirects.`);
}
export async function sha256File(file) {
const hash = createHash('sha256');
for await (const chunk of createReadStream(file)) hash.update(chunk);
return hash.digest('hex');
}
export async function acquireArtifact(
source,
expectedSha256,
lockDirectory,
downloadDirectory
) {
const parsed = parseArtifactSource(source, lockDirectory);
await mkdir(downloadDirectory, { recursive: true });
const file = path.join(downloadDirectory, `${expectedSha256}.zip`);
let downloaded = false;
if (parsed.type === 'local') {
const details = await lstat(parsed.path);
if (!details.isFile() || details.isSymbolicLink())
throw new Error(`Local artifact is not a regular file: ${parsed.path}`);
// Work from a private snapshot. The checksum below therefore covers the
// exact bytes later inspected and extracted, even if the source is replaced.
await copyFile(parsed.path, file, constants.COPYFILE_EXCL);
} else {
const { response, finalUrl } = await fetchArtifact(parsed.url);
if (!response.ok || !response.body)
throw new Error(
`Artifact download failed with HTTP ${response.status}: ${finalUrl.href}`
);
const declaredLength = Number(response.headers.get('content-length'));
if (Number.isFinite(declaredLength) && declaredLength > MAX_DOWNLOAD_SIZE)
throw new Error('Artifact download exceeds the size limit.');
let bytes = 0;
const limiter = new Transform({
transform(chunk, _encoding, callback) {
bytes += chunk.length;
callback(
bytes > MAX_DOWNLOAD_SIZE
? new Error('Artifact download exceeds the size limit.')
: null,
chunk
);
},
});
await pipeline(
Readable.fromWeb(response.body),
limiter,
createWriteStream(file, { flags: 'wx', mode: 0o600 })
);
downloaded = true;
}
const actualSha256 = await sha256File(file);
if (actualSha256 !== expectedSha256) {
throw new Error(
`SHA-256 mismatch for ${source}: expected ${expectedSha256}, received ${actualSha256}.`
);
}
return { file, downloaded, sha256: actualSha256 };
}

381
scripts/assemble.mjs Normal file
View File

@@ -0,0 +1,381 @@
#!/usr/bin/env node
import { constants } from 'node:fs';
import {
access,
copyFile,
mkdir,
mkdtemp,
readFile,
readdir,
rm,
stat,
writeFile,
} from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { parseToolboxApp } from '@add-ideas/toolbox-contract';
import { acquireArtifact, sha256File } from './artifact.mjs';
import { createStaticArchive } from './archive.mjs';
import {
assertSafeRemovalTarget,
canonicalExistingPath,
canonicalPathsOverlap,
} from './path-safety.mjs';
import { publishStagedTargets } from './publication.mjs';
import { buildCatalogue, parseReleaseLock } from './release-lock.mjs';
import {
extractInspectedArchive,
inspectAppArchive,
safeEntryPath,
} from './zip-security.mjs';
function exists(file) {
return access(file).then(
() => true,
() => false
);
}
async function readJson(file, label) {
try {
return JSON.parse(await readFile(file, 'utf8'));
} catch (error) {
throw new Error(`${label} is not valid JSON: ${file}`, { cause: error });
}
}
async function copyDirectoryContents(source, destination) {
for (const entry of await readdir(source, { withFileTypes: true })) {
if (entry.isSymbolicLink())
throw new Error(`Portal dist contains a symbolic link: ${entry.name}`);
if (!entry.isDirectory() && !entry.isFile())
throw new Error(
`Portal dist contains an unsupported file: ${entry.name}`
);
const sourcePath = path.join(source, entry.name);
const destinationPath = path.join(destination, entry.name);
if (entry.isDirectory()) {
await mkdir(destinationPath);
await copyDirectoryContents(sourcePath, destinationPath);
} else {
await copyFile(sourcePath, destinationPath, constants.COPYFILE_EXCL);
}
}
}
function manifestLocalFile(reference, field, directoryIndex = false) {
if (
/^[A-Za-z][A-Za-z0-9+.-]*:/.test(reference) ||
reference.startsWith('/') ||
reference.startsWith('//') ||
reference.includes('?') ||
reference.includes('#')
) {
throw new Error(
`${field} must be a relative packaged path without query parameters or fragments.`
);
}
const normalized = safeEntryPath(reference);
if (normalized.endsWith('/') || normalized === '.') {
if (directoryIndex) return path.posix.join(normalized, 'index.html');
throw new Error(`${field} must identify a packaged file.`);
}
return normalized;
}
async function requireManifestFile(root, reference, appId, field, options) {
const relative = manifestLocalFile(reference, field, options?.directoryIndex);
const canonicalRoot = path.resolve(root);
const file = path.resolve(canonicalRoot, ...relative.split('/'));
if (!file.startsWith(`${canonicalRoot}${path.sep}`))
throw new Error(`${field} escapes its target: ${appId}`);
let details;
try {
details = await stat(file);
} catch (error) {
throw new Error(`${field} is missing for ${appId}: ${reference}`, {
cause: error,
});
}
if (!details.isFile())
throw new Error(`${field} is not a file for ${appId}: ${reference}`);
}
async function smokeCheck(output, lock, manifests) {
const index = path.join(output, 'index.html');
if (!(await exists(index)))
throw new Error('Portal dist does not contain index.html.');
const catalogue = await readJson(
path.join(output, 'toolbox.catalog.json'),
'Generated catalogue'
);
if (catalogue.apps.length !== lock.apps.length)
throw new Error('Generated catalogue app count does not match the lock.');
for (let index = 0; index < lock.apps.length; index += 1) {
const app = lock.apps[index];
const manifest = manifests[index];
const root = path.join(output, 'apps', app.target);
await requireManifestFile(root, manifest.entry, app.id, 'manifest.entry', {
directoryIndex: true,
});
await requireManifestFile(root, manifest.icon, app.id, 'manifest.icon');
for (const [assetIndex, asset] of (manifest.assets ?? []).entries()) {
await requireManifestFile(
root,
asset,
app.id,
`manifest.assets[${assetIndex}]`
);
}
const copiedManifest = parseToolboxApp(
await readJson(path.join(root, 'toolbox-app.json'), 'Copied app manifest')
);
if (copiedManifest.id !== app.id || copiedManifest.version !== app.version)
throw new Error(`Copied manifest identity changed for ${app.id}.`);
}
}
function provenance(lock) {
return {
schemaVersion: 1,
releaseVersion: lock.releaseVersion,
portalVersion: lock.portalVersion,
apps: lock.apps.map(({ id, version, sha256, target }) => ({
id,
version,
sha256,
target,
})),
};
}
export async function assembleRelease({
lockFile,
portalDist,
outputDirectory,
archiveFile,
force = false,
}) {
const lockPath = await canonicalExistingPath(lockFile, 'Release lock');
const portalPath = await canonicalExistingPath(portalDist, 'Portal dist');
const outputPath = await assertSafeRemovalTarget(outputDirectory);
const archivePath = archiveFile
? await assertSafeRemovalTarget(archiveFile)
: undefined;
const checksumPath = archivePath
? await assertSafeRemovalTarget(`${archivePath}.sha256`)
: undefined;
if (archivePath && path.extname(archivePath).toLocaleLowerCase() !== '.zip')
throw new Error('Archive output must use a .zip extension.');
if (canonicalPathsOverlap(outputPath, portalPath))
throw new Error('Output and portal dist directories must not overlap.');
if (canonicalPathsOverlap(outputPath, lockPath))
throw new Error('Output must not contain or replace the release lock.');
if (archivePath && canonicalPathsOverlap(archivePath, outputPath))
throw new Error('Archive must be outside the assembled output directory.');
if (checksumPath && canonicalPathsOverlap(checksumPath, outputPath))
throw new Error('Checksum must be outside the assembled output directory.');
if (archivePath && canonicalPathsOverlap(archivePath, portalPath))
throw new Error('Archive must be outside the portal dist directory.');
if (checksumPath && canonicalPathsOverlap(checksumPath, portalPath))
throw new Error('Checksum must be outside the portal dist directory.');
if (archivePath && canonicalPathsOverlap(archivePath, lockPath))
throw new Error('Archive must not replace the release lock.');
if (checksumPath && canonicalPathsOverlap(checksumPath, lockPath))
throw new Error('Checksum must not replace the release lock.');
const lock = parseReleaseLock(await readJson(lockPath, 'Release lock'));
const generatedCatalogue = buildCatalogue(lock);
const portalPackage = await readJson(
fileURLToPath(new URL('../package.json', import.meta.url)),
'Portal package'
);
if (portalPackage.version !== lock.portalVersion) {
throw new Error(
`Portal version mismatch: lock pins ${lock.portalVersion}, package is ${portalPackage.version}.`
);
}
const portalDetails = await stat(portalPath);
if (!portalDetails.isDirectory())
throw new Error('Portal dist must be a regular directory.');
if (!(await exists(path.join(portalPath, 'index.html'))))
throw new Error(
'Portal dist does not contain index.html; run npm run build first.'
);
const outputExists = await exists(outputPath);
const archiveExists = archivePath ? await exists(archivePath) : false;
const checksumExists = checksumPath ? await exists(checksumPath) : false;
if (outputExists && !force)
throw new Error(
`Output already exists (use --force to replace it): ${outputPath}`
);
if (archivePath && archiveExists && !force)
throw new Error(
`Archive already exists (use --force to replace it): ${archivePath}`
);
if (checksumPath && checksumExists && !force)
throw new Error(
`Checksum file already exists (use --force to replace it): ${checksumPath}`
);
await mkdir(path.dirname(outputPath), { recursive: true });
const staging = await mkdtemp(
path.join(path.dirname(outputPath), '.toolbox-assemble-')
);
const downloads = path.join(staging, '.downloads');
const manifests = [];
let archiveStaging;
try {
await copyDirectoryContents(portalPath, staging);
await mkdir(path.join(staging, 'apps'), { recursive: true });
for (const app of lock.apps) {
const artifact = await acquireArtifact(
app.artifact,
app.sha256,
path.dirname(lockPath),
downloads
);
const inspection = await inspectAppArchive(artifact.file);
const manifest = parseToolboxApp(inspection.manifest);
if (manifest.id !== app.id)
throw new Error(
`Manifest id mismatch for ${app.target}: expected ${app.id}, received ${manifest.id}.`
);
if (manifest.version !== app.version)
throw new Error(
`Manifest version mismatch for ${app.id}: expected ${app.version}, received ${manifest.version}.`
);
await extractInspectedArchive(
artifact.file,
path.join(staging, 'apps', app.target),
inspection
);
manifests.push(manifest);
}
await rm(downloads, { recursive: true, force: true });
await writeFile(
path.join(staging, 'toolbox.catalog.json'),
`${JSON.stringify(generatedCatalogue, null, 2)}\n`,
{ flag: 'w' }
);
await writeFile(
path.join(staging, 'toolbox.release.json'),
`${JSON.stringify(provenance(lock), null, 2)}\n`,
{ flag: 'wx' }
);
await smokeCheck(staging, lock, manifests);
if (archivePath) {
await mkdir(path.dirname(archivePath), { recursive: true });
archiveStaging = await mkdtemp(
path.join(path.dirname(archivePath), '.toolbox-archive-')
);
const stagedArchive = path.join(
archiveStaging,
path.basename(archivePath)
);
const stagedChecksum = `${stagedArchive}.sha256`;
await createStaticArchive(staging, stagedArchive);
const checksum = await sha256File(stagedArchive);
await writeFile(
stagedChecksum,
`${checksum} ${path.basename(archivePath)}\n`,
{ flag: 'wx' }
);
}
const publicationTargets = [{ staged: staging, target: outputPath }];
if (archivePath) {
publicationTargets.push(
{
staged: path.join(archiveStaging, path.basename(archivePath)),
target: archivePath,
},
{
staged: path.join(
archiveStaging,
`${path.basename(archivePath)}.sha256`
),
target: checksumPath,
}
);
}
await publishStagedTargets(publicationTargets, { force });
if (archivePath) {
await rm(archiveStaging, { recursive: true, force: true });
archiveStaging = undefined;
}
} catch (error) {
await rm(staging, { recursive: true, force: true });
if (archiveStaging)
await rm(archiveStaging, { recursive: true, force: true });
throw error;
}
return {
outputDirectory: outputPath,
archiveFile: archivePath,
checksumFile: checksumPath,
releaseVersion: lock.releaseVersion,
};
}
function usage() {
return `Usage: node scripts/assemble.mjs --lock FILE [options]\n\nOptions:\n --portal-dist DIR Built portal directory (default: dist)\n --output DIR Assembled static directory (default: build/toolbox)\n --archive FILE Static ZIP (default: build/add-ideas-toolbox-VERSION.zip)\n --force Replace the exact output paths if they exist\n --help Show this message\n`;
}
function parseArguments(argv) {
const values = {};
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === '--force' || argument === '--help')
values[argument.slice(2)] = true;
else if (
['--lock', '--portal-dist', '--output', '--archive'].includes(argument)
) {
const value = argv[index + 1];
if (!value || value.startsWith('--'))
throw new Error(`${argument} requires a value.`);
values[argument.slice(2)] = value;
index += 1;
} else throw new Error(`Unknown argument: ${argument}`);
}
return values;
}
async function main() {
const arguments_ = parseArguments(process.argv.slice(2));
if (arguments_.help) {
process.stdout.write(usage());
return;
}
if (!arguments_.lock) throw new Error(`--lock is required.\n\n${usage()}`);
const rawLock = parseReleaseLock(
await readJson(path.resolve(arguments_.lock), 'Release lock')
);
const result = await assembleRelease({
lockFile: arguments_.lock,
portalDist: arguments_['portal-dist'] ?? 'dist',
outputDirectory: arguments_.output ?? 'build/toolbox',
archiveFile:
arguments_.archive ??
`build/add-ideas-toolbox-${rawLock.releaseVersion}.zip`,
force: Boolean(arguments_.force),
});
process.stdout.write(
`Assembled toolbox ${result.releaseVersion} at ${result.outputDirectory}\nArchive: ${result.archiveFile}\nChecksum: ${result.checksumFile}\n`
);
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
main().catch((error) => {
process.stderr.write(
`Assembly failed: ${error instanceof Error ? error.message : String(error)}\n`
);
process.exitCode = 1;
});
}

487
scripts/assembler.test.ts Normal file
View File

@@ -0,0 +1,487 @@
// @vitest-environment node
import { execFileSync } from 'node:child_process';
import { createWriteStream } from 'node:fs';
import {
mkdtemp,
mkdir,
readFile,
rename,
rm,
symlink,
writeFile,
} from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import { pathToFileURL } from 'node:url';
import archiver from 'archiver';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { assembleRelease } from './assemble.mjs';
import { createStaticArchive } from './archive.mjs';
import { acquireArtifact, sha256File } from './artifact.mjs';
import { publishStagedTargets } from './publication.mjs';
import { parseReleaseLock } from './release-lock.mjs';
import { inspectAppArchive } from './zip-security.mjs';
const temporaryDirectories: string[] = [];
afterEach(async () => {
vi.unstubAllGlobals();
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { recursive: true, force: true }))
);
});
async function temporaryDirectory() {
const directory = await mkdtemp(
path.join(os.tmpdir(), 'toolbox-portal-test-')
);
temporaryDirectories.push(directory);
return directory;
}
async function zipEntries(
output: string,
entries: Array<{ name: string; content: string }>
) {
const archive = archiver('zip', { zlib: { level: 9 } });
const writing = pipeline(archive, createWriteStream(output, { flags: 'wx' }));
for (const entry of entries)
archive.append(entry.content, { name: entry.name });
await archive.finalize();
await writing;
}
const manifest = {
schemaVersion: 1,
id: 'de.add-ideas.test-tool',
name: 'Test tool',
version: '1.2.3',
description: 'Test fixture',
entry: './',
icon: './favicon.svg',
categories: ['test'],
tags: [],
integration: {
contextVersion: 1,
launchModes: ['navigate'],
embedding: 'unsupported',
},
requirements: {
secureContext: false,
workers: false,
indexedDb: false,
crossOriginIsolated: false,
},
privacy: { processing: 'local', fileUploads: false, telemetry: false },
source: { repository: 'https://example.com/test-tool', license: 'MIT' },
};
function makeLock(artifact: string, sha256: string) {
return {
schemaVersion: 1,
releaseVersion: '0.1.0',
portalVersion: '0.1.0',
catalogue: {
id: 'de.add-ideas.toolbox',
name: 'Test Toolbox',
home: './',
theme: { mode: 'system', brand: 'add·ideas' },
},
apps: [
{
id: manifest.id,
version: manifest.version,
artifact,
sha256,
target: 'test',
},
],
};
}
describe('release assembly integrity', () => {
it('rejects latest aliases and malformed checksums in release locks', () => {
expect(() =>
parseReleaseLock(
makeLock('https://example.com/latest/tool.zip', '0'.repeat(64))
)
).toThrow(/latest alias/i);
expect(() => parseReleaseLock(makeLock('./tool.zip', 'ABC'))).toThrow(
/sha256/i
);
});
it('refuses an artifact whose SHA-256 does not match', async () => {
const directory = await temporaryDirectory();
const artifact = path.join(directory, 'app.zip');
await writeFile(artifact, 'not the expected artifact');
await expect(
acquireArtifact(
artifact,
'0'.repeat(64),
directory,
path.join(directory, 'downloads')
)
).rejects.toThrow(/SHA-256 mismatch/);
});
it('rejects credential-bearing HTTPS sources and self-containing archives', async () => {
const directory = await temporaryDirectory();
await expect(
acquireArtifact(
'https://user:secret@example.com/tool.zip',
'0'.repeat(64),
directory,
path.join(directory, 'downloads')
)
).rejects.toThrow(/credentials/i);
await expect(
createStaticArchive(directory, path.join(directory, 'self.zip'))
).rejects.toThrow(/overlap/i);
});
it('canonicalizes symlink aliases before archive overlap checks', async () => {
const directory = await temporaryDirectory();
const input = path.join(directory, 'input');
const alias = path.join(directory, 'input-alias');
await mkdir(input);
await writeFile(path.join(input, 'index.html'), 'release input');
await symlink(input, alias, 'dir');
await expect(
createStaticArchive(alias, path.join(input, 'self.zip'))
).rejects.toThrow(/overlap/i);
});
it('rejects a dangling symlink in an archive output parent', async () => {
const directory = await temporaryDirectory();
const input = path.join(directory, 'input');
const dangling = path.join(directory, 'dangling');
await mkdir(input);
await writeFile(path.join(input, 'index.html'), 'release input');
await symlink(path.join(directory, 'missing'), dangling, 'dir');
await expect(
createStaticArchive(input, path.join(dangling, 'release.zip'))
).rejects.toThrow(/dangling symbolic link/i);
});
it('rejects insecure or credential-bearing redirect targets', async () => {
const directory = await temporaryDirectory();
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(null, {
status: 302,
headers: { location: 'http://example.com/tool.zip' },
})
)
);
await expect(
acquireArtifact(
'https://example.com/tool.zip',
'0'.repeat(64),
directory,
path.join(directory, 'downloads')
)
).rejects.toThrow(/redirect URL must use HTTPS/i);
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(null, {
status: 302,
headers: { location: 'https://user:secret@example.com/tool.zip' },
})
)
);
await expect(
acquireArtifact(
'https://example.com/tool.zip',
'0'.repeat(64),
directory,
path.join(directory, 'other-downloads')
)
).rejects.toThrow(/redirect URL must not contain credentials/i);
});
it('blocks ZIP path traversal before extraction', async () => {
const directory = await temporaryDirectory();
const safeZip = path.join(directory, 'safe.zip');
const hostileZip = path.join(directory, 'hostile.zip');
await zipEntries(safeZip, [
{ name: 'toolbox-app.json', content: JSON.stringify(manifest) },
{ name: 'xx/evil.txt', content: 'escape attempt' },
]);
const buffer = await readFile(safeZip);
const before = Buffer.from('xx/evil.txt');
const after = Buffer.from('../evil.txt');
let replacements = 0;
for (
let offset = buffer.indexOf(before);
offset >= 0;
offset = buffer.indexOf(before, offset + after.length)
) {
after.copy(buffer, offset);
replacements += 1;
}
expect(replacements).toBeGreaterThanOrEqual(2);
await writeFile(hostileZip, buffer);
await expect(inspectAppArchive(hostileZip)).rejects.toThrow(
/relative path|traversal/i
);
});
it('blocks symbolic links before extraction', async () => {
const directory = await temporaryDirectory();
const artifact = path.join(directory, 'symlink.zip');
const archive = archiver('zip', { zlib: { level: 9 } });
const writing = pipeline(
archive,
createWriteStream(artifact, { flags: 'wx' })
);
archive.append(JSON.stringify(manifest), { name: 'toolbox-app.json' });
archive.symlink('link', '../outside');
await archive.finalize();
await writing;
await expect(inspectAppArchive(artifact)).rejects.toThrow(/symbolic link/i);
});
it('packages the same files into byte-identical static archives', async () => {
const directory = await temporaryDirectory();
const input = path.join(directory, 'input');
const first = path.join(directory, 'first.zip');
const second = path.join(directory, 'second.zip');
await mkdir(input);
await writeFile(path.join(input, 'z.txt'), 'last');
await writeFile(path.join(input, 'ä.txt'), 'middle');
await writeFile(path.join(input, '😀.txt'), 'astral');
await createStaticArchive(input, first);
await createStaticArchive(input, second);
expect(await sha256File(first)).toBe(await sha256File(second));
});
it('packages byte-identical static archives in different host time zones', async () => {
const directory = await temporaryDirectory();
const input = path.join(directory, 'input');
const utcArchive = path.join(directory, 'utc.zip');
const honoluluArchive = path.join(directory, 'honolulu.zip');
await mkdir(input);
await writeFile(path.join(input, 'index.html'), 'timezone invariant');
const moduleUrl = pathToFileURL(
path.join(process.cwd(), 'scripts/archive.mjs')
).href;
const run = (timezone: string, output: string) => {
const program = `
import { createStaticArchive } from ${JSON.stringify(moduleUrl)};
await createStaticArchive(${JSON.stringify(input)}, ${JSON.stringify(output)});
`;
execFileSync(
process.execPath,
['--input-type=module', '--eval', program],
{ env: { ...process.env, TZ: timezone } }
);
};
run('UTC', utcArchive);
run('Pacific/Honolulu', honoluluArchive);
expect(await sha256File(utcArchive)).toBe(
await sha256File(honoluluArchive)
);
});
it('rejects a valid archive whose manifest identity is not the lock identity', async () => {
const directory = await temporaryDirectory();
const portalDist = path.join(directory, 'portal-dist');
const artifact = path.join(directory, 'wrong-tool.zip');
await mkdir(portalDist);
await writeFile(
path.join(portalDist, 'index.html'),
'<!doctype html><title>Toolbox</title>'
);
await zipEntries(artifact, [
{
name: 'toolbox-app.json',
content: JSON.stringify({ ...manifest, id: 'de.add-ideas.other-tool' }),
},
]);
const lockFile = path.join(directory, 'toolbox.lock.json');
await writeFile(
lockFile,
JSON.stringify(makeLock(artifact, await sha256File(artifact)))
);
await expect(
assembleRelease({
lockFile,
portalDist,
outputDirectory: path.join(directory, 'assembled'),
})
).rejects.toThrow(/manifest id mismatch/i);
});
it('refuses a force output symlink that aliases the portal input', async () => {
const directory = await temporaryDirectory();
const portalDist = path.join(directory, 'portal-dist');
const outputAlias = path.join(directory, 'output-alias');
const lockFile = path.join(directory, 'toolbox.lock.json');
await mkdir(portalDist);
await writeFile(path.join(portalDist, 'index.html'), 'portal remains');
await symlink(portalDist, outputAlias, 'dir');
await writeFile(lockFile, '{}');
await expect(
assembleRelease({
lockFile,
portalDist,
outputDirectory: outputAlias,
force: true,
})
).rejects.toThrow(/must not overlap/i);
expect(await readFile(path.join(portalDist, 'index.html'), 'utf8')).toBe(
'portal remains'
);
});
it('rolls all targets back if transactional publication fails', async () => {
const directory = await temporaryDirectory();
const stagedFirst = path.join(directory, 'staged-first');
const stagedSecond = path.join(directory, 'staged-second');
const targetFirst = path.join(directory, 'target-first');
const targetSecond = path.join(directory, 'target-second');
await writeFile(stagedFirst, 'new first');
await writeFile(stagedSecond, 'new second');
await writeFile(targetFirst, 'old first');
await writeFile(targetSecond, 'old second');
const renameWithFailure = async (source: string, target: string) => {
if (source === stagedSecond) throw new Error('injected rename failure');
await rename(source, target);
};
await expect(
publishStagedTargets(
[
{ staged: stagedFirst, target: targetFirst },
{ staged: stagedSecond, target: targetSecond },
],
{ force: true, renamePath: renameWithFailure }
)
).rejects.toThrow(/injected rename failure/i);
expect(await readFile(targetFirst, 'utf8')).toBe('old first');
expect(await readFile(targetSecond, 'utf8')).toBe('old second');
});
it('requires every manifest icon and declared asset in the app archive', async () => {
const directory = await temporaryDirectory();
const portalDist = path.join(directory, 'portal-dist');
const artifact = path.join(directory, 'missing-assets.zip');
await mkdir(portalDist);
await writeFile(
path.join(portalDist, 'index.html'),
'<title>Portal</title>'
);
await zipEntries(artifact, [
{ name: 'index.html', content: '<title>App</title>' },
{
name: 'toolbox-app.json',
content: JSON.stringify({
...manifest,
assets: ['./runtime.js'],
}),
},
]);
const lockFile = path.join(directory, 'toolbox.lock.json');
await writeFile(
lockFile,
JSON.stringify(makeLock(artifact, await sha256File(artifact)))
);
await expect(
assembleRelease({
lockFile,
portalDist,
outputDirectory: path.join(directory, 'assembled'),
})
).rejects.toThrow(/manifest\.icon is missing/i);
const artifactWithIcon = path.join(directory, 'missing-runtime.zip');
await zipEntries(artifactWithIcon, [
{ name: 'index.html', content: '<title>App</title>' },
{ name: 'favicon.svg', content: '<svg />' },
{
name: 'toolbox-app.json',
content: JSON.stringify({
...manifest,
assets: ['./runtime.js'],
}),
},
]);
await writeFile(
lockFile,
JSON.stringify(
makeLock(artifactWithIcon, await sha256File(artifactWithIcon))
)
);
await expect(
assembleRelease({
lockFile,
portalDist,
outputDirectory: path.join(directory, 'assembled-again'),
})
).rejects.toThrow(/manifest\.assets\[0\] is missing/i);
});
it('assembles only the pinned artifact, validates its manifest, and emits provenance', async () => {
const directory = await temporaryDirectory();
const portalDist = path.join(directory, 'portal-dist');
const artifact = path.join(directory, 'test-tool-1.2.3.zip');
const output = path.join(directory, 'assembled');
const archive = path.join(directory, 'toolbox-0.1.0.zip');
await mkdir(portalDist);
await writeFile(
path.join(portalDist, 'index.html'),
'<!doctype html><title>Toolbox</title>'
);
await zipEntries(artifact, [
{ name: 'index.html', content: '<!doctype html><title>Test app</title>' },
{
name: 'favicon.svg',
content: '<svg xmlns="http://www.w3.org/2000/svg"/>',
},
{ name: 'toolbox-app.json', content: JSON.stringify(manifest) },
]);
const checksum = await sha256File(artifact);
const lockFile = path.join(directory, 'toolbox.lock.json');
await writeFile(lockFile, JSON.stringify(makeLock(artifact, checksum)));
await assembleRelease({
lockFile,
portalDist,
outputDirectory: output,
archiveFile: archive,
});
const catalogue = JSON.parse(
await readFile(path.join(output, 'toolbox.catalog.json'), 'utf8')
);
const release = JSON.parse(
await readFile(path.join(output, 'toolbox.release.json'), 'utf8')
);
expect(catalogue.apps).toEqual([
{ manifest: './apps/test/toolbox-app.json', enabled: true },
]);
expect(release.apps[0]).toEqual({
id: manifest.id,
version: manifest.version,
sha256: checksum,
target: 'test',
});
expect(
await readFile(path.join(output, 'apps/test/index.html'), 'utf8')
).toContain('Test app');
expect((await readFile(archive)).length).toBeGreaterThan(0);
expect(await readFile(`${archive}.sha256`, 'utf8')).toBe(
`${await sha256File(archive)} ${path.basename(archive)}\n`
);
});
});

4
scripts/licenses.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
export function createRuntimeLicenseBundle(
projectRoot: string,
packageNames?: string[]
): Promise<string>;

100
scripts/licenses.mjs Normal file
View File

@@ -0,0 +1,100 @@
import { readFile, readdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const LICENSE_FILE = /^(?:licen[cs]e|copying|notice)(?:[._-].*)?$/iu;
const RUNTIME_PACKAGES = [
'@add-ideas/toolbox-contract',
'react',
'react-dom',
'scheduler',
];
function compareCodePoints(left, right) {
return left < right ? -1 : left > right ? 1 : 0;
}
export async function createRuntimeLicenseBundle(
projectRoot,
packageNames = RUNTIME_PACKAGES
) {
const sections = [];
for (const packageName of [...new Set(packageNames)].sort(
compareCodePoints
)) {
const packageDirectory = path.join(
projectRoot,
'node_modules',
...packageName.split('/')
);
const packageJson = JSON.parse(
await readFile(path.join(packageDirectory, 'package.json'), 'utf8')
);
const licenseFiles = await collectLicenseFiles(packageDirectory);
if (licenseFiles.length === 0)
throw new Error(`No license text found for ${packageName}.`);
const contents = [];
for (const licenseFile of licenseFiles) {
const text = await readFile(
path.join(packageDirectory, ...licenseFile.split('/')),
'utf8'
);
contents.push(
`--- ${licenseFile} ---\n${text.replaceAll('\r\n', '\n').trim()}\n`
);
}
sections.push(
[
'================================================================================',
`${packageJson.name ?? packageName}@${packageJson.version ?? 'unknown'}`,
`Declared license: ${String(packageJson.license ?? 'unspecified')}`,
'================================================================================',
...contents,
].join('\n')
);
}
return [
'Third-party software license texts bundled with add·ideas Toolbox',
'Generated deterministically from the installed runtime packages.',
'',
...sections,
'',
].join('\n');
}
async function collectLicenseFiles(directory, prefix = '') {
const matches = [];
const entries = await readdir(directory, { withFileTypes: true });
entries.sort((left, right) => compareCodePoints(left.name, right.name));
for (const entry of entries) {
if (entry.name === 'node_modules') continue;
const relativePath = path.posix.join(prefix, entry.name);
const absolutePath = path.join(directory, entry.name);
if (entry.isDirectory())
matches.push(...(await collectLicenseFiles(absolutePath, relativePath)));
else if (entry.isFile() && LICENSE_FILE.test(entry.name))
matches.push(relativePath);
}
return matches;
}
async function main() {
const projectRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..'
);
const output = path.join(projectRoot, 'public', 'THIRD_PARTY_LICENSES.txt');
await writeFile(
output,
await createRuntimeLicenseBundle(projectRoot),
'utf8'
);
process.stdout.write('Generated public/THIRD_PARTY_LICENSES.txt\n');
}
if (
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
)
await main();

14
scripts/licenses.test.ts Normal file
View File

@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest';
import { createRuntimeLicenseBundle } from './licenses.mjs';
describe('runtime license bundle', () => {
it('contains the complete installed runtime package notices', async () => {
const bundle = await createRuntimeLicenseBundle(process.cwd());
expect(bundle).toContain('@add-ideas/toolbox-contract@');
expect(bundle).toContain('react@');
expect(bundle).toContain('react-dom@');
expect(bundle).toContain('scheduler@');
expect(bundle).toContain('Apache License');
expect(bundle).toContain('MIT License');
});
});

View File

@@ -0,0 +1,71 @@
#!/usr/bin/env node
import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { createStaticArchive } from './archive.mjs';
import { sha256File } from './artifact.mjs';
import {
assertSafeRemovalTarget,
canonicalExistingPath,
canonicalPathsOverlap,
} from './path-safety.mjs';
import { publishStagedTargets } from './publication.mjs';
function argument(name, fallback) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : fallback;
}
const input = await canonicalExistingPath(
argument('--input', 'build/toolbox'),
'Static archive input'
);
const output = await assertSafeRemovalTarget(
argument('--output', 'artifacts/add-ideas-toolbox-static.zip')
);
const checksumOutput = await assertSafeRemovalTarget(`${output}.sha256`);
const force = process.argv.includes('--force');
if (path.extname(output).toLocaleLowerCase() !== '.zip')
throw new Error('Archive output must use a .zip extension.');
if (canonicalPathsOverlap(output, input))
throw new Error('Archive output and input directory must not overlap.');
const outputExists = await access(output).then(
() => true,
() => false
);
const checksumExists = await access(checksumOutput).then(
() => true,
() => false
);
if (outputExists && !force)
throw new Error(
`Archive already exists (use --force to replace it): ${output}`
);
if (checksumExists && !force)
throw new Error(
`Checksum already exists (use --force to replace it): ${checksumOutput}`
);
await mkdir(path.dirname(output), { recursive: true });
const staging = await mkdtemp(
path.join(path.dirname(output), '.toolbox-package-')
);
const stagedArchive = path.join(staging, path.basename(output));
const stagedChecksum = `${stagedArchive}.sha256`;
try {
await createStaticArchive(input, stagedArchive);
const checksum = await sha256File(stagedArchive);
await writeFile(stagedChecksum, `${checksum} ${path.basename(output)}\n`, {
flag: 'wx',
});
await publishStagedTargets(
[
{ staged: stagedArchive, target: output },
{ staged: stagedChecksum, target: checksumOutput },
],
{ force }
);
} finally {
await rm(staging, { recursive: true, force: true });
}
process.stdout.write(`Created ${output}\nChecksum: ${checksumOutput}\n`);

93
scripts/path-safety.mjs Normal file
View File

@@ -0,0 +1,93 @@
import { lstat, realpath, stat } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
function isMissing(error) {
return error && typeof error === 'object' && error.code === 'ENOENT';
}
export async function canonicalExistingPath(candidate, label = 'Path') {
const absolute = path.resolve(candidate);
try {
return await realpath(absolute);
} catch (error) {
throw new Error(
`${label} does not resolve to an existing path: ${absolute}`,
{
cause: error,
}
);
}
}
export async function canonicalProspectivePath(candidate) {
const absolute = path.resolve(candidate);
const suffix = [];
let current = absolute;
while (true) {
let details;
try {
details = await lstat(current);
} catch (error) {
if (!isMissing(error)) throw error;
const parent = path.dirname(current);
if (parent === current) throw error;
suffix.unshift(path.basename(current));
current = parent;
continue;
}
let canonical;
try {
canonical = await realpath(current);
} catch (error) {
if (details.isSymbolicLink() && isMissing(error)) {
throw new Error(`Path contains a dangling symbolic link: ${current}`, {
cause: error,
});
}
throw error;
}
if (suffix.length > 0) {
const canonicalDetails = await stat(canonical);
if (!canonicalDetails.isDirectory()) {
throw new Error(
`Path has a non-directory existing ancestor: ${current}`
);
}
}
return path.join(canonical, ...suffix);
}
}
export function canonicalPathsOverlap(left, right) {
const resolvedLeft = path.resolve(left);
const resolvedRight = path.resolve(right);
return (
resolvedLeft === resolvedRight ||
resolvedLeft.startsWith(`${resolvedRight}${path.sep}`) ||
resolvedRight.startsWith(`${resolvedLeft}${path.sep}`)
);
}
export async function assertSafeRemovalTarget(target) {
const canonical = await canonicalProspectivePath(target);
const root = path.parse(canonical).root;
const protectedPaths = await Promise.all([
canonicalProspectivePath(process.cwd()),
canonicalProspectivePath(os.homedir()),
]);
if (
canonical === root ||
protectedPaths.some(
(protectedPath) =>
protectedPath === canonical ||
protectedPath.startsWith(`${canonical}${path.sep}`)
)
) {
throw new Error(`Refusing unsafe output target: ${canonical}`);
}
return canonical;
}

84
scripts/publication.mjs Normal file
View File

@@ -0,0 +1,84 @@
import { randomUUID } from 'node:crypto';
import { access, rename, rm } from 'node:fs/promises';
import path from 'node:path';
async function exists(candidate) {
return access(candidate).then(
() => true,
() => false
);
}
function backupPath(target) {
return path.join(
path.dirname(target),
`.${path.basename(target)}.toolbox-backup-${randomUUID()}`
);
}
export async function publishStagedTargets(
targets,
{ force = false, renamePath = rename, removePath = rm } = {}
) {
if (!Array.isArray(targets) || targets.length === 0)
throw new Error('At least one staged publication target is required.');
const normalized = targets.map(({ staged, target }) => ({
staged: path.resolve(staged),
target: path.resolve(target),
}));
if (
new Set(normalized.map(({ target }) => target)).size !== normalized.length
)
throw new Error('Publication targets must be unique.');
for (const item of normalized) {
if (!(await exists(item.staged)))
throw new Error(`Staged publication input is missing: ${item.staged}`);
if (!force && (await exists(item.target)))
throw new Error(`Publication target already exists: ${item.target}`);
}
const backups = [];
const published = [];
try {
for (const item of normalized) {
if (!(await exists(item.target))) continue;
const backup = backupPath(item.target);
await renamePath(item.target, backup);
backups.push({ target: item.target, backup });
}
for (const item of normalized) {
await renamePath(item.staged, item.target);
published.push(item.target);
}
} catch (publicationError) {
const rollbackErrors = [];
for (const target of [...published].reverse()) {
try {
await removePath(target, { recursive: true, force: true });
} catch (error) {
rollbackErrors.push(error);
}
}
for (const item of [...backups].reverse()) {
try {
await renamePath(item.backup, item.target);
} catch (error) {
rollbackErrors.push(error);
}
}
if (rollbackErrors.length > 0) {
throw new AggregateError(
[publicationError, ...rollbackErrors],
'Release publication failed and rollback was incomplete.'
);
}
throw publicationError;
}
for (const { backup } of backups) {
await removePath(backup, { recursive: true, force: true });
}
}

137
scripts/release-lock.mjs Normal file
View File

@@ -0,0 +1,137 @@
import { parseToolboxCatalog } from '@add-ideas/toolbox-contract';
const SHA256 = /^[a-f0-9]{64}$/;
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
const REVERSE_DNS_ID = /^[a-z0-9]+(?:[.-][a-z0-9]+)+$/;
const TARGET_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
function record(value, field) {
if (typeof value !== 'object' || value === null || Array.isArray(value))
throw new Error(`${field} must be an object.`);
return value;
}
function string(value, field) {
if (typeof value !== 'string' || value.trim() === '')
throw new Error(`${field} must be a non-empty string.`);
return value;
}
function onlyKeys(value, allowed, field) {
const unexpected = Object.keys(value).filter((key) => !allowed.has(key));
if (unexpected.length > 0)
throw new Error(`${field} contains unsupported field: ${unexpected[0]}.`);
}
function usesLatestAlias(value) {
let decoded = value;
try {
decoded = decodeURIComponent(value);
} catch {
// An invalid encoded URL will be rejected when the artifact is acquired.
}
return /(?:^|[^a-z0-9])latest(?:[^a-z0-9]|$)/i.test(decoded);
}
export function parseReleaseLock(value) {
const lock = record(value, 'lock');
onlyKeys(
lock,
new Set([
'$schema',
'schemaVersion',
'releaseVersion',
'portalVersion',
'catalogue',
'apps',
]),
'lock'
);
if (lock.schemaVersion !== 1)
throw new Error('lock.schemaVersion must be 1.');
string(lock.releaseVersion, 'lock.releaseVersion');
if (!SEMVER.test(lock.releaseVersion))
throw new Error('lock.releaseVersion must use semantic versioning.');
string(lock.portalVersion, 'lock.portalVersion');
if (!SEMVER.test(lock.portalVersion))
throw new Error('lock.portalVersion must use semantic versioning.');
const catalogue = record(lock.catalogue, 'lock.catalogue');
onlyKeys(
catalogue,
new Set(['id', 'name', 'home', 'theme']),
'lock.catalogue'
);
string(catalogue.id, 'lock.catalogue.id');
if (!REVERSE_DNS_ID.test(catalogue.id))
throw new Error('lock.catalogue.id must be a reverse-DNS identifier.');
string(catalogue.name, 'lock.catalogue.name');
string(catalogue.home, 'lock.catalogue.home');
const theme = record(catalogue.theme, 'lock.catalogue.theme');
onlyKeys(theme, new Set(['mode', 'brand']), 'lock.catalogue.theme');
if (!['light', 'dark', 'system'].includes(theme.mode))
throw new Error('lock.catalogue.theme.mode is invalid.');
string(theme.brand, 'lock.catalogue.theme.brand');
if (!Array.isArray(lock.apps) || lock.apps.length === 0)
throw new Error('lock.apps must contain at least one pinned app.');
const ids = new Set();
const targets = new Set();
lock.apps.forEach((rawApp, index) => {
const app = record(rawApp, `lock.apps[${index}]`);
onlyKeys(
app,
new Set(['id', 'version', 'artifact', 'sha256', 'target']),
`lock.apps[${index}]`
);
string(app.id, `lock.apps[${index}].id`);
if (!REVERSE_DNS_ID.test(app.id))
throw new Error(
`lock.apps[${index}].id must be a reverse-DNS identifier.`
);
string(app.version, `lock.apps[${index}].version`);
if (!SEMVER.test(app.version))
throw new Error(
`lock.apps[${index}].version must use semantic versioning.`
);
string(app.artifact, `lock.apps[${index}].artifact`);
if (usesLatestAlias(app.artifact))
throw new Error(
`lock.apps[${index}].artifact must not use a latest alias.`
);
string(app.sha256, `lock.apps[${index}].sha256`);
if (!SHA256.test(app.sha256))
throw new Error(
`lock.apps[${index}].sha256 must be 64 lowercase hexadecimal characters.`
);
string(app.target, `lock.apps[${index}].target`);
if (!TARGET_SLUG.test(app.target))
throw new Error(`lock.apps[${index}].target must be a safe URL slug.`);
if (ids.has(app.id))
throw new Error(`Duplicate app id in lock: ${app.id}.`);
if (targets.has(app.target))
throw new Error(`Duplicate app target in lock: ${app.target}.`);
ids.add(app.id);
targets.add(app.target);
});
return lock;
}
export function buildCatalogue(lock) {
const catalogue = {
schemaVersion: 1,
id: lock.catalogue.id,
name: lock.catalogue.name,
home: lock.catalogue.home,
theme: lock.catalogue.theme,
apps: lock.apps.map((app) => ({
manifest: `./apps/${app.target}/toolbox-app.json`,
enabled: true,
})),
};
return {
$schema:
'https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-catalog.v1.schema.json',
...parseToolboxCatalog(catalogue),
};
}

206
scripts/zip-security.mjs Normal file
View File

@@ -0,0 +1,206 @@
import { createWriteStream } from 'node:fs';
import { mkdir } from 'node:fs/promises';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import yauzl from 'yauzl';
const MAX_ENTRIES = 50_000;
const MAX_FILE_SIZE = 256 * 1024 * 1024;
const MAX_TOTAL_SIZE = 2 * 1024 * 1024 * 1024;
const MAX_MANIFEST_SIZE = 1024 * 1024;
const MAX_COMPRESSION_RATIO = 1_000;
function openZip(file) {
return new Promise((resolve, reject) => {
yauzl.open(
file,
{
lazyEntries: true,
autoClose: true,
decodeStrings: true,
validateEntrySizes: true,
},
(error, zip) => {
if (error) reject(error);
else resolve(zip);
}
);
});
}
function readEntry(zip, entry, limit = MAX_FILE_SIZE) {
return new Promise((resolve, reject) => {
zip.openReadStream(entry, (error, stream) => {
if (error) return reject(error);
const chunks = [];
let length = 0;
stream.on('data', (chunk) => {
length += chunk.length;
if (length > limit)
stream.destroy(
new Error(`ZIP entry exceeds the ${limit}-byte read limit.`)
);
else chunks.push(chunk);
});
stream.once('error', reject);
stream.once('end', () => resolve(Buffer.concat(chunks)));
});
});
}
function unixFileType(entry) {
const madeBy = entry.versionMadeBy >>> 8;
return madeBy === 3 ? (entry.externalFileAttributes >>> 16) & 0o170000 : 0;
}
export function safeEntryPath(fileName) {
if (
typeof fileName !== 'string' ||
fileName.length === 0 ||
fileName.includes('\0')
)
throw new Error('ZIP contains an invalid empty or NUL path.');
const portable = fileName.replaceAll('\\', '/');
if (portable.startsWith('/') || /^[A-Za-z]:\//.test(portable))
throw new Error(`ZIP contains an absolute path: ${fileName}`);
const components = portable.split('/');
if (components.some((part) => part === '..'))
throw new Error(`ZIP path traversal blocked: ${fileName}`);
const normalized = path.posix.normalize(portable);
if (normalized === '..' || normalized.startsWith('../'))
throw new Error(`ZIP path traversal blocked: ${fileName}`);
if (normalized === '.' && !portable.endsWith('/'))
throw new Error(`ZIP contains an invalid path: ${fileName}`);
return normalized;
}
function validateMetadata(entry, total) {
const normalized = safeEntryPath(entry.fileName);
const directory = entry.fileName.endsWith('/');
const fileType = unixFileType(entry);
if (fileType === 0o120000)
throw new Error(`ZIP symbolic link blocked: ${entry.fileName}`);
if (fileType !== 0 && fileType !== 0o100000 && fileType !== 0o040000)
throw new Error(`ZIP special file blocked: ${entry.fileName}`);
if (entry.uncompressedSize > MAX_FILE_SIZE)
throw new Error(`ZIP entry is too large: ${entry.fileName}`);
if (
entry.compressedSize > 0 &&
entry.uncompressedSize / entry.compressedSize > MAX_COMPRESSION_RATIO
) {
throw new Error(
`ZIP entry has an unsafe compression ratio: ${entry.fileName}`
);
}
if (total + entry.uncompressedSize > MAX_TOTAL_SIZE)
throw new Error('ZIP exceeds the total uncompressed-size limit.');
return { normalized, directory };
}
export async function inspectAppArchive(file) {
const zip = await openZip(file);
const entries = [];
const paths = new Set();
let total = 0;
let manifestBuffer;
await new Promise((resolve, reject) => {
let settled = false;
const fail = (error) => {
if (settled) return;
settled = true;
zip.close();
reject(error);
};
zip.once('error', fail);
zip.once('end', () => {
if (settled) return;
settled = true;
resolve();
});
zip.on('entry', (entry) => {
void (async () => {
if (entries.length >= MAX_ENTRIES)
throw new Error(`ZIP exceeds the ${MAX_ENTRIES}-entry limit.`);
const metadata = validateMetadata(entry, total);
total += entry.uncompressedSize;
if (paths.has(metadata.normalized))
throw new Error(`ZIP contains a duplicate path: ${entry.fileName}`);
paths.add(metadata.normalized);
entries.push({ fileName: entry.fileName, ...metadata });
if (!metadata.directory && metadata.normalized === 'toolbox-app.json') {
manifestBuffer = await readEntry(zip, entry, MAX_MANIFEST_SIZE);
}
zip.readEntry();
})().catch(fail);
});
zip.readEntry();
});
if (!manifestBuffer)
throw new Error('ZIP does not contain toolbox-app.json at its root.');
let manifest;
try {
manifest = JSON.parse(manifestBuffer.toString('utf8'));
} catch (error) {
throw new Error('ZIP toolbox-app.json is not valid JSON.', {
cause: error,
});
}
return { entries, manifest };
}
export async function extractInspectedArchive(file, destination, inspection) {
const root = path.resolve(destination);
await mkdir(root, { recursive: true });
const allowed = new Map(
inspection.entries.map((entry) => [entry.fileName, entry])
);
const zip = await openZip(file);
await new Promise((resolve, reject) => {
let settled = false;
const fail = (error) => {
if (settled) return;
settled = true;
zip.close();
reject(error);
};
zip.once('error', fail);
zip.once('end', () => {
if (settled) return;
settled = true;
resolve();
});
zip.on('entry', (entry) => {
void (async () => {
const metadata = allowed.get(entry.fileName);
if (!metadata)
throw new Error(
`ZIP changed between inspection and extraction: ${entry.fileName}`
);
const target = path.resolve(root, ...metadata.normalized.split('/'));
if (target !== root && !target.startsWith(`${root}${path.sep}`))
throw new Error(
`ZIP path escaped extraction root: ${entry.fileName}`
);
if (metadata.directory) {
await mkdir(target, { recursive: true });
} else {
await mkdir(path.dirname(target), { recursive: true });
await new Promise((resolveStream, rejectStream) => {
zip.openReadStream(entry, (error, stream) => {
if (error) return rejectStream(error);
pipeline(
stream,
createWriteStream(target, { flags: 'wx', mode: 0o644 })
).then(resolveStream, rejectStream);
});
});
}
zip.readEntry();
})().catch(fail);
});
zip.readEntry();
});
}