mirror of
https://github.com/tt-a1i/archify.git
synced 2026-09-28 23:16:23 +08:00
532 lines
19 KiB
JavaScript
532 lines
19 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { createHash, randomBytes } from 'node:crypto';
|
|
import { constants as zlibConstants, deflateRawSync } from 'node:zlib';
|
|
|
|
import {
|
|
backupPublicRegularFileBinding,
|
|
captureAtomicOutput,
|
|
captureRegularFileBinding,
|
|
quarantineRemoveRegularFileBinding,
|
|
releaseRegularFileBinding,
|
|
removeOwnedRegularFile,
|
|
verifyAtomicOutput,
|
|
verifyRegularFileBinding,
|
|
} from '../archify/renderers/shared/atomic-output.mjs';
|
|
import { validateNativeOutputPath } from '../archify/renderers/shared/output-path.mjs';
|
|
import { validatePortablePathSet } from '../archify/renderers/shared/portable-path.mjs';
|
|
|
|
function usage() {
|
|
console.error([
|
|
'Usage: node scripts/write-deterministic-zip.mjs <directory> <output.zip> --mode-manifest <modes.json>',
|
|
' node scripts/write-deterministic-zip.mjs --validate-output <output.zip>',
|
|
].join('\n'));
|
|
process.exit(2);
|
|
}
|
|
|
|
const positionals = [];
|
|
let modeManifestArg = null;
|
|
const argv = process.argv.slice(2);
|
|
if (argv[0] === '--validate-output') {
|
|
if (argv.length !== 2) usage();
|
|
validateArchiveOutputPath(argv[1]);
|
|
process.exit(0);
|
|
}
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
if (argv[index] === '--mode-manifest') {
|
|
modeManifestArg = argv[index + 1] ?? null;
|
|
index += 1;
|
|
} else if (argv[index].startsWith('--')) {
|
|
usage();
|
|
} else {
|
|
positionals.push(argv[index]);
|
|
}
|
|
}
|
|
const [rootArg, outputArg] = positionals;
|
|
if (!rootArg || !outputArg || positionals.length !== 2 || !modeManifestArg) usage();
|
|
|
|
function validateArchiveOutputPath(rawOutput) {
|
|
try {
|
|
validateNativeOutputPath(rawOutput, { kind: 'file' });
|
|
if (process.platform !== 'win32') {
|
|
// ZIPs are published and consumed cross-platform. Apply the shared
|
|
// Windows component grammar on POSIX too, while removing only the POSIX
|
|
// root marker so an ordinary /tmp/archive.zip is not mistaken for a
|
|
// current-drive-rooted Windows spelling.
|
|
const posixRoot = path.posix.parse(rawOutput).root;
|
|
const windowsComparable = posixRoot ? rawOutput.slice(posixRoot.length) : rawOutput;
|
|
validateNativeOutputPath(windowsComparable, { platform: 'win32', kind: 'file' });
|
|
}
|
|
} catch (error) {
|
|
const reason = error?.archifyDiagnostics?.[0]?.evidence?.reason;
|
|
console.error(
|
|
`archive output is not a valid native filesystem path${reason ? ` (${reason})` : ''}: ${JSON.stringify(rawOutput)}`,
|
|
);
|
|
process.exit(2);
|
|
}
|
|
}
|
|
|
|
validateArchiveOutputPath(outputArg);
|
|
const root = path.resolve(rootArg);
|
|
const output = path.resolve(outputArg);
|
|
const outputCapture = captureAtomicOutput(output, {
|
|
requestedEntryPolicy: 'regular-or-absent',
|
|
});
|
|
if (outputCapture.status !== 'captured') {
|
|
throw new Error(
|
|
`archive output cannot be published safely (${outputCapture.reason?.code || 'capture-failed'}): ${output}`,
|
|
);
|
|
}
|
|
|
|
// Entry modes come from the Git index, recorded by scripts/stage-clean-skill.mjs.
|
|
// Filesystem permission bits are not portable across platforms (Windows cannot
|
|
// store an executable bit), so deriving them from stat() would change the
|
|
// archive bytes depending on where it was built.
|
|
function loadModeManifest(manifestPath) {
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
} catch (error) {
|
|
throw new Error(`unreadable mode manifest ${manifestPath}: ${error.message}`);
|
|
}
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
throw new Error(`mode manifest must be a JSON object: ${manifestPath}`);
|
|
}
|
|
const modes = new Map();
|
|
for (const [relative, gitMode] of Object.entries(parsed)) {
|
|
if (gitMode !== '100644' && gitMode !== '100755') {
|
|
throw new Error(`unsupported Git mode ${JSON.stringify(gitMode)} for ${relative} in mode manifest`);
|
|
}
|
|
modes.set(relative, gitMode === '100755' ? 0o755 : 0o644);
|
|
}
|
|
validatePortablePathSet([...modes.keys()], { profile: 'archive' });
|
|
return modes;
|
|
}
|
|
|
|
const recordedModes = loadModeManifest(path.resolve(modeManifestArg));
|
|
const unusedModes = new Set(recordedModes.keys());
|
|
|
|
function recordedMode(relative) {
|
|
if (!recordedModes.has(relative)) {
|
|
throw new Error(`staged file has no recorded Git mode: ${relative}`);
|
|
}
|
|
unusedModes.delete(relative);
|
|
return recordedModes.get(relative);
|
|
}
|
|
const UTF8_FLAG = 0x0800;
|
|
const DEFLATE_METHOD = 8;
|
|
const DOS_TIME = 0;
|
|
const DOS_DATE = 0x0021; // 1980-01-01, the earliest ZIP timestamp.
|
|
const ZIP32_MAX_ENTRIES = 0xffff;
|
|
const ZIP32_MAX_NAME_BYTES = 0xffff;
|
|
const ZIP32_MAX_VALUE = 0xffffffff;
|
|
|
|
function requireZip32(condition, detail) {
|
|
if (!condition) throw new Error(`ZIP64 is not supported: ${detail}`);
|
|
}
|
|
|
|
function crc32(buffer) {
|
|
let crc = 0xffffffff;
|
|
for (const byte of buffer) {
|
|
crc ^= byte;
|
|
for (let bit = 0; bit < 8; bit += 1) {
|
|
crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
|
|
}
|
|
}
|
|
return (crc ^ 0xffffffff) >>> 0;
|
|
}
|
|
|
|
function sortedEntries(directory, prefix = '') {
|
|
const entries = fs.readdirSync(directory, { withFileTypes: true })
|
|
.sort((left, right) => Buffer.compare(Buffer.from(left.name), Buffer.from(right.name)));
|
|
const files = [];
|
|
for (const entry of entries) {
|
|
const absolute = path.join(directory, entry.name);
|
|
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
if (entry.isSymbolicLink()) throw new Error(`refusing to archive symlink: ${relative}`);
|
|
if (entry.isDirectory()) files.push(...sortedEntries(absolute, relative));
|
|
else if (entry.isFile()) files.push({ absolute, relative });
|
|
else throw new Error(`refusing to archive non-regular file: ${relative}`);
|
|
}
|
|
return files;
|
|
}
|
|
|
|
function localHeader({ name, crc, compressedSize, uncompressedSize }) {
|
|
const header = Buffer.alloc(30);
|
|
header.writeUInt32LE(0x04034b50, 0);
|
|
header.writeUInt16LE(20, 4);
|
|
header.writeUInt16LE(UTF8_FLAG, 6);
|
|
header.writeUInt16LE(DEFLATE_METHOD, 8);
|
|
header.writeUInt16LE(DOS_TIME, 10);
|
|
header.writeUInt16LE(DOS_DATE, 12);
|
|
header.writeUInt32LE(crc, 14);
|
|
header.writeUInt32LE(compressedSize, 18);
|
|
header.writeUInt32LE(uncompressedSize, 22);
|
|
header.writeUInt16LE(name.length, 26);
|
|
header.writeUInt16LE(0, 28);
|
|
return header;
|
|
}
|
|
|
|
function centralHeader({ name, crc, compressedSize, uncompressedSize, offset, mode }) {
|
|
const header = Buffer.alloc(46);
|
|
header.writeUInt32LE(0x02014b50, 0);
|
|
header.writeUInt16LE(0x0314, 4); // ZIP 2.0, created on Unix.
|
|
header.writeUInt16LE(20, 6);
|
|
header.writeUInt16LE(UTF8_FLAG, 8);
|
|
header.writeUInt16LE(DEFLATE_METHOD, 10);
|
|
header.writeUInt16LE(DOS_TIME, 12);
|
|
header.writeUInt16LE(DOS_DATE, 14);
|
|
header.writeUInt32LE(crc, 16);
|
|
header.writeUInt32LE(compressedSize, 20);
|
|
header.writeUInt32LE(uncompressedSize, 24);
|
|
header.writeUInt16LE(name.length, 28);
|
|
header.writeUInt16LE(0, 30);
|
|
header.writeUInt16LE(0, 32);
|
|
header.writeUInt16LE(0, 34);
|
|
header.writeUInt16LE(0, 36);
|
|
header.writeUInt32LE(((0o100000 | mode) << 16) >>> 0, 38);
|
|
header.writeUInt32LE(offset, 42);
|
|
return header;
|
|
}
|
|
|
|
const localParts = [];
|
|
const centralParts = [];
|
|
let offset = 0;
|
|
const files = sortedEntries(root);
|
|
validatePortablePathSet(
|
|
files.map((file) => `${path.basename(root)}/${file.relative}`),
|
|
{ profile: 'archive' },
|
|
);
|
|
requireZip32(files.length < ZIP32_MAX_ENTRIES, `entry count ${files.length} reaches ZIP64 sentinel ${ZIP32_MAX_ENTRIES}`);
|
|
|
|
for (const file of files) {
|
|
const name = Buffer.from(`${path.basename(root)}/${file.relative}`, 'utf8');
|
|
const content = fs.readFileSync(file.absolute);
|
|
const compressed = deflateRawSync(content, {
|
|
level: 9,
|
|
memLevel: 9,
|
|
strategy: zlibConstants.Z_FIXED,
|
|
});
|
|
requireZip32(name.length <= ZIP32_MAX_NAME_BYTES, `file name is too long: ${file.relative}`);
|
|
requireZip32(content.length < ZIP32_MAX_VALUE, `file reaches the ZIP64 size sentinel: ${file.relative}`);
|
|
requireZip32(compressed.length < ZIP32_MAX_VALUE, `compressed file reaches the ZIP64 size sentinel: ${file.relative}`);
|
|
requireZip32(offset < ZIP32_MAX_VALUE, `local header offset reaches the ZIP64 sentinel: ${file.relative}`);
|
|
const checksum = crc32(content);
|
|
const mode = recordedMode(file.relative);
|
|
const local = localHeader({
|
|
name,
|
|
crc: checksum,
|
|
compressedSize: compressed.length,
|
|
uncompressedSize: content.length,
|
|
});
|
|
localParts.push(local, name, compressed);
|
|
centralParts.push(
|
|
centralHeader({
|
|
name,
|
|
crc: checksum,
|
|
compressedSize: compressed.length,
|
|
uncompressedSize: content.length,
|
|
offset,
|
|
mode,
|
|
}),
|
|
name,
|
|
);
|
|
offset += local.length + name.length + compressed.length;
|
|
}
|
|
if (unusedModes.size > 0) {
|
|
throw new Error(`mode manifest lists files that were not staged: ${[...unusedModes].sort().join(', ')}`);
|
|
}
|
|
|
|
const centralOffset = offset;
|
|
const centralSize = centralParts.reduce((total, part) => total + part.length, 0);
|
|
const entryCount = files.length;
|
|
requireZip32(centralOffset < ZIP32_MAX_VALUE, `central directory offset ${centralOffset} reaches the ZIP64 sentinel`);
|
|
requireZip32(centralSize < ZIP32_MAX_VALUE, `central directory size ${centralSize} reaches the ZIP64 sentinel`);
|
|
|
|
const end = Buffer.alloc(22);
|
|
end.writeUInt32LE(0x06054b50, 0);
|
|
end.writeUInt16LE(0, 4);
|
|
end.writeUInt16LE(0, 6);
|
|
end.writeUInt16LE(entryCount, 8);
|
|
end.writeUInt16LE(entryCount, 10);
|
|
end.writeUInt32LE(centralSize, 12);
|
|
end.writeUInt32LE(centralOffset, 16);
|
|
end.writeUInt16LE(0, 20);
|
|
|
|
const archive = Buffer.concat([...localParts, ...centralParts, end]);
|
|
const archiveSha256 = createHash('sha256').update(archive).digest('hex');
|
|
const outputNamespace = createHash('sha256')
|
|
.update(path.basename(outputCapture.commitPath))
|
|
.digest('hex')
|
|
.slice(0, 16);
|
|
const temporary = path.join(
|
|
path.dirname(outputCapture.commitPath),
|
|
`.archify-zip-${outputNamespace}-${randomBytes(16).toString('hex')}.tmp`,
|
|
);
|
|
const previousOutput = path.join(
|
|
path.dirname(outputCapture.commitPath),
|
|
`.archify-zip-backup-${outputNamespace}-${randomBytes(16).toString('hex')}.tmp`,
|
|
);
|
|
let descriptor;
|
|
let candidateIdentity;
|
|
let candidateBinding;
|
|
let previousBinding;
|
|
let candidatePresent = false;
|
|
let targetCommitted = false;
|
|
let backupPresent = false;
|
|
let publicationFailure;
|
|
|
|
function archivePublicationError(message, result, filePath = output) {
|
|
const reason = result?.reason || result || {};
|
|
return new Error(
|
|
`${message} (${reason.code || 'verification-failed'}): ${filePath}`,
|
|
{ cause: reason },
|
|
);
|
|
}
|
|
|
|
function removeBoundArchiveCandidate(expectedLinks) {
|
|
const removed = quarantineRemoveRegularFileBinding(
|
|
candidateBinding,
|
|
temporary,
|
|
{ subject: 'archive-candidate', expectedLinks },
|
|
);
|
|
if (removed.status === 'removed' || removed.status === 'preserved') return removed;
|
|
|
|
// If the owned inode was modified in place, bind its current state before
|
|
// quarantine removal. An entry replaced by another inode is never claimed.
|
|
const rebound = captureRegularFileBinding(temporary, {
|
|
subject: 'mutated-archive-candidate',
|
|
expectedIdentity: candidateIdentity,
|
|
expectedLinks,
|
|
});
|
|
if (rebound.status !== 'captured') return removed;
|
|
try {
|
|
return quarantineRemoveRegularFileBinding(
|
|
rebound.binding,
|
|
temporary,
|
|
{ subject: 'mutated-archive-candidate', expectedLinks },
|
|
);
|
|
} finally {
|
|
releaseRegularFileBinding(rebound.binding);
|
|
}
|
|
}
|
|
|
|
try {
|
|
const noFollow = process.platform === 'win32' ? 0 : (fs.constants.O_NOFOLLOW || 0);
|
|
descriptor = fs.openSync(
|
|
temporary,
|
|
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | noFollow,
|
|
0o666,
|
|
);
|
|
let candidateMetadata;
|
|
try {
|
|
candidateMetadata = fs.fstatSync(descriptor, { bigint: true });
|
|
} catch (error) {
|
|
try {
|
|
const retry = fs.fstatSync(descriptor, { bigint: true });
|
|
if (retry.isFile() && retry.ino !== 0n) {
|
|
candidateIdentity = { device: retry.dev, inode: retry.ino };
|
|
}
|
|
} catch {}
|
|
throw error;
|
|
}
|
|
if (!candidateMetadata.isFile() || candidateMetadata.ino === 0n) {
|
|
throw new Error(`archive candidate identity is unavailable: ${temporary}`);
|
|
}
|
|
candidateIdentity = {
|
|
device: candidateMetadata.dev,
|
|
inode: candidateMetadata.ino,
|
|
};
|
|
candidatePresent = true;
|
|
fs.writeFileSync(descriptor, archive);
|
|
if (outputCapture.mode !== null) fs.fchmodSync(descriptor, outputCapture.mode);
|
|
fs.fsyncSync(descriptor);
|
|
fs.closeSync(descriptor);
|
|
descriptor = undefined;
|
|
const candidate = captureRegularFileBinding(temporary, {
|
|
subject: 'archive-candidate',
|
|
expectedIdentity: candidateIdentity,
|
|
expectedSha256: archiveSha256,
|
|
expectedBytes: archive.length,
|
|
...(outputCapture.mode === null ? {} : { expectedMode: outputCapture.mode }),
|
|
});
|
|
if (candidate.status !== 'captured') {
|
|
throw new Error(
|
|
`archive candidate cannot be published safely (${candidate.reason?.code || 'capture-failed'}): ${temporary}`,
|
|
);
|
|
}
|
|
candidateBinding = candidate.binding;
|
|
const verifiedOutput = verifyAtomicOutput(outputCapture.snapshot);
|
|
if (verifiedOutput.status !== 'match') {
|
|
throw archivePublicationError('archive output changed before publication', verifiedOutput);
|
|
}
|
|
const verifiedCandidate = verifyRegularFileBinding(candidateBinding);
|
|
if (verifiedCandidate.status !== 'match') {
|
|
throw new Error(
|
|
`archive candidate changed before publication (${verifiedCandidate.reason?.code || 'verification-failed'}): ${temporary}`,
|
|
);
|
|
}
|
|
if (outputCapture.snapshot.target.kind === 'file') {
|
|
const expected = outputCapture.snapshot.target;
|
|
const previous = captureRegularFileBinding(outputCapture.commitPath, {
|
|
subject: 'previous-archive-output',
|
|
expectedIdentity: { device: expected.device, inode: expected.inode },
|
|
expectedMode: expected.mode,
|
|
expectedLinks: 1,
|
|
});
|
|
if (previous.status !== 'captured') {
|
|
throw archivePublicationError('previous archive output cannot be bound safely', previous);
|
|
}
|
|
previousBinding = previous.binding;
|
|
const backup = backupPublicRegularFileBinding(
|
|
previousBinding,
|
|
outputCapture.commitPath,
|
|
previousOutput,
|
|
{ subject: 'previous-archive-output' },
|
|
);
|
|
backupPresent = backup.backupCreated === true;
|
|
if (backup.status !== 'backed-up') {
|
|
throw archivePublicationError('previous archive output cannot be backed up safely', backup);
|
|
}
|
|
}
|
|
|
|
const ready = verifyRegularFileBinding(candidateBinding, {
|
|
filePath: temporary,
|
|
expectedLinks: 1,
|
|
});
|
|
if (ready.status !== 'match') {
|
|
throw archivePublicationError('archive candidate changed before commit', ready, temporary);
|
|
}
|
|
try {
|
|
// linkSync is a no-clobber commit: a writer that claims the public name
|
|
// after preflight is preserved. The candidate descriptor stays open and
|
|
// identity-bound until the linked public inode has been verified.
|
|
fs.linkSync(temporary, outputCapture.commitPath);
|
|
} catch (error) {
|
|
throw archivePublicationError('archive output cannot be committed safely', {
|
|
code: error?.code === 'EEXIST'
|
|
? 'archive-output-claimed-during-publish'
|
|
: 'archive-output-publish-failed',
|
|
systemCode: error?.code,
|
|
});
|
|
}
|
|
targetCommitted = true;
|
|
const linked = verifyRegularFileBinding(candidateBinding, {
|
|
filePath: outputCapture.commitPath,
|
|
expectedLinks: 2,
|
|
});
|
|
if (linked.status !== 'match') {
|
|
throw archivePublicationError('published archive identity cannot be verified', linked);
|
|
}
|
|
|
|
const removedCandidate = removeBoundArchiveCandidate(2);
|
|
if (removedCandidate.status !== 'removed') {
|
|
throw archivePublicationError('archive candidate cannot be finalized safely', removedCandidate, temporary);
|
|
}
|
|
candidatePresent = false;
|
|
const finalized = verifyRegularFileBinding(candidateBinding, {
|
|
filePath: outputCapture.commitPath,
|
|
expectedLinks: 1,
|
|
});
|
|
if (finalized.status !== 'match') {
|
|
throw archivePublicationError('published archive changed during finalization', finalized);
|
|
}
|
|
|
|
if (backupPresent) {
|
|
const removedBackup = quarantineRemoveRegularFileBinding(
|
|
previousBinding,
|
|
previousOutput,
|
|
{ subject: 'previous-archive-output', expectedLinks: 1 },
|
|
);
|
|
if (removedBackup.status !== 'removed') {
|
|
throw archivePublicationError('previous archive backup cannot be finalized safely', removedBackup, previousOutput);
|
|
}
|
|
backupPresent = false;
|
|
}
|
|
candidateIdentity = undefined;
|
|
} catch (error) {
|
|
publicationFailure = error;
|
|
const rollbackErrors = [];
|
|
if (targetCommitted && candidateBinding) {
|
|
const removed = quarantineRemoveRegularFileBinding(
|
|
candidateBinding,
|
|
outputCapture.commitPath,
|
|
{ subject: 'published-archive-output', expectedLinks: candidatePresent ? 2 : 1 },
|
|
);
|
|
if (removed.status === 'removed') {
|
|
targetCommitted = false;
|
|
} else {
|
|
rollbackErrors.push(`published archive removal failed (${removed.reason?.code || removed.status})`);
|
|
}
|
|
}
|
|
if (backupPresent && previousBinding && !targetCommitted) {
|
|
const backup = verifyRegularFileBinding(previousBinding, {
|
|
filePath: previousOutput,
|
|
expectedLinks: 1,
|
|
});
|
|
if (backup.status !== 'match') {
|
|
rollbackErrors.push(`previous archive backup changed (${backup.reason?.code || backup.status})`);
|
|
} else {
|
|
try {
|
|
fs.linkSync(previousOutput, outputCapture.commitPath);
|
|
const restored = verifyRegularFileBinding(previousBinding, {
|
|
filePath: outputCapture.commitPath,
|
|
expectedLinks: 2,
|
|
});
|
|
if (restored.status !== 'match') {
|
|
rollbackErrors.push(`previous archive restore changed (${restored.reason?.code || restored.status})`);
|
|
} else {
|
|
const removedBackup = quarantineRemoveRegularFileBinding(
|
|
previousBinding,
|
|
previousOutput,
|
|
{ subject: 'previous-archive-output', expectedLinks: 2 },
|
|
);
|
|
if (removedBackup.status === 'removed') {
|
|
backupPresent = false;
|
|
} else {
|
|
rollbackErrors.push(`previous archive backup cleanup failed (${removedBackup.reason?.code || removedBackup.status})`);
|
|
}
|
|
}
|
|
} catch (restoreError) {
|
|
rollbackErrors.push(`previous archive restore failed (${restoreError.code || restoreError.message})`);
|
|
}
|
|
}
|
|
}
|
|
if (rollbackErrors.length) {
|
|
throw new AggregateError(
|
|
[error],
|
|
`Archive publication requires recovery: ${rollbackErrors.join('; ')}`,
|
|
);
|
|
}
|
|
throw error;
|
|
} finally {
|
|
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
if (candidateBinding && candidatePresent) {
|
|
const cleanup = removeBoundArchiveCandidate(targetCommitted ? 2 : 1);
|
|
if (!publicationFailure && !['removed', 'preserved'].includes(cleanup.status)) {
|
|
publicationFailure = archivePublicationError(
|
|
'archive candidate could not be cleaned safely',
|
|
cleanup,
|
|
temporary,
|
|
);
|
|
}
|
|
} else if (candidateIdentity) {
|
|
const cleanup = removeOwnedRegularFile(temporary, candidateIdentity, {
|
|
subject: 'archive-candidate',
|
|
});
|
|
if (!publicationFailure && !['removed', 'absent', 'preserved'].includes(cleanup.status)) {
|
|
publicationFailure = archivePublicationError(
|
|
'archive candidate could not be cleaned safely',
|
|
cleanup,
|
|
temporary,
|
|
);
|
|
}
|
|
}
|
|
if (candidateBinding) releaseRegularFileBinding(candidateBinding);
|
|
if (previousBinding) releaseRegularFileBinding(previousBinding);
|
|
}
|
|
if (publicationFailure) throw publicationFailure;
|
|
console.log(`built ${output} (${entryCount} files)`);
|