use std::path::Path;
use super::RiskClass;
use super::shell::*;
pub const READ_ONLY_DENIAL_MARKER: &str = "read-only safety mode";
pub const PLAN_DENIAL_MARKER: &str = "plan mode";
#[must_use]
pub fn is_plan_safe_build_command(command: &str) -> bool {
let split = split_command(command);
if !split.heredocs.is_empty() {
return false;
}
let segments = split.segments;
if segments.is_empty() {
return false;
}
if segments
.iter()
.any(|seg| !extract_substitutions(seg).is_empty())
{
return false;
}
segments.iter().all(|seg| {
let tokens = tokenize(seg);
match classify_segment(&tokens) {
RiskClass::ReadOnly => true,
RiskClass::Process => {
!segment_has_file_write(&tokens) && segment_is_safe_build(&tokens)
},
_ => false,
}
})
}
#[must_use]
pub fn is_plan_file_path(workdir: &Path, raw: &str, plan_file: &Path) -> bool {
fn normalize(p: &Path) -> std::path::PathBuf {
use std::path::Component;
let mut out = std::path::PathBuf::new();
for c in p.components() {
match c {
Component::CurDir => {},
Component::ParentDir => {
out.pop();
},
other => out.push(other.as_os_str()),
}
}
out
}
let p = Path::new(raw);
let abs = if p.is_absolute() {
p.to_path_buf()
} else {
workdir.join(p)
};
normalize(&abs) == normalize(plan_file)
}
pub(crate) const CWD_CHANGING_BUILTINS: &[&str] = &["cd", "pushd", "popd"];
#[must_use]
pub fn is_plan_file_only_write(command: &str, workdir: &Path, plan_file: &Path) -> bool {
let split = split_command(command);
if split.segments.is_empty() {
return false;
}
if split
.segments
.iter()
.any(|seg| !extract_substitutions(seg).is_empty())
{
return false;
}
if split.heredocs.iter().any(|hd| {
hd.expands && (hd.body.contains("$(") || hd.body.contains('`') || hd.body.contains("<("))
}) {
return false;
}
let mut saw_plan_redirect = false;
for seg in &split.segments {
let tokens = tokenize(seg);
let mut kept: Vec<String> = Vec::with_capacity(tokens.len());
let mut skip_next = false;
for (i, tok) in tokens.iter().enumerate() {
if skip_next {
skip_next = false;
continue;
}
let t = tok.as_str();
if t == "tee" || t == "dd" {
return false;
}
if CWD_CHANGING_BUILTINS.contains(&basename(t)) {
return false;
}
if redirect_target_after(t).is_some() {
match redirect_write_target(&tokens, i) {
Some(target) if is_safe_device_write(target) => {},
Some(target) if is_plan_file_path(workdir, target, plan_file) => {
saw_plan_redirect = true;
if redirect_target_after(t).is_some_and(|g| !g.is_empty()) {
continue;
}
skip_next = true;
continue;
},
_ => return false,
}
}
kept.push(tok.clone());
}
if classify_segment(&kept) != RiskClass::ReadOnly {
return false;
}
}
saw_plan_redirect
}
pub(crate) fn segment_has_file_write(tokens: &[String]) -> bool {
tokens.iter().enumerate().any(|(i, tok)| {
let t = tok.as_str();
if t == "tee" || t == "dd" {
return true;
}
if redirect_target_after(t).is_some() {
return !matches!(
redirect_write_target(tokens, i),
Some(target) if is_safe_device_write(target)
);
}
false
})
}
pub(crate) fn segment_is_safe_build(tokens: &[String]) -> bool {
let Some(head) = tokens.first().map(|t| basename(t)) else {
return false;
};
let mut positional = tokens
.iter()
.skip(1)
.map(String::as_str)
.filter(|t| !t.starts_with('-') && !t.starts_with('+'));
let sub = positional.next();
let second = positional.next();
match head {
"cargo" => match sub {
Some(
"check" | "build" | "test" | "clippy" | "doc" | "bench" | "tree" | "metadata"
| "fetch" | "verify-project",
) => true,
Some("nextest") => matches!(second, Some("run") | Some("list")),
Some("fmt") => tokens.iter().any(|t| t == "--check"),
_ => false,
},
"go" => matches!(sub, Some("build" | "test" | "vet")),
"npm" | "pnpm" | "yarn" | "bun" => match sub {
Some("test") => true,
Some("run") => matches!(
second,
Some("test" | "build" | "lint" | "check" | "typecheck")
),
_ => false,
},
"make" => matches!(
sub,
None | Some("all" | "build" | "test" | "check" | "lint")
),
_ => false,
}
}