Files
StepForge/app/preload.js
T
TylerandClaude Fable 5 8aa9756b8a
Template tests / tests (pull_request) Failing after 33s
Harden archives, snapshots, locks, and search against corruption and races
Phase 1/2 of the improvement plan (PR 6 of the sequence). Recovery and
resource-limit hardening for the storage-adjacent modules.

ZIP resource limits (core/zip.js):
- unzipSync now enforces entry-count, total compressed, total inflated, and
  per-entry inflated budgets, and caps inflation with inflateRawSync
  maxOutputLength so a deflate bomb can't exhaust memory. Exact inflated-size
  match (not "at least") and CRC verification are kept. Import uses the
  default limits.

Transactional archive import (core/archive.js):
- The import validates the guide AND every step before writing anything, then
  stages the whole guide in a temp directory and publishes it with a single
  atomic rename. A corrupt step no longer leaves a partial guide in the
  library; a failure cleans up the staging directory.

Atomic snapshot restore (core/snapshots.js):
- Restore extracts and validates into a temp directory first; only then does
  it swap content in, moving live content aside so a mid-swap failure rolls
  back. A corrupt/truncated snapshot can no longer destroy the live guide
  (the old restore deleted live content before extracting).
- Fixed snapshot filename collisions: names kept milliseconds so two backups
  in the same second no longer overwrite each other.

Automatic backups (core/snapshots.js):
- Implemented the previously-dead backups.automatic/everyNSaves/keepLast
  settings: autoSnapshotIfDue snapshots every N saves and prunes to keepLast,
  wired into the save choke point in main.js. Never throws — a backup failure
  cannot break the save that triggered it.

Exclusive locks (core/locks.js):
- acquireLock uses O_CREAT|O_EXCL (flag 'wx') so only one writer wins the
  race; the old read-then-write left a window where two writers both believed
  they held the lock. Added a per-acquisition token so release only removes
  the exact lock it took (never one a force-steal replaced). Same-process
  re-acquire still succeeds; cross-process fresh locks conflict.

Search reconciliation (core/search.js):
- New reconcile(store) rebuilds/repairs the index against the library at
  startup using per-guide fingerprints (updatedAt+revision): reindexes new/
  changed guides, drops entries for deleted ones, and exposes a recovery
  status ('ok'|'reset'|'reconciled'). A missing/corrupt/version-mismatched
  index recovers instead of silently returning nothing. Wired into startup.

Recovery surface:
- New recovery:status IPC + preload method returns quarantined files (this
  session) and the search index status so the UI can surface data issues.

Tests: ZIP bomb/limits, transactional import abort with no partial guide,
atomic snapshot restore preserving the live guide on corruption, exclusive
lock conflict/steal/release-by-token, same-process re-acquire, search
reconcile (rebuild/drop/reindex/corrupt-reset), and automatic backup
cadence/pruning. 255 unit tests pass; startup smoke and workflow E2E pass.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 23:10:21 -05:00

114 lines
3.5 KiB
JavaScript

'use strict';
const { contextBridge, ipcRenderer } = require('electron');
/**
* The complete privileged API exposed to the sandboxed renderer. Every call
* is an explicit invoke; no raw ipcRenderer or Node access leaks through.
*/
const invoke = (channel) => (args) => ipcRenderer.invoke(channel, args);
const api = {
library: {
list: invoke('library:list'),
create: invoke('library:create'),
duplicate: invoke('library:duplicate'),
delete: invoke('library:delete'),
setFavorite: invoke('library:setFavorite'),
trashList: invoke('library:trash:list'),
trashRestore: invoke('library:trash:restore'),
trashPurge: invoke('library:trash:purge'),
},
folders: {
create: invoke('folders:create'),
rename: invoke('folders:rename'),
delete: invoke('folders:delete'),
moveGuide: invoke('folders:moveGuide'),
},
guide: {
get: invoke('guide:get'),
save: invoke('guide:save'),
},
step: {
add: invoke('step:add'),
save: invoke('step:save'),
delete: invoke('step:delete'),
restore: invoke('step:restore'),
reorder: invoke('steps:reorder'),
imagePath: invoke('step:imagePath'),
setWorkingImage: invoke('step:setWorkingImage'),
resetWorkingImage: invoke('step:resetWorkingImage'),
fromClipboard: invoke('step:fromClipboard'),
importImage: invoke('step:importImage'),
},
search: {
query: invoke('search:query'),
titles: invoke('search:titles'),
},
settings: {
all: invoke('settings:all'),
set: invoke('settings:set'),
globalPlaceholders: invoke('placeholders:globals:get'),
setGlobalPlaceholders: invoke('placeholders:globals:set'),
},
ai: {
test: invoke('ai:test'),
fillStep: invoke('ai:fillStep'),
rewriteText: invoke('ai:rewriteText'),
cancel: invoke('ai:cancel'),
},
capture: {
shoot: invoke('capture:shoot'),
region: invoke('capture:region'),
session: invoke('capture:session'),
state: invoke('capture:state'),
onAdded: (fn) => ipcRenderer.on('capture:added', (e, payload) => fn(payload)),
onState: (fn) => ipcRenderer.on('capture:state', (e, payload) => fn(payload)),
onStepUpdated: (fn) => ipcRenderer.on('step:updated', (e, payload) => fn(payload)),
},
archive: {
export: invoke('archive:export'),
open: invoke('archive:open'),
saveLinked: invoke('archive:saveLinked'),
},
snapshots: {
list: invoke('snapshots:list'),
create: invoke('snapshots:create'),
restore: invoke('snapshots:restore'),
},
recovery: {
status: invoke('recovery:status'),
},
templates: {
list: invoke('templates:list'),
load: invoke('templates:load'),
save: invoke('templates:save'),
delete: invoke('templates:delete'),
rename: invoke('templates:rename'),
duplicate: invoke('templates:duplicate'),
export: invoke('templates:export'),
import: invoke('templates:import'),
},
export: {
formats: invoke('export:formats'),
defaults: invoke('export:defaults'),
run: invoke('export:run'),
chooseDir: invoke('export:chooseDir'),
preview: invoke('export:preview'),
cleanupPreviews: invoke('preview:cleanup'),
},
shell: {
// Intent-specific shell access only: files the main process produced,
// the guide's linked archive, and scheme-validated external links.
openProduced: invoke('shell:openProduced'),
revealLinkedArchive: invoke('shell:revealLinkedArchive'),
openExternal: invoke('shell:openExternal'),
},
app: {
info: invoke('app:info'),
},
};
contextBridge.exposeInMainWorld('stepforge', api);