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}