feat(fs): the fs module — a per-app file tree behind a nine-op spec, sim host, pocket-fs reference core - #238
Conversation
|
On-device verification added:
🤖 Generated with Claude Code |
1128d94 to
8879b21
Compare
|
Design revision after review (PR body updated to match):
Re-verified end to end after the revision: full suite green, clippy clean, espidf cross-compile clean, and on-device 🤖 Generated with Claude Code |
2cb4e9e to
afc8d4e
Compare
…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>
…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>
afc8d4e to
a17c02c
Compare
doodlewind
left a comment
There was a problem hiding this comment.
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)
tests/fs.test.tswas never wired into the gate. The PR body claims 331+ passing, butbun run test's unit stage is a hand-maintained list intools/test.tsand this file's entry was missing — the gate actually ran 316. Wired in; the stage now runs 331. (main'stest-suite.test.tsguard only checksiphone2g-*files — generalizing it is the follow-up below.)fsValidSegmentaccepted 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.- sim
list()mishandled a negative offset — passed straight toArray.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. - docs/FS.md now states the chunking atomicity caveat. The op is the atomic unit, so an SDK write above
FS_MAX_IO_BYTEScrosses 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. - 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.
- Minor: dropped a dead
padcounter inbytes.tscarried 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-fs12/12 (incl. the Dir-storage atomicity + symlink-escape test), clippy clean,cargo check --workspacecleantests/fs.test.ts+tests/db.test.ts33/33; unit stage 330/331 — the 1 fail is the pre-existing symbian-runtime Gatekeeper flake described in #231, unrelated to this diffbunx tsc --noEmitclean- 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.tsto require everytests/*.test.tsto 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
doodlewind
left a comment
There was a problem hiding this comment.
Thanks for the feat!
…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>
a17c02c to
01bfd0e
Compare
|
Verdict: merge. The 2026-08-07 review findings are already folded in as the branch's fix commit (fs tests wired into the gate — the suite the PR claimed was never running them; lone-surrogate segment names refused by the shared predicate; sim What changed since: rebased over Verified on the rebased head:
Design conformance, for the record: spec-first nine-op boundary with universal byte-identity names and isolation by construction ( |
The fifth module-shaped vertical slice (after
ui,strike,audioand db), built spec-first per the module discipline: a per-app file tree mounted asglobalThis.fs, capability iddata.fs.The boundary
contracts/spec/fs.tspins nine synchronous ops —read/write/remove/list/stat/mkdir/rename/usage/lastError— plus:.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;.., 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/;{"$b": base64}spelling; 64 KiB per op crossing (the SDK chunks larger files transparently);tmpand clears it on construction, so the app's tree never shows host machinery and a crash orphan cannot outlive the next boot;FS_MAX_DIR_ENTRIES(256) per call with offset + eof;statcarries no mtime — a timestamp is the fs spelling ofDate.now, excluded for the same golden-test reason. Nowatch().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) andwrite(), plus the node:fs sync subset Bun implements (readFileSync,writeFileSync,appendFileSync,mkdirSync,readdirSync,rmSync,renameSync,statSync,existsSync), andusage(). Everything returns synchronously per the frame contract, andawaitunwraps 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 viabootWorldextraGlobals; byte-exact names, code-point-sorted listings.engine/crates/pocket-fs— the reference core:Storage::Memory/Storage::Dir { root, tmp }overstd::fs, root confinement, per-segment lstat symlink refusal, atomic truncate writes through the host-owned temp dir, per-app quota.mountis a default feature;default-features = falsedrops the pocket-mod dependency for firmware with its own QuickJS wiring — verified tocargo checkclean forriscv32imafc-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, thedata/+tmp/layout, and the three-move adoption path.data.fsregistered ahead of any stock TARGET advertising it — theaudio.pcm/data.sqliteprecedent: the sim host and the reference core implement and test the whole contract.Verified
bun run test11/11 stages green (tests/fs.test.ts15 pass: the op contract and the SDK; suite 331+ pass, contract byte-compare included)cargo test -p pocket-fs12/12, including a live QuickJS guest round-trip, universal-name round-trips, orphan-sweep, and the symlink-escape refusalcargo check --workspaceclean, clippy cleancargo check --target riscv32imafc-esp-espidf(no default features) cleanDATA-SMOKE: PASS boot=1/PASS boot=2on a Waveshare ESP32-P4 rev 1.3 (fs contract ~0.45 s, 288-row tx ~0.4–0.6 s)🤖 Generated with Claude Code