stable_which/durability.rs
1//! Durability judging: can a path be pinned into a service definition?
2//!
3//! See `DR-016` (durable-to-pin model) in the repository's `docs/decisions/`.
4//! This module is internal; the public surface is [`crate::Durability`] and
5//! [`crate::Candidate::durability`]. (The DR reference is an in-repo document,
6//! not a docs.rs link.)
7
8use std::path::{Component, Path};
9
10use crate::path_analysis;
11
12/// Whether a path string keeps pointing at the same (logical) target across
13/// rebuild / upgrade / reboot.
14///
15/// This is a *durable-to-pin* judgement: a [`Durable`](Durability::Durable)
16/// path may be baked into a launchd plist / systemd unit and is expected to
17/// survive upgrades and reboots. Judgement is **per candidate** — for the same
18/// binary, the reference path (`/opt/homebrew/bin/git`) can be
19/// [`Durable`](Durability::Durable) while its canonical realpath
20/// (`/opt/homebrew/Cellar/git/2.44.0/bin/git`) is
21/// [`NotDurable`](Durability::NotDurable).
22///
23/// # Judging model (allow-list)
24///
25/// 1. `NotDurable` is decided first: versioned-install / ephemeral /
26/// build-output / project-local patterns.
27/// 2. Otherwise, if the path is under a known durable location (the
28/// "environment-wide reference surfaces": standard system bins, profile
29/// bins matched by their exact structure, and shims at standard
30/// HOME-anchored locations), it is `Durable`.
31/// 3. Otherwise `Unknown` (treated as not-durable by
32/// [`Candidate::is_stable`](crate::Candidate::is_stable)).
33///
34/// User dropbox directories (`~/bin`, `~/.local/bin`) are deliberately *not*
35/// in the durable allow-list: they mix shebang-embedded scripts and versioned
36/// symlinks, so they fall through to `Unknown`.
37///
38/// # Known limitations
39///
40/// Judgement is path-string based and cannot detect:
41///
42/// - **shebang-embedded versioned dependencies**: e.g. a script in a standard
43/// reference surface whose shebang points at a versioned interpreter
44/// (`cpython@<ver>`). Such a path may be judged `Durable` yet break on
45/// upgrade. This is why shebang-prone user dropboxes are excluded from the
46/// allow-list (see above), but the case cannot be ruled out entirely.
47/// - **environment-dependent paths**: e.g. `~/.nix-profile` is alive in some
48/// environments and dead in others; durability cannot be confirmed without
49/// inspecting the realpath.
50/// - **non-UTF-8 paths**: pattern matching goes through
51/// [`Path::to_string_lossy`], so a path containing non-UTF-8 bytes in a
52/// matched segment may compare unexpectedly; such paths typically fall
53/// through to `Unknown` (safe side).
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55#[non_exhaustive]
56pub enum Durability {
57 /// The path string keeps pointing at the same entity (or same logical
58 /// target) across rebuild / upgrade / reboot.
59 Durable,
60 /// Versioned-install / ephemeral / build-output / project-local etc.;
61 /// baking this path into a service definition can break it.
62 NotDurable,
63 /// Matched neither a known durable nor a known not-durable pattern; cannot
64 /// decide (safe side = treated as not-durable).
65 Unknown,
66}
67
68/// Internal scope of a path. Not exposed publicly (DR-016 Decision 2):
69/// project-local is folded into [`Durability::NotDurable`].
70///
71/// Kept as an enum (rather than a bool) as a deliberate placeholder for the
72/// future: if DR-016 Decision 2's "expose scope" option is ever taken, the
73/// public `Scope` enum can grow from this internal one without a churny
74/// bool→enum migration.
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76enum Scope {
77 /// Scoped to a single project (`.direnv/`, `venv/`, `node_modules/.bin/`).
78 /// A global service must not reference it.
79 ProjectLocal,
80 /// Not project-scoped (the common case).
81 Global,
82}
83
84/// Project-local path fragments. A global service should never reference these.
85const PROJECT_LOCAL_PATTERNS: &[&str] = &[
86 "/.direnv/",
87 "/node_modules/.bin/",
88 "/.venv/",
89 "/venv/bin/",
90 "/.tox/",
91];
92
93/// Versioned-install path fragments: the path embeds a version identifier, so
94/// it changes on upgrade. These are NotDurable.
95const VERSIONED_INSTALL_PATTERNS: &[&str] = &[
96 // Homebrew Cellar / Caskroom
97 "/Cellar/",
98 "/Caskroom/",
99 // mise / asdf installs
100 "/mise/installs/",
101 "/.mise/installs/",
102 "/asdf/installs/",
103 "/.asdf/installs/",
104 // nix store (hash + version in first segment)
105 "/nix/store/",
106 // version-manager versions/ trees
107 "/.nvm/versions/",
108 "/.fnm/node-versions/",
109 "/.rustup/toolchains/",
110 "/.volta/tools/",
111 "/.sdkman/candidates/",
112 "/.pyenv/versions/",
113 "/.rbenv/versions/",
114 "/.goenv/versions/",
115 "/.proto/tools/",
116 "/scoop/apps/",
117 "/chocolatey/lib/",
118 "/WinGet/Packages/",
119 // aqua internal packages
120 "/aquaproj-aqua/internal/pkgs/",
121 // app-specific versioned trees, anchored to the specific tool name to avoid
122 // false positives from an unrelated generic `/versions/` segment (findings:
123 // claude / cursor-agent each keep a `versions/<ver>/` tree).
124 "/claude/versions/",
125 "/cursor-agent/versions/",
126 // rye cpython interpreters
127 "/cpython@",
128];
129
130/// Durable directory surfaces where the binary lives **directly** in the dir
131/// (`<dir>/<file>`). These are matched by exact parent-directory equality, not
132/// substring, so `/usr/local/binutils/foo` or `/opt/homebrew/binfoo/git` do
133/// not match.
134///
135/// Design rationale: `/usr/local/bin` is included because it is a standard,
136/// environment-wide reference surface populated by `make install`, distro
137/// packages, and `brew link` symlinks — the reference path is stable across
138/// upgrades. The residual risk is a tool that itself places a *versioned
139/// symlink* directly in `/usr/local/bin` (rare); that case is a known
140/// limitation of path-string judgement (a `Durable` verdict that could still
141/// break) and is documented on [`Durability`].
142const DURABLE_DIRECT_DIRS: &[&str] = &[
143 // standard system bins
144 "/usr/bin",
145 "/bin",
146 "/usr/local/bin",
147 "/usr/sbin",
148 "/sbin",
149 // homebrew profile bins (the symlink surface, not Cellar)
150 "/opt/homebrew/bin",
151 "/opt/homebrew/sbin",
152 // nix-darwin / Determinate Nix profile surfaces
153 "/run/current-system/sw/bin",
154 "/nix/var/nix/profiles/default/bin",
155];
156
157/// Standard shim directory suffixes (HOME-anchored). A shim path is treated as
158/// durable only when it both matches one of these and is anchored under the
159/// resolved HOME, so a project-local `/repo/.mise/shims/tool` does not qualify.
160const STANDARD_SHIM_SUFFIXES: &[&str] = &[
161 "/.local/share/mise/shims",
162 "/.mise/shims",
163 "/.asdf/shims",
164 "/.pyenv/shims",
165 "/.rbenv/shims",
166 "/.goenv/shims",
167 "/.proto/shims",
168];
169
170/// Normalize a path string to forward slashes for pattern matching.
171fn norm(path: &Path) -> String {
172 path.to_string_lossy().replace('\\', "/")
173}
174
175/// Detect whether a path is project-local (scoped to a single project).
176fn scope_of(path: &Path) -> Scope {
177 let s = norm(path);
178 if PROJECT_LOCAL_PATTERNS.iter().any(|p| s.contains(p)) {
179 Scope::ProjectLocal
180 } else {
181 Scope::Global
182 }
183}
184
185/// Detect whether a path embeds a version identifier (versioned install).
186fn is_versioned_install(path: &Path) -> bool {
187 let s = norm(path);
188 VERSIONED_INSTALL_PATTERNS.iter().any(|p| s.contains(p))
189}
190
191/// Collect the normal (named) path components of `path` as strings.
192///
193/// Only [`Component::Normal`] segments are kept; root / prefix / `.` / `..`
194/// are dropped. A path with any `..` is considered un-normalized by callers and
195/// will not match the strict structural checks below.
196fn normal_components(path: &Path) -> Vec<String> {
197 path.components()
198 .filter_map(|c| match c {
199 Component::Normal(os) => Some(os.to_string_lossy().into_owned()),
200 _ => None,
201 })
202 .collect()
203}
204
205/// `/etc/profiles/per-user/<user>/bin/<file>` (home-manager profile surface).
206///
207/// Strictly require the exact structure: components `etc`, `profiles`,
208/// `per-user`, `<user>`, `bin`, `<file>` with nothing in between and the file
209/// as the last component. This rejects
210/// `/etc/profiles/per-user/alice/lib/python/bin/foo` and a non-absolute
211/// `home/u/etc/profiles/per-user/alice/bin/foo`.
212fn is_etc_profiles_per_user_bin(path: &Path) -> bool {
213 if !path.is_absolute() {
214 return false;
215 }
216 let c = normal_components(path);
217 c.len() == 6
218 && c[0] == "etc"
219 && c[1] == "profiles"
220 && c[2] == "per-user"
221 // c[3] is the user name (any single segment)
222 && c[4] == "bin"
223 // c[5] is the file name (last component); len()==6 guarantees terminal.
224}
225
226/// Whether the binary lives directly in one of [`DURABLE_DIRECT_DIRS`]
227/// (`<dir>/<file>`), matched by exact parent equality on an absolute path.
228fn is_durable_direct_dir(path: &Path) -> bool {
229 if !path.is_absolute() {
230 return false;
231 }
232 let Some(parent) = path.parent() else {
233 return false;
234 };
235 // file_name() must exist (so `path` is `<dir>/<file>`, not `<dir>/`).
236 if path.file_name().is_none() {
237 return false;
238 }
239 let parent_s = norm(parent);
240 DURABLE_DIRECT_DIRS.iter().any(|&d| parent_s == d)
241}
242
243/// Whether `path` is a shim at a standard HOME-anchored location.
244///
245/// `home` is the resolved HOME directory; when `None`, a HOME-anchored shim
246/// cannot be confirmed and the function returns `false` (→ `Unknown`).
247fn is_standard_shim(path: &Path, home: Option<&Path>) -> bool {
248 // The shim must be recognized as a shim by the shared detector first.
249 if !path_analysis::is_shim_path(path) {
250 return false;
251 }
252 let Some(home) = home else {
253 return false;
254 };
255 if !path.is_absolute() {
256 return false;
257 }
258 let home_s = {
259 let mut h = norm(home);
260 while h.ends_with('/') {
261 h.pop();
262 }
263 h
264 };
265 if home_s.is_empty() {
266 return false;
267 }
268 let s = norm(path);
269 // The shim dir must be `<home><suffix>/<file>`: HOME-anchored, with exactly
270 // the standard shims directory, then a single file segment.
271 STANDARD_SHIM_SUFFIXES.iter().any(|suffix| {
272 let dir = format!("{home_s}{suffix}");
273 let with_sep = format!("{dir}/");
274 // `s` starts with `<home><suffix>/` and the remainder is a single file
275 // segment (no further `/`).
276 s.strip_prefix(&with_sep)
277 .is_some_and(|rest| !rest.is_empty() && !rest.contains('/'))
278 })
279}
280
281/// Detect whether a path lives at a known durable reference surface.
282///
283/// Each surface is matched by its exact structure (direct-dir parent equality,
284/// the strict per-user profile shape, or a HOME-anchored standard shim), never
285/// by loose substring. Unrecognized locations fall through to `Unknown`.
286fn is_durable_location(path: &Path, home: Option<&Path>) -> bool {
287 is_durable_direct_dir(path)
288 || is_etc_profiles_per_user_bin(path)
289 || is_standard_shim(path, home)
290}
291
292/// Judge the durability of a candidate path, resolving HOME from the process
293/// environment. See [`Durability`].
294pub fn judge(path: &Path) -> Durability {
295 let home = std::env::var_os("HOME").map(std::path::PathBuf::from);
296 judge_with_home(path, home.as_deref())
297}
298
299/// Judge durability with an explicit HOME (for testability). See [`Durability`].
300fn judge_with_home(path: &Path, home: Option<&Path>) -> Durability {
301 // 1. NotDurable is decided first.
302 if is_versioned_install(path)
303 || path_analysis::is_ephemeral(path)
304 || path_analysis::is_build_output(path)
305 || scope_of(path) == Scope::ProjectLocal
306 {
307 return Durability::NotDurable;
308 }
309
310 // 2. Known durable reference surfaces.
311 if is_durable_location(path, home) {
312 return Durability::Durable;
313 }
314
315 // 3. Fall through: cannot decide.
316 Durability::Unknown
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322 use std::path::PathBuf;
323
324 const HOME: &str = "/home/u";
325
326 fn judge_str(s: &str) -> Durability {
327 // Most tests anchor shims under "/home/u".
328 judge_with_home(Path::new(s), Some(Path::new(HOME)))
329 }
330
331 // --- NotDurable: versioned-install (findings real examples) ---
332
333 #[test]
334 fn homebrew_cellar_is_not_durable() {
335 assert_eq!(
336 judge_str("/opt/homebrew/Cellar/node/26.0.0/bin/node"),
337 Durability::NotDurable
338 );
339 }
340
341 #[test]
342 fn nix_store_is_not_durable() {
343 assert_eq!(
344 judge_str("/nix/store/abc123hash-git-2.54.0/bin/git"),
345 Durability::NotDurable
346 );
347 }
348
349 #[test]
350 fn mise_installs_is_not_durable() {
351 assert_eq!(
352 judge_str("/home/u/.local/share/mise/installs/node/22.22.0/bin/node"),
353 Durability::NotDurable
354 );
355 }
356
357 #[test]
358 fn claude_versions_is_not_durable() {
359 assert_eq!(
360 judge_str("/home/u/.local/share/claude/versions/2.1.177/bin/claude"),
361 Durability::NotDurable
362 );
363 }
364
365 #[test]
366 fn cursor_agent_versions_is_not_durable() {
367 assert_eq!(
368 judge_str(
369 "/home/u/.local/share/cursor-agent/versions/2025.09.17-25b418f/bin/cursor-agent"
370 ),
371 Durability::NotDurable
372 );
373 }
374
375 #[test]
376 fn generic_versions_segment_is_not_versioned_install() {
377 // H: the generic `/versions/` pattern was removed. An unrelated dir
378 // named "versions" should NOT be classified as a versioned install
379 // (falls through to Unknown here, not NotDurable-by-versioned).
380 assert_eq!(
381 judge_str("/home/u/myapp/versions/data/tool"),
382 Durability::Unknown
383 );
384 }
385
386 #[test]
387 fn rye_cpython_is_not_durable() {
388 assert_eq!(
389 judge_str("/home/u/.dotfiles/cache/rye/py/cpython@3.12.8/bin/python"),
390 Durability::NotDurable
391 );
392 }
393
394 #[test]
395 fn aqua_internal_pkgs_is_not_durable() {
396 assert_eq!(
397 judge_str("/home/u/.local/share/aquaproj-aqua/internal/pkgs/foo/v2.56.2/bin/aqua"),
398 Durability::NotDurable
399 );
400 }
401
402 // --- NotDurable: ephemeral / build-output / project-local ---
403
404 #[test]
405 fn cache_dir_is_not_durable() {
406 assert_eq!(
407 judge_str("/home/u/.cache/jj-worktree/bin/git"),
408 Durability::NotDurable
409 );
410 }
411
412 #[test]
413 fn build_output_is_not_durable() {
414 assert_eq!(
415 judge_str("/home/u/project/target/release/myapp"),
416 Durability::NotDurable
417 );
418 }
419
420 #[test]
421 fn direnv_is_not_durable() {
422 assert_eq!(
423 judge_str("/home/u/project/.direnv/python-3.12/bin/python"),
424 Durability::NotDurable
425 );
426 }
427
428 #[test]
429 fn venv_is_not_durable() {
430 assert_eq!(
431 judge_str("/home/u/project/.venv/bin/python"),
432 Durability::NotDurable
433 );
434 }
435
436 #[test]
437 fn node_modules_bin_is_not_durable() {
438 assert_eq!(
439 judge_str("/home/u/project/node_modules/.bin/eslint"),
440 Durability::NotDurable
441 );
442 }
443
444 // --- Durable: environment-wide reference surfaces ---
445
446 #[test]
447 fn homebrew_bin_is_durable() {
448 assert_eq!(judge_str("/opt/homebrew/bin/node"), Durability::Durable);
449 }
450
451 #[test]
452 fn homebrew_sbin_is_durable() {
453 assert_eq!(judge_str("/opt/homebrew/sbin/foo"), Durability::Durable);
454 }
455
456 #[test]
457 fn usr_bin_is_durable() {
458 assert_eq!(judge_str("/usr/bin/git"), Durability::Durable);
459 }
460
461 #[test]
462 fn usr_sbin_is_durable() {
463 assert_eq!(judge_str("/usr/sbin/foo"), Durability::Durable);
464 }
465
466 #[test]
467 fn sbin_is_durable() {
468 assert_eq!(judge_str("/sbin/init"), Durability::Durable);
469 }
470
471 #[test]
472 fn bin_is_durable() {
473 assert_eq!(judge_str("/bin/sh"), Durability::Durable);
474 }
475
476 #[test]
477 fn usr_local_bin_is_durable() {
478 assert_eq!(judge_str("/usr/local/bin/node"), Durability::Durable);
479 }
480
481 #[test]
482 fn mise_shim_is_durable() {
483 assert_eq!(
484 judge_str("/home/u/.local/share/mise/shims/node"),
485 Durability::Durable
486 );
487 }
488
489 #[test]
490 fn run_current_system_is_durable() {
491 assert_eq!(
492 judge_str("/run/current-system/sw/bin/git"),
493 Durability::Durable
494 );
495 }
496
497 #[test]
498 fn etc_profiles_per_user_is_durable() {
499 assert_eq!(
500 judge_str("/etc/profiles/per-user/alice/bin/curl"),
501 Durability::Durable
502 );
503 }
504
505 #[test]
506 fn nix_default_profile_is_durable() {
507 assert_eq!(
508 judge_str("/nix/var/nix/profiles/default/bin/nix"),
509 Durability::Durable
510 );
511 }
512
513 // --- A: strict per-user profile structure (false-positive regression) ---
514
515 #[test]
516 fn etc_profiles_per_user_deep_is_not_durable() {
517 // Deeper than `<user>/bin/<file>`: must NOT be Durable.
518 assert_eq!(
519 judge_with_home(
520 Path::new("/etc/profiles/per-user/alice/lib/python/bin/foo"),
521 Some(Path::new(HOME))
522 ),
523 Durability::Unknown
524 );
525 }
526
527 #[test]
528 fn etc_profiles_per_user_relative_is_not_durable() {
529 // Non-absolute lookalike must NOT match.
530 assert_eq!(
531 judge_with_home(
532 Path::new("home/u/etc/profiles/per-user/alice/bin/foo"),
533 Some(Path::new(HOME))
534 ),
535 Durability::Unknown
536 );
537 }
538
539 // --- A/E: direct-dir exact match (substring false-positive regression) ---
540
541 #[test]
542 fn usr_local_binutils_is_unknown() {
543 // `/usr/local/binutils/foo` must NOT match `/usr/local/bin`.
544 assert_eq!(judge_str("/usr/local/binutils/foo"), Durability::Unknown);
545 }
546
547 #[test]
548 fn opt_homebrew_binfoo_is_unknown() {
549 // `/opt/homebrew/binfoo/git` must NOT match `/opt/homebrew/bin`.
550 assert_eq!(judge_str("/opt/homebrew/binfoo/git"), Durability::Unknown);
551 }
552
553 #[test]
554 fn usr_bin_nested_is_unknown() {
555 // A file nested below /usr/bin (not direct child) must NOT match.
556 assert_eq!(judge_str("/usr/bin/subdir/foo"), Durability::Unknown);
557 }
558
559 // --- B: project/user-local shim must not be Durable ---
560
561 #[test]
562 fn project_local_mise_shim_is_unknown() {
563 // `/repo/.mise/shims/tool` is shim-shaped but NOT HOME-anchored.
564 assert_eq!(
565 judge_with_home(Path::new("/repo/.mise/shims/tool"), Some(Path::new(HOME))),
566 Durability::Unknown
567 );
568 }
569
570 #[test]
571 fn shim_without_home_is_unknown() {
572 // HOME unknown → cannot confirm standard shim → Unknown.
573 assert_eq!(
574 judge_with_home(Path::new("/home/u/.local/share/mise/shims/node"), None),
575 Durability::Unknown
576 );
577 }
578
579 #[test]
580 fn shim_anchored_under_home_is_durable() {
581 assert_eq!(
582 judge_with_home(Path::new("/home/u/.asdf/shims/ruby"), Some(Path::new(HOME))),
583 Durability::Durable
584 );
585 }
586
587 // --- Unknown: user dropbox and unrecognized locations ---
588
589 #[test]
590 fn local_bin_jupyter_is_unknown() {
591 // ~/.local/bin mixes shebang/versioned, deliberately excluded.
592 assert_eq!(judge_str("/home/u/.local/bin/jupyter"), Durability::Unknown);
593 }
594
595 #[test]
596 fn home_bin_is_unknown() {
597 assert_eq!(judge_str("/home/u/bin/mytool"), Durability::Unknown);
598 }
599
600 #[test]
601 fn cargo_bin_is_unknown() {
602 // ~/.cargo/bin is durable in practice but not in the allow-list yet;
603 // safe side = Unknown (0.5.x may promote).
604 assert_eq!(judge_str("/home/u/.cargo/bin/ripgrep"), Durability::Unknown);
605 }
606
607 #[test]
608 fn unrecognized_dir_is_unknown() {
609 assert_eq!(judge_str("/some/random/dir/tool"), Durability::Unknown);
610 }
611
612 // --- ordering: NotDurable wins over a durable-looking surface ---
613
614 #[test]
615 fn ephemeral_shim_is_not_durable_despite_shim() {
616 // jj-worktree cache: ephemeral + shim. NotDurable must win (decided first).
617 assert_eq!(
618 judge_str("/home/u/.cache/jj-worktree/.mise/shims/git"),
619 Durability::NotDurable
620 );
621 }
622
623 // --- O: Windows backslash normalization ---
624
625 #[test]
626 fn windows_chocolatey_lib_is_not_durable() {
627 assert_eq!(
628 judge_str(r"C:\ProgramData\chocolatey\lib\foo\bin\foo.exe"),
629 Durability::NotDurable
630 );
631 }
632
633 #[test]
634 fn windows_winget_packages_is_not_durable() {
635 assert_eq!(
636 judge_str(r"C:\Users\u\AppData\Local\Microsoft\WinGet\Packages\Foo\bin\foo.exe"),
637 Durability::NotDurable
638 );
639 }
640
641 #[test]
642 fn windows_scoop_apps_is_not_durable() {
643 assert_eq!(
644 judge_str(r"C:\Users\u\scoop\apps\foo\1.2.3\foo.exe"),
645 Durability::NotDurable
646 );
647 }
648
649 // --- helper-level direct tests ---
650
651 #[test]
652 fn is_durable_direct_dir_exact_only() {
653 assert!(is_durable_direct_dir(&PathBuf::from("/usr/bin/git")));
654 assert!(!is_durable_direct_dir(&PathBuf::from("/usr/bin/sub/git")));
655 assert!(!is_durable_direct_dir(&PathBuf::from("usr/bin/git")));
656 }
657}