Skip to content

feat(desktop): macOS desktop catch-up and hybrid metadata fix#148

Merged
FSM1 merged 45 commits into
mainfrom
feat/phase-11.1-01
Feb 19, 2026
Merged

feat(desktop): macOS desktop catch-up and hybrid metadata fix#148
FSM1 merged 45 commits into
mainfrom
feat/phase-11.1-01

Conversation

@FSM1

@FSM1 FSM1 commented Feb 18, 2026

Copy link
Copy Markdown
Owner

Summary

  • Complete Phase 11.1 (macOS Desktop Catch-Up): crypto primitives, Core Kit auth migration, HKDF vault init, v2 FUSE metadata, MFA challenge UI, device registry, SIWE wallet login
  • Fix FUSE mount showing empty when web app writes hybrid v2 metadata with mixed v1/v2 file entries
  • Switch FUSE-T to SMB backend (NFS kernel bug workaround), add proactive readdir prefetch, non-blocking open/read
  • Add dev-key headless auth mode, security fixes, branded tray/app icons

Test plan

  • Desktop app launches and authenticates via staging API
  • FUSE mount at ~/CipherBox shows all vault files (v1 inline + v2 FilePointer)
  • Subfolder contents pre-populated
  • File read/download works through mount
  • Verify on clean clone (no stale build artifacts)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • macOS Phase 11.1: Core Kit auth (Google/Email), SIWE wallet login, MFA recovery/approval, headless dev-key, device registry, deterministic IPNS keys, AES‑CTR per-file decryption, and FUSE v2 folder metadata with per-file IPNS pointers.
  • Bug Fixes

    • Hardened auth logging (redaction/zeroize), URL‑encoded IPNS requests, SMB backend compatibility (opendir handle), NFC filename normalization, and robust FUSE socket receive to handle fragmented messages.
  • Documentation

    • Expanded ROADMAP/STATE, verification reports, developer guide, env examples, and vendor FUSE docs.
  • Tests

    • Extensive crypto, FUSE, v2 metadata, and AES‑CTR test coverage and examples.

FSM1 and others added 30 commits February 17, 2026 17:43
- Add hkdf.rs with derive_vault_ipns_keypair, derive_file_ipns_keypair,
  derive_registry_ipns_keypair using HKDF-SHA256 with domain-separated info
- Add aes_ctr.rs with encrypt_aes_ctr, decrypt_aes_ctr, decrypt_aes_ctr_range
  using Ctr64BE matching Web Crypto API length:64
- Add hkdf, sha2, aes, ctr crate dependencies to Cargo.toml
- Register aes_ctr and hkdf modules in crypto/mod.rs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- M-11: Manual Debug impls for auth DTOs redact tokens and keys
- L-2: URL-encode IPNS name query parameter via urlencoding crate
- L-4: Remove console.log that leaked private key length
- L-5: Zeroize Ed25519 key_bytes stack copy after SigningKey creation
- L-7: Sanitize sync daemon error messages before tray display

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ests

- Add FilePointer, FolderChildV2, FolderMetadataV2, AnyFolderMetadata types
- Add FileMetadata with serde default "GCM" for encryptionMode field
- Add decrypt_any_folder_metadata for v1/v2 version-dispatched parsing
- Add encrypt_file_metadata and decrypt_file_metadata functions
- Add 20 new tests: HKDF derivation, AES-CTR roundtrip/range, v2 types,
  FileMetadata serde, camelCase serialization, encrypt/decrypt roundtrips
- All 113 tests pass (cargo test)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- clap CLI arg parsing gated by #[cfg(debug_assertions)]
- dev_key stored in AppState, accessible via get_dev_key Tauri command
- dev_key zeroized on clear_keys() (logout)
- Release builds compile out the CLI module entirely

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tasks completed: 2/2
- HKDF derivation functions and AES-CTR primitives
- v2 folder schema types + FileMetadata type + tests

SUMMARY: .planning/phases/11.1-macos-desktop-catch-up/11.1-01-SUMMARY.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tasks completed: 2/2
- Task 1: Security fixes M-11, L-2, L-4, L-5, L-7
- Task 2: --dev-key CLI argument for headless auth

SUMMARY: .planning/phases/11.1-macos-desktop-catch-up/11.1-02-SUMMARY.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Replace random Ed25519 keygen with HKDF deterministic derivation
- initialize_vault now calls crypto::hkdf::derive_vault_ipns_keypair
- IPNS name derived directly from HKDF output (no separate derivation)
- fetch_and_decrypt_vault verifies stored key matches HKDF derivation
- Backward compatible: warns on mismatch but proceeds with stored key

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Replace PnP Modal SDK (@web3auth/modal) with MPC Core Kit
- Add @toruslabs/tss-dkls-lib TSS library dependency
- Rewrite auth.ts: Core Kit loginWithJWT via CipherBox identity provider
- Google OAuth via Google Identity Services (GIS) with 60s timeout
- Email OTP via CipherBox backend send-otp/verify-otp endpoints
- REQUIRED_SHARE status handling for MFA-enabled accounts
- Add vite-env.d.ts for import.meta.env type support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Rewrite main.ts: CipherBox-branded login with Google OAuth + Email OTP
- Replace single [CONNECT] button with full login form
- Google sign-in via GIS, email OTP with send-code/verify flow
- REQUIRED_SHARE stub UI for MFA-enabled accounts (full MFA in Plan 05)
- Dev-key auth flow via test-login endpoint for debug builds
- Update commands.rs loginType from 'social' to 'corekit'
- Add Clone/Debug derives + to_v1() helper to AnyFolderMetadata
- Include uncommitted v2 folder metadata handling in FUSE layer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tasks completed: 2/2
- Core Kit SDK setup and auth.ts rewrite
- Login UI in main.ts + Rust loginType update

SUMMARY: .planning/phases/11.1-macos-desktop-catch-up/11.1-04-SUMMARY.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tasks completed: 2/2
- Task 1: HKDF vault initialization in commands.rs
- Task 2: v2 folder metadata + CTR decryption in FUSE layer

SUMMARY: .planning/phases/11.1-macos-desktop-catch-up/11.1-03-SUMMARY.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add loginWithWallet() in auth.ts using injected EIP-1193 provider
- Build EIP-4361 SIWE message matching web app format exactly
- Nonce from GET /auth/identity/wallet/nonce, verify via POST /auth/identity/wallet
- Add "Connect Wallet" button to login UI between Google and Email
- Wire wallet button with error handling and REQUIRED_SHARE support
- Document Tauri webview limitation for browser extensions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- DeviceEntry and DeviceRegistry types matching web app TypeScript definitions
- ECIES-encrypted registry CRUD: register, fetch, decrypt
- HKDF-derived IPNS keypair for deterministic registry discovery
- Persistent device ID via macOS Keychain
- 7 unit tests for serialization, deserialization, and helpers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tasks completed: 1/1
- SIWE wallet login implementation

SUMMARY: .planning/phases/11.1-macos-desktop-catch-up/11.1-07-SUMMARY.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add tokio::spawn wrapper for register_device after step 8 (mark authenticated)
- Fire-and-forget pattern: failures logged but never block login
- Uses cloned Arc<ApiClient>, private key, and public key for spawned task
- Desktop device appears in web app's Authorized Devices view after first login

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tasks completed: 2/2
- Device registry types and service module
- Non-blocking registry call in auth flow

SUMMARY: .planning/phases/11.1-macos-desktop-catch-up/11.1-06-SUMMARY.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- replace stub with full recovery phrase + approval UI
- recovery path: 24-word mnemonic textarea with validation
- approval path: bulletin board request + 3s polling
- cancel button cleans up approval request + ephemeral key
- back-to-login cleans up active approval before logout
- import inputRecoveryPhrase, requestDeviceApproval, etc.
- add Phase 11.2 TODO for approval listener after auth

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tasks completed: 2/2
- MFA recovery and device approval in auth.ts
- REQUIRED_SHARE UI in main.ts

SUMMARY: .planning/phases/11.1-macos-desktop-catch-up/11.1-05-SUMMARY.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
7 plans executed across 3 waves. Verified 10/10 must-haves:
- HKDF vault IPNS derivation, AES-CTR decryption, v2 folder metadata
- Core Kit auth migration, MFA recovery, device registry, SIWE wallet

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace Tauri placeholder icons with CipherBox-branded assets:
- Tray icon: >_ prompt symbol as macOS template image (22x22, 44x44 @2x)
- App icons: green > on black background (32x32, 128x128, 128x128@2x, icns, ico)
- Load tray icon from embedded bytes with icon_as_template(true)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Update login page to match web app's terminal aesthetic:
- Black background (#000000) with green text (#00D084)
- JetBrains Mono font, > prompt prefix on title
- Green-on-dark button and input styles with matching hover/focus states
- Remove wallet connect button (not available in Tauri webview)
- Update MFA challenge UI and loading/error screens to match

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
GIS One Tap doesn't work in Tauri webview (no Google session,
unregistered origin). Switch to OAuth2 implicit flow that opens
Google consent page in a popup, with localStorage polling for
the ID token callback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Backend expects uncompressed secp256k1 public key (130 hex chars,
0x04 prefix) matching Web3Auth JWT. Was incorrectly sending
compressed format (66 hex chars).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add dotenvy to load .env from the desktop app root, sharing VITE_*
vars between webview and Rust backend. API URL now resolves:
CIPHERBOX_API_URL > VITE_API_URL > localhost default.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Fix three FUSE-T NFS issues:
- create() marks parent as mutated to prevent background refresh
  from wiping newly created files
- release() uploads new files (empty CID) even when not dirty,
  fixing touch+release without write
- open() downloads content synchronously instead of async prefetch,
  preventing NFS EIO retry exhaustion on large files
- Add trace logging to all FUSE callbacks for debugging

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add detailed instructions for --dev-key CLI flag, including:
- Requirements (VITE_TEST_LOGIN_SECRET, API NODE_ENV)
- Usage examples with local API
- Note that staging doesn't support it yet (NODE_ENV=production)
- Add VITE_TEST_LOGIN_SECRET to .env.example

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two key FUSE-T NFS fixes:

1. Background IPNS refresh now uses merge-only mode: existing children
   not in remote metadata are preserved instead of removed. This prevents
   files from disappearing when IPNS publish hasn't propagated yet.
   Initial mount still does full replace.

2. read() now downloads content synchronously as fallback when cache
   misses. This handles NFSv3 stateless behavior where FUSE-T may call
   read() without a prior open(), and cache eviction scenarios.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
FUSE-T uses NFSv4 with hard mount and timeo=10 (1-second timeout).
The previous open() blocked the single NFS thread for content download
(up to 120s), causing the mount to enter "not responding" state where
no READ operations could be processed.

Fix: open() now returns immediately and starts an async background
prefetch via the existing content_tx channel. read() serves from
cache if prefetch completed, otherwise falls back to synchronous
download. This keeps the NFS thread responsive and prevents mount
degradation.

Also extracts fetch_and_decrypt_content_async() helper to share
download+decrypt logic between prefetch tasks and read() fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
macOS NFS client may send filenames in NFC or NFD Unicode form.
Our inode table stores names from metadata which may also be in
either form. This mismatch causes files with accented characters
(e.g., Muyè) to fail lookup — ls shows the file but open/stat fails.

Fix: normalize all names to NFC in InodeTable insert/find_child/remove
and in the rename callback. This ensures consistent HashMap keys
regardless of which Unicode normalization form the NFS client uses.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
FSM1 and others added 9 commits February 18, 2026 17:42
Extract shared `complete_auth_setup` helper from `handle_auth_complete`
and add `handle_test_login_complete` (debug-only) that skips /auth/login.

Fixes three bugs in handleDevKeyAuth:
- Use accessToken from test-login response (not idToken)
- Use server-generated privateKeyHex (not CLI dev key) so keypair
  matches the user record for vault ECIES operations
- Call handle_test_login_complete instead of handle_auth_complete

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Dev-key mode was broken because no webview window was created on
startup (only created when user clicks Login in tray). The JS auth
flow never ran.

- Auto-create hidden webview in setup() when dev-key is present
- Add skip_keychain flag to complete_auth_setup to avoid macOS
  Keychain permission popups during automated test-login flow
- Import tauri::Manager trait for handle.state() access

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
WIP FUSE improvements for better NFS responsiveness:

- Proactive content prefetch on readdir (offset=0): starts downloading
  and decrypting all child file contents in background
- Write-open checks content cache before falling back to sync download
- read() cache miss uses poll-based approach (100ms increments, 3s max)
  instead of blocking sync download to avoid stalling NFS thread

Contains eprintln debug lines for ongoing debugging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… reads

macOS NFS client has a kernel bug where WRITE RPCs never reach FUSE-T's
NFS server for newly created files, causing permanent process hangs
(confirmed by FUSE-T author, reported to Apple).

Switch to FUSE-T's SMB backend which correctly delivers write callbacks.
Fix SMB-specific issues:
- opendir must return non-zero file handles (SMB treats fh=0 as invalid)
- Vendor patched fuser with peek-based receive() that handles Unix domain
  socket fragmentation — stock fuser assumes /dev/fuse atomic message
  delivery, but FUSE-T communicates via socket where large messages
  arrive in fragments and multiple messages can coalesce

Tested: 50MB and 100MB file write+readback with matching checksums,
20 rapid small file creates, directory operations, file deletion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… in debug

- Set reqwest timeout to 120s (was unlimited) with 10s connect timeout
  to prevent FUSE thread hangs from slow API responses
- Move device registry tokio::spawn after FUSE mount to prevent HTTP
  connection pool starvation during pre-populate
- Skip macOS Keychain in debug builds for device ID — each rebuild
  produces a new binary signature, triggering repeated permission prompts
- Remove debug eprintln statements from API client

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add learnings entry covering the NFS write bug discovery, SMB backend
switch, fuser socket patch, and what worked vs what didn't.

Rewrite desktop CLAUDE.md with comprehensive FUSE architecture docs,
known limitations, debugging commands, and detailed porting notes for
Linux (kernel FUSE) and Windows (WinFSP/Dokan) implementations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Area: crypto
Priority: urgent

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Web app writes version:"v2" folder metadata containing both v1-style
inline FileEntry children (with cid, fileKeyEncrypted) and v2-style
FilePointer children (with fileMetaIpnsName). The Rust deserializer
rejected this hybrid format, causing the FUSE mount to show empty.

Fix: make FileEntry fields optional via serde defaults, add
file_meta_ipns_name field, and detect pointer entries in
populate_folder. Also adds V2→V1 fallback in decrypt_any_folder_metadata
and debug logging for decrypted metadata JSON.

These workarounds are documented in the todo for removal once the web
app metadata versioning is fixed at the source.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Feb 18, 2026

Copy link
Copy Markdown

Caution

Review failed

The pull request is closed.

Walkthrough

Adds macOS desktop Phase 11.1 work: deterministic HKDF IPNS key derivation, v2 folder metadata with per-file IPNS pointers and eager resolution, AES‑CTR file decryptions and range reads, encrypted device registry, Core Kit/SIWE/MFA auth changes, vendored fuser 0.16 with a peek-based socket receive patch, FUSE-layer integration, tests, CI and docs.

Changes

Cohort / File(s) Summary
Vendored fuser & low-level FUSE
apps/desktop/src-tauri/vendor/fuser/**, apps/desktop/src-tauri/Cargo.toml
Added vendored fuser 0.16 (sources, examples, CI, build scripts); applied [patch.crates-io] override; implemented patched channel.rs::receive() (peek-first length-driven read) and new Channel/ReplySender APIs.
Crypto primitives & metadata (v2)
apps/desktop/src-tauri/src/crypto/{hkdf.rs,aes_ctr.rs,folder.rs,mod.rs,tests.rs}
New HKDF-based deterministic IPNS key derivation (vault/registry/file), AES-256-CTR encrypt/decrypt and range-decrypt, v2 FolderMetadata/FilePointer/FileMetadata types, AnyFolderMetadata dispatch, and tests.
FUSE layer & inode handling
apps/desktop/src-tauri/src/fuse/{inode.rs,mod.rs,operations.rs,operations/*}
NFC name normalization, per-file IPNS fields (file_meta_ipns_name,file_meta_resolved), populate_folder_any/v2 flows, eager pointer resolution helpers, debounced publish queue, SMB mount hint and opendir fh adjustments, and many read/write/publish path changes.
Tauri backend, commands & registry
apps/desktop/src-tauri/src/{commands.rs,main.rs,state.rs,registry/*}
Deterministic vault IPNS derivation integrated into initialize_vault; consolidated post-auth orchestration; added encrypted device registry module and non-blocking register_device; dev-key debug plumbing and new tauri commands (get_dev_key, handle_test_login_complete); AppState.dev_key field.
Auth/UI & frontend changes
apps/desktop/src/*, apps/desktop/package.json, apps/desktop/public/*, apps/desktop/.env.example
Migrated desktop auth to Core Kit (mpc-core-kit), added SIWE wallet flow and MFA scaffolding, headless dev-key support, updated dependencies, google-callback page and example env.
API, safety & misc infra
apps/desktop/src-tauri/src/api/{client.rs,ipns.rs,types.rs}, sync/mod.rs, tray/mod.rs, index.html
HTTP client timeouts, url-encode IPNS queries, redacted Debug impls for auth DTOs, error sanitization for tray messages, tray icon load change, dotenv loading and small frontend CSS tweak.
Tests, examples & CI
apps/desktop/src-tauri/src/crypto/tests.rs, apps/desktop/src-tauri/vendor/fuser/examples/*, vendor CI scripts
Expanded crypto and v2 metadata tests; many fuser examples, vendor CI workflows, Dockerfiles and test scripts added.
Planning, docs & todos
.planning/**, .learnings/*, apps/desktop/CLAUDE.md
Large set of new/updated planning, verification, TODOs and developer documentation describing SMB/FUSE rationale, vendored fuser patch, and Phase 11.1 scope.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant DesktopApp as Desktop App (Webview)
    participant Tauri as Tauri Backend
    participant Crypto as Crypto (HKDF/AES)
    participant IPFS as IPFS/IPNS
    participant Inode as Inode Table

    User->>DesktopApp: Request mount after auth
    DesktopApp->>Tauri: handle_auth_complete(access_token, private_key)
    Tauri->>Crypto: derive_vault_ipns_keypair(private_key)
    Crypto-->>Tauri: (ipns_private_key, ipns_public_key, ipns_name)
    Tauri->>IPFS: fetch metadata for ipns_name
    IPFS-->>Tauri: encrypted folder metadata (v1 or v2)
    Tauri->>Crypto: decrypt_any_folder_metadata(encrypted, folder_key)
    Crypto-->>Tauri: AnyFolderMetadata
    alt v2 with FilePointers
        Tauri->>Inode: populate_folder_v2(metadata_v2)
        Inode-->>Tauri: unresolved FilePointers list
        loop per pointer
            Tauri->>IPFS: resolve pointer (IPNS → CID)
            IPFS-->>Tauri: encrypted FileMetadata
            Tauri->>Crypto: decrypt_file_metadata(encrypted, folder_key)
            Crypto-->>Tauri: FileMetadata (cid,key,iv,size,mode)
            Tauri->>Inode: resolve_file_pointer(ino, cid, key, iv, size, mode)
        end
    else v1
        Tauri->>Inode: populate_folder(metadata_v1)
    end
    Tauri->>DesktopApp: mount ready
Loading
sequenceDiagram
    participant User
    participant Desktop as Desktop App
    participant Wallet as Wallet (EIP-1193)
    participant CipherBox as Backend
    participant CoreKit as Core Kit
    participant Tauri as Tauri Backend

    User->>Desktop: Click "Connect Wallet"
    Desktop->>Wallet: request accounts / personal_sign
    Wallet-->>Desktop: account & signature
    Desktop->>CipherBox: GET nonce -> POST signature for JWT
    CipherBox-->>Desktop: JWT
    Desktop->>CoreKit: loginWithJWT(JWT)
    CoreKit-->>Desktop: export TSS key
    Desktop->>Tauri: handle_auth_complete(JWT, exported_keys)
    Tauri->>Crypto: derive_vault_ipns_keypair(exported_key)
    Crypto-->>Tauri: ipns_name
    Tauri->>IPFS: fetch & decrypt metadata → mount
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main accomplishment: completing macOS desktop catch-up and fixing hybrid metadata handling, which aligns with the extensive changes across crypto, FUSE, authentication, and related desktop components.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/phase-11.1-01

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 and usage tips.

@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: 5

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
apps/desktop/src-tauri/src/crypto/ed25519.rs (1)

85-97: ⚠️ Potential issue | 🟡 Minor

get_public_key leaves its key_bytes stack copy unzeroized – inconsistent with sign_ed25519.

The same private-key pattern is used here (stack-allocated [u8; 32] copy), but key_bytes.zeroize() is never called. The verification doc already notes this as a warning anti-pattern. Risk is lower (read-only derivation), but the pattern should be consistent.

🛡️ Proposed fix
 pub fn get_public_key(private_key: &[u8]) -> Result<Vec<u8>, Ed25519Error> {
     if private_key.len() != ED25519_PRIVATE_KEY_SIZE {
         return Err(Ed25519Error::InvalidPrivateKeySize);
     }
 
-    let key_bytes: [u8; 32] = private_key
+    let mut key_bytes: [u8; 32] = private_key
         .try_into()
         .map_err(|_| Ed25519Error::InvalidPrivateKeySize)?;
     let signing_key = SigningKey::from_bytes(&key_bytes);
+    key_bytes.zeroize();
     let verifying_key = signing_key.verifying_key();
 
     Ok(verifying_key.to_bytes().to_vec())
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src-tauri/src/crypto/ed25519.rs` around lines 85 - 97,
get_public_key creates a stack-allocated copy key_bytes of the private key but
doesn’t zeroize it like sign_ed25519 does; update get_public_key to call
key_bytes.zeroize() after constructing the SigningKey (or after use) so the
temporary [u8; 32] is cleared, referencing the existing key_bytes variable and
the SigningKey::from_bytes / signing_key.verifying_key usage to determine the
safe point to zeroize. Ensure you import/use the same zeroize trait used
elsewhere and keep zeroization immediate after the private bytes are no longer
needed.
.planning/STATE.md (1)

143-155: ⚠️ Potential issue | 🟡 Minor

Pending Todos section is missing 2 entries from this PR.

The count says "10 pending todo(s)" but only 8 are listed. The two new planning todos added in this PR — 2026-02-18-remove-pinata-references-from-api.md and 2026-02-18-fix-fuse-rename-on-smb-backend.md — are not reflected in this list.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.planning/STATE.md around lines 143 - 155, Update the "### Pending Todos"
section so the displayed count and list include the two missing entries from
this PR: add `2026-02-18-remove-pinata-references-from-api.md` and
`2026-02-18-fix-fuse-rename-on-smb-backend.md` to the list and change the count
text from "10 pending todo(s)" to "12 pending todo(s)"; ensure the new lines
follow the existing format (dash, backtick filename, description and area) used
by the other entries so the section remains consistent.
apps/desktop/src-tauri/src/commands.rs (1)

729-738: ⚠️ Potential issue | 🟡 Minor

derive_compressed_public_key_hex is unused dead code and should be removed.

The function has no callers in the codebase. The login flow derives the uncompressed public key via derive_public_key() at line 49 and sends it in the login request at line 55, making the compressed variant unnecessary.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src-tauri/src/commands.rs` around lines 729 - 738, Remove the
dead function derive_compressed_public_key_hex from commands.rs since it is
unused; delete the entire function definition and any now-unused imports it
solely relied on (e.g., ecies, hex) and run a build to confirm no remaining
references; keep derive_public_key() and the existing login flow intact (they
already produce the uncompressed key), and ensure no other code attempts to call
derive_compressed_public_key_hex after removal.
apps/desktop/src-tauri/src/fuse/mod.rs (1)

448-485: ⚠️ Potential issue | 🟠 Major

Preserve file_meta_ipns_name when publishing folder metadata.

build_folder_metadata hard-codes file_meta_ipns_name: None at line 484, dropping FilePointer IPNS names from v2-format files. When the folder metadata is published, unresolved FilePointers (files with empty CID but valid IPNS reference) lose their only way to be resolved, becoming undiscoverable to other clients. Capture file_meta_ipns_name from the inode and propagate it to the FileEntry.

Suggested fix
-                inode::InodeKind::File {
-                    cid,
-                    encrypted_file_key,
-                    iv,
-                    size,
-                    encryption_mode,
-                    ..
-                } => {
+                inode::InodeKind::File {
+                    cid,
+                    encrypted_file_key,
+                    iv,
+                    size,
+                    encryption_mode,
+                    file_meta_ipns_name,
+                    ..
+                } => {
                     ...
                     metadata_children.push(crate::crypto::folder::FolderChild::File(
                         crate::crypto::folder::FileEntry {
                             ...
-                            file_meta_ipns_name: None,
+                            file_meta_ipns_name: file_meta_ipns_name.clone(),
                         },
                     ));
                 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src-tauri/src/fuse/mod.rs` around lines 448 - 485,
build_folder_metadata currently hard-codes FileEntry.file_meta_ipns_name to
None, losing FilePointer IPNS names; update the code in the InodeKind::File
match arm so it reads the file_meta_ipns_name from the inode and passes it into
crate::crypto::folder::FileEntry instead of None. Locate the match for
inode::InodeKind::File (the block that constructs
crate::crypto::folder::FolderChild::File and uses uuid_from_ino(child_ino),
name, cid, file_key_encrypted, file_iv, size, created_at, modified_at,
encryption_mode) and replace the hard-coded None with the inode's
file_meta_ipns_name (propagating the value you destructure or access from
`child`/the inode) so unresolved FilePointers keep their IPNS reference when
publishing folder metadata.
🟠 Major comments (17)
apps/desktop/src-tauri/vendor/fuser/simplefs_tests.sh-5-9 (1)

5-9: ⚠️ Potential issue | 🟠 Major

INT/EXIT trap silently swallows failures — ${TEST_EXIT_STATUS:-1} required

The INT/EXIT trap at line 9 uses bare $TEST_EXIT_STATUS, while the TERM handler at line 6 correctly uses ${TEST_EXIT_STATUS:-1}. Because TEST_EXIT_STATUS is only set at lines 36 and 46, any early-exit path — such as the mount check at line 30 (exit 1) or the touch failure at line 37 — fires the EXIT trap while TEST_EXIT_STATUS is still unset. In bash, exit $unset_var expands to exit which is treated as exit 0, so the CI job reports success even when the test has failed.

Every sibling script that shares this variable (bsd_mount_tests.sh, mount_tests.sh, osx_mount_tests.sh) uses ${TEST_EXIT_STATUS:-1} to guard against exactly this.

🐛 Proposed fix
-trap 'kill $(jobs -p); exit $TEST_EXIT_STATUS' INT EXIT
+trap 'kill $(jobs -p) 2>/dev/null; exit "${TEST_EXIT_STATUS:-1}"' INT EXIT

The 2>/dev/null also suppresses the spurious error from kill when no background jobs remain (e.g., after the FUSE process has already been waited on at line 43).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src-tauri/vendor/fuser/simplefs_tests.sh` around lines 5 - 9,
The INT/EXIT trap currently uses a bare $TEST_EXIT_STATUS which can be unset and
cause a zero exit; update the trap that runs "kill $(jobs -p); exit
$TEST_EXIT_STATUS" to use the same guarded expansion as exit_handler (use
${TEST_EXIT_STATUS:-1}) and redirect the kill error output to avoid noisy errors
when there are no jobs (e.g., "kill $(jobs -p) 2>/dev/null; exit
${TEST_EXIT_STATUS:-1}"); modify the trap registration in the script and keep
the existing exit_handler function unchanged.
apps/desktop/src-tauri/vendor/fuser/pjdfs.Dockerfile-1-24 (1)

1-24: ⚠️ Potential issue | 🟠 Major

Container runs entirely as root — switch to the fsgqa user created on line 8.

The image already creates a non-root user (fsgqa) but never issues a USER instruction, so every RUN, the final binary, and any container entrypoint execute as UID 0. Trivy flags this as DS-0002.

🔒 Proposed fix
 ADD . /code/fuser/

 RUN cd /code/fuser && cargo build --release --examples $BUILD_FEATURES && cp target/release/examples/simple /bin/fuser
+
+USER fsgqa
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src-tauri/vendor/fuser/pjdfs.Dockerfile` around lines 1 - 24,
The Dockerfile creates a non-root user fsgqa but never switches to it, so
containers run as root; after building and copying the artifact (reference: the
cargo build step in RUN cd /code/fuser && cargo build --release --examples
$BUILD_FEATURES && cp target/release/examples/simple /bin/fuser) change
ownership of runtime files (e.g. RUN chown fsgqa:fsgqa /bin/fuser and chown -R
fsgqa:fsgqa /code if needed) and add a USER fsgqa instruction near the end of
the Dockerfile so the final image and any subsequent commands run as the fsgqa
user instead of root. Ensure any runtime-required paths are readable/executable
by fsgqa.
apps/desktop/public/google-callback.html-13-17 (1)

13-17: ⚠️ Potential issue | 🟠 Major

Google ID token persisted in localStorage without guaranteed cleanup — use sessionStorage or Tauri IPC instead

localStorage is disk-persistent and survives webview restarts. If window.close() is suppressed or delayed by the webview (which can happen in Tauri on macOS when the window was opened programmatically), the raw Google ID token JWT will sit in storage indefinitely with no TTL and no cleanup code in this file. The consuming side must atomically read-and-delete the key, but there is no enforcement of that contract here.

Recommended mitigations (in preference order):

  1. Tauri IPC — emit the token directly to the opener via window.__TAURI__.event.emit so no storage is touched at all.
  2. sessionStorage — scoped to the browsing context; auto-wiped when the window is destroyed, so a stuck window.close() no longer leaves the token on disk.
  3. If localStorage must be used, pair it with a short absolute expiry sentinel (e.g. write { idToken, expiresAt: Date.now() + 30_000 }) so the consumer can reject stale tokens.
🛡️ Minimal fix — switch to sessionStorage
-    localStorage.setItem('google-auth-result', JSON.stringify({
+    sessionStorage.setItem('google-auth-result', JSON.stringify({
       idToken: idToken,
       error: error || (!idToken ? 'No ID token received' : null)
     }));

The consuming code should also be updated to read from sessionStorage and delete the key immediately after reading.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/public/google-callback.html` around lines 13 - 17, Persisting
the Google ID token into localStorage ('google-auth-result' via
localStorage.setItem) is unsafe; change the implementation to emit the token
directly to the opener using Tauri IPC (window.__TAURI__.event.emit) to avoid
storage entirely, and if Tauri IPC is not available fall back to storing in
sessionStorage under the same key and ensure the consumer immediately
reads-and-deletes the key; if you must keep localStorage as a last resort, wrap
the payload in an object with an expiresAt timestamp (Date.now() + 30000) so
consumers can reject stale tokens.
apps/desktop/public/google-callback.html-9-12 (1)

9-12: ⚠️ Potential issue | 🟠 Major

Missing state parameter validation — open to CSRF / confused-deputy token injection

OAuth 2.0 (RFC 6749 §10.12) requires the callback to verify the state value round-trips correctly to prevent cross-site request forgery. This callback ignores state entirely. Without it, a crafted redirect URL containing an attacker-controlled id_token would be accepted and forwarded to the main app, which would then trust it as a legitimate Google credential.

Even in a Tauri desktop app with a custom protocol, the state check defends against confused-deputy scenarios where another local webpage or deep-link handler triggers this callback.

🛡️ Proposed fix — validate state before storing the token
     var hash = window.location.hash.substring(1);
     var params = new URLSearchParams(hash);
     var idToken = params.get('id_token');
     var error = params.get('error');
+    var state = params.get('state');
+    var expectedState = sessionStorage.getItem('google-auth-state');
+    sessionStorage.removeItem('google-auth-state');
+    if (!error && state !== expectedState) {
+      error = 'state_mismatch';
+      idToken = null;
+    }
-    localStorage.setItem('google-auth-result', JSON.stringify({
+    sessionStorage.setItem('google-auth-result', JSON.stringify({
       idToken: idToken,
       error: error || (!idToken ? 'No ID token received' : null)
     }));

The code that opens the Google authorization URL should generate a random state, store it in sessionStorage['google-auth-state'], and include it in the authorization request.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/public/google-callback.html` around lines 9 - 12, The callback
must validate the OAuth2 state before accepting tokens: read the 'state' from
the parsed params (alongside id_token and error), compare it to the stored value
in sessionStorage['google-auth-state'], and if it does not match or is missing,
abort forwarding/storing the idToken and surface/log an error; on success,
remove the stored sessionStorage['google-auth-state'] and then continue
processing idToken/error as before. Use the existing variables (hash, params,
idToken, error) and the sessionStorage key 'google-auth-state' to locate where
to add this check.
apps/desktop/src-tauri/src/api/client.rs-24-28 (2)

24-28: ⚠️ Potential issue | 🟠 Major

Silent fallback to Client::new() removes all timeout protection

The reqwest async ClientBuilder docs confirm that the timeout() method "enables a total request timeout … applied from when the request starts connecting until the response body has finished … default is no timeout." This means Client::new() — the fallback — carries no timeout. If ClientBuilder::build() fails (e.g., if the TLS backend cannot be initialized, or the resolver cannot load the system configuration), the .unwrap_or_else(|_| Client::new()) silently produces a timeout-free client. Every subsequent request through that client can hang indefinitely, with no log, no metric, and no indication to the caller that timeout protection is absent.

For a desktop app, expect() at startup is the cleaner choice — a loud crash beats silent infinite hangs. At minimum, the error should be logged before falling back.

🔧 Option A — panic loudly (preferred for startup code)
-        let client = Client::builder()
-            .timeout(std::time::Duration::from_secs(120))
-            .connect_timeout(std::time::Duration::from_secs(10))
-            .build()
-            .unwrap_or_else(|_| Client::new());
+        let client = Client::builder()
+            .timeout(std::time::Duration::from_secs(120))
+            .connect_timeout(std::time::Duration::from_secs(10))
+            .build()
+            .expect("failed to initialize HTTP client (TLS/resolver error)");
🔧 Option B — log and fall back (if graceful degradation is required)
-        let client = Client::builder()
-            .timeout(std::time::Duration::from_secs(120))
-            .connect_timeout(std::time::Duration::from_secs(10))
-            .build()
-            .unwrap_or_else(|_| Client::new());
+        let client = Client::builder()
+            .timeout(std::time::Duration::from_secs(120))
+            .connect_timeout(std::time::Duration::from_secs(10))
+            .build()
+            .unwrap_or_else(|e| {
+                eprintln!("[ApiClient] HTTP client builder failed: {e}; falling back — no timeouts active");
+                Client::new()
+            });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src-tauri/src/api/client.rs` around lines 24 - 28, The current
Client::builder().timeout(...).connect_timeout(...).build().unwrap_or_else(|_|
Client::new()) silently falls back to a timeout-free Client::new() on build()
failure; replace the silent fallback with a loud failure by using
build().expect(...) (with a clear error message including the build error) so
the app crashes at startup, or if graceful degradation is required, change
unwrap_or_else to log the error (include the build() error) before returning
Client::new(); update the call sites around Client::builder(), build(),
unwrap_or_else to use either expect or a logging fallback so no timeout-free
client is produced silently.

24-28: ⚠️ Potential issue | 🟠 Major

Silent fallback to Client::new() removes all timeout protection

Client::new() has no timeout by default in reqwest's async client. If the builder fails (e.g., native TLS root store unavailable), the fallback silently produces a timeout-free client, defeating the timeout configuration entirely. The error is swallowed with no logging, leaving zero operator visibility into the degradation.

Consider one of:

  1. Panic/expect at startup (appropriate for desktop; better to fail loudly than silently hang on every request).
  2. Log the error before falling back, so the failure is surfaced in logs.
🔧 Proposed fix (log + retain timeouts on fallback)
-        let client = Client::builder()
-            .timeout(std::time::Duration::from_secs(120))
-            .connect_timeout(std::time::Duration::from_secs(10))
-            .build()
-            .unwrap_or_else(|_| Client::new());
+        let client = Client::builder()
+            .timeout(std::time::Duration::from_secs(120))
+            .connect_timeout(std::time::Duration::from_secs(10))
+            .build()
+            .unwrap_or_else(|e| {
+                eprintln!("[ApiClient] reqwest builder failed ({e}); falling back to Client::new() — no timeouts active");
+                Client::new()
+            });

Or prefer expect if builder failure should not occur in practice:

-        let client = Client::builder()
-            .timeout(std::time::Duration::from_secs(120))
-            .connect_timeout(std::time::Duration::from_secs(10))
-            .build()
-            .unwrap_or_else(|_| Client::new());
+        let client = Client::builder()
+            .timeout(std::time::Duration::from_secs(120))
+            .connect_timeout(std::time::Duration::from_secs(10))
+            .build()
+            .expect("failed to build HTTP client");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src-tauri/src/api/client.rs` around lines 24 - 28, The current
Client::builder() call swallows build errors via unwrap_or_else(|_|
Client::new()), which removes the configured timeout/connection limits and hides
the underlying error; replace that silent fallback with either a loud failure
(use .expect or unwrap with a clear message on the result of
Client::builder().timeout(...).connect_timeout(...).build()) or, if you must
continue, change unwrap_or_else to capture the error and log it (e.g.,
unwrap_or_else(|e| { log::error!("reqwest Client::builder().build() failed: {}",
e); Client::new() })) so failures are visible; target the expression using
Client::builder(), timeout, connect_timeout, build(), and unwrap_or_else in this
file.
apps/desktop/src-tauri/vendor/fuser/bsd_mount_tests.sh-5-9 (1)

5-9: ⚠️ Potential issue | 🟠 Major

EXIT trap silently masks mount-check failures due to uninitialized TEST_EXIT_STATUS

The TERM handler (line 6) correctly uses ${TEST_EXIT_STATUS:-1}, but the INT/EXIT trap (line 9) uses bare $TEST_EXIT_STATUS. Because TEST_EXIT_STATUS is never initialized at the top of the script, when the mount check on line 26 calls exit 1 before any value is set, the EXIT trap fires and evaluates exit $TEST_EXIT_STATUS as exit (empty expansion) — which bash treats as success (exit 0). A FreeBSD CI run with a broken FUSE mount would report a green build.

Fix: initialize TEST_EXIT_STATUS=1 at the top of the script (fail-safe default) and only set it to 0 at line 45 upon success, and apply the same :-1 guard in the INT/EXIT trap.

🐛 Proposed fix
 #!/usr/bin/env bash
 
 set -x
 
+TEST_EXIT_STATUS=1
+
 exit_handler() {
     exit "${TEST_EXIT_STATUS:-1}"
 }
 trap exit_handler TERM
-trap 'kill $(jobs -p); exit $TEST_EXIT_STATUS' INT EXIT
+trap 'kill $(jobs -p); exit "${TEST_EXIT_STATUS:-1}"' INT EXIT

Also applies to: 45-45

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src-tauri/vendor/fuser/bsd_mount_tests.sh` around lines 5 - 9,
Initialize TEST_EXIT_STATUS=1 at the top of the script to provide a fail-safe
default, change the INT/EXIT trap to use the same guarded expansion
(${TEST_EXIT_STATUS:-1}) or call the existing exit_handler to avoid empty
expansion, and ensure the success path (function/section that currently sets
success at line 45) explicitly sets TEST_EXIT_STATUS=0 on success; this keeps
exit behavior consistent with exit_handler and prevents silent success when a
prior exit 1 occurred.
apps/desktop/package.json-14-14 (1)

14-14: 🛠️ Refactor suggestion | 🟠 Major

Verify @noble/secp256k1 version consistency and address guideline deviation.

auth.ts correctly uses secp256k1.keygen() which is the v3 API (returning {secretKey, publicKey}). However, @cipherbox/crypto declares v2.2.3 in devDependencies, and its test files use secp256k1.utils.randomPrivateKey() (v2 API), creating a version mismatch in the monorepo. The encrypt.ts validation using ProjectivePoint.fromHex() should work with v3, but inconsistency across versions may cause issues during testing or when dependencies are resolved.

Additionally, the codebase deviates from the cryptographic guideline: use ethers.js or libsodium.js for ECIES secp256k1 and ECDSA operations. @noble/secp256k1 is neither. While @cipherbox/crypto uses the compliant eciesjs library for ECIES wrapping, the ephemeral keygen in auth.ts relies directly on @noble/secp256k1. Consider consolidating ephemeral keypair generation into @cipherbox/crypto and aligning the monorepo to a single approved library.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/package.json` at line 14, The repo has mixed `@noble/secp256k1`
usage (auth.ts uses secp256k1.keygen(), encrypt.ts uses
ProjectivePoint.fromHex(), tests use secp256k1.utils.randomPrivateKey()) causing
version/API mismatch and a guideline deviation; fix by consolidating ephemeral
keypair generation into `@cipherbox/crypto` (add a single exported
generateEphemeralKeypair function there using an approved library), update
auth.ts to call that function instead of secp256k1.keygen(), update tests to use
the new helper instead of secp256k1.utils.randomPrivateKey(), and ensure
package.json uses a single, consistent crypto dependency (prefer replacing
`@noble/secp256k1` with ethers.js or libsodium.js or ensure `@cipherbox/crypto`
wraps eciesjs/approved lib) so ProjectivePoint/fromHex usage is removed or
adapted to the chosen approved library.
apps/desktop/src-tauri/vendor/fuser/deny.toml-44-48 (1)

44-48: ⚠️ Potential issue | 🟠 Major

Apache-2.0 missing from license allow-list will break cargo deny check licenses

Five of fuser's seven direct dependencies—libc, log, serde, smallvec, and zerocopy—declare Apache-2.0 (in Apache-2.0 OR MIT dual licensing). With Apache-2.0 absent from the allow list, cargo deny check licenses will fail on these core dependencies.

The proposed additions of BSD-3-Clause and ISC should be verified against fuser's actual transitive dependency tree, as they are not present in the direct dependencies. Additionally, memchr uses Unlicense, which is also missing from the allow list.

🔧 Suggested fix
 allow = [
     "BSD-2-Clause",
-#    "BSD-3-Clause",
     "MIT",
+    "Apache-2.0",
 ]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src-tauri/vendor/fuser/deny.toml` around lines 44 - 48, The
license allow-list in deny.toml currently omits several licenses required by
fuser's dependencies; update the allow = [...] array to include "Apache-2.0" (to
cover libc/log/serde/smallvec/zerocopy dual-licensed crates), add "ISC" and
"BSD-3-Clause" if verified against fuser's transitive dependency tree, and
include "Unlicense" for memchr; after updating the allow list in the deny.toml
file, run cargo deny check licenses to confirm no other transitive licenses are
missing and adjust entries accordingly.
apps/desktop/src-tauri/src/sync/mod.rs-236-240 (1)

236-240: ⚠️ Potential issue | 🟠 Major

Byte-slice &truncated[..80] will panic on multi-byte UTF-8 error strings.

truncated.len() returns the byte length. If any byte in position 78–80 is part of a multi-byte codepoint (e.g., a localized API error body containing accented characters or CJK), &truncated[..80] panics with "byte index X is not a char boundary". This kills the tokio sync task.

🐛 Proposed fix – char-aware truncation
-    let truncated = if truncated.len() > 80 {
-        format!("{}...", &truncated[..80])
-    } else {
-        truncated.to_string()
-    };
+    let truncated = match truncated.char_indices().nth(80) {
+        Some((i, _)) => format!("{}...", &truncated[..i]),
+        None => truncated.to_string(),
+    };

Alternatively, if you prefer byte-level truncation, use the stable floor_char_boundary (Rust ≥ 1.65):

-    let truncated = if truncated.len() > 80 {
-        format!("{}...", &truncated[..80])
-    } else {
-        truncated.to_string()
-    };
+    let truncated = if truncated.len() > 80 {
+        let boundary = truncated.floor_char_boundary(80);
+        format!("{}...", &truncated[..boundary])
+    } else {
+        truncated.to_string()
+    };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src-tauri/src/sync/mod.rs` around lines 236 - 240, The current
byte-slice &truncated[..80] can panic on multi-byte UTF-8 boundaries; replace
the byte-based truncation with a char-aware truncation: check if
truncated.chars().count() > 80, and if so build the prefix using
truncated.chars().take(80).collect::<String>() and append "..." (otherwise use
truncated.to_string()); update the code around the truncated variable and remove
the direct &truncated[..80] slice to avoid char-boundary panics.
.planning/phases/11.1-macos-desktop-catch-up/11.1-02-PLAN.md-178-197 (1)

178-197: ⚠️ Potential issue | 🟠 Major

Ensure get_dev_key IPC command and its AppState field are cfg-gated to debug builds.

The CLI parsing is correctly wrapped in #[cfg(debug_assertions)], but the actual implementation exposes get_dev_key in release builds, unnecessarily widening the release binary's IPC attack surface.

Evidence:

  • commands.rs line 415: get_dev_key lacks #[cfg(debug_assertions)]
  • state.rs line 68: dev_key: RwLock<Option<String>> field lacks #[cfg(debug_assertions)]
  • main.rs lines 134–142: The release build branch (#[cfg(not(debug_assertions))]) still registers get_dev_key on line 141, exposing the endpoint in production binaries

While the command returns None in release builds (no actual leak), the IPC endpoint and struct field must not exist at all in release, adhering to the principle of least privilege for attack surface.

Wrap the command definition, AppState field, and release-build registration with #[cfg(debug_assertions)].

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.planning/phases/11.1-macos-desktop-catch-up/11.1-02-PLAN.md around lines
178 - 197, Add cfg gating so the debug-only IPC surface is compiled out in
release: annotate the Tauri command function get_dev_key in commands.rs with
#[cfg(debug_assertions)], annotate the dev_key field on AppState (the
RwLock<Option<String>> in state.rs) with the same attribute, and ensure main.rs
only registers get_dev_key inside the debug_assertions branch of the
invoke_handler (remove/guard any registration in the
#[cfg(not(debug_assertions))] branch). This will ensure the get_dev_key symbol,
the AppState.dev_key field, and the IPC registration do not exist in release
builds.
.planning/phases/11.1-macos-desktop-catch-up/11.1-05-PLAN.md-147-160 (1)

147-160: ⚠️ Potential issue | 🟠 Major

Security note: factorKey transmitted in plaintext through the backend.

The pollApprovalStatus flow receives the raw factor key from the server (line 147). If the backend is compromised, the attacker gains the TSS device share. The plan for approveDevice (line 192) similarly sends it as plaintext JSON.

Consider noting in the plan that the factor key should be encrypted end-to-end between devices (e.g., using ECIES with the requesting device's ephemeral public key) so the backend only relays an opaque blob. This aligns with the project's E2E encryption stance.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.planning/phases/11.1-macos-desktop-catch-up/11.1-05-PLAN.md around lines
147 - 160, pollApprovalStatus (and the related approveDevice flow) currently
accepts/transmits factorKey in plaintext and then uses
coreKit!.inputFactorKey/new BN(...) and _UNSAFE_exportTssKey, which exposes the
TSS device share if the backend is compromised; change the design to have the
approving device encrypt factorKey end-to-end to the requesting device (e.g.,
use ECIES / ephemeral recipient public key) so the server only relays an opaque
ciphertext blob, update pollApprovalStatus and approveDevice to accept/return
the encrypted blob instead of raw factorKey, and adjust the caller that invokes
coreKit!.inputFactorKey to decrypt locally before calling inputFactorKey.
apps/desktop/src-tauri/src/crypto/hkdf.rs-56-73 (1)

56-73: ⚠️ Potential issue | 🟠 Major

okm (derived Ed25519 seed) is not zeroized after use.

The 32-byte okm array holds sensitive key material on the stack. The PR's own security scope includes zeroization of Ed25519 keys (per 11.1-02-PLAN). After constructing the SigningKey, okm should be cleared.

Proposed fix
+use zeroize::Zeroize;
+
 fn derive_ipns_keypair(
     user_private_key: &[u8; 32],
     info: &[u8],
 ) -> Result<(Vec<u8>, Vec<u8>, String), HkdfError> {
     let hk = Hkdf::<Sha256>::new(Some(HKDF_SALT), user_private_key);
     let mut okm = [0u8; 32];
     hk.expand(info, &mut okm)
         .map_err(|_| HkdfError::DerivationFailed)?;
 
     let signing_key = SigningKey::from_bytes(&okm);
+    okm.zeroize();
     let verifying_key = signing_key.verifying_key();

Also, the returned Vec<u8> private key won't be auto-zeroized on drop — callers should use zeroize::Zeroizing<Vec<u8>> or document the zeroization responsibility.

Based on learnings, the PR's 11.1-02 plan explicitly lists "zeroize Ed25519 key" as a security fix.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src-tauri/src/crypto/hkdf.rs` around lines 56 - 73, The okm
stack buffer holding the derived Ed25519 seed must be zeroized after it is
consumed: after calling SigningKey::from_bytes(&okm) (and before returning),
explicitly clear/zeroize the okm array to remove sensitive material and handle
any errors first (relates to okm, SigningKey::from_bytes, hk.expand, and
HkdfError::*). Also do not return a plain Vec<u8> for the private key—either
wrap the produced private_key Vec in zeroize::Zeroizing<Vec<u8>> before
returning or add a clear API contract/documentation that callers must zeroize
the returned private key (references: private_key, public_key,
ipns::derive_ipns_name). Ensure zeroization occurs even on error paths.
apps/desktop/src-tauri/vendor/fuser/src/mnt/fuse3.rs-68-86 (1)

68-86: ⚠️ Potential issue | 🟠 Major

fuse_session_destroy is never called on the successful libc_umount path, leaking the session.

When libc_umount succeeds (the Ok branch of the if let), drop exits without calling fuse_session_destroy. Same for the error-but-not-PermissionDenied branch (line 83). Only the Linux + PermissionDenied path cleans up fully. Since fuse_session is allocated in Mount::new via fuse_session_new, it must be freed before the Mount struct is dropped.

Proposed fix
impl Drop for Mount {
    fn drop(&mut self) {
        use std::io::ErrorKind::PermissionDenied;

        if let Err(err) = super::libc_umount(&self.mountpoint) {
            // Linux always returns EPERM for non-root users.  We have to let the
            // library go through the setuid-root "fusermount -u" to unmount.
            if err.kind() == PermissionDenied {
                #[cfg(target_os = "linux")]
                unsafe {
                    fuse_session_unmount(self.fuse_session);
                    fuse_session_destroy(self.fuse_session);
                    return;
                }
            }
            warn!("umount failed with {err:?}");
        }
+        unsafe {
+            fuse_session_destroy(self.fuse_session);
+        }
    }
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src-tauri/vendor/fuser/src/mnt/fuse3.rs` around lines 68 - 86,
The Drop implementation for Mount currently only calls fuse_session_destroy in
the Linux+PermissionDenied path, leaking the fuse_session on successful
libc_umount and on non-PermissionDenied errors; update Mount::drop to always
clean up the session: after calling super::libc_umount(&self.mountpoint) (both
in the Ok branch and in the Err branch when the error is not PermissionDenied)
invoke the same unsafe cleanup sequence currently used for the PermissionDenied
path (unsafe { fuse_session_unmount(self.fuse_session);
fuse_session_destroy(self.fuse_session); }), and keep the PermissionDenied
branch behavior but avoid an early return so cleanup is uniform for all paths;
reference symbols: Mount::drop, libc_umount, fuse_session_unmount,
fuse_session_destroy, fuse_session.
apps/desktop/src-tauri/src/registry/mod.rs-166-198 (1)

166-198: ⚠️ Potential issue | 🟠 Major

panic! in release-mode get_or_create_device_id violates the "never block login" contract.

This function is called from register_device, which runs inside a tokio::spawn fire-and-forget task. A panic! on line 171 will crash that task. The module-level documentation explicitly states "Registry operations must NEVER block login" and "All errors are caught and logged by the caller," but a panic cannot be caught by the error handling in place and will abort the task instead.

Return a Result or fallback to an ephemeral ID to handle the keyring entry creation failure gracefully.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src-tauri/src/registry/mod.rs` around lines 166 - 198, The code
currently panics on keyring entry creation (the unwrap_or_else in
get_or_create_device_id), which can abort the background task; change it to
handle the error gracefully by logging the failure and falling back to an
ephemeral/generated UUID instead of panicking. Specifically, replace the
unwrap_or_else that calls panic with a match or if-let on
keyring::Entry::new("cipherbox-desktop", "device-id") so that on Err(e) you log
a warning and proceed to generate the UUID (the same code used in the default
branch) without attempting to set the credential; ensure all pathways return the
device id string and never call panic! so register_device’s fire-and-forget task
cannot be aborted.
apps/desktop/src-tauri/src/fuse/mod.rs-575-610 (1)

575-610: ⚠️ Potential issue | 🟠 Major

Gate metadata publishing on all uploads completing.

The safety valve publishes after 10s regardless of pending_uploads, then removes the queue entry. If an upload takes >10s (realistic given the 120s timeout), metadata is published with cid="" and never re-published. The fix correctly prevents publishing until pending_uploads == 0.

🛠️ Suggested fix
-        let ready: Vec<u64> = self.publish_queue.iter()
-            .filter(|(_, entry)| {
-                let elapsed = now.duration_since(entry.first_dirty);
-                (entry.pending_uploads == 0 && elapsed >= debounce)
-                    || elapsed >= safety_valve
-            })
+        let ready: Vec<u64> = self.publish_queue.iter()
+            .filter(|(_, entry)| {
+                let elapsed = now.duration_since(entry.first_dirty);
+                if entry.pending_uploads == 0 {
+                    elapsed >= debounce || elapsed >= safety_valve
+                } else {
+                    false
+                }
+            })
             .map(|(&ino, _)| ino)
             .collect();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src-tauri/src/fuse/mod.rs` around lines 575 - 610, The
flush_publish_queue logic currently allows the safety_valve to publish even when
entry.pending_uploads > 0; update the filter inside flush_publish_queue to
always require entry.pending_uploads == 0 before publishing (i.e., gate
publishing on pending_uploads == 0) and only then check elapsed against
debounce/safety_valve, so that entries are removed/published (leading to
build_folder_metadata and spawn_metadata_publish) only when no uploads remain;
adjust the iterator/filter that builds ready: Vec<u64> using the publish_queue
entry fields (pending_uploads and first_dirty) accordingly.
apps/desktop/src-tauri/vendor/fuser/src/ll/request.rs-1681-1689 (1)

1681-1689: ⚠️ Potential issue | 🟠 Major

assert! in FUSE_WRITE parse will crash the daemon on malformed packets.

Since this vendored fuser is patched specifically for SMB socket-based reads (per the PR), partial/truncated reads are a realistic scenario. If data.len() doesn't match arg.size, the assert! on line 1687 panics and kills the FUSE daemon. The same applies to FUSE_SETXATTR at line 1706. Both should return None to surface the error gracefully via RequestError::InsufficientData.

Proposed fix: Replace asserts with checked returns
 fuse_opcode::FUSE_WRITE => Operation::Write({
     let out = Write {
         header,
         arg: data.fetch()?,
         data: data.fetch_all(),
     };
-    assert!(out.data().len() == out.arg.size as usize);
+    if out.data().len() != out.arg.size as usize {
+        return None;
+    }
     out
 }),

And similarly for SETXATTR:

 fuse_opcode::FUSE_SETXATTR => Operation::SetXAttr({
     let out = SetXAttr {
         header,
         arg: data.fetch()?,
         name: data.fetch_str()?,
         value: data.fetch_all(),
     };
-    assert!(out.value.len() == out.arg.size as usize);
+    if out.value.len() != out.arg.size as usize {
+        return None;
+    }
     out
 }),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src-tauri/vendor/fuser/src/ll/request.rs` around lines 1681 -
1689, The FUSE_WRITE and FUSE_SETXATTR parsing currently use assert! to validate
payload lengths which will panic on malformed/truncated packets; instead, after
constructing the Write (for fuse_opcode::FUSE_WRITE -> Operation::Write) or
Setxattr (for fuse_opcode::FUSE_SETXATTR -> Operation::Setxattr) objects, check
whether out.data().len() == out.arg.size as usize and if not return None (or
propagate RequestError::InsufficientData) rather than asserting; update the
blocks that build Write (header, arg: data.fetch()?, data: data.fetch_all()) and
Setxattr similarly to perform a conditional length check and return the
error/None to fail parsing gracefully.

Comment thread apps/desktop/src-tauri/src/crypto/aes_ctr.rs
Comment thread apps/desktop/src-tauri/src/fuse/operations.rs
Comment thread apps/desktop/src-tauri/vendor/fuser/Cargo.toml
Comment thread apps/desktop/src-tauri/vendor/fuser/examples/ioctl.rs
Comment thread apps/desktop/src-tauri/vendor/fuser/examples/ioctl.rs
FSM1 and others added 2 commits February 19, 2026 00:38
- aes_ctr: prevent panic on empty ciphertext / out-of-bounds start_byte
- fuse: use PendingContent enum to clear prefetch on download failure
- fuse: preserve file_meta_ipns_name in build_folder_metadata
- fuse: gate publish queue flush on pending_uploads == 0
- operations: send PendingContent::Failure on prefetch errors
- client: replace silent Client::new() fallback with expect()
- sync: fix UTF-8 byte-slice panic in sanitize_error truncation
- hkdf: zeroize okm after deriving IPNS signing key
- ed25519: zeroize key_bytes after constructing SigningKey
- registry: replace panic! with graceful keyring fallback
- commands: remove dead derive_compressed_public_key_hex
- main: remove get_dev_key from release build handler list
- google-callback: use sessionStorage instead of localStorage
- google-callback: add OAuth state parameter CSRF validation
- auth: generate and verify state parameter for Google OAuth

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- ioctl: add bounds check on read to prevent out-of-bounds panic
- ioctl: fix unwrap() panic and stale size on SET_SIZE
- request: replace assert! with checked returns in FUSE_WRITE/SETXATTR
- fuse3: add fuse_session_destroy on successful unmount path
- deny.toml: add Apache-2.0 and Unlicense to license allow-list
- pjdfs.Dockerfile: run container as non-root user
- simplefs_tests.sh: fix trap to propagate test exit status
- bsd_mount_tests.sh: fix trap to propagate test exit status
- rust-toolchain.toml: pin to Rust 1.88 (edition 2024 + time crate)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@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: 17

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/desktop/src-tauri/src/fuse/operations.rs (2)

89-95: 🛠️ Refactor suggestion | 🟠 Major

EncryptedFolderMetadata struct declared three times; decrypt logic duplicated across private helpers and public wrappers.

The private decrypt_metadata_from_ipfs (lines 102–136) and decrypt_file_metadata_from_ipfs (lines 258–284) are virtually identical to their public counterparts decrypt_metadata_from_ipfs_public (lines 2200–2236) and decrypt_file_metadata_from_ipfs_public (lines 2240–2272). The EncryptedFolderMetadata struct is re-declared verbatim inside each of the three scopes.

The duplication exists because the private helpers live inside mod implementation { } and are unreachable from outside it. Lifting the shared parsing logic outside that module eliminates all three copies:

♻️ Suggested refactor
+// Outside mod implementation — shared by both private handlers and public wrappers
+#[cfg(feature = "fuse")]
+#[derive(serde::Deserialize)]
+struct EncryptedIpfsPayload {
+    iv: String,
+    data: String,
+}
+
+/// Parse encrypted IPFS payload bytes into a sealed (IV || ciphertext) buffer.
+#[cfg(feature = "fuse")]
+fn parse_sealed_from_ipfs_bytes(encrypted_bytes: &[u8]) -> Result<Vec<u8>, String> {
+    let enc: EncryptedIpfsPayload = serde_json::from_slice(encrypted_bytes)
+        .map_err(|e| format!("Failed to parse encrypted metadata JSON: {}", e))?;
+    let iv_bytes = hex::decode(&enc.iv)
+        .map_err(|_| "Invalid metadata IV hex".to_string())?;
+    if iv_bytes.len() != 12 {
+        return Err(format!("Invalid IV length: {} (expected 12)", iv_bytes.len()));
+    }
+    use base64::Engine;
+    let ciphertext = base64::engine::general_purpose::STANDARD
+        .decode(&enc.data)
+        .map_err(|e| format!("Invalid metadata base64: {}", e))?;
+    let mut sealed = Vec::with_capacity(12 + ciphertext.len());
+    sealed.extend_from_slice(&iv_bytes);
+    sealed.extend_from_slice(&ciphertext);
+    Ok(sealed)
+}

Then the four functions collapse to one-liners delegating to parse_sealed_from_ipfs_bytes + the appropriate crypto call, and all three EncryptedFolderMetadata / EncryptedIpfsPayload local-struct declarations are removed.

Also applies to: 97-136, 254-284, 2197-2272

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src-tauri/src/fuse/operations.rs` around lines 89 - 95, The
EncryptedFolderMetadata struct and parsing logic are duplicated across mod
implementation and public wrappers; extract a single shared
EncryptedFolderMetadata (or EncryptedIpfsPayload) and a helper like
parse_sealed_from_ipfs_bytes to perform the serde deserialization/IV+data
decoding, then update decrypt_metadata_from_ipfs,
decrypt_file_metadata_from_ipfs, decrypt_metadata_from_ipfs_public, and
decrypt_file_metadata_from_ipfs_public to call that helper and then their
respective crypto routines (AES-GCM decrypt call) so the struct and parsing
exist only once and the four functions become thin delegating wrappers.

947-967: ⚠️ Potential issue | 🟠 Major

Writable open of a large uncached file fails with EIO in under 3 seconds.

Line 956 falls back to fetch_and_decrypt_file_content, which internally calls block_with_timeout with NETWORK_TIMEOUT = 3s. The CONTENT_DOWNLOAD_TIMEOUT (120s) comment explicitly acknowledges that "large files (e.g., 64MB) can take 30-60s from staging IPFS", but that constant is only used in the async prefetch path — not here.

The read-only open path correctly avoids blocking the NFS thread by returning immediately and starting a background prefetch; the writable open path has no equivalent: it blocks the NFS thread and times out well before large files finish downloading.

Concrete failure scenario: user opens a 64MB vault file in a text editor without a prior ls to warm the cache → open(O_RDWR) blocks for 3s, then returns EIO.

Mitigations ranked by invasiveness:

  1. (Minimal fix) Introduce a dedicated WRITE_OPEN_DOWNLOAD_TIMEOUT (e.g., 30s) and use it here — accepts a longer NFS thread stall for the edit case.
  2. (Better) If content isn't cached, skip pre-population and return an empty temp file; rely on the existing readdir prefetch having warmed the cache. The caller (text editor) would overwrite the content anyway.
  3. (Best) Mirror the read-only async prefetch pattern: start a background fetch for the content, poll briefly, and return a partially pre-populated handle — same 3s poll budget as read().
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src-tauri/src/fuse/operations.rs` around lines 947 - 967, The
writable-open path currently calls fetch_and_decrypt_file_content which uses the
global NETWORK_TIMEOUT (3s) and causes EIO for large files; fix by adding a new
longer timeout constant (e.g., WRITE_OPEN_DOWNLOAD_TIMEOUT ~30s) and use it for
this code path when calling fetch_and_decrypt_file_content (or its blocking
helper block_with_timeout) so open(O_WRONLY|O_RDWR) allows a longer download
window; update the call-site in operations.rs (the branch that checks
cid.is_empty() and invokes fetch_and_decrypt_file_content) to pass or apply
WRITE_OPEN_DOWNLOAD_TIMEOUT instead of the short NETWORK_TIMEOUT.
🧹 Nitpick comments (15)
apps/desktop/src-tauri/vendor/fuser/pjdfs.Dockerfile (2)

1-1: Consider pinning the base image to a digest for reproducible builds.

ubuntu:22.04 is a mutable tag; a tag move or upstream update silently changes the build environment. Pinning to the immutable sha256 digest (e.g., FROM ubuntu:22.04@sha256:<digest>) eliminates that variability and prevents potential supply-chain drift.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src-tauri/vendor/fuser/pjdfs.Dockerfile` at line 1, Replace the
mutable base image tag in the pjdfs.Dockerfile's FROM instruction with an
immutable digest-pinned image; locate the FROM ubuntu:22.04 line and change it
to use the matching sha256 digest (e.g., FROM ubuntu:22.04@sha256:<digest>) so
builds are reproducible and protected from upstream tag drift—ensure you fetch
the correct digest for Ubuntu 22.04 from an official registry and update the
Dockerfile accordingly.

5-6: Remove apt list cache to reduce image size.

The apt install layer currently retains /var/lib/apt/lists/*, which can add tens to hundreds of MB to the image. Appending a cleanup in the same RUN layer keeps the intermediate cache out of the final layer. --no-install-recommends further trims unnecessary transitive packages.

♻️ Proposed fix
-RUN apt update && apt install -y git build-essential autoconf curl cmake libfuse-dev pkg-config fuse3 bc libtool \
-  uuid-dev xfslibs-dev libattr1-dev libacl1-dev libaio-dev attr acl quota bsdmainutils dbench psmisc libfuse3-dev
+RUN apt-get update && apt-get install -y --no-install-recommends \
+  git build-essential autoconf curl cmake libfuse-dev pkg-config fuse3 bc libtool \
+  uuid-dev xfslibs-dev libattr1-dev libacl1-dev libaio-dev attr acl quota bsdmainutils dbench psmisc libfuse3-dev \
+  && rm -rf /var/lib/apt/lists/*
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src-tauri/vendor/fuser/pjdfs.Dockerfile` around lines 5 - 6, The
Dockerfile RUN layer that performs apt update && apt install (the long RUN line
installing git build-essential ... libfuse3-dev) leaves apt lists cached and
installs recommended packages; modify that same RUN command to add
--no-install-recommends to the apt install invocation and remove the apt cache
in the same layer by cleaning /var/lib/apt/lists/* (e.g., apt-get clean && rm
-rf /var/lib/apt/lists/*) so the image size is reduced and no extra cache
persists.
.planning/STATE.md (1)

154-156: URGENT pending todo represents unresolved root cause — track actively.

The FUSE-side fix in this PR (optional FileEntry fields + v2→v1 fallback parsing) is a defensive workaround. The root cause — the web app writing v2-tagged metadata with v1-style file entries — is explicitly deferred to fix-v2-metadata-version-mismatch.md. Until that todo is resolved, any new vault written by the web app can still trigger the mixed-format condition, and the desktop fallback path will continue to be exercised in production.

The two other new todos (remove-pinata-references-from-api.md, fix-fuse-rename-on-smb-backend.md) are lower-urgency but should also be tracked against the SMB backend switch introduced in this phase, since rename is a core FUSE operation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.planning/STATE.md around lines 154 - 156, The note warns that the PR's
FUSE-side defensive workaround (optional FileEntry fields and the v2→v1 fallback
parsing logic) only masks the real bug where the web app writes v2-tagged
metadata containing v1-style file entries; update the project tracking by
creating and actively tracking the high-priority todo document named
fix-v2-metadata-version-mismatch.md, and in code references locate symbols
implementing the fallback parsing (e.g., FileEntry parsing/serializer functions
and the v2→v1 fallback code paths) to add TODO comments linking to that todo
file and ensure tests cover generation of v2-tagged metadata from the web app so
the root cause can be fixed rather than relying on the FUSE fallback.
apps/desktop/src-tauri/rust-toolchain.toml (1)

1-2: Consider pinning targets and components for reproducible onboarding.

The file only specifies channel. For a Tauri macOS desktop app targeting both Apple Silicon and Intel, explicitly declaring the required targets and dev-tooling components in the toolchain file ensures rustup installs everything needed on a clean clone — directly relevant to the open "Verify on clean clone" test plan item.

♻️ Suggested extension
 [toolchain]
-channel = "1.88"
+channel = "1.93"
+targets  = ["aarch64-apple-darwin", "x86_64-apple-darwin"]
+components = ["rustfmt", "clippy"]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src-tauri/rust-toolchain.toml` around lines 1 - 2, The toolchain
file only pins channel; update the [toolchain] section to also pin the required
targets and components so rustup can install them on a clean clone: add the
macOS targets (aarch64-apple-darwin and x86_64-apple-darwin) under a targets
list and include dev components like rust-src, clippy and rustfmt under a
components list (keeping channel = "1.88"); modify the rust-toolchain.toml entry
named [toolchain] to include these targets and components so onboarding is
reproducible.
apps/desktop/src-tauri/vendor/fuser/examples/ioctl.rs (2)

132-132: readdir skips the negative-offset guard present in read.

read (line 101) explicitly rejects offset < 0 before casting to usize. Here, a negative offset silently wraps to usize::MAX, causing skip() to exhaust all entries and return an empty listing rather than an error. FUSE doesn't send negative readdir offsets in practice, but the inconsistency is worth aligning.

♻️ Proposed fix
+        if offset < 0 {
+            reply.error(EINVAL);
+            return;
+        }
         for (i, entry) in entries.into_iter().enumerate().skip(offset as usize) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src-tauri/vendor/fuser/examples/ioctl.rs` at line 132, In the
readdir implementation, reject negative offsets before casting instead of
silently wrapping via enumerate().skip(offset as usize); add the same
negative-offset guard used in read (check if offset < 0 and return the same
error/result) and only then cast offset to usize to use in
entries.into_iter().enumerate().skip(...), referencing the readdir function and
the entries.into_iter().enumerate().skip(offset as usize) expression so behavior
is consistent with read.

46-62: fioc_file_attr initial blocks: 1 is inconsistent with size: 0.

The file starts empty (content: vec![], size: 0), so blocks should be 0 to match. Until the first FIOC_SET_SIZE call, getattr for inode 2 returns (size=0, blocks=1), which some tools may interpret as 512 bytes of allocated space.

♻️ Proposed fix
         let fioc_file_attr = FileAttr {
             ino: 2,
             size: 0,
-            blocks: 1,
+            blocks: 0,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src-tauri/vendor/fuser/examples/ioctl.rs` around lines 46 - 62,
The FileAttr for fioc_file_attr incorrectly sets blocks: 1 while size: 0,
causing getattr to report allocated blocks for an empty file; update
fioc_file_attr to set blocks: 0 (so blocks reflects size 0) — locate the
fioc_file_attr initializer and change the blocks field from 1 to 0 (no other
fields need adjustment, blksize can remain 512).
apps/desktop/src-tauri/vendor/fuser/src/mnt/fuse3.rs (2)

33-33: CString::new(...).unwrap() — propagate the error instead of panicking.

While null bytes in Unix paths are practically impossible, unwrap() is still a panic site. Propagating the error with ? (via a short map_err) is more consistent with the io::Result return type of the enclosing function.

♻️ Proposed fix
-        let mnt = CString::new(mnt.as_os_str().as_bytes()).unwrap();
+        let mnt = CString::new(mnt.as_os_str().as_bytes())
+            .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src-tauri/vendor/fuser/src/mnt/fuse3.rs` at line 33, Replace the
panic site CString::new(...).unwrap() with error propagation: use
CString::new(mnt.as_os_str().as_bytes()).map_err(|e|
std::io::Error::new(std::io::ErrorKind::InvalidInput, e))? so the conversion
error is returned as an io::Result rather than causing a panic; locate the
occurrence of CString::new and the mnt variable in the function that returns
io::Result and apply this map_err + ? pattern.

17-24: ensure_last_os_error is duplicated from fuse2.rs — extract to mod.rs.

The function body is byte-for-byte identical to the one in apps/desktop/src-tauri/vendor/fuser/src/mnt/fuse2.rs. Moving it to mod.rs (or a small shared submodule) removes the duplication and keeps future fixes in one place.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src-tauri/vendor/fuser/src/mnt/fuse3.rs` around lines 17 - 24,
The ensure_last_os_error function is duplicated in fuse3.rs and fuse2.rs;
extract it into the common module (mod.rs) or a small shared submodule and have
both fuse2.rs and fuse3.rs import and call that single implementation. Move the
function body (ensure_last_os_error) into mod.rs, make it pub(crate) if needed,
remove the duplicate definitions from fuse2.rs and fuse3.rs, and add a
use/import (or call via crate::mnt::ensure_last_os_error) in both files so they
reference the shared implementation.
apps/desktop/src-tauri/vendor/fuser/src/ll/request.rs (1)

580-583: RmDir unnecessarily exposes pub name as a direct field, inconsistent with all other operation structs.

Every other operation struct (e.g., Unlink, Lookup, MkNod) keeps fields private and exposes them only through methods. RmDir has both a pub name field and a redundant pub fn name() method. Since the name() accessor already exists, the field should be private to keep the API consistent and encapsulated.

♻️ Proposed fix
 pub struct RmDir<'a> {
     header: &'a fuse_in_header,
-    pub name: &'a Path,
+    name: &'a Path,
 }

Note: the Display implementation at line 1949 also bypasses the accessor (x.name instead of x.name()), which can be updated to x.name() for consistency.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src-tauri/vendor/fuser/src/ll/request.rs` around lines 580 -
583, The RmDir struct exposes its name field publicly while also providing a pub
fn name() accessor, breaking encapsulation and consistency with other request
structs; change the field declaration in RmDir from pub name: &'a Path to a
private name: &'a Path, keep the existing pub fn name(&self) -> &Path as the
public accessor, and update any direct field uses (notably the Display
implementation which currently uses x.name) to call x.name() instead so all
access goes through the accessor.
apps/desktop/src-tauri/src/registry/mod.rs (1)

66-67: now_ms() called twice — created_at and last_seen_at may differ by a few microseconds.

Capture the value once and reuse it:

🔧 Proposed fix
+    let now = now_ms();
     let device_entry = DeviceEntry {
         ...
-        created_at: now_ms(),
-        last_seen_at: now_ms(),
+        created_at: now,
+        last_seen_at: now,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src-tauri/src/registry/mod.rs` around lines 66 - 67, The
timestamps for created_at and last_seen_at are produced by two separate now_ms()
calls which can differ; capture a single timestamp once (e.g., let ts =
now_ms()) and reuse it for both fields when constructing the record so
created_at and last_seen_at are identical; update the code around the
struct/record initialization that sets created_at and last_seen_at to use that
single variable instead of calling now_ms() twice.
apps/desktop/src-tauri/src/commands.rs (1)

415-418: get_dev_key is missing #[cfg(debug_assertions)] — it compiles into release builds as dead code.

handle_test_login_complete (line 428) is correctly gated. get_dev_key is not, so it compiles in release but is only registered in the debug invoke_handler. Add the cfg gate for consistency and to avoid returning a dev_key value that is always None in release.

+#[cfg(debug_assertions)]
 #[tauri::command]
 pub async fn get_dev_key(state: State<'_, AppState>) -> Result<Option<String>, String> {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src-tauri/src/commands.rs` around lines 415 - 418, get_dev_key
is missing the debug-only gate and therefore compiles into release as dead code;
add the same #[cfg(debug_assertions)] attribute used for
handle_test_login_complete above the get_dev_key function so it only exists in
debug builds and matches the debug-only registration in the invoke_handler,
ensuring dev_key isn't present (or returned as None) in release builds.
apps/desktop/src-tauri/src/crypto/hkdf.rs (1)

40-41: InvalidKeySize variant is dead code — input type &[u8; 32] statically guarantees the correct size.

Since all three public functions accept &[u8; 32], HkdfError::InvalidKeySize can never be constructed. Either remove it or change the internal helper to accept &[u8] to cover future callers that pass a slice.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src-tauri/src/crypto/hkdf.rs` around lines 40 - 41,
HkdfError::InvalidKeySize is dead code because all three public functions take
&[u8; 32] so that variant can never be produced; either remove the
InvalidKeySize enum variant and all references to it from HkdfError, or change
the internal helper that performs the extract/expand (referenced as the internal
HKDF helper used by the public functions) to accept a flexible &[u8] key
parameter instead of &[u8; 32] so the variant becomes meaningful—update the
helper’s signature and any callers, or delete the HkdfError::InvalidKeySize
variant and its uses accordingly.
apps/desktop/src-tauri/vendor/fuser/bsd_mount_tests.sh (1)

19-21: Unquoted path variables are susceptible to word-splitting.

$DIR and $3 should be quoted. A temp directory with spaces (possible on some CI environments) would break the cargo run invocation.

🔧 Proposed fix
-  cargo build --example hello $1 > /dev/null 2>&1
-  cargo run --example hello $1 -- $DIR $3 &
+  cargo build --example hello "$1" > /dev/null 2>&1
+  cargo run --example hello "$1" -- "$DIR" $3 &

Also on line 28, quote the substitution inside [[ ]]:

-  if [[ $(cat ${DIR}/hello.txt) = "Hello World!" ]]; then
+  if [[ "$(cat "${DIR}/hello.txt")" = "Hello World!" ]]; then
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src-tauri/vendor/fuser/bsd_mount_tests.sh` around lines 19 - 21,
The cargo run invocation uses unquoted path variables ($DIR and $3) which can
break on paths with spaces; update the command in the example runner (the cargo
run --example hello invocation) to quote both $DIR and $3 so the shell treats
them as single arguments, and ensure any related variable expansions used in the
surrounding logic (e.g., the conditional test around line 28 that uses [[ ...
]]) quotes the substitution inside the [[ ]] expression as well so
word-splitting and globbing cannot occur.
apps/desktop/src-tauri/src/fuse/operations.rs (2)

1981-2003: Hoist repeated use unicode_normalization::UnicodeNormalization; imports to function scope.

The same use statement appears in two separate blocks within the rename function. It can be declared once at the top of the function.

♻️ Minor cleanup
+        use unicode_normalization::UnicodeNormalization;
+
         // Remove source from old parent's name index (NFC-normalized)
         {
-            use unicode_normalization::UnicodeNormalization;
             let nfc_key: String = name_str.nfc().collect();
             self.inodes.name_to_ino.remove(&(parent, nfc_key));
         }

         // ...

         // Update the name lookup index for the new location (NFC-normalized)
         {
-            use unicode_normalization::UnicodeNormalization;
             let nfc_key: String = newname_str.nfc().collect();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src-tauri/src/fuse/operations.rs` around lines 1981 - 2003, The
repeated local imports of UnicodeNormalization inside the rename function are
redundant; remove the two inner "use
unicode_normalization::UnicodeNormalization;" lines and add a single "use
unicode_normalization::UnicodeNormalization;" declaration at the top of the
rename function body so both uses (the NFC normalization of name_str and
newname_str when building nfc_key) share the same import and reduce duplication
in the function where name_str, newname_str, parent, newparent, and the
inodes.name_to_ino updates occur.

199-252: resolve_file_pointers_blocking resolves FilePointers sequentially — startup latency scales O(N).

Each FilePointer triggers a separate block_with_timeout call (3s timeout), and they're processed one-at-a-time. A folder with 20 files could add up to ~60s of sequential blocking before the mount is usable, depending on network latency.

Since rt is available, all resolutions can be dispatched concurrently with a single rt.block_on(futures::future::join_all(...)):

♻️ Suggested refactor — parallel resolution
-    for (ino, ipns_name) in unresolved {
-        let resolve_result = block_with_timeout(&rt, async {
-            let resp = crate::api::ipns::resolve_ipns(&api, ipns_name).await?;
-            let encrypted_bytes = crate::api::ipfs::fetch_content(&api, &resp.cid).await?;
-            Ok::<Vec<u8>, String>(encrypted_bytes)
-        });
-
-        match resolve_result {
-            Ok(encrypted_bytes) => { /* ... */ }
-            Err(e) => { log::warn!(...) }
-        }
-    }
+    let tasks: Vec<_> = unresolved.iter().map(|(ino, ipns_name)| {
+        let api = api.clone();
+        let ipns_name = ipns_name.clone();
+        let ino = *ino;
+        async move {
+            let result = tokio::time::timeout(NETWORK_TIMEOUT, async {
+                let resp = crate::api::ipns::resolve_ipns(&api, &ipns_name).await?;
+                crate::api::ipfs::fetch_content(&api, &resp.cid).await
+            }).await;
+            (ino, ipns_name, result)
+        }
+    }).collect();
+
+    let results = rt.block_on(futures::future::join_all(tasks));
+    for (ino, ipns_name, result) in results {
+        match result {
+            Ok(Ok(encrypted_bytes)) => { /* decrypt and resolve ... */ }
+            Ok(Err(e)) | Err(_) => { log::warn!("FilePointer resolve failed for ino {} ({}): ...", ino, ipns_name, e) }
+        }
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src-tauri/src/fuse/operations.rs` around lines 199 - 252,
resolve_file_pointers_blocking currently calls block_with_timeout sequentially
for each (ino, ipns_name), causing O(N) startup latency; instead spawn all
resolve+fetch tasks and await them concurrently via
rt.block_on(futures::future::join_all(...)). Convert folder_key to
folder_key_arr as before, then build a Vec of async closures that call
crate::api::ipns::resolve_ipns, crate::api::ipfs::fetch_content, and
decrypt_file_metadata_from_ipfs (or return the error), call
rt.block_on(join_all(tasks)), and iterate results to call
fs.inodes.resolve_file_pointer on successes and log per-item errors (preserving
the current log messages). Ensure block_with_timeout is removed per-item and any
timeout is applied around the whole join_all or per-task as desired.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.planning/STATE.md:
- Around line 208-209: Remove the stale conflicting "Next: Phase 13 (File
Versioning) or Phase 11 (Cross-Platform Desktop)" line from .planning/STATE.md
so only the current "Next: Phase 11.1 complete. Next phase TBD." remains; locate
the two "Next:" lines (the exact string "Next: Phase 13 (File Versioning) or
Phase 11 (Cross-Platform Desktop)" and "Next: Phase 11.1 complete. Next phase
TBD.") and delete the first to avoid contradictory next-step information.

In `@apps/desktop/src-tauri/rust-toolchain.toml`:
- Line 2: The toolchain is pinned to an old Rust channel value ("channel =
\"1.88\"") and should be updated to a more recent release; change the channel
string in rust-toolchain.toml from "1.88" to a newer supported value (e.g.,
"1.93" or at least "1.83"), or set it to "stable" if exact reproducibility is
not required, then commit the updated rust-toolchain.toml so the project builds
with the newer toolchain.

In `@apps/desktop/src-tauri/src/commands.rs`:
- Around line 94-103: The function complete_auth_setup currently accepts
private_key_bytes and public_key_bytes as plain Vec<u8>, leaving sensitive key
material unzeroed; change their types to zeroize::Zeroizing<Vec<u8>> in
complete_auth_setup and update uses (e.g., when constructing CipherBoxFS and
when creating reg_private_key for the registry spawn) to avoid cloning raw Vecs
— pass or clone the Zeroizing-wrapped values so drops zero the memory, and
update the callers handle_auth_complete and handle_test_login_complete to wrap
decoded bytes with Zeroizing::new(...) before calling complete_auth_setup.

In `@apps/desktop/src-tauri/src/crypto/hkdf.rs`:
- Around line 56-75: The function in hkdf.rs currently returns a plain Vec<u8>
for the derived private key (private_key = signing_key.to_bytes().to_vec()),
leaving secret bytes unzeroized on the heap; update the function signature to
return Zeroizing<Vec<u8>> for the private key (i.e., Result<(Zeroizing<Vec<u8>>,
Vec<u8>, String), HkdfError>), construct the private_key as
Zeroizing::new(signing_key.to_bytes().to_vec()), import zeroize::Zeroizing, and
then update all callers (e.g., initialize_vault and register_device) to accept
and propagate Zeroizing<Vec<u8>> instead of plain Vec<u8> so the secret buffer
is automatically zeroized on drop and no unzeroized clones remain.

In `@apps/desktop/src-tauri/src/fuse/mod.rs`:
- Around line 662-704: The current drain_refresh_completions implementation
blocks the FUSE thread by calling block_with_timeout/rt.block_on for every
unresolved FilePointer; instead, create a file_pointer_tx/file_pointer_rx
channel pair (analogous to refresh_tx/refresh_rx), and in
drain_refresh_completions replace the synchronous block_with_timeout call with
spawning background tasks via self.rt.spawn that perform
crate::api::ipns::resolve_ipns and crate::api::ipfs::fetch_content and then send
results (ino, enc_bytes or error) on file_pointer_tx; add a new
drain_file_pointer_results() invoked at the start of FUSE callbacks that reads
from file_pointer_rx and calls self.inodes.resolve_file_pointer(...) (or logs
errors); use a prefetching set keyed by IPNS name to avoid duplicate spawns when
iterating get_unresolved_file_pointers(), and keep resolve/decrypt logic
(operations::decrypt_file_metadata_from_ipfs_public) in the background task
before sending final resolved metadata to the channel.
- Around line 579-616: In flush_publish_queue, the safety-valve check must run
even when entry.pending_uploads > 0; change the filter so it publishes if either
elapsed >= safety_valve (regardless of pending_uploads) or (pending_uploads == 0
and elapsed >= debounce). Specifically, in the iterator over self.publish_queue
use entry.first_dirty and now to compute elapsed, then allow readiness when
elapsed >= safety_valve OR (entry.pending_uploads == 0 && elapsed >= debounce),
instead of returning early on pending_uploads > 0; keep references to debounce,
safety_valve, entry.pending_uploads, entry.first_dirty and the ready collection
logic in flush_publish_queue.

In `@apps/desktop/src-tauri/src/fuse/operations.rs`:
- Around line 773-779: Replace the four unconditional eprintln! debug prints in
the prefetch paths with structured logging: in the readdir prefetch block (the
match arm that prints ">>> prefetch(readdir)"), the open prefetch block (around
open()), the read() prefetch spawn (the spawned prefetch in read()), and the
read polling loop, change eprintln! to log::debug! and remove the ">>> " prefix
from the message; keep the same formatted values (e.g., plaintext.len() and
&cid_clone[..cid_clone.len().min(12)]) and message text otherwise so logs are
emitted via the log framework instead of writing directly to stderr. Ensure the
log crate is in scope where these prints are replaced.
- Around line 311-338: Wrap the heap key Vec returned by
crate::crypto::ecies::unwrap_key in zeroize::Zeroizing (e.g., let file_key =
Zeroizing::new(...)) so it is zeroed on every exit path, then obtain the
fixed-size file_key_arr from file_key.as_slice().try_into(); also ensure the
stack array is zeroed by using Zeroizing<[u8;32]> or calling zeroize::Zeroize on
file_key_arr after use (instead of relying on
crate::crypto::utils::clear_bytes). Remove the current explicit clear_bytes(&mut
file_key) and apply the same change in both fetch_and_decrypt_file_content and
fetch_and_decrypt_content_async (and the similar block at lines 354-378) so
early-return error paths zeroize secrets.

In `@apps/desktop/src-tauri/src/main.rs`:
- Around line 89-91: Replace the tokio async RwLock for the AppState dev_key
with a synchronous primitive (prefer std::sync::RwLock<Option<String>> or a
plain Option<String>) because setup runs on Tauri's Tokio runtime and mixing
blocking_read() / .await will panic; update all access sites: in main.rs where
you check state.dev_key.blocking_read().is_some() switch to
state.dev_key.read().unwrap().is_some(), in get_dev_key() return
state.dev_key.read().unwrap().clone(), and in zeroize_keys() replace the async
.write().await usage with a synchronous write (e.g. .write().unwrap()) or set
the Option directly; ensure all uses reference the dev_key field on AppState and
remove any tokio::sync::RwLock imports.

In `@apps/desktop/src-tauri/src/registry/mod.rs`:
- Around line 154-162: The UUID v4 formatting builds the third group incorrectly
because `bytes[6] & 0x0f` is printed with `{:02x}`, producing two hex digits and
making the third group 5 chars; update the formatting for that expression to use
a single-digit formatter (`{:01x}`) in each UUID generation site so the third
group becomes `"4" + one-nibble + bytes[7]` (4 chars total); apply this exact
change for the `uuid` format calls where `bytes[6] & 0x0f` appears (the three
occurrences in mod.rs) so the produced UUIDs are 36 characters and conform to v4
layout.
- Around line 150-208: The UUID-v4 formatting logic is duplicated in
get_or_create_device_id (three places); extract that logic into a new helper fn
generate_uuid_v4() -> String which calls
crypto::utils::generate_random_bytes(16) and returns the formatted UUID string,
then replace each inline format block in get_or_create_device_id with calls to
generate_uuid_v4(); keep existing behavior around keyring::Entry handling,
entry.get_password()/set_password()/delete_credential() and logging unchanged.

In `@apps/desktop/src-tauri/vendor/fuser/pjdfs.Dockerfile`:
- Line 15: Replace the Dockerfile's use of ADD for local file copies with COPY:
change the instruction "ADD rust-toolchain /code/fuser/rust-toolchain" to use
COPY, and similarly replace any other local-file ADD occurrences (e.g., the ADD
on the other noted occurrence) with COPY to avoid implicit side-effects like tar
extraction or remote fetching; update the Dockerfile so all local file/directory
copies use the COPY instruction instead of ADD.

In `@apps/desktop/src-tauri/vendor/fuser/src/ll/request.rs`:
- Around line 1297-1300: IoCtl::in_data currently slices self.data using
self.arg.in_size without bounds checks, which can panic if the kernel header is
wrong; change in_data to validate bounds and return a Result<&[u8], SomeError>
(or at least clamp to the available length and document it) instead of
panicking: check that (self.arg.in_size as usize) <= self.data.len(), return an
explicit error (e.g., IoctlError::InvalidSize) when it exceeds, and update all
callers of IoCtl::in_data to handle the Result (or the clamped slice)
accordingly so no unchecked slicing occurs; reference the IoCtl struct, the
in_data method, self.data, and self.arg.in_size when making the changes.
- Around line 1607-1613: system_time_from_time computes the wrong SystemTime for
negative secs when nsecs > 0; change the negative branch in
system_time_from_time so the nanoseconds are handled as an additive offset
rather than making the total larger (convert timespec to a signed nanosecond
total or adjust by borrowing one second): when secs < 0 and nsecs == 0 keep
UNIX_EPOCH - Duration::new((-secs) as u64, 0), but when secs < 0 and nsecs > 0
use UNIX_EPOCH - Duration::new(((-secs) as u64) - 1, 1_000_000_000 - nsecs) so
that secs = -1, nsecs = 500_000_000 yields EPOCH − 0.5s.
- Around line 2204-2213: The big-endian constant MKNOD_REQUEST currently has a
raw array literal assigned to the type AlignedData<[u8; 56]> causing a type
mismatch; change the constant to construct the tuple struct by wrapping the
array in AlignedData(...) (same pattern used by INIT_REQUEST and other
big-endian constants) so the value is AlignedData([0x00, 0x00, ... 0x00])
instead of a bare array literal.

In `@apps/desktop/src-tauri/vendor/fuser/src/mnt/fuse3.rs`:
- Line 46: Replace direct calls to io::Error::last_os_error() with the helper
ensure_last_os_error() to normalize errors when C functions return null/negative
but errno is 0; specifically change the return at the branch that currently does
return Err(io::Error::last_os_error()) (and the similar occurrence later) to
return Err(ensure_last_os_error()), keeping the same control flow and using the
existing ensure_last_os_error() function to avoid misleading "Success" messages.
- Around line 68-88: The Drop impl for Mount currently calls
fuse_session_destroy but skips fuse_session_unmount when super::libc_umount
returns an Err (non-PermissionDenied), which can leave a stale kernel mount;
modify Mount::drop so that in the error path (after logging warn!("umount failed
with {err:?}")) you call unsafe { fuse_session_unmount(self.fuse_session); }
before calling fuse_session_destroy(self.fuse_session) (keeping the existing
early-return behavior for the PermissionDenied/Linux branch that already
unmounts and destroys); this ensures the canonical teardown sequence
fuse_session_unmount -> fuse_session_destroy runs for all non-permission
failures.

---

Outside diff comments:
In `@apps/desktop/src-tauri/src/fuse/operations.rs`:
- Around line 89-95: The EncryptedFolderMetadata struct and parsing logic are
duplicated across mod implementation and public wrappers; extract a single
shared EncryptedFolderMetadata (or EncryptedIpfsPayload) and a helper like
parse_sealed_from_ipfs_bytes to perform the serde deserialization/IV+data
decoding, then update decrypt_metadata_from_ipfs,
decrypt_file_metadata_from_ipfs, decrypt_metadata_from_ipfs_public, and
decrypt_file_metadata_from_ipfs_public to call that helper and then their
respective crypto routines (AES-GCM decrypt call) so the struct and parsing
exist only once and the four functions become thin delegating wrappers.
- Around line 947-967: The writable-open path currently calls
fetch_and_decrypt_file_content which uses the global NETWORK_TIMEOUT (3s) and
causes EIO for large files; fix by adding a new longer timeout constant (e.g.,
WRITE_OPEN_DOWNLOAD_TIMEOUT ~30s) and use it for this code path when calling
fetch_and_decrypt_file_content (or its blocking helper block_with_timeout) so
open(O_WRONLY|O_RDWR) allows a longer download window; update the call-site in
operations.rs (the branch that checks cid.is_empty() and invokes
fetch_and_decrypt_file_content) to pass or apply WRITE_OPEN_DOWNLOAD_TIMEOUT
instead of the short NETWORK_TIMEOUT.

---

Duplicate comments:
In `@apps/desktop/src-tauri/src/crypto/aes_ctr.rs`:
- Around line 91-104: The panic on empty ciphertext is addressed by adding an
early guard: in the function using ciphertext, start_byte and end_byte (check
for AesCtrError::InvalidRange), keep the conditional that returns Ok(Vec::new())
when ciphertext.is_empty() || start_byte >= ciphertext.len(), then compute
clamped_end with ciphertext.len().saturating_sub(1) and return Ok(Vec::new()) if
clamped_end < start_byte; ensure these exact checks and the use of
saturating_sub remain in place to avoid underflow and out-of-range indexing.

In `@apps/desktop/src-tauri/src/fuse/operations.rs`:
- Around line 741-799: Prefetch logic is correct and doesn't need changes: keep
the proactive readdir prefetch in the block that checks offset == 0, retain the
dedup guard using self.prefetching.insert(...) and checks, continue spawning the
background task with rt.spawn calling fetch_and_decrypt_content_async, and
ensure both error and timeout branches still send
crate::fuse::PendingContent::Failure (and success sends PendingContent::Success)
so no cleanup/fix is required.

In `@apps/desktop/src-tauri/vendor/fuser/examples/ioctl.rs`:
- Around line 165-174: The previous ioctl handler for FIOC_SET_SIZE used an
unsafe unwrap causing panics and left file attributes stale; update the
FIOC_SET_SIZE arm in the ioctl handling code so it safely converts in_data via a
fallible pattern (e.g., use let Ok(bytes) = in_data.try_into() else {
reply.error(EINVAL); return; }) instead of try_into().unwrap(), then compute
new_size from usize::from_ne_bytes(bytes), resize self.content to vec![0_u8;
new_size], and update self.fioc_file_attr.size and self.fioc_file_attr.blocks
((new_size as u64 + 511) / 512) before calling reply.ioctl(0, &[]).
- Around line 100-111: No change required: the read handling correctly guards
against negative offsets and EOF and clamps the returned slice by using the
checks on offset (< 0 and >= self.content.len()), computing start/end with
offset and _size, and calling reply.data(&self.content[start..end]); leave the
logic around ino, offset, _size, self.content, reply.data, and
reply.error(ENOENT) as-is.

---

Nitpick comments:
In @.planning/STATE.md:
- Around line 154-156: The note warns that the PR's FUSE-side defensive
workaround (optional FileEntry fields and the v2→v1 fallback parsing logic) only
masks the real bug where the web app writes v2-tagged metadata containing
v1-style file entries; update the project tracking by creating and actively
tracking the high-priority todo document named
fix-v2-metadata-version-mismatch.md, and in code references locate symbols
implementing the fallback parsing (e.g., FileEntry parsing/serializer functions
and the v2→v1 fallback code paths) to add TODO comments linking to that todo
file and ensure tests cover generation of v2-tagged metadata from the web app so
the root cause can be fixed rather than relying on the FUSE fallback.

In `@apps/desktop/src-tauri/rust-toolchain.toml`:
- Around line 1-2: The toolchain file only pins channel; update the [toolchain]
section to also pin the required targets and components so rustup can install
them on a clean clone: add the macOS targets (aarch64-apple-darwin and
x86_64-apple-darwin) under a targets list and include dev components like
rust-src, clippy and rustfmt under a components list (keeping channel = "1.88");
modify the rust-toolchain.toml entry named [toolchain] to include these targets
and components so onboarding is reproducible.

In `@apps/desktop/src-tauri/src/commands.rs`:
- Around line 415-418: get_dev_key is missing the debug-only gate and therefore
compiles into release as dead code; add the same #[cfg(debug_assertions)]
attribute used for handle_test_login_complete above the get_dev_key function so
it only exists in debug builds and matches the debug-only registration in the
invoke_handler, ensuring dev_key isn't present (or returned as None) in release
builds.

In `@apps/desktop/src-tauri/src/crypto/hkdf.rs`:
- Around line 40-41: HkdfError::InvalidKeySize is dead code because all three
public functions take &[u8; 32] so that variant can never be produced; either
remove the InvalidKeySize enum variant and all references to it from HkdfError,
or change the internal helper that performs the extract/expand (referenced as
the internal HKDF helper used by the public functions) to accept a flexible
&[u8] key parameter instead of &[u8; 32] so the variant becomes
meaningful—update the helper’s signature and any callers, or delete the
HkdfError::InvalidKeySize variant and its uses accordingly.

In `@apps/desktop/src-tauri/src/fuse/operations.rs`:
- Around line 1981-2003: The repeated local imports of UnicodeNormalization
inside the rename function are redundant; remove the two inner "use
unicode_normalization::UnicodeNormalization;" lines and add a single "use
unicode_normalization::UnicodeNormalization;" declaration at the top of the
rename function body so both uses (the NFC normalization of name_str and
newname_str when building nfc_key) share the same import and reduce duplication
in the function where name_str, newname_str, parent, newparent, and the
inodes.name_to_ino updates occur.
- Around line 199-252: resolve_file_pointers_blocking currently calls
block_with_timeout sequentially for each (ino, ipns_name), causing O(N) startup
latency; instead spawn all resolve+fetch tasks and await them concurrently via
rt.block_on(futures::future::join_all(...)). Convert folder_key to
folder_key_arr as before, then build a Vec of async closures that call
crate::api::ipns::resolve_ipns, crate::api::ipfs::fetch_content, and
decrypt_file_metadata_from_ipfs (or return the error), call
rt.block_on(join_all(tasks)), and iterate results to call
fs.inodes.resolve_file_pointer on successes and log per-item errors (preserving
the current log messages). Ensure block_with_timeout is removed per-item and any
timeout is applied around the whole join_all or per-task as desired.

In `@apps/desktop/src-tauri/src/registry/mod.rs`:
- Around line 66-67: The timestamps for created_at and last_seen_at are produced
by two separate now_ms() calls which can differ; capture a single timestamp once
(e.g., let ts = now_ms()) and reuse it for both fields when constructing the
record so created_at and last_seen_at are identical; update the code around the
struct/record initialization that sets created_at and last_seen_at to use that
single variable instead of calling now_ms() twice.

In `@apps/desktop/src-tauri/vendor/fuser/bsd_mount_tests.sh`:
- Around line 19-21: The cargo run invocation uses unquoted path variables ($DIR
and $3) which can break on paths with spaces; update the command in the example
runner (the cargo run --example hello invocation) to quote both $DIR and $3 so
the shell treats them as single arguments, and ensure any related variable
expansions used in the surrounding logic (e.g., the conditional test around line
28 that uses [[ ... ]]) quotes the substitution inside the [[ ]] expression as
well so word-splitting and globbing cannot occur.

In `@apps/desktop/src-tauri/vendor/fuser/examples/ioctl.rs`:
- Line 132: In the readdir implementation, reject negative offsets before
casting instead of silently wrapping via enumerate().skip(offset as usize); add
the same negative-offset guard used in read (check if offset < 0 and return the
same error/result) and only then cast offset to usize to use in
entries.into_iter().enumerate().skip(...), referencing the readdir function and
the entries.into_iter().enumerate().skip(offset as usize) expression so behavior
is consistent with read.
- Around line 46-62: The FileAttr for fioc_file_attr incorrectly sets blocks: 1
while size: 0, causing getattr to report allocated blocks for an empty file;
update fioc_file_attr to set blocks: 0 (so blocks reflects size 0) — locate the
fioc_file_attr initializer and change the blocks field from 1 to 0 (no other
fields need adjustment, blksize can remain 512).

In `@apps/desktop/src-tauri/vendor/fuser/pjdfs.Dockerfile`:
- Line 1: Replace the mutable base image tag in the pjdfs.Dockerfile's FROM
instruction with an immutable digest-pinned image; locate the FROM ubuntu:22.04
line and change it to use the matching sha256 digest (e.g., FROM
ubuntu:22.04@sha256:<digest>) so builds are reproducible and protected from
upstream tag drift—ensure you fetch the correct digest for Ubuntu 22.04 from an
official registry and update the Dockerfile accordingly.
- Around line 5-6: The Dockerfile RUN layer that performs apt update && apt
install (the long RUN line installing git build-essential ... libfuse3-dev)
leaves apt lists cached and installs recommended packages; modify that same RUN
command to add --no-install-recommends to the apt install invocation and remove
the apt cache in the same layer by cleaning /var/lib/apt/lists/* (e.g., apt-get
clean && rm -rf /var/lib/apt/lists/*) so the image size is reduced and no extra
cache persists.

In `@apps/desktop/src-tauri/vendor/fuser/src/ll/request.rs`:
- Around line 580-583: The RmDir struct exposes its name field publicly while
also providing a pub fn name() accessor, breaking encapsulation and consistency
with other request structs; change the field declaration in RmDir from pub name:
&'a Path to a private name: &'a Path, keep the existing pub fn name(&self) ->
&Path as the public accessor, and update any direct field uses (notably the
Display implementation which currently uses x.name) to call x.name() instead so
all access goes through the accessor.

In `@apps/desktop/src-tauri/vendor/fuser/src/mnt/fuse3.rs`:
- Line 33: Replace the panic site CString::new(...).unwrap() with error
propagation: use CString::new(mnt.as_os_str().as_bytes()).map_err(|e|
std::io::Error::new(std::io::ErrorKind::InvalidInput, e))? so the conversion
error is returned as an io::Result rather than causing a panic; locate the
occurrence of CString::new and the mnt variable in the function that returns
io::Result and apply this map_err + ? pattern.
- Around line 17-24: The ensure_last_os_error function is duplicated in fuse3.rs
and fuse2.rs; extract it into the common module (mod.rs) or a small shared
submodule and have both fuse2.rs and fuse3.rs import and call that single
implementation. Move the function body (ensure_last_os_error) into mod.rs, make
it pub(crate) if needed, remove the duplicate definitions from fuse2.rs and
fuse3.rs, and add a use/import (or call via crate::mnt::ensure_last_os_error) in
both files so they reference the shared implementation.

Comment thread .planning/STATE.md Outdated
Comment thread apps/desktop/src-tauri/rust-toolchain.toml
Comment thread apps/desktop/src-tauri/src/commands.rs
Comment thread apps/desktop/src-tauri/src/crypto/hkdf.rs Outdated
Comment thread apps/desktop/src-tauri/src/fuse/mod.rs
Comment thread apps/desktop/src-tauri/vendor/fuser/src/ll/request.rs
Comment thread apps/desktop/src-tauri/vendor/fuser/src/ll/request.rs
Comment thread apps/desktop/src-tauri/vendor/fuser/src/ll/request.rs Outdated
Comment thread apps/desktop/src-tauri/vendor/fuser/src/mnt/fuse3.rs Outdated
Comment thread apps/desktop/src-tauri/vendor/fuser/src/mnt/fuse3.rs
FSM1 and others added 2 commits February 19, 2026 01:13
- Wrap private key material in Zeroizing<Vec<u8>> (hkdf returns, commands param,
  operations file_key) ensuring zero-on-drop for all exit paths
- Fix safety valve in flush_publish_queue: was unreachable because
  pending_uploads > 0 returned false before safety_valve check
- Extract generate_uuid_v4() helper and fix format bug ({:02x} → {:01x}
  for masked nibble producing 5-char group instead of 4)
- Replace eprintln!(">>> ...") debug prints with log::debug!
- Add TODO for blocking I/O in drain_refresh_completions
- Remove stale duplicate STATE.md line

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- ADD → COPY in Dockerfile (Hadolint DL3020)
- IoCtl bounds check with .min() to prevent OOB slice
- Fix system_time_from_time for negative secs with nsecs > 0
- Fix big-endian MKNOD_REQUEST missing AlignedData wrapper
- Use ensure_last_os_error() consistently in fuse3 mount
- Add fuse_session_unmount fallback on non-EPERM umount failure

Co-Authored-By: Claude Opus 4.6 <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