Skip to content

Conversation

@atrtde
Copy link
Owner

@atrtde atrtde commented Feb 9, 2026

Summary by CodeRabbit

  • New Features

    • Encryption Password Cache — local encrypted password caching with configurable TTL and per-profile overrides
    • Sync Diff Views — new mntn sync --diff to display combined unstaged and staged changes
    • Centralized password prompting with cache get/confirm flows
  • Security / Hardening

    • Improved permission lockdown for secret files/directories (cross-platform)
    • .secrets directory auto-excluded from git
    • Automatic cache invalidation on detected password errors
  • Bug Fixes

    • Package registry output now strips ANSI escape codes
  • Documentation

    • Comprehensive docs added for password cache and sync diff features
  • Tests

    • Unit tests for cache TTLs, round-trip, expiry, and invalidation

@atrtde atrtde self-assigned this Feb 9, 2026
Copilot AI review requested due to automatic review settings February 9, 2026 23:09
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 9, 2026

Warning

Rate limit exceeded

@atrtde has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 21 minutes and 33 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📝 Walkthrough

Walkthrough

Adds an encrypted, per-profile password cache with configurable TTL and file-based storage, a CLI --diff flag for showing git diffs (with staged fallback), ANSI escape-code stripping for command outputs, platform-specific permission lockdown utilities, new secrets/security path helpers, and documentation updates.

Changes

Cohort / File(s) Summary
Core Security Module
src/security.rs
New module implementing encrypted password cache (age), per-profile/global config, TTL handling, load/save security.json, get_or_prompt_password(), invalidate_password_cache(), helpers, and unit tests.
CLI & Entrypoint
src/cli.rs, src/main.rs
Added --diff flag (SyncArgs.diff) and imported the security module into main.
Sync Task & Git Diff
src/tasks/sync.rs
Wired diff: bool through SyncTask; added git-diff display code, staged/--cached fallback, gitignore handling for .secrets/, and dry-run diff/status entries.
Task Integrations
src/tasks/backup.rs, src/tasks/restore.rs, src/tasks/validate.rs
Replaced direct password prompts with get_or_prompt_password(); use is_password_error() to detect password issues and call invalidate_password_cache() on failures; backup output now strips ANSI codes.
Encryption Helper
src/encryption.rs
Added pub fn is_password_error(message: &str) -> bool predicate for unified password-related error detection.
Paths & System Utilities
src/utils/paths.rs, src/utils/system.rs, src/utils/mod.rs
New secrets/security path constants and getters (.secrets/, security.json, cache/keys dirs); added strip_ansi_codes() with regex; exported new permissions module.
Permissions Utilities
src/utils/permissions.rs
New platform-specific permission lockdown helpers: lock_down_dir and lock_down_file (Unix: chmod; Windows: ACL/DACL via Win32 APIs).
Build / Platform
Cargo.toml
Added Windows-specific windows-sys dependency for Win32 permission APIs.
Docs
CHANGELOG.md, README.md
Added v2.3.0 notes and comprehensive documentation for the encryption password cache, file layout, TTLs, and sync --diff usage.

Sequence Diagrams

sequenceDiagram
    participant User
    participant CLI as CLI/Task
    participant Security
    participant Cache as CacheFile
    participant Age as Encryption

    User->>CLI: Run operation needing password
    CLI->>Security: get_or_prompt_password(profile)
    Security->>Security: Load security config (security.json)
    Security->>Cache: Check cache file (exists & TTL?)
    alt Cache hit & valid
        Cache-->>Security: Encrypted record
        Security->>Age: Decrypt with identity
        Age-->>Security: Plain password
        Security-->>CLI: Return password
    else Cache miss or expired
        Security->>User: Prompt for password
        User-->>Security: Enter password
        Security->>Age: Encrypt password
        Age->>Cache: Store encrypted record + timestamp
        Security-->>CLI: Return password
    end
    CLI->>CLI: Continue operation
Loading
sequenceDiagram
    participant User
    participant CLI as CLI/SyncTask
    participant Git
    participant Output

    User->>CLI: mntn sync --diff
    CLI->>Git: git status
    Git-->>CLI: Status
    CLI->>Git: git diff (unstaged)
    Git-->>CLI: Unstaged diff bytes
    CLI->>Output: Print "Unstaged Changes"
    CLI->>Git: git diff --staged (or --cached)
    alt staged diff succeeds
        Git-->>CLI: Staged diff bytes
    else staged diff fails
        Git-->>CLI: Error -> CLI retries with --cached
        Git-->>CLI: Staged diff bytes
    end
    CLI->>Output: Print "Staged Changes"
    Output-->>User: Display combined diffs
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • feat: secure password cache #7: Strong overlap — adds the same security subsystem (src/security.rs), get_or_prompt_password, invalidate_password_cache, and related wiring.
  • feat: sync diff #8: Closely related — adds sync --diff handling, strip_ansi_codes, and sync/backup integrations.

Poem

🐇 I nibble secrets, tuck them tight,
Encrypt at dusk, awake at light,
Diffs parade in tidy rows,
ANSI fades — the output glows,
A hopping cheer for safe config nights!

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 48.44% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: add sync diff view and secure password cache (plus fixes/docs)' accurately captures the two main features (sync diff view and password cache) and documentation updates reflected in the changeset.

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

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch staging

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.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

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

⚠️ Outside diff range comments (1)
src/tasks/restore.rs (1)

321-331: ⚠️ Potential issue | 🟠 Major

On password error, the loop continues with the same wrong password, producing redundant warnings for all remaining entries.

After invalidate_password_cache on Line 324, the loop continues to the next entry which will also fail with the same already-obtained wrong password. validate.rs correctly does return errors; in the same scenario (Line 560). Consider breaking out of the loop here as well.

Proposed fix
                     let err_msg = e.to_string();
                     if is_password_error(&err_msg) {
                         invalidate_password_cache(profile);
+                        log_warning(&format!(
+                            "Failed to restore encrypted {}: incorrect password",
+                            entry.name
+                        ));
+                        skipped_count += 1;
+                        // Wrong password — skip remaining encrypted entries
+                        skipped_count += enabled_entries.len() as u32
+                            - (restored_count + skipped_count);
+                        return (restored_count, skipped_count);
                     }
-                    log_warning(&format!(
-                        "Failed to restore encrypted {}: {}",
-                        entry.name, e
-                    ));
-                    skipped_count += 1;
+                    else {
+                        log_warning(&format!(
+                            "Failed to restore encrypted {}: {}",
+                            entry.name, e
+                        ));
+                        skipped_count += 1;
+                    }
🤖 Fix all issues with AI agents
In `@CHANGELOG.md`:
- Line 13: Change the likely-typo "skills ls -g" in the CHANGELOG entry to the
intended package manager command (e.g., replace "skills ls -g" with the actual
command like "npm ls -g" or "pnpm ls -g"), and update the surrounding text to
match that command (the entry labeled "Package Registry Output" that mentions
stripping ANSI escape codes should reference the corrected command name).
🧹 Nitpick comments (6)
src/utils/system.rs (1)

207-212: Test looks good, but consider adding an edge-case test for plain text passthrough.

A test with no ANSI codes (e.g., "plain text") confirming the string is returned unchanged would strengthen coverage.

README.md (1)

371-372: Implementation detail leaks into user-facing docs.

The parenthetical "(uses --cached fallback for staged diffs)" describes an internal git implementation detail that may confuse users. Consider simplifying to just describe the user-visible behavior, e.g., "Show changes before syncing."

src/encryption.rs (1)

84-91: Broad keyword matching could cause false positives.

Terms like "decrypt" and "identity" are generic and could match non-password errors (e.g., "failed to decrypt configuration" due to corrupted data, or "identity not found" in a different context). In practice this is likely fine since callers are scoped to age decryption, but consider adding a doc comment clarifying the intended use context so future callers don't misuse it.

Suggested doc comment
+/// Heuristically checks whether an error message from the `age` encryption
+/// library indicates a wrong-password / bad-identity failure.
+/// Not intended for general-purpose error classification.
 pub fn is_password_error(message: &str) -> bool {
src/tasks/sync.rs (1)

307-324: Redundant use std::io::Write; — already imported at file scope (Line 8).

The inner use std::io::Write; at Line 308 shadows the file-level import at Line 8. Harmless but unnecessary.

src/security.rs (2)

79-83: PasswordCacheRecord.password is a plain String — not zeroized on drop.

After deserialization (Line 250) or before serialization (Line 293-296), the plaintext password exists as a regular String in heap memory. When the PasswordCacheRecord is dropped, the memory is deallocated but not zeroed, leaving the password potentially recoverable from memory. Consider using SecretString (or the zeroize crate's Zeroizing<String>) for the password field in PasswordCacheRecord, or manually zeroize the record after use.

This is a defense-in-depth concern rather than a practical exploit in most scenarios.


299-302: Minor TOCTOU in temp file path selection.

The existence check on Line 300 and creation on Line 304 are not atomic. A concurrent process could create the same path between the check and the write. The risk is minimal (worst case: a failed write that's already handled by ?), but using tempfile::NamedTempFile in the same directory would be more robust.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a sync --diff view and introduces an encrypted, local password cache to reduce repeated prompts during encrypted backup/restore/validate flows. Also improves backup output stability by stripping ANSI escape sequences from captured package-manager command output.

Changes:

  • Add mntn sync --diff to display unstaged + staged diffs (with --cached fallback).
  • Introduce security module with security.json configuration and age-encrypted password caching under ~/.mntn/.secrets/, plus cache invalidation on password errors.
  • Strip ANSI CSI sequences from package manager command output before persisting backups.

Reviewed changes

Copilot reviewed 12 out of 13 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
src/utils/system.rs Adds ANSI escape stripping helper used to sanitize captured command output.
src/utils/paths.rs Adds .secrets + security.json path constants and helpers.
src/tasks/validate.rs Switches encrypted validation password flow to cached prompting and cache invalidation on password errors.
src/tasks/sync.rs Adds --diff flag behavior and implementation to print git diffs.
src/tasks/restore.rs Switches encrypted restore to cached prompting; invalidates cache on password errors.
src/tasks/backup.rs Switches encrypted backup to cached prompting; strips ANSI escapes from package outputs before writing.
src/security.rs New module implementing security config + encrypted password cache + tests.
src/main.rs Registers the new security module.
src/encryption.rs Adds is_password_error helper used by restore/validate flows.
src/cli.rs Adds mntn sync --diff CLI flag.
README.md Documents .secrets/, security.json, password cache behavior, and sync --diff.
Cargo.lock Dependency lock updates (including regex, chrono, clap, etc.).
CHANGELOG.md Documents v2.3.0 features/fixes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/utils/permissions.rs`:
- Around line 29-141: The variable `token` is declared as HANDLE (an isize) but
is initialized with ptr::null_mut() (a pointer), causing a type mismatch; change
the initialization of token to 0 (e.g., let mut token: HANDLE = 0;) and ensure
subsequent uses (OpenProcessToken, CloseHandle, GetTokenInformation) treat token
as the numeric HANDLE value returned by OpenProcessToken in set_owner_only_dacl;
update any casts/uses that assumed a pointer (e.g., passing &mut token into
OpenProcessToken stays the same) and remove pointer-specific initializations so
token is correctly typed for windows-sys 0.59.0.
🧹 Nitpick comments (5)
src/utils/permissions.rs (1)

84-85: Potential alignment issue when casting Vec<u8> buffer to TOKEN_USER.

TOKEN_USER may have alignment requirements stricter than 1 byte. Casting buffer.as_ptr() (a *const u8, aligned to 1) to *const TOKEN_USER is technically undefined behavior if the alignment doesn't match. In practice, Vec<u8> may be over-aligned on Windows, but this is not guaranteed.

A safer approach would be to use a buffer with guaranteed alignment (e.g., Vec<u64> or #[repr(align)] wrapper).

src/utils/paths.rs (1)

90-100: Good env var override with defensive trimming.

The MNTN_SECRETS_KEY_DIR override pattern is clean and mirrors the existing MNTN_PROFILE pattern. Consider adding a test for the env var override path (similar to test_get_active_profile_name_from_env_var) to prevent regressions.

src/security.rs (2)

386-401: fs::read failure short-circuits the retry loop, unlike other steps.

The ? on fs::read (line 390) causes an immediate return from the function, while all other error paths (Decryptor, decrypt, read_to_end, deserialize) use the if attempt == 0 { continue; } retry pattern. This inconsistency means a transient I/O error won't be retried.

In practice the impact is low since a file read failure (e.g., file-not-found, permission denied) is unlikely to self-resolve on immediate retry. But the inconsistent control flow could confuse future maintainers.

♻️ Suggested consistency fix
     for attempt in 0..2 {
-        let encrypted = fs::read(cache_path).map_err(|e| {
-            last_error = Some(format!("Failed to read password cache: {}", e));
-            last_error.clone().unwrap()
-        })?;
+        let encrypted = match fs::read(cache_path) {
+            Ok(data) => data,
+            Err(e) => {
+                last_error = Some(format!("Failed to read password cache: {}", e));
+                if attempt == 0 {
+                    continue;
+                }
+                return Err(last_error.clone().unwrap());
+            }
+        };

113-120: save does not lock down file permissions on the written config.

security.json isn't secret (no passwords), but it does reveal cache policy (enabled, TTL, profile names). If an attacker can write to it, they could disable caching to force password prompts (social engineering vector) or set TTL to never. Consider applying lock_down_file after write, similar to how cache and key files are protected.

Cargo.toml (1)

43-44: Windows-specific dependency is properly scoped; consider updating to the latest version.

The features align with the Win32 APIs used in src/utils/permissions.rs and the dependency is correctly gated behind cfg(windows). However, windows-sys 0.61.2 is now available—consider updating from 0.59.0 for the latest bug fixes and improvements.

impl PasswordCacheTtl {
pub fn as_seconds(self) -> Option<i64> {
match self {
PasswordCacheTtl::FifteenMinutes => Some(15 * 60),

Check failure

Code scanning / CodeQL

Hard-coded cryptographic value Critical

This hard-coded value is used as
a password
.

Copilot Autofix

AI about 20 hours ago

Copilot could not generate an autofix suggestion

Copilot could not generate an autofix suggestion for this alert. Try pushing a new commit or if the problem persists contact support.

pub fn as_seconds(self) -> Option<i64> {
match self {
PasswordCacheTtl::FifteenMinutes => Some(15 * 60),
PasswordCacheTtl::OneHour => Some(60 * 60),

Check failure

Code scanning / CodeQL

Hard-coded cryptographic value Critical

This hard-coded value is used as
a password
.

Copilot Autofix

AI about 20 hours ago

Copilot could not generate an autofix suggestion

Copilot could not generate an autofix suggestion for this alert. Try pushing a new commit or if the problem persists contact support.

pub fn as_seconds(self) -> Option<i64> {
match self {
PasswordCacheTtl::FifteenMinutes => Some(15 * 60),
PasswordCacheTtl::OneHour => Some(60 * 60),

Check failure

Code scanning / CodeQL

Hard-coded cryptographic value Critical

This hard-coded value is used as
a password
.

Copilot Autofix

AI about 20 hours ago

Copilot could not generate an autofix suggestion

Copilot could not generate an autofix suggestion for this alert. Try pushing a new commit or if the problem persists contact support.

match self {
PasswordCacheTtl::FifteenMinutes => Some(15 * 60),
PasswordCacheTtl::OneHour => Some(60 * 60),
PasswordCacheTtl::EightHours => Some(8 * 60 * 60),

Check failure

Code scanning / CodeQL

Hard-coded cryptographic value Critical

This hard-coded value is used as
a password
.

Copilot Autofix

AI about 20 hours ago

Copilot could not generate an autofix suggestion

Copilot could not generate an autofix suggestion for this alert. Try pushing a new commit or if the problem persists contact support.

PasswordCacheTtl::OneHour => Some(60 * 60),
PasswordCacheTtl::EightHours => Some(8 * 60 * 60),
PasswordCacheTtl::OneDay => Some(24 * 60 * 60),
PasswordCacheTtl::SevenDays => Some(7 * 24 * 60 * 60),

Check failure

Code scanning / CodeQL

Hard-coded cryptographic value Critical

This hard-coded value is used as
a password
.

Copilot Autofix

AI about 20 hours ago

Copilot could not generate an autofix suggestion

Copilot could not generate an autofix suggestion for this alert. Try pushing a new commit or if the problem persists contact support.

PasswordCacheTtl::OneHour => Some(60 * 60),
PasswordCacheTtl::EightHours => Some(8 * 60 * 60),
PasswordCacheTtl::OneDay => Some(24 * 60 * 60),
PasswordCacheTtl::SevenDays => Some(7 * 24 * 60 * 60),

Check failure

Code scanning / CodeQL

Hard-coded cryptographic value Critical

This hard-coded value is used as
a password
.

Copilot Autofix

AI about 20 hours ago

Copilot could not generate an autofix suggestion

Copilot could not generate an autofix suggestion for this alert. Try pushing a new commit or if the problem persists contact support.

PasswordCacheTtl::OneHour => Some(60 * 60),
PasswordCacheTtl::EightHours => Some(8 * 60 * 60),
PasswordCacheTtl::OneDay => Some(24 * 60 * 60),
PasswordCacheTtl::SevenDays => Some(7 * 24 * 60 * 60),

Check failure

Code scanning / CodeQL

Hard-coded cryptographic value Critical

This hard-coded value is used as
a password
.

Copilot Autofix

AI about 20 hours ago

Copilot could not generate an autofix suggestion

Copilot could not generate an autofix suggestion for this alert. Try pushing a new commit or if the problem persists contact support.

PasswordCacheTtl::OneHour => Some(60 * 60),
PasswordCacheTtl::EightHours => Some(8 * 60 * 60),
PasswordCacheTtl::OneDay => Some(24 * 60 * 60),
PasswordCacheTtl::SevenDays => Some(7 * 24 * 60 * 60),

Check failure

Code scanning / CodeQL

Hard-coded cryptographic value Critical

This hard-coded value is used as
a password
.

Copilot Autofix

AI about 20 hours ago

Copilot could not generate an autofix suggestion

Copilot could not generate an autofix suggestion for this alert. Try pushing a new commit or if the problem persists contact support.

with_temp_home(|| {
let profile = ActiveProfile::with_profile("test-profile");
let ttl_seconds = PasswordCacheTtl::OneHour.as_seconds().unwrap();
let expired_at = Utc::now().timestamp() - ttl_seconds - 1;

Check failure

Code scanning / CodeQL

Hard-coded cryptographic value Critical

This hard-coded value is used as
a password
.

Copilot Autofix

AI about 20 hours ago

Copilot could not generate an autofix suggestion

Copilot could not generate an autofix suggestion for this alert. Try pushing a new commit or if the problem persists contact support.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/security.rs`:
- Around line 272-320: store_cached_password currently creates tmp_path and
tmp_file and may return early via ? after partial work, leaving orphaned tmp
files; fix by ensuring tmp_path is removed on any error: wrap the
encryption/write/sync/lock_down/rename sequence in a scope that captures
tmp_path and collects the Result, and on Err(_) call let _ =
fs::remove_file(&tmp_path) before returning the error, or use a small RAII guard
(or tempfile::NamedTempFile) that removes the temp file on drop unless
explicitly persisted; update references inside store_cached_password (tmp_path,
tmp_file, encryptor, lock_down_file, fs::rename) so the temp file is always
cleaned up on failure and only preserved when fs::rename succeeds.
🧹 Nitpick comments (3)
src/security.rs (3)

113-120: Consider locking down the security config file after save.

save creates the file but doesn't restrict permissions. While this file doesn't hold secrets directly, it does control security-sensitive settings (e.g., whether caching is enabled, TTL values). A tampered config could trick the system into caching passwords indefinitely or disabling cache expiry. Other write sites in this module (store_cached_password, get_or_create_cache_identity) consistently call lock_down_file.

🛡️ Proposed fix
         let content = serde_json::to_string_pretty(self)?;
         fs::write(path, content)?;
+        if let Err(e) = lock_down_file(path) {
+            log_warning(&format!("Failed to lock down security config file: {}", e));
+        }
         Ok(())

380-438: Retry loop is ineffective — immediate re-read of the same file without delay.

Two issues with this retry approach:

  1. The ? operator on line 390 causes an immediate return on I/O read errors, bypassing the retry logic entirely — inconsistent with the other error branches that use continue.
  2. For crypto/parse errors, retrying immediately (no sleep/backoff) against the same file will almost certainly produce the same result, making the second attempt a no-op.

If the goal is to handle a narrow race with a concurrent writer, consider adding a small delay between attempts or removing the retry altogether and simply returning the error (the caller already handles failure gracefully by falling back to prompting).

♻️ Simplified version without retry
 fn read_cached_password_record(
     cache_path: &Path,
     identity: &age::x25519::Identity,
 ) -> Result<PasswordCacheRecord, String> {
-    let mut last_error = None;
-
-    for attempt in 0..2 {
-        let encrypted = fs::read(cache_path).map_err(|e| {
-            last_error = Some(format!("Failed to read password cache: {}", e));
-            last_error.clone().unwrap()
-        })?;
-
-        let decryptor = match Decryptor::new(&encrypted[..]) {
-            Ok(decryptor) => decryptor,
-            Err(e) => {
-                last_error = Some(format!("Failed to parse password cache: {}", e));
-                if attempt == 0 {
-                    continue;
-                }
-                return Err(last_error.clone().unwrap());
-            }
-        };
-
-        let mut decrypted = Vec::new();
-        let mut reader = match decryptor.decrypt(std::iter::once(identity as &dyn age::Identity)) {
-            Ok(reader) => reader,
-            Err(e) => {
-                last_error = Some(format!("Failed to decrypt password cache: {}", e));
-                if attempt == 0 {
-                    continue;
-                }
-                return Err(last_error.clone().unwrap());
-            }
-        };
-        if reader.read_to_end(&mut decrypted).is_err() {
-            last_error = Some("Failed to read decrypted password cache".to_string());
-            if attempt == 0 {
-                continue;
-            }
-            return Err(last_error.clone().unwrap());
-        }
-
-        let decrypted = SecretBox::new(Box::new(decrypted));
-        let record: PasswordCacheRecord = match serde_json::from_slice(decrypted.expose_secret()) {
-            Ok(record) => record,
-            Err(e) => {
-                last_error = Some(format!("Failed to parse password cache record: {}", e));
-                if attempt == 0 {
-                    continue;
-                }
-                return Err(last_error.clone().unwrap());
-            }
-        };
-        drop(decrypted);
-        return Ok(record);
-    }
-
-    Err(last_error.unwrap_or_else(|| "Failed to read password cache".to_string()))
+    let encrypted = fs::read(cache_path)
+        .map_err(|e| format!("Failed to read password cache: {}", e))?;
+
+    let decryptor = Decryptor::new(&encrypted[..])
+        .map_err(|e| format!("Failed to parse password cache: {}", e))?;
+
+    let mut decrypted = Vec::new();
+    let mut reader = decryptor
+        .decrypt(std::iter::once(identity as &dyn age::Identity))
+        .map_err(|e| format!("Failed to decrypt password cache: {}", e))?;
+    reader
+        .read_to_end(&mut decrypted)
+        .map_err(|_| "Failed to read decrypted password cache".to_string())?;
+
+    let decrypted = SecretBox::new(Box::new(decrypted));
+    let record: PasswordCacheRecord = serde_json::from_slice(decrypted.expose_secret())
+        .map_err(|e| format!("Failed to parse password cache record: {}", e))?;
+    drop(decrypted);
+    Ok(record)
 }

293-298: Minor TOCTOU on temp file path selection.

The existence check on line 296 and File::create on line 300 are not atomic — another process could create the same temp file in between. Since fs::File::create truncates, this won't error, but it could cause data loss if two processes race. Consider using create_new(true) (like you do for the key file on line 342) and retrying with the pid-suffixed path on AlreadyExists.

♻️ Use create_new for atomicity
-    let mut tmp_path = cache_path.with_extension("tmp");
-    if tmp_path.exists() {
-        tmp_path = cache_path.with_extension(format!("tmp.{}", std::process::id()));
-    }
-
-    let mut tmp_file = fs::File::create(&tmp_path)?;
+    let tmp_path = cache_path.with_extension(format!("tmp.{}", std::process::id()));
+    let mut tmp_file = fs::OpenOptions::new()
+        .create_new(true)
+        .write(true)
+        .open(&tmp_path)
+        .or_else(|_| {
+            // Fallback: truncate if our own PID file somehow exists
+            fs::File::create(&tmp_path)
+        })?;

@atrtde atrtde closed this Feb 10, 2026
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