Skip to content

feat: implement the fuse content write path over sealed spill files - #1156

Merged
FSM1 merged 3 commits into
mainfrom
feat/fuse-content-write-path
Aug 7, 2026
Merged

feat: implement the fuse content write path over sealed spill files#1156
FSM1 merged 3 commits into
mainfrom
feat/fuse-content-write-path

Conversation

@FSM1

@FSM1 FSM1 commented Aug 7, 2026

Copy link
Copy Markdown
Owner

What this is

The fuse content write path: write, flush, fsync, truncate and a release that journals. Before this, release closed a handle and emitted zero ops, and nothing in crates/fuse ever reached begin_write / push_chunk / commit_write.

The spill file

Bytes land in a per-handle sealed spill file in the engine data dir, exactly as blueprint/desktop.md "Reads, writes, and the never-block law" specifies:

  • XChaCha20-Poly1305 under a random per-handle key, minted from an injected entropy source (SpillArea::open(dir, entropy)) — the projection never calls an RNG.
  • The key lives only in process memory, is never persisted or logged, and zeroizes with the handle.
  • Nonces are a per-file counter. The key is fresh per handle, so a counter is unique under it by construction, and a rewritten block never repeats one.
  • One slot per framing block, nonce || ciphertext||tag, with the block index in the AAD so a slot transplanted to another offset fails to open.
  • A crash leaves ciphertext whose key died with the process — v1's plaintext cb-write-* class is gone structurally, so the spill is deleted rather than zero-overwritten. SpillArea::open sweeps a previous run's debris.

Sealing uses crates/core's AEAD; the fuse crate implements no crypto of its own.

Ops and the ack

flush / fsync / release turn what a handle holds into exactly one updateContent op. The version is assembled block by block — the spill's block if it took one, else the base version's bytes, else the zeros a hole reads as — and fed through push_chunk, so peak plaintext is one block however large the file. A block a write replaces whole never fetches the version it replaces.

The kernel is acked only once commit_write has journaled the op durably in the StagingStore. A queue that cannot journal makes flush and release fail, and the op queue is left exactly as it was — no half-formed op, no false ack.

Decisions worth reviewing

  • truncate on an open writable handle is a spill-file operation, folded into that handle's one op; with no handle it becomes its own op, so a bare truncate(2) is never silently lost. O_TRUNC is the first form: the adapter opens the handle, then truncates it to zero. Truncating to zero resolves nothing, so > file needs no network. A shrink also lowers the floor the base version may contribute from, so bytes it removed never come back if the file grows again.
  • A partial write over a version whose size is unprojected fails closed. An unprojected size is unknown, not zero; guessing zero would drop the file's untouched tail. The mount resolves the head to project it and surfaces an availability verdict if it cannot. The consequence: appending to a file whose updateContent is staged but not yet published is refused until it publishes, because the engine's content read plane resolves published heads only.
  • A handle holding writes reads what it wrote, and lookup/getattr report the length those writes leave, so a program that stats or re-reads what it just wrote does not see the old file.
  • Writes on a read-only handle are refused rather than accepted and dropped, which Access::writable previously had no caller to do.

Coverage

fuse-op-core gains the write path end to end, plus spill unit tests in crates/fuse/src/spill.rs (the gate runs cargo test -p cipherbox-fuse, so both are in it):

  • a write then release journals exactly one op, and the projected size proves it was an updateContent
  • a partial write over a real published version merges, publishes, and reads back with the untouched bytes intact; an extending write likewise
  • a fail_enqueue_after(0) outage refuses the write rather than acking it, leaving the queue untouched
  • the spill file holds no plaintext; two handles on one node seal one plaintext differently
  • a released handle leaves no spill behind, and an unreleased write dies with the mount, journaling nothing
  • a write on a read-only handle is refused; a write into the spill never parks
  • a truncate to zero on an open handle rides that handle's op; with no handle it journals its own
  • release with no writes journals nothing, and repeated flushes journal one op

What the review gates changed

The three mandatory gates ran on this diff and found one real defect plus a set of hardening items, all folded in:

  • A shrink recorded no floor on the base version. After ftruncate(fd, 4), any range at or past 4 that a later write or regrow re-covered was served from the version being replaced, so bytes a member truncated away were re-sealed into the next version and published. Pending now carries a base_len clamped by every truncate, which the assembler consults — and which collapses the old Base enum away. Two regression tests over a real published version cover it.
  • A zero-length write no longer extends the file.
  • A spill closes its file handle before unlinking, so the Windows leg really leaves nothing behind.
  • Spill files are named from entropy rather than a per-area counter, so two areas over one directory cannot unlink each other's live spill.
  • A slot is claimed before its bytes land, so a half-written slot fails closed instead of quietly falling back to the base version for a block the caller wrote.
  • A zero block size is refused at creation rather than dividing by zero later.

Rejected with reason: binding a slot generation into the AAD. It defends only against an attacker who can write the 0600 spill file, who by the same access can read the plaintext out of the mount or the process — the local uid is not a boundary this system defends.

blueprint/desktop.md said crates/fuse depends on the engine facade alone, which its own writes section contradicts by requiring the FS core to seal spill files. The narrower statement is amended to carve out core's AEAD.

Not verified here

The FUSE mount itself cannot be exercised in this environment — macFUSE-versus-FUSE-T linking breaks a local mount — so no kernel drove these paths. What needs a real mount: that a host adapter maps O_TRUNC to open-then-truncate, that close(2) surfaces the flush error, and the spill's behaviour under a real 1 MiB framing block rather than CI's 16-byte one.

SpillArea also has no production construction site yet — there is no host adapter implementation either, so the mount is not wired at the Tauri shell. Whoever lands that must construct the spill area from the same production getrandom source the engine uses; the counter-nonce argument rests on it.

Closes #1110

Note

Implement write path for FUSE content using sealed per-handle spill files

  • Adds SpillArea and SpillFile to manage a per-account spill directory with per-handle XChaCha20-Poly1305 AEAD encryption; keys are in-memory only and files are swept on mount open.
  • Extends OperationCore with write, truncate, flush, and fsync operations that buffer writes to spill files and commit a single updateContent op per handle on flush or release.
  • Partial block writes merge from the base file version; attributes() reports the projected size for unjournaled writes.
  • Adds spill_dir path utility to desktop-seams and promotes cipherbox-core to a runtime dependency of the fuse crate.
  • Risk: release is now async; callers must be updated accordingly.

Macroscope summarized 2ace6f6.

Summary by CodeRabbit

  • New Features

    • Added encrypted, per-handle spill storage for pending file changes.
    • Writable files now support staged writes, reads, truncation, flushing, syncing, and release before committing changes.
    • Added support for sparse writes, zero-filled gaps, and accurate pending file sizes.
    • Spill data is automatically secured, cleaned up, and discarded when appropriate.
  • Bug Fixes

    • Improved validation for invalid handles, offsets, sizes, and unsupported file operations.
    • Added safeguards against tampered or unavailable spill data.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The FUSE crate now supports encrypted per-handle spill files and staged content writes. Pending writes are read locally and committed through one durable updateContent operation on flush, fsync, or release. Desktop path helpers and comprehensive write-path tests were added.

Changes

FUSE content write path

Layer / File(s) Summary
Spill path and crate wiring
blueprint/desktop.md, crates/desktop-seams/src/*, crates/fuse/Cargo.toml, crates/fuse/src/cache.rs, crates/fuse/src/lib.rs
The desktop seams expose an account-local spill directory. The FUSE crate enables runtime AEAD support, adds filesystem test support, documents spill alignment, and exports SpillArea.
Sealed spill-file storage
crates/fuse/src/spill.rs
SpillArea and SpillFile create private per-handle files, encrypt block data, validate access and offsets, and remove files during cleanup.
Pending write operations
crates/fuse/src/ops.rs
OperationCore stages writes and truncations per handle. Reads use pending content. Flush, fsync, and release commit dirty content through one durable engine write. Unmount discards uncommitted state.
Write-path integration validation
crates/fuse/tests/fuse_op_core.rs
Tests cover access checks, sparse writes, journaling, encryption, key isolation, cleanup, failures, truncation, and published-file updates.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant FUSEClient
  participant OperationCore
  participant SpillFile
  participant Engine
  participant StagingStore
  FUSEClient->>OperationCore: write(handle, offset, data)
  OperationCore->>SpillFile: store encrypted pending blocks
  FUSEClient->>OperationCore: flush or release(handle)
  OperationCore->>Engine: commit updateContent
  Engine->>StagingStore: journal durable operation
  StagingStore-->>OperationCore: acknowledge durable journal
Loading

Possibly related issues

Possibly related PRs

  • FSM1/cipher-box#726 — Hardens the core AEAD APIs used for spill-file sealing.
  • FSM1/cipher-box#885 — Introduces the FUSE operation-core and integration-test surfaces extended here.
  • FSM1/cipher-box#1118 — Adds earlier FUSE operation, cache, and test infrastructure extended by spill-backed writes.

Suggested labels: release:cipherbox-fuse:feat

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The implementation covers the write, spill, release, flush, fsync, truncate, permission, durability, security, cleanup, and failure-path requirements, but no setattr behavior is shown. Implement the required setattr behavior and add focused fuse-op-core coverage for its content-write semantics.
✅ Passed checks (4 passed)
Check name Status Explanation
Out of Scope Changes check ✅ Passed The changes support the linked issue through spill storage, FUSE write operations, path wiring, documentation, and targeted tests; no unrelated code changes are evident.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: implementing the FUSE content write path using sealed spill files.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/fuse-content-write-path

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

FSM1 added 2 commits August 7, 2026 22:45
Kernel writes land in a per-handle sealed spill file — XChaCha20-Poly1305
under a random key minted from the injected entropy seam, held only in
process memory and gone when the handle closes. A crash leaves ciphertext
nothing can open, replacing v1's plaintext cb-write-* exposure class.

flush, fsync and release turn what a handle holds into exactly one
updateContent op, assembled block by block from the spill, the base
version, and the zeros a hole reads as, so peak plaintext is one block
however large the file. The kernel is acked only once that op is durable
in the StagingStore — a queue that cannot journal refuses the write
instead of losing it.

truncate on an open writable handle is a spill-file operation folded into
that handle's op; with no handle it becomes its own op, so a bare
truncate(2) is never silently lost. Writes on a read-only handle are
refused rather than accepted and dropped, and a partial write over a
version whose length is unprojected fails closed rather than guessing
zero and dropping the file's untouched tail.

Closes #1110
Review findings from this PR's own gates.

A shrink recorded no floor on the base version, so any range at or past
the new length that a later write or regrow re-covered was served from
the version being replaced instead of reading as the hole it is. Bytes a
member truncated away were re-sealed into the next version and published.
Pending now carries base_len, clamped by every truncate and consulted by
the assembler, which also collapses the Base enum away.

Also: a zero-length write no longer extends the file, a spill closes its
file handle before unlinking so the Windows leg really leaves nothing
behind, spill files are named from entropy rather than a per-area counter
so two areas over one directory cannot unlink each other's live spill, a
slot is claimed before its bytes land so a half-written slot fails closed
rather than falling back to the base version, and a zero block size is
refused at creation.
@FSM1
FSM1 force-pushed the feat/fuse-content-write-path branch from 90bc157 to d312a25 Compare August 7, 2026 20:47
@FSM1
FSM1 marked this pull request as ready for review August 7, 2026 20:48

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (2)
crates/fuse/src/spill.rs (1)

252-270: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Document why the non-unix arms carry no permission enforcement.

On Windows restrict_dir returns Ok(()) and open_private sets no ACL, so the spill directory and file inherit the parent's permissions. The confidentiality argument still holds, because the file holds ciphertext under a memory-only key. State that argument here. The unix arms are self-explanatory from the mode bits; these arms are not, and the blueprint lists WinFsp as a shipping backend.

♻️ Proposed comment
 #[cfg(not(unix))]
+/// No mode bits outside unix. The spill holds ciphertext under a key this
+/// process never writes down, so confidentiality does not rest on the file
+/// permissions; the directory only inherits the account root's ACL.
 fn open_private(path: &Path) -> io::Result<File> {
     OpenOptions::new()
         .read(true)
         .write(true)
         .create_new(true)
         .open(path)
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/fuse/src/spill.rs` around lines 252 - 270, Add comments to the
non-unix `open_private` and `restrict_dir` implementations explaining that
inherited Windows permissions are intentional because spill files contain
ciphertext encrypted with a memory-only key; note that WinFsp is a supported
backend. Leave the Unix implementations and behavior unchanged.
crates/fuse/src/ops.rs (1)

589-615: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

version_block bypasses the chunk cache, so base blocks are re-fetched on every read and again on commit.

read consults ChunkCache and inserts each fetched block. version_block calls engine.read_stream directly and never reads or fills the cache. Two consequences follow:

  • Once a handle holds pending writes, read routes to read_pending, so every repeated read of an untouched region re-fetches that region from the engine.
  • push_version calls version_block for every block of the file, so committing a one-byte patch to a large file re-fetches every untouched block from the engine.

The cache is keyed by (StreamHandle, index) and version_block already resolves the stream through stream_for, so the same key is available here. Consider routing the base-version read through the cache.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/fuse/src/ops.rs` around lines 589 - 615, Update version_block to route
base-block reads through ChunkCache using the resolved StreamHandle and block
index, reusing cached blocks and inserting fetched blocks when absent. Preserve
the existing handle validation, spill overlay, truncation clamping, zero-filled
output, and block-length behavior while replacing the direct engine.read_stream
path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/fuse/src/ops.rs`:
- Around line 643-646: Update the documentation comment for the read stream
pinning handle to remove the claim that writes replacing every block avoid the
resolve and stream slot. State only the accurate behavior supported by the
current begin_pending, base_len, and stream_for flow, preserving the rest of the
comment.
- Around line 536-541: Update the successful commit_write flow around the
pending dirty reset and Invalidation::Attributes call to first issue
Invalidation::Data { ino } for the same inode. Preserve the existing attribute
invalidation and return behavior.

In `@crates/fuse/tests/fuse_op_core.rs`:
- Around line 918-926: Narrow the never-block test comment and name to cover
only created handles, then add a test for a reopened existing file with a
projected size. Use the existing mount/open and projection setup helpers, poll
the first write on that reopened handle, and assert it is immediately
Poll::Ready without invoking resolution.

---

Nitpick comments:
In `@crates/fuse/src/ops.rs`:
- Around line 589-615: Update version_block to route base-block reads through
ChunkCache using the resolved StreamHandle and block index, reusing cached
blocks and inserting fetched blocks when absent. Preserve the existing handle
validation, spill overlay, truncation clamping, zero-filled output, and
block-length behavior while replacing the direct engine.read_stream path.

In `@crates/fuse/src/spill.rs`:
- Around line 252-270: Add comments to the non-unix `open_private` and
`restrict_dir` implementations explaining that inherited Windows permissions are
intentional because spill files contain ciphertext encrypted with a memory-only
key; note that WinFsp is a supported backend. Leave the Unix implementations and
behavior unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d450906e-4f3b-49b0-b746-6712332a3fd7

📥 Commits

Reviewing files that changed from the base of the PR and between f4d9ca5 and d312a25.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (9)
  • blueprint/desktop.md
  • crates/desktop-seams/src/lib.rs
  • crates/desktop-seams/src/paths.rs
  • crates/fuse/Cargo.toml
  • crates/fuse/src/cache.rs
  • crates/fuse/src/lib.rs
  • crates/fuse/src/ops.rs
  • crates/fuse/src/spill.rs
  • crates/fuse/tests/fuse_op_core.rs

Comment thread crates/fuse/src/ops.rs
Comment thread crates/fuse/src/ops.rs Outdated
Comment thread crates/fuse/tests/fuse_op_core.rs Outdated
@FSM1
FSM1 marked this pull request as draft August 7, 2026 20:56
A successful commit_write pushed only an attributes invalidation, so the
kernel kept serving the pre-commit pages it already held for that inode —
nothing re-binds a stream to notice the new version. Push a data
invalidation first, so the pages are gone before the kernel learns the new
size and could serve them as the new version.

Narrow the stream_for doc: a writer skips the resolve only over an
already-projected size, since an unprojected one is what the resolve yields.

Narrow the never-block write test to created handles, which is all it proved,
and add the reopened-handle case over a projected size.

State the confidentiality argument on spill.rs's non-unix arms, where the
mode bits do not make it for themselves.
@FSM1

FSM1 commented Aug 7, 2026

Copy link
Copy Markdown
Owner Author

The two nitpicks from the review body, which have no thread to reply on.

spill.rs non-unix arms — taken. restrict_dir really does return Ok(()) and open_private really does set no ACL off unix, so the spill dir and file inherit the parent's permissions, and WinFsp is a shipping backend per blueprint/desktop.md. Both arms now carry the argument that does hold: confidentiality rests on the seal, not the mode — every block is ciphertext under a per-handle key that only ever exists in memory. Stated once on open_private, cross-referenced from restrict_dir.

version_block bypassing ChunkCache — real, but not taken here; filed as #1168 (parented under #991, blocked by #1110).

The re-fetch is confirmed: read goes through the cache, version_block calls engine.read_stream directly, so every sub-block write, every read_pending window, and the whole push_version walk re-fetch base blocks that a reader already paid for.

The staleness worry does not apply — ChunkCache is keyed by (StreamHandle, index) and a stream pins one head version for its whole life (cache.rs module header), so a cached block can never be served as a slice of a different version, commit included.

What stopped me from doing it in this PR is two other things:

  1. version_block fetches want = block_bytes.min(base_len - at) and zero-fills the rest, because bytes past the floor a truncate left must read as a hole. That clamp is per-handle Pending state, not a property of the stream. Caching the clamped, zero-padded block under (stream, index) would poison read on any other handle sharing that stream; reading a cached full block without re-applying the clamp would re-introduce exactly what d312a25b3 — this branch's previous commit — fixed. A correct version caches unclamped and clamps at use.
  2. push_version walks the file linearly once. Inserting every base block it touches evicts the reader's hot blocks for zero reuse, against a 64-block budget. The commit walk wants read-through-without-insert, which the cache does not offer today.

So the fix is a semantics change on the clamp this branch's head commit had just corrected, plus a new cache mode — not a small one, and not one I want landing untested alongside the invalidation fix. #1168 carries both constraints and the acceptance criteria.

@FSM1
FSM1 marked this pull request as ready for review August 7, 2026 21:22
@FSM1
FSM1 merged commit 6d38a10 into main Aug 7, 2026
37 checks passed
@FSM1
FSM1 deleted the feat/fuse-content-write-path branch August 7, 2026 21:23
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.

desktop: implement the fuse content write path, spill files, and the release journal ack

1 participant