Skip to content

Fix repository safety, file previews, and process cleanup from main audit - #138

Merged
danielss-dev merged 14 commits into
mainfrom
developments/audit-hardening
Sep 29, 2026
Merged

danielss-dev merged 14 commits into
mainfrom
developments/audit-hardening

Conversation

@danielss-dev

@danielss-dev danielss-dev commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

Summary

Several repository operations could affect files outside the user's selection or destroy unsnapshotted data. For example, discarding [id].tsx could also revert i.tsx, and a hard reset could overwrite an untracked file that collides with the target commit.

This PR addresses all seven findings from the main-branch audit:

  • Treat special filenames literally during discard/unstage and stage dangling symlink entries correctly.
  • Refuse hard resets that would overwrite untracked or ignored data, including file/directory collisions and sparse/LFS dispatches.
  • Refuse symlink/non-file .gitignore targets and moves into Git metadata or the worktree root.
  • Bound working-tree previews to a 2 MB prefix without splitting valid UTF-8.
  • Resolve filesystem aliases when removing registrations for missing worktrees.

Stronger regression tests also exposed process-cleanup gaps: Unix Git cancellation now kills surviving helpers after the leader exits, and provider commands terminate their owned process tree before draining pipes. Test fixtures now isolate personal signing/LFS settings and use readiness checkpoints for cancellation. Rust CI adds macOS coverage.

Changes are split into fourteen focused commits. The audit report, task list, roadmap, durable learnings, README, and affected user-guide pages record the fixes and remaining work.

Validation

  • Rust: 234 core unit tests, 13 core integration tests, and 167 Tauri tests pass (414 total); six existing optional tests remain ignored.
  • Frontend: 568 tests pass; TypeScript type-check passes.
  • cargo check -p strand-core -p strand-tauri passes.
  • cargo clippy -p strand-core -p strand-tauri -- -D warnings passes.
  • Final focused reset regressions and git diff --check pass.
  • A 1 GiB sparse-file preview regression used approximately 18.6 MB maximum RSS for the test process; this is not a packaged-app performance certification.

Remaining validation and follow-ups

Packaged-app validation and production performance certification remain outstanding. The Windows reset, cancellation, and provider regression subsets passed on 6e97c73; the final terminal ordering fix has a fresh CI run pending. Historical blob materialization and unbounded provider output reads remain separate follow-ups. See docs/main-audit-2026-09-29.md for evidence and limitations.

Review follow-up

Confirmed and repaired the Unix post-reap process-group race and Windows helper cancellation after leader exit. Windows Git is assigned to its Job Object while suspended. The separator findings were false positives because git2 0.19 normalizes Windows paths internally. New nested-path regressions also caught and fixed stale-index collision checks after external Git changes. All inline findings have replies with evidence and fix commits. The Windows CI job now runs reset, cancellation, and provider regression subsets.

Linux CI also reproduced a terminal lifecycle race: Exit was published before removing the session from the registry. The final commit moves notification after removal, with a synchronous observer regression that fails on the original code and passes after the fix.

@danielss-dev danielss-dev left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Bugbot found 3 bugs and 1 risk in the new hard-reset collision guard and process-cleanup paths. Literal pathspec / ignore / rename / preview changes look solid on review; main gaps are Windows index path separators and post-reap kill(-pgid) on provider CLI success.

guard_reset_tree(repo, index, &repo.find_tree(entry.id())?, root, &rel)?;
} else if metadata.is_dir() && entry.kind() != Some(git2::ObjectType::Commit) {
guard_replaced_directory(index, root, &rel)?;
} else if index.get_path(&rel, 0).is_none() {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

bug Hard-reset collision guard misses tracked paths on Windows (backslash index lookup)

parent.join(name) / rel.join(child.file_name()) build PathBufs with \\ separators on Windows, then those paths are passed straight to index.get_path. Libgit2 index keys are always forward-slash (src/main.rs), so get_path("src\\main.rs") returns None for every nested tracked entry.

Failure mode: On Windows, a hard reset whose target tree contains any nested path is refused as an "untracked or ignored" collision even when the worktree is clean — or, for directory-replacement checks, the same mismatch mis-classifies tracked children. Root-only collisions still work, which is why Unix CI/tests would not catch this. The PR notes Windows execution of these branches as outstanding.

Suggested fix: normalize to git's path form before the lookup (and reuse in guard_replaced_directory):

fn git_index_path(path: &Path) -> String {
    path.components()
        .map(|c| c.as_os_str().to_string_lossy())
        .collect::<Vec<_>>()
        .join("/")
}
// ...
} else if index.get_path(git_index_path(&rel).as_str(), 0).is_none() {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This is a false positive for the pinned git2 0.19.0 API. Index::get_path (src/index.rs:396–397) first calls path_to_repo_path; on Windows that helper (src/util.rs:203–234) calls fixup_windows_path, which converts backslashes to forward slashes before invoking libgit2. Both reset guards therefore already normalize these PathBuf values. Adding lossy conversion here would duplicate the binding and risk corrupting valid Unix non-UTF-8 names. I am adding a cross-platform regression covering nested tracked files and tracked-directory replacement, and including it in Windows CI.

let path = rel.join(child.file_name());
if child.file_type()?.is_dir() {
guard_replaced_directory(index, root, &path)?;
} else if index.get_path(&path, 0).is_none() {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

bug Same Windows separator bug in guard_replaced_directory

path is built with rel.join(child.file_name()), so on Windows it contains backslashes before index.get_path. Tracked descendants of a directory that would be replaced by a file/symlink are treated as untracked, producing false-positive hard-reset refusals (or incorrect collision paths) for any nested layout.

Use the same forward-slash normalization as in guard_reset_tree before get_path.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This is a false positive for the pinned git2 0.19.0 API. Index::get_path (src/index.rs:396–397) first calls path_to_repo_path; on Windows that helper (src/util.rs:203–234) calls fixup_windows_path, which converts backslashes to forward slashes before invoking libgit2. Both reset guards therefore already normalize these PathBuf values. Adding lossy conversion here would duplicate the binding and risk corrupting valid Unix non-UTF-8 names. I am adding a cross-platform regression covering nested tracked files and tracked-directory replacement, and including it in Windows CI.

};
// A CLI can exit while a helper still owns the pipes. Drain only after
// stopping the complete owned process tree, also on natural completion.
stop(&mut child);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

bug stop() after try_wait reaps the leader, then kill(-pgid) on a free PID

On the success path, child.try_wait() returning Ok(Some(status)) has already reaped the provider process, freeing its PID/PGID. The following stop(&mut child) does kill(-(child.id() as i32), SIGKILL) (via kill_process_tree).

Failure mode:

  1. Intended case (helper still alive): PGID is held by the helper, so the kill is correct and unblocks pipe joins — good.
  2. Common case (no helper, normal gh/az exit): the process group is gone; kill(-pgid) is usually ESRCH, but the PID is eligible for reuse.
  3. Race on a multi-threaded Tauri host: another Strand spawn that calls process_group(0) can recycle that PID as a new group leader between reap and stop(). This then SIGKILLs an unrelated process tree (e.g. a concurrent git/AI child).

The new natural_exit_stops_helpers_before_joining_provider_output test needs helper cleanup, but unconditional post-reap killpg on every successful CLI call is unsafe.

Suggested direction: record the pgid at spawn; only signal the group while members may still exist (e.g. kill before reap via waitid(..., WNOWAIT), or call stop only while stdout/stderr reader threads have not yet seen EOF). Never kill(-id) after a successful reap when the group may already be empty.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Confirmed. Fixed in 45e4e1e: Unix completion is now observed with waitid(WEXITED | WNOHANG | WNOWAIT), then the owned group is signaled before Child::wait releases the PID. Windows still targets its owned Job Object. A regression verifies that two exit observations leave the leader waitable, and the existing helper-held-pipe test still passes. All 167 Tauri tests pass locally.

if matches!(child.try_wait(), Ok(Some(_))) { return; }
#[cfg(windows)]
{
if matches!(child.try_wait(), Ok(Some(_))) { return; }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

risk Windows cancel still skips tree kill once the Git leader has exited

The Unix side of this change correctly keeps signaling the owned group after the leader exits so LFS/submodule helpers cannot retain pipes. On Windows the early try_wait() == Some(_) → return was preserved, so taskkill /T never runs against that dead PID.

Failure mode: the same "leader exited, helper still owns stderr/stdout" cancel scenario this PR regresses on Unix remains on Windows: Cancel can hang until the helper finishes transferring, because descendants are not torn down once the root PID is gone. Consider a Job Object (as in the provider-command path) or another tree kill that does not require the leader to still be alive.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Confirmed. Fixed in 30658d1: streaming Git retains an owned Windows Job Object through cancellation, so cleanup does not depend on the wrapper PID still running. Git starts suspended, is assigned to the job, then resumes; this also closes the spawn/assignment race for fast Git wrappers. The job wrapper is shared with Tauri and uses owned handles. Added a native Windows regression that reaps the leader while its helper retains stdout, then checks that cancellation closes the pipe. The module cross-compiles locally; the new regression runs in Windows CI on this PR.

guard_reset_tree(repo, index, &repo.find_tree(entry.id())?, root, &rel)?;
} else if metadata.is_dir() && entry.kind() != Some(git2::ObjectType::Commit) {
guard_replaced_directory(index, root, &rel)?;
} else if index.get_path(&rel, 0).is_none() {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

bug Hard-reset collision guard misses tracked paths on Windows (backslash index lookup)

parent.join(name) / rel.join(child.file_name()) build PathBufs with \\ separators on Windows, then those paths are passed straight to index.get_path. Libgit2 index keys are always forward-slash (src/main.rs), so get_path("src\\main.rs") returns None for every nested tracked entry.

Failure mode: On Windows, a hard reset whose target tree contains any nested path is refused as an "untracked or ignored" collision even when the worktree is clean — or, for directory-replacement checks, the same mismatch mis-classifies tracked children. Root-only collisions still work, which is why Unix CI/tests would not catch this. The PR notes Windows execution of these branches as outstanding.

Suggested fix: normalize to git's path form before the lookup (and reuse in guard_replaced_directory):

fn git_index_path(path: &Path) -> String {
    path.components()
        .map(|c| c.as_os_str().to_string_lossy())
        .collect::<Vec<_>>()
        .join("/")
}
// ...
} else if index.get_path(git_index_path(&rel).as_str(), 0).is_none() {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This is a false positive for the pinned git2 0.19.0 API. Index::get_path (src/index.rs:396–397) first calls path_to_repo_path; on Windows that helper (src/util.rs:203–234) calls fixup_windows_path, which converts backslashes to forward slashes before invoking libgit2. Both reset guards therefore already normalize these PathBuf values. Adding lossy conversion here would duplicate the binding and risk corrupting valid Unix non-UTF-8 names. I am adding a cross-platform regression covering nested tracked files and tracked-directory replacement, and including it in Windows CI.

let path = rel.join(child.file_name());
if child.file_type()?.is_dir() {
guard_replaced_directory(index, root, &path)?;
} else if index.get_path(&path, 0).is_none() {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

bug Same Windows separator bug in guard_replaced_directory

path is built with rel.join(child.file_name()), so on Windows it contains backslashes before index.get_path. Tracked descendants of a directory that would be replaced by a file/symlink are treated as untracked, producing false-positive hard-reset refusals (or incorrect collision paths) for any nested layout.

Use the same forward-slash normalization as in guard_reset_tree before get_path.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This is a false positive for the pinned git2 0.19.0 API. Index::get_path (src/index.rs:396–397) first calls path_to_repo_path; on Windows that helper (src/util.rs:203–234) calls fixup_windows_path, which converts backslashes to forward slashes before invoking libgit2. Both reset guards therefore already normalize these PathBuf values. Adding lossy conversion here would duplicate the binding and risk corrupting valid Unix non-UTF-8 names. I am adding a cross-platform regression covering nested tracked files and tracked-directory replacement, and including it in Windows CI.

};
// A CLI can exit while a helper still owns the pipes. Drain only after
// stopping the complete owned process tree, also on natural completion.
stop(&mut child);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

bug stop() after try_wait reaps the leader, then kill(-pgid) on a free PID

On the success path, child.try_wait() returning Ok(Some(status)) has already reaped the provider process, freeing its PID/PGID. The following stop(&mut child) does kill(-(child.id() as i32), SIGKILL) (via kill_process_tree).

Failure mode:

  1. Intended case (helper still alive): PGID is held by the helper, so the kill is correct and unblocks pipe joins — good.
  2. Common case (no helper, normal gh/az exit): the process group is gone; kill(-pgid) is usually ESRCH, but the PID is eligible for reuse.
  3. Race on a multi-threaded Tauri host: another Strand spawn that calls process_group(0) can recycle that PID as a new group leader between reap and stop(). This then SIGKILLs an unrelated process tree (e.g. a concurrent git/AI child).

The new natural_exit_stops_helpers_before_joining_provider_output test needs helper cleanup, but unconditional post-reap killpg on every successful CLI call is unsafe.

Suggested direction: record the pgid at spawn; only signal the group while members may still exist (e.g. kill before reap via waitid(..., WNOWAIT), or call stop only while stdout/stderr reader threads have not yet seen EOF). Never kill(-id) after a successful reap when the group may already be empty.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Confirmed. Fixed in 45e4e1e: Unix completion is now observed with waitid(WEXITED | WNOHANG | WNOWAIT), then the owned group is signaled before Child::wait releases the PID. Windows still targets its owned Job Object. A regression verifies that two exit observations leave the leader waitable, and the existing helper-held-pipe test still passes. All 167 Tauri tests pass locally.

if matches!(child.try_wait(), Ok(Some(_))) { return; }
#[cfg(windows)]
{
if matches!(child.try_wait(), Ok(Some(_))) { return; }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

risk Windows cancel still skips tree kill once the Git leader has exited

The Unix side of this change correctly keeps signaling the owned group after the leader exits so LFS/submodule helpers cannot retain pipes. On Windows the early try_wait() == Some(_) → return was preserved, so taskkill /T never runs against that dead PID.

Failure mode: the same "leader exited, helper still owns stderr/stdout" cancel scenario this PR regresses on Unix remains on Windows: Cancel can hang until the helper finishes transferring, because descendants are not torn down once the root PID is gone. Consider a Job Object (as in the provider-command path) or another tree kill that does not require the leader to still be alive.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Confirmed. Fixed in 30658d1: streaming Git retains an owned Windows Job Object through cancellation, so cleanup does not depend on the wrapper PID still running. Git starts suspended, is assigned to the job, then resumes; this also closes the spawn/assignment race for fast Git wrappers. The job wrapper is shared with Tauri and uses owned handles. Added a native Windows regression that reaps the leader while its helper retains stdout, then checks that cancellation closes the pipe. The module cross-compiles locally; the new regression runs in Windows CI on this PR.

@danielss-dev

Copy link
Copy Markdown
Owner Author

Reviewed all eight inline comments (four findings, each posted twice).

  • The two Windows separator findings are false positives: git2 0.19 normalizes paths before index lookup. Replied to every occurrence with the binding-level evidence and added nested-path regressions.
  • Fixed the Unix provider cleanup race in 45e4e1e: observe exit without reaping, signal the owned group, then reap.
  • Fixed Windows streaming Git helper cancellation in 30658d1: assign the suspended process to an owned Job Object before resuming it. The Windows reset, cancellation and provider CI subsets passed on 6e97c73.
  • The new reset regression exposed stale cached index membership after external Git changes; fixed in f7e5d8a.
  • Linux CI exposed a terminal exit/registry ordering race; fixed in ad63df6. The stronger synchronous regression fails on the original implementation and passes with the fix.

Local validation: 414 Rust tests pass, plus the focused terminal regression after the final fix; Cargo check and strict Clippy pass. macOS and frontend CI passed on 6e97c73. The final head ad63df6 has a fresh CI run pending. No merge performed.

@danielss-dev
danielss-dev merged commit 381ca50 into main Sep 29, 2026
5 checks passed
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