git_policy_cli/effective_target.rs
1//! What:
2//! Classify the worktree an invocation targets for worktree-enforcing policies.
3//! Why:
4//! Linked-worktree safeguards discipline a person's repositories,
5//! not the
6//! disposable clones a tool keeps in its own cache.
7//!
8//! In TS you'd write (pseudocode):
9//! ```ts
10//! // classifyEffectiveTarget(identity, allowedDirs) === 'linked-worktree'
11//! ```
12
13/// Import getenv-style lookup and the resolved repository identity.
14use super::child_environment::environment_value;
15use super::worktree_identity::WorktreeIdentity;
16/// What:
17/// `OsString` is owned operating-system text of raw OS bytes (sibling `String`
18/// must be UTF-8).
19/// Why:
20/// Cache directories come from environment values that need not be UTF-8.
21///
22/// In TS you'd write (pseudocode):
23/// ```ts
24/// // process.env values, but byte-preserving.
25/// ```
26use std::ffi::OsString;
27/// `Path`/`PathBuf` are borrowed/owned filesystem paths of raw OS bytes.
28use std::path::{Path, PathBuf};
29
30/// What:
31/// The four targets worktree policies distinguish.
32/// `#[derive(...)]` generates copying,
33/// debug printing and `==`.
34/// Why:
35/// A bare repository or no repository has no worktree to protect;
36/// an
37/// allowlisted tool cache is exempt;
38/// main versus linked decides enforcement.
39///
40/// In TS you'd write (pseudocode):
41/// ```ts
42/// type EffectiveTarget = 'outside-worktree' | 'main-worktree' | 'linked-worktree' | 'allowlisted';
43/// ```
44#[derive(Clone, Copy, Debug, Eq, PartialEq)]
45pub enum EffectiveTarget {
46 /// No worktree:
47 /// outside any repository,
48 /// or a bare repository.
49 OutsideWorktree,
50 /// The repository's main worktree.
51 MainWorktree,
52 /// A linked worktree.
53 LinkedWorktree,
54 /// A repository under a tool-cache directory exempt from enforcement.
55 Allowlisted,
56}
57
58/// What:
59/// Resolve uv's cache directory the way uv does:
60/// `UV_CACHE_DIR`,
61/// then
62/// `XDG_CACHE_HOME/uv`,
63/// then `HOME/.cache/uv`;
64/// an empty value counts as unset.
65/// `Option<&Path>` is "a borrowed home directory or nothing";
66/// `Option<PathBuf>` is "an owned path or nothing".
67/// Why:
68/// uv runs destructive Git inside throwaway clones it owns there.
69/// The home
70/// directory is injected so tests never read the real one.
71///
72/// In TS you'd write (pseudocode):
73/// ```ts
74/// function uvCacheDir(environment: [string, string][], home?: string): string | undefined;
75/// ```
76pub fn uv_cache_dir(environment: &[(OsString, OsString)], home: Option<&Path>) -> Option<PathBuf> {
77 // `if let Some(x) = ... && cond` runs only when the variable is set and the second
78 // condition also holds, like `x !== undefined && x !== ""`.
79 if let Some(explicit) = environment_value(environment, "UV_CACHE_DIR")
80 && !explicit.is_empty()
81 {
82 // `Some(...)` is the "present" variant.
83 return Some(PathBuf::from(explicit));
84 }
85 if let Some(cache_home) = environment_value(environment, "XDG_CACHE_HOME")
86 && !cache_home.is_empty()
87 {
88 // `.join` appends one path segment and returns an owned path.
89 return Some(PathBuf::from(cache_home).join("uv"));
90 }
91 // What: `let Some(x) = ... else { return None; };` unwraps the home or exits.
92 // Why: Without any hint and without a home directory there is no cache to exempt.
93 //
94 // In TS you'd write (pseudocode):
95 // ```ts
96 // if (home === undefined) return undefined;
97 // ```
98 let Some(home_directory) = home else {
99 // `None` is the "absent" variant.
100 return None;
101 };
102 return Some(home_directory.join(".cache").join("uv"));
103}
104
105/// What:
106/// Resolve mise's data directory the way mise does:
107/// `MISE_DATA_DIR`,
108/// then
109/// `XDG_DATA_HOME/mise`,
110/// then `HOME/.local/share/mise`;
111/// an empty value counts as unset.
112/// `Option<&Path>` is "a borrowed home directory or nothing";
113/// `Option<PathBuf>` is "an owned path or nothing".
114/// Why:
115/// mise keeps each tool plugin as a Git clone under `plugins/` there and updates it with a
116/// forced checkout, which the main-worktree rule would reject.
117/// The home directory is injected so
118/// tests never read the real one.
119///
120/// In TS you'd write (pseudocode):
121/// ```ts
122/// function miseDataDir(environment: [string, string][], home?: string): string | undefined;
123/// ```
124pub fn mise_data_dir(environment: &[(OsString, OsString)], home: Option<&Path>) -> Option<PathBuf> {
125 // `if let Some(x) = ... && cond` runs only when the variable is set and the second
126 // condition also holds, like `x !== undefined && x !== ""`.
127 if let Some(explicit) = environment_value(environment, "MISE_DATA_DIR")
128 && !explicit.is_empty()
129 {
130 // `Some(...)` is the "present" variant.
131 return Some(PathBuf::from(explicit));
132 }
133 if let Some(data_home) = environment_value(environment, "XDG_DATA_HOME")
134 && !data_home.is_empty()
135 {
136 // `.join` appends one path segment and returns an owned path.
137 return Some(PathBuf::from(data_home).join("mise"));
138 }
139 // What: `let Some(x) = ... else { return None; };` unwraps the home or exits.
140 // Why: Without any hint and without a home directory there is no data directory to exempt.
141 //
142 // In TS you'd write (pseudocode):
143 // ```ts
144 // if (home === undefined) return undefined;
145 // ```
146 let Some(home_directory) = home else {
147 // `None` is the "absent" variant.
148 return None;
149 };
150 return Some(home_directory.join(".local").join("share").join("mise"));
151}
152
153/// What:
154/// The tool directories exempt from worktree enforcement on this machine.
155/// `Vec<PathBuf>` is an owned list of owned paths.
156/// Why:
157/// The set is a property of installed tooling,
158/// not of any repository,
159/// so it is
160/// compiled in rather than configured.
161/// Each entry is a directory whose Git clones a tool
162/// creates and discards itself.
163///
164/// In TS you'd write (pseudocode):
165/// ```ts
166/// function defaultAllowedWorktreeDirs(environment, home): string[];
167/// ```
168pub fn default_allowed_worktree_dirs(
169 environment: &[(OsString, OsString)],
170 home: Option<&Path>,
171) -> Vec<PathBuf> {
172 // `Vec::<PathBuf>::new()` is an empty owned list; `mut` allows pushing.
173 let mut directories: Vec<PathBuf> = Vec::<PathBuf>::new();
174 if let Some(uv) = uv_cache_dir(environment, home) {
175 directories.push(uv);
176 }
177 if let Some(mise) = mise_data_dir(environment, home) {
178 directories.push(mise);
179 }
180 return directories;
181}
182
183/// What:
184/// Report whether the canonical `candidate` lies inside any allowed directory.
185/// Why:
186/// Each allowed directory is resolved through symbolic links first,
187/// so a home
188/// reached through a link still matches;
189/// a directory that does not exist drops
190/// out.
191/// `Path::starts_with` compares whole segments,
192/// so `/a/b` does not contain
193/// `/a/bc`.
194///
195/// In TS you'd write (pseudocode):
196/// ```ts
197/// async function isAllowedWorktreeDir(candidate: string, allowedDirs: string[]): Promise<boolean>;
198/// ```
199pub fn is_allowed_worktree_dir(candidate: &Path, allowed_dirs: &[PathBuf]) -> bool {
200 // `for directory in allowed_dirs` borrows each entry in turn.
201 for directory in allowed_dirs {
202 // What: `if let Ok(resolved) = ... && cond` runs only when the directory could be
203 // resolved and the candidate lies inside it.
204 // Why: A tool that is not installed has no cache directory to compare with.
205 //
206 // In TS you'd write (pseudocode):
207 // ```ts
208 // let resolved; try { resolved = await realpath(directory); } catch { continue; }
209 // ```
210 if let Ok(resolved) = std::fs::canonicalize(directory)
211 && candidate.starts_with(resolved.as_path())
212 {
213 return true;
214 }
215 }
216 return false;
217}
218
219/// What:
220/// Map a repository identity to the target worktree policies act on.
221/// Why:
222/// Bare and absent repositories have no worktree;
223/// a repository whose Git
224/// directory sits under an allowed tool cache is exempt before main/linked matters.
225///
226/// In TS you'd write (pseudocode):
227/// ```ts
228/// function classifyEffectiveTarget(identity: WorktreeIdentity, allowedDirs: string[]): EffectiveTarget;
229/// ```
230pub fn classify_effective_target(
231 identity: &WorktreeIdentity,
232 allowed_dirs: &[PathBuf],
233) -> EffectiveTarget {
234 // What: `match` on the borrowed identity; `{ git_dir, .. }` binds one field and
235 // ignores the rest.
236 // Why: Each shape maps to exactly one target, and the compiler checks none is missed.
237 //
238 // In TS you'd write (pseudocode):
239 // ```ts
240 // switch (identity.kind) { case 'outside-worktree': return 'outside-worktree'; ... }
241 // ```
242 match identity {
243 WorktreeIdentity::OutsideWorktree | WorktreeIdentity::BareRepository { .. } => {
244 return EffectiveTarget::OutsideWorktree;
245 }
246 WorktreeIdentity::MainWorktree { git_dir, .. } => {
247 if is_allowed_worktree_dir(git_dir.as_path(), allowed_dirs) {
248 return EffectiveTarget::Allowlisted;
249 }
250 return EffectiveTarget::MainWorktree;
251 }
252 WorktreeIdentity::LinkedWorktree { git_dir, .. } => {
253 if is_allowed_worktree_dir(git_dir.as_path(), allowed_dirs) {
254 return EffectiveTarget::Allowlisted;
255 }
256 return EffectiveTarget::LinkedWorktree;
257 }
258 }
259}
260
261/// Disposable-directory controls stay out of the release executable.
262#[cfg(test)]
263#[path = "effective_target_tests.rs"]
264mod tests;