UNPKG

reproduce

Version:

Validate a package's reproducibility against it's published repository information.

259 lines (223 loc) 7.54 kB
import { execSync } from 'node:child_process'; import { Spec } from '@vltpkg/spec'; import { manifest as getManifest } from '@vltpkg/package-info'; import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; const pkg = JSON.parse(readFileSync('./package.json', 'utf8')); interface PackageManifest { name: string version: string repository?: { url: string type?: string directory?: string } gitHead?: string dist: { tarball: string integrity: string attestations?: { url: string } } } interface PackedResult { integrity?: string [key: string]: any } interface Strategy { getVersion: () => string install: (dir: string) => string pack: (dir: string) => { command: string parseResult: (output: string) => PackedResult } } export interface ReproduceOptions { cache?: Record<string, any> cacheDir?: string cacheFile?: string strategy?: 'npm' } export interface ReproduceResult { reproduceVersion: string timestamp: Date os: string arch: string strategy: string reproduced: boolean attested: boolean package: { name: string version: string spec: string location: string integrity: string } source: { spec: string location: string integrity: string } diff?: string } // Parse a URL to get the name and version of the package function parseURL(url: string): { name: string; version: string } { // Split the URL by "/" const parts = url.split('/'); // Find the tarball filename (last part of the URL) const tarball = parts[parts.length - 1]; // Ensure it ends with `.tgz` if (!tarball.endsWith('.tgz')) { throw new Error('Invalid npm tarball URL'); } // Remove the `.tgz` extension const baseName = tarball.slice(0, -4); // Find the last `-` to split the name and version const lastDashIndex = baseName.lastIndexOf('-'); if (lastDashIndex === -1) { throw new Error('Invalid tarball filename structure'); } const namePart = baseName.slice(0, lastDashIndex); const version = baseName.slice(lastDashIndex + 1); // Determine if it's a scoped package let name = namePart; const scopeIndex = parts.indexOf('-'); if (scopeIndex > 0 && parts[scopeIndex - 1].startsWith('@')) { name = `${parts[scopeIndex - 1]}/${namePart}`; } return { name, version }; } // Get OS-specific cache directory function getDefaultCacheDir(): string { switch (process.platform) { case 'darwin': return join(homedir(), 'Library', 'Caches', 'reproduce'); case 'win32': return join(homedir(), 'AppData', 'Local', 'reproduce', 'Cache'); default: // Linux and others follow XDG spec return join(process.env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'reproduce'); } } const DEFAULT_CACHE_DIR = getDefaultCacheDir(); const DEFAULT_CACHE_FILE = 'cache.json'; const EXEC_OPTIONS = { stdio: [] }; const STRATEGIES: Record<string, Strategy> = { npm: { getVersion: () => execSync('npm --version', EXEC_OPTIONS).toString().trim(), install: (dir: string) => `cd ${dir} && npm install --no-audit --no-fund --silent >/dev/null`, pack: (dir: string) => ({ command: ` cd ${dir} && npm pack --dry-run --json`, parseResult: (output: string) => JSON.parse(output)[0] }) } }; export async function reproduce(spec: string, opts: ReproduceOptions = {}): Promise<ReproduceResult | false> { opts = { cache: {}, cacheDir: DEFAULT_CACHE_DIR, cacheFile: DEFAULT_CACHE_FILE, strategy: 'npm', ...opts }; if (!opts.strategy || !STRATEGIES[opts.strategy]) { throw new Error(`Invalid strategy: ${opts.strategy}`); } let skipSetup = false; const cacheFilePath = join(opts.cacheDir!, opts.cacheFile!); if (!existsSync(cacheFilePath)) { mkdirSync(opts.cacheDir!, { recursive: true }); writeFileSync(cacheFilePath, JSON.stringify(opts.cache)); } opts.cache = Object.keys(opts.cache!).length > 0 ? opts.cache : JSON.parse(readFileSync(cacheFilePath, 'utf8')); try { const info = new Spec(spec); if (!spec || !info || info.type != 'registry' || info.registry != 'https://registry.npmjs.org/') { return false; } // Make cache spec-based by using the full spec as the key if (opts.cache && opts.cache.hasOwnProperty(spec)) { // If the package name was never set, parse the URL and set it & version (useful for old caches) const cacheEntry = opts.cache[spec]; if (cacheEntry?.package && !cacheEntry.package.name) { const { name, version } = parseURL(cacheEntry.package.location); cacheEntry.package.name = name; cacheEntry.package.version = version; } return cacheEntry; } const manifest = await getManifest(spec) as unknown as PackageManifest; if (!manifest || !manifest?.repository?.url) { return false; } const repo = manifest.repository!; const url = repo.url; const parsed = new URL(url); const location = parsed.pathname.replace('.git', '').split('/').slice(1, 3).join('/'); const path = repo.directory ? `::path:${repo.directory}` : ''; const explicitRef = url.indexOf('#') > 0 ? url.substring(0, url.indexOf('#')) : ''; const implicitRef = manifest.gitHead || 'HEAD'; const ref = explicitRef || implicitRef || ''; const host = parsed.host; if (host !== 'github.com') { return false; } const source = `github:${location}#${ref}${path}`; const sourceSpec = new Spec(`${manifest.name}@${source}`); let packed: PackedResult = {}; const strategy = STRATEGIES[opts.strategy!]; const cacheDir = join(opts.cacheDir!, sourceSpec.name); try { // Skip setup if the package is already cached or if the git repository is already cloned if (opts.cache!.hasOwnProperty(sourceSpec.toString()) || existsSync(cacheDir)) { skipSetup = true; } // Clone and install if (!skipSetup) { execSync(` rm -rf ${cacheDir} && git clone https://github.com/${location}.git ${cacheDir} --depth 1 >/dev/null && cd ${cacheDir} && git checkout ${ref} >/dev/null `, EXEC_OPTIONS); // Install dependencies execSync(strategy.install(cacheDir), EXEC_OPTIONS); } // Pack and get integrity const packCommand = strategy.pack(cacheDir); const packResult = execSync(packCommand.command, EXEC_OPTIONS); packed = packCommand.parseResult(packResult.toString()); } catch (e) { // swallow reproducibility errors } const check: ReproduceResult = opts.cache![spec] = { reproduceVersion: pkg.version, timestamp: new Date(), os: process.platform, arch: process.arch, strategy: `${opts.strategy}:${strategy.getVersion()}`, reproduced: packed?.integrity ? manifest.dist.integrity === packed.integrity : false, attested: !!manifest.dist?.attestations?.url, package: { spec, name: manifest.name, version: manifest.version, location: manifest.dist.tarball, integrity: manifest.dist.integrity, }, source: { spec: source, location: repo.url, integrity: packed?.integrity || 'null', } }; // Persist cache writeFileSync(cacheFilePath, JSON.stringify(opts.cache, null, 2)); return check; } catch (e) { return opts.cache![spec] = false; } }