Skip to content

Feature/p2p sync#143

Open
diegorv wants to merge 29 commits into
mainfrom
feature/p2p-sync
Open

Feature/p2p sync#143
diegorv wants to merge 29 commits into
mainfrom
feature/p2p-sync

Conversation

@diegorv

@diegorv diegorv commented Jul 4, 2026

Copy link
Copy Markdown
Owner

No description provided.

diegorv and others added 29 commits July 3, 2026 22:00
Context: The P2P vault sync design spec was just written and committed
(39a440f). A self-review pass found two under-specified points.

Problem: (1) Decision-table case 3 (only remote changed) did not state
its effect on the persisted sync state, unlike cases 1 and 5. (2) The
protocol section did not define what happens when a locally subscribed
folder is no longer present in the peer's Shares list.

Solution: State the case-3 state transition explicitly
(synced = seen_remote = remote) and add a protocol rule: a session pulls
only the intersection of local subscriptions and the peer's current
Shares; dropped folders are skipped, reported in the summary, and never
deleted locally.

Behavior: Documentation only; no code changes.

Files:
- docs/superpowers/specs/2026-07-03-p2p-sync-design.md (decision table
  row 3; protocol bullet list)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Context: The P2P vault sync design was approved and specced in
docs/superpowers/specs/2026-07-03-p2p-sync-design.md. Implementation
needs a task-by-task plan with real code, tests, and commit gates.

Solution: Add the full implementation plan (13 tasks, TDD steps with
complete code for each) and the tasks/todo checklist file required by
the Plan Mode Workflow. The plan pins verified dependency versions
(snow 0.10.0, getrandom 0.4.3, serde_bytes 0.11.19), grounds every
edit point in existing files (SettingsPanel registration, DEFAULT_SETTINGS,
app-lifecycle init/teardown), and covers the spec's whole test matrix,
including wrong-PSK handshake failure and hash-mismatch downloads
writing nothing.

Behavior: Documentation only; no code changes.

Files:
- docs/superpowers/plans/2026-07-03-p2p-sync.md (new; full plan)
- tasks/todo/p2p-sync.md (new; task checklist per Plan Mode Workflow)

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

Context: Pre-flight plan review for subagent execution flagged the
Task 8 test local_lan_ip_does_not_panic as an assert-nothing test,
which the review rubric treats as a defect. User chose to replace it.

Solution: The test now asserts that, when local_lan_ip() returns Some,
the string parses as std::net::IpAddr. None remains accepted so the
test cannot flake on machines without a network route.

Behavior: Documentation only; no code changes.

Files:
- docs/superpowers/plans/2026-07-03-p2p-sync.md (Task 8, step 1 test)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Context: P2P vault sync requires a wire protocol for message exchange between
peers over Noise-encrypted TCP. This is the foundation for all subsequent sync
tasks: message serialization, connection establishment, state management, and
the pull engine all depend on these protocol definitions and framing primitives.

Solution: Implemented the complete protocol layer as specified in
docs/superpowers/specs/2026-07-03-p2p-sync-design.md:
- Message types (Hello, HelloAck, ListShares, Shares, GetManifest, ManifestPage,
  GetFile, FileChunk, FileEnd, Error, Bye) for all client-server interactions
- FileMeta struct (rel_path, size, sha256) for file metadata
- Constants: PROTOCOL_VERSION, MAX_FRAME_LEN, MAX_PLAINTEXT_LEN, FILE_CHUNK_LEN,
  MANIFEST_PAGE_LEN
- MessagePack serialization (encode_msg, decode_msg) with serde_bytes support
- Length-prefixed framing (write_frame, read_frame) handling u32 LE length headers
  with MAX_FRAME_LEN validation
- Added dependencies: snow (Noise protocol), getrandom, serde_bytes
- Extended tokio features: net, sync, time

Behavior: The sync module now exports all protocol primitives needed for:
- Message round-tripping through serialization (4 variants tested)
- Frame I/O over async streams with length validation
- Rejection of oversized frames and invalid headers
Tests verify correctness of encoding/decoding, framing, and boundary conditions.

Files:
- src-tauri/Cargo.toml:38-43: Added P2P sync dependencies (snow, getrandom,
  serde_bytes) and extended tokio features (net, sync, time)
- src-tauri/src/lib.rs:6: Added pub mod sync after pub mod semantic
- src-tauri/src/sync/mod.rs:1-7: New module root with protocol submodule
- src-tauri/src/sync/protocol.rs:1-145: Full wire protocol implementation:
  - 1-26: Module docstring and imports
  - 27-35: Constants (PROTOCOL_VERSION, MAX_FRAME_LEN, MAX_PLAINTEXT_LEN,
    FILE_CHUNK_LEN, MANIFEST_PAGE_LEN)
  - 36-44: FileMeta struct with serde derive
  - 45-69: Msg enum with 11 variants for sync session lifecycle
  - 71-76: encode_msg using rmp_serde with named fields
  - 78-80: decode_msg using rmp_serde
  - 82-90: write_frame with length prefix and validation
  - 92-104: read_frame with length validation and rejection
  - 106-145: 4 unit tests covering message round-tripping, frame I/O,
    oversized frame handling, and invalid header rejection
- tasks/todo/p2p-sync.md:10: Marked Task 1 complete

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Context: Task 2 of the P2P vault sync implementation. The sync protocol
(from Task 1) provides message framing and serialization; this task adds
the encryption layer to protect channel confidentiality and enforce
pre-shared key (pairing key) authentication.

Problem: No way to generate or parse pairing keys, no encrypted channel
implementation, and no handshake to establish a Noise XXpsk3 session
between sync initiator and responder.

Solution: Implemented noise.rs with:
- generate_pairing_key(): generates 32 random bytes, returns as 64 lowercase hex
- parse_pairing_key(hex): parses hex string to 32-byte array, trims whitespace
- NoiseChannel<S>: wraps any AsyncRead+AsyncWrite, implements send()/recv()
- handshake_initiator()/handshake_responder(): run XXpsk3 handshake with PSK

Behavior:
- Each pairing key is 64 lowercase hex chars (32 bytes random)
- Parsing trims whitespace and rejects non-hex or wrong-length input
- Handshake uses transient per-session keypairs; identity is PSK-only
- Wrong PSK fails handshake before any application data flows
- All wire data is indistinguishable from random (no metadata leakage)
- Each Msg encrypts to one Noise message via snow 0.10

Test results: 5 tests PASS (pairing key generation/parsing, roundtrip trim,
bad input rejection, bidirectional encrypted message exchange, PSK mismatch
detection). Full Rust suite: 119 tests PASS, 0 FAIL, 2 IGNORED.

Files:
- src-tauri/src/sync/noise.rs (lines 1-177): implementation + 5 test cases
- src-tauri/src/sync/mod.rs (line 8): add pub mod noise

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Context: The NoiseChannel::send() method has a guard that rejects plaintext
messages larger than MAX_PLAINTEXT_LEN, but this rejection path was not
being exercised by any test.

Problem: The size-check guard at send() line 49-51 was never validated by
the test suite, leaving uncertainty about whether the error handling works
correctly in practice.

Solution: Added a new test `oversized_message_is_rejected_before_send` that
constructs a FileChunk message whose data exceeds MAX_PLAINTEXT_LEN by 1 byte,
performs a valid handshake on both sides, and verifies that send() returns
an error with the expected "too large" message substring.

Behavior: The test verifies that NoiseChannel::send() rejects oversized
messages with an error containing "too large" before attempting encryption.
Previously, this code path was untested; now all six noise tests pass.

Files:
- src-tauri/src/sync/noise.rs:177-184: Added oversized_message_is_rejected_before_send()
  test that creates a FileChunk exceeding MAX_PLAINTEXT_LEN and verifies rejection

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Context: The P2P sync feature (feature/p2p-sync) requires persistent state
tracking of which content hash both sides last agreed on, and the last remote
hash observed. This prevents unnecessary re-downloads and enables intelligent
conflict-copy dedup logic. Task 3 of the plan adds the JSON persistence layer.

Solution: Created sync/state.rs with FileSyncState struct (synced and
seen_remote Option<String> fields), SyncStateMap type alias (peer device name
to file state mapping), and three core functions: state_file_path() computes
the location at <vault>/.kokobrain/sync-state.json, load_state() reads the
JSON with graceful fallback to empty map on missing or corrupt files, and
save_state() persists the map as pretty-printed JSON. Three inline tests cover
missing file (returns empty), save-then-load roundtrip, and corrupt file
handling. All tests pass; full Rust suite verified (119 passed).

Behavior: Sync state is now persisted across sessions. A lost state file only
triggers a one-time re-download/conflict-copy pass; never causes data loss.
The state format is human-readable JSON for easy inspection and debugging.

Files:
- src-tauri/src/sync/state.rs:1-82: New file with FileSyncState, SyncStateMap,
  state_file_path, load_state, save_state, and three inline tests
- src-tauri/src/sync/mod.rs:9: Added pub mod state; declaration
- tasks/todo/p2p-sync.md:12: Marked Task 3 [x] complete

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

Context:
Task 4 in the P2P Vault Sync plan implements the module shared by the sync
server (reads exposed folders) and engine (writes downloads). Consumed by
future sync/server.rs and sync/engine.rs tasks.

Problem:
The sync infrastructure needs path validation, file hashing, and folder walking
to build manifests for replication. Without this, the server cannot enumerate
files for peer discovery, and the engine cannot verify integrity.

Solution:
Added sync/manifest.rs with four exported functions:
- validate_rel_path: Rejects empty, absolute, escaped, or hidden paths.
- hash_bytes: SHA-256 of content (lowercase hex, matches history.rs).
- hash_file: Reads file and returns its hash.
- build_manifest: Walks folder, sorts by rel_path, skips dot-prefixed entries
  and symlinks, builds a Vec<FileMeta> with size and sha256.

The module uses a depth-first stack walk with tempfile for test isolation.
All functions integrate with protocol::FileMeta to match the wire format.

Behavior:
- Manifest validation: rejects "" (empty), "/abs" (absolute), "a/../b" (escape),
  ".." (parent), "a//b" (empty component), ".hidden/x" (hidden), "a/.git/c"
  (hidden subdir), "a\\b" (backslash), "C:/x" (Windows drive).
- Hashing: sha256("abc") → "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
  (matches external sha256sum).
- Manifest walk: lists nested files sorted by rel_path, with `/` separators.
  Skips dot-prefixed files and directories, symlinks, and non-UTF-8 names.
  Preserves file size (u64) and sha256 hash for integrity tracking.

Tests (6 total, all passing):
- validate_rel_path_accepts_normal_paths: "Notes", "Notes/sub dir/a b.md"
- validate_rel_path_rejects_escapes_and_hidden: "" "/" ".." "../" "a//b" ".hidden" ".git" "\\" "C:"
- hash_bytes_matches_known_sha256: matches known test vector
- build_manifest_lists_nested_files_sorted_and_skips_hidden: tempdir with
  Notes/{b.md,sub/a.md,.dotfile,.hidden/c.md} → lists only sorted regular files
- build_manifest_skips_symlinks: /unix only; symlinks not in manifest
- build_manifest_missing_folder_errors: nonexistent folder → Err

Files:
- src-tauri/src/sync/manifest.rs (new, 146 lines)
  Lines 1-80: validate_rel_path, hash_bytes, hash_file, build_manifest (implementations)
  Lines 87-146: test module with 6 test functions
- src-tauri/src/sync/mod.rs (modified, +1 line)
  Line 10: pub mod manifest; declaration
- tasks/todo/p2p-sync.md (modified, 1 checkbox)
  Line 13: Task 4 marked [x]

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

Context: Security and reliability review of the manifest building logic
revealed that build_manifest correctly skips symlinked directories and
non-UTF-8 filenames, but the test suite lacked coverage for these
important edge cases.

Problem: The build_manifest function checks for symlinks and non-UTF-8
names (lines 56 and 61-62), but the test suite only covered symlinked
files, not symlinked directories. Additionally, there was no test for
the non-UTF-8 name skip behavior, creating a gap in coverage for
security-relevant path handling.

Solution: Added two new tests to src-tauri/src/sync/manifest.rs:
- build_manifest_skips_symlinked_directories: verifies that symlinks
  pointing to directories are skipped, preventing directory traversal
  via symlinks
- build_manifest_skips_non_utf8_names: verifies that files with
  non-UTF-8 filenames are skipped, with conditional logic for
  filesystems (like APFS) that don't support non-UTF-8 names

Behavior: The two new tests now verify that build_manifest correctly
filters both symlinked directories and non-UTF-8 names. All 8
sync::manifest tests pass. No changes to the build_manifest implementation.

Files:
- src-tauri/src/sync/manifest.rs:141-169: Added two new test functions
  covering symlinked directories and non-UTF-8 filename handling

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Context: Task 5 of the P2P sync implementation adds pure conflict-resolution
logic that determines what action the engine should take for each file during
a sync pull: whether to download the remote version, keep the local version
unchanged, or handle a conflict by saving both versions.

Problem: The sync engine needs a decision table that implements the "local wins"
semantics from the spec, plus a utility to generate conflict-copy file names
with peer device name and date information.

Solution: Implemented the Action enum (Download, UpToDate, KeepLocal, Conflict)
and the decide() function that exhaustively handles all five spec table rows
plus edge cases (first sync with differing local content, repeat sync with
unchanged remote). Also implemented conflict_copy_rel_path() to generate
sanitized conflict copy names that prevent path injection via malicious peer names.

Behavior: decide() takes local content hash (or None if file missing), remote
hash, and optional sync state, returning the appropriate Action. The function
correctly handles: (1) missing local file → Download; (2) identical hashes →
UpToDate; (3) only remote changed → Download; (4) only local changed → KeepLocal;
(5) both changed → Conflict with write_copy flag based on whether this remote
hash was already seen. conflict_copy_rel_path() transforms "Notes/a.md" with
peer="Studio" and date="2026-07-03" into "Notes/a (conflict from Studio
2026-07-03).md", sanitizing the peer name so "/" "\\" and ":" become "-".

Files changed:
- src-tauri/src/sync/decision.rs:1-137 (new, Action enum + decide + conflict_copy_rel_path + 8 tests)
- src-tauri/src/sync/mod.rs:11 (added pub mod decision;)
- tasks/todo/p2p-sync.md:14 (marked Task 5 complete)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Context: Task 6 of the P2P vault sync plan (docs/superpowers/plans/2026-07-03-p2p-sync.md).
Tasks 1-5 built the wire protocol, Noise channel, persisted sync state,
manifest/hashing, and conflict decision table. This task adds the
listening side: the machine being synced FROM must expose a read-only
TCP endpoint that a peer can connect to, authenticate via the shared
pairing key, and pull files from.

Solution: Added `sync/server.rs` with `start_server(ServerConfig, port)`,
which binds a TCP listener (port 0 = ephemeral) and spawns an accept loop
on a background task. Each connection runs the Noise XXpsk3 responder
handshake, exchanges `Hello`/`HelloAck` (checking `PROTOCOL_VERSION`),
then serves `ListShares`, `GetManifest`, and `GetFile` requests strictly
against `config.exposed_folders` — folders that no longer exist on disk
are silently dropped from `ListShares`, and any path failing
`validate_rel_path` or resolving outside a folder in `exposed_folders`
returns `Msg::Error` instead of touching disk. `GetFile` additionally
canonicalizes the resolved path and checks it stays under the
canonicalized vault root (defense in depth against symlink escapes,
beyond what `validate_rel_path`'s `..`-rejection already covers).
Connections are served one at a time (sequential await inside the
accept loop) since exposing folders is a low-throughput, occasional
operation. Shutdown uses a `tokio::sync::watch<bool>` channel:
`RunningServer::stop(self)` sends `true` and consumes `self`, so the
`TcpListener` (owned by the spawned task, not by the handle) drops as
soon as the accept loop observes the change and returns, closing the
socket.

Behavior: `start_server` returns a `RunningServer { port, .. }` handle
immediately after binding; `port` reports the actual bound port so
callers requesting port 0 can read back what the OS assigned. Peers
that complete the Noise handshake with the correct pairing key can list
shares, page through a folder's manifest (paginated at
`MANIFEST_PAGE_LEN` entries per `ManifestPage`), and download files in
`FILE_CHUNK_LEN` chunks followed by a `FileEnd { sha256 }` whose hash
the caller can verify against the received bytes. Wrong pairing keys
fail the handshake before any `Msg` is exchanged. A protocol version
mismatch on `Hello` gets an `Error` reply mentioning "version" and ends
the session. Calling `RunningServer::stop()` closes the listening
socket; new connection attempts to that port fail immediately after.

Files:
- src-tauri/src/sync/server.rs:1-176: New file. `ServerConfig` (Clone),
  `RunningServer` (port + `stop()`), `SyncServerState` (Tauri managed
  state wrapper, unused until Task 8 wires the Tauri commands),
  `start_server()` accept loop with watch-channel shutdown,
  `serve_connection()` per-session message loop (`ListShares`,
  `GetManifest` w/ pagination, `GetFile`, `Bye`), `serve_file()` with
  exposed-folder + canonicalized-path checks.
- src-tauri/src/sync/mod.rs:12: Added `pub mod server;`.
- src-tauri/tests/sync_server_test.rs:1-159: New integration test file.
  Covers: filtering missing exposed folders out of `ListShares`;
  manifest pagination + multi-chunk file download with hash
  verification + empty-file zero-chunk download; refusal of
  unshared/traversal/absolute paths; handshake failure on wrong PSK;
  protocol version mismatch producing an `Error` containing "version";
  and `stop()` actually closing the listening socket.
- tasks/todo/p2p-sync.md:15: Marked Task 6 done.

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

Context: The sync listener's accept loop ran serve_connection(...).await
inside the outer select! arm, so while a session was in flight rx.changed()
was never polled. Neither the Noise handshake nor NoiseChannel::recv had
a read timeout.

Problem: Any device that could reach the port could open a bare TCP
connection, send nothing, and permanently freeze the listener: stop()
became unresponsive because the shutdown watch channel was never observed
during an in-flight session, and no new peer could ever connect while the
stalled connection held the single session slot. Zero authentication was
required to trigger this — the handshake itself was one of the unbounded
reads.

Solution: Restructured the accept loop to race each session against the
shutdown signal via a nested tokio::select! — dropping the session future
on shutdown closes the socket immediately, so a stalled peer no longer
blocks stop(). Added HANDSHAKE_TIMEOUT (10s) and RECV_TIMEOUT (30s)
constants and wrapped every blocking read in serve_connection (the Noise
handshake, the initial Hello recv, and the per-request recv loop) in
tokio::time::timeout so a silent peer can hold the session for at most
RECV_TIMEOUT before being dropped. The request-loop recv already treated
disconnects as a normal end-of-session; timeouts now fold into that same
path so existing hung-up semantics are unchanged.

Behavior: Previously, a peer that connected and sent nothing froze the
listener indefinitely — stop() would never take effect and no other peer
could connect. Now, shutdown always takes effect within one select poll
even mid-session, and any peer that goes silent during the handshake or
between requests is dropped after its respective timeout, freeing the
session slot for the next connection.

Files:
- src-tauri/src/sync/server.rs:8-21: Added tokio::time::{timeout, Duration}
  import and HANDSHAKE_TIMEOUT (10s) / RECV_TIMEOUT (30s) constants
- src-tauri/src/sync/server.rs:68-82: Restructured the accept loop's
  connection branch to race serve_connection against rx.changed() in a
  nested select!, so shutdown is observed even while a session is active
- src-tauri/src/sync/server.rs:90-96: Wrapped the Noise handshake and the
  initial Hello recv in timeout(), converting Elapsed into a session error
- src-tauri/src/sync/server.rs:114-117: Wrapped the per-request recv in
  timeout(), folding timeout into the existing "peer hung up" early return
- src-tauri/tests/sync_server_test.rs:161-171: Added
  stop_unblocks_while_a_session_is_stalled, which opens a raw TCP
  connection that never sends bytes (stalling the handshake) and asserts
  stop() still closes the listener promptly

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Context: Tasks 1-6 built the wire protocol, Noise-encrypted channel,
persisted sync state, manifest/hashing, decision table, and read-only
listener for P2P vault sync. This is the final piece of the sync core:
the client side that actually connects to a peer and pulls files into
the local vault. It is the only sync code that writes into the vault -
the listener (server.rs) is strictly read-only.

Solution: Added `sync/engine.rs` with `PeerTarget` (address, pairing
key, local device name), `list_remote_shares` (connect, ListShares,
Bye), and `run_sync` (full session: connect, ListShares, then per
subscribed folder fetch the paginated manifest, diff against local
hashes via `build_manifest`, and apply `decision::decide` per file).
Downloads and conflict copies both go through `download_file`, which
requests the file, accumulates `FileChunk`s bounded by a 1 GiB cap,
and verifies the peer-reported `FileEnd` hash against the actual bytes
before calling `write_atomic` - never the manifest hash, since the
file can change between the manifest fetch and the file fetch. Verified
bytes are written to a dot-prefixed temp file in the destination
directory and renamed into place, so a crash or corrupted transfer
never leaves a partial file in the vault. Per-file and per-folder
failures are collected into `SyncSummary.errors` and the session keeps
going rather than aborting the whole sync. Subscribed folders the peer
no longer exposes are reported via `skipped_folders` instead of erroring.
Session-level failures (unreachable peer, wrong pairing key, protocol
mismatch) short-circuit `run_sync` with a descriptive error since there
is nothing to sync without a channel.

Behavior: New `list_remote_shares(&PeerTarget) -> Result<Vec<String>, String>`
and `run_sync(vault_path, &PeerTarget, subscriptions) -> Result<SyncSummary, String>`
are available for the Tauri command layer (Task 8) to call. A fresh
sync downloads every remote file in each subscribed folder; a repeat
sync with no changes downloads nothing. When only the remote side
changed, the new version is downloaded; when only local changed, the
local file is left untouched (peer picks it up on its own next pull).
When both sides changed, the local version is kept and the remote
version is written alongside as `<name> (conflict from <peer>
<date>).<ext>` - written once per divergence, not duplicated on repeat
syncs of the same divergence. Deletions on the peer never propagate:
a file removed remotely stays in the local vault. A corrupted transfer
(hash mismatch on `FileEnd`) is rejected before any bytes reach the
vault and is surfaced as one entry in `SyncSummary.errors`.

Files:
- src-tauri/src/sync/engine.rs:1-276: New file. `SyncSummary`,
  `PeerTarget`, `connect`/`recv` helpers, `list_remote_shares`,
  `run_sync`, `sync_folder` (manifest fetch + per-file decision
  dispatch), `download_file` (fetch + hash verification), and
  `write_atomic` (temp-file-then-rename)
- src-tauri/src/sync/mod.rs:13: Added `pub mod engine;`
- src-tauri/tests/sync_e2e_test.rs:1-225: New e2e test file covering
  share listing, fresh pull + idempotent repeat, remote-only vs
  local-only changes, conflict-copy creation and dedup, non-propagating
  deletions with local-only file survival, unexposed-folder reporting,
  corrupted-transfer rejection via a fake malicious peer, wrong pairing
  key, and unreachable peer
- tasks/todo/p2p-sync.md:16: Marked Task 7 complete

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

Context: engine.rs is the only sync code that writes into the vault, so a
review pass focused on two correctness findings in the download/conflict
path before this branch merges.

Problem: (1) In the Action::Conflict { write_copy } arm, seen_remote was
set unconditionally after attempting a conflict-copy download. When the
download failed, seen_remote was still recorded as seen, so decide() would
return write_copy: false on the next sync and the copy would never be
retried — a permanently lost conflict copy. (2) download_file returned a
flat Result<String, String>, so every failure (timeout, size-cap overrun,
an unexpected message mid-transfer) was treated the same as a clean
single-message Msg::Error refusal. After a partial read or timeout the
Noise channel's position in the request/reply sequence is unknown, but the
old code kept reusing it for the next file — a stale reply for file A
could be misattributed to file B's request and written to B's path (the
hash check wouldn't catch this since the bytes are A's genuine content).

Solution: Added a DownloadError enum (Recoverable vs Fatal) to
download_file's error path so every failure is classified by whether the
Noise channel is still aligned with the request/reply sequence:
Msg::Error replies, post-transfer hash mismatches, and local write_atomic
failures are Recoverable (a full message was consumed, or the failure
never touched the wire); GetFile send failures, recv()/timeout failures,
size-cap overruns, and unexpected message variants are Fatal (the channel
state is unknown because the reply stream was abandoned mid-sequence).
sync_folder now returns bool instead of Result<(), String> so it can
report to run_sync whether the channel is still usable: Fatal errors push
to summary.errors and return false; Recoverable errors push and continue
the file loop. The manifest phase got the same treatment (GetManifest send
failure, recv failure, or an unexpected message variant are channel-fatal;
a clean Msg::Error reply to GetManifest or a local build_manifest error are
not). In the conflict arm, seen_remote is now set only when the copy
download actually succeeds (Ok) or when write_copy was already false —
never on a failed attempt — so a failed copy is retried on the next sync
instead of being silently and permanently suppressed. run_sync's folder
loop now breaks out (skipping remaining subscribed folders) as soon as
sync_folder returns false, then still runs save_state so already-confirmed
progress isn't lost.

Behavior: Previously, a failed conflict-copy download silently marked the
remote hash as seen, so the copy was never retried on subsequent syncs.
Now the copy is retried until it actually succeeds. Previously, a timeout
or malformed reply mid-download left the channel in an unknown state but
the session continued serving further requests over it, risking a stale
reply being written under the wrong file's path. Now any such error aborts
the remainder of the session (remaining folders are skipped, not attempted
over a possibly-desynced channel), while clean single-message refusals
(Msg::Error) and local I/O errors no longer abort the session since the
channel is provably still aligned.

Files:
- src-tauri/src/sync/engine.rs:98-142: run_sync's folder loop now checks
  sync_folder's bool return and breaks (instead of only logging an Err)
  when the channel is desynchronized; save_state still runs after the loop.
- src-tauri/src/sync/engine.rs:144-268: sync_folder rewritten to return
  bool. Manifest-phase errors are classified (send/recv/unexpected-message
  failures are channel-fatal -> push error + return false; a clean
  Msg::Error reply or a local build_manifest error are not -> push error +
  return true). The Download and Conflict arms now match on
  DownloadError::Recoverable vs ::Fatal instead of a flat error string.
  The Conflict arm's seen_remote update moved inside the Ok branch (and
  the write_copy: false branch), so a failed copy attempt no longer
  suppresses the retry.
- src-tauri/src/sync/engine.rs:270-312: Added the DownloadError enum
  (Recoverable/Fatal) above download_file; download_file now returns
  Result<String, DownloadError>, classifying GetFile send failures,
  recv()/timeout failures, size-cap overruns, and unexpected replies as
  Fatal, and Msg::Error replies, hash mismatches, and write_atomic
  failures as Recoverable.
- src-tauri/tests/sync_e2e_test.rs:198-269: Added
  malicious_manifest_path_is_rejected_and_session_survives. A fake peer
  advertises a manifest with a legitimate Notes/a.md and a traversal entry
  Notes/../evil.md, serves a.md normally, and replies Msg::Error to any
  other GetFile. Asserts the traversal entry is rejected before any
  GetFile is sent (never reaches the network), the recoverable Msg::Error
  path does not abort the session (run_sync still returns Ok), a.md is
  downloaded, and nothing is written for the traversal entry (checked at
  its resolved escape target and by asserting Notes/ contains only a.md).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Context: The sync backend (engine, noise, server, state, manifest, decision, protocol) is complete. This task exposes it over Tauri IPC so the frontend can start/stop the listener, generate keys, and initiate sync sessions.

Problem: No IPC commands exist to invoke sync operations from the frontend.

Solution: Created `commands/sync.rs` with 6 async/sync command wrappers:
- sync_generate_pairing_key() - fresh 64-hex-char key
- sync_start_listener(vault_path, port, key, device_name, exposed_folders) - (re)start listener, return bound port
- sync_stop_listener() - stop listening if running
- sync_status() - return {listening, port, localIp} struct
- sync_list_remote_shares(address, key, device_name) - ask peer for its exposed folders
- sync_now(vault_path, address, key, device_name, subscriptions) - run one pull session

Each wraps the corresponding backend function in `crate::sync::*`. Added:
- SyncStatus struct (serializes to camelCase JSON)
- local_lan_ip() helper (UDP socket bind trick for best-effort LAN IP)
- State management: SyncServerState registered via .manage()

Behavior: Frontend can now generate pairing keys, start listeners on dynamic ports, query peer shares, and sync. All tests pass (627 unit + integration tests). No breaking changes.

Files with line ranges:
- src-tauri/src/commands/sync.rs: 1-129 (new file)
- src-tauri/src/commands/mod.rs: 8 (added pub mod sync)
- src-tauri/src/lib.rs: 281 (added .manage), 347-352 (added 6 commands to invoke_handler)
- tasks/todo/p2p-sync.md: 17 (marked Task 8 done)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Context: Frontend plumbing for P2P vault sync feature (backend tasks 1-8
complete). The app needs to persist and manage sync configuration: listener
enabled/port, device name, peer address, pairing key, and folder subscriptions.

Problem: No sync settings existed. Frontend could not read, update, or persist
sync configuration. Settings load-on-startup did not merge sync defaults with
persisted values.

Solution: Added SyncSettings interface defining all sync config fields.
Integrated into AppSettings with default values (listener disabled, random port
0, empty device/pairing/peer, no subscriptions). Added settingsStore.sync getter
and updateSync() method for reactive access and partial updates. Updated
loadSettings to merge sync defaults with disk values on startup.

Behavior: Frontend can now read/write P2P sync configuration. Settings persisted
to .kokobrain/settings.json survive restarts. Partial updates preserve unmodified
fields. All 46 settings tests pass, including new sync defaults and merge tests.

Files:
- src/lib/core/settings/settings.types.ts:283-304: Added SyncSettings interface
  with 7 fields (exposeEnabled, listenPort, deviceName, pairingKey, peerAddress,
  exposedFolders, subscriptions) + sync key to AppSettings:345
- src/lib/core/settings/settings.store.svelte.ts:1: Imported SyncSettings type
- src/lib/core/settings/settings.store.svelte.ts:126-133: Added DEFAULT_SETTINGS.sync
  with defaults (exposeEnabled=false, listenPort=0, empty strings, empty arrays)
- src/lib/core/settings/settings.store.svelte.ts:163: Added get sync() getter
- src/lib/core/settings/settings.store.svelte.ts:294-296: Added updateSync() method
  to merge partial SyncSettings with existing values
- src/lib/core/settings/settings.service.ts:137: Added sync merge line in merged
  object inside loadSettings() to apply defaults + persisted overrides
- src/tests/lib/core/settings/settings.store.test.ts:313-333: Added 2 new tests:
  sync defaults (all fields match expected zero/empty values) + updateSync merge
  behavior (partial updates preserve unmodified fields across multiple calls)
- tasks/todo/p2p-sync.md:18: Marked Task 9 complete

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Context: Task 10 of the P2P sync feature implementation. The sync plugin
needs reactive state to track listener status (port, IP, running), remote
peer's exposed shares list, last sync session summary (downloads, conflicts,
errors), and flags for syncing/busy operations.

Solution: Implemented syncStore following the project's getter-based store
pattern with Svelte 5 runes ($state). Added types file with SyncSummary and
SyncListenerStatus interfaces (mirroring Rust types). Store provides state
getters, individual setters, and a reset() method. Computed getter lastSyncClean
returns true only when the last sync finished without errors, enabling UI to
display a "clean" vs "error" state.

Behavior: Frontend components can now subscribe to reactive listener status
and sync state. The lastSyncClean computed getter drives conditional UI
rendering (success vs error display). Reset is called on vault teardown.

Files:
- src/lib/plugins/sync/sync.types.ts:1-23: New file with SyncSummary and
  SyncListenerStatus interfaces (with JSDoc)
- src/lib/plugins/sync/sync.store.svelte.ts:1-73: New store implementation
  with 7 getters (status, remoteShares, lastSummary, lastSyncAt, syncing,
  busy, lastSyncClean), 7 setters, and reset() method
- src/tests/lib/plugins/sync/sync.store.test.ts:1-50: Test suite covering
  defaults, setter/reset, and lastSyncClean computed getter logic
- tasks/todo/p2p-sync.md:19: Marked Task 10 complete

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

Context: The privacy workflow's "Scan for External Calls" job greps
src-tauri/src for networking primitives (TcpStream::connect,
UdpSocket::connect, hardcoded URLs) and fails on any match not on the
reviewed `known` allowlist. The new P2P vault sync engine opens a direct
TCP connection to a peer, so the scan blocked PR #143
(run 28747919240).

Problem: sync/engine.rs:59 `TcpStream::connect(&target.address)` is a
legitimate, reviewed external call — the address is the ip:port the user
enters in Sync settings, never hardcoded, and there is no external
server — but the scan has no way to know that and fails the build.

Solution: Add a documented entry to the scan action's `known` allowlist
in privacy.yml matching `sync/engine\.rs:.*TcpStream::connect`. The
read-only listener uses TcpListener::bind (not a scanned pattern) and the
local-IP helper uses `socket.connect` (not the `UdpSocket::connect`
pattern), so neither needs allowlisting. Verified by locally replaying
the action's pattern/skip/known classification: 0 unknown/blocking
matches remain.

Behavior: The privacy scan passes; the sync TCP connect surfaces as a
reviewed `::notice::` instead of a blocking error. No source or runtime
change.

Files:
- .github/workflows/privacy.yml: added the sync/engine.rs TcpStream
  entry (with rationale comment) to the Rust scan's `known` list

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Context:
Task 11 of P2P Vault Sync feature. Implements the service layer that wraps
Tauri sync IPC commands (sync_generate_pairing_key, sync_start_listener,
sync_stop_listener, sync_status, sync_list_remote_shares, sync_now),
integrates with syncStore and settingsStore, and provides error handling
with user-facing toasts.

Problem:
The sync feature had Tauri backend commands and a reactive store (Task 10)
but no TypeScript service layer to orchestrate them. Components need a
clean async API to trigger sync operations with proper state management
and error handling.

Solution:
Created sync.service.ts with six exported async functions:
- generatePairingKey(): invoke Rust command, store key in settings
- startListener(vaultPath): start listener, persist ephemeral port, refresh status
- stopListener(): stop listener, refresh status
- refreshStatus(): fetch and store listener status (no toast, used opportunistically)
- listRemoteShares(): fetch peer's exposed folders, manage busy state
- syncNow(vaultPath): run one pull session, store summary + timestamp, show result toast

Each function follows the error handling pattern: try/catch, log via error(),
show toast on failure, throw to propagate to caller. State updates happen
via syncStore setters; port persistence uses saveSettings(vaultPath) when
needed.

Behavior:
- generatePairingKey: stores 64-char hex key; rethrows on failure
- startListener: adapts ephemeral port 0 picks; skips re-save if port unchanged
- stopListener: always refreshes status
- refreshStatus: stores SyncListenerStatus; throws on failure (no user-facing toast)
- listRemoteShares: manages busy state in finally block; shows toast on failure
- syncNow: manages syncing state in finally block; shows success/warning toasts

Tests (10 total, all passing):
- generatePairingKey: happy path (stores key), error path (rethrows, leaves settings)
- startListener: ephemeral port save + refresh, configured port no re-save
- stopListener: refreshes status
- refreshStatus: stores status
- listRemoteShares: success (stores folders, clears busy), error (clears busy, rethrows)
- syncNow: success (stores summary + timestamp, shows toast), error (clears syncing, rethrows)

Test coverage uses real syncStore and settingsStore (no mocks), mocks only
@tauri-apps/api/core, svelte-sonner, $lib/utils/debug, and settings.service.
All assertions on real store state, not .toHaveBeenCalled() alone.

Files:
- src/lib/plugins/sync/sync.service.ts: 126 lines (lines 1-126)
- src/tests/lib/plugins/sync/sync.service.test.ts: 134 lines (lines 1-134)
- tasks/todo/p2p-sync.md: marked Task 11 [x]

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Context: The Task 10 sync store review found that the "setters update
state and reset clears it" test mutated all six fields but only asserted
three (status, remoteShares, syncing) after reset(). A regression that
dropped busy, lastSyncAt, or lastSummary from reset() would pass the
suite undetected, since those fields' defaults are otherwise only checked
by the "has inert defaults" test before any mutation.

Solution: Add the three missing post-reset assertions (lastSummary,
lastSyncAt, busy) alongside the existing three so reset() is fully
covered.

Behavior: Test-only change; the store implementation was already correct.
sync.store test: 3/3 pass; pnpm check: 0 errors.

Files:
- src/tests/lib/plugins/sync/sync.store.test.ts: three added assertions
  in the reset() test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Context:
The sync.service test suite had incomplete coverage for error handling
paths. Three functions (startListener, stopListener, refreshStatus) had
only happy-path tests; additionally, syncNow's warning-toast branch
(when errors are present) was not exercised.

Problem:
Project testing rule 11 (docs/TESTING.md) requires every service
function to include both happy-path and error-propagation tests. The
missing tests left rethrow behavior and error-state invariants
unverified.

Solution:
Added four new tests to src/tests/lib/plugins/sync/sync.service.test.ts:

1. startListener error path: Verify invoke rejection is rethrown and
   saveSettings is NOT called (failure before port persistence).

2. stopListener error path: Verify invoke rejection is rethrown.

3. refreshStatus error path: Verify invoke rejection is rethrown and
   syncStore.status remains at default { listening: false, port: null,
   localIp: null }.

4. syncNow warning-branch: When invoke resolves with non-empty errors
   array, verify lastSummary.errors has length 1,
   lastSyncClean is false, and syncing clears to false.

Behavior:
All 14 tests in sync.service.test.ts pass (10 existing + 4 new).
Type checking passes (0 errors). All six service functions
(generatePairingKey, startListener, stopListener, refreshStatus,
listRemoteShares, syncNow) now have both happy-path and error-path
coverage.

Files:
- src/tests/lib/plugins/sync/sync.service.test.ts (lines 135–164,
  new tests added after existing suite)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Context: Tasks 9-11 built the sync settings block, reactive store, and
Tauri-calling service for P2P vault sync, but there was no UI to
configure them and the listener never started automatically on vault
open. This task adds the last integration points: the SettingsPanel
section, the SettingsSection union entry (deliberately deferred from
Task 9 so the union and the panel's icon Record change together in one
commit), and vault init/teardown hooks for the expose-on-startup
listener.

Solution: Added SyncSection.svelte following the existing settings
section pattern (SettingItem rows, Input/Switch/Button from
$lib/components/ui, an untrack()-wrapped $effect for the initial
refreshStatus() call per docs/PATTERNS.md so the service call isn't
tracked as a reactive dependency). Registered 'sync' in the
SettingsSection union and in the Integrations settings group after
Todoist, and added the section's icon + content branch to
SettingsPanel.svelte. Wired app-lifecycle.service.ts to start the
listener on vault init when settingsStore.sync.exposeEnabled is true
(mirroring the existing auto-move enable/disable pattern), stop it
during teardown alongside the other background-process shutdowns, and
reset syncStore in the store-reset block alongside todoistStore.

Behavior: The Settings dialog now has a "Sync" entry under
Integrations with controls for device name, an expose-to-peer toggle,
pairing key generate/copy, peer address, exposed folders, peer
subscriptions, and a manual "Sync now" action with a last-sync
summary. Opening a vault with sync.exposeEnabled on now starts the
listener automatically; closing the vault stops it and resets the
sync store.

Files:
- src/lib/core/settings/sections/SyncSection.svelte:1-239: New section
  component wiring syncStore/sync.service to the sync settings fields,
  listener status, remote share listing, and sync-now.
- src/lib/core/settings/settings.types.ts:308: Added 'sync' to the
  SettingsSection union (deferred from Task 9 per plan notes).
- src/lib/core/settings/settings.logic.ts:91-97: Added the sync
  section entry to the Integrations group after Todoist.
- src/lib/core/settings/SettingsPanel.svelte:28,46,69,159-160: Added
  the SyncSection import, ArrowLeftRightIcon import, sectionIcons
  entry, and the {:else if 'sync'} content branch after Todoist.
- src/lib/core/app-lifecycle/app-lifecycle.service.ts:68-69,279-285,
  395,450: Added sync.service/sync.store imports, a start-listener-on
  -init block after the auto-move block, a stop-listener call in the
  background-process teardown block, and syncStore.reset() in the
  store-reset block.
- src/tests/lib/core/settings/settings.logic.test.ts:206,217-220:
  Updated the pre-existing ordered-ids assertion to include 'sync' (a
  direct consequence of registering the new section) and added a new
  test asserting the sync section is registered under Integrations.
- tasks/todo/p2p-sync.md:21: Marked Task 12 complete.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Context: The Sync settings section has a "Copy" button that writes the
pairing key to the clipboard via writeText() from
@tauri-apps/plugin-clipboard-manager. Manual testing showed the button
did nothing — the key never reached the clipboard and no error surfaced.

Problem: The main-window capability (capabilities/default.json) granted
only clipboard-manager:allow-read-text (used by deep-link/quick-capture
paste), never allow-write-text. Tauri therefore denied writeText(), the
promise rejected, and handleCopyKey() had no try/catch, so the failure
was swallowed with no user feedback.

Solution: Add the granular clipboard-manager:allow-write-text permission
(matching the file's least-privilege style, not the broader :default).
Harden handleCopyKey() with try/catch: a success toast confirms the copy
(otherwise invisible to the user) and an error toast surfaces any future
failure instead of swallowing it; also skip the no-op when the key is
empty.

Behavior: With the app rebuilt, clicking Copy places the pairing key on
the clipboard and shows "Pairing key copied to clipboard". The capability
change takes effect only after a Tauri rebuild (restart pnpm tauri dev),
since capabilities are compiled into the binary.

Files:
- src-tauri/capabilities/default.json: add clipboard-manager:allow-write-text
- src/lib/core/settings/sections/SyncSection.svelte: try/catch + toast
  feedback in handleCopyKey, empty-key guard, import toast

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Context: The Cargo Audit CI job (rustsec/audit-check in security.yml)
started failing on PR #143 after the RUSTSEC database added two quick-xml
advisories on 2026-07-05. This is unrelated to the P2P sync feature — it
would fail on any branch, including main.

Problem: quick-xml 0.39.4 has RUSTSEC-2026-0194 (O(N^2) duplicate-attribute
check) and RUSTSEC-2026-0195 (unbounded namespace-declaration allocation),
both DoS, patched in >=0.41.0. It is pulled only transitively:
quick-xml <- plist <- tauri. plist 1.9.0 pins quick-xml "^0.39.2", so
`cargo update` cannot select the patched 0.41.x without an upstream
tauri/plist release — the fix is not available to this repo yet.

Solution: Add src-tauri/.cargo/audit.toml ignoring exactly those two
advisory IDs, with a documented justification: quick-xml is reached only
through plist parsing app-controlled macOS property lists (bundle
Info.plist), never untrusted network XML, so neither DoS vector is
reachable in this app. The ignore is scoped to the two IDs — any other
advisory still fails the audit. Verified locally that cargo-audit reads
this file from src-tauri/.cargo/audit.toml and applies the ignore list.

Behavior: Cargo Audit passes; the two quick-xml advisories are silenced
with a revisit note (drop the ignores once tauri ships a plist allowing
quick-xml >= 0.41). No dependency or source change.

Files:
- src-tauri/.cargo/audit.toml: new; ignore RUSTSEC-2026-0194 / -0195

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Context: All 13 tasks of the P2P vault sync plan are implemented,
reviewed, and committed. The CLAUDE.md plugins list already lists `sync`
(commit e1fec15), so this closeout only moves the plan checklist to done.

Solution: Mark Task 13 complete and move the task file from tasks/todo to
tasks/done per the project's Plan Mode Workflow.

Behavior: Documentation/bookkeeping only.

Files:
- tasks/todo/p2p-sync.md -> tasks/done/p2p-sync.md (all 13 tasks checked)

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

Context: PR #143's Cargo Audit job kept failing on the two quick-xml DoS
advisories even after src-tauri/.cargo/audit.toml was added (commit
7cf2176). The CI output showed "ignore":[] — the rustsec/audit-check
action does NOT read .cargo/audit.toml; that file only affects a local
`cargo audit` CLI run.

Problem: The ignore list was in the wrong place for CI. The action
(confirmed via its action.yml at the pinned SHA) exposes an `ignore`
input — a comma-separated list of advisory IDs — which is the only
mechanism it honors.

Solution: Add `ignore: RUSTSEC-2026-0194,RUSTSEC-2026-0195` to the
audit-check step's `with:` block in security.yml, with the same
justification comment. Reword src-tauri/.cargo/audit.toml to state
clearly that it is for LOCAL runs only and that CI reads the workflow
input, so the two must be kept in sync — preventing the exact confusion
that caused this second failure.

Behavior: Cargo Audit passes in CI (the two quick-xml advisories are
ignored with justification); local `cargo audit` from src-tauri also
passes via audit.toml. Scoped to the two IDs — any other advisory still
fails.

Files:
- .github/workflows/security.yml: add ignore input to the audit-check step
- src-tauri/.cargo/audit.toml: clarify it is local-only, keep IDs in sync

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Context: Final security review of the P2P sync feature (docs/superpowers/specs/2026-07-03-p2p-sync-design.md
§Security) found that serve_file only validated the resolved path against
the vault root, not the specific exposed folder the request claimed to be
under.

Problem: A symlink inside an exposed folder that points elsewhere in the
vault passed validation. Example: vault exposes ["Notes"] and also
contains Private/secret.md; a symlink Notes/leak.md -> ../Private/secret.md
resolves inside the vault root, so the old check (resolved.starts_with(vault_root))
accepted it. build_manifest skips symlinks so the name is never advertised,
but an authenticated peer who guesses/knows the name via GetFile { rel_path:
"Notes/leak.md" } received Private/secret.md, a file outside the exposed
set. Additionally, serve_file's error replies distinguished "file not
shared" from "file not readable: {os error}" / "read failed: {os error}",
letting a peer probe file existence and leaking raw OS error text.

Solution: Replaced the boolean is_exposed helper with
matched_exposed_folder, which returns the specific exposed folder a
rel_path sits inside. serve_file now canonicalizes that matched folder
(not the vault root) and requires the resolved file to stay under it,
so a symlink can no longer escape into a sibling folder while staying
inside the vault. All failure branches (invalid path, no matching folder,
folder/file canonicalize failure, starts_with check, read failure) now
reply with one generic "file not available: {rel_path}" message, removing
the OS error text and the distinguishable "not shared" vs "not
readable"/"read failed" wording a peer could use to probe.

Behavior: Previously, GetFile for a symlink inside an exposed folder that
targeted a file outside it would succeed and stream the target file's
bytes. Now it is refused with a generic Error reply, verified end-to-end:
a new test spawns a server exposing only "Notes", creates
Notes/leak.md -> ../Private/secret.md, and asserts GetFile returns
Msg::Error (never FileChunk/FileEnd) while GetFile for the real
Notes/a.md still serves normally. Re-running the test against the
pre-fix code (via a temporary git stash of only server.rs) confirmed it
fails red, receiving a FileChunk containing the secret's bytes.

Files:
- src-tauri/src/sync/server.rs:159-205: Replaced is_exposed(bool) with
  matched_exposed_folder(Option<&String>); rewrote serve_file to
  canonicalize and confine to the matched folder instead of the vault
  root, and unified every failure branch into one generic "file not
  available: {rel_path}" reply with no OS error text.
- src-tauri/tests/sync_server_test.rs:173-240: Added
  symlink_within_exposed_folder_is_refused (#[cfg(unix)]), a
  self-contained test (own tempdir vault, since spawn_test_server's
  fixed layout has no Private/ folder or symlink) covering the escape
  scenario and a sanity check that normal in-folder files still serve.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant