From 3c51cf6f81b98579e86c60e9b73649f478b60613 Mon Sep 17 00:00:00 2001 From: Iisyourdad Date: Wed, 10 Jun 2026 16:38:30 -0500 Subject: [PATCH] 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 --- core/archive.js | 173 +++++++++++++++++++++++++++++++ core/locks.js | 67 ++++++++++++ core/snapshots.js | 69 +++++++++++++ core/zip.js | 206 +++++++++++++++++++++++++++++++++++++ tests/unit/archive.test.js | 202 ++++++++++++++++++++++++++++++++++++ 5 files changed, 717 insertions(+) create mode 100644 core/archive.js create mode 100644 core/locks.js create mode 100644 core/snapshots.js create mode 100644 core/zip.js create mode 100644 tests/unit/archive.test.js diff --git a/core/archive.js b/core/archive.js new file mode 100644 index 0000000..bd3d312 --- /dev/null +++ b/core/archive.js @@ -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//step.json + * steps// + */ + +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, +}; diff --git a/core/locks.js b/core/locks.js new file mode 100644 index 0000000..4e9e8ce --- /dev/null +++ b/core/locks.js @@ -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 }; diff --git a/core/snapshots.js b/core/snapshots.js new file mode 100644 index 0000000..a785197 --- /dev/null +++ b/core/snapshots.js @@ -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 /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 }; diff --git a/core/zip.js b/core/zip.js new file mode 100644 index 0000000..6eba788 --- /dev/null +++ b/core/zip.js @@ -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 }; diff --git a/tests/unit/archive.test.js b/tests/unit/archive.test.js new file mode 100644 index 0000000..d6116cf --- /dev/null +++ b/tests/unit/archive.test.js @@ -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: '

Company VPN setup

', + 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: '

VPN notes

' }); + 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')); +});