Skip to main content

aft/
scoped_key.rs

1//! Artifact identities for standing roots that point below a Git worktree.
2//!
3//! A scoped root must not reuse the whole-repository session key: doing so
4//! would let a subtree warm or publish an artifact for files it does not own.
5
6use 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/// A resolved standing entry suitable for durable path pinning.
123#[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
132/// Derive the versioned subtree identity. `repo_identity` is the canonical,
133/// sorted root-commit identity returned by the existing search-index helper;
134/// this function never rebuilds or normalizes it.
135pub 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
147/// Convert the two recorded canonical paths into the strict logical path used
148/// by `scoped-v1`. It validates rather than repairs the recorded identity.
149pub 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
202/// Classify a resolved root without performing any implicit normalization or
203/// storage mutation. Callers must reject duplicates before lifecycle admission.
204pub 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        // This deliberately calls the session derivation itself rather than a
216        // sibling reimplementation, keeping top-level sharing byte-identical.
217        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
235/// Resolve a configured spelling into the durable identity it records. The
236/// literal spelling remains untouched; only the recorded target is canonical.
237pub 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
267/// Reject shared artifact identities before a standing actor can admit work.
268pub 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}