//! Folder-trust store ("do you trust this folder?"). //! //! Persists per-folder trust decisions to `~/.grok/trusted_folders.toml`. //! This is the durable backing store for the VS-Code-style folder-trust gate //! that decides whether repo-local MCP / LSP servers (which run arbitrary //! commands from repo-controlled config files) are allowed to spawn. //! //! TOML shape: //! ```toml //! [folders."/abs/repo/root"] //! trusted = true //! decided_at = 1780000000 //! ``` //! //! Trust **cascades to subdirectories**: if a folder is recorded trusted, any //! path at or below it is considered trusted, unless a nearer (more specific) //! folder records its own decision — the longest matching path prefix wins, so //! an explicit child untrust overrides an ancestor's trust. The persisted file //! is written atomically with owner-only (`0600`) permissions. //! //! The store is rooted at [`xai_grok_config::user_grok_home`] — the **Option** //! home that resolves to `None` (rather than a cwd-relative `./.grok`) when //! neither `$GROK_HOME` nor a home directory is set (e.g. a minimal container / //! CI). In that no-home environment [`TrustStore::load`] yields an **empty, //! trust-nothing** store that persists nothing, so a cloned repo can never ship //! a `./.grok/trusted_folders.toml` that self-trusts its own checkout (fail //! closed). use std::collections::BTreeMap; use std::io; use std::path::{Path, PathBuf}; use std::sync::Once; use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; /// Filename of the folder-trust store under `~/.grok/`. pub const TRUST_FILE_NAME: &str = "trusted_folders.toml"; /// A single folder's trust record. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FolderTrust { /// Whether the folder (and its subdirectories) is trusted. pub trusted: bool, /// Unix timestamp (seconds) of when the decision was recorded. #[serde(default, skip_serializing_if = "Option::is_none")] pub decided_at: Option, } /// On-disk document shape for `trusted_folders.toml`. #[derive(Debug, Clone, Default, Serialize, Deserialize)] struct TrustDocument { #[serde(default)] folders: BTreeMap, } /// Persisted set of trusted folders. /// /// Construct with [`TrustStore::load`] (production) or [`TrustStore::load_from`] /// (tests). Mutating with [`TrustStore::set_trusted`] persists to disk. /// /// `path` is `None` only in a no-home environment (see [`TrustStore::load`]): /// such a store holds no folders, trusts nothing, and persists nothing. #[derive(Debug, Clone)] pub struct TrustStore { doc: TrustDocument, /// Backing file, or `None` when no user home resolves — a trust-nothing, /// persist-nothing store. Never a cwd-relative path. path: Option, } impl TrustStore { /// Load the trust store from `/trusted_folders.toml`. /// /// When no user home resolves (see the module-level fail-closed note) the /// path is `None` and this returns an [`Self::empty`] store. Otherwise an /// empty store is returned if the file is missing or unparseable (logged). pub fn load() -> Self { match Self::default_path() { Some(path) => Self::load_from(path), None => Self::empty(), } } /// Load from a custom path (for tests). pub fn load_from(path: PathBuf) -> Self { let doc = Self::read_doc(&path); Self { doc, path: Some(path), } } /// An empty store with no backing path: trusts nothing and persists /// nothing. Used for the no-home environment where [`Self::default_path`] /// resolves to `None`. fn empty() -> Self { Self { doc: TrustDocument::default(), path: None, } } /// Default on-disk path: `/trusted_folders.toml`, or `None` /// when no user home resolves. /// /// Resolves via [`xai_grok_config::user_grok_home`], never /// [`xai_grok_config::grok_home`], so it never falls back to a cwd-relative /// `./.grok` — that fallback would let an untrusted cloned repo's `.grok` /// masquerade as the user-global store and self-trust the checkout. pub fn default_path() -> Option { Self::default_path_in(xai_grok_config::user_grok_home()) } /// Map a resolved user-grok-home to the store path, preserving "no home" as /// "no path" (never synthesizing a fallback). Split from [`Self::default_path`] /// as a pure seam so the no-home branch is unit-testable without the /// process-global home cache. fn default_path_in(user_grok_home: Option) -> Option { Some(user_grok_home?.join(TRUST_FILE_NAME)) } /// Whether `workspace_key` is trusted, per the MOST-SPECIFIC recorded /// decision (longest matching path prefix). /// /// This is the SHARED folder-trust gate: the most-specific-wins semantics /// apply to ALL folder-trust surfaces (repo-local MCP and LSP servers, and /// project hooks), not just hooks. /// /// Trust cascades to subdirectories: a trusted parent folder trusts all of /// its children. When both an ancestor and a nearer folder are recorded, the /// longest matching prefix wins, so an explicit child untrust overrides an /// ancestor's trust instead of being undone by the cascade. The query key is /// canonicalized here, so callers need not pre-canonicalize (symmetric with /// [`Self::set_trusted`]). /// /// Over-broad keys are ignored on read (fail closed): an empty/relative /// key, the filesystem root, or the user's home directory are never honored /// even if such a record reaches the file via hand-edit or migration — each /// would otherwise trust huge swaths of the filesystem through the cascade. /// See [`is_unsafe_trust_root`]. pub fn is_trusted(&self, workspace_key: &Path) -> bool { let workspace_key = canonicalize_or_owned(workspace_key); // Among all recorded ancestor folders (including the key itself), the // longest match decides. Canonical, code-produced keys are normalized, so // that longest match is unique. A hand-edited store could hold // non-canonical aliases (e.g. `/a/b` vs `/a/b/`) that tie on depth; on a // tie we require EVERY tied record to be trusted, so a contradictory edit // fails closed. let mut best_depth: Option = None; let mut trusted = false; for (folder, record) in &self.doc.folders { let folder = Path::new(folder); if is_unsafe_trust_root(folder) || !workspace_key.starts_with(folder) { continue; } let depth = folder.components().count(); match best_depth { Some(d) if depth < d => {} Some(d) if depth == d => trusted &= record.trusted, _ => { best_depth = Some(depth); trusted = record.trusted; } } } trusted } /// Record `workspace_key` as **trusted** and persist to disk. /// /// The key is canonicalized before storage so alias spellings (symlinks, /// `/tmp` vs `/private/tmp`, …) still match later lookups, which canonicalize /// too. Keys are stored as UTF-8 strings (via `to_string_lossy`); a non-UTF-8 /// path (rare on Unix) is stored lossily and therefore fails closed (it /// simply won't match on lookup) rather than over-trusting. /// /// **Over-broad roots are refused:** if the canonical key is non-absolute, /// the filesystem root, or the user's home directory it is rejected — /// nothing is recorded (neither in memory nor on disk) and `Ok(())` is /// returned, so `is_trusted` stays `false` for it on both read and write. /// A later [`Self::set_untrusted`] on the same folder flips the stored /// decision (the insert overwrites). See `record_decision` for the locked /// read-modify-write contract and the no-home `Ok(())` no-op. pub fn set_trusted(&mut self, workspace_key: &Path) -> io::Result<()> { self.record_decision(workspace_key, true) } /// Record `workspace_key` as **untrusted** ("Never" / explicitly declined) /// and persist to disk. /// /// Mirrors [`Self::set_trusted`] exactly (canonicalization + over-broad-root /// refusal) but stores `trusted = false`. [`Self::is_trusted`] already /// returns `false` for such a record; recording it lets a consumer tell /// "explicitly declined" apart from "undecided" (e.g. to avoid /// re-prompting). A later [`Self::set_trusted`] flips it back. pub fn set_untrusted(&mut self, workspace_key: &Path) -> io::Result<()> { self.record_decision(workspace_key, false) } /// Number of recorded folders (for diagnostics / tests). pub fn len(&self) -> usize { self.doc.folders.len() } /// Whether the store has no recorded folders. pub fn is_empty(&self) -> bool { self.doc.folders.is_empty() } /// Whether `workspace_key` has an EXACT recorded decision (trusted OR /// untrusted) — not cascade-aware. Used by the legacy-hook-trust migration to /// avoid overriding a folder the user has already decided on. pub fn has_decision(&self, workspace_key: &Path) -> bool { let canonical = canonicalize_or_owned(workspace_key); self.doc .folders .contains_key(canonical.to_string_lossy().as_ref()) } // ── Internal ────────────────────────────────────────────────────── /// Shared write path for [`Self::set_trusted`] / [`Self::set_untrusted`]. /// /// Canonicalizes the key, refuses over-broad roots (non-absolute / /// filesystem root / home dir → `warn!` + `Ok(())` recording nothing), and /// is a `warn!` + `Ok(())` no-op when there is no backing path (no-home /// environment), so it never writes a cwd-relative file. Otherwise it /// performs a locked read-modify-write-commit: /// 1. take an exclusive advisory lock on a sidecar `*.toml.lock` file, held /// for the whole critical section (released on drop), so concurrent /// writers serialize; /// 2. re-read the current on-disk document so a peer's decisions are merged /// rather than clobbered (lost-update fix); /// 3. insert the record and persist atomically; /// 4. only on success commit the new document to memory — on any /// lock/persist error `self.doc` is left unchanged. fn record_decision(&mut self, workspace_key: &Path, trusted: bool) -> io::Result<()> { let canonical = canonicalize_or_owned(workspace_key); if is_unsafe_trust_root(&canonical) { tracing::warn!( path = %canonical.display(), trusted, "folder trust: refusing to record an over-broad root (home, filesystem root, or non-absolute path); nothing recorded" ); return Ok(()); } // No backing file (no-home env) → record nothing, return `Ok` so // callers treat "no home" like "nothing to persist" (see fn doc). let Some(path) = self.path.as_deref() else { tracing::warn!( path = %canonical.display(), trusted, "folder trust: no user grok home resolved; trust decision not recorded" ); return Ok(()); }; // The lock file lives beside the store, so ensure the dir exists first. let parent = path.parent().ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidInput, "trust store path has no parent", ) })?; std::fs::create_dir_all(parent)?; // Serialize cross-process writers for the whole read-modify-write so a // concurrent peer's records are preserved, not clobbered. let _lock = ExclusiveLock::acquire(&path.with_extension("toml.lock"))?; // Re-read the latest on-disk state (merges a peer's concurrent writes). let mut doc = Self::read_doc(path); doc.folders.insert( canonical.to_string_lossy().to_string(), FolderTrust { trusted, decided_at: now_unix(), }, ); // Commit to memory only after a successful durable write, so a failure // leaves the in-memory store unchanged. Self::persist_doc(path, &doc)?; self.doc = doc; Ok(()) } fn read_doc(path: &Path) -> TrustDocument { let contents = match std::fs::read_to_string(path) { Ok(c) => c, Err(e) if e.kind() == io::ErrorKind::NotFound => return TrustDocument::default(), Err(e) => { tracing::warn!( path = %path.display(), error = %e, "folder trust: failed to read trust store; treating as empty" ); return TrustDocument::default(); } }; toml::from_str(&contents).unwrap_or_else(|e| { tracing::warn!( path = %path.display(), error = %e, "folder trust: failed to parse trust store; treating as empty" ); TrustDocument::default() }) } /// Write `doc` to `path` atomically (unique temp + fsync + rename) with /// owner-only (`0600`) permissions. /// /// Uses a unique temp file in the destination directory so concurrent /// writers never share a temp path, fsyncs it for crash durability, then /// renames it over the destination. `tempfile::NamedTempFile` creates the /// temp with `O_EXCL` and `0600` permissions on Unix, and `persist` /// performs an atomic replace (including over an existing destination on /// Windows). fn persist_doc(path: &Path, doc: &TrustDocument) -> io::Result<()> { use std::io::Write; let parent = path.parent().ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidInput, "trust store path has no parent", ) })?; std::fs::create_dir_all(parent)?; let body = toml::to_string_pretty(doc) .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; // Unique temp in the same directory (atomic rename requires same FS). let mut tmp = tempfile::NamedTempFile::new_in(parent)?; tmp.write_all(body.as_bytes())?; // Durably flush to disk before publishing so a crash can't leave a // zero-length or stale store behind. (`File::flush` is a no-op for // durability; `sync_all` is what guarantees the bytes hit disk.) tmp.as_file().sync_all()?; // Atomic publish. tmp.persist(path).map_err(|e| e.error)?; Ok(()) } } /// Compute the trust **workspace key** for a working directory. /// /// The key is the canonicalized git repository root when `cwd` is inside a /// repo (trust applies to the whole repo), otherwise the canonicalized `cwd`. /// /// A grok-managed worktree first collapses onto its recorded source repo's git /// ROOT (via the `~/.grok/worktrees.db` registry), so every `grok -w` worktree /// shares one trust key regardless of creation mode — including standalone clones /// that git can't link back to their source — and regardless of the subdir /// `grok -w` was launched from (the recorded source repo may be a repo subdir). /// Non-registry git worktrees fall through to the git-topology collapse below. /// /// A linked git worktree collapses onto its MAIN checkout's root so every /// `grok -w` worktree of a repo shares one trust key. The collapse fires ONLY /// for the conventional `/.git` layout — i.e. the common gitdir /// resolves back to `/.git`. For bare or `--separate-git-dir` /// repos (where the common gitdir's inferred workdir would be the gitdir's /// parent, broader than the real checkout) the key instead falls back to the /// worktree's own workdir, so it is narrow and never widened. Resolution is via /// git2 (honoring `core.worktree`), never path surgery. /// /// Finally, an over-broad derived root is rejected in favor of the cwd: when /// `$HOME` is itself a git repo (dotfiles-in-home) the up-walk would otherwise /// land on the home dir, so [`is_unsafe_trust_root`] re-scopes the key to the /// cwd (keeping trust bound to the working dir, not the whole home subtree). A /// cwd that IS home is out of scope — no narrower safe fallback exists. pub fn workspace_key(cwd: &Path) -> PathBuf { let key = git_derived_workspace_key(cwd); if is_unsafe_trust_root(&key) { return canonicalize_or_owned(cwd); } key } /// Git-topology-derived workspace key (pre-safety-guard); see [`workspace_key`], /// which rejects an over-broad derived root in favor of the cwd. fn git_derived_workspace_key(cwd: &Path) -> PathBuf { // A grok-managed worktree (any creation mode, incl. standalone clones git // can't link) collapses onto its recorded source repo so trust is shared. if let Some(source_repo) = crate::worktree::source_repo_for_cwd(&cwd.to_string_lossy()) { // Key on the source repo's git ROOT so every worktree of one repo shares // ONE key regardless of the subdir grok -w was launched from (parity with // the git-topology branch below). Fall back to the recorded path when the // source repo is gone (deleted-source standalone worktrees still work). let root = git2::Repository::discover(&source_repo) .ok() .and_then(|r| r.workdir().map(canonicalize_or_owned)); return root.unwrap_or_else(|| canonicalize_or_owned(&source_repo)); } if let Ok(repo) = git2::Repository::discover(cwd) { // Share one trust key across a repo's worktrees instead of re-prompting per worktree. if repo.is_worktree() && let Ok(main) = git2::Repository::open(repo.commondir()) && let Some(main_workdir) = main.workdir() && canonicalize_or_owned(&main_workdir.join(".git")) == canonicalize_or_owned(repo.commondir()) { return canonicalize_or_owned(main_workdir); } if let Some(workdir) = repo.workdir() { return canonicalize_or_owned(workdir); } } canonicalize_or_owned(cwd) } /// Whether `path` resolves to the user's home directory. pub fn is_home_dir(path: &Path) -> bool { let Some(home) = dirs::home_dir() else { return false; }; canonicalize_or_owned(path) == canonicalize_or_owned(&home) } /// Whether `key` is too broad to ever be a safe trust root — refused on write /// and ignored on read (fail closed). /// /// Each case would trust huge swaths of the filesystem via the subdirectory /// cascade in [`TrustStore::is_trusted`]: /// - **empty / relative** — the empty path is a prefix of every path, so it /// would trust everything (`is_absolute()` is false for these); /// - **filesystem root** (`/`) — `parent()` is `None`, and the root is a prefix /// of every absolute path, so it would trust the entire filesystem; /// - **home directory** — would trust every repository checked out under /// `$HOME`. /// /// Also consumed by [`crate::folder_trust`] as the "key can never be recorded" /// signal: such a key can't be durably gated, so it resolves Trusted instead of /// prompting on a decision that could never persist. Public because the shell's /// revoke path refuses the same roots symmetrically — an in-process cache deny /// for a key the store can never grant would be unliftable. pub fn is_unsafe_trust_root(key: &Path) -> bool { !key.is_absolute() || key.parent().is_none() || is_home_dir(key) } fn canonicalize_or_owned(path: &Path) -> PathBuf { dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) } fn now_unix() -> Option { SystemTime::now() .duration_since(UNIX_EPOCH) .ok() .map(|d| d.as_secs() as i64) } /// RAII exclusive advisory lock on a sidecar lock file, released on drop. /// /// Serializes concurrent `TrustStore` writers (multiple processes / instances /// sharing `~/.grok/`) across the whole read-modify-write so updates merge /// instead of clobbering each other. The lock is advisory; only writers that /// take it (i.e. this code) coordinate, which is sufficient since this store is /// the sole writer of its file. struct ExclusiveLock { file: std::fs::File, } impl ExclusiveLock { fn acquire(lock_path: &Path) -> io::Result { let file = std::fs::OpenOptions::new() .read(true) .write(true) .create(true) .truncate(false) .open(lock_path)?; fs2::FileExt::lock_exclusive(&file)?; Ok(Self { file }) } } impl Drop for ExclusiveLock { fn drop(&mut self) { // Best-effort unlock; the OS also releases the flock when `file` closes. let _ = fs2::FileExt::unlock(&self.file); } } /// One-time migration of legacy project-hook trust grants into the unified /// folder-trust store. Idempotent and guarded to run at most once per process. /// /// The legacy `~/.grok/trusted-hook-projects` file listed one canonical project /// path per line; each becomes a folder-trust grant so the unified gate honors /// prior decisions. The legacy file is then renamed to `*.migrated` so it is /// read only once. A no-op when the legacy file is absent/already migrated or no /// user grok home resolves. pub fn migrate_legacy_hook_trust() { // Local/dev builds do NO trust-store I/O: skip the load + legacy-file rename. if crate::folder_trust::folder_trust_inert() { return; } static MIGRATED: Once = Once::new(); MIGRATED.call_once(|| { let Some(legacy_file) = xai_grok_hooks::trust::legacy_trust_file_path() else { return; }; let mut store = TrustStore::load(); let migrated = migrate_legacy_hook_trust_in(&legacy_file, &mut store); if migrated > 0 { tracing::info!( migrated, "migrated legacy hook-trust grants into folder-trust" ); } }); } /// Seam for [`migrate_legacy_hook_trust`] with explicit paths, so the migration /// is testable without the process-global grok-home cache. Returns the number /// of grants seeded into `store`. fn migrate_legacy_hook_trust_in(legacy_file: &Path, store: &mut TrustStore) -> usize { // A read error must NOT be mistaken for "no grants": bail without renaming so // a transient/permission failure can't permanently consume the legacy file. let projects = match xai_grok_hooks::trust::list_trusted_projects_with_file(legacy_file) { Ok(p) => p, Err(e) => { tracing::warn!( path = %legacy_file.display(), error = %e, "failed to read legacy hook-trust file; leaving it in place for a future run" ); return 0; } }; let mut migrated = 0; let mut had_seed_error = false; for project in &projects { // Never override an existing decision: a folder the user has since trusted // or untrusted keeps that decision, so a re-run after a rename failure // can't silently re-trust a folder the user untrusted in between. if store.has_decision(project) { continue; } if let Err(e) = store.set_trusted(project) { tracing::warn!( path = %project.display(), error = %e, "failed to migrate a legacy hook-trust grant" ); // A seeding WRITE failure (e.g. a full disk) dropped this grant: leave // the legacy file in place below so a future run retries it. had_seed_error = true; continue; } // set_trusted silently refuses over-broad roots (records nothing), so // count only folders actually seeded. if store.has_decision(project) { migrated += 1; } } // Rename so the legacy file is consumed exactly once, reached only after a // SUCCESSFUL read AND with every grant seeded — a seeding write error leaves // the file in place for a future run (mirrors the read-error bail). Idempotent: // skipped when already migrated/absent. if had_seed_error { tracing::warn!( path = %legacy_file.display(), "leaving legacy hook-trust file in place after a seeding error; a future run will retry" ); } else if legacy_file.exists() { let migrated_file = legacy_file.with_extension("migrated"); if let Err(e) = std::fs::rename(legacy_file, &migrated_file) { tracing::warn!( path = %legacy_file.display(), error = %e, "failed to rename legacy hook-trust file after migration" ); } } migrated } #[cfg(test)] mod tests { use super::*; #[test] fn migrate_legacy_hook_trust_seeds_store_and_renames_file() { let tmp = tempfile::tempdir().unwrap(); let store_path = tmp.path().join(TRUST_FILE_NAME); // A real project dir so set_trusted's canonicalize succeeds. let project = tmp.path().join("repo"); std::fs::create_dir_all(&project).unwrap(); let project_key = canonicalize_or_owned(&project); // Legacy file with one canonical project path. let legacy = tmp.path().join("trusted-hook-projects"); std::fs::write(&legacy, format!("{}\n", project_key.display())).unwrap(); let mut store = TrustStore::load_from(store_path.clone()); let migrated = migrate_legacy_hook_trust_in(&legacy, &mut store); assert_eq!(migrated, 1); assert!(store.is_trusted(&project_key), "migrated grant is trusted"); // Legacy file renamed so it is read only once. assert!(!legacy.exists(), "legacy file consumed"); assert!( legacy.with_extension("migrated").exists(), "renamed to .migrated" ); // The grant persisted to disk. let reloaded = TrustStore::load_from(store_path); assert!(reloaded.is_trusted(&project_key)); } #[test] fn migrate_legacy_hook_trust_does_not_override_existing_decision() { let tmp = tempfile::tempdir().unwrap(); let store_path = tmp.path().join(TRUST_FILE_NAME); let project = tmp.path().join("repo"); std::fs::create_dir_all(&project).unwrap(); let project_key = canonicalize_or_owned(&project); let legacy = tmp.path().join("trusted-hook-projects"); std::fs::write(&legacy, format!("{}\n", project_key.display())).unwrap(); // The user has ALREADY untrusted this folder in the unified store. let mut store = TrustStore::load_from(store_path); store.set_untrusted(&project_key).unwrap(); let migrated = migrate_legacy_hook_trust_in(&legacy, &mut store); assert_eq!(migrated, 0, "an already-decided folder is not re-seeded"); assert!( !store.is_trusted(&project_key), "the user's untrust decision is preserved, not overridden by migration" ); // The legacy file is still consumed (renamed) so it is read only once. assert!(!legacy.exists()); assert!(legacy.with_extension("migrated").exists()); } #[test] fn migrate_legacy_hook_trust_is_noop_when_file_absent() { let tmp = tempfile::tempdir().unwrap(); let store_path = tmp.path().join(TRUST_FILE_NAME); let legacy = tmp.path().join("trusted-hook-projects"); // never created let mut store = TrustStore::load_from(store_path); let migrated = migrate_legacy_hook_trust_in(&legacy, &mut store); assert_eq!(migrated, 0); assert!(store.is_empty(), "nothing recorded"); assert!( !legacy.with_extension("migrated").exists(), "no rename without source" ); } #[test] fn migrate_legacy_hook_trust_leaves_unreadable_file_in_place() { // A legacy file that EXISTS but can't be read must not be consumed: a // transient read error would otherwise rename it and permanently drop // every grant. Use a directory at the legacy path — it `exists()` but // `read_to_string` errors (non-NotFound), portably simulating the failure. let tmp = tempfile::tempdir().unwrap(); let store_path = tmp.path().join(TRUST_FILE_NAME); let legacy = tmp.path().join("trusted-hook-projects"); std::fs::create_dir_all(&legacy).unwrap(); let mut store = TrustStore::load_from(store_path); let migrated = migrate_legacy_hook_trust_in(&legacy, &mut store); assert_eq!(migrated, 0, "an unreadable legacy file seeds nothing"); assert!(store.is_empty(), "nothing recorded on a read failure"); assert!(legacy.exists(), "unreadable legacy file is left in place"); assert!( !legacy.with_extension("migrated").exists(), "unreadable legacy file must not be consumed/renamed" ); } #[test] fn migrate_legacy_hook_trust_leaves_file_in_place_on_seed_write_error() { // A seeding WRITE failure (e.g. a full disk) must not consume the legacy // file either: leave it un-renamed so a future run retries the grants. // Force set_trusted to error by making the store path a DIRECTORY so its // atomic persist rename fails (same trick as // `persist_failure_leaves_memory_unchanged`, robust even when run as root). let tmp = tempfile::tempdir().unwrap(); let store_path = tmp.path().join(TRUST_FILE_NAME); std::fs::create_dir_all(&store_path).unwrap(); // store path is a dir, not a file let project = tmp.path().join("repo"); std::fs::create_dir_all(&project).unwrap(); let project_key = canonicalize_or_owned(&project); let legacy = tmp.path().join("trusted-hook-projects"); std::fs::write(&legacy, format!("{}\n", project_key.display())).unwrap(); let mut store = TrustStore::load_from(store_path); let migrated = migrate_legacy_hook_trust_in(&legacy, &mut store); assert_eq!(migrated, 0, "a seeding write error seeds nothing"); assert!( legacy.exists(), "legacy file is left in place on a seeding write error" ); assert!( !legacy.with_extension("migrated").exists(), "a seeding write error must not consume/rename the legacy file" ); } #[test] fn empty_store_trusts_nothing() { let tmp = tempfile::tempdir().unwrap(); let store = TrustStore::load_from(tmp.path().join(TRUST_FILE_NAME)); assert!(store.is_empty()); assert!(!store.is_trusted(tmp.path())); } #[test] fn default_path_in_maps_home_and_preserves_no_home() { // With a resolvable home the store sits at /trusted_folders.toml. let home = PathBuf::from("/home/alice/.grok"); assert_eq!( TrustStore::default_path_in(Some(home.clone())), Some(home.join(TRUST_FILE_NAME)) ); // With NO resolvable home the path is `None` — never a synthesized // fallback. This is the regression guard that keeps the store off the // cwd-relative `./.grok` that grok_home() would invent, which is exactly // how a cloned repo's own `/.grok/trusted_folders.toml` could // masquerade as the user-global store and self-trust the checkout. assert_eq!(TrustStore::default_path_in(None), None); } #[test] fn default_path_sources_from_user_grok_home() { // Thin source-pin: the production accessor reads user_grok_home() // (Option, no cwd fallback), not grok_home(). The real regression guard // is the seam test above (default_path_in(None) == None). assert_eq!( TrustStore::default_path(), xai_grok_config::user_grok_home().map(|h| h.join(TRUST_FILE_NAME)) ); } #[test] fn no_home_store_trusts_nothing_and_persists_nothing() { // Simulate the no-home environment where `default_path()` is `None`: // `load()` yields `empty()`, a store with no backing path. It must // trust nothing and silently no-op on writes — never touching a // cwd-relative `./.grok`. let mut store = TrustStore::empty(); assert!(store.is_empty()); let key = Path::new("/some/abs/repo"); assert!(!store.is_trusted(key), "no-home store trusts nothing"); // set_trusted is a no-op that returns Ok and records nothing. store .set_trusted(key) .expect("no-home set_trusted is a no-op Ok"); assert!( store.is_empty(), "no-home set_trusted must record nothing (in memory)" ); assert!( !store.is_trusted(key), "still trusts nothing after the no-op write" ); // set_untrusted likewise no-ops without panicking or recording. store .set_untrusted(key) .expect("no-home set_untrusted is a no-op Ok"); assert!(store.is_empty()); } #[test] fn set_trusted_round_trips() { let tmp = tempfile::tempdir().unwrap(); let store_path = tmp.path().join(TRUST_FILE_NAME); let repo = tmp.path().join("repo"); std::fs::create_dir_all(&repo).unwrap(); let key = canonicalize_or_owned(&repo); let mut store = TrustStore::load_from(store_path.clone()); assert!(!store.is_trusted(&key)); store.set_trusted(&key).unwrap(); assert!(store.is_trusted(&key)); // Reload from disk and verify persistence. let reloaded = TrustStore::load_from(store_path); assert!(reloaded.is_trusted(&key)); } #[test] fn persist_overwrites_existing_and_round_trips_both() { let tmp = tempfile::tempdir().unwrap(); let store_path = tmp.path().join(TRUST_FILE_NAME); let repo_a = tmp.path().join("repo-a"); let repo_b = tmp.path().join("repo-b"); std::fs::create_dir_all(&repo_a).unwrap(); std::fs::create_dir_all(&repo_b).unwrap(); let key_a = canonicalize_or_owned(&repo_a); let key_b = canonicalize_or_owned(&repo_b); let mut store = TrustStore::load_from(store_path.clone()); store.set_trusted(&key_a).unwrap(); // The second persist runs over an already-existing destination file. store.set_trusted(&key_b).unwrap(); // Both decisions survive the overwrite, after reloading from disk. let reloaded = TrustStore::load_from(store_path.clone()); assert!(reloaded.is_trusted(&key_a)); assert!(reloaded.is_trusted(&key_b)); // The owner-only guarantee still holds after the overwrite, independent // of umask (NamedTempFile creates 0600 on Unix regardless of umask). #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mode = std::fs::metadata(&store_path).unwrap().permissions().mode(); assert_eq!( mode & 0o777, 0o600, "trust store must stay 0600 after overwrite" ); } } #[test] fn trust_cascades_to_subdirectories() { let tmp = tempfile::tempdir().unwrap(); let repo = tmp.path().join("repo"); let child = repo.join("crates").join("inner"); std::fs::create_dir_all(&child).unwrap(); let repo_key = canonicalize_or_owned(&repo); let child_key = canonicalize_or_owned(&child); let mut store = TrustStore::load_from(tmp.path().join(TRUST_FILE_NAME)); store.set_trusted(&repo_key).unwrap(); // Child dir is trusted because an ancestor (the repo root) is trusted. assert!(store.is_trusted(&child_key)); // A sibling outside the trusted root is NOT trusted. let sibling = canonicalize_or_owned(tmp.path()).join("other-repo"); assert!(!store.is_trusted(&sibling)); // A sibling that *string*-prefixes the trusted root must NOT be trusted: // the cascade is component-wise (`Path::starts_with`), so `…/repo` must // not trust `…/repo-sibling` or `…/repository`. let prefix_sibling = canonicalize_or_owned(tmp.path()).join("repo-sibling"); assert!( !store.is_trusted(&prefix_sibling), "string-prefix sibling must NOT be trusted (cascade is component-wise)" ); } #[test] fn most_specific_decision_wins_over_ancestor_cascade() { // An explicit child untrust must override a trusted ancestor (the bug // where an untrust was undone by the cascade on the next reload). The // longest-prefix match decides, so siblings of the untrusted child stay // trusted via the ancestor. let tmp = tempfile::tempdir().unwrap(); let parent = tmp.path().join("parent"); let child = parent.join("child"); let other = parent.join("other"); std::fs::create_dir_all(&child).unwrap(); std::fs::create_dir_all(&other).unwrap(); let parent_key = canonicalize_or_owned(&parent); let child_key = canonicalize_or_owned(&child); let other_key = canonicalize_or_owned(&other); let mut store = TrustStore::load_from(tmp.path().join(TRUST_FILE_NAME)); store.set_trusted(&parent_key).unwrap(); store.set_untrusted(&child_key).unwrap(); assert!(store.is_trusted(&parent_key), "the ancestor stays trusted"); assert!( !store.is_trusted(&child_key), "an explicit child untrust overrides the trusted ancestor" ); assert!( !store.is_trusted(&child_key.join("nested")), "the untrust cascades to the child's own subdirectories" ); assert!( store.is_trusted(&other_key), "a sibling without its own decision is still trusted via the ancestor" ); // The most-specific-wins decision survives a reload from disk. let reloaded = TrustStore::load_from(tmp.path().join(TRUST_FILE_NAME)); assert!(!reloaded.is_trusted(&child_key)); assert!(reloaded.is_trusted(&other_key)); } #[test] fn most_specific_trust_wins_over_untrusted_ancestor() { // The symmetric half of most-specific-wins: an UNTRUSTED ancestor plus a // nearer TRUSTED child => the child IS trusted (the nearer decision wins), // and that trust cascades to the child's own subdirectories. let tmp = tempfile::tempdir().unwrap(); let parent = tmp.path().join("parent"); let child = parent.join("child"); std::fs::create_dir_all(&child).unwrap(); let parent_key = canonicalize_or_owned(&parent); let child_key = canonicalize_or_owned(&child); let mut store = TrustStore::load_from(tmp.path().join(TRUST_FILE_NAME)); store.set_untrusted(&parent_key).unwrap(); store.set_trusted(&child_key).unwrap(); assert!( !store.is_trusted(&parent_key), "the ancestor stays untrusted" ); assert!( store.is_trusted(&child_key), "a nearer explicit trust overrides the untrusted ancestor" ); assert!( store.is_trusted(&child_key.join("nested")), "the child's trust cascades to its own subdirectories" ); // The decision survives a reload from disk. let reloaded = TrustStore::load_from(tmp.path().join(TRUST_FILE_NAME)); assert!(!reloaded.is_trusted(&parent_key)); assert!(reloaded.is_trusted(&child_key)); } #[cfg(unix)] #[test] fn persisted_file_is_owner_only() { use std::os::unix::fs::PermissionsExt; let tmp = tempfile::tempdir().unwrap(); let store_path = tmp.path().join(TRUST_FILE_NAME); let repo = tmp.path().join("repo"); std::fs::create_dir_all(&repo).unwrap(); let mut store = TrustStore::load_from(store_path.clone()); store.set_trusted(&canonicalize_or_owned(&repo)).unwrap(); let mode = std::fs::metadata(&store_path).unwrap().permissions().mode(); assert_eq!(mode & 0o777, 0o600, "trust store must be 0600"); } #[test] fn home_dir_is_not_persisted() { // Serialize with the in-file $HOME mutator: its temp $HOME window could // otherwise flip is_home_dir mid-test. This test mutates no env itself. let _lock = crate::ENV_TEST_LOCK .lock() .unwrap_or_else(|e| e.into_inner()); let tmp = tempfile::tempdir().unwrap(); let store_path = tmp.path().join(TRUST_FILE_NAME); let Some(home) = dirs::home_dir() else { return; // no home dir in this environment; nothing to assert }; let mut store = TrustStore::load_from(store_path.clone()); store.set_trusted(&home).unwrap(); // Nothing was persisted, and the store still holds no folders. assert!(store.is_empty(), "home dir must not be recorded"); assert!( !store_path.exists(), "no trust file should be written for the home dir" ); } #[test] fn workspace_key_falls_back_to_cwd_outside_repo() { // A freshly created temp dir is not inside a git repo in CI sandboxes; // the key should be the canonicalized dir itself. let tmp = tempfile::tempdir().unwrap(); let sub = tmp.path().join("plain"); std::fs::create_dir_all(&sub).unwrap(); let key = workspace_key(&sub); assert!(key.is_absolute()); // Only pin the fallback when the temp dir is genuinely outside any git // repo (a dev/CI checkout may place $TMPDIR inside the source repository). if git2::Repository::discover(&sub).is_err() { assert_eq!(key, canonicalize_or_owned(&sub)); } } #[test] fn workspace_key_ignores_home_git_repo_for_subdir() { // Home-is-a-git-repo (dotfiles in $HOME): a subdir launched from under // home must key trust on the SUBDIR, not on $HOME — even though the git // up-walk discovers home as the repo root. Serialize + guard $HOME // (dirs::home_dir reads it) via the crate-shared env lock. let _lock = crate::ENV_TEST_LOCK .lock() .unwrap_or_else(|e| e.into_inner()); let home = tempfile::tempdir().unwrap(); let _home_guard = crate::TestEnvGuard::set("HOME", home.path()); git2::Repository::init(home.path()).unwrap(); let civ = home.path().join("Documents").join("civ"); std::fs::create_dir_all(&civ).unwrap(); let key = workspace_key(&civ); assert_eq!( key, canonicalize_or_owned(&civ), "a subdir under a home git repo must key on the subdir, not $HOME" ); assert!( !is_home_dir(&key), "the workspace key must never resolve to the home dir" ); } #[test] fn empty_key_is_not_trusted() { // Fail closed: a degenerate `[folders.""] trusted = true` must not trust // anything. The empty path is a prefix of every path, so honoring it // would trust the whole filesystem (fail open). let tmp = tempfile::tempdir().unwrap(); let store_path = tmp.path().join(TRUST_FILE_NAME); std::fs::write(&store_path, "[folders.\"\"]\ntrusted = true\n").unwrap(); let store = TrustStore::load_from(store_path); // The record loads, so this exercises the read-side guard (not a parse drop). assert!(!store.is_empty(), "empty-key record should still load"); assert!( !store.is_trusted(Path::new("/some/arbitrary/path")), "an empty key must not trust the filesystem" ); } #[test] fn malformed_store_fails_soft_to_empty() { // Corrupt TOML must fail closed: empty store, trust nothing, no panic. let tmp = tempfile::tempdir().unwrap(); let store_path = tmp.path().join(TRUST_FILE_NAME); std::fs::write(&store_path, "this is not = valid toml [[[").unwrap(); let store = TrustStore::load_from(store_path); assert!(store.is_empty(), "malformed store must load as empty"); assert!(!store.is_trusted(Path::new("/any/path"))); } #[test] fn root_key_is_not_trusted() { // Fail closed: a `[folders."/"]` record must not trust every absolute // path. The root is a prefix of all of them via the cascade, so it is // ignored on read even if it reaches the file by hand-edit / migration. let tmp = tempfile::tempdir().unwrap(); let store_path = tmp.path().join(TRUST_FILE_NAME); std::fs::write(&store_path, "[folders.\"/\"]\ntrusted = true\n").unwrap(); let store = TrustStore::load_from(store_path); assert!(!store.is_empty(), "root-key record should still load"); assert!( !store.is_trusted(Path::new("/any/abs/path")), "filesystem root must never be honored as a trust key" ); } #[test] fn tied_conflicting_aliases_fail_closed() { // Two equal-depth, non-canonical aliases of the SAME folder with CONFLICTING // decisions: `Path::components()` normalizes the trailing slash so `/a/b` and // `/a/b/` tie on depth, yet they load as distinct map keys. The tie branch // ANDs the tied records, so any untrusted tied alias forces a fail-closed // `false` REGARDLESS of map order. Asserting BOTH orderings is what pins // this: a revert to a last-wins form (return the LAST equal-depth record, // i.e. `/a/b/`) returns `true` for ordering (b) below and fails this test, // whereas pinning a single ordering would pass under both the AND-loop and // the buggy last-wins form. let fails_closed = |trusted_ab: bool, trusted_ab_slash: bool| { let tmp = tempfile::tempdir().unwrap(); let store_path = tmp.path().join(TRUST_FILE_NAME); std::fs::write( &store_path, format!( "[folders.'/a/b']\ntrusted = {trusted_ab}\n\ [folders.'/a/b/']\ntrusted = {trusted_ab_slash}\n" ), ) .unwrap(); let store = TrustStore::load_from(store_path); assert_eq!(store.len(), 2, "both alias records should load distinctly"); // `/a/b/c` does not exist, so `canonicalize_or_owned` is a no-op; both // aliases prefix it and tie on depth. !store.is_trusted(Path::new("/a/b/c")) }; // (a) untrusted alias sorts LAST (`/a/b/`): caught by a revert to the // original `any(trusted)` form, but NOT by a last-wins revert. assert!( fails_closed(true, false), "tie with `/a/b` trusted + `/a/b/` untrusted must fail closed" ); // (b) untrusted alias sorts FIRST (`/a/b`): a last-wins revert returns the // last record (`/a/b/` = trusted) => true, so THIS ordering is what // catches a last-wins regression; the AND-loop still yields false. assert!( fails_closed(false, true), "tie with `/a/b` untrusted + `/a/b/` trusted must STILL fail closed" ); } #[test] fn home_key_on_disk_is_not_honored() { // Serialize with the in-file $HOME mutator: its temp $HOME window could // otherwise flip is_home_dir mid-test. This test mutates no env itself. let _lock = crate::ENV_TEST_LOCK .lock() .unwrap_or_else(|e| e.into_inner()); // A hand-edited / migrated `[folders.""]` record must not trust // repos under $HOME — the read side ignores it, matching set_trusted. let Some(home) = dirs::home_dir() else { return; // no home dir in this environment; nothing to assert }; let canonical_home = canonicalize_or_owned(&home); let tmp = tempfile::tempdir().unwrap(); let store_path = tmp.path().join(TRUST_FILE_NAME); // TOML literal-string key avoids escaping issues on any platform. let body = format!( "[folders.'{}']\ntrusted = true\n", canonical_home.to_string_lossy() ); std::fs::write(&store_path, body).unwrap(); let store = TrustStore::load_from(store_path); let sub = canonical_home.join("some").join("sub"); assert!( !store.is_trusted(&sub), "a home-dir key on disk must not be honored" ); } #[cfg(unix)] #[test] fn set_trusted_canonicalizes_key() { use std::os::unix::fs::symlink; let tmp = tempfile::tempdir().unwrap(); let store_path = tmp.path().join(TRUST_FILE_NAME); let real = tmp.path().join("real-repo"); std::fs::create_dir_all(&real).unwrap(); let link = tmp.path().join("link-repo"); symlink(&real, &link).unwrap(); // Trust via the symlink alias. let mut store = TrustStore::load_from(store_path); store.set_trusted(&link).unwrap(); // The stored key was canonicalized, so a canonical lookup matches. let canonical_real = canonicalize_or_owned(&real); assert!( store.is_trusted(&canonical_real), "set_trusted must store the canonical path so canonical lookups match" ); } #[test] fn set_untrusted_records_explicit_deny() { let tmp = tempfile::tempdir().unwrap(); let store_path = tmp.path().join(TRUST_FILE_NAME); let repo = tmp.path().join("repo"); std::fs::create_dir_all(&repo).unwrap(); let key = canonicalize_or_owned(&repo); let mut store = TrustStore::load_from(store_path.clone()); store.set_untrusted(&key).unwrap(); assert!(!store.is_trusted(&key), "an explicit deny is not trusted"); assert!(!store.is_empty(), "the deny decision is recorded"); // Reload from disk: the deny record persisted. let reloaded = TrustStore::load_from(store_path); assert!(!reloaded.is_trusted(&key)); assert!(!reloaded.is_empty(), "deny record survives reload"); } #[test] fn trust_decision_flips() { let tmp = tempfile::tempdir().unwrap(); let store_path = tmp.path().join(TRUST_FILE_NAME); let repo = tmp.path().join("repo"); std::fs::create_dir_all(&repo).unwrap(); let key = canonicalize_or_owned(&repo); let mut store = TrustStore::load_from(store_path.clone()); store.set_trusted(&key).unwrap(); assert!(store.is_trusted(&key)); store.set_untrusted(&key).unwrap(); assert!(!store.is_trusted(&key), "untrust flips the stored bool"); store.set_trusted(&key).unwrap(); assert!(store.is_trusted(&key), "re-trust flips it back"); // The insert overwrites — one record per folder, no duplicates. assert_eq!(store.len(), 1); let reloaded = TrustStore::load_from(store_path); assert!(reloaded.is_trusted(&key)); assert_eq!(reloaded.len(), 1); } #[cfg(unix)] #[test] fn is_trusted_canonicalizes_query() { use std::os::unix::fs::symlink; let tmp = tempfile::tempdir().unwrap(); let store_path = tmp.path().join(TRUST_FILE_NAME); let real = tmp.path().join("real-repo"); std::fs::create_dir_all(&real).unwrap(); let link = tmp.path().join("link-repo"); symlink(&real, &link).unwrap(); let mut store = TrustStore::load_from(store_path); store.set_trusted(&canonicalize_or_owned(&real)).unwrap(); // A query via the symlink alias resolves to the trusted real dir. assert!( store.is_trusted(&link), "is_trusted must canonicalize the query so a symlink alias matches" ); } #[test] fn concurrent_writers_do_not_clobber() { let tmp = tempfile::tempdir().unwrap(); let store_path = tmp.path().join(TRUST_FILE_NAME); let repo_a = tmp.path().join("repo-a"); let repo_b = tmp.path().join("repo-b"); std::fs::create_dir_all(&repo_a).unwrap(); std::fs::create_dir_all(&repo_b).unwrap(); let key_a = canonicalize_or_owned(&repo_a); let key_b = canonicalize_or_owned(&repo_b); // Two instances loaded while the file is empty: both start with an empty // in-memory doc, mimicking two processes that raced the initial load. let mut s1 = TrustStore::load_from(store_path.clone()); let mut s2 = TrustStore::load_from(store_path.clone()); s1.set_trusted(&key_a).unwrap(); s2.set_trusted(&key_b).unwrap(); // The locked re-read-merge means s2's write did not clobber s1's. let reloaded = TrustStore::load_from(store_path); assert!( reloaded.is_trusted(&key_a), "A must survive a concurrent write" ); assert!( reloaded.is_trusted(&key_b), "B must survive a concurrent write" ); } #[cfg(unix)] #[test] fn persist_failure_leaves_memory_unchanged() { // Make the destination path itself a DIRECTORY so the final atomic // rename in persist fails (renaming a file over a directory). This is // robust even when tests run as root (a chmod 0o500 dir would be // bypassed by root), and it exercises the invariant: on a write error // the in-memory doc is left unchanged (memory-before-persist fix). let tmp = tempfile::tempdir().unwrap(); let store_path = tmp.path().join(TRUST_FILE_NAME); std::fs::create_dir_all(&store_path).unwrap(); // store path is a dir, not a file let repo = tmp.path().join("repo"); std::fs::create_dir_all(&repo).unwrap(); let key = canonicalize_or_owned(&repo); let mut store = TrustStore::load_from(store_path); let result = store.set_trusted(&key); assert!( result.is_err(), "persist over a directory destination must fail" ); assert!( !store.is_trusted(&key), "memory must be unchanged on persist failure" ); } #[test] fn workspace_key_collapses_linked_worktrees_onto_main_checkout() { // Every linked `grok -w` worktree of a repo must share ONE trust key: // its main checkout's root. Build a real repo + two linked worktrees and // assert each collapses onto the main checkout (so it is trusted once, // not re-prompted per worktree). let dir = tempfile::tempdir().unwrap(); let main = dir.path().join("main"); std::fs::create_dir_all(&main).unwrap(); let repo = git2::Repository::init(&main).unwrap(); // Worktree creation requires a valid HEAD, so make an initial commit. let sig = git2::Signature::now("t", "t@t").unwrap(); let tree = { let mut idx = repo.index().unwrap(); let oid = idx.write_tree().unwrap(); repo.find_tree(oid).unwrap() }; repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]) .unwrap(); // Linked worktrees OUTSIDE the main dir (git2 creates the paths). let wt1 = dir.path().join("wt1"); let wt2 = dir.path().join("wt2"); repo.worktree("wt1", &wt1, None).unwrap(); repo.worktree("wt2", &wt2, None).unwrap(); let main_key = workspace_key(&main); // Parity: the main checkout keys off its own workdir. assert_eq!(main_key, canonicalize_or_owned(&main)); assert_eq!( workspace_key(&wt1), main_key, "worktree must collapse onto main checkout" ); assert_eq!( workspace_key(&wt2), main_key, "second worktree must share the same key" ); } #[test] fn workspace_key_bare_repo_worktree_does_not_widen_to_parent() { // A bare repo's `commondir()` is the bare dir itself, so a naive // `commondir().parent()` would key off the dir CONTAINING the repo and // trust every sibling via the subdirectory cascade. The key must instead // fall back to the worktree's OWN dir (narrow, never widened). let dir = tempfile::tempdir().unwrap(); let bare = dir.path().join("repo.git"); let repo = git2::Repository::init_bare(&bare).unwrap(); // Worktree creation needs a valid HEAD; build an empty commit (bare repo // has no index, so use a treebuilder for the empty tree). let sig = git2::Signature::now("t", "t@t").unwrap(); let tree_oid = repo.treebuilder(None).unwrap().write().unwrap(); let tree = repo.find_tree(tree_oid).unwrap(); repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]) .unwrap(); let wt = dir.path().join("wt"); repo.worktree("wt", &wt, None).unwrap(); let key = workspace_key(&wt); assert_ne!( key, canonicalize_or_owned(dir.path()), "bare-repo worktree key must not widen to the parent dir" ); assert_eq!( key, canonicalize_or_owned(&wt), "bare-repo worktree falls back to its own dir (narrow, safe)" ); } #[test] fn workspace_key_separate_gitdir_worktree_does_not_widen() { // `git init --separate-git-dir` leaves `core.worktree` unset, so the // common gitdir's INFERRED workdir is the PARENT of the relocated gitdir, // not the checkout. The layout guard (`/.git` must equal the // common gitdir) rejects that, so the key falls back to the worktree's // own dir — never widening to the gitdir's parent. let dir = tempfile::tempdir().unwrap(); let checkout = dir.path().join("checkout"); let gitdir = dir.path().join("gitstore"); std::fs::create_dir_all(&checkout).unwrap(); let run = |args: &[&str], cwd: &std::path::Path| { std::process::Command::new("git") .args(args) .current_dir(cwd) .env("GIT_AUTHOR_NAME", "t") .env("GIT_AUTHOR_EMAIL", "t@t") .env("GIT_COMMITTER_NAME", "t") .env("GIT_COMMITTER_EMAIL", "t@t") .status() .map(|s| s.success()) .unwrap_or(false) }; // If git isn't usable for this layout, skip rather than false-fail. if !run( &[ "init", "--separate-git-dir", gitdir.to_str().unwrap(), checkout.to_str().unwrap(), ], dir.path(), ) || !run(&["commit", "--allow-empty", "-m", "init"], &checkout) { return; } let wt = dir.path().join("wt"); if !run(&["worktree", "add", wt.to_str().unwrap()], &checkout) { return; } // Only assert once the worktree is a real linked worktree whose common // gitdir is the relocated separate gitdir (the layout this test targets). let Ok(repo) = git2::Repository::discover(&wt) else { return; }; if !repo.is_worktree() { return; } let key = workspace_key(&wt); // The invariant: the key is NOT a broad ancestor of the checkout. assert_ne!( key, canonicalize_or_owned(dir.path()), "separate-gitdir worktree key must not widen to the gitdir's parent" ); // It falls back to the worktree's own dir (narrow, safe). assert_eq!( key, canonicalize_or_owned(&wt), "separate-gitdir worktree falls back to its own dir (narrow, safe)" ); } // ── workspace_key registry collapse (grok-managed worktrees) ───────── // Crate-shared env lock + env guards bundled as ONE value so the env restores // before the lock releases by struct field order (see lib.rs), regardless of // how the caller binds the fixture's return. use crate::LockedTestEnv; /// Point `GROK_HOME` at an isolated tempdir and register one grok-managed /// worktree at `/worktrees/repo/` recording `source_repo` and /// `creation_mode`. The worktree dir is a PLAIN directory — NOT a git linked /// worktree — so only the registry can collapse it. Returns `(env, worktree /// dir)`; the [`LockedTestEnv`] holds the lock and restores `GROK_HOME` on /// drop (before releasing the lock), so the caller may bind it any way. fn register_grok_worktree( temp: &tempfile::TempDir, name: &str, source_repo: &Path, creation_mode: &str, ) -> (LockedTestEnv, PathBuf) { use xai_fast_worktree::{WorktreeDb, WorktreeKind, WorktreeRecord, WorktreeStatus}; // Canonicalize so macOS /var -> /private/var agrees between the stored // record path and the canonicalized lookup query. let root = dunce::canonicalize(temp.path()).unwrap(); let home = root.join("grok-home"); let wt = home.join("worktrees").join("repo").join(name); std::fs::create_dir_all(&wt).unwrap(); // Acquire the lock, then set the env under it (LockedTestEnv restores the // env before releasing the lock on drop). let env = LockedTestEnv::lock().set("GROK_HOME", &home); let db = WorktreeDb::open(&home).unwrap(); let record = WorktreeRecord { id: name.to_string(), path: wt.clone(), source_repo: source_repo.to_path_buf(), repo_name: "repo".to_string(), kind: WorktreeKind::Session, creation_mode: creation_mode.to_string(), git_ref: None, head_commit: None, session_id: None, creator_pid: None, created_at: 100, last_accessed_at: None, status: WorktreeStatus::Alive, metadata: None, }; db.register(&record).unwrap(); (env, wt) } #[test] fn workspace_key_collapses_standalone_grok_worktree_onto_source_repo() { // A standalone worktree is a full clone with its OWN `.git`, so git // topology can't link it to its source; the registry (worktrees.db) must // collapse it onto the recorded source repo so trust is shared. The // worktree dir is a plain dir (no git), proving the REGISTRY path — not // git topology — does the collapse. `source_repo` is a real git repo (as // in production), so the git-root normalization is deterministic // regardless of where `$TMPDIR` lives. let temp = tempfile::TempDir::new().unwrap(); let root = dunce::canonicalize(temp.path()).unwrap(); let source_repo = root.join("source-repo"); std::fs::create_dir_all(&source_repo).unwrap(); git2::Repository::init(&source_repo).unwrap(); let (_env, wt) = register_grok_worktree(&temp, "wt", &source_repo, "standalone"); let expected = canonicalize_or_owned(&source_repo); assert_eq!( workspace_key(&wt), expected, "a standalone grok worktree must collapse onto its recorded source repo" ); // A cwd nested below the worktree root collapses onto the same key (the // registry walk ascends to the registered worktree). let nested = wt.join("crates").join("inner"); std::fs::create_dir_all(&nested).unwrap(); assert_eq!( workspace_key(&nested), expected, "a nested cwd in the worktree collapses onto the same source repo" ); } #[test] fn workspace_key_collapses_worktree_onto_source_repo_git_root() { // The registry records `source_repo` as the launch cwd, which may be a // SUBDIR of the repo. workspace_key must key on the repo's git ROOT so a // worktree launched from a subdir shares ONE key with the source and // linked worktrees (which key on the root), not on `/sub`. let temp = tempfile::TempDir::new().unwrap(); let root = dunce::canonicalize(temp.path()).unwrap(); let repo = root.join("realrepo"); std::fs::create_dir_all(&repo).unwrap(); git2::Repository::init(&repo).unwrap(); let subdir = repo.join("crates").join("sub"); std::fs::create_dir_all(&subdir).unwrap(); let (_env, wt) = register_grok_worktree(&temp, "wt", &subdir, "standalone"); assert_eq!( workspace_key(&wt), canonicalize_or_owned(&repo), "source_repo recorded as a subdir must collapse onto the repo git root" ); } #[test] fn workspace_key_ignores_registry_for_cwd_outside_worktrees_dir() { // A populated registry must NOT collapse a cwd OUTSIDE // `/worktrees`: `worktree_record_for_cwd` skips the registry // there, so the key falls back to git/cwd. Non-vacuous: the registry IS // populated with a real git source repo that WOULD be returned for a // worktree cwd, and `outside` is its OWN git repo (under grok HOME but not // under its `worktrees/`) so the fallback is deterministic (no conditional // skip) — we assert the key is `outside`'s own root, never the source repo. let temp = tempfile::TempDir::new().unwrap(); let root = dunce::canonicalize(temp.path()).unwrap(); let source_repo = root.join("source-repo"); std::fs::create_dir_all(&source_repo).unwrap(); git2::Repository::init(&source_repo).unwrap(); let (_env, _wt) = register_grok_worktree(&temp, "wt", &source_repo, "standalone"); // Under grok HOME but NOT under `/worktrees`, and its own git repo. let outside = root.join("grok-home").join("not-worktrees").join("proj"); std::fs::create_dir_all(&outside).unwrap(); git2::Repository::init(&outside).unwrap(); let key = workspace_key(&outside); assert_eq!( key, canonicalize_or_owned(&outside), "a cwd outside /worktrees keys on its own repo root" ); assert_ne!( key, canonicalize_or_owned(&source_repo), "it must not collapse onto the populated registry's source repo" ); } }