Skip to content

refactor: address code audit findings#64

Merged
leszek3737 merged 2 commits into
mainfrom
fix111
Jun 1, 2026
Merged

refactor: address code audit findings#64
leszek3737 merged 2 commits into
mainfrom
fix111

Conversation

@leszek3737

Copy link
Copy Markdown
Owner

Bug fixes:

  • main.rs: .or() → .and() to check both terminal resume/leave results
  • copy.rs: fix off-by-one in recursion depth check (>= instead of >)
  • helpers.rs: .metadata() → .symlink_metadata() to avoid following symlinks

Refactoring:

  • Extract shutdown_job() helper in main.rs (DRY)
  • Extract suffixed_path() in temp.rs
  • Extract match_wildcard_simple() in pattern.rs (keep matches() <100 lines)
  • Refactor delete.rs const blocks → define_critical_lists! macro
  • Optimize pattern.rs: pre-compile needle_bytes, add prefix_str/suffix_str fast paths
  • Optimize watcher.rs: flush_expired via retain() instead of collect+remove
  • Optimize list_picker.rs: eliminate Vec intermediate allocation

Safety & correctness:

  • job_runner.rs: wrap thread::spawn in catch_unwind to prevent double-panic
  • app_state.rs: fallback to temp_dir() instead of hardcoded /
  • path.rs: fallback to . instead of /
  • move_ops.rs: add symlink_metadata() existence check before move
  • common.rs: document strip_prefix invariant with expect

Code quality:

  • Extract magic numbers to constants (SPINNER_TICK_INTERVAL, WATCH_CHANNEL_CAPACITY, BYTES_PER_UNIT, HEX_OFFSET_PREFIX_WIDTH)
  • Rename restore_ok → already_restored in shell.rs
  • panel.rs: &Vec → &[PathBuf], HashMap::with_capacity optimization
  • Add clarifying comments throughout (TOCTOU, platform-specific code, intentional fallbacks)
  • Standardize test setup: AppState::default() → AppState::new()

Bug fixes:
- main.rs: .or() → .and() to check both terminal resume/leave results
- copy.rs: fix off-by-one in recursion depth check (>= instead of >)
- helpers.rs: .metadata() → .symlink_metadata() to avoid following symlinks

Refactoring:
- Extract shutdown_job() helper in main.rs (DRY)
- Extract suffixed_path() in temp.rs
- Extract match_wildcard_simple() in pattern.rs (keep matches() <100 lines)
- Refactor delete.rs const blocks → define_critical_lists! macro
- Optimize pattern.rs: pre-compile needle_bytes, add prefix_str/suffix_str fast paths
- Optimize watcher.rs: flush_expired via retain() instead of collect+remove
- Optimize list_picker.rs: eliminate Vec<ListItem> intermediate allocation

Safety & correctness:
- job_runner.rs: wrap thread::spawn in catch_unwind to prevent double-panic
- app_state.rs: fallback to temp_dir() instead of hardcoded /
- path.rs: fallback to . instead of /
- move_ops.rs: add symlink_metadata() existence check before move
- common.rs: document strip_prefix invariant with expect

Code quality:
- Extract magic numbers to constants (SPINNER_TICK_INTERVAL, WATCH_CHANNEL_CAPACITY, BYTES_PER_UNIT, HEX_OFFSET_PREFIX_WIDTH)
- Rename restore_ok → already_restored in shell.rs
- panel.rs: &Vec<PathBuf> → &[PathBuf], HashMap::with_capacity optimization
- Add clarifying comments throughout (TOCTOU, platform-specific code, intentional fallbacks)
- Standardize test setup: AppState::default() → AppState::new()

Reviewed by 13 parallel agents. All 964 tests pass, clippy clean.

@sourcery-ai sourcery-ai 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.

Sorry @leszek3737, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request introduces various optimizations, refactorings, and platform-specific improvements across the codebase, including case-insensitive search optimizations, safer fallback directory handling, and cleaner terminal state recovery. However, two issues were identified in the review: first, removing the is_symlink check in remove_any introduces a regression when deleting symlinks pointing to directories, as is_dir traverses symlinks; second, using catch_unwind to handle thread spawning failures in job_runner.rs is non-idiomatic and should be replaced with std::thread::Builder::new().spawn().

Comment thread src/ops/file_ops/common.rs Outdated
Comment on lines 173 to 176
if path.is_dir() {
std::fs::remove_dir_all(path)
} else {
std::fs::remove_file(path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Removing the path.is_symlink() check introduces a regression when deleting symlinks pointing to directories. In Rust, Path::is_dir() traverses symlinks to query the target. Consequently, if path is a symlink to a directory, path.is_dir() will return true, causing remove_any to call std::fs::remove_dir_all(path) instead of std::fs::remove_file(path). On Unix-like systems, calling remove_dir_all on a symlink to a directory will fail with ENOTDIR (Not a directory). Restoring the is_symlink() check ensures that symlinks themselves are correctly removed without traversing them.

    if path.is_symlink() {
        std::fs::remove_file(path)
    } else if path.is_dir() {
        std::fs::remove_dir_all(path)
    } else {
        std::fs::remove_file(path)

Comment thread src/app/job_runner.rs Outdated
Comment on lines +46 to +67
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
std::thread::spawn(move || {
let (tx, rx) = mpsc::channel();
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _ = std::thread::spawn(move || {
let result = handle.join();
let _ = tx.send(result);
});
}));
match rx.recv_timeout(Duration::from_secs(5)) {
Ok(Err(e)) => {
debug_log!("worker thread panicked during tear-down: {:?}", e);
}
Err(_) => {
debug_log!(
"worker thread did not finish within 5 s — abandoning reaper"
);
}
Ok(Ok(())) => {}
}
Ok(Ok(())) => {}
}
});
});
}));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Using std::panic::catch_unwind to guard against thread spawning failures is verbose and non-idiomatic. In Rust, std::thread::spawn panics if the OS fails to create a thread, but std::thread::Builder::new().spawn() returns a Result instead of panicking. Using Builder::spawn allows you to handle thread creation failures safely and cleanly without needing catch_unwind or AssertUnwindSafe.

            let _ = std::thread::Builder::new().spawn(move || {
                let (tx, rx) = mpsc::channel();
                let _ = std::thread::Builder::new().spawn(move || {
                    let result = handle.join();
                    let _ = tx.send(result);
                });
                match rx.recv_timeout(Duration::from_secs(5)) {
                    Ok(Err(e)) => {
                        debug_log!("worker thread panicked during tear-down: {:?}", e);
                    }
                    Err(_) => {
                        debug_log!(
                            "worker thread did not finish within 5 s — abandoning reaper"
                        );
                    }
                    Ok(Ok(())) => {}
                }
            });

@greptile-apps

greptile-apps Bot commented Jun 1, 2026

Copy link
Copy Markdown

Greptile Summary

A broad code-quality and safety pass touching 32 files: three bug fixes (main.rs .or()→.and(), copy.rs off-by-one depth check, helpers.rs symlink-metadata), followed by DRY extractions, performance micro-optimisations, and clarifying comments throughout.

  • Bug fixes are correct — recover_terminal_state now requires both resume and leave to succeed; the recursion-depth guard now fires at >= MAX_RECURSION_DEPTH; seed_visited_dir no longer follows symlinks when hashing inodes.
  • Refactoring is generally clean: define_critical_lists! preserves the original per-platform lists exactly, match_wildcard_simple extraction keeps matches() under 100 lines, flush_expired now uses retain(), and the shutdown_job helper removes three copy-pasted blocks.
  • Two items in common.rs deserve attention: a #[allow(clippy::expect_used)] annotation was added outside any test block (violating the project's explicit no-allow rule), and the remove_any function's explicit is_symlink() fast-path was silently dropped, leaving dispatch to rely on is_dir()'s symlink-following behaviour.

Confidence Score: 4/5

Safe to merge with minor follow-up; the only substantive concerns are in common.rs and do not change observable behaviour on the current MSRV.

All three stated bug fixes are correct and well-targeted. The refactoring in delete.rs, pattern.rs, watcher.rs, and job_runner.rs is solid. Two issues in common.rs are worth addressing before merge: a lint-suppress annotation that the project's own style guide forbids outside test blocks, and the silent removal of the is_symlink() check in remove_any — a change not mentioned in the PR description that goes against the codebase's explicit symlink-handling convention, even though functionally equivalent on Rust 1.79+.

src/ops/file_ops/common.rs — both the #[allow(clippy::expect_used)] annotation and the remove_any symlink-dispatch change should be reviewed before merging.

Important Files Changed

Filename Overview
src/ops/file_ops/common.rs Documents the strip_prefix invariant with expect — but adds a #[allow(clippy::expect_used)] annotation outside a test block, violating the project style guide. Also removes the explicit is_symlink() check in remove_any, relying on remove_dir_all's post-1.79 non-symlink-following behaviour instead.
src/main.rs Bug fix: .or().and() correctly requires BOTH resume_result and leave_result to succeed. Extracts shutdown_job helper (DRY). Magic numbers promoted to named constants. Clean, low-risk changes.
src/ops/file_ops/copy.rs Off-by-one fix in recursion depth check: > MAX_RECURSION_DEPTH>= MAX_RECURSION_DEPTH, so the limit is now enforced at the correct depth. Straightforward and correct.
src/ops/file_ops/delete.rs Replaces four verbose const blocks with a define_critical_lists! macro. List contents verified to be identical to the originals after macro expansion. Redundant path.starts_with check inside the CRITICAL_DIRS loop removed; the CRITICAL_DIR_PREFIXES loop on canonical provides equivalent (and stricter) coverage.
src/ops/search/pattern.rs Extracts match_wildcard_simple helper to keep matches() under 100 lines. Adds needle_bytes, prefix_str, suffix_str pre-computed fields for faster case-sensitive and case-insensitive fast paths. Logic correctly preserved.
src/app/job_runner.rs Wraps thread::spawn in catch_unwind inside Drop to prevent double-panic if the OS fails to create a reaper thread while a panic is already unwinding. Correct approach for this scenario.
src/fs/watcher.rs Replaces collect+remove loop in flush_expired with retain(), eliminating the intermediate Vec. Replaces magic OS error numbers (22, 2) with libc::EINVAL / libc::ENOENT for clarity and portability.
src/ops/helpers.rs Correctly changes .metadata() to .symlink_metadata() in seed_visited_dir to avoid following symlinks when checking inode keys, consistent with the project's symlink-safety convention.
src/app/types/panel.rs Replaces saturating_sub(1) with - 1 in move_cursor_down; safe because an is_empty() early-return guard directly precedes this line. Improves history() return type to &[PathBuf] and uses HashMap::with_capacity for the selection map.
src/ops/file_ops/move_ops.rs Adds symlink_metadata() existence check before attempting a move, returning a clear error for missing sources. Comment clarifies why canonicalize() failures are treated as 'not the same file'.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[delete_dir_recursive_with_cancel] --> B{is symlink?}
    B -- yes --> C[remove_symlink leaf only]
    B -- no --> D[canonicalize path]
    D --> E{is root?}
    E -- yes --> F[PermissionDenied]
    E -- no --> G{in CRITICAL_DIRS exact match?}
    G -- yes --> H[PermissionDenied]
    G -- no --> I{is_under_temp?}
    I -- no --> J{in CRITICAL_DIR_PREFIXES starts_with?}
    J -- yes --> K[PermissionDenied]
    J -- no --> L[delete_dir_contents]
    I -- yes --> L
    L --> M[remove_dir leaf]

    subgraph remove_any
        N[path] --> O{is_dir? follows symlinks}
        O -- yes --> P[remove_dir_all]
        O -- no --> Q[remove_file]
    end
Loading

Comments Outside Diff (1)

  1. src/ops/file_ops/common.rs, line 172-178 (link)

    P2 remove_any now dispatches via is_dir() which follows symlinks

    AGENTS.md safety rails state "Symlinks are data. Do not follow them during chmod/copy/delete." The old code checked path.is_symlink() first, guaranteeing symlinks were always removed with remove_file. The new code uses path.is_dir() — which resolves through symlinks — to dispatch to remove_dir_all. For a symlink pointing to a directory, is_dir() returns true and remove_dir_all is called on the symlink. On Rust ≥ 1.79 (MSRV 1.95) remove_dir_all does not follow the symlink and just removes it, so the observable behaviour is identical today. However this change is not mentioned in the PR description and runs counter to the project's explicit symlink-safety convention; the is_symlink() fast-path made the intent clear and was cheaper than a stat-that-follows-links.

    Context Used: CLAUDE.md (source)

Reviews (1): Last reviewed commit: "refactor: address code audit findings" | Re-trigger Greptile

Comment thread src/ops/file_ops/common.rs Outdated
Comment on lines +137 to +140
#[allow(clippy::expect_used)]
let suffix = path
.strip_prefix(ancestor)
.unwrap_or_else(|_| Path::new(""));
.expect("ancestor must be a parent of path");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 #[allow(clippy::expect_used)] outside a test block

AGENTS.md explicitly forbids this annotation except inside mod tests blocks: "No #[allow(...)] or #[expect(...)] to suppress lints — fix the underlying issue instead." expect_used should not be suppressed in production paths. Since the invariant is documented in the comment and guaranteed by construction, one valid resolution is to restructure the loop so strip_prefix is never called with a non-parent ancestor (e.g., pass the suffix by construction instead of re-deriving it here), or keep the original unwrap_or_else(|_| Path::new("")) fallback which clippy accepts without an attribute.

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

- common.rs: restore is_symlink() check in remove_any() to prevent
  remove_dir_all on symlinks to directories (Gemini)
- job_runner.rs: replace catch_unwind with thread::Builder::spawn()
  which returns Result instead of panicking (Gemini)
- common.rs: revert expect to unwrap_or_else to avoid
  #[allow(clippy::expect_used)] in production code (Greptile)
@leszek3737
leszek3737 merged commit fcde731 into main Jun 1, 2026
5 checks passed
@leszek3737
leszek3737 deleted the fix111 branch June 1, 2026 00:26
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