Add archive layer: zip, .sfgz share files, locks, snapshots
- Hand-rolled ZIP writer/reader (node:zlib only) with CRC verification, UTF-8 names, store/deflate, path-traversal validation; verified interoperable with system unzip - .sfgz guide archives: export, copy-import (fresh ids, remapped substeps), linked-import with explicit write-back save - Advisory .lock-sfgz sidecar locks with stale detection and force-steal - Snapshot backups with pruning and undoable restore - 7 more workflow tests (19 total) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+173
@@ -0,0 +1,173 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { zipSync, unzipSync } = require('./zip');
|
||||
const { newId, nowIso, atomicWriteFileSync, writeJsonSync, deepClone } = require('./util');
|
||||
const { normalizeGuide, normalizeStep, validateGuide, validateStep, SCHEMA_VERSION } = require('./schema');
|
||||
const { acquireLock, releaseLock } = require('./locks');
|
||||
|
||||
const ARCHIVE_FORMAT = 'stepforge-guide-archive';
|
||||
const APP_VERSION = require('../package.json').version;
|
||||
|
||||
/**
|
||||
* Single-file share archive (.sfgz). Zip layout:
|
||||
* manifest.json
|
||||
* guide.json
|
||||
* steps/<stepId>/step.json
|
||||
* steps/<stepId>/<image files>
|
||||
*/
|
||||
|
||||
function buildArchiveEntries(store, guideId) {
|
||||
const guide = store.getGuide(guideId);
|
||||
const steps = store.listSteps(guideId);
|
||||
const entries = [];
|
||||
|
||||
const manifest = {
|
||||
format: ARCHIVE_FORMAT,
|
||||
formatVersion: 1,
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
appVersion: APP_VERSION,
|
||||
guideId: guide.guideId,
|
||||
title: guide.title,
|
||||
exportedAt: nowIso(),
|
||||
stepCount: guide.stepsOrder.length,
|
||||
};
|
||||
entries.push({ name: 'manifest.json', data: JSON.stringify(manifest, null, 2) });
|
||||
|
||||
const portableGuide = deepClone(guide);
|
||||
portableGuide.linkedSource = null; // links are a property of the library, not the file
|
||||
entries.push({ name: 'guide.json', data: JSON.stringify(portableGuide, null, 2) });
|
||||
|
||||
for (const stepId of guide.stepsOrder) {
|
||||
const step = steps.get(stepId);
|
||||
if (!step) continue;
|
||||
entries.push({ name: `steps/${stepId}/step.json`, data: JSON.stringify(step, null, 2) });
|
||||
const dir = store.stepDir(guideId, stepId);
|
||||
for (const file of fs.readdirSync(dir)) {
|
||||
if (file === 'step.json') continue;
|
||||
entries.push({ name: `steps/${stepId}/${file}`, data: fs.readFileSync(path.join(dir, file)), store: /\.(png|jpg|jpeg|gif|webp)$/i.test(file) });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Export a guide to a .sfgz file. Returns the manifest written. */
|
||||
function exportGuideArchive(store, guideId, destFile) {
|
||||
const entries = buildArchiveEntries(store, guideId);
|
||||
atomicWriteFileSync(destFile, zipSync(entries));
|
||||
return JSON.parse(entries[0].data);
|
||||
}
|
||||
|
||||
function readArchive(file) {
|
||||
const entries = unzipSync(fs.readFileSync(file));
|
||||
const byName = new Map(entries.map((e) => [e.name, e.data]));
|
||||
if (!byName.has('manifest.json') || !byName.has('guide.json')) {
|
||||
throw new Error('not a StepForge guide archive (missing manifest)');
|
||||
}
|
||||
const manifest = JSON.parse(byName.get('manifest.json').toString('utf8'));
|
||||
if (manifest.format !== ARCHIVE_FORMAT) throw new Error(`unsupported archive format: ${manifest.format}`);
|
||||
if (manifest.schemaVersion > SCHEMA_VERSION) {
|
||||
throw new Error(`archive uses newer schema (${manifest.schemaVersion}) than this app supports`);
|
||||
}
|
||||
const guide = normalizeGuide(JSON.parse(byName.get('guide.json').toString('utf8')));
|
||||
validateGuide(guide);
|
||||
return { manifest, guide, entries };
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a .sfgz into the library.
|
||||
* mode 'copy' — fresh ids, fully independent local guide.
|
||||
* mode 'linked' — keeps archive identity; edits autosave locally and write
|
||||
* back to the file only on explicit save (saveLinkedGuide).
|
||||
*/
|
||||
function importGuideArchive(store, file, { mode = 'copy' } = {}) {
|
||||
const { guide, entries } = readArchive(file);
|
||||
|
||||
const idMap = new Map();
|
||||
const stepFiles = new Map(); // newStepId -> [{name, data}]
|
||||
const stepJsons = new Map();
|
||||
|
||||
for (const { name, data } of entries) {
|
||||
const m = /^steps\/([^/]+)\/(.+)$/.exec(name);
|
||||
if (!m) continue;
|
||||
const [, oldStepId, rest] = m;
|
||||
if (!idMap.has(oldStepId)) {
|
||||
idMap.set(oldStepId, mode === 'copy' ? newId('step') : oldStepId);
|
||||
}
|
||||
const stepId = idMap.get(oldStepId);
|
||||
if (rest === 'step.json') stepJsons.set(stepId, { oldStepId, raw: JSON.parse(data.toString('utf8')) });
|
||||
else {
|
||||
if (!stepFiles.has(stepId)) stepFiles.set(stepId, []);
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(rest)) continue; // only flat, safe file names
|
||||
stepFiles.get(stepId).push({ name: rest, data });
|
||||
}
|
||||
}
|
||||
|
||||
const newGuide = deepClone(guide);
|
||||
if (mode === 'copy') {
|
||||
newGuide.guideId = newId('guide');
|
||||
newGuide.linkedSource = null;
|
||||
} else {
|
||||
if (store.guideExists(newGuide.guideId)) {
|
||||
throw new Error('this shared guide is already in the library');
|
||||
}
|
||||
newGuide.linkedSource = {
|
||||
path: path.resolve(file),
|
||||
openedAt: nowIso(),
|
||||
lastSavedAt: null,
|
||||
};
|
||||
}
|
||||
newGuide.stepsOrder = guide.stepsOrder.map((id) => idMap.get(id)).filter(Boolean);
|
||||
|
||||
return finalizeImport(store, newGuide, idMap, stepJsons, stepFiles);
|
||||
}
|
||||
|
||||
function finalizeImport(store, newGuide, idMap, stepJsons, stepFiles) {
|
||||
validateGuide(newGuide);
|
||||
writeJsonSync(path.join(store.guideDir(newGuide.guideId), 'guide.json'), newGuide);
|
||||
|
||||
for (const [stepId, { raw }] of stepJsons) {
|
||||
const step = normalizeStep({ ...raw, stepId });
|
||||
step.parentStepId = raw.parentStepId ? idMap.get(raw.parentStepId) || null : null;
|
||||
validateStep(step);
|
||||
const dir = store.stepDir(newGuide.guideId, stepId);
|
||||
writeJsonSync(path.join(dir, 'step.json'), step);
|
||||
for (const { name, data } of stepFiles.get(stepId) || []) {
|
||||
atomicWriteFileSync(path.join(dir, name), data);
|
||||
}
|
||||
}
|
||||
return store.getGuide(newGuide.guideId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a linked guide back to its shared archive (explicit Ctrl+S save).
|
||||
* Takes the advisory lock for the duration of the write.
|
||||
*/
|
||||
function saveLinkedGuide(store, guideId, { force = false } = {}) {
|
||||
const guide = store.getGuide(guideId);
|
||||
if (!guide.linkedSource || !guide.linkedSource.path) {
|
||||
throw new Error('guide is not linked to a shared archive');
|
||||
}
|
||||
const target = guide.linkedSource.path;
|
||||
const result = acquireLock(target, { force });
|
||||
if (!result.acquired) {
|
||||
return { saved: false, conflict: result.conflict };
|
||||
}
|
||||
try {
|
||||
exportGuideArchive(store, guideId, target);
|
||||
guide.linkedSource.lastSavedAt = nowIso();
|
||||
store.saveGuide(guide, { touch: false });
|
||||
return { saved: true, path: target };
|
||||
} finally {
|
||||
releaseLock(target);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ARCHIVE_FORMAT,
|
||||
exportGuideArchive,
|
||||
readArchive,
|
||||
importGuideArchive,
|
||||
saveLinkedGuide,
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { nowIso, readJsonIfExists } = require('./util');
|
||||
|
||||
/**
|
||||
* Advisory sidecar lock files for shared .sfgz guides on network folders.
|
||||
* For `guide.sfgz` the lock is `guide.lock-sfgz` next to it. This is a
|
||||
* coordination mechanism, not a security boundary (see SECURITY.md).
|
||||
*/
|
||||
|
||||
const STALE_AFTER_MS = 1000 * 60 * 60 * 8; // 8h: treat crashed holders as stale
|
||||
|
||||
function lockPathFor(archivePath) {
|
||||
const dir = path.dirname(archivePath);
|
||||
const base = path.basename(archivePath);
|
||||
const stem = base.endsWith('.sfgz') ? base.slice(0, -'.sfgz'.length) : base;
|
||||
return path.join(dir, `${stem}.lock-sfgz`);
|
||||
}
|
||||
|
||||
function currentHolder() {
|
||||
return { host: os.hostname(), user: os.userInfo().username, pid: process.pid };
|
||||
}
|
||||
|
||||
function readLock(archivePath) {
|
||||
return readJsonIfExists(lockPathFor(archivePath), null);
|
||||
}
|
||||
|
||||
function sameHolder(a, b) {
|
||||
return a && b && a.host === b.host && a.user === b.user && a.pid === b.pid;
|
||||
}
|
||||
|
||||
function isStale(lock, now = Date.now()) {
|
||||
const t = Date.parse(lock && lock.acquiredAt);
|
||||
return !Number.isFinite(t) || now - t > STALE_AFTER_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to take the lock. Returns { acquired: true, lock } or
|
||||
* { acquired: false, conflict } when someone else holds a fresh lock.
|
||||
* Pass force=true to steal (after the user confirmed in the conflict dialog).
|
||||
*/
|
||||
function acquireLock(archivePath, { force = false } = {}) {
|
||||
const file = lockPathFor(archivePath);
|
||||
const existing = readLock(archivePath);
|
||||
const me = currentHolder();
|
||||
if (existing && !sameHolder(existing, me) && !isStale(existing) && !force) {
|
||||
return { acquired: false, conflict: existing };
|
||||
}
|
||||
const lock = { ...me, acquiredAt: nowIso() };
|
||||
fs.writeFileSync(file, JSON.stringify(lock, null, 2));
|
||||
return { acquired: true, lock };
|
||||
}
|
||||
|
||||
/** Release only if we are the holder (or force). */
|
||||
function releaseLock(archivePath, { force = false } = {}) {
|
||||
const file = lockPathFor(archivePath);
|
||||
const existing = readLock(archivePath);
|
||||
if (!existing) return true;
|
||||
if (!force && !sameHolder(existing, currentHolder())) return false;
|
||||
fs.rmSync(file, { force: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = { lockPathFor, readLock, acquireLock, releaseLock, isStale, STALE_AFTER_MS };
|
||||
@@ -0,0 +1,69 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { zipDirSync, extractZipSync } = require('./zip');
|
||||
const { atomicWriteFileSync } = require('./util');
|
||||
|
||||
/**
|
||||
* Snapshot backups: a zip of the guide directory (excluding history/) stored
|
||||
* under <guide>/history/snapshots/. Used for automated backups and manual
|
||||
* backup/restore.
|
||||
*/
|
||||
|
||||
function snapshotsDir(store, guideId) {
|
||||
return path.join(store.guideDir(guideId), 'history', 'snapshots');
|
||||
}
|
||||
|
||||
function snapshotName(label) {
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-').replace(/-\d{3}Z$/, 'Z');
|
||||
return label ? `${stamp}-${label.replace(/[^A-Za-z0-9_-]+/g, '_')}.zip` : `${stamp}.zip`;
|
||||
}
|
||||
|
||||
function createSnapshot(store, guideId, { label = '', keepLast = 0 } = {}) {
|
||||
const guideDir = store.guideDir(guideId);
|
||||
if (!fs.existsSync(path.join(guideDir, 'guide.json'))) throw new Error(`guide not found: ${guideId}`);
|
||||
const buf = zipDirSync(guideDir, {
|
||||
filter: (rel) => rel !== 'history' && !rel.startsWith('history/'),
|
||||
});
|
||||
const dir = snapshotsDir(store, guideId);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const name = snapshotName(label);
|
||||
atomicWriteFileSync(path.join(dir, name), buf);
|
||||
if (keepLast > 0) pruneSnapshots(store, guideId, keepLast);
|
||||
return name;
|
||||
}
|
||||
|
||||
function listSnapshots(store, guideId) {
|
||||
const dir = snapshotsDir(store, guideId);
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
return fs.readdirSync(dir).filter((n) => n.endsWith('.zip')).sort().reverse();
|
||||
}
|
||||
|
||||
function pruneSnapshots(store, guideId, keepLast) {
|
||||
const all = listSnapshots(store, guideId);
|
||||
for (const name of all.slice(keepLast)) {
|
||||
fs.rmSync(path.join(snapshotsDir(store, guideId), name), { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a snapshot: replaces the guide's current content (guide.json and
|
||||
* steps/) with the snapshot's, keeping the history/ directory intact.
|
||||
*/
|
||||
function restoreSnapshot(store, guideId, name) {
|
||||
const file = path.join(snapshotsDir(store, guideId), path.basename(name));
|
||||
if (!fs.existsSync(file)) throw new Error(`snapshot not found: ${name}`);
|
||||
const buf = fs.readFileSync(file);
|
||||
const guideDir = store.guideDir(guideId);
|
||||
// Safety: snapshot the pre-restore state too, so a restore is undoable.
|
||||
createSnapshot(store, guideId, { label: 'pre-restore' });
|
||||
for (const entry of fs.readdirSync(guideDir)) {
|
||||
if (entry === 'history') continue;
|
||||
fs.rmSync(path.join(guideDir, entry), { recursive: true, force: true });
|
||||
}
|
||||
extractZipSync(buf, guideDir);
|
||||
return store.getGuide(guideId);
|
||||
}
|
||||
|
||||
module.exports = { createSnapshot, listSnapshots, pruneSnapshots, restoreSnapshot, snapshotsDir };
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const zlib = require('node:zlib');
|
||||
|
||||
/**
|
||||
* Minimal ZIP writer/reader using only node:zlib. Supports methods 0 (store)
|
||||
* and 8 (deflate), UTF-8 names, and CRC-32 verification on read. This backs
|
||||
* .sfgz / .sfglt archives, snapshots, DOCX, and PPTX — no dependency needed.
|
||||
*/
|
||||
|
||||
const CRC_TABLE = (() => {
|
||||
const table = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
table[n] = c >>> 0;
|
||||
}
|
||||
return table;
|
||||
})();
|
||||
|
||||
function crc32(buf) {
|
||||
let c = 0xffffffff;
|
||||
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function dosDateTime(date) {
|
||||
const d = date || new Date(2026, 0, 1);
|
||||
const time = (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1);
|
||||
const day = (((d.getFullYear() - 1980) & 0x7f) << 9) | ((d.getMonth() + 1) << 5) | d.getDate();
|
||||
return { time, day };
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws unless `name` is a safe relative archive entry path.
|
||||
* Rejects absolute paths, drive letters, backslashes, and `..` segments.
|
||||
*/
|
||||
function assertSafeEntryName(name) {
|
||||
if (typeof name !== 'string' || name.length === 0 || name.length > 4096) {
|
||||
throw new Error('zip: invalid entry name');
|
||||
}
|
||||
if (name.includes('\\')) throw new Error(`zip: backslash in entry name: ${name}`);
|
||||
if (name.startsWith('/') || /^[a-zA-Z]:/.test(name)) {
|
||||
throw new Error(`zip: absolute entry name: ${name}`);
|
||||
}
|
||||
const isDir = name.endsWith('/');
|
||||
const segs = (isDir ? name.slice(0, -1) : name).split('/');
|
||||
if (segs.some((s) => s === '' || s === '.' || s === '..')) {
|
||||
throw new Error(`zip: unsafe entry name: ${name}`);
|
||||
}
|
||||
if (name.includes('\u0000')) throw new Error('zip: NUL in entry name');
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a zip from entries: [{ name, data (Buffer|string), store? }].
|
||||
* Deterministic when `date` is fixed.
|
||||
*/
|
||||
function zipSync(entries, { date = new Date(2026, 0, 1) } = {}) {
|
||||
const { time, day } = dosDateTime(date);
|
||||
const localParts = [];
|
||||
const centralParts = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
assertSafeEntryName(entry.name);
|
||||
const nameBuf = Buffer.from(entry.name, 'utf8');
|
||||
const data = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(String(entry.data), 'utf8');
|
||||
const crc = crc32(data);
|
||||
let method = 8;
|
||||
let payload = zlib.deflateRawSync(data, { level: 6 });
|
||||
if (entry.store || payload.length >= data.length) {
|
||||
method = 0;
|
||||
payload = data;
|
||||
}
|
||||
|
||||
const local = Buffer.alloc(30);
|
||||
local.writeUInt32LE(0x04034b50, 0);
|
||||
local.writeUInt16LE(20, 4); // version needed
|
||||
local.writeUInt16LE(0x0800, 6); // UTF-8 names
|
||||
local.writeUInt16LE(method, 8);
|
||||
local.writeUInt16LE(time, 10);
|
||||
local.writeUInt16LE(day, 12);
|
||||
local.writeUInt32LE(crc, 14);
|
||||
local.writeUInt32LE(payload.length, 18);
|
||||
local.writeUInt32LE(data.length, 22);
|
||||
local.writeUInt16LE(nameBuf.length, 26);
|
||||
local.writeUInt16LE(0, 28); // extra len
|
||||
|
||||
const central = Buffer.alloc(46);
|
||||
central.writeUInt32LE(0x02014b50, 0);
|
||||
central.writeUInt16LE(20, 4); // made by
|
||||
central.writeUInt16LE(20, 6); // needed
|
||||
central.writeUInt16LE(0x0800, 8);
|
||||
central.writeUInt16LE(method, 10);
|
||||
central.writeUInt16LE(time, 12);
|
||||
central.writeUInt16LE(day, 14);
|
||||
central.writeUInt32LE(crc, 16);
|
||||
central.writeUInt32LE(payload.length, 20);
|
||||
central.writeUInt32LE(data.length, 24);
|
||||
central.writeUInt16LE(nameBuf.length, 28);
|
||||
// extra/comment/disk/attrs all zero
|
||||
central.writeUInt32LE(offset, 42);
|
||||
|
||||
localParts.push(local, nameBuf, payload);
|
||||
centralParts.push(central, nameBuf);
|
||||
offset += local.length + nameBuf.length + payload.length;
|
||||
}
|
||||
|
||||
const centralStart = offset;
|
||||
const centralBuf = Buffer.concat(centralParts);
|
||||
const eocd = Buffer.alloc(22);
|
||||
eocd.writeUInt32LE(0x06054b50, 0);
|
||||
eocd.writeUInt16LE(entries.length, 8);
|
||||
eocd.writeUInt16LE(entries.length, 10);
|
||||
eocd.writeUInt32LE(centralBuf.length, 12);
|
||||
eocd.writeUInt32LE(centralStart, 16);
|
||||
|
||||
return Buffer.concat([...localParts, centralBuf, eocd]);
|
||||
}
|
||||
|
||||
/** Parse a zip buffer into [{ name, data }] with CRC verification. */
|
||||
function unzipSync(buffer) {
|
||||
if (!Buffer.isBuffer(buffer) || buffer.length < 22) throw new Error('zip: too small');
|
||||
// Find end-of-central-directory record (scan backwards over the comment).
|
||||
let eocd = -1;
|
||||
const scanStart = Math.max(0, buffer.length - 22 - 0xffff);
|
||||
for (let i = buffer.length - 22; i >= scanStart; i--) {
|
||||
if (buffer.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
|
||||
}
|
||||
if (eocd < 0) throw new Error('zip: end record not found');
|
||||
const count = buffer.readUInt16LE(eocd + 10);
|
||||
let pos = buffer.readUInt32LE(eocd + 16);
|
||||
|
||||
const entries = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (buffer.readUInt32LE(pos) !== 0x02014b50) throw new Error('zip: bad central header');
|
||||
const method = buffer.readUInt16LE(pos + 10);
|
||||
const crc = buffer.readUInt32LE(pos + 16);
|
||||
const compSize = buffer.readUInt32LE(pos + 20);
|
||||
const uncompSize = buffer.readUInt32LE(pos + 24);
|
||||
const nameLen = buffer.readUInt16LE(pos + 28);
|
||||
const extraLen = buffer.readUInt16LE(pos + 30);
|
||||
const commentLen = buffer.readUInt16LE(pos + 32);
|
||||
const localOffset = buffer.readUInt32LE(pos + 42);
|
||||
const name = buffer.subarray(pos + 46, pos + 46 + nameLen).toString('utf8');
|
||||
pos += 46 + nameLen + extraLen + commentLen;
|
||||
|
||||
assertSafeEntryName(name);
|
||||
if (name.endsWith('/')) continue; // directory entry
|
||||
|
||||
if (buffer.readUInt32LE(localOffset) !== 0x04034b50) throw new Error('zip: bad local header');
|
||||
const lNameLen = buffer.readUInt16LE(localOffset + 26);
|
||||
const lExtraLen = buffer.readUInt16LE(localOffset + 28);
|
||||
const dataStart = localOffset + 30 + lNameLen + lExtraLen;
|
||||
const raw = buffer.subarray(dataStart, dataStart + compSize);
|
||||
|
||||
let data;
|
||||
if (method === 0) data = Buffer.from(raw);
|
||||
else if (method === 8) data = zlib.inflateRawSync(raw);
|
||||
else throw new Error(`zip: unsupported method ${method} for ${name}`);
|
||||
|
||||
if (data.length !== uncompSize) throw new Error(`zip: size mismatch for ${name}`);
|
||||
if (crc32(data) !== crc) throw new Error(`zip: CRC mismatch for ${name}`);
|
||||
entries.push({ name, data });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Extract a zip buffer under destDir; every path is traversal-checked. */
|
||||
function extractZipSync(buffer, destDir) {
|
||||
const resolvedDest = path.resolve(destDir);
|
||||
const written = [];
|
||||
for (const { name, data } of unzipSync(buffer)) {
|
||||
const target = path.resolve(resolvedDest, name);
|
||||
if (target !== resolvedDest && !target.startsWith(resolvedDest + path.sep)) {
|
||||
throw new Error(`zip: entry escapes destination: ${name}`);
|
||||
}
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, data);
|
||||
written.push(target);
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
/** Zip a directory tree (relative names, sorted for determinism). */
|
||||
function zipDirSync(dir, { filter = () => true, prefix = '' } = {}) {
|
||||
const entries = [];
|
||||
const walk = (rel) => {
|
||||
const abs = path.join(dir, rel);
|
||||
for (const entry of fs.readdirSync(abs, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
||||
if (!filter(childRel, entry)) continue;
|
||||
if (entry.isDirectory()) walk(childRel);
|
||||
else if (entry.isFile()) {
|
||||
entries.push({ name: prefix + childRel, data: fs.readFileSync(path.join(dir, childRel)) });
|
||||
}
|
||||
}
|
||||
};
|
||||
walk('');
|
||||
return zipSync(entries);
|
||||
}
|
||||
|
||||
module.exports = { crc32, zipSync, unzipSync, extractZipSync, zipDirSync, assertSafeEntryName };
|
||||
Reference in New Issue
Block a user