feat(fs): the fs module — a per-app file tree behind a nine-op spec, sim host, pocket-fs reference core - #238
feat(fs): the fs module — a per-app file tree behind a nine-op spec, sim host, pocket-fs reference core#238siwei-yuan wants to merge 6 commits into
Conversation
…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>
|
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 |
b85301c to
2cb4e9e
Compare
…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>
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>
…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>
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
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