use std::path::Path;
mod project_delete;
mod shell;
mod tracked_writes;
use project_delete::segment_deletes_project;
use shell::*;
const MAX_DEPTH: usize = 8;
const PROJECT_DELETE_REASON: &str = "destructive recursive deletion of the project root is blocked";
const FORK_BOMB_REASON: &str =
"fork bomb (`:(){ :|:& };:`) is blocked: it would exhaust the machine";
pub(super) fn reason_at(
command: &str,
workspace_roots: &[String],
active_cwd: Option<&Path>,
) -> Option<String> {
reason_inner(command, workspace_roots, active_cwd, 0)
}
pub(super) fn command_segments(command: &str) -> Vec<String> {
command_segments_inner(command, 0)
}
fn command_segments_inner(command: &str, depth: usize) -> Vec<String> {
if depth > MAX_DEPTH {
return Vec::new();
}
let mut segments = Vec::new();
for segment in split_chained_command(command) {
push_unique(&mut segments, segment.trim());
let tokens = shell_words(&segment);
let mut start = 0;
while start < tokens.len() {
let end = next_pipeline_boundary(&tokens, start);
if start < end {
push_unique(&mut segments, &tokens[start..end].join(" "));
let command_index = unwrapped_command_index(&tokens, start, end);
if command_index < end {
let command = command_basename(&tokens[command_index]);
if matches!(command, "bash" | "sh" | "zsh") {
if let Some(script) = shell_c_script(&tokens[(command_index + 1)..end]) {
for inner in command_segments_inner(script, depth + 1) {
push_unique(&mut segments, &inner);
}
}
}
}
}
start = end + 1;
}
}
segments
}
fn push_unique(values: &mut Vec<String>, value: &str) {
let value = value.trim();
if !value.is_empty() && !values.iter().any(|existing| existing == value) {
values.push(value.to_string());
}
}
fn reason_inner(
command: &str,
roots: &[String],
active_cwd: Option<&Path>,
depth: usize,
) -> Option<String> {
if depth > MAX_DEPTH {
return None;
}
if is_fork_bomb(command) {
return Some(FORK_BOMB_REASON.to_string());
}
for segment in split_chained_command(command) {
if let Some(hit) = segment_catastrophe(&segment, roots, active_cwd, depth) {
return Some(hit);
}
if segment_deletes_project(&segment) {
return Some(PROJECT_DELETE_REASON.to_string());
}
}
None
}
fn segment_catastrophe(
segment: &str,
roots: &[String],
active_cwd: Option<&Path>,
depth: usize,
) -> Option<String> {
if let Some(reason) = tracked_writes::redirect_over_tracked_reason(segment, active_cwd, roots) {
return Some(reason);
}
if is_fork_bomb(segment) {
return Some(FORK_BOMB_REASON.to_string());
}
let tokens = shell_words(segment);
let mut start = 0;
while start < tokens.len() {
let end = next_pipeline_boundary(&tokens, start);
if let Some(hit) = invocation_catastrophe(&tokens, start, end, roots, active_cwd, depth) {
return Some(hit);
}
start = end + 1;
}
None
}
fn invocation_catastrophe(
tokens: &[String],
start: usize,
end: usize,
roots: &[String],
active_cwd: Option<&Path>,
depth: usize,
) -> Option<String> {
let command_index = unwrapped_command_index(tokens, start, end);
if command_index >= end {
return None;
}
let argv = &tokens[command_index..end];
let command = command_basename(&argv[0]);
let args = &argv[1..];
if matches!(command, "bash" | "sh" | "zsh") {
if let Some(script) = shell_c_script(args) {
if let Some(hit) = reason_inner(script, roots, active_cwd, depth + 1) {
return Some(hit);
}
}
}
match command {
"git" => git_catastrophe(args),
"rm" => rm_escape_catastrophe(args, roots),
"dd" => dd_catastrophe(args),
"mkfs" | "mke2fs" => Some(format!(
"`{command}` (filesystem format) is blocked: it would destroy a device"
)),
_ if command.starts_with("mkfs.") => Some(format!(
"`{command}` (filesystem format) is blocked: it would destroy a device"
)),
"chmod" => chmod_catastrophe(args),
"truncate" => tracked_writes::truncate_catastrophe(args, active_cwd, roots),
_ => None,
}
}
fn shell_c_script(args: &[String]) -> Option<&str> {
let mut index = 0;
while index < args.len() {
let token = &args[index];
if token == "--" {
index += 1;
continue;
}
if token.starts_with('-') && token != "-" {
if token.chars().skip(1).any(|flag| flag == 'c') {
return args.get(index + 1).map(String::as_str);
}
index += 1;
continue;
}
return None;
}
None
}
fn git_catastrophe(args: &[String]) -> Option<String> {
let mut index = 0;
while index < args.len() {
let token = &args[index];
match token.as_str() {
"-C" | "-c" | "--git-dir" | "--work-tree" | "--namespace" => {
index += 2;
continue;
}
_ if token.starts_with('-') => {
index += 1;
continue;
}
_ => break,
}
}
let subcommand = args.get(index)?.as_str();
let rest = &args[(index + 1).min(args.len())..];
match subcommand {
"reset" => rest.iter().any(|a| a == "--hard").then(|| {
"`git reset --hard` is blocked: it discards all uncommitted work. Commit or stash first, then reset on a feature branch.".to_string()
}),
"clean" => {
let mut force = false;
let mut dirs = false;
for arg in rest {
if arg == "--force" {
force = true;
} else if arg == "-d" || arg == "--directory" {
dirs = true;
} else if arg.starts_with('-') && !arg.starts_with("--") {
for flag in arg.chars().skip(1) {
match flag {
'f' => force = true,
'd' => dirs = true,
_ => {}
}
}
}
}
(force && dirs).then(|| {
"`git clean -fd`/`-fdx` is blocked: it permanently deletes untracked files and directories. Inspect with `git clean -nd` first, or remove specific paths.".to_string()
})
}
"push" => {
let force = rest.iter().any(|a| {
a == "--force"
|| a == "-f"
|| a == "--force-with-lease"
|| a.starts_with("--force-with-lease=")
|| (a.starts_with('-') && !a.starts_with("--") && a.contains('f'))
});
force.then(|| {
"force-push (`git push --force` / `-f` / `--force-with-lease`) is blocked: it can rewrite shared history. Push without `--force`, or perform the force-push yourself after review.".to_string()
})
}
_ => None,
}
}
fn rm_escape_catastrophe(args: &[String], roots: &[String]) -> Option<String> {
let mut force = false;
let mut recursive = false;
let mut targets: Vec<&str> = Vec::new();
let mut parsing_options = true;
for arg in args {
if parsing_options && arg == "--" {
parsing_options = false;
continue;
}
if parsing_options && arg.starts_with("--") {
force = force || arg == "--force";
recursive = recursive || arg == "--recursive";
continue;
}
if parsing_options && arg.starts_with('-') && arg != "-" {
for flag in arg.chars().skip(1) {
force = force || flag == 'f';
recursive = recursive || flag == 'r' || flag == 'R';
}
continue;
}
parsing_options = false;
targets.push(arg);
}
if !(force && recursive) {
return None;
}
for target in targets {
if path_escapes_root(target, roots) {
return Some(format!(
"`rm -rf` of `{target}` is blocked: it targets an absolute path or escapes the project root. Delete only paths inside the workspace, and prefer a scoped removal."
));
}
}
None
}
fn dd_catastrophe(args: &[String]) -> Option<String> {
args.iter().any(|a| a.starts_with("of=")).then(|| {
"`dd of=…` is blocked: a raw block-device/file overwrite is irreversible.".to_string()
})
}
fn chmod_catastrophe(args: &[String]) -> Option<String> {
let recursive = args.iter().any(|a| {
a == "-R"
|| a == "--recursive"
|| (a.starts_with('-') && !a.starts_with("--") && a.contains('R'))
});
let strips_all = args.iter().any(|a| a == "000" || a == "0000");
(recursive && strips_all).then(|| {
"`chmod -R 000` is blocked: recursively stripping all permissions can lock you out of the tree.".to_string()
})
}
fn is_fork_bomb(segment: &str) -> bool {
let compact: String = segment.chars().filter(|c| !c.is_whitespace()).collect();
compact.contains(":(){:|:&};:") || compact.contains(":(){:|:&;}:")
}
fn path_escapes_root(target: &str, roots: &[String]) -> bool {
let cleaned = strip_trailing_slashes(target);
if cleaned.is_empty() {
return false;
}
if cleaned == "~"
|| cleaned.starts_with("~/")
|| cleaned == "/"
|| cleaned == "/*"
|| cleaned.starts_with("$HOME")
|| cleaned.starts_with("${HOME}")
{
return true;
}
if cleaned.starts_with('/') {
if roots.is_empty() {
return true;
}
return !roots.iter().any(|root| {
let root = strip_trailing_slashes(root);
cleaned == root || cleaned.starts_with(&format!("{root}/"))
});
}
relative_path_escapes(cleaned)
}
fn relative_path_escapes(path: &str) -> bool {
let mut depth: i32 = 0;
for part in path.split('/') {
match part {
"" | "." => {}
".." => {
depth -= 1;
if depth < 0 {
return true;
}
}
_ => depth += 1,
}
}
false
}
fn strip_trailing_slashes(value: &str) -> &str {
let mut end = value.len();
while end > 1 && value.as_bytes()[end - 1] == b'/' {
end -= 1;
}
&value[..end]
}