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 };
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const { zipSync, unzipSync, extractZipSync, assertSafeEntryName } = require('../../core/zip');
|
||||||
|
const { GuideStore } = require('../../core/store');
|
||||||
|
const { exportGuideArchive, importGuideArchive, readArchive, saveLinkedGuide } = require('../../core/archive');
|
||||||
|
const { acquireLock, releaseLock, lockPathFor, readLock } = require('../../core/locks');
|
||||||
|
const { createSnapshot, listSnapshots, restoreSnapshot } = require('../../core/snapshots');
|
||||||
|
const { makeTmpDir, rmrf, TINY_PNG } = require('./helpers');
|
||||||
|
|
||||||
|
function makeGuide(store) {
|
||||||
|
const guide = store.createGuide({
|
||||||
|
title: 'Install VPN',
|
||||||
|
descriptionHtml: '<p>Company VPN setup</p>',
|
||||||
|
placeholders: { Company: 'Acme' },
|
||||||
|
});
|
||||||
|
const s1 = store.addStep(guide.guideId, {
|
||||||
|
title: 'Download installer',
|
||||||
|
annotations: [{ type: 'rect', x: 0.1, y: 0.1, w: 0.5, h: 0.3 }],
|
||||||
|
}, TINY_PNG, { width: 1, height: 1 });
|
||||||
|
const s2 = store.addStep(guide.guideId, { kind: 'content', title: 'Notes', descriptionHtml: '<p>VPN notes</p>' });
|
||||||
|
const sub = store.addStep(guide.guideId, { kind: 'empty', title: 'Substep', parentStepId: s1.stepId });
|
||||||
|
return { guide: store.getGuide(guide.guideId), s1, s2, sub };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('zip round-trips data through actual zip bytes and rejects unsafe names', (t) => {
|
||||||
|
const dir = makeTmpDir('zip');
|
||||||
|
t.after(() => rmrf(dir));
|
||||||
|
|
||||||
|
const buf = zipSync([
|
||||||
|
{ name: 'a.txt', data: 'alpha' },
|
||||||
|
{ name: 'nested/deep/b.bin', data: Buffer.from([0, 255, 1, 254]) },
|
||||||
|
{ name: 'stored.png', data: TINY_PNG, store: true },
|
||||||
|
]);
|
||||||
|
const entries = unzipSync(buf);
|
||||||
|
assert.deepEqual(entries.map((e) => e.name), ['a.txt', 'nested/deep/b.bin', 'stored.png']);
|
||||||
|
assert.equal(entries[0].data.toString(), 'alpha');
|
||||||
|
assert.deepEqual([...entries[1].data], [0, 255, 1, 254]);
|
||||||
|
assert.deepEqual(entries[2].data, TINY_PNG);
|
||||||
|
|
||||||
|
extractZipSync(buf, dir);
|
||||||
|
assert.equal(fs.readFileSync(path.join(dir, 'a.txt'), 'utf8'), 'alpha');
|
||||||
|
assert.deepEqual(fs.readFileSync(path.join(dir, 'nested/deep/b.bin'))[1], 255);
|
||||||
|
|
||||||
|
for (const bad of ['../evil', '/abs', 'C:/win', 'a/../../b', 'a\\b', 'a/./b', '']) {
|
||||||
|
assert.throws(() => assertSafeEntryName(bad), `should reject: ${bad}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A corrupted byte must be caught by CRC verification.
|
||||||
|
const corrupted = Buffer.from(buf);
|
||||||
|
corrupted[35] ^= 0xff;
|
||||||
|
assert.throws(() => unzipSync(corrupted));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('.sfgz export -> import(copy) round-trips full guide content with new ids', (t) => {
|
||||||
|
const dir = makeTmpDir('sfgz');
|
||||||
|
t.after(() => rmrf(dir));
|
||||||
|
const store = new GuideStore(path.join(dir, 'data'));
|
||||||
|
const { guide, s1 } = makeGuide(store);
|
||||||
|
|
||||||
|
const file = path.join(dir, 'install-vpn.sfgz');
|
||||||
|
const manifest = exportGuideArchive(store, guide.guideId, file);
|
||||||
|
assert.equal(manifest.stepCount, 3);
|
||||||
|
assert.ok(fs.statSync(file).size > 0);
|
||||||
|
|
||||||
|
// Import into a second, empty library as an independent copy.
|
||||||
|
const store2 = new GuideStore(path.join(dir, 'data2'));
|
||||||
|
const imported = importGuideArchive(store2, file, { mode: 'copy' });
|
||||||
|
assert.notEqual(imported.guideId, guide.guideId);
|
||||||
|
assert.equal(imported.title, 'Install VPN');
|
||||||
|
assert.equal(imported.placeholders.Company, 'Acme');
|
||||||
|
assert.equal(imported.linkedSource, null);
|
||||||
|
assert.equal(imported.stepsOrder.length, 3);
|
||||||
|
|
||||||
|
const steps = store2.listSteps(imported.guideId);
|
||||||
|
const titles = imported.stepsOrder.map((id) => steps.get(id).title);
|
||||||
|
assert.deepEqual(titles, ['Download installer', 'Notes', 'Substep']);
|
||||||
|
// Substep parent remapped to the copied parent's new id.
|
||||||
|
const subStep = [...steps.values()].find((s) => s.title === 'Substep');
|
||||||
|
const parent = [...steps.values()].find((s) => s.title === 'Download installer');
|
||||||
|
assert.equal(subStep.parentStepId, parent.stepId);
|
||||||
|
// Image bytes survive the round trip.
|
||||||
|
assert.deepEqual(
|
||||||
|
fs.readFileSync(store2.stepImagePath(imported.guideId, parent.stepId, 'original')),
|
||||||
|
TINY_PNG
|
||||||
|
);
|
||||||
|
assert.equal(parent.annotations[0].type, 'rect');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('linked import keeps identity; explicit save writes back to the archive', (t) => {
|
||||||
|
const dir = makeTmpDir('linked');
|
||||||
|
t.after(() => rmrf(dir));
|
||||||
|
const storeA = new GuideStore(path.join(dir, 'userA'));
|
||||||
|
const { guide } = makeGuide(storeA);
|
||||||
|
const shared = path.join(dir, 'shared.sfgz');
|
||||||
|
exportGuideArchive(storeA, guide.guideId, shared);
|
||||||
|
|
||||||
|
const storeB = new GuideStore(path.join(dir, 'userB'));
|
||||||
|
const linked = importGuideArchive(storeB, shared, { mode: 'linked' });
|
||||||
|
assert.equal(linked.guideId, guide.guideId, 'linked mode preserves guide identity');
|
||||||
|
assert.equal(linked.linkedSource.path, path.resolve(shared));
|
||||||
|
|
||||||
|
// Importing the same shared file twice must be refused.
|
||||||
|
assert.throws(() => importGuideArchive(storeB, shared, { mode: 'linked' }));
|
||||||
|
|
||||||
|
// Local edit + explicit save-back, then the other user re-reads the file.
|
||||||
|
linked.title = 'Install VPN v2';
|
||||||
|
storeB.saveGuide(linked);
|
||||||
|
const result = saveLinkedGuide(storeB, linked.guideId);
|
||||||
|
assert.equal(result.saved, true);
|
||||||
|
assert.equal(readArchive(shared).guide.title, 'Install VPN v2');
|
||||||
|
// Lock is released after a successful save.
|
||||||
|
assert.equal(readLock(shared), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lock conflicts block linked save until forced', (t) => {
|
||||||
|
const dir = makeTmpDir('locks');
|
||||||
|
t.after(() => rmrf(dir));
|
||||||
|
const store = new GuideStore(path.join(dir, 'data'));
|
||||||
|
const { guide } = makeGuide(store);
|
||||||
|
const shared = path.join(dir, 'team.sfgz');
|
||||||
|
exportGuideArchive(store, guide.guideId, shared);
|
||||||
|
const linkedStore = new GuideStore(path.join(dir, 'data2'));
|
||||||
|
const linked = importGuideArchive(linkedStore, shared, { mode: 'linked' });
|
||||||
|
|
||||||
|
// Simulate another user holding the lock.
|
||||||
|
fs.writeFileSync(lockPathFor(shared), JSON.stringify({
|
||||||
|
host: 'other-machine', user: 'colleague', pid: 1234,
|
||||||
|
acquiredAt: new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const blocked = saveLinkedGuide(linkedStore, linked.guideId);
|
||||||
|
assert.equal(blocked.saved, false);
|
||||||
|
assert.equal(blocked.conflict.host, 'other-machine');
|
||||||
|
|
||||||
|
// Forcing (the user chose "save anyway" in the conflict dialog) succeeds.
|
||||||
|
const forced = saveLinkedGuide(linkedStore, linked.guideId, { force: true });
|
||||||
|
assert.equal(forced.saved, true);
|
||||||
|
|
||||||
|
// A stale lock (crashed peer) does not block.
|
||||||
|
fs.writeFileSync(lockPathFor(shared), JSON.stringify({
|
||||||
|
host: 'other-machine', user: 'colleague', pid: 99,
|
||||||
|
acquiredAt: new Date(Date.now() - 9 * 3600 * 1000).toISOString(),
|
||||||
|
}));
|
||||||
|
assert.equal(saveLinkedGuide(linkedStore, linked.guideId).saved, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('acquire/release lock lifecycle for this process', (t) => {
|
||||||
|
const dir = makeTmpDir('lock2');
|
||||||
|
t.after(() => rmrf(dir));
|
||||||
|
const archive = path.join(dir, 'g.sfgz');
|
||||||
|
fs.writeFileSync(archive, 'placeholder');
|
||||||
|
|
||||||
|
const r1 = acquireLock(archive);
|
||||||
|
assert.equal(r1.acquired, true);
|
||||||
|
// Re-acquiring our own lock succeeds (same holder).
|
||||||
|
assert.equal(acquireLock(archive).acquired, true);
|
||||||
|
assert.equal(releaseLock(archive), true);
|
||||||
|
assert.equal(readLock(archive), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snapshot and restore recover a damaged guide, and restores are undoable', (t) => {
|
||||||
|
const dir = makeTmpDir('snap');
|
||||||
|
t.after(() => rmrf(dir));
|
||||||
|
const store = new GuideStore(path.join(dir, 'data'));
|
||||||
|
const { guide, s1 } = makeGuide(store);
|
||||||
|
|
||||||
|
createSnapshot(store, guide.guideId, { label: 'before-edit' });
|
||||||
|
assert.equal(listSnapshots(store, guide.guideId).length, 1);
|
||||||
|
|
||||||
|
// Damage the guide: change title and delete a step.
|
||||||
|
const g = store.getGuide(guide.guideId);
|
||||||
|
g.title = 'Ruined';
|
||||||
|
store.saveGuide(g);
|
||||||
|
store.deleteStep(guide.guideId, s1.stepId);
|
||||||
|
assert.equal(store.getGuide(guide.guideId).stepsOrder.length, 2);
|
||||||
|
|
||||||
|
const restored = restoreSnapshot(store, guide.guideId, listSnapshots(store, guide.guideId).find((n) => n.includes('before-edit')));
|
||||||
|
assert.equal(restored.title, 'Install VPN');
|
||||||
|
assert.equal(restored.stepsOrder.length, 3);
|
||||||
|
assert.deepEqual(
|
||||||
|
fs.readFileSync(store.stepImagePath(guide.guideId, s1.stepId, 'original')),
|
||||||
|
TINY_PNG
|
||||||
|
);
|
||||||
|
// The pre-restore state was snapshotted too (restore is undoable).
|
||||||
|
assert.ok(listSnapshots(store, guide.guideId).some((n) => n.includes('pre-restore')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snapshot pruning keeps only the most recent N', (t) => {
|
||||||
|
const dir = makeTmpDir('prune');
|
||||||
|
t.after(() => rmrf(dir));
|
||||||
|
const store = new GuideStore(path.join(dir, 'data'));
|
||||||
|
const { guide } = makeGuide(store);
|
||||||
|
for (let i = 0; i < 5; i++) createSnapshot(store, guide.guideId, { label: `s${i}`, keepLast: 3 });
|
||||||
|
const names = listSnapshots(store, guide.guideId);
|
||||||
|
assert.equal(names.length, 3);
|
||||||
|
assert.ok(names[0].includes('s4'));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user