Skip to content

feat(fs): the fs module — a per-app file tree behind a nine-op spec, sim host, pocket-fs reference core - #238

Open
siwei-yuan wants to merge 6 commits into
pocket-stack:mainfrom
siwei-yuan:feat/fs-surface
Open

feat(fs): the fs module — a per-app file tree behind a nine-op spec, sim host, pocket-fs reference core#238
siwei-yuan wants to merge 6 commits into
pocket-stack:mainfrom
siwei-yuan:feat/fs-surface

Conversation

@siwei-yuan

@siwei-yuan siwei-yuan commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Stacked on #231 (the db module) — review the last commit pair; the first two are #231's. Will rebase to a clean diff once #231 merges.

The fifth module-shaped vertical slice (after ui, strike, audio and db), built spec-first per the module discipline: a per-app file tree mounted as globalThis.fs, capability id data.fs.

The boundary

contracts/spec/fs.ts pins nine synchronous ops — read / write / remove / list / stat / mkdir / rename / usage / lastError — plus:

  • universal names: a segment is any well-formed Unicode — Chinese names, dot-prefixed names, spaces — except . and .. (the escape hatches), control characters, and oversize (64 UTF-8 bytes / segment, 8 segments, 160 bytes / path). No name is reserved to the host. Names are byte-for-byte identities (no case folding, no normalization) — the folding-filesystem caveat is documented, and the deterministic hosts are byte-exact so goldens catch collisions early;
  • isolation by construction, independent of names: every path is relative and resolves under the root the host binds at mount; .., absolute paths, and /-in-name are unrepresentable, so apps cannot spell each other's trees — the ATTACH-refusal principle generalized. The reference core lstat-refuses symlinks planted by host-side actors. Privilege is the binding: on Pocket Pi the device agent is the same module bound at /workspace, apps at /workspace/apps/<id>/data/;
  • payload encoding: text as a JSON string (stored as UTF-8), bytes as the db module's {"$b": base64} spelling; 64 KiB per op crossing (the SDK chunks larger files transparently);
  • atomicity with a clean tree: truncate writes land in the module's own temp directory — outside the bound root, same filesystem — then rename over the target (the power-loss contract LittleFS hosts inherit). The module owns tmp and clears it on construction, so the app's tree never shows host machinery and a crash orphan cannot outlive the next boot;
  • paged list: sorted by Unicode code point (= UTF-8 byte order; the sim host carries the comparator since JS sorts UTF-16 code units), FS_MAX_DIR_ENTRIES (256) per call with offset + eof;
  • frame contract: no clock, no events, and stat carries no mtime — a timestamp is the fs spelling of Date.now, excluded for the same golden-test reason. No watch().

One data root serves both data modules: a database is an ordinary file (<root>/<name>.sqlite) in the app's home — its own asset, visible like any of its files (backup = a file copy). Overwriting it corrupts the app's own data, the same trust class as deleting its own files; SQLite fails loudly on a corrupt image.

What ships

  • @pocketjs/framework/fs — the Bun shape: file() (.text/.bytes/.json/.size/.exists) and write(), plus the node:fs sync subset Bun implements (readFileSync, writeFileSync, appendFileSync, mkdirSync, readdirSync, rmSync, renameSync, statSync, existsSync), and usage(). Everything returns synchronously per the frame contract, and await unwraps a plain value, so Bun-idiomatic code (await Bun.file(p).text()) migrates unchanged. Throws where the namespace is unmounted, like db: file code that silently drops writes is a corruption bug.
  • framework/src/bytes.ts — the base64 codec extracted from db-api plus a strict UTF-8 decoder (QuickJS has no TextDecoder), shared by both data SDKs.
  • hosts/sim/fs.ts — in-memory tree behind the op namespace, injected via bootWorld extraGlobals; byte-exact names, code-point-sorted listings.
  • engine/crates/pocket-fs — the reference core: Storage::Memory / Storage::Dir { root, tmp } over std::fs, root confinement, per-segment lstat symlink refusal, atomic truncate writes through the host-owned temp dir, per-app quota. mount is a default feature; default-features = false drops the pocket-mod dependency for firmware with its own QuickJS wiring — verified to cargo check clean for riscv32imafc-esp-espidf.
  • hosts/esp32p4/examples/data-smoke — the ppa-smoke pattern's second instance: both module cores driven on a real ESP32-P4 over LittleFS (see the commit for measured numbers; persistence proven across resets, including SQLite transaction atomicity through interrupted runs — the sample count stays a multiple of 288).
  • docs/FS.md — the boundary, the data/ + tmp/ layout, and the three-move adoption path.
  • data.fs registered ahead of any stock TARGET advertising it — the audio.pcm / data.sqlite precedent: the sim host and the reference core implement and test the whole contract.

Verified

  • bun run test 11/11 stages green (tests/fs.test.ts 15 pass: the op contract and the SDK; suite 331+ pass, contract byte-compare included)
  • cargo test -p pocket-fs 12/12, including a live QuickJS guest round-trip, universal-name round-trips, orphan-sweep, and the symlink-escape refusal
  • cargo check --workspace clean, clippy clean
  • cargo check --target riscv32imafc-esp-espidf (no default features) clean
  • On-device: DATA-SMOKE: PASS boot=1 / PASS boot=2 on a Waveshare ESP32-P4 rev 1.3 (fs contract ~0.45 s, 288-row tx ~0.4–0.6 s)

🤖 Generated with Claude Code

…ket-db reference core

The fourth module-shaped vertical slice (after ui, strike and audio),
built spec-first per the module discipline: contracts/spec/db.ts pins
five synchronous ops (open/close/exec/query/lastError), the JSON value
encoding (blobs as {"$b": base64}, integers past 2^53-1 fail loudly),
logical database names the host maps under the app's own data root, and
the resource ceilings (4 databases, 4096 result rows per query).

Statement caching is host-side, keyed by the sql string — the guest
holds no statement handles, so there is nothing to finalize and nothing
to leak. The module owns no clock and emits no events: every op
completes inside the guest's single per-tick turn, and golden-tested
apps must not depend on random()/'now'-relative SQL (the Date.now rule
applied to the dialect).

ATTACH — the one SQL statement that names a file — is refused so the
app's data root stays the sandbox boundary; load_extension stays off.

- gen-rust emits pub mod db into engine/core/src/spec.rs (drift-guarded)
- data.sqlite capability registered ahead of any stock TARGET
  advertising it, the audio.pcm precedent: the sim host and the
  reference core implement and test the whole contract
- @pocketjs/framework/db SDK: the bun:sqlite shape (Database, cached
  Statement .get/.all/.values/.run, transaction with savepoint
  nesting), throwing where the namespace is unmounted — data code that
  silently drops writes is a corruption bug, not a missing enhancement
- hosts/sim/db.ts: bun:sqlite behind the op namespace, injected via
  bootWorld extraGlobals; tests/db.test.ts runs the op contract, the
  SDK, and an oracle comparison against bun:sqlite directly
- engine/crates/pocket-db: the reference core over rusqlite (bundled),
  a real SQLite authorizer for the ATTACH refusal, Storage::Memory/Dir,
  mountable as globalThis.db on any pocket-mod guest — the adoption
  path a device host copies, with SQLite's own VFS as the port point
- docs/DB.md maps the boundary and the three-move adoption path

Verified: bun run test 11/11 stages green (tests/db.test.ts 17 pass;
suite 313+ pass), cargo test -p pocket-db 9/9 including a live QuickJS
guest round-trip, cargo check --workspace clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@siwei-yuan

Copy link
Copy Markdown
Contributor Author

On-device verification added: hosts/esp32p4/examples/data-smoke (the ppa-smoke pattern — one building block, one hardware verifier) runs both module cores with default-features = false against a LittleFS partition on a Waveshare ESP32-P4 rev 1.3:

DATA-SMOKE: fs ok in 505.072ms; boot 1; usedBytes 12299
DATA-SMOKE: db ok in 1.044217s (288-row tx 538.36ms)
DATA-SMOKE: heap before 582008 after 580780 (delta 1228)
DATA-SMOKE: PASS boot=1

PASS boot=2 after another reset — a boot counter (fs truncate-write) and a per-boot 288-row transaction persist across resets, verified as boots × 288 on every start. The traversal/dot-name refusals, the ATTACH refusal, and the .db/ fence location all hold on-device.

🤖 Generated with Claude Code

@siwei-yuan

Copy link
Copy Markdown
Contributor Author

Design revision after review (PR body updated to match):

  1. Names are now universal. The earlier filename-safe-token grammar over-restricted: apps could not name their own files in Chinese or with a leading dot, and the restriction was never load-bearing for isolation (that's the binding + unrepresentable ../absolute paths + symlink refusal, unchanged). A segment is now any well-formed Unicode except ./.., control chars, and oversize. Nothing is reserved to the host.
  2. Atomic-write temps moved out of the app's tree. Storage::Dir { root, tmp }: temps land in a host-owned directory outside the bound root (same filesystem — rename stays atomic), which the module clears on construction. The app's tree provably holds only the app's own names, and a crash orphan cannot outlive the next boot — a guarantee the old in-tree dot-file scheme could never give once dot names became legal.
  3. The .db/ fence is gone (with feat(db): the db module — SQLite behind a five-op spec, sim host, pocket-db reference core #231's update): a database is an ordinary visible file in the shared data root. The fence protected an app from itself — an app that can already remove("", recursive) its whole home gains nothing from one file being unspellable, and visibility makes backup a file copy.

Re-verified end to end after the revision: full suite green, clippy clean, espidf cross-compile clean, and on-device PASS boot=1 / PASS boot=2 — including universal names (CJK + dot-prefixed) round-tripping on real LittleFS, and SQLite transaction atomicity witnessed across interrupted runs (sample count stays a multiple of 288 through resets).

🤖 Generated with Claude Code

@siwei-yuan
siwei-yuan force-pushed the feat/fs-surface branch 5 times, most recently from b85301c to 2cb4e9e Compare August 6, 2026 19:07
…he app root

Two changes from the ESP32-P4 bring-up, both invisible on desktop:

- cfg(target_os = "espidf") support ships in the crate: the newlib shims
  SQLite's syscall table references (geteuid/fchmod/fchown/utimes/readlink
  no-ops — honest on a filesystem with no users or symlinks — and
  nanosleep routed through usleep for the busy handler), plus the
  unix-none VFS on open (LittleFS has no fcntl locks; a module instance
  is its files' only writer) with the flash-friendly pragmas
  (journal_mode=TRUNCATE, synchronous=NORMAL, cache_size=-32). `mount` is
  now a default feature — default-features = false drops the
  pocket-mod/rquickjs dependency for firmware with its own QuickJS wiring,
  so an MCU build compiles only the module core plus SQLite.

- Storage::Dir creates the data root on first open and maps a name to
  <dir>/<name>.sqlite — an ORDINARY file in the app's own home, the same
  root the fs module is typically bound to. The database is the app's own
  asset, deliberately visible and touchable like any of its files (backup
  = a file copy); overwriting it corrupts the app's own data, the same
  trust class as deleting its own files, and SQLite fails loudly on a
  corrupt image.

The build-environment half a firmware must supply (LIBSQLITE3_FLAGS, the
empty sys/ioctl.h shim, arch CFLAGS) is documented in docs/DB.md —
values validated on a Waveshare ESP32-P4 over a LittleFS workspace:
open 15 ms, 288-row transaction ~0.4 s, ~70–80 KB heap, data intact
across reopen and power cycling.

Verified: cargo test -p pocket-db 9/9, clippy clean, cargo check
--no-default-features clean, cargo check --target
riscv32imafc-esp-espidf (no default features) clean incl. the bundled
libsqlite3.a.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
doodlewind and others added 4 commits August 7, 2026 11:50
…th the reference core

Review findings on pocket-stack#231, verified by probing both hosts with the same
binding/ATTACH matrix:

- ATTACH <expr> AS x bypassed BOTH refusals: rusqlite maps a NULL
  filename (any non-literal ATTACH argument) to AuthAction::Unknown, so
  the authorizer's catch-all allowed it, and the sim regex only matched
  the DATABASE-keyword and string-literal spellings — the probe left a
  real file on disk from each host. The core now also sets
  SQLITE_LIMIT_ATTACHED=0 (rusqlite "limits" feature) so every spelling
  is refused at the engine level; the sim matches the word "attach"
  anywhere (the documented false-positive trade widens accordingly).
- The sim silently accepted named parameters without the $/:/@ prefix
  (bun binds bare keys) where the reference core fails with "unknown
  parameter" — an app developed on the sim would break on device. The
  sim now refuses them with the core's message; the remaining leniency
  (a PREFIXED key the statement never names is ignored by bun, loud on
  the core) is documented, since bun exposes no parameter-name
  introspection.
- The sim host's header claimed named databases persist "the way a
  device keeps its files", but close() dropped the data that a
  Storage::Dir host keeps. Closed named databases are now stashed with
  serialize() and restored on reopen, and the close/reopen path is
  pinned in the SDK test.
- DB_NAME_PATTERN tightened from 64 to 57 chars so the reference
  mapping <name>.sqlite (+7 bytes) stays within the fs module's
  64-byte segment ceiling (pocket-stack#238) — without this, a max-length database
  file is invisible to a co-mounted fs module, contradicting the
  "visible like any of its files" contract both PRs document.

cargo test -p pocket-db 10/10 (new expression-ATTACH test), clippy
clean, tests/db.test.ts 18/18, bunx tsc --noEmit clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sim host, pocket-fs reference core

The fifth module-shaped vertical slice (after ui, strike, audio and db),
built spec-first per the module discipline: contracts/spec/fs.ts pins
nine synchronous ops (read/write/remove/list/stat/mkdir/rename/usage/
lastError), the payload encoding (text as a JSON string, bytes as the db
module's {"$b": base64} spelling), the path grammar, and the resource
ceilings (64 KiB per payload crossing, 256 entries per paged list call).

Names are UNIVERSAL: a segment is any well-formed Unicode — Chinese
names, dot-prefixed names, spaces — except "." and ".." (the escape
hatches), control characters, and oversize (64 UTF-8 bytes). No name is
reserved to the host. Isolation never depended on names: every path is
relative and resolves under the root the host binds at mount, ".."/
absolute/"/"-in-name are unrepresentable, and the reference core
lstat-refuses symlinks planted by host-side actors — so apps cannot
spell each other's trees, the ATTACH-refusal principle generalized.
Privilege is the binding: on Pocket Pi the device agent is the same
module bound at /workspace, apps at /workspace/apps/<id>/data/.

Truncate writes are atomic (O_EXCL temp beside the target + rename — the
power-loss contract LittleFS hosts inherit from their atomic rename).
Entries list in Unicode code point order (= UTF-8 byte order; the sim
host carries the comparator since JS sorts UTF-16 code units). The
module owns no clock, emits no events, and stat carries NO mtime — a
timestamp is the fs spelling of Date.now, excluded for the same
golden-test reason.

- gen-rust emits pub mod fs into engine/core/src/spec.rs (drift-guarded)
- data.fs capability registered ahead of any stock TARGET advertising
  it, the audio.pcm/data.sqlite precedent
- @pocketjs/framework/fs SDK: the Bun shape — file()/write() plus the
  node:fs sync subset Bun implements — so Bun file code migrates
  unchanged (await unwraps the sync returns); payloads chunk
  transparently past FS_MAX_IO_BYTES; throws where the namespace is
  unmounted, like db
- framework/src/bytes.ts: the base64 codec extracted from db-api plus a
  strict UTF-8 decoder (QuickJS has no TextDecoder), shared by both SDKs
- hosts/sim/fs.ts: in-memory tree behind the op namespace, injected via
  bootWorld extraGlobals; tests/fs.test.ts runs the op contract and the
  SDK
- engine/crates/pocket-fs: the reference core, Storage::Memory/Dir over
  std::fs — root confinement, symlink refusal, atomic truncate writes,
  per-app quota. mount is a default feature; default-features = false
  drops the pocket-mod dependency for firmware with its own QuickJS
  wiring — verified to cargo check clean for riscv32imafc-esp-espidf
- docs/FS.md maps the boundary, the shared-root layout with db, and the
  three-move adoption path

Verified: bun run test 11/11 stages green (tests/fs.test.ts 15 pass),
cargo test -p pocket-fs 12/12 including a live QuickJS guest round-trip
and universal-name round-trips, cargo check --workspace clean, clippy
clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
hosts/esp32p4 grows its second example, following the ppa-smoke pattern
(one building block, one on-device verifier): pocket-fs and pocket-db
driven directly (default-features = false — no QuickJS, the way a device
host with its own guest wiring consumes the cores) against a LittleFS
partition on the ESP32-P4.

The smoke runs both contracts' hardware-facing edges — fs write/append/
chunked read/sorted list/rename/recursive-remove, universal names
(dot-prefixed and CJK) on real LittleFS, and the traversal refusal; db
DDL, a 288-row single-transaction insert, the ATTACH refusal, and the
database as an ordinary file in the shared data root — and proves
persistence rather than asserting it: an fs boot counter survives
resets, and the sample count stays a multiple of 288 across power
cycles, SQLite's transaction atomicity witnessed through the module
(an interrupted run contributes exactly zero rows).

Measured on a Waveshare ESP32-P4 rev 1.3 (UART transcript):

  DATA-SMOKE: fs ok in 453.626ms; boot 1; usedBytes 11
  DATA-SMOKE: db ok in 557.143ms (288-row tx 338.076ms)
  DATA-SMOKE: PASS boot=1
  ...and PASS boot=2 after another reset (576 rows verified).

The example is self-contained: pinned nightly + build-std, its own
partition table (an 8 MB LittleFS "workspace"), the SQLite build recipe
from docs/DB.md as committed .cargo config, and cc/ar wrappers that find
the esp-idf-sys-installed toolchain (IDF_TOOLS_PATH reuses an existing
install).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…Unicode claim

Review findings on pocket-stack#238:

- tests/fs.test.ts was never added to the tools/test.ts unit stage, so
  `bun run test` ran 316 tests, not the 331+ the PR body claims — the
  suite list is hand-maintained and the PR missed its own entry. Wired
  in; the stage now runs 331.
- The path grammar documents "any well-formed Unicode" but
  fsValidSegment accepted unpaired surrogates, which have no UTF-8
  spelling: a JS host stores one byte-exactly while the QuickJS-to-
  native bridge mangles it into a DIFFERENT name — silent identity
  divergence between sim and a native core. The shared predicate now
  refuses them (pinned in tests); the spec also documents that a text
  PAYLOAD with a lone surrogate is host-dependent, so arbitrary bytes
  belong in the {"$b"} spelling.
- sim list() passed a negative offset straight to Array.slice, which
  wraps to slice-from-the-end where the reference core clamps to 0 —
  clamped to match (cross-host parity probe now byte-agrees on the
  whole op matrix modulo JSON key order).
- docs/FS.md now states the chunking caveat: the op is the atomic
  unit, so an SDK write above FS_MAX_IO_BYTES crosses as truncate +
  appends and power loss between chunks can keep only the leading
  chunks; whole-file atomicity above 64 KiB is write-sibling + rename.
- site/content/docs/concepts.md's module diagram enumerated ui/audio/
  strike; db and fs join it (both PRs describe themselves as the 4th
  and 5th modules but neither updated the enumerating docs page).
- bytes.ts: dropped a dead `pad` counter carried over from db-api.

cargo test -p pocket-fs 12/12, clippy clean, cargo check --workspace
clean, tests/fs.test.ts + db.test.ts 33/33, bunx tsc --noEmit clean,
unit stage 330/331 (the 1 fail is the pre-existing Gatekeeper first-
launch stall in symbian-runtime.test.ts, present on main).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@doodlewind doodlewind left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: approved. Same quality bar as #231 — the nine-op boundary is clean, the atomic-write contract is carefully built (host-owned temp outside the root, swept on construction), and the Dir backend's symlink refusal is real. I ran the same op matrix through the sim and the Memory core, found the issues below, and pushed the fixes as a17c02c. This branch is rebased onto #231's fixed head, so it will show a clean diff once #231 lands.

What the review found (and the commit fixes)

  1. tests/fs.test.ts was never wired into the gate. The PR body claims 331+ passing, but bun run test's unit stage is a hand-maintained list in tools/test.ts and this file's entry was missing — the gate actually ran 316. Wired in; the stage now runs 331. (main's test-suite.test.ts guard only checks iphone2g-* files — generalizing it is the follow-up below.)
  2. fsValidSegment accepted unpaired surrogates, contradicting the spec's "any well-formed Unicode". A lone surrogate has no UTF-8 spelling: a JS host stores it byte-exactly while the QuickJS→native bridge mangles it into a different name — silent identity divergence between sim and a native core, exactly the class of bug the byte-exact hosts exist to catch. The shared predicate now refuses it (pinned in tests), and the spec documents that a text payload with a lone surrogate is likewise host-dependent, so arbitrary bytes belong in the {"$b"} spelling.
  3. sim list() mishandled a negative offset — passed straight to Array.slice, which wraps to slice-from-the-end, where the reference core clamps to 0. Clamped to match. A cross-host parity probe now byte-agrees on the whole op matrix (modulo JSON key order), including that astral-plane names sort by code point on both hosts.
  4. docs/FS.md now states the chunking atomicity caveat. The op is the atomic unit, so an SDK write above FS_MAX_IO_BYTES crosses as truncate + appends and power loss between chunks can leave the leading chunks only — whole-file atomicity above 64 KiB is write-a-sibling + rename, the same move the module itself makes. The single-op atomicity contract was well-documented; this closes the multi-op gap.
  5. concepts.md's module diagram enumerated ui/audio/strike; db and fs join it. Both PRs call themselves the 4th and 5th modules, but neither updated the page that enumerates them.
  6. Minor: dropped a dead pad counter in bytes.ts carried over from the db-api extraction.

Verified consistent, left as-is: the Dir backend's lstat-per-segment symlink refusal (the security-critical path) round-trips correctly and hides planted links from read/stat/list; universal names (Chinese, dot-prefixed, spaces) round-trip byte-exact; the quota accounting agrees between hosts.

Verified

  • cargo test -p pocket-fs 12/12 (incl. the Dir-storage atomicity + symlink-escape test), clippy clean, cargo check --workspace clean
  • tests/fs.test.ts + tests/db.test.ts 33/33; unit stage 330/331 — the 1 fail is the pre-existing symbian-runtime Gatekeeper flake described in #231, unrelated to this diff
  • bunx tsc --noEmit clean
  • The on-device data-smoke (both module cores on a real ESP32-P4 over LittleFS, persistence across resets incl. SQLite tx atomicity) is taken as hardware-verified per the ppa-smoke precedent.

Left alone / follow-ups (not this PR)

  • Generalize test-suite.test.ts to require every tests/*.test.ts to have a stage home (with an explicit exclusion list), so the next module can't repeat finding #1.
  • The symbian-runtime 5s-timeout flake on managed macOS hosts (shared with #231).

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants