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 &arg in &tokens[i + 1..n] {
247 if is_shell_sep(arg) {
248 break;
249 }
250 if arg.starts_with('-') {
251 continue;
252 }
253 if is_path_token(arg) {
254 targets.push(expand_home(arg));
255 }
256 break;
257 }
258 }
259 "cp" | "mv" => {
260 let mut last: Option<String> = None;
262 for &arg in &tokens[i + 1..n] {
263 if is_shell_sep(arg) {
264 break;
265 }
266 if !arg.starts_with('-') && is_path_token(arg) {
267 last = Some(expand_home(arg));
268 }
269 }
270 if let Some(p) = last {
271 targets.push(p);
272 }
273 }
274 "truncate" => {
275 let mut j = i + 1;
276 while j < n {
277 let arg = tokens[j];
278 if is_shell_sep(arg) {
279 break;
280 }
281 if arg == "-s" || arg == "--size" {
283 j += 2;
284 continue;
285 }
286 if !arg.starts_with('-') && is_path_token(arg) {
287 targets.push(expand_home(arg));
288 }
289 j += 1;
290 }
291 }
292 _ => {}
293 }
294
295 i += 1;
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 #[test]
306 fn canonicalize_lenient_absolute_existing() {
307 let tmp = tempfile::tempdir().unwrap();
308 let p = tmp.path().to_path_buf();
309 let result = canonicalize_lenient(&p);
310 assert!(result.is_absolute());
312 }
313
314 #[test]
315 fn canonicalize_lenient_nonexistent_leaf() {
316 let tmp = tempfile::tempdir().unwrap();
317 let p = tmp.path().join("nonexistent.txt");
318 let result = canonicalize_lenient(&p);
319 assert!(result.is_absolute());
321 assert_eq!(result.file_name().unwrap().to_str().unwrap(), "nonexistent.txt");
322 }
323
324 #[test]
325 fn canonicalize_lenient_dotdot_inside_existing() {
326 let tmp = tempfile::tempdir().unwrap();
327 let sub = tmp.path().join("sub");
328 std::fs::create_dir(&sub).unwrap();
329 let candidate = sub.join("..").join("other.txt");
331 let result = canonicalize_lenient(&candidate);
332 let expected_parent = std::fs::canonicalize(tmp.path()).unwrap();
334 assert_eq!(result.parent().unwrap(), expected_parent);
335 }
336
337 #[test]
338 fn canonicalize_lenient_dotdot_escape_stays_out() {
339 let tmp = tempfile::tempdir().unwrap();
340 let wt = tmp.path().join("worktree");
341 let sub = wt.join("subdir");
342 std::fs::create_dir_all(&sub).unwrap();
343 let path = sub.join("..").join("..").join("etc").join("passwd");
345 let result = canonicalize_lenient(&path);
346 let canon_wt = std::fs::canonicalize(&wt).unwrap();
348 assert!(!result.starts_with(&canon_wt));
349 }
350
351 #[test]
352 fn canonicalize_lenient_symlink_inside_worktree_resolves_outside() {
353 let tmp = tempfile::tempdir().unwrap();
354 let wt = tmp.path().join("wt");
355 std::fs::create_dir(&wt).unwrap();
356 let outside = tmp.path().join("outside");
357 std::fs::create_dir(&outside).unwrap();
358 let link = wt.join("link");
359 std::os::unix::fs::symlink(&outside, &link).unwrap();
360 let target = link.join("secret.txt");
361 let result = canonicalize_lenient(&target);
362 let canon_wt = std::fs::canonicalize(&wt).unwrap();
363 assert!(!result.starts_with(&canon_wt));
364 }
365
366 fn make_guard(wt: &Path) -> PathGuard {
369 PathGuard::new(wt, &[], &[]).unwrap()
370 }
371
372 #[test]
373 fn check_write_inside_worktree_allowed() {
374 let tmp = tempfile::tempdir().unwrap();
375 let wt = tmp.path().join("wt");
376 std::fs::create_dir(&wt).unwrap();
377 let guard = make_guard(&wt);
378 assert!(guard.check_write(&wt.join("file.txt")).is_ok());
379 }
380
381 #[test]
382 fn check_write_outside_worktree_rejected() {
383 let tmp = tempfile::tempdir().unwrap();
384 let wt = tmp.path().join("wt");
385 std::fs::create_dir(&wt).unwrap();
386 let guard = make_guard(&wt);
387 let outside = tmp.path().join("outside.txt");
388 let err = guard.check_write(&outside).unwrap_err();
389 assert!(err.contains("path outside ticket worktree"));
390 assert!(err.contains("APM_TICKET_WORKTREE"));
391 }
392
393 #[test]
394 fn check_write_rejection_message_contains_worktree() {
395 let tmp = tempfile::tempdir().unwrap();
396 let wt = tmp.path().join("wt");
397 std::fs::create_dir(&wt).unwrap();
398 let guard = make_guard(&wt);
399 let err = guard.check_write(&tmp.path().join("x")).unwrap_err();
400 assert!(err.contains("APM_TICKET_WORKTREE"));
401 }
402
403 #[test]
404 fn check_write_dotdot_escape_rejected() {
405 let tmp = tempfile::tempdir().unwrap();
406 let wt = tmp.path().join("wt");
407 let sub = wt.join("sub");
408 std::fs::create_dir_all(&sub).unwrap();
409 let guard = make_guard(&wt);
410 let path = sub.join("..").join("..").join("etc").join("passwd");
412 assert!(guard.check_write(&path).is_err());
413 }
414
415 #[test]
416 fn check_write_symlink_to_outside_rejected() {
417 let tmp = tempfile::tempdir().unwrap();
418 let wt = tmp.path().join("wt");
419 std::fs::create_dir(&wt).unwrap();
420 let outside = tmp.path().join("outside");
421 std::fs::create_dir(&outside).unwrap();
422 let link = wt.join("link");
423 std::os::unix::fs::symlink(&outside, &link).unwrap();
424 let guard = make_guard(&wt);
425 assert!(guard.check_write(&link.join("file.txt")).is_err());
426 }
427
428 #[test]
429 fn check_write_protected_inside_worktree_rejected() {
430 let tmp = tempfile::tempdir().unwrap();
431 let wt = tmp.path().join("wt");
432 std::fs::create_dir(&wt).unwrap();
433 let apm_bin = wt.join("target").join("debug").join("apm");
435 std::fs::create_dir_all(apm_bin.parent().unwrap()).unwrap();
436 std::fs::write(&apm_bin, "binary").unwrap();
437 let guard = PathGuard::new(&wt, &[], std::slice::from_ref(&apm_bin)).unwrap();
438 let err = guard.check_write(&apm_bin).unwrap_err();
439 assert!(err.contains("path outside ticket worktree"));
440 }
441
442 #[test]
443 fn check_write_apm_bin_outside_worktree_rejected() {
444 let tmp = tempfile::tempdir().unwrap();
445 let wt = tmp.path().join("wt");
446 std::fs::create_dir(&wt).unwrap();
447 let apm_bin = tmp.path().join("usr").join("bin").join("apm");
448 std::fs::create_dir_all(apm_bin.parent().unwrap()).unwrap();
449 std::fs::write(&apm_bin, "binary").unwrap();
450 let guard = PathGuard::new(&wt, &[], std::slice::from_ref(&apm_bin)).unwrap();
451 assert!(guard.check_write(&apm_bin).is_err());
452 }
453
454 #[test]
457 fn bash_redirect_gt_detected() {
458 let targets = detect_write_targets("echo foo > /outside/file");
459 assert!(targets.iter().any(|t| t == "/outside/file"), "got: {targets:?}");
460 }
461
462 #[test]
463 fn bash_redirect_gtgt_detected() {
464 let targets = detect_write_targets("cat data >> /outside/append.log");
465 assert!(targets.iter().any(|t| t == "/outside/append.log"), "got: {targets:?}");
466 }
467
468 #[test]
469 fn bash_tee_detected() {
470 let targets = detect_write_targets("some-cmd | tee /outside/output.txt");
471 assert!(targets.iter().any(|t| t == "/outside/output.txt"), "got: {targets:?}");
472 }
473
474 #[test]
475 fn bash_tee_flag_skipped() {
476 let targets = detect_write_targets("some-cmd | tee -a /outside/output.txt");
477 assert!(targets.iter().any(|t| t == "/outside/output.txt"), "got: {targets:?}");
478 }
479
480 #[test]
481 fn bash_cp_dest_detected() {
482 let targets = detect_write_targets("cp /inside/src /outside/dest");
483 assert!(targets.iter().any(|t| t == "/outside/dest"), "got: {targets:?}");
484 assert!(!targets.iter().any(|t| t == "/inside/src"), "src should not be write target: {targets:?}");
486 }
487
488 #[test]
489 fn bash_mv_dest_detected() {
490 let targets = detect_write_targets("mv /inside/file /outside/dest");
491 assert!(targets.iter().any(|t| t == "/outside/dest"), "got: {targets:?}");
492 }
493
494 #[test]
495 fn bash_truncate_detected() {
496 let targets = detect_write_targets("truncate -s 0 /outside/file");
497 assert!(targets.iter().any(|t| t == "/outside/file"), "got: {targets:?}");
498 }
499
500 #[test]
501 fn bash_cat_not_detected() {
502 let targets = detect_write_targets("cat /etc/resolv.conf");
503 assert!(targets.is_empty(), "cat should produce no write targets: {targets:?}");
504 }
505
506 #[test]
507 fn bash_grep_not_detected() {
508 let targets = detect_write_targets("grep pattern /etc/hosts");
509 assert!(targets.is_empty(), "grep should produce no write targets: {targets:?}");
510 }
511
512 #[test]
513 fn bash_ls_not_detected() {
514 let targets = detect_write_targets("ls /outside/dir");
515 assert!(targets.is_empty(), "ls should produce no write targets: {targets:?}");
516 }
517
518 #[test]
519 fn bash_diff_not_detected() {
520 let targets = detect_write_targets("diff /file1 /file2");
521 assert!(targets.is_empty(), "diff should produce no write targets: {targets:?}");
522 }
523
524 #[test]
525 fn bash_wc_not_detected() {
526 let targets = detect_write_targets("wc -l /var/log/syslog");
527 assert!(targets.is_empty(), "wc should produce no write targets: {targets:?}");
528 }
529
530 #[test]
531 fn bash_echo_no_path_not_detected() {
532 let targets = detect_write_targets("echo hello");
533 assert!(targets.is_empty(), "echo without path should produce no write targets: {targets:?}");
534 }
535
536 #[test]
539 fn check_bash_redirect_outside_rejected() {
540 let tmp = tempfile::tempdir().unwrap();
541 let wt = tmp.path().join("wt");
542 std::fs::create_dir(&wt).unwrap();
543 let guard = make_guard(&wt);
544 let outside = tmp.path().join("outside.txt");
545 let cmd = format!("echo foo > {}", outside.display());
546 assert!(guard.check_bash(&cmd).is_err());
547 }
548
549 #[test]
550 fn check_bash_redirect_inside_allowed() {
551 let tmp = tempfile::tempdir().unwrap();
552 let wt = tmp.path().join("wt");
553 std::fs::create_dir(&wt).unwrap();
554 let guard = make_guard(&wt);
555 let inside = wt.join("output.txt");
556 let cmd = format!("echo foo > {}", inside.display());
557 assert!(guard.check_bash(&cmd).is_ok());
558 }
559
560 #[test]
561 fn check_bash_cat_read_allowed() {
562 let tmp = tempfile::tempdir().unwrap();
563 let wt = tmp.path().join("wt");
564 std::fs::create_dir(&wt).unwrap();
565 let guard = make_guard(&wt);
566 assert!(guard.check_bash("cat /etc/resolv.conf").is_ok());
567 }
568
569 #[test]
570 fn check_bash_tilde_gitconfig_allowed() {
571 let tmp = tempfile::tempdir().unwrap();
572 let wt = tmp.path().join("wt");
573 std::fs::create_dir(&wt).unwrap();
574 let guard = make_guard(&wt);
575 assert!(guard.check_bash("cat ~/.gitconfig").is_ok());
576 }
577}