1use std::collections::HashMap;
7use std::fmt;
8use std::path::{Component, Path, PathBuf};
9use std::process::Command;
10
11use sha2::{Digest, Sha256};
12
13use crate::config::expand_index_root_path;
14
15const SCOPED_V1_DOMAIN: &[u8] = b"scoped-v1";
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum ScopedKeyError {
19 EmptyRelativePath,
20 RelativePathHasTrailingSeparator,
21 RelativePathHasBackslash,
22 RelativePathHasDotComponent,
23 NonUnicodePath(PathBuf),
24 NotInsideGitToplevel {
25 target: PathBuf,
26 git_toplevel: PathBuf,
27 },
28 ResolvePath {
29 path: PathBuf,
30 detail: String,
31 },
32 GitProbe {
33 path: PathBuf,
34 detail: String,
35 },
36 DuplicateArtifactKey {
37 artifact_key: String,
38 first_path: String,
39 duplicate_path: String,
40 },
41}
42
43impl fmt::Display for ScopedKeyError {
44 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45 match self {
46 Self::EmptyRelativePath => write!(f, "scoped-v1 refuses an empty relative path"),
47 Self::RelativePathHasTrailingSeparator => {
48 write!(f, "scoped-v1 refuses a relative path with a trailing separator")
49 }
50 Self::RelativePathHasBackslash => {
51 write!(f, "scoped-v1 refuses a logical relative path containing a backslash")
52 }
53 Self::RelativePathHasDotComponent => {
54 write!(f, "scoped-v1 refuses a relative path containing . or ..")
55 }
56 Self::NonUnicodePath(path) => {
57 write!(f, "standing root path is not valid Unicode: {}", path.display())
58 }
59 Self::NotInsideGitToplevel {
60 target,
61 git_toplevel,
62 } => write!(
63 f,
64 "resolved target {} is not inside recorded git toplevel {}",
65 target.display(),
66 git_toplevel.display()
67 ),
68 Self::ResolvePath { path, detail } => {
69 write!(f, "failed to resolve standing root {}: {detail}", path.display())
70 }
71 Self::GitProbe { path, detail } => {
72 write!(f, "failed to determine git toplevel for {}: {detail}", path.display())
73 }
74 Self::DuplicateArtifactKey {
75 artifact_key,
76 first_path,
77 duplicate_path,
78 } => write!(
79 f,
80 "duplicate standing artifact key {artifact_key} for {first_path:?} and {duplicate_path:?}"
81 ),
82 }
83 }
84}
85
86impl std::error::Error for ScopedKeyError {}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum StandingArtifactIdentity {
90 GitToplevel {
91 artifact_key: String,
92 },
93 GitSubtree {
94 artifact_key: String,
95 scoped_relative_path: String,
96 },
97 NonGit {
98 artifact_key: String,
99 },
100}
101
102impl StandingArtifactIdentity {
103 pub fn artifact_key(&self) -> &str {
104 match self {
105 Self::GitToplevel { artifact_key }
106 | Self::GitSubtree { artifact_key, .. }
107 | Self::NonGit { artifact_key } => artifact_key,
108 }
109 }
110
111 pub fn scoped_relative_path(&self) -> Option<&str> {
112 match self {
113 Self::GitSubtree {
114 scoped_relative_path,
115 ..
116 } => Some(scoped_relative_path),
117 Self::GitToplevel { .. } | Self::NonGit { .. } => None,
118 }
119 }
120}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct ResolvedStandingRoot {
125 pub literal_path: String,
126 pub resolved_target: String,
127 pub resolved_git_toplevel: Option<String>,
128 pub scoped_relative_path: Option<String>,
129 pub artifact_key: String,
130}
131
132pub fn scoped_v1_key(repo_identity: &str, rel_path_bytes: &[u8]) -> Result<String, ScopedKeyError> {
136 validate_logical_relative_path_bytes(rel_path_bytes)?;
137
138 let mut hasher = Sha256::new();
139 hasher.update(SCOPED_V1_DOMAIN);
140 hasher.update([0]);
141 hasher.update(repo_identity.as_bytes());
142 hasher.update([0]);
143 hasher.update(rel_path_bytes);
144 Ok(format!("{:x}", hasher.finalize()))
145}
146
147pub fn scoped_relative_path(
150 resolved_target: &Path,
151 resolved_git_toplevel: &Path,
152) -> Result<String, ScopedKeyError> {
153 let relative = resolved_target
154 .strip_prefix(resolved_git_toplevel)
155 .map_err(|_| ScopedKeyError::NotInsideGitToplevel {
156 target: resolved_target.to_path_buf(),
157 git_toplevel: resolved_git_toplevel.to_path_buf(),
158 })?;
159 if relative.as_os_str().is_empty() {
160 return Err(ScopedKeyError::EmptyRelativePath);
161 }
162
163 let native = relative
164 .to_str()
165 .ok_or_else(|| ScopedKeyError::NonUnicodePath(relative.to_path_buf()))?;
166 if native.ends_with('/') || native.ends_with('\\') {
167 return Err(ScopedKeyError::RelativePathHasTrailingSeparator);
168 }
169
170 let mut components = Vec::new();
171 for component in relative.components() {
172 match component {
173 Component::Normal(component) => {
174 let component = component
175 .to_str()
176 .ok_or_else(|| ScopedKeyError::NonUnicodePath(relative.to_path_buf()))?;
177 if component.contains('\\') || component.contains('/') {
178 return Err(ScopedKeyError::RelativePathHasBackslash);
179 }
180 components.push(component);
181 }
182 Component::CurDir | Component::ParentDir => {
183 return Err(ScopedKeyError::RelativePathHasDotComponent)
184 }
185 Component::Prefix(_) | Component::RootDir => {
186 return Err(ScopedKeyError::NotInsideGitToplevel {
187 target: resolved_target.to_path_buf(),
188 git_toplevel: resolved_git_toplevel.to_path_buf(),
189 })
190 }
191 }
192 }
193
194 if components.is_empty() {
195 return Err(ScopedKeyError::EmptyRelativePath);
196 }
197 let logical = components.join("/");
198 validate_logical_relative_path_bytes(logical.as_bytes())?;
199 Ok(logical)
200}
201
202pub fn classify_resolved_standing_root(
205 resolved_target: &Path,
206 resolved_git_toplevel: Option<&Path>,
207) -> Result<StandingArtifactIdentity, ScopedKeyError> {
208 let Some(git_toplevel) = resolved_git_toplevel else {
209 return Ok(StandingArtifactIdentity::NonGit {
210 artifact_key: crate::search_index::artifact_path_identity_key(resolved_target),
211 });
212 };
213
214 if resolved_target == git_toplevel {
215 return Ok(StandingArtifactIdentity::GitToplevel {
218 artifact_key: crate::search_index::artifact_cache_key(resolved_target),
219 });
220 }
221
222 let relative = scoped_relative_path(resolved_target, git_toplevel)?;
223 let repo_identity = crate::search_index::canonical_git_root_commit_identity(git_toplevel)
224 .map_err(|error| ScopedKeyError::GitProbe {
225 path: git_toplevel.to_path_buf(),
226 detail: error.to_string(),
227 })?;
228 let artifact_key = scoped_v1_key(&repo_identity, relative.as_bytes())?;
229 Ok(StandingArtifactIdentity::GitSubtree {
230 artifact_key,
231 scoped_relative_path: relative,
232 })
233}
234
235pub fn resolve_standing_root(literal_path: &str) -> Result<ResolvedStandingRoot, ScopedKeyError> {
238 let home = std::env::var_os("HOME")
239 .or_else(|| std::env::var_os("USERPROFILE"))
240 .map(PathBuf::from);
241 let expanded = expand_index_root_path(literal_path, home.as_deref()).map_err(|detail| {
242 ScopedKeyError::ResolvePath {
243 path: PathBuf::from(literal_path),
244 detail,
245 }
246 })?;
247 let target = std::fs::canonicalize(&expanded).map_err(|error| ScopedKeyError::ResolvePath {
248 path: expanded,
249 detail: error.to_string(),
250 })?;
251 let git_toplevel = find_git_toplevel(&target)?;
252 let identity = classify_resolved_standing_root(&target, git_toplevel.as_deref())?;
253
254 let resolved_target = unicode_path(&target)?;
255 let resolved_git_toplevel = git_toplevel.as_deref().map(unicode_path).transpose()?;
256 let scoped_relative_path = identity.scoped_relative_path().map(str::to_string);
257
258 Ok(ResolvedStandingRoot {
259 literal_path: literal_path.to_string(),
260 resolved_target,
261 resolved_git_toplevel,
262 scoped_relative_path,
263 artifact_key: identity.artifact_key().to_string(),
264 })
265}
266
267pub fn reject_duplicate_artifact_keys(
269 entries: &[ResolvedStandingRoot],
270) -> Result<(), ScopedKeyError> {
271 let mut first_paths = HashMap::<&str, &str>::new();
272 for entry in entries {
273 if let Some(first_path) = first_paths.insert(&entry.artifact_key, &entry.literal_path) {
274 return Err(ScopedKeyError::DuplicateArtifactKey {
275 artifact_key: entry.artifact_key.clone(),
276 first_path: first_path.to_string(),
277 duplicate_path: entry.literal_path.clone(),
278 });
279 }
280 }
281 Ok(())
282}
283
284fn validate_logical_relative_path_bytes(bytes: &[u8]) -> Result<(), ScopedKeyError> {
285 if bytes.is_empty() {
286 return Err(ScopedKeyError::EmptyRelativePath);
287 }
288 let logical =
289 std::str::from_utf8(bytes).map_err(|_| ScopedKeyError::RelativePathHasBackslash)?;
290 if logical.ends_with('/') {
291 return Err(ScopedKeyError::RelativePathHasTrailingSeparator);
292 }
293 if logical.contains('\\') {
294 return Err(ScopedKeyError::RelativePathHasBackslash);
295 }
296 if logical
297 .split('/')
298 .any(|component| component.is_empty() || component == "." || component == "..")
299 {
300 return Err(ScopedKeyError::RelativePathHasDotComponent);
301 }
302 Ok(())
303}
304
305fn unicode_path(path: &Path) -> Result<String, ScopedKeyError> {
306 path.to_str()
307 .map(str::to_string)
308 .ok_or_else(|| ScopedKeyError::NonUnicodePath(path.to_path_buf()))
309}
310
311fn find_git_toplevel(path: &Path) -> Result<Option<PathBuf>, ScopedKeyError> {
312 let output = Command::new("git")
313 .arg("-C")
314 .arg(path)
315 .args(["rev-parse", "--show-toplevel"])
316 .output()
317 .map_err(|error| ScopedKeyError::GitProbe {
318 path: path.to_path_buf(),
319 detail: format!("spawn failed: {error}"),
320 })?;
321 if output.status.success() {
322 let output = String::from_utf8(output.stdout).map_err(|_| ScopedKeyError::GitProbe {
323 path: path.to_path_buf(),
324 detail: "git returned a non-Unicode toplevel".to_string(),
325 })?;
326 let toplevel = output.trim_end_matches(['\r', '\n']);
327 return std::fs::canonicalize(toplevel).map(Some).map_err(|error| {
328 ScopedKeyError::GitProbe {
329 path: path.to_path_buf(),
330 detail: error.to_string(),
331 }
332 });
333 }
334
335 let stderr = String::from_utf8_lossy(&output.stderr);
336 if stderr.contains("not a git repository") {
337 return Ok(None);
338 }
339 Err(ScopedKeyError::GitProbe {
340 path: path.to_path_buf(),
341 detail: format!("exit {:?}: {}", output.status.code(), stderr.trim()),
342 })
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348
349 #[test]
350 fn scoped_v1_has_domain_separation_and_stable_bytes() {
351 let scoped = scoped_v1_key("root-a\nroot-b", "src/é.rs".as_bytes()).unwrap();
352 assert_eq!(
353 scoped,
354 "11f4480f5f5374290464d5a82c2e08153ca75de53b5e71a9c3c6e645b57cd4a4"
355 );
356 assert_ne!(
357 scoped,
358 crate::search_index::artifact_key_from_git_identity("root-a\nroot-b")
359 );
360 }
361
362 #[test]
363 fn scoped_v1_preserves_unicode_case_and_never_normalizes() {
364 let composed = scoped_v1_key("roots", "Src/é.rs".as_bytes()).unwrap();
365 let decomposed = scoped_v1_key("roots", "Src/e\u{301}.rs".as_bytes()).unwrap();
366 let lower = scoped_v1_key("roots", "src/é.rs".as_bytes()).unwrap();
367 assert_ne!(composed, decomposed);
368 assert_ne!(composed, lower);
369 }
370
371 #[test]
372 fn scoped_v1_rejects_unsafe_logical_paths() {
373 for path in ["", "src/", "src\\lib.rs", "./src", "src/../lib"] {
374 assert!(
375 scoped_v1_key("roots", path.as_bytes()).is_err(),
376 "accepted {path:?}"
377 );
378 }
379 }
380
381 #[test]
382 fn relative_path_uses_forward_slashes_without_case_folding() {
383 let relative =
384 scoped_relative_path(Path::new("/repo/Src/Lib"), Path::new("/repo")).unwrap();
385 assert_eq!(relative, "Src/Lib");
386 }
387
388 #[cfg(windows)]
389 #[test]
390 fn windows_native_separators_match_logical_forward_slashes() {
391 let relative =
392 scoped_relative_path(Path::new(r"C:\repo\src\lib"), Path::new(r"C:\repo")).unwrap();
393 assert_eq!(relative, "src/lib");
394 assert_eq!(
395 scoped_v1_key("roots", relative.as_bytes()).unwrap(),
396 scoped_v1_key("roots", b"src/lib").unwrap()
397 );
398 }
399
400 #[test]
401 fn subtree_key_never_equals_or_prewarms_the_repository_session_key() {
402 let Some(repo) = initialized_git_repo() else {
403 eprintln!("skipping: git is not available");
404 return;
405 };
406 let root = std::fs::canonicalize(repo.path()).unwrap();
407 let subtree = root.join("src");
408 assert!(crate::search_index::artifact_cache_key_memoized_only(&root).is_none());
409
410 let subtree_identity = classify_resolved_standing_root(&subtree, Some(&root)).unwrap();
411 assert!(matches!(
412 subtree_identity,
413 StandingArtifactIdentity::GitSubtree { .. }
414 ));
415 assert!(
416 crate::search_index::artifact_cache_key_memoized_only(&root).is_none(),
417 "subtree derivation must not pre-warm the repository session key"
418 );
419
420 let session_key = crate::search_index::artifact_cache_key(&root);
421 assert_ne!(subtree_identity.artifact_key(), session_key);
422 let top_level = classify_resolved_standing_root(&root, Some(&root)).unwrap();
423 assert_eq!(top_level.artifact_key(), session_key);
424 }
425
426 #[test]
427 fn non_git_roots_use_the_existing_path_scope_key() {
428 let root = tempfile::tempdir().unwrap();
429 let identity = classify_resolved_standing_root(root.path(), None).unwrap();
430 assert_eq!(
431 identity.artifact_key(),
432 crate::search_index::artifact_path_identity_key(root.path())
433 );
434 }
435
436 #[test]
437 fn same_repo_worktree_and_logical_subtree_share_scoped_v1_key() {
438 let Some(repo) = initialized_git_repo() else {
439 eprintln!("skipping: git is not available");
440 return;
441 };
442 let worktree = repo.path().join("linked-worktree");
443 let status = Command::new("git")
444 .arg("-C")
445 .arg(repo.path())
446 .args(["worktree", "add", "--detach"])
447 .arg(&worktree)
448 .status();
449 let Ok(status) = status else {
450 eprintln!("skipping: git worktree is unavailable");
451 return;
452 };
453 if !status.success() {
454 eprintln!("skipping: git worktree add failed");
455 return;
456 }
457
458 let main_root = std::fs::canonicalize(repo.path()).unwrap();
459 let linked_root = std::fs::canonicalize(&worktree).unwrap();
460 let main =
461 classify_resolved_standing_root(&main_root.join("src"), Some(&main_root)).unwrap();
462 let linked =
463 classify_resolved_standing_root(&linked_root.join("src"), Some(&linked_root)).unwrap();
464 assert_eq!(main.artifact_key(), linked.artifact_key());
465 }
466
467 #[cfg(unix)]
468 #[test]
469 fn symlink_retargeting_resolves_to_a_different_pinned_identity() {
470 use std::os::unix::fs::symlink;
471
472 let temp = tempfile::tempdir().unwrap();
473 let first = temp.path().join("first");
474 let second = temp.path().join("second");
475 std::fs::create_dir_all(&first).unwrap();
476 std::fs::create_dir_all(&second).unwrap();
477 let link = temp.path().join("root");
478 symlink(&first, &link).unwrap();
479 let original = resolve_standing_root(link.to_str().unwrap()).unwrap();
480 std::fs::remove_file(&link).unwrap();
481 symlink(&second, &link).unwrap();
482 let retargeted = resolve_standing_root(link.to_str().unwrap()).unwrap();
483 assert_ne!(original.resolved_target, retargeted.resolved_target);
484 }
485
486 #[test]
487 fn duplicate_artifact_keys_are_refused_before_admission() {
488 let entries = vec![
489 ResolvedStandingRoot {
490 literal_path: "/one".to_string(),
491 resolved_target: "/one".to_string(),
492 resolved_git_toplevel: None,
493 scoped_relative_path: None,
494 artifact_key: "same".to_string(),
495 },
496 ResolvedStandingRoot {
497 literal_path: "/two".to_string(),
498 resolved_target: "/two".to_string(),
499 resolved_git_toplevel: None,
500 scoped_relative_path: None,
501 artifact_key: "same".to_string(),
502 },
503 ];
504 assert!(matches!(
505 reject_duplicate_artifact_keys(&entries),
506 Err(ScopedKeyError::DuplicateArtifactKey { .. })
507 ));
508 }
509
510 fn initialized_git_repo() -> Option<tempfile::TempDir> {
511 let repo = tempfile::tempdir().ok()?;
512 let run = |args: &[&str]| {
513 Command::new("git")
514 .arg("-C")
515 .arg(repo.path())
516 .args(args)
517 .status()
518 .ok()
519 .is_some_and(|status| status.success())
520 };
521 if !run(&["init", "-q"]) {
522 return None;
523 }
524 std::fs::create_dir_all(repo.path().join("src")).ok()?;
525 std::fs::write(repo.path().join("src/lib.rs"), "pub fn f() {}\n").ok()?;
526 if !run(&["add", "."])
527 || !run(&[
528 "-c",
529 "user.name=AFT test",
530 "-c",
531 "user.email=aft@example.test",
532 "commit",
533 "-qm",
534 "init",
535 ])
536 {
537 return None;
538 }
539 Some(repo)
540 }
541}