use anyhow::Result;
use shell_words;
fn has_windows_drive_prefix(part: &str) -> bool {
part.chars()
.nth(1)
.is_some_and(|character| character == ':')
}
fn looks_like_path_argument(part: &str) -> bool {
part.contains('/') || part.contains('\\') || has_windows_drive_prefix(part)
}
fn is_explicit_absolute_path(part: &str, candidate: &std::path::Path) -> bool {
candidate.is_absolute()
|| part.starts_with('/')
|| part.starts_with('\\')
|| has_windows_drive_prefix(part)
}
#[derive(Debug, Clone)]
pub struct SanitizeResult {
pub parts: Vec<String>,
pub warnings: Vec<String>,
pub rejected: bool,
pub rejection_reason: Option<String>,
}
pub fn sanitize_command(command: &str) -> Result<SanitizeResult> {
let mut result = SanitizeResult {
parts: Vec::new(),
warnings: Vec::new(),
rejected: false,
rejection_reason: None,
};
match shell_words::split(command) {
Ok(parts) => {
result.parts = parts;
}
Err(e) => {
result.rejected = true;
result.rejection_reason = Some(format!("Failed to parse command: {}", e));
return Ok(result);
}
}
if command.contains('`') {
result
.warnings
.push("Command contains backtick subshell expansion".to_string());
}
if command.contains("$(") {
result
.warnings
.push("Command contains $() subshell expansion".to_string());
}
let dangerous_chains = ["&&", "||", ";"];
for chain in &dangerous_chains {
if command.contains(chain) {
result
.warnings
.push(format!("Command contains chaining operator: {}", chain));
}
}
let sensitive_paths = ["/etc/", "/root/", "~/.ssh/", "/dev/", "/proc/", "/sys/"];
for path in &sensitive_paths {
if command.contains(&format!("> {}", path))
|| command.contains(&format!(">> {}", path))
|| command.contains(&format!("< {}", path))
{
result.warnings.push(format!(
"Command redirects to/from sensitive path: {}",
path
));
}
}
let destructive_patterns = [
("rm -rf /", "Recursive delete of root"),
("rm -rf /*", "Recursive delete of root contents"),
("rm -rf ~", "Recursive delete of home directory"),
(":(){:|:&};:", "Fork bomb"),
("mkfs", "Filesystem creation"),
("dd if=/dev/zero", "Disk overwrite"),
("> /dev/sda", "Direct disk write"),
];
for (pattern, description) in &destructive_patterns {
if command.contains(pattern) {
result.rejected = true;
result.rejection_reason = Some(format!(
"Dangerous pattern detected: {} ({})",
pattern, description
));
return Ok(result);
}
}
Ok(result)
}
#[cfg(test)]
pub(crate) fn canonicalize(command: &str) -> Result<String> {
let parts = shell_words::split(command)?;
Ok(shell_words::join(&parts))
}
pub fn validate_workspace_bound(command: &str, workspace_root: &std::path::Path) -> Result<()> {
let normalized;
let command_for_parse = if cfg!(windows) {
normalized = command.replace('\\', "/");
&normalized
} else {
command
};
let parts = shell_words::split(command_for_parse)?;
for part in &parts {
if part.starts_with('-') || !looks_like_path_argument(part) {
continue;
}
let candidate = std::path::Path::new(part);
if is_explicit_absolute_path(part, candidate) {
if !candidate.starts_with(workspace_root) {
anyhow::bail!(
"command references path outside workspace: {} (workspace: {})",
part,
workspace_root.display()
);
}
} else if part.contains("..") {
if perspt_core::path::normalize_artifact_path(part).is_err() {
anyhow::bail!(
"command contains path that escapes workspace root: {} (workspace: {})",
part,
workspace_root.display()
);
}
let resolved = workspace_root.join(candidate);
if let Ok(canonical) = resolved.canonicalize() {
if !canonical.starts_with(workspace_root) {
anyhow::bail!(
"command escapes workspace via '..': {} resolves to {} (workspace: {})",
part,
canonical.display(),
workspace_root.display()
);
}
}
}
}
Ok(())
}
pub fn validate_artifact_mutation(
path: &str,
workspace_root: &std::path::Path,
operation: &str,
) -> Result<()> {
perspt_core::path::normalize_artifact_path(path)
.map_err(|e| anyhow::anyhow!("{} rejected for {}: {}", operation, path, e))?;
let protected: &[&str] = &[
"Cargo.toml",
"Cargo.lock",
"pyproject.toml",
"package.json",
"package-lock.json",
".gitignore",
".git",
];
let normalized = path.replace('\\', "/");
let basename = normalized.rsplit('/').next().unwrap_or(&normalized);
if !normalized.contains('/') && protected.contains(&basename) {
anyhow::bail!(
"{} rejected: '{}' is a protected project root file",
operation,
path
);
}
let resolved = workspace_root.join(path);
if resolved.is_dir() && !normalized.contains('/') {
anyhow::bail!(
"{} rejected: '{}' is a top-level directory; specify individual files",
operation,
path
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_safe_command() {
let result = sanitize_command("cargo build --release").unwrap();
assert!(!result.rejected);
assert!(result.warnings.is_empty());
}
#[test]
fn test_dangerous_command_rejected() {
let result = sanitize_command("rm -rf /").unwrap();
assert!(result.rejected);
}
#[test]
fn test_subshell_warning() {
let result = sanitize_command("echo $(whoami)").unwrap();
assert!(!result.warnings.is_empty());
}
#[test]
fn test_chaining_warning() {
let result = sanitize_command("ls && rm file").unwrap();
assert!(!result.warnings.is_empty());
}
#[test]
fn test_canonicalize() {
let normalized = canonicalize("ls -la /tmp").unwrap();
assert_eq!(normalized, "ls -la /tmp");
}
#[test]
fn test_workspace_bound_relative_safe() {
let ws = std::path::PathBuf::from("/home/user/project");
assert!(validate_workspace_bound("cargo build", &ws).is_ok());
}
#[test]
fn test_workspace_bound_absolute_inside() {
let (ws, command) = if cfg!(windows) {
(
std::path::PathBuf::from(r"C:\Users\user\project"),
r"cat C:\Users\user\project\src\main.rs",
)
} else {
(
std::path::PathBuf::from("/home/user/project"),
"cat /home/user/project/src/main.rs",
)
};
assert!(validate_workspace_bound(command, &ws).is_ok());
}
#[test]
fn test_workspace_bound_absolute_outside_rejected() {
let (ws, command) = if cfg!(windows) {
(
std::path::PathBuf::from(r"C:\Users\user\project"),
r"cat C:\Windows\System32\drivers\etc\hosts",
)
} else {
(
std::path::PathBuf::from("/home/user/project"),
"cat /etc/passwd",
)
};
let result = validate_workspace_bound(command, &ws);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("outside workspace"));
}
#[test]
fn test_workspace_bound_flags_ignored() {
let ws = std::path::PathBuf::from("/home/user/project");
assert!(validate_workspace_bound("cargo build --release", &ws).is_ok());
}
#[test]
fn test_artifact_mutation_normal_file_allowed() {
let ws = std::env::temp_dir();
assert!(validate_artifact_mutation("src/main.rs", &ws, "Delete").is_ok());
}
#[test]
fn test_artifact_mutation_nested_cargo_toml_allowed() {
let ws = std::env::temp_dir();
assert!(validate_artifact_mutation("crates/foo/Cargo.toml", &ws, "Delete").is_ok());
}
#[test]
fn test_artifact_mutation_root_cargo_toml_rejected() {
let ws = std::env::temp_dir();
let result = validate_artifact_mutation("Cargo.toml", &ws, "Delete");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("protected"));
}
#[test]
fn test_artifact_mutation_gitignore_rejected() {
let ws = std::env::temp_dir();
let result = validate_artifact_mutation(".gitignore", &ws, "Delete");
assert!(result.is_err());
}
#[test]
fn test_artifact_mutation_traversal_rejected() {
let ws = std::env::temp_dir();
let result = validate_artifact_mutation("../etc/passwd", &ws, "Move");
assert!(result.is_err());
}
}