1#![expect(
2 clippy::indexing_slicing,
3 reason = "Command validators inspect byte-oriented option tokens after checking their lengths and delimiters."
4)]
5
6use std::env;
7use std::path::{Path, PathBuf};
8use tokio::fs;
9
10use anyhow::{Context, Result, anyhow};
11use vtcode_commons::paths::normalize_path;
12
13mod git;
14
15use git::{
16 validate_git_add, validate_git_checkout, validate_git_commit, validate_git_merge, validate_git_read_only,
17 validate_git_reset, validate_git_stash,
18};
19
20fn validate_allowed_flags(args: &[String], allowed_flags: &[&str], command_name: &str) -> Result<()> {
23 for arg in args {
24 if arg.starts_with('-') && !allowed_flags.contains(&arg.as_str()) {
25 return Err(anyhow!("unsupported {command_name} flag '{arg}'"));
26 }
27 }
28 Ok(())
29}
30
31fn validate_no_args(args: &[String], command_name: &str) -> Result<()> {
33 if args.is_empty() {
34 Ok(())
35 } else {
36 Err(anyhow!("{command_name} does not accept arguments"))
37 }
38}
39
40pub async fn validate_command(
46 command: &[String],
47 workspace_root: &Path,
48 working_dir: &Path,
49 confirm: bool,
50) -> Result<()> {
51 if command.is_empty() {
52 return Err(anyhow!("command cannot be empty"));
53 }
54
55 let (program, args) = command
56 .split_first()
57 .ok_or_else(|| anyhow!("command cannot be empty (unexpected)"))?;
58 let program = program.as_str();
59
60 match program {
61 "echo" => validate_echo(args),
62 "ls" => validate_ls(args, workspace_root, working_dir).await,
63 "cat" => validate_cat(args, workspace_root, working_dir).await,
64 "cp" => validate_cp(args, workspace_root, working_dir).await,
65 "head" => validate_head(args, workspace_root, working_dir).await,
66 "tail" => validate_tail(args, workspace_root, working_dir).await,
67 "printenv" => validate_printenv(args),
68 "pwd" => validate_pwd(args),
69 "rg" => validate_rg(args, workspace_root, working_dir).await,
70 "grep" => validate_grep(args, workspace_root, working_dir).await,
71 "sed" => validate_sed(args, workspace_root, working_dir).await,
72 "which" => validate_which(args),
73 "date" => validate_date(args),
74 "whoami" => validate_whoami(args),
75 "hostname" => validate_hostname(args),
76 "uname" => validate_uname(args),
77 "wc" => validate_wc(args, workspace_root, working_dir).await,
78 "git" => validate_git(args, workspace_root, working_dir, confirm).await,
79 "cargo" => validate_cargo(args, workspace_root, working_dir, confirm).await,
80 "python" | "python3" => validate_python(args, workspace_root, working_dir).await,
81 "npm" => validate_npm(args, workspace_root, working_dir).await,
82 "node" => validate_node(args, workspace_root, working_dir).await,
83 other => Err(anyhow!("command '{other}' is not permitted by the execution policy")),
84 }
85}
86
87pub async fn sanitize_working_dir(workspace_root: &Path, working_dir: Option<&str>) -> Result<PathBuf> {
89 let normalized_root = normalize_workspace_root(workspace_root)?;
90 if let Some(dir) = working_dir {
91 if dir.trim().is_empty() {
92 return Ok(normalized_root);
93 }
94 let candidate = normalize_path(&normalized_root.join(dir));
95 if !candidate.starts_with(&normalized_root) {
96 return Err(anyhow!("working directory '{dir}' escapes the workspace root"));
97 }
98 ensure_within_workspace(&normalized_root, &candidate).await?;
99 Ok(candidate)
100 } else {
101 Ok(normalized_root)
102 }
103}
104
105fn validate_echo(args: &[String]) -> Result<()> {
106 validate_allowed_flags(args, &["-n", "-e", "-E"], "echo")
107}
108
109async fn validate_ls(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
110 let allowed_ls_flags = &["-1", "-a", "-l"];
111 for arg in args {
112 if arg.starts_with('-') {
113 if !allowed_ls_flags.contains(&arg.as_str()) {
114 return Err(anyhow!("unsupported ls flag '{arg}'"));
115 }
116 } else {
117 let path = resolve_path(workspace_root, working_dir, arg).await?;
118 ensure_path_exists(&path)?;
119 }
120 }
121 Ok(())
122}
123
124async fn validate_cat(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
125 let allowed_cat_flags = &["-b", "-n", "-t"];
126 let mut files = Vec::with_capacity(args.len());
127
128 for arg in args {
129 if arg.starts_with('-') {
130 if !allowed_cat_flags.contains(&arg.as_str()) {
131 return Err(anyhow!("unsupported cat flag '{arg}'"));
132 }
133 } else {
134 let path = resolve_path(workspace_root, working_dir, arg).await?;
135 ensure_is_file(&path).await?;
136 files.push(path);
137 }
138 }
139
140 if files.is_empty() {
141 return Err(anyhow!("cat requires at least one readable file"));
142 }
143
144 Ok(())
145}
146
147async fn validate_cp(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
148 let mut positional = Vec::new();
149 let mut allow_recursive = false;
150
151 for arg in args {
152 match arg.as_str() {
153 "-r" | "-R" | "--recursive" => {
154 allow_recursive = true;
155 }
156 value if value.starts_with('-') => {
157 return Err(anyhow!("unsupported cp flag '{value}'"));
158 }
159 value => positional.push(value.to_owned()),
160 }
161 }
162
163 if positional.len() < 2 {
164 return Err(anyhow!("cp requires a source and destination"));
165 }
166
167 let dest_raw = positional
168 .last()
169 .ok_or_else(|| anyhow!("cp command missing destination path"))?;
170 let sources = &positional[..positional.len() - 1];
171
172 for source in sources {
173 let path = resolve_path(workspace_root, working_dir, source).await?;
174 let metadata = fs::metadata(&path)
175 .await
176 .with_context(|| format!("failed to inspect source '{source}'"))?;
177 if metadata.is_dir() && !allow_recursive {
178 return Err(anyhow!("copying directories requires the recursive flag for '{source}'"));
179 }
180 if !metadata.is_file() && !metadata.is_dir() {
181 return Err(anyhow!("unsupported source type for '{source}'"));
182 }
183 }
184
185 let dest_path = resolve_path_allow_new(workspace_root, working_dir, dest_raw).await?;
186 if let Some(parent) = dest_path.parent()
187 && !fs::try_exists(parent).await.unwrap_or(false)
188 {
189 return Err(anyhow!("destination parent '{}' must exist", parent.display()));
190 }
191
192 Ok(())
193}
194
195async fn validate_head(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
196 let mut positional = Vec::new();
197 let mut index = 0;
198
199 while index < args.len() {
200 let current = &args[index];
201 match current.as_str() {
202 "-c" | "-n" => {
203 let value = args
204 .get(index + 1)
205 .ok_or_else(|| anyhow!("option '{current}' requires a value"))?;
206 let _parsed =
207 parse_positive_int(value).with_context(|| format!("invalid value '{value}' for '{current}'"))?;
208 index += 2;
209 }
210 value if value.starts_with('-') => {
211 return Err(anyhow!("unsupported head flag '{value}'"));
212 }
213 value => {
214 positional.push(value);
215 index += 1;
216 }
217 }
218 }
219
220 if positional.is_empty() {
221 return Err(anyhow!("head requires at least one file"));
222 }
223
224 for file in positional {
225 let path = resolve_path(workspace_root, working_dir, file).await?;
226 ensure_is_file(&path).await?;
227 }
228
229 Ok(())
230}
231
232fn validate_printenv(args: &[String]) -> Result<()> {
233 match args.len() {
234 0 => Ok(()),
235 1 => {
236 let name = &args[0];
237 if name.is_empty() || !name.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_') {
238 return Err(anyhow!("invalid environment variable name '{name}'"));
239 }
240 Ok(())
241 }
242 _ => Err(anyhow!("printenv accepts zero or one argument")),
243 }
244}
245
246fn validate_pwd(args: &[String]) -> Result<()> {
247 validate_no_args(args, "pwd")
248}
249
250async fn validate_rg(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
251 let mut index = 0;
252 let mut allow_no_pattern = false;
253
254 while index < args.len() {
255 let current = &args[index];
256 if current == "--" {
257 index += 1;
258 break;
259 }
260
261 match current.as_str() {
262 "--pre" | "--pre-glob" => {
264 return Err(anyhow!(
265 "ripgrep preprocessor flag '{current}' is not permitted for security reasons. \
266 This flag enables arbitrary command execution."
267 ));
268 }
269 "-A" | "-B" | "-C" | "-d" | "--max-depth" | "-m" | "--max-count" => {
270 let value = args
271 .get(index + 1)
272 .ok_or_else(|| anyhow!("option '{current}' requires a value"))?;
273 let _parsed =
274 parse_positive_int(value).with_context(|| format!("invalid value '{value}' for '{current}'"))?;
275 index += 2;
276 }
277 "-g" | "--glob" => {
278 let value = args
279 .get(index + 1)
280 .ok_or_else(|| anyhow!("option '{current}' requires a value"))?;
281 if value.is_empty() {
282 return Err(anyhow!("glob value for '{current}' cannot be empty"));
283 }
284 index += 2;
285 }
286 "-n" | "-i" | "-l" | "--files" | "--files-with-matches" | "--files-without-match" => {
287 if matches!(current.as_str(), "--files" | "--files-with-matches" | "--files-without-match") {
288 allow_no_pattern = true;
289 }
290 index += 1;
291 }
292 value if value.starts_with('-') => {
293 return Err(anyhow!("unsupported ripgrep flag '{value}'"));
294 }
295 _ => break,
296 }
297 }
298
299 let remaining = &args[index..];
300 if remaining.is_empty() && !allow_no_pattern {
301 return Err(anyhow!("ripgrep requires a pattern unless file listing flags are used"));
302 }
303
304 let mut rem_index = 0;
305 if !remaining.is_empty() {
306 let pattern = &remaining[0];
307 if pattern.is_empty() {
308 return Err(anyhow!("ripgrep pattern cannot be empty"));
309 }
310 rem_index = 1;
311 }
312
313 if remaining.len() > rem_index {
314 let search_root = &remaining[rem_index];
315 let path = resolve_path_allow_dir(workspace_root, working_dir, search_root).await?;
316 if !fs::try_exists(&path).await.unwrap_or(false) {
317 return Err(anyhow!("search path '{search_root}' does not exist"));
318 }
319 if remaining.len() > rem_index + 1 {
320 return Err(anyhow!("ripgrep accepts at most one search path"));
321 }
322 }
323
324 Ok(())
325}
326
327async fn validate_sed(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
328 let mut commands = Vec::new();
329 let mut files = Vec::new();
330 let mut index = 0;
331
332 while index < args.len() {
333 let current = &args[index];
334 match current.as_str() {
335 "-n" | "-u" => {
336 index += 1;
337 }
338 "-e" => {
339 let value = args.get(index + 1).ok_or_else(|| anyhow!("-e requires a sed command"))?;
340 ensure_safe_sed_command(value)?;
341 commands.push(value.clone());
342 index += 2;
343 }
344 value if value.starts_with('-') => {
345 return Err(anyhow!("unsupported sed flag '{value}'"));
346 }
347 value => {
348 if commands.is_empty() {
349 ensure_safe_sed_command(value)?;
350 commands.push(value.to_owned());
351 index += 1;
352 } else {
353 let path = resolve_path(workspace_root, working_dir, value).await?;
354 ensure_is_file(&path).await?;
355 files.push(path);
356 index += 1;
357 }
358 }
359 }
360 }
361
362 if commands.is_empty() {
363 return Err(anyhow!("sed requires at least one command"));
364 }
365
366 if files.is_empty() {
367 return Err(anyhow!("sed requires at least one readable file"));
368 }
369
370 Ok(())
371}
372
373fn validate_which(args: &[String]) -> Result<()> {
374 if args.is_empty() {
375 return Err(anyhow!("which requires at least one program name"));
376 }
377
378 for arg in args {
379 match arg.as_str() {
380 "-a" | "-s" => continue,
381 value if value.starts_with('-') => {
382 return Err(anyhow!("unsupported which flag '{value}'"));
383 }
384 value => {
385 if value.is_empty() || value.contains('/') || value.chars().any(|ch| ch.is_whitespace()) {
386 return Err(anyhow!("program name '{value}' contains unsupported characters"));
387 }
388 }
389 }
390 }
391
392 Ok(())
393}
394
395async fn validate_git(args: &[String], workspace_root: &Path, working_dir: &Path, confirm: bool) -> Result<()> {
396 if args.is_empty() {
397 return Err(anyhow!("git requires a subcommand"));
398 }
399
400 let subcommand = args[0].as_str();
401 let subargs = &args[1..];
402
403 match subcommand {
405 "status" | "log" | "show" | "diff" | "branch" | "tag" | "remote" => {
407 if subcommand == "tag" && !subargs.is_empty() && !subargs[0].starts_with('-') {
409 }
412 validate_git_read_only(subcommand, subargs)
413 }
414
415 "ls-tree" | "ls-files" | "cat-file" | "rev-parse" | "describe" => validate_git_read_only(subcommand, subargs),
417
418 "config" if subargs.is_empty() || subargs.iter().all(|a| !a.starts_with("--")) => {
420 validate_git_read_only(subcommand, subargs)
421 }
422
423 "blame" | "grep" | "shortlog" | "format-patch" => validate_git_read_only(subcommand, subargs),
425
426 "stash" if matches!(subargs.first().map(|s| s.as_str()), Some("list" | "show" | "pop" | "apply" | "drop")) => {
428 validate_git_stash(subargs)
429 }
430
431 "add" => validate_git_add(subargs, workspace_root, working_dir).await,
433 "commit" => validate_git_commit(subargs),
434 "reset" => validate_git_reset(subargs, confirm),
435 "checkout" | "switch" => validate_git_checkout(subargs, workspace_root, working_dir, confirm).await,
436 "restore" => validate_git_checkout(subargs, workspace_root, working_dir, confirm).await,
437 "merge" => validate_git_merge(subargs),
438
439 "push" => {
441 if subargs.iter().any(|a| a.contains("force") || a == "-f" || a == "--no-verify") {
443 Err(anyhow!("git push with force flags is not permitted. Use safe push operations only."))
444 } else {
445 validate_git_read_only(subcommand, subargs)
446 }
447 }
448
449 "force-push" => Err(anyhow!("git force-push is not permitted by the execution policy")),
450
451 "clean" => {
452 Err(anyhow!("git clean is not permitted by the execution policy. Use explicit rm commands instead."))
453 }
454
455 "gc" if subargs.iter().any(|a| a.contains("aggressive")) => {
456 Err(anyhow!("git gc with aggressive flag is not permitted"))
457 }
458
459 "filter-branch" | "rebase" | "cherry-pick" => {
460 Err(anyhow!("git {subcommand} is not permitted - complex history operations require confirmation"))
461 }
462
463 other => Err(anyhow!("git subcommand '{other}' is not permitted by the execution policy")),
464 }
465}
466
467fn normalize_workspace_root(workspace_root: &Path) -> Result<PathBuf> {
468 if workspace_root.is_absolute() {
469 return Ok(normalize_path(workspace_root));
470 }
471
472 let cwd = env::current_dir().context("failed to resolve current working directory")?;
473 Ok(normalize_path(&cwd.join(workspace_root)))
474}
475
476fn ensure_path_exists(path: &Path) -> Result<()> {
477 if path.exists() {
478 Ok(())
479 } else {
480 Err(anyhow!("path '{}' does not exist", path.display()))
481 }
482}
483
484async fn ensure_is_file(path: &Path) -> Result<()> {
485 let metadata = fs::metadata(path)
486 .await
487 .with_context(|| format!("failed to inspect '{}'", path.display()))?;
488 if metadata.is_file() {
489 Ok(())
490 } else {
491 Err(anyhow!("'{}' is not a file", path.display()))
492 }
493}
494
495fn parse_positive_int(value: &str) -> Result<u64> {
496 let parsed: u64 = value.parse()?;
497 if parsed == 0 {
498 return Err(anyhow!("value must be greater than zero"));
499 }
500 Ok(parsed)
501}
502
503fn ensure_safe_sed_command(value: &str) -> Result<()> {
504 if value.trim().is_empty() {
505 return Err(anyhow!("sed command cannot be empty"));
506 }
507 if value.contains([';', '|', '&', '`']) {
508 return Err(anyhow!("sed command contains unsupported control characters"));
509 }
510
511 let mut chars = value.chars();
512 if chars.next() != Some('s') {
513 return Err(anyhow!("only sed substitution commands are supported"));
514 }
515 let delimiter = chars.next().ok_or_else(|| anyhow!("sed substitution is missing a delimiter"))?;
516 if delimiter.is_ascii_alphanumeric() || delimiter.is_ascii_whitespace() {
517 return Err(anyhow!("invalid sed delimiter"));
518 }
519
520 let mut pattern = String::new();
521 let mut replacement = String::new();
522 let mut flags = String::new();
523
524 parse_sed_section(&mut chars, delimiter, &mut pattern)?;
525 parse_sed_section(&mut chars, delimiter, &mut replacement)?;
526 collect_sed_flags(chars, &mut flags)?;
527
528 if flags.chars().any(|ch| matches!(ch, 'e' | 'E' | 'F' | 'f')) {
529 return Err(anyhow!("sed execution flags are not permitted in substitution"));
530 }
531
532 Ok(())
533}
534
535async fn ensure_within_workspace(normalized_root: &Path, candidate: &Path) -> Result<()> {
536 vtcode_commons::paths::ensure_path_within_workspace_resolved(candidate, normalized_root)
537 .await
538 .map(|_| ())
539}
540
541fn parse_sed_section(chars: &mut std::str::Chars<'_>, delimiter: char, target: &mut String) -> Result<()> {
542 let mut escaped = false;
543 for ch in chars.by_ref() {
544 if escaped {
545 target.push(ch);
546 escaped = false;
547 continue;
548 }
549 match ch {
550 '\\' => {
551 escaped = true;
552 }
553 value if value == delimiter => {
554 return Ok(());
555 }
556 other => target.push(other),
557 }
558 }
559 Err(anyhow!("sed command is missing a closing delimiter"))
560}
561
562fn collect_sed_flags(chars: std::str::Chars<'_>, target: &mut String) -> Result<()> {
563 for ch in chars {
564 if ch.is_ascii_alphabetic() {
565 target.push(ch);
566 } else {
567 return Err(anyhow!("sed flags contain unsupported characters"));
568 }
569 }
570 Ok(())
571}
572
573async fn validate_tail(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
575 for arg in args {
577 if !arg.starts_with('-') {
578 let path = normalize_path(&working_dir.join(arg));
579 ensure_within_workspace(workspace_root, &path).await?;
580 }
581 }
582 Ok(())
583}
584
585async fn validate_grep(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
586 let mut pattern_seen = false;
588 for arg in args {
589 if !arg.starts_with('-') && pattern_seen {
590 let path = normalize_path(&working_dir.join(arg));
592 ensure_within_workspace(workspace_root, &path).await?;
593 } else if !arg.starts_with('-') {
594 pattern_seen = true;
595 }
596 }
597 Ok(())
598}
599
600fn validate_date(args: &[String]) -> Result<()> {
601 for arg in args {
603 if arg.starts_with('+') {
604 continue;
606 }
607 }
608 Ok(())
609}
610
611fn validate_whoami(_args: &[String]) -> Result<()> {
612 Ok(())
614}
615
616fn validate_hostname(_args: &[String]) -> Result<()> {
617 Ok(())
619}
620
621fn validate_uname(args: &[String]) -> Result<()> {
622 let safe_flags = ["-a", "-s", "-n", "-r", "-v", "-m"];
624 for arg in args {
625 if arg.starts_with('-') && !safe_flags.contains(&arg.as_str()) {
626 return Err(anyhow!("unsupported uname flag '{arg}'"));
627 }
628 }
629 Ok(())
630}
631
632async fn validate_wc(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
633 for arg in args {
635 if !arg.starts_with('-') {
636 let path = normalize_path(&working_dir.join(arg));
637 ensure_within_workspace(workspace_root, &path).await?;
638 }
639 }
640 Ok(())
641}
642
643async fn validate_cargo(args: &[String], workspace_root: &Path, working_dir: &Path, confirm: bool) -> Result<()> {
644 if args.is_empty() {
646 return Err(anyhow!("cargo requires a subcommand"));
647 }
648
649 let subcommand = args[0].as_str();
650 match subcommand {
651 "build" | "check" | "test" | "doc" | "clippy" | "fmt" | "run" | "bench" | "expand" | "tree" | "metadata"
653 | "search" | "cache" => {
654 ensure_within_workspace(workspace_root, working_dir).await?;
656 Ok(())
657 }
658 "clean" | "install" | "uninstall" | "publish" | "yank" => {
660 if confirm {
661 ensure_within_workspace(workspace_root, working_dir).await?;
663 Ok(())
664 } else {
665 Err(anyhow!("cargo {subcommand} is potentially destructive; set `confirm=true` to proceed."))
666 }
667 }
668 other => Err(anyhow!("cargo subcommand '{other}' is not permitted by the execution policy")),
669 }
670}
671
672async fn validate_python(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
673 ensure_within_workspace(workspace_root, working_dir).await?;
675 if args.is_empty() {
676 return Ok(()); }
678
679 let first_arg = &args[0];
680
681 if first_arg == "-c" {
683 return Err(anyhow!(
684 "python -c is not permitted for security reasons: \
685 it enables arbitrary code execution outside the workspace sandbox"
686 ));
687 }
688
689 if first_arg == "-e" || first_arg == "-exec" {
691 return Err(anyhow!("python {first_arg} is not permitted: it enables arbitrary code execution"));
692 }
693
694 if first_arg == "-m" || first_arg == "-W" {
695 if first_arg != "-m" && args.len() > 1 {
697 let path = normalize_path(&working_dir.join(&args[1]));
698 ensure_within_workspace(workspace_root, &path).await?;
699 }
700 } else if !first_arg.starts_with('-') {
701 let path = normalize_path(&working_dir.join(first_arg));
703 ensure_within_workspace(workspace_root, &path).await?;
704 }
705 Ok(())
706}
707
708async fn validate_npm(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
709 ensure_within_workspace(workspace_root, working_dir).await?;
711 if args.is_empty() {
712 return Ok(());
713 }
714
715 let subcommand = args[0].as_str();
716 match subcommand {
717 "publish" | "unpublish" => Err(anyhow!("npm {subcommand} is not permitted by the execution policy")),
719 _ => Ok(()),
721 }
722}
723
724async fn validate_node(args: &[String], workspace_root: &Path, working_dir: &Path) -> Result<()> {
725 ensure_within_workspace(workspace_root, working_dir).await?;
727 if args.is_empty() {
728 return Ok(()); }
730
731 let first_arg = &args[0];
732
733 if first_arg == "-e" || first_arg == "--eval" {
735 return Err(anyhow!(
736 "node {first_arg} is not permitted for security reasons: \
737 it enables arbitrary code execution outside the workspace sandbox"
738 ));
739 }
740
741 if !first_arg.starts_with('-') {
742 let path = normalize_path(&working_dir.join(first_arg));
744 ensure_within_workspace(workspace_root, &path).await?;
745 }
746 Ok(())
747}
748
749async fn resolve_path(workspace_root: &Path, working_dir: &Path, value: &str) -> Result<PathBuf> {
750 let base = build_candidate_path(workspace_root, working_dir, value).await?;
751 if !fs::try_exists(&base).await.unwrap_or(false) {
752 return Err(anyhow!("path '{value}' does not exist"));
753 }
754 if !base.starts_with(workspace_root) {
755 return Err(anyhow!("path '{value}' is outside the workspace root"));
756 }
757 Ok(base)
758}
759
760async fn resolve_path_allow_new(workspace_root: &Path, working_dir: &Path, value: &str) -> Result<PathBuf> {
761 let candidate = build_candidate_path(workspace_root, working_dir, value).await?;
762 if !candidate.starts_with(workspace_root) {
763 return Err(anyhow!("path '{value}' is outside the workspace root"));
764 }
765 Ok(candidate)
766}
767
768async fn resolve_path_allow_dir(workspace_root: &Path, working_dir: &Path, value: &str) -> Result<PathBuf> {
769 let candidate = build_candidate_path(workspace_root, working_dir, value).await?;
770 if !candidate.starts_with(workspace_root) {
771 return Err(anyhow!("path '{value}' is outside the workspace root"));
772 }
773 Ok(candidate)
774}
775
776async fn build_candidate_path(workspace_root: &Path, working_dir: &Path, value: &str) -> Result<PathBuf> {
777 let normalized_root = normalize_workspace_root(workspace_root)?;
778 let normalized_working = normalize_path(working_dir);
779 let raw_path = Path::new(value);
780 let candidate = if raw_path.is_absolute() {
781 normalize_path(raw_path)
782 } else {
783 normalize_path(&normalized_working.join(raw_path))
784 };
785
786 if !candidate.starts_with(&normalized_root) {
787 return Err(anyhow!("path '{value}' escapes the workspace root"));
788 }
789 ensure_within_workspace(&normalized_root, &candidate).await?;
790 Ok(candidate)
791}
792
793#[cfg(test)]
794mod tests;