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:
Iisyourdad
2026-06-10 16:38:30 -05:00
parent 2a602d7477
commit 3c51cf6f81
5 changed files with 717 additions and 0 deletions
+173
View File
@@ -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,
};
+67
View File
@@ -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 };
+69
View File
@@ -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
View File
@@ -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 };
+202
View File
@@ -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'));
});