feat: release toolbox portal 0.1.0
This commit is contained in:
77
scripts/archive.mjs
Normal file
77
scripts/archive.mjs
Normal 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
145
scripts/artifact.mjs
Normal 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
381
scripts/assemble.mjs
Normal 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
487
scripts/assembler.test.ts
Normal 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
4
scripts/licenses.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
export function createRuntimeLicenseBundle(
|
||||
projectRoot: string,
|
||||
packageNames?: string[]
|
||||
): Promise<string>;
|
||||
100
scripts/licenses.mjs
Normal file
100
scripts/licenses.mjs
Normal 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
14
scripts/licenses.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
71
scripts/package-static.mjs
Normal file
71
scripts/package-static.mjs
Normal 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
93
scripts/path-safety.mjs
Normal 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
84
scripts/publication.mjs
Normal 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
137
scripts/release-lock.mjs
Normal 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
206
scripts/zip-security.mjs
Normal 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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user