Skip to content

feat: Implement multi-device shared storage and address review findings - #14

Merged
Hureru merged 3 commits into
mainfrom
feature/multi-device-shared-storage
Jun 8, 2026
Merged

feat: Implement multi-device shared storage and address review findings#14
Hureru merged 3 commits into
mainfrom
feature/multi-device-shared-storage

Conversation

@Hureru

@Hureru Hureru commented Jun 8, 2026

Copy link
Copy Markdown
Owner

This pull request implements a new multi-device shared storage mode for SSHWarden, enabling multiple devices to share a common configuration and key cache while keeping runtime/session data isolated per device. It introduces new configuration options, updates the storage layout logic, and refactors path handling throughout the codebase to support this feature. Additionally, it improves file writing safety and path normalization.

Multi-device shared storage support:

  • Added [storage] multi_device and device_id options to config.toml and parsing logic, allowing users to enable a shared storage mode suitable for syncing via OneDrive/Dropbox, with per-device runtime/session isolation. (config.toml.example, crates/sshwarden-config/src/lib.rs [1] [2]
  • Introduced new functions and caching for resolving shared data directories and per-device directories (shared_data_dir, device_data_dir), and logic to sanitize and auto-generate device IDs. (crates/sshwarden-config/src/lib.rs [1] [2]

Path handling and config changes:

  • Updated path resolution for SSH config snippets and runtime files to respect the new storage layout, ensuring that device-specific files are kept separate and shared files are properly located. (crates/sshwarden-config/src/lib.rs [1] [2]
  • Added SshConfigPathStyle to allow generated SSH config to use home-relative (~/...) paths for better cross-device compatibility. (crates/sshwarden-config/src/lib.rs crates/sshwarden-config/src/lib.rsR264-R288)

File writing and safety:

  • Replaced the old write_owner_only_file with a new, more robust version that atomically writes files with owner-only permissions and includes backup/rollback logic for Windows. (crates/sshwarden-config/src/cache.rs, crates/sshwarden-config/src/lib.rs [1] [2] [3] [4]

Control command updates:

  • Updated the Forget control action to optionally remove shared cache files in multi-device mode, and added a new forget-shared command. (crates/sshwarden-agent/src/control.rs [1] [2]

Testing and validation:

These changes collectively enable safe and robust multi-device usage scenarios, particularly for users syncing their SSHWarden data via cloud storage.

Summary by CodeRabbit

  • New Features

    • Opt-in multi-device storage mode: separates shared config from per-device runtime and unlock material
    • SSH config path-style option to emit absolute or home-relative (~/...) Include paths
    • forget gains a shared-cache option to remove shared vs device-only data
    • Per-device session/unlock-slot storage and safer file writes; status now reports device ID and storage mode
  • Documentation

    • Added ADR describing multi-device layout and migration behavior

Hureru added 2 commits June 6, 2026 21:17
A multi-dimensional review of the feature surfaced correctness, security,
and CI issues. Fix all confirmed findings:

- clippy -D warnings (CI gate): use inspect instead of map-for-side-effect;
  take the recovered key out of the MutexGuard before awaiting
- device id path traversal: reject "."/".." in sanitize_device_id and
  assert a single normal path segment in device_data_dir()
- cache the multi_device decision (OnceLock) so a transient config.toml
  parse failure can no longer flip storage mode mid-run (also fixes a
  destructive forget that could delete the shared cache)
- enroll this device's Hello/native slot after a PIN unlock so a
  late-joining device is not stuck PIN-only forever
- atomic owner-only writes (tmp + rename) for local-key-cache.json and
  unlock-slots.json via a shared write_owner_only_file helper
- case-insensitive home-relative path match on Windows; warn on fallback
- gate the manual-sync PIN prompt to multi-device mode (no single-device
  regression)
- device-only forget clears key_names unconditionally
- keep the in-memory cache when a shared reload finds the file absent
- add tests for sanitize_device_id and home_relative_path; doc fixes

Gates green: cargo fmt --check, clippy --all-targets -D warnings,
test --workspace (56 passed).
@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8db6a7c7-b1f2-4bf9-a09e-47503c753cee

📥 Commits

Reviewing files that changed from the base of the PR and between f60fc19 and 105c872.

📒 Files selected for processing (5)
  • crates/sshwarden-config/src/lib.rs
  • crates/sshwarden-config/src/session.rs
  • crates/sshwarden-config/src/ssh_config.rs
  • crates/sshwarden-config/src/unlock_slots.rs
  • src/main.rs
🚧 Files skipped from review as they are similar to previous changes (5)
  • crates/sshwarden-config/src/session.rs
  • crates/sshwarden-config/src/unlock_slots.rs
  • crates/sshwarden-config/src/ssh_config.rs
  • crates/sshwarden-config/src/lib.rs
  • src/main.rs

📝 Walkthrough

Walkthrough

SSHWarden gains opt-in multi-device storage mode separating shared config artifacts from device-local runtime and unlock state. The PR restructures directory resolution, adds device ID resolution with sanitization, implements home-relative SSH config path styling, refactors cache persistence and unlock-slot handling, updates the forget command with a --shared-cache flag, and extends daemon introspection to surface multi-device details.

Changes

Multi-Device Storage Implementation

Layer / File(s) Summary
Configuration schema and path-style enum
config.toml.example, crates/sshwarden-config/src/lib.rs
Adds [storage].multi_device, [storage].device_id config keys and new [ssh_config].path_style setting (absolute or home-relative). Introduces SshConfigPathStyle enum and extends StorageConfig with multi-device fields.
Directory resolution and device ID infrastructure
crates/sshwarden-config/src/lib.rs
Replaces single cached directory with multiple OnceLock-based resolution for shared_data_dir(), device_data_dir(), current_device_id(), and multi_device_enabled(). Implements device ID auto-generation from hostname/user and sanitization to prevent path traversal. Adds crate-visible write_owner_only_file() for atomic, owner-only writes with safe replace fallback.
SSH config home-relative path formatting
crates/sshwarden-config/src/ssh_config.rs
Adds path_arg_with_style() and include_line_with_style() to generate Include paths as absolute or ~/... form. Includes home-directory detection with Windows case-insensitive handling and tests.
Per-device session and runtime file placement
crates/sshwarden-config/src/session.rs, crates/sshwarden-config/src/cache.rs
Updates SessionFile to resolve under device_data_dir with legacy path fallback and cleanup. Refactors LocalKeyCacheFile to use crate-level write_owner_only_file(). Adjusts runtime_dir() to prefer per-device storage while preserving platform-specific overrides.
Device-local unlock slots persistence
crates/sshwarden-config/src/unlock_slots.rs
Introduces UnlockSlotsFile for versioned JSON storage of device-specific platform unlock material (native_encrypted, hello_challenge, hello_encrypted) with atomic persistence and deletion.
Control protocol and CLI for multi-device forget
crates/sshwarden-agent/src/control.rs, src/main.rs
Changes ControlAction::Forget from unit variant to struct with shared: bool flag. Adds --shared-cache option to forget CLI command, routing to distinct IPC commands (forget vs forget-shared).
Multi-device cache and unlock slot refactoring
src/main.rs
Introduces helpers to reuse existing local cache keys, refactors shared-cache refresh to preserve/strip platform unlock slots per multi-device mode, adds helpers for current native/Hello unlock selection, and enrollment logic for device-local slots.
Control action implementations
src/main.rs
Implements Forget with shared-cache vs device-only branches, UnlockPin enrollment of missing device platform slots, Sync with optional PIN recovery for manual sync, and SetPin that reuses existing in-memory key when available.
Auto-unlock and API session with device-local unlock slots
src/main.rs
Native and Hello unlock flows use device-local slot helpers; Windows Hello session restore and refresh-token creation use device-local Hello challenge; auto-unlock UI checks device-local slots.
SSH config inclusion, status reporting, and daemon startup
src/main.rs
Include-line management writes with configured path style and drops legacy unmarked directives. Status JSON and human status include shared/device dirs, device ID, multi-device flag, and path style. Daemon startup migrates legacy shared unlock-slots into the device-local file.
Architecture Decision Record
docs/adr/0024-multi-device-shared-storage-with-device-runtime.md
Comprehensive ADR documenting the multi-device directory split, unlock-slot migration, SSH path styling semantics, updated forget behavior, and operational consequences.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • Hureru/SSHWarden#6: Adds persistent SessionFile concept for API sessions, directly related to this PR's multi-device updates to SessionFile path resolution with device-local storage and legacy fallback cleanup.

Poem

A rabbit hops through shared and device-bound stores,
I tuck each key where a new device implores.
~/ paths for neighbors, absolute for the rest,
Unlock slots settle on the device that's best.
Tap forget with care—shared or solo, you choose. 🐇🔐

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 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 change: implementing multi-device shared storage while addressing review findings, which aligns with the comprehensive changeset across config, control, and storage modules.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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

Caution

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

⚠️ Outside diff range comments (1)
crates/sshwarden-config/src/session.rs (1)

116-121: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Session file writes are not atomic, unlike the shared cache.

session.rs still uses a local write_owner_only_file that does a non-atomic std::fs::write followed by a permission change. Meanwhile, cache.rs was updated to use crate::write_owner_only_file which writes to a temp file, sets permissions before rename, and handles Windows rename failures with backup/rollback.

The session file contains encrypted refresh tokens. A crash or cloud-sync collision during write could truncate it. Consider switching to the crate-level atomic helper:

Proposed fix
     pub fn save(&self) -> anyhow::Result<()> {
         let path = Self::path()?;
         if let Some(parent) = path.parent() {
             std::fs::create_dir_all(parent).with_context(|| {
                 format!("Failed to create session directory: {}", parent.display())
             })?;
         }
         let content =
             serde_json::to_string_pretty(self).context("Failed to serialize session file")?;
-        write_owner_only_file(&path, content)
+        crate::write_owner_only_file(&path, content)
             .with_context(|| format!("Failed to write session file: {}", path.display()))?;
         Ok(())
     }
 ...
-fn write_owner_only_file(path: &Path, content: impl AsRef<[u8]>) -> anyhow::Result<()> {
-    std::fs::write(path, content)?;
-    #[cfg(unix)]
-    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
-    Ok(())
-}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/sshwarden-config/src/session.rs` around lines 116 - 121, The local
non-atomic write_owner_only_file in session.rs should be replaced with the
crate-level atomic helper used by cache.rs: stop calling std::fs::write +
set_permissions and instead call crate::write_owner_only_file so the file is
written to a temp file, permissions are set before rename, and Windows rename
failures are handled with backup/rollback; update any references to the local
function (write_owner_only_file) and remove the local implementation so session
file writes become atomic and consistent with cache.rs behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/sshwarden-config/src/ssh_config.rs`:
- Around line 69-74: line_matches_sshwarden_include is triggering tracing::warn!
indirectly because include_line_with_style(...,
SshConfigPathStyle::HomeRelative) calls path_arg_with_style which logs when the
path is outside the home; change matching to use a non-logging helper for
home-relative rewriting or a cached precomputed pattern instead of calling
path_arg_with_style here: extract the path normalization/backing logic used by
path_arg_with_style into a new function (e.g., path_arg_normalized_no_log) and
have include_line_with_style call that plus emit logs only on the write path, or
compute include_line/include_line_with_style/SshConfigPathStyle::HomeRelative
variants once at startup and reuse them in line_matches_sshwarden_include so no
tracing::warn! is emitted during parsing.

In `@crates/sshwarden-config/src/unlock_slots.rs`:
- Line 15: The UnlockSlots struct exposes pub version and save() currently
writes whatever self.version contains while load() rejects unsupported versions;
update save() to validate self.version against the supported version set (the
same check used in load()) and return an error instead of persisting an
unsupported version; specifically, in UnlockSlots::save() add the same version
validation logic used in UnlockSlots::load() (or call a shared helper/constant)
to guard against writing unreadable files (also apply this guard to any other
save-like methods handling version, as noted around the code region covering
lines 62-75).
- Around line 44-45: The current code does a check-then-act using path.exists()
which can race if the file is removed between the check and the I/O; change both
code paths that check path.exists() (the block using path and the later
remove/read block) to remove the pre-check and instead perform the I/O (e.g.,
fs::read / fs::read_to_string and fs::remove_file) and match the resulting
Result; if the error kind() is std::io::ErrorKind::NotFound return Ok(None) or
otherwise ignore/continue as appropriate, and for other errors propagate them
(using ?); reference the same local variable path and the read/remove calls in
unlock_slots.rs when updating the code.

In `@src/main.rs`:
- Around line 4597-4602: After a successful PIN auto-unlock in
handle_ui_request(), call
enroll_device_platform_slots_if_missing(&local_cache_key) the same way the
explicit "unlock-pin" control path does; locate the PIN-dialog/SSH-request
unlock branch in handle_ui_request() and insert the enrollment call immediately
after the code that marks the unlock as successful so newly-joined multi-device
peers get their platform slots seeded (the function is a no-op in single-device
mode or if a slot already exists).
- Around line 2591-2603: current_hello_challenge_b64() fails in single-device
mode because it only checks load_device_unlock_slots() and falls back to
VaultFile::load(), but after set-pin/envelope migration the Hello challenge is
stored in the envelope cache (local-key-cache.json) not vault.enc; update
current_hello_challenge_b64 to, when multi_device_mode() is false, try the
envelope cache reader (the code/path that loads the local key cache / envelope
cache) before or instead of calling sshwarden_config::vault::VaultFile::load(),
so it returns the migrated hello_challenge and restores Hello-related flows
(refer to current_hello_challenge_b64, load_device_unlock_slots,
multi_device_mode, and VaultFile::load to locate where to insert the
envelope-cache lookup).

---

Outside diff comments:
In `@crates/sshwarden-config/src/session.rs`:
- Around line 116-121: The local non-atomic write_owner_only_file in session.rs
should be replaced with the crate-level atomic helper used by cache.rs: stop
calling std::fs::write + set_permissions and instead call
crate::write_owner_only_file so the file is written to a temp file, permissions
are set before rename, and Windows rename failures are handled with
backup/rollback; update any references to the local function
(write_owner_only_file) and remove the local implementation so session file
writes become atomic and consistent with cache.rs behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ea4b87f1-cb08-418e-977d-7a7fbbcb8d45

📥 Commits

Reviewing files that changed from the base of the PR and between cd9397b and f60fc19.

📒 Files selected for processing (9)
  • config.toml.example
  • crates/sshwarden-agent/src/control.rs
  • crates/sshwarden-config/src/cache.rs
  • crates/sshwarden-config/src/lib.rs
  • crates/sshwarden-config/src/session.rs
  • crates/sshwarden-config/src/ssh_config.rs
  • crates/sshwarden-config/src/unlock_slots.rs
  • docs/adr/0024-multi-device-shared-storage-with-device-runtime.md
  • src/main.rs

Comment thread crates/sshwarden-config/src/ssh_config.rs
Comment thread crates/sshwarden-config/src/unlock_slots.rs
Comment thread crates/sshwarden-config/src/unlock_slots.rs Outdated
Comment thread src/main.rs
Comment thread src/main.rs
Address a second review pass over the previous fix commit:

- ssh_config: stop line_matches_sshwarden_include from emitting a
  home_relative warning on every ~/.ssh/config comparison; warn only on
  the write path via a new no-log path_arg_with_style_quiet
- unlock_slots: validate version in save() (shared helper with load()) so
  an unreadable file is never persisted; drop the exists()-then-IO TOCTOU
  in load()/delete() in favor of matching ErrorKind::NotFound
- session: use the crate-level atomic write_owner_only_file instead of a
  local non-atomic write; generalize its tmp/backup naming to append a
  suffix so .enc files keep their extension (session-x.enc.tmp)
- main: current_hello_challenge_b64 reads the envelope cache
  (local-key-cache.json) before vault.enc in single-device mode, fixing
  Hello token restore after envelope migration
- main: seed this device's platform unlock slot after the SSH-request PIN
  auto-unlock in handle_ui_request, matching the control-path behavior

Gates green: cargo fmt --check, clippy --all-targets -D warnings,
test --workspace (56 passed).
@Hureru
Hureru merged commit a3a554e into main Jun 8, 2026
6 checks passed
@Hureru
Hureru deleted the feature/multi-device-shared-storage branch June 8, 2026 06:18
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