use std::io;
use std::path::{Path, PathBuf};
#[derive(Debug)]
pub enum CdPeelError {
CdPrefixInvalid { detail: String, work_dir: String },
UnsafeArg,
MissingCommand,
SpawnOther { cmd: String, source: io::Error },
}
#[must_use]
pub fn arg_has_parent_dir_ref(arg: &str) -> bool {
let a = arg.trim();
if a.is_empty() {
return false;
}
if a == ".." {
return true;
}
if a.starts_with("../") || a.ends_with("/..") || a.contains("/../") {
return true;
}
if a.contains("=../") || a.ends_with("=..") {
return true;
}
a.split('/').any(|c| c == "..")
}
pub fn is_arg_safe(cmd_name: &str, arg: &str) -> bool {
let a = arg.trim();
if cmd_name == "cd" {
return !arg_has_parent_dir_ref(a) && !a.starts_with('/');
}
if cmd_name == "cmake" {
return !a.starts_with('/');
}
!arg_has_parent_dir_ref(a) && !a.starts_with('/')
}
pub fn merge_dot_slash_with_single_relative_path(cmd_raw: &mut String, cmd_args: &mut Vec<String>) {
if cmd_raw.trim() != "./" {
return;
}
let Some(first) = cmd_args.first() else {
return;
};
let arg = first.trim();
if arg.is_empty() || arg.starts_with('-') || arg_has_parent_dir_ref(arg) || arg.starts_with('/')
{
return;
}
*cmd_raw = if arg.starts_with("./") {
arg.to_string()
} else {
format!("./{arg}")
};
cmd_args.remove(0);
}
pub fn split_command_prefix_if_embedded(cmd_raw: &mut String, cmd_args: &mut Vec<String>) {
if cmd_raw.contains('/') {
return;
}
let parts = crate::cmd_mate::split_command_line(cmd_raw);
if parts.len() <= 1 {
return;
}
let head = parts[0].clone();
if head.is_empty() {
return;
}
let mut prefix: Vec<String> = parts[1..].to_vec();
prefix.append(cmd_args);
*cmd_args = prefix;
*cmd_raw = head;
}
fn cd_prefix_invalid(work_dir: &Path, detail: impl Into<String>) -> CdPeelError {
CdPeelError::CdPrefixInvalid {
detail: detail.into(),
work_dir: work_dir.display().to_string(),
}
}
pub fn peel_workspace_cd_prefix(
workspace_root: &Path,
effective_working_dir: &mut PathBuf,
cmd_raw: &mut String,
cmd_args: &mut Vec<String>,
allow_external_paths: bool,
) -> Result<(), CdPeelError> {
let anchor = workspace_root
.canonicalize()
.map_err(|e| CdPeelError::SpawnOther {
cmd: "canonicalize(workspace)".to_string(),
source: e,
})?;
loop {
if !cmd_raw.eq_ignore_ascii_case("cd") {
break;
}
if cmd_args.len() < 3 || cmd_args[1] != "&&" {
return Err(cd_prefix_invalid(
effective_working_dir,
"run_command 不经过 shell;`cd` 仅支持参数形式 [相对目录, \"&&\", 命令, …],例如 [\"frontend\", \"&&\", \"cargo\", \"check\", …]",
));
}
let dir = cmd_args[0].trim();
if !allow_external_paths && !is_arg_safe("cd", dir) {
return Err(CdPeelError::UnsafeArg);
}
let candidate = effective_working_dir.join(dir);
if !candidate.is_dir() {
return Err(cd_prefix_invalid(
effective_working_dir,
format!("路径 `{dir}` 不是已存在目录"),
));
}
let canon_cand = candidate
.canonicalize()
.map_err(|e| CdPeelError::SpawnOther {
cmd: format!("canonicalize({})", candidate.display()),
source: e,
})?;
if !allow_external_paths && !canon_cand.starts_with(&anchor) {
return Err(CdPeelError::UnsafeArg);
}
*effective_working_dir = canon_cand;
*cmd_args = cmd_args[2..].to_vec();
if cmd_args.is_empty() {
return Err(CdPeelError::MissingCommand);
}
*cmd_raw = cmd_args[0].clone();
let rest: Vec<String> = cmd_args[1..].to_vec();
*cmd_args = rest;
split_command_prefix_if_embedded(cmd_raw, cmd_args);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{arg_has_parent_dir_ref, is_arg_safe, merge_dot_slash_with_single_relative_path};
#[test]
fn parent_dir_ref_detects_path_traversal_not_git_range() {
assert!(arg_has_parent_dir_ref(".."));
assert!(arg_has_parent_dir_ref("../x"));
assert!(arg_has_parent_dir_ref("a/../b"));
assert!(arg_has_parent_dir_ref("a/.."));
assert!(arg_has_parent_dir_ref("--exclude=../x"));
assert!(arg_has_parent_dir_ref("--opt=.."));
assert!(!arg_has_parent_dir_ref("main..HEAD"));
assert!(!arg_has_parent_dir_ref("a...b"));
assert!(!arg_has_parent_dir_ref("HEAD~1..HEAD"));
assert!(!arg_has_parent_dir_ref("src"));
assert!(!arg_has_parent_dir_ref(""));
}
#[test]
fn is_arg_safe_allows_git_rev_range() {
assert!(is_arg_safe("git", "main..HEAD"));
assert!(is_arg_safe("git", "a...b"));
assert!(!is_arg_safe("git", "../outside"));
assert!(!is_arg_safe("ls", "/tmp"));
assert!(is_arg_safe("cmake", ".."));
}
#[test]
fn merge_dot_slash_single_relative_path() {
let mut cmd = "./".to_string();
let mut args = vec!["hello/build/hello".to_string()];
merge_dot_slash_with_single_relative_path(&mut cmd, &mut args);
assert_eq!(cmd, "./hello/build/hello");
assert!(args.is_empty());
}
#[test]
fn merge_dot_slash_preserves_arg_with_dot_slash_prefix() {
let mut cmd = "./".to_string();
let mut args = vec!["./bin/app".to_string()];
merge_dot_slash_with_single_relative_path(&mut cmd, &mut args);
assert_eq!(cmd, "./bin/app");
assert!(args.is_empty());
}
#[test]
fn merge_dot_slash_merges_leading_path_keeps_rest_args() {
let mut cmd = "./".to_string();
let mut args = vec![
"build/hello".to_string(),
"-g".to_string(),
"Hi".to_string(),
"-n".to_string(),
"2".to_string(),
"Alice".to_string(),
];
merge_dot_slash_with_single_relative_path(&mut cmd, &mut args);
assert_eq!(cmd, "./build/hello");
assert_eq!(
args,
vec![
"-g".to_string(),
"Hi".to_string(),
"-n".to_string(),
"2".to_string(),
"Alice".to_string(),
]
);
}
#[test]
fn merge_dot_slash_skips_when_first_arg_is_flag() {
let mut cmd = "./".to_string();
let mut args = vec!["-g".to_string(), "Hi".to_string()];
merge_dot_slash_with_single_relative_path(&mut cmd, &mut args);
assert_eq!(cmd, "./");
assert_eq!(args, vec!["-g".to_string(), "Hi".to_string()]);
}
#[test]
fn merge_dot_slash_skips_unsafe_arg() {
let mut cmd = "./".to_string();
let mut args = vec!["../outside".to_string()];
merge_dot_slash_with_single_relative_path(&mut cmd, &mut args);
assert_eq!(cmd, "./");
assert_eq!(args, vec!["../outside".to_string()]);
}
}