use std::path::{Path, PathBuf};
use super::credential_print::{COMPOUND_OPENERS, is_identifier};
use super::floor_d4_rules::{disk_tool_reason, exfiltration_reason};
use super::force_push::{GitProbe, force_push_reason};
use super::shell_lex::{WrappedCommand, wrapped_command};
use super::{MAX_WRAPPER_DEPTH, split_shell_segments_raw, unclassifiable_command};
use crate::commands::hook_rewrite::{COMMAND_WRAPPERS, first_command_token, strip_wrapper_prefix};
const D4_WORDS: &[&str] = &[
"curl", "wget", "scp", "rsync", "nc", "ncat", "netcat", "ssh", "diskutil", "dd", "mkfs",
"newfs", "fdisk", "hdiutil", "asr", "push",
];
pub(super) const EXEC_FLAGS: &[&str] = &["-exec", "-execdir", "-ok", "-okdir"];
#[derive(Debug)]
pub(super) struct Segment {
pub(super) text: String,
pub(super) piped: bool,
pub(super) after_cd: bool,
}
pub(crate) fn evaluate_d4_floor(command: &str, cwd: &Path, git: &dyn GitProbe) -> Option<String> {
if unclassifiable_command(command).is_some() && mentions_d4_word(command) {
return Some(unparseable_reason());
}
for seg in segments(command) {
let Some(argv) = shlex::split(seg.text.trim()) else {
if mentions_d4_word(&seg.text) {
return Some(unparseable_reason());
}
continue;
};
let dir: Option<PathBuf> = (!seg.after_cd).then(|| cwd.to_path_buf());
let reason = exfiltration_reason(&argv, seg.piped)
.or_else(|| disk_tool_reason(&argv, dir.as_deref()))
.or_else(|| force_push_reason(&argv, dir.as_deref(), git));
if reason.is_some() {
return reason;
}
}
None
}
pub(super) fn segments(command: &str) -> Vec<Segment> {
let mut out = Vec::new();
let mut after_cd = false;
walk(command, false, 0, &mut after_cd, &mut out);
out
}
fn walk(command: &str, piped_in: bool, depth: usize, after_cd: &mut bool, out: &mut Vec<Segment>) {
for (n, raw) in split_shell_segments_raw(command).into_iter().enumerate() {
let piped = (n == 0 && piped_in) || follows_pipe(command, raw);
let trimmed = raw.trim();
if trimmed.is_empty() {
continue;
}
if first_command_token(trimmed).as_deref() == Some("cd") {
*after_cd = true;
}
out.push(Segment {
text: trimmed.to_string(),
piped,
after_cd: *after_cd,
});
if depth < MAX_WRAPPER_DEPTH
&& let WrappedCommand::Inner(inner) = wrapped_command(trimmed)
{
walk(&inner, piped, depth + 1, after_cd, out);
}
}
}
fn follows_pipe(command: &str, segment: &str) -> bool {
let offset = (segment.as_ptr() as usize).wrapping_sub(command.as_ptr() as usize);
let Some(before) = command.get(..offset) else {
return false;
};
let before = before.trim_end();
(before.ends_with('|') && !before.ends_with("||")) || before.ends_with("|&")
}
fn mentions_d4_word(text: &str) -> bool {
text.split(|c: char| !c.is_ascii_alphanumeric())
.any(|word| D4_WORDS.contains(&word))
}
pub(super) fn program_positions(argv: &[String]) -> Vec<(usize, String)> {
let lead = grammar_prefix(argv);
let words: Vec<String> = argv[lead..]
.iter()
.map(|t| t.trim_start_matches('(').to_string())
.collect();
positions_past_wrappers(&words)
.into_iter()
.map(|(i, program)| (i + lead, program))
.collect()
}
const COMMAND_STARTERS: &[&str] = &[
"{", "(", "!", "if", "then", "else", "elif", "do", "while", "until",
];
fn grammar_prefix(argv: &[String]) -> usize {
let opens = |w: Option<&String>| w.is_some_and(|w| w == "{" || w.starts_with('('));
let mut i = 0;
while let Some(tok) = argv.get(i).map(String::as_str) {
let next = argv.get(i + 1);
let header = |t: &str, end: &str| t.len() > end.len() && t.ends_with(end);
i += if COMMAND_STARTERS.contains(&tok) || header(tok, "()") || header(tok, "(){") {
1
} else if tok == "function" || next.is_some_and(|n| n == "()" || n == "(){") {
2
} else if tok == "case" && argv.get(i + 2).is_some_and(|w| w == "in") {
3
} else if (is_case_pattern(tok) && next.is_some()) || (tok == "time" && opens(next)) {
1
} else if tok == "coproc" {
let compound = argv.get(i + 2);
let named =
opens(compound) || compound.is_some_and(|w| COMPOUND_OPENERS.contains(&w.as_str()));
1 + usize::from(named && next.is_some_and(|n| is_identifier(n)))
} else {
break;
};
}
i.min(argv.len())
}
fn is_case_pattern(tok: &str) -> bool {
let Some(body) = tok.strip_suffix(')') else {
return false;
};
let body = body.strip_prefix('(').unwrap_or(body);
!body.is_empty() && !body.contains(['(', ')', '$', '`'])
}
fn positions_past_wrappers(argv: &[String]) -> Vec<(usize, String)> {
let base = |tok: &str| {
let tok = tok.strip_prefix('\\').unwrap_or(tok);
tok.rsplit('/').next().unwrap_or(tok).to_string()
};
let Some(first) = strip_wrapper_prefix(argv) else {
return argv.iter().enumerate().map(|(i, t)| (i, base(t))).collect();
};
let mut out: Vec<(usize, String)> = Vec::new();
for (i, tok) in argv.iter().enumerate() {
let prev = i.checked_sub(1).map(|p| base(&argv[p]));
let after_timeout = i >= 2 && base(&argv[i - 2]) == "timeout";
let in_position = i == first
|| prev
.as_deref()
.is_some_and(|p| COMMAND_WRAPPERS.contains(&p) || EXEC_FLAGS.contains(&p))
|| after_timeout;
if in_position && !tok.starts_with('-') {
out.push((i, base(tok)));
}
}
out
}
fn unparseable_reason() -> String {
format!(
"Hard-floor deny (#8878 D4): this command names a network-upload, disk or `git push` \
program, but the guard cannot parse it, so it cannot rule out an upload, a disk erase \
or a force-push to the default branch. {D4_REMEDY}"
)
}
pub(super) const D4_REMEDY: &str = "This floor binds every session but the Architect's main \
thread, and `TRUSTY_MPM_PM_UNRESTRICTED` / `TRUSTY_MPM_DISABLE_HOOKS` do not lift it. \
Ask the operator, or run it from the Architect session.";
#[cfg(test)]
#[path = "floor_d4_tests.rs"]
mod tests;