Skip to main content

zeph_worktree/
sanitize.rs

1// SPDX-License-Identifier: MIT
2//! Input sanitisation for branch components and worktree root paths.
3//!
4//! Every value that ends up in a `git` invocation passes through one of the
5//! functions here before being used.  This module is the enforcement point for
6//! the `NEVER` invariants in the spec:
7//! - branch components are validated before use
8//! - root paths are canonicalised and confined to the repository tree
9
10use std::path::{Path, PathBuf};
11
12use crate::error::WorktreeError;
13
14/// Validates that `s` is safe to use as a git branch component.
15///
16/// A valid component matches `^[A-Za-z0-9._-]+$`, must not start with `-` or
17/// `.`, and must not contain the substrings `..` or `/`.
18///
19/// # Errors
20///
21/// Returns [`WorktreeError::InvalidBranchName`] when any rule is violated.
22///
23/// # Examples
24///
25/// ```no_run
26/// use zeph_worktree::sanitize::validate_branch_component;
27///
28/// validate_branch_component("agent-42").unwrap();
29/// assert!(validate_branch_component("../escape").is_err());
30/// assert!(validate_branch_component("-leading-dash").is_err());
31/// ```
32pub fn validate_branch_component(s: &str) -> Result<(), WorktreeError> {
33    // Must not be empty, and must not start with '-' or '.'
34    match s.chars().next() {
35        None | Some('-' | '.') => {
36            return Err(WorktreeError::InvalidBranchName(s.to_string()));
37        }
38        Some(_) => {}
39    }
40
41    // Must not contain '..' or '/'
42    if s.contains("..") || s.contains('/') {
43        return Err(WorktreeError::InvalidBranchName(s.to_string()));
44    }
45
46    // All characters must be in [A-Za-z0-9._-]
47    if !s
48        .chars()
49        .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
50    {
51        return Err(WorktreeError::InvalidBranchName(s.to_string()));
52    }
53
54    Ok(())
55}
56
57/// Canonicalises `root` relative to `repo_root`, rejecting paths that escape
58/// the repository.
59///
60/// If `root` is relative, it is joined onto `repo_root` before canonicalisation.
61/// The resulting canonical path must have `repo_root`'s canonical path as a
62/// prefix; paths that resolve outside emit [`WorktreeError::RootOutsideRepo`].
63///
64/// Containment is validated against the nearest existing ancestor of the
65/// candidate path *before* any directory is created — a path that would
66/// escape the repository is rejected without mutating the filesystem. Only
67/// once containment is confirmed is [`std::fs::create_dir_all`] called so
68/// that the final `canonicalize` does not fail on a not-yet-existing
69/// worktree root.
70///
71/// # Errors
72///
73/// - [`WorktreeError::RootOutsideRepo`] if the path escapes the repository.
74/// - [`WorktreeError::Io`] for any underlying I/O failure.
75///
76/// # Examples
77///
78/// ```no_run
79/// use std::path::Path;
80/// use zeph_worktree::sanitize::canonicalize_root;
81///
82/// let repo = Path::new("/tmp/myrepo");
83/// let root = canonicalize_root(Path::new(".claude/worktrees"), repo).unwrap();
84/// assert!(root.starts_with(repo));
85/// ```
86pub fn canonicalize_root(root: &Path, repo_root: &Path) -> Result<PathBuf, WorktreeError> {
87    let candidate = if root.is_relative() {
88        repo_root.join(root)
89    } else {
90        root.to_path_buf()
91    };
92
93    let canonical_repo = std::fs::canonicalize(repo_root)?;
94
95    // Validate containment against the nearest existing ancestor first, so a
96    // candidate that would escape the repository is rejected before
97    // `create_dir_all` mutates the filesystem below.
98    let existing_ancestor = nearest_existing_ancestor(&candidate);
99    let canonical_ancestor = std::fs::canonicalize(&existing_ancestor)?;
100    if !canonical_ancestor.starts_with(&canonical_repo) {
101        let suffix = candidate
102            .strip_prefix(&existing_ancestor)
103            .unwrap_or_else(|_| Path::new(""));
104        return Err(WorktreeError::RootOutsideRepo(
105            canonical_ancestor.join(suffix),
106        ));
107    }
108
109    std::fs::create_dir_all(&candidate)?;
110    let canonical_root = std::fs::canonicalize(&candidate)?;
111
112    if !canonical_root.starts_with(&canonical_repo) {
113        return Err(WorktreeError::RootOutsideRepo(canonical_root));
114    }
115
116    Ok(canonical_root)
117}
118
119/// Walks up from `path` until an existing directory or file is found.
120///
121/// Used by [`canonicalize_root`] to find a real, canonicalisable ancestor of
122/// a not-yet-created candidate path so containment can be validated before
123/// any directory is created.
124fn nearest_existing_ancestor(path: &Path) -> PathBuf {
125    let mut current = path;
126    loop {
127        if current.exists() {
128            return current.to_path_buf();
129        }
130        match current.parent() {
131            Some(parent) if !parent.as_os_str().is_empty() => current = parent,
132            _ => return current.to_path_buf(),
133        }
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use std::assert_matches;
141
142    // --- validate_branch_component ---
143
144    #[test]
145    fn valid_simple() {
146        assert!(validate_branch_component("agent-42").is_ok());
147        assert!(validate_branch_component("feat.work").is_ok());
148        assert!(validate_branch_component("A_B_C").is_ok());
149        assert!(validate_branch_component("abc123").is_ok());
150    }
151
152    #[test]
153    fn rejects_empty() {
154        assert!(validate_branch_component("").is_err());
155    }
156
157    #[test]
158    fn rejects_leading_dash() {
159        assert!(validate_branch_component("-bad").is_err());
160    }
161
162    #[test]
163    fn rejects_leading_dot() {
164        assert!(validate_branch_component(".git").is_err());
165    }
166
167    #[test]
168    fn rejects_double_dot() {
169        assert!(validate_branch_component("a..b").is_err());
170        assert!(validate_branch_component("../escape").is_err());
171    }
172
173    #[test]
174    fn rejects_slash() {
175        assert!(validate_branch_component("a/b").is_err());
176    }
177
178    #[test]
179    fn rejects_special_chars() {
180        assert!(validate_branch_component("ag@nt").is_err());
181        assert!(validate_branch_component("ag nt").is_err());
182        assert!(validate_branch_component("ag:nt").is_err());
183    }
184
185    // --- canonicalize_root ---
186
187    #[test]
188    fn root_inside_repo_is_ok() {
189        let dir = tempfile::tempdir().unwrap();
190        let repo = dir.path();
191        let canonical_repo = std::fs::canonicalize(repo).unwrap();
192        let result = canonicalize_root(std::path::Path::new("worktrees"), repo);
193        assert!(result.is_ok(), "expected Ok, got: {result:?}");
194        assert!(result.unwrap().starts_with(&canonical_repo));
195    }
196
197    #[test]
198    fn root_outside_repo_is_rejected() {
199        let dir = tempfile::tempdir().unwrap();
200        let repo = dir.path().join("inner");
201        std::fs::create_dir_all(&repo).unwrap();
202        // Absolute path pointing to the parent of repo_root escapes confinement.
203        let parent = dir.path().to_path_buf();
204        let err = canonicalize_root(&parent, &repo).unwrap_err();
205        assert_matches!(err, WorktreeError::RootOutsideRepo(_));
206    }
207
208    #[test]
209    fn absolute_root_inside_repo_is_ok() {
210        let dir = tempfile::tempdir().unwrap();
211        let repo = dir.path();
212        let sub = repo.join("sub");
213        std::fs::create_dir_all(&sub).unwrap();
214        let result = canonicalize_root(&sub, repo);
215        assert!(result.is_ok());
216    }
217
218    /// Regression test for #5940: `root_outside_repo_is_rejected` uses `dir.path()` —
219    /// the tempdir root, which already exists — as the escaping candidate, so
220    /// `nearest_existing_ancestor` never has to walk past a non-existent segment and
221    /// the test passes identically whether containment is checked before or after
222    /// `create_dir_all`. This test uses a multi-level *non-existent* escaping path
223    /// (`escaped/nested/deep`, none of which exist under the tempdir) to prove
224    /// rejection happens *before* any directory is created — the pre-fix code
225    /// unconditionally called `create_dir_all` on the full candidate first, so it
226    /// would have created `escaped/nested/deep` on disk even though the path was
227    /// ultimately rejected as outside the repo.
228    #[test]
229    fn root_outside_repo_rejected_before_any_directory_created() {
230        let dir = tempfile::tempdir().unwrap();
231        let repo = dir.path().join("inner");
232        std::fs::create_dir_all(&repo).unwrap();
233        // Escapes confinement (sibling of `repo`, not a descendant) and none of its
234        // segments exist yet.
235        let escaping_candidate = dir.path().join("escaped/nested/deep");
236        let err = canonicalize_root(&escaping_candidate, &repo).unwrap_err();
237        assert_matches!(err, WorktreeError::RootOutsideRepo(_));
238        assert!(
239            !dir.path().join("escaped").exists(),
240            "containment check must reject before create_dir_all mutates the filesystem"
241        );
242    }
243}