fix(snapshot): refuse to snapshot $HOME directory (#798)

* fix(snapshot): refuse to snapshot home directory (#793)

When the TUI is launched from $HOME, the snapshot system would run
`git add -A` on the entire home directory, consuming unbounded CPU and
disk. This manifests as a multi-GB side-repo under ~/.deepseek/snapshots
and makes the TUI appear frozen.

Add a guard in SnapshotRepo::open_or_init that compares the canonical
workspace path against the canonical home directory and returns an error
if they match. The error is non-fatal (snapshots are a safety net, not a
correctness gate) so the turn loop continues without snapshots.

Closes #793

* test(snapshot): fix home guard test portability

* test(snapshot): avoid env-dependent home guard test

---------

Co-authored-by: Hunter Bown <hmbown@gmail.com>
This commit is contained in:
Jason
2026-05-06 14:08:05 +08:00
committed by GitHub
parent 8c091cce98
commit 1981c09970
+47 -8
View File
@@ -62,6 +62,14 @@ impl SnapshotRepo {
.canonicalize()
.unwrap_or_else(|_| workspace.to_path_buf());
// Refuse to snapshot the user's home directory: `git add -A` on $HOME
// can consume unbounded disk/CPU and effectively DoS the TUI (#793).
if is_home_directory(&work_tree, dirs::home_dir().as_deref()) {
return Err(io_other(
"refusing to snapshot home directory - start deepseek from a project directory instead",
));
}
let _ = ensure_snapshot_dir(&work_tree)?;
let git_dir = snapshot_git_dir(&work_tree);
@@ -407,6 +415,15 @@ fn io_other(msg: impl Into<String>) -> io::Error {
io::Error::other(msg.into())
}
fn is_home_directory(work_tree: &Path, home: Option<&Path>) -> bool {
let Some(home) = home else {
return false;
};
let home_canonical = home.canonicalize().unwrap_or_else(|_| home.to_path_buf());
work_tree == home_canonical
}
fn parse_nul_paths(bytes: &[u8]) -> HashSet<PathBuf> {
bytes
.split(|b| *b == 0)
@@ -429,33 +446,41 @@ mod tests {
use std::sync::MutexGuard;
use tempfile::tempdir;
/// Holds HOME pinned to a tempdir for the lifetime of a test. Also
/// Holds the home directory pinned to a tempdir for the lifetime of a test. Also
/// owns the process-wide env-var mutex so tests across modules
/// don't trample each other's `HOME`.
/// don't trample each other's home env vars.
pub(super) struct ScopedHome {
prev: Option<std::ffi::OsString>,
prev_vars: Vec<(&'static str, Option<std::ffi::OsString>)>,
_guard: MutexGuard<'static, ()>,
}
impl Drop for ScopedHome {
fn drop(&mut self) {
// SAFETY: process-wide lock still held.
unsafe {
match self.prev.take() {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
for (key, prev) in self.prev_vars.drain(..) {
match prev {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
}
}
}
}
pub(super) fn scoped_home(home: &Path) -> ScopedHome {
let guard = lock_test_env();
let prev = std::env::var_os("HOME");
let prev_vars = ["HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH"]
.into_iter()
.map(|key| (key, std::env::var_os(key)))
.collect();
// SAFETY: serialised by the global env lock.
unsafe {
std::env::set_var("HOME", home);
std::env::set_var("USERPROFILE", home);
std::env::remove_var("HOMEDRIVE");
std::env::remove_var("HOMEPATH");
}
ScopedHome {
prev,
prev_vars,
_guard: guard,
}
}
@@ -661,4 +686,18 @@ mod tests {
drop((_r, _h));
let (_r2, _h2) = make_repo(tmp.path());
}
#[test]
fn home_directory_guard_matches_canonical_paths() {
let tmp = tempdir().unwrap();
let home = tmp.path();
let home_canonical = home.canonicalize().unwrap();
let workspace = home.join("workspace");
std::fs::create_dir_all(&workspace).unwrap();
let workspace_canonical = workspace.canonicalize().unwrap();
assert!(is_home_directory(&home_canonical, Some(home)));
assert!(!is_home_directory(&workspace_canonical, Some(home)));
assert!(!is_home_directory(&home_canonical, None));
}
}