apm_core/wrapper/
path_guard.rs1use std::path::{Component, Path, PathBuf};
2use globset::{Glob, GlobSetBuilder};
3
4pub struct PathGuard {
5 worktree: PathBuf, write_protected: Vec<PathBuf>, }
8
9impl PathGuard {
10 pub fn new(
11 worktree: &Path,
12 read_allow_patterns: &[String],
13 write_protected: &[PathBuf],
14 ) -> anyhow::Result<Self> {
15 let worktree = std::fs::canonicalize(worktree)
16 .unwrap_or_else(|_| canonicalize_lenient(worktree));
17
18 let mut builder = GlobSetBuilder::new();
22 for pattern in read_allow_patterns {
23 let expanded = expand_home_str(pattern);
24 builder.add(Glob::new(&expanded).map_err(|e| anyhow::anyhow!("invalid glob {pattern:?}: {e}"))?);
25 }
26 builder.build().map_err(|e| anyhow::anyhow!("glob build failed: {e}"))?;
27
28 let write_protected = write_protected
29 .iter()
30 .map(|p| std::fs::canonicalize(p).unwrap_or_else(|_| canonicalize_lenient(p)))
31 .collect();
32
33 Ok(PathGuard { worktree, write_protected })
34 }
35
36 pub fn check_write(&self, path: &Path) -> Result<(), String> {
37 let resolved = canonicalize_lenient(path);
38
39 if self.write_protected.iter().any(|p| p == &resolved) {
41 return Err(rejection_msg(path, &self.worktree));
42 }
43
44 if resolved.starts_with(&self.worktree) {
45 return Ok(());
46 }
47
48 Err(rejection_msg(path, &self.worktree))
49 }
50
51 pub fn check_bash(&self, cmd: &str) -> Result<(), String> {
52 let targets = detect_write_targets(cmd);
53 for target_str in targets {
54 let path = PathBuf::from(&target_str);
55 self.check_write(&path)?;
56 }
57 Ok(())
58 }
59}
60
61fn rejection_msg(requested: &Path, worktree: &Path) -> String {
63 format!(
64 "path outside ticket worktree; isolation enforced by APM wrapper.\n Requested: {}\n APM_TICKET_WORKTREE = {}",
65 requested.display(),
66 worktree.display()
67 )
68}
69
70pub fn canonicalize_lenient(path: &Path) -> PathBuf {
77 let mut result = PathBuf::new();
78
79 for component in path.components() {
80 match component {
81 Component::Prefix(p) => {
82 result = PathBuf::from(p.as_os_str());
83 }
84 Component::RootDir => {
85 result.push(component);
86 }
87 Component::CurDir => {
88 }
90 Component::ParentDir => {
91 let candidate = result.join("..");
93 if candidate.exists() {
94 result = std::fs::canonicalize(&candidate).unwrap_or(candidate);
95 } else {
96 result.pop();
98 }
99 }
100 Component::Normal(_) => {
101 result.push(component);
102 if result.exists() {
103 result = std::fs::canonicalize(&result).unwrap_or_else(|_| result.clone());
104 }
105 }
106 }
107 }
108
109 result
110}
111
112fn expand_home_str(s: &str) -> String {
114 if let Some(rest) = s.strip_prefix("~/") {
115 if let Ok(home) = std::env::var("HOME") {
116 if !home.is_empty() {
117 return format!("{home}/{rest}");
118 }
119 }
120 }
121 s.to_string()
122}
123
124fn expand_home(s: &str) -> String {
126 expand_home_str(s)
127}
128
129fn is_path_token(s: &str) -> bool {
130 s.starts_with('/') || s.starts_with("~/")
131}
132
133fn is_shell_sep(s: &str) -> bool {
134 matches!(s, ";" | "&&" | "||" | "|" | "&")
135}
136
137fn detect_write_targets(cmd: &str) -> Vec<String> {
150 let mut targets = Vec::new();
151
152 detect_redirects(cmd, &mut targets);
154
155 let tokens: Vec<&str> = cmd.split_whitespace().collect();
157 detect_command_writes(&tokens, &mut targets);
158
159 targets
160}
161
162fn detect_redirects(cmd: &str, targets: &mut Vec<String>) {
165 let chars: Vec<char> = cmd.chars().collect();
166 let n = chars.len();
167 let mut i = 0;
168
169 while i < n {
170 let c = chars[i];
171
172 if c == '\'' {
174 i += 1;
175 while i < n && chars[i] != '\'' {
176 i += 1;
177 }
178 if i < n {
179 i += 1;
180 }
181 continue;
182 }
183
184 if c == '"' {
186 i += 1;
187 while i < n && chars[i] != '"' {
188 if chars[i] == '\\' {
189 i += 1; }
191 if i < n {
192 i += 1;
193 }
194 }
195 if i < n {
196 i += 1;
197 }
198 continue;
199 }
200
201 if c == '>' {
202 let is_double = i + 1 < n && chars[i + 1] == '>';
203 let advance = if is_double { 2 } else { 1 };
204
205 let mut j = i + advance;
207 while j < n && chars[j] == ' ' {
208 j += 1;
209 }
210
211 if j < n
213 && (chars[j] == '/'
214 || (chars[j] == '~' && j + 1 < n && chars[j + 1] == '/'))
215 {
216 let path_start = j;
217 while j < n
218 && !chars[j].is_whitespace()
219 && !matches!(chars[j], ';' | '|' | '&' | '(')
220 {
221 j += 1;
222 }
223 let path: String = chars[path_start..j].iter().collect();
224 targets.push(expand_home(&path));
225 }
226
227 i += advance;
228 continue;
229 }
230
231 i += 1;
232 }
233}
234
235fn detect_command_writes(tokens: &[&str], targets: &mut Vec<String>) {
237 let n = tokens.len();
238 let mut i = 0;
239
240 while i < n {
241 let tok = tokens[i];
242
243 match tok {
244 "tee" => {
245 for j in (i + 1)..n {
247 let arg = tokens[j];
248 if is_shell_sep(arg) {
249 break;
250 }
251 if arg.starts_with('-') {
252 continue;
253 }
254 if is_path_token(arg) {
255 targets.push(expand_home(arg));
256 }
257 break;
258 }
259 }
260 "cp" | "mv" => {
261 let mut last: Option<String> = None;
263 for j in (i + 1)..n {
264 let arg = tokens[j];
265 if is_shell_sep(arg) {
266 break;
267 }
268 if !arg.starts_with('-') && is_path_token(arg) {
269 last = Some(expand_home(arg));
270 }
271 }
272 if let Some(p) = last {
273 targets.push(p);
274 }
275 }
276 "truncate" => {
277 let mut j = i + 1;
278 while j < n {
279 let arg = tokens[j];
280 if is_shell_sep(arg) {
281 break;
282 }
283 if arg == "-s" || arg == "--size" {
285 j += 2;
286 continue;
287 }
288 if !arg.starts_with('-') && is_path_token(arg) {
289 targets.push(expand_home(arg));
290 }
291 j += 1;
292 }
293 }
294 _ => {}
295 }
296
297 i += 1;
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304
305 #[test]
308 fn canonicalize_lenient_absolute_existing() {
309 let tmp = tempfile::tempdir().unwrap();
310 let p = tmp.path().to_path_buf();
311 let result = canonicalize_lenient(&p);
312 assert!(result.is_absolute());
314 }
315
316 #[test]
317 fn canonicalize_lenient_nonexistent_leaf() {
318 let tmp = tempfile::tempdir().unwrap();
319 let p = tmp.path().join("nonexistent.txt");
320 let result = canonicalize_lenient(&p);
321 assert!(result.is_absolute());
323 assert_eq!(result.file_name().unwrap().to_str().unwrap(), "nonexistent.txt");
324 }
325
326 #[test]
327 fn canonicalize_lenient_dotdot_inside_existing() {
328 let tmp = tempfile::tempdir().unwrap();
329 let sub = tmp.path().join("sub");
330 std::fs::create_dir(&sub).unwrap();
331 let candidate = sub.join("..").join("other.txt");
333 let result = canonicalize_lenient(&candidate);
334 let expected_parent = std::fs::canonicalize(tmp.path()).unwrap();
336 assert_eq!(result.parent().unwrap(), expected_parent);
337 }
338
339 #[test]
340 fn canonicalize_lenient_dotdot_escape_stays_out() {
341 let tmp = tempfile::tempdir().unwrap();
342 let wt = tmp.path().join("worktree");
343 let sub = wt.join("subdir");
344 std::fs::create_dir_all(&sub).unwrap();
345 let path = sub.join("..").join("..").join("etc").join("passwd");
347 let result = canonicalize_lenient(&path);
348 let canon_wt = std::fs::canonicalize(&wt).unwrap();
350 assert!(!result.starts_with(&canon_wt));
351 }
352
353 #[test]
354 fn canonicalize_lenient_symlink_inside_worktree_resolves_outside() {
355 let tmp = tempfile::tempdir().unwrap();
356 let wt = tmp.path().join("wt");
357 std::fs::create_dir(&wt).unwrap();
358 let outside = tmp.path().join("outside");
359 std::fs::create_dir(&outside).unwrap();
360 let link = wt.join("link");
361 std::os::unix::fs::symlink(&outside, &link).unwrap();
362 let target = link.join("secret.txt");
363 let result = canonicalize_lenient(&target);
364 let canon_wt = std::fs::canonicalize(&wt).unwrap();
365 assert!(!result.starts_with(&canon_wt));
366 }
367
368 fn make_guard(wt: &Path) -> PathGuard {
371 PathGuard::new(wt, &[], &[]).unwrap()
372 }
373
374 #[test]
375 fn check_write_inside_worktree_allowed() {
376 let tmp = tempfile::tempdir().unwrap();
377 let wt = tmp.path().join("wt");
378 std::fs::create_dir(&wt).unwrap();
379 let guard = make_guard(&wt);
380 assert!(guard.check_write(&wt.join("file.txt")).is_ok());
381 }
382
383 #[test]
384 fn check_write_outside_worktree_rejected() {
385 let tmp = tempfile::tempdir().unwrap();
386 let wt = tmp.path().join("wt");
387 std::fs::create_dir(&wt).unwrap();
388 let guard = make_guard(&wt);
389 let outside = tmp.path().join("outside.txt");
390 let err = guard.check_write(&outside).unwrap_err();
391 assert!(err.contains("path outside ticket worktree"));
392 assert!(err.contains("APM_TICKET_WORKTREE"));
393 }
394
395 #[test]
396 fn check_write_rejection_message_contains_worktree() {
397 let tmp = tempfile::tempdir().unwrap();
398 let wt = tmp.path().join("wt");
399 std::fs::create_dir(&wt).unwrap();
400 let guard = make_guard(&wt);
401 let err = guard.check_write(&tmp.path().join("x")).unwrap_err();
402 assert!(err.contains("APM_TICKET_WORKTREE"));
403 }
404
405 #[test]
406 fn check_write_dotdot_escape_rejected() {
407 let tmp = tempfile::tempdir().unwrap();
408 let wt = tmp.path().join("wt");
409 let sub = wt.join("sub");
410 std::fs::create_dir_all(&sub).unwrap();
411 let guard = make_guard(&wt);
412 let path = sub.join("..").join("..").join("etc").join("passwd");
414 assert!(guard.check_write(&path).is_err());
415 }
416
417 #[test]
418 fn check_write_symlink_to_outside_rejected() {
419 let tmp = tempfile::tempdir().unwrap();
420 let wt = tmp.path().join("wt");
421 std::fs::create_dir(&wt).unwrap();
422 let outside = tmp.path().join("outside");
423 std::fs::create_dir(&outside).unwrap();
424 let link = wt.join("link");
425 std::os::unix::fs::symlink(&outside, &link).unwrap();
426 let guard = make_guard(&wt);
427 assert!(guard.check_write(&link.join("file.txt")).is_err());
428 }
429
430 #[test]
431 fn check_write_protected_inside_worktree_rejected() {
432 let tmp = tempfile::tempdir().unwrap();
433 let wt = tmp.path().join("wt");
434 std::fs::create_dir(&wt).unwrap();
435 let apm_bin = wt.join("target").join("debug").join("apm");
437 std::fs::create_dir_all(apm_bin.parent().unwrap()).unwrap();
438 std::fs::write(&apm_bin, "binary").unwrap();
439 let guard = PathGuard::new(&wt, &[], &[apm_bin.clone()]).unwrap();
440 let err = guard.check_write(&apm_bin).unwrap_err();
441 assert!(err.contains("path outside ticket worktree"));
442 }
443
444 #[test]
445 fn check_write_apm_bin_outside_worktree_rejected() {
446 let tmp = tempfile::tempdir().unwrap();
447 let wt = tmp.path().join("wt");
448 std::fs::create_dir(&wt).unwrap();
449 let apm_bin = tmp.path().join("usr").join("bin").join("apm");
450 std::fs::create_dir_all(apm_bin.parent().unwrap()).unwrap();
451 std::fs::write(&apm_bin, "binary").unwrap();
452 let guard = PathGuard::new(&wt, &[], &[apm_bin.clone()]).unwrap();
453 assert!(guard.check_write(&apm_bin).is_err());
454 }
455
456 #[test]
459 fn bash_redirect_gt_detected() {
460 let targets = detect_write_targets("echo foo > /outside/file");
461 assert!(targets.iter().any(|t| t == "/outside/file"), "got: {targets:?}");
462 }
463
464 #[test]
465 fn bash_redirect_gtgt_detected() {
466 let targets = detect_write_targets("cat data >> /outside/append.log");
467 assert!(targets.iter().any(|t| t == "/outside/append.log"), "got: {targets:?}");
468 }
469
470 #[test]
471 fn bash_tee_detected() {
472 let targets = detect_write_targets("some-cmd | tee /outside/output.txt");
473 assert!(targets.iter().any(|t| t == "/outside/output.txt"), "got: {targets:?}");
474 }
475
476 #[test]
477 fn bash_tee_flag_skipped() {
478 let targets = detect_write_targets("some-cmd | tee -a /outside/output.txt");
479 assert!(targets.iter().any(|t| t == "/outside/output.txt"), "got: {targets:?}");
480 }
481
482 #[test]
483 fn bash_cp_dest_detected() {
484 let targets = detect_write_targets("cp /inside/src /outside/dest");
485 assert!(targets.iter().any(|t| t == "/outside/dest"), "got: {targets:?}");
486 assert!(!targets.iter().any(|t| t == "/inside/src"), "src should not be write target: {targets:?}");
488 }
489
490 #[test]
491 fn bash_mv_dest_detected() {
492 let targets = detect_write_targets("mv /inside/file /outside/dest");
493 assert!(targets.iter().any(|t| t == "/outside/dest"), "got: {targets:?}");
494 }
495
496 #[test]
497 fn bash_truncate_detected() {
498 let targets = detect_write_targets("truncate -s 0 /outside/file");
499 assert!(targets.iter().any(|t| t == "/outside/file"), "got: {targets:?}");
500 }
501
502 #[test]
503 fn bash_cat_not_detected() {
504 let targets = detect_write_targets("cat /etc/resolv.conf");
505 assert!(targets.is_empty(), "cat should produce no write targets: {targets:?}");
506 }
507
508 #[test]
509 fn bash_grep_not_detected() {
510 let targets = detect_write_targets("grep pattern /etc/hosts");
511 assert!(targets.is_empty(), "grep should produce no write targets: {targets:?}");
512 }
513
514 #[test]
515 fn bash_ls_not_detected() {
516 let targets = detect_write_targets("ls /outside/dir");
517 assert!(targets.is_empty(), "ls should produce no write targets: {targets:?}");
518 }
519
520 #[test]
521 fn bash_diff_not_detected() {
522 let targets = detect_write_targets("diff /file1 /file2");
523 assert!(targets.is_empty(), "diff should produce no write targets: {targets:?}");
524 }
525
526 #[test]
527 fn bash_wc_not_detected() {
528 let targets = detect_write_targets("wc -l /var/log/syslog");
529 assert!(targets.is_empty(), "wc should produce no write targets: {targets:?}");
530 }
531
532 #[test]
533 fn bash_echo_no_path_not_detected() {
534 let targets = detect_write_targets("echo hello");
535 assert!(targets.is_empty(), "echo without path should produce no write targets: {targets:?}");
536 }
537
538 #[test]
541 fn check_bash_redirect_outside_rejected() {
542 let tmp = tempfile::tempdir().unwrap();
543 let wt = tmp.path().join("wt");
544 std::fs::create_dir(&wt).unwrap();
545 let guard = make_guard(&wt);
546 let outside = tmp.path().join("outside.txt");
547 let cmd = format!("echo foo > {}", outside.display());
548 assert!(guard.check_bash(&cmd).is_err());
549 }
550
551 #[test]
552 fn check_bash_redirect_inside_allowed() {
553 let tmp = tempfile::tempdir().unwrap();
554 let wt = tmp.path().join("wt");
555 std::fs::create_dir(&wt).unwrap();
556 let guard = make_guard(&wt);
557 let inside = wt.join("output.txt");
558 let cmd = format!("echo foo > {}", inside.display());
559 assert!(guard.check_bash(&cmd).is_ok());
560 }
561
562 #[test]
563 fn check_bash_cat_read_allowed() {
564 let tmp = tempfile::tempdir().unwrap();
565 let wt = tmp.path().join("wt");
566 std::fs::create_dir(&wt).unwrap();
567 let guard = make_guard(&wt);
568 assert!(guard.check_bash("cat /etc/resolv.conf").is_ok());
569 }
570
571 #[test]
572 fn check_bash_tilde_gitconfig_allowed() {
573 let tmp = tempfile::tempdir().unwrap();
574 let wt = tmp.path().join("wt");
575 std::fs::create_dir(&wt).unwrap();
576 let guard = make_guard(&wt);
577 assert!(guard.check_bash("cat ~/.gitconfig").is_ok());
578 }
579}