Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
08373e0
docs(52): capture phase context
FSM1 Jun 19, 2026
0114147
docs(state): record phase 52 context session
FSM1 Jun 19, 2026
e7321b8
docs(fuse): research phase 52 FUSE journal durability and at-rest safety
FSM1 Jun 19, 2026
cafd437
docs(52): add research and validation strategy
FSM1 Jun 19, 2026
615c001
docs: create phase 52 plan
FSM1 Jun 19, 2026
bd9d28e
docs(52): record planning completion and wave deps
FSM1 Jun 19, 2026
f93b725
Merge remote-tracking branch 'origin/main' into feat/desktop-fuse-dur…
FSM1 Jun 20, 2026
a117444
fix: harden FUSE journal at-rest safety with path scrub and removal l…
FSM1 Jun 20, 2026
955bd16
feat: add ciphertext sidecar and encrypted-name journal shape
FSM1 Jun 20, 2026
935a390
feat: enforce write-path size cap, filename encryption, off-thread du…
FSM1 Jun 20, 2026
275f97d
feat: replay from ciphertext sidecar with name decryption, timeout, c…
FSM1 Jun 20, 2026
9c8ed33
feat: purge vault journal on logout and GC failed entries at mount
FSM1 Jun 20, 2026
e04d71b
docs: record phase 52 completion and unsigned-commit ship state
FSM1 Jun 20, 2026
d4cef85
refactor: dedupe journal-name legacy fallback and path-scrub skip loop
FSM1 Jun 20, 2026
3558220
docs: add phase 52 verification, security, and validation reports
FSM1 Jun 20, 2026
a5463c1
style: wrap logout purge log line to satisfy rustfmt
FSM1 Jun 20, 2026
8b75fe4
fix: run journal GC on the Windows mount path for parity with Unix
FSM1 Jun 20, 2026
2e15499
chore(release): set release targets for PR #533
github-actions[bot] Jun 20, 2026
77e2ef3
fix: address PR review comments for FUSE durability
FSM1 Jun 20, 2026
1bece5b
docs: move consumed phase 52 todo to completed
FSM1 Jun 20, 2026
c141dd4
fix: address CodeRabbit full-review findings for FUSE durability
FSM1 Jun 20, 2026
9dba0e0
fix(fuse): stop parking hex-shaped legacy journal names as corrupt
FSM1 Jun 20, 2026
c90394a
Merge branch 'main' into feat/desktop-fuse-durability-at-rest-safety
FSM1 Jun 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions .planning/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -862,18 +862,29 @@ Plans:

### Phase 52: Desktop FUSE Durability & At-Rest Safety

**Goal:** [To be planned]
**Goal:** Bound and harden the desktop FUSE write-journal so large-file writes never block or OOM the filesystem, replay never stalls the mount, retention is bounded across vaults, and no plaintext filename or host path persists at rest.
**Requirements**: HARD-03
**Depends on:** Phase 49 (v1.1 baseline)
**Plans:** 0 plans
**Plans:** 5 plans

Scope (captured todos):

- [ ] **[#9]** FUSE write-journal unbounded growth + ciphertext-in-JSON, and replay has no network timeout — `2026-06-18-fuse-journal-growth-and-replay-timeout.md`

Plans:
**Wave 1**

- [ ] 52-01-PLAN.md — D-05 sanitize_error scrub + D-06 logged journal.remove failures (trivials, Wave 1)
- [ ] 52-02-PLAN.md — D-01/D-04 journal entry shape: sidecar fields, put_with_sidecar, GC/cap constants, compat deserializer (Wave 1)

**Wave 2** *(blocked on Wave 1 completion)*

- [ ] 52-03-PLAN.md — D-01/D-04 write-side: size cap, ECIES filename encryption, off-thread sidecar write with bounded durable-ack oneshot (Wave 2)
- [ ] 52-04-PLAN.md — D-03/D-04 replay-side: sidecar read+verify, name decryption, per-entry timeouts, replay concurrent with mount (Unix+Windows) (Wave 2)

**Wave 3** *(blocked on Wave 2 completion)*

- [ ] TBD (run /gsd:plan-phase 52 to break down)
- [ ] 52-05-PLAN.md — D-02 retention: purge_vault on logout + gc_failed_entries at mount (Wave 3)

### Phase 53: Release & Supply-Chain Engineering

Expand Down
2 changes: 1 addition & 1 deletion .planning/STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ All M2 blockers resolved. See `.planning/milestones/m2/m2-v1.0-production-MILEST

Last activity: 2026-06-20

Last session: 2026-06-19T22:20:00Z
Last session: 2026-06-19T23:48:07.982Z

## Decisions

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
---
phase: 52-desktop-fuse-durability-at-rest-safety
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- crates/sdk/src/sync.rs
- crates/fuse/src/lib.rs
- crates/fuse/src/write_ops.rs
autonomous: true
requirements: [HARD-03]
tags: [fuse, journal, error-handling, at-rest-safety, rust]

must_haves:
truths:
- "Error strings shown in tray/notification copy never contain a real host path prefix (/Users, /home, /var, /tmp, /private, or C:\\Users\\)"
- "A failed journal.remove() after a successful replay/publish emits a log::warn! instead of being silently swallowed"
artifacts:
- path: "crates/sdk/src/sync.rs"
provides: "Extended regex_replace_paths scrub list (D-05)"
contains: "/var/"
- path: "crates/fuse/src/lib.rs"
provides: "Logged journal.remove failures at the two replay success arms (D-06)"
contains: "if let Err"
- path: "crates/fuse/src/write_ops.rs"
provides: "Logged journal.remove failure at the mkdir parent-publish success arm (D-06)"
contains: "if let Err"
key_links:
- from: "crates/sdk/src/sync.rs sanitize_error"
to: "regex_replace_paths"
via: "scrub-list branch covers all six prefixes"
pattern: "regex_replace_paths"
---

<objective>
Land the two trivial, dependency-free at-rest-safety fixes from the Phase 43 review:
D-05 (extend `sanitize_error` path scrub) and D-06 (stop swallowing `journal.remove` errors
at the three known sites). Neither change touches the journal entry struct, so this plan is
independent of every other Phase 52 plan and runs in the first wave.

Purpose: Prevent host-path leakage into user-facing error copy (Information Disclosure) and
prevent a failed journal removal from silently causing a later double-replay / double-publish
(Tampering). Both are HARD-03 line items.

Output: Extended scrub list in `regex_replace_paths`; three `let _ = journal.remove(...)`
sites replaced with `if let Err(e) { log::warn!(...) }`; new unit tests for both.
</objective>

<execution_context>
@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/workflows/execute-plan.md
@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-CONTEXT.md
@.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-RESEARCH.md
@.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-PATTERNS.md
</context>

<artifacts_this_phase_produces>
This plan introduces NO new public symbols (struct fields, fns, or constants). It only:
- extends the existing `regex_replace_paths` branch in `crates/sdk/src/sync.rs` with new prefixes
- swaps three `let _ = journal.remove(...)` statements for `if let Err(e) { log::warn!(...) }`
Source-grounding should treat `regex_replace_paths`, `sanitize_error`, `journal.remove`,
`log::warn!` as PRE-EXISTING symbols (not phase-new).
</artifacts_this_phase_produces>

<tasks>

<task type="tdd" tdd="true">
<name>Task 1: D-05 — extend sanitize_error scrub to /var, /tmp, /private, and C:\Users\</name>
<files>crates/sdk/src/sync.rs</files>
<read_first>
- crates/sdk/src/sync.rs:244-285 (the file being modified — `sanitize_error` and `regex_replace_paths`, the single scrub site)
- 52-PATTERNS.md "crates/sdk/src/sync.rs (D-05 sanitize_error path scrub)" — the exact extension block (additional `||` prefixes + the new Windows `else if c.is_ascii_uppercase()` branch)
- 52-RESEARCH.md "D-05: sanitize_error Path Scrub (IN-04)" and "Pattern 5: regex_replace_paths extension"
</read_first>
<behavior>
Tests (add to the existing `#[cfg(test)] mod tests` in sync.rs; name the test exactly `sanitize_error_extended_paths`):
- `/Users/alice/secret.txt` in an error → replaced with `[path]` (existing behavior, keep green)
- `/home/bob/x` → `[path]`
- `/var/folders/zz/foo` → `[path]`
- `/tmp/cb-journal/abc` → `[path]`
- `/private/var/foo` → `[path]`
- `C:\Users\carol\AppData\file` → `[path]` (drive letter is any ASCII uppercase, e.g. also `D:\Users\...`)
- A string with no path prefix is returned unchanged
- Scrub stops at the first whitespace / `"` / `'` boundary (does not over-consume the rest of the message)
</behavior>
<action>
Per D-05: extend the existing `if c == '/' && (input[i..].starts_with("/Users/") || input[i..].starts_with("/home/"))`
condition in `regex_replace_paths` (crates/sdk/src/sync.rs:270-283) to ALSO match
`input[i..].starts_with("/var/")`, `"/tmp/"`, and `"/private/"`. Add a NEW `else if` branch
BEFORE the final `else { result.push(c); }` that matches a Windows drive-letter path:
`c.is_ascii_uppercase() && i + 1 < input.len() && input[i + 1..].starts_with(":\\Users\\")`.
In BOTH branches, push `"[path]"` and reuse the existing skip-until-whitespace/quote loop
(peek for `next_c.is_whitespace() || next_c == '"' || next_c == '\''`) verbatim — copy the
exact loop from the PATTERNS.md analog so behavior matches the current `/Users/` handling.
Do NOT introduce the `regex` crate; keep the hand-rolled char_indices scan that is already there.
</action>
<verify>
<automated>cargo test -p cipherbox-sdk sanitize_error_extended_paths 2>&1 | tail -20</automated>
</verify>
<done>
`sanitize_error_extended_paths` passes; all six prefixes (5 Unix + Windows drive-letter) are
scrubbed to `[path]`; existing `/Users//home` behavior unchanged; no new external crate added.
</done>
</task>

<task type="auto">
<name>Task 2: D-06 — log journal.remove failures at all three swallowed-removal sites</name>
<files>crates/fuse/src/lib.rs, crates/fuse/src/write_ops.rs</files>
<read_first>
- crates/fuse/src/lib.rs:1489-1514 (the file being modified — the MkdirPublish success arm at :1494 and the adjacent record_failure warn! format to mirror)
- crates/fuse/src/lib.rs:1551-1578 (the UploadFile success arm at :1558)
- crates/fuse/src/write_ops.rs:678-680 (the file being modified — the mkdir parent-publish success arm)
- 52-PATTERNS.md "crates/fuse/src/lib.rs (D-06 ...)" and "crates/fuse/src/write_ops.rs (D-06 ...)" — exact replacement blocks and the `record_failure` warn! format to mirror
- 52-RESEARCH.md "D-06: Swallowed Removal Log (IN-05)"
</read_first>
<action>
Per D-06: replace each of the three `let _ = journal.remove(...)` statements with an
`if let Err(e) = journal.remove(...) { log::warn!(...) }` block that names the entry id and
states the double-replay risk, mirroring the adjacent `record_failure` `log::warn!` format
(lib.rs:1505-1513). The three sites:
(1) crates/fuse/src/lib.rs:1494 — MkdirPublish `Ok(())` success arm (`journal.remove(&entry.id)`).
Warn message must mention "MkdirPublish" + entry.id + "may replay again on next mount".
(2) crates/fuse/src/lib.rs:1558 — UploadFile `Ok(())` success arm (`journal.remove(&entry.id)`).
Warn message must mention "UploadFile" + entry.id + "may replay again on next mount".
(3) crates/fuse/src/write_ops.rs:679 — mkdir parent-publish `Success` arm
(`journal_for_mkdir.remove(&mkdir_journal_entry_id)`). Warn must mention the mkdir entry id
+ "may replay again on next mount", then keep the existing `log::info!("Parent metadata published after mkdir")`.
Do not change control flow otherwise — `journal.remove` already treats NotFound as `Ok(())`
(queue.rs:216), so the only error case is a genuine I/O failure. Use the `log` facade
(already imported in both crates' Cargo.toml).
</action>
<verify>
<automated>cargo test -p cipherbox-fuse remove_failure_is_logged 2>&1 | tail -20</automated>
</verify>
<done>
All three sites use `if let Err(e) = ... { log::warn!(...) }`; no `let _ = journal.remove`
remains in the three files (grep below); the `remove_failure_is_logged` test passes.
Grep gate: `grep -rn "let _ = journal" crates/fuse/src/lib.rs crates/fuse/src/write_ops.rs | grep -v '^#' | wc -l` returns 0 for `.remove(` removal sites.
</done>
</task>

<task type="auto" tdd="true">
<name>Task 3: D-06 test — remove_failure_is_logged</name>
<files>crates/fuse/src/lib.rs</files>
<read_first>
- crates/fuse/src/lib.rs:2276+ (the file being modified — existing `#[cfg(test)] mod tests` block; this is where the new test goes)
- crates/sdk/src/queue.rs:203-218 (WriteQueue::remove behavior — NotFound is Ok; genuine I/O error returns Err)
- 52-PATTERNS.md "Test structure — #[cfg(test)] mod tests inline" and the queue.rs `make_temp_queue` helper pattern
- 52-VALIDATION.md row D-06-a — test name `remove_failure_is_logged`
</read_first>
<behavior>
Test `remove_failure_is_logged` (add to the `#[cfg(test)] mod tests` block in lib.rs):
- Construct a `WriteQueue` over a temp dir, `put` an entry, then make the journal dir
removal fail (e.g. point the queue at a non-existent / read-only path, OR delete the
`.json` so a follow-up state forces an error path) and assert that the production helper
surfaces the error via the warn path rather than panicking or silently succeeding.
- Because `WriteQueue::remove` is idempotent on NotFound, drive the genuine-error case by
using a journal_dir whose `.json` removal returns an `Err(...)` (e.g. set the parent dir
read-only on Unix via std::fs permissions, guarded by `#[cfg(unix)]`), and assert
`journal.remove(id)` returns `Err`. The test asserts the Err branch is reachable so the
`if let Err` logging path in Task 2 is exercised by a real failure shape.
</behavior>
<action>
Add `#[test] fn remove_failure_is_logged()` to the existing tests module in
crates/fuse/src/lib.rs. Build a temp `WriteQueue` (mirror `make_temp_queue` from queue.rs),
`put` one entry, then induce a removal failure and assert `WriteQueue::remove` returns `Err`
(proving the `if let Err` branch added in Task 2 is reachable). Gate the permission-mutation
portion with `#[cfg(unix)]`; on non-unix, assert the NotFound idempotency path returns `Ok`.
Do NOT run the full suite — scope to this test name only.
</action>
<verify>
<automated>cargo test -p cipherbox-fuse remove_failure_is_logged 2>&1 | tail -20</automated>
</verify>
<done>
`remove_failure_is_logged` exists and passes; it proves a genuine removal error is an `Err`
(the shape Task 2's `if let Err` handles), confirming D-06 is not dead code.
</done>
</task>

</tasks>

<threat_model>

## Trust Boundaries

| Boundary | Description |
| -------- | ----------- |
| local error string → user-facing tray/notification copy | host filesystem paths must not cross into displayed text |
| journal file on local disk → replay/publish lifecycle | a swallowed removal failure can cause an unintended second publish |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
| --------- | -------- | --------- | ----------- | --------------- |
| T-52-01 | Information Disclosure | `sanitize_error` / `regex_replace_paths` (sync.rs) | mitigate (D-05) | Extend scrub list to `/var`, `/tmp`, `/private`, and `C:\Users\` drive-letter pattern so no host path reaches tray/notification copy; unit-tested in `sanitize_error_extended_paths` |
| T-52-02 | Tampering | `journal.remove` at lib.rs:1494/:1558, write_ops.rs:679 | mitigate (D-06) | Replace `let _` with `if let Err(e) { log::warn!(...) }` so a failed removal is surfaced; idempotent replay short-circuit remains the functional guard against actual double-publish |

No HIGH-severity threats are introduced or left unmitigated by this plan. (Sidecar/at-rest ciphertext and durable-ack HIGH items are owned by plans 52-02/52-03.)

</threat_model>

<verification>
- `cargo test -p cipherbox-sdk sanitize_error_extended_paths` passes
- `cargo test -p cipherbox-fuse remove_failure_is_logged` passes
- No `let _ = journal.remove` / `let _ = journal_for_mkdir.remove` remains in the three edited files
- `cargo test -p cipherbox-sdk -p cipherbox-fuse 2>&1 | tail -20` shows no regressions
</verification>

<success_criteria>
- D-05: all six path prefixes scrubbed to `[path]`; existing behavior preserved
- D-06: all three removal sites log on failure; no swallowed `let _` removals remain
- Both new tests green; no new external crate added; build is Rust-only
</success_criteria>

<output>
Create `.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-01-SUMMARY.md` when done
</output>
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
---
phase: 52-desktop-fuse-durability-at-rest-safety
plan: 01
subsystem: fuse/sdk
tags: [security, at-rest-safety, error-handling, journal, rust, s1]
dependency_graph:
requires: []
provides: [D-05-path-scrub, D-06-removal-logging]
affects:
- crates/sdk/src/sync.rs
- crates/fuse/src/lib.rs
- crates/fuse/src/write_ops.rs
tech_stack:
added: []
patterns: [hand-rolled-char-scan-scrub, if-let-err-warn-logging]
key_files:
created: []
modified:
- crates/sdk/src/sync.rs
- crates/fuse/src/lib.rs
- crates/fuse/src/write_ops.rs
decisions:
- "D-05 keeps the existing hand-rolled char_indices scan; no regex crate added"
- "Windows drive-letter branch matches any ASCII uppercase letter + ':\\Users\\'"
- "D-06 test decoupled from JournalEntry struct shape (writes a raw .json) so it survives the 52-02 field rename"
metrics:
completed: "2026-06-20T00:00:00Z"
tasks_completed: 3
files_modified: 3
---

# Phase 52 Plan 01: At-Rest-Safety Path Scrub and Removal Logging

One-liner: Extended `sanitize_error`'s path scrub to `/var`, `/tmp`, `/private`, and Windows drive-letter `X:\Users\` (D-05), and replaced the three swallowed `let _ = journal.remove(...)` sites with `if let Err(e) { log::warn!(...) }` so a failed removal can no longer silently cause a later double-replay/double-publish (D-06).

## What Was Built

### D-05 — extended `regex_replace_paths` scrub (crates/sdk/src/sync.rs)

- Added `/var/`, `/tmp/`, `/private/` to the existing `if c == '/' && (...)` prefix list.
- Added a new `else if c.is_ascii_uppercase() && i + 2 < input.len() && input[i + 1..].starts_with(":\\Users\\")` branch for Windows drive-letter paths (e.g. `C:\Users\...`, `D:\Users\...`), reusing the same skip-until-whitespace/quote loop.
- New `#[cfg(test)] mod tests` with `sanitize_error_extended_paths` covering all five Unix prefixes, two Windows drive letters, an unchanged no-path string, and a multi-path boundary case.

### D-06 — log `journal.remove` failures (crates/fuse/src/lib.rs, write_ops.rs)

Three sites converted from `let _ = ...remove(...)` to `if let Err(e) { log::warn!(...) }`, each naming the op + entry id and stating "entry may replay again on next mount":

1. `crates/fuse/src/lib.rs` MkdirPublish replay-success arm.
2. `crates/fuse/src/lib.rs` UploadFile replay-success arm.
3. `crates/fuse/src/write_ops.rs` mkdir parent-publish success arm (the existing `log::info!("Parent metadata published after mkdir")` is preserved).

New `remove_failure_is_logged` test in lib.rs proves a genuine removal error is an `Err` (on Unix, by setting the journal dir to `0o500` so unlinking the child `.json` fails), confirming the `if let Err` branch is reachable — not dead code. The test writes a raw `.json` rather than constructing a `JournalEntry`, so it is decoupled from the upcoming 52-02 field rename.

## Phase 51 Reconciliation

D-06 line numbers in the plan (`:1494`/`:1558`) were stale relative to the merged tree — Phase 51's zeroization/Zeroizing edits shifted them to `:1503`/`:1567`. Located the actual sites by grep before editing. No Phase 51 hardening was reverted; these edits only swap the swallowed-error idiom for logging and do not touch any key-handling code.

## Test Results

- `sanitize_error_extended_paths` (cipherbox-sdk): pass.
- `remove_failure_is_logged` (cipherbox-fuse): pass.
- Grep gate: 0 remaining `let _ = journal.remove` / `let _ = journal_for_mkdir.remove` in the three files.
- Full suites: cipherbox-fuse 61/61, cipherbox-sdk 49/49 — no regressions (baseline was 60/48).

## Deviations from Plan

None functionally. The only adjustment: the `let _ = cipherbox_api_client::ipfs::unpin_content(...)` at write_ops.rs (adjacent to the journal removal) is intentionally left fire-and-forget — it is out of D-06 scope, which targets journal-entry removals only.

## Known Stubs

None.

## Self-Check: PASSED

- `crates/sdk/src/sync.rs` contains `/var/`, `/tmp/`, `/private/`, and the `:\\Users\\` branch.
- `crates/fuse/src/lib.rs` and `crates/fuse/src/write_ops.rs` contain `if let Err` removal logging at all three sites.
- Both new tests pass; grep gate is 0; no regressions.
Loading
Loading