use std::borrow::Cow;
use std::path::PathBuf;
use tree_sitter::Node;
use super::super::{REFUSAL_FRAME, framed_refusal};
use super::scan;
use super::{CheckContext, ShellPlatform, rejection_message};
pub(super) enum WinVerdict {
Allow,
Refuse(String),
Pass,
}
const VERB_EXTENSIONS: &[&str] = &["exe", "com", "bat", "cmd"];
#[rustfmt::skip] const DENIED_VERBS: &[&str] = &[
"format", "diskpart", "diskcomp", "diskcopy", "chkdsk", "chkntfs", "fsutil",
"defrag", "convert", "bcdedit", "bootcfg", "mountvol", "subst", "wbadmin",
"vssadmin", "label", "diskperf",
"expand",
"bcdboot", "wevtutil", "secedit", "powercfg", "sfc", "auditpol", "dism",
"gpupdate", "verifier",
"takeown", "setx",
"taskkill", "tskill", "net", "net1",
"regedit", "regedt32", "regini", "regsvr32", "assoc", "ftype",
"at", "instsrv", "logoff", "msiexec", "wusa",
"netsh", "route",
];
const TEMP_GATED_VERBS: &[&str] = &[
"del", "erase", "rd", "rmdir", "md", "mkdir", "move", "ren", "rename", "mklink", "replace",
];
const CWD_DESTINATION_VERBS: &[&str] = &["move", "replace"];
const TEMP_GATED_DESTINATION_VERBS: &[&str] = &["copy", "xcopy", "robocopy"];
const COPY_DENIED_SWITCHES: &[&str] = &[
"move", "mov", "reg", "log", "unilog", "m", "mot", "mon", "timfix",
];
const LIST_ONLY_VERBS: &[&str] = &["attrib", "icacls", "cacls", "cipher", "compact"];
const QUERY_VERBS: &[(&str, &[&str])] = &[
("reg", &["query"]),
("sc", &["query", "queryex"]),
("schtasks", &["/query"]),
];
const CLOCK_VERBS: &[&str] = &["time", "date"];
const INTERNAL_VERBS: &[&str] = &["cd", "chdir", "popd", "pushd", "set"];
fn split_glued_switch(word: &str) -> (&str, Vec<&str>) {
let bare = word.strip_prefix('@').unwrap_or(word);
let Some((head, _)) = bare.split_once('/') else {
return (word, Vec::new());
};
if !is_glue_splittable(head) {
return (word, Vec::new());
}
let at = word.len() - bare.len() + head.len();
(&word[..at], switch_cluster(&word[at..]))
}
fn switch_cluster(glue: &str) -> Vec<&str> {
let mut switches = Vec::new();
let mut start = 0;
for (at, _) in glue.match_indices('/').skip(1) {
switches.push(&glue[start..at]);
start = at;
}
switches.push(&glue[start..]);
switches
}
const VERB_TABLES: &[&[&str]] = &[
DENIED_VERBS,
TEMP_GATED_VERBS,
TEMP_GATED_DESTINATION_VERBS,
LIST_ONLY_VERBS,
CLOCK_VERBS,
INTERNAL_VERBS,
];
fn is_glue_splittable(head: &str) -> bool {
let head = scan::strip_quoted_word(head);
VERB_TABLES
.iter()
.any(|table| table.iter().any(|verb| verb.eq_ignore_ascii_case(head)))
|| QUERY_VERBS
.iter()
.any(|(name, _)| name.eq_ignore_ascii_case(head))
}
pub(super) fn verb_key(word: &str) -> Option<String> {
verb_key_of(split_glued_switch(word).0)
}
pub(super) fn dispatch_word(platform: ShellPlatform, raw: &str, fallback: String) -> String {
if platform == ShellPlatform::Windows {
verb_key(raw).unwrap_or_else(|| fallback.to_ascii_lowercase())
} else {
fallback
}
}
fn verb_key_of(word: &str) -> Option<String> {
let w = scan::strip_quoted_word(word);
let w = w.strip_prefix('@').unwrap_or(w);
if w.is_empty()
|| w.contains([
'@', '$', '`', '%', '!', '*', '?', '[', ']', '{', '}', ',', '~', '\'', '"',
])
{
return None;
}
let basename = w
.rsplit(['\\', '/'])
.next()
.expect("rsplit always yields at least one segment");
if basename.is_empty() || basename.chars().any(char::is_whitespace) {
return None;
}
let (stem, has_extension) = match basename.rsplit_once('.') {
Some((stem, ext))
if !stem.is_empty() && VERB_EXTENSIONS.iter().any(|e| e.eq_ignore_ascii_case(ext)) =>
{
(stem, true)
}
_ => (basename, false),
};
if !has_extension && stem.len() != w.len() {
return None;
}
Some(stem.to_ascii_lowercase())
}
fn split_prefix(path: &str) -> Option<(String, &str)> {
if let Some(len) = drive_letter(path) {
return Some((path[..len].to_ascii_lowercase(), &path[len..]));
}
if let Some(stripped) = path.strip_prefix(r"\\") {
let mut parts = stripped.splitn(3, '\\');
let server = parts.next().unwrap_or("");
let share = parts.next().unwrap_or("");
if server.is_empty() || share.is_empty() {
return None;
}
let end = 2 + server.len() + 1 + share.len();
return Some((path[..end].to_ascii_lowercase(), &path[end..]));
}
Some((String::new(), path))
}
fn drive_letter(path: &str) -> Option<usize> {
let bytes = path.as_bytes();
(bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic()).then_some(2)
}
fn is_absolute(folded: &str) -> bool {
if folded.starts_with(r"\\") {
return true;
}
drive_letter(folded).is_some_and(|len| folded.as_bytes().get(len) == Some(&b'\\'))
}
fn normalize(path: &str) -> Option<String> {
let folded = path.replace('/', "\\");
let (prefix, rest) = split_prefix(&folded)?;
let mut segments: Vec<String> = Vec::new();
for segment in rest.split('\\') {
match segment {
"" | "." => {}
".." => {
segments.pop()?;
}
segment => {
if segment.ends_with(['.', ' ']) {
return None;
}
segments.push(segment.to_ascii_lowercase());
}
}
}
let mut out = prefix;
for segment in &segments {
out.push('\\');
out.push_str(segment);
}
Some(out)
}
fn strip_double_quotes(word: &str) -> Option<&str> {
word.strip_prefix('"')
.and_then(|rest| rest.strip_suffix('"'))
.filter(|inner| !inner.contains('"'))
}
fn cmd_token(word: &str) -> Option<&str> {
match strip_double_quotes(word) {
Some(inner) => Some(inner),
None if word.contains(['"', '\'']) => None,
None => Some(word),
}
}
fn resolve_raw(word: &str, ctx: &CheckContext) -> Option<String> {
let raw = cmd_token(word)?;
let quoted = raw != word;
if raw.is_empty() {
return None;
}
let expanded = expand_percent_vars(raw, ctx)?.replace('/', "\\");
if expanded.contains(['*', '?', '~', '^', '!']) {
return None;
}
if !quoted
&& (expanded.chars().any(char::is_whitespace)
|| expanded.contains([',', '=', ';', '&', '|', '<', '>']))
{
return None;
}
is_absolute(&expanded).then_some(expanded)
}
fn resolve(word: &str, ctx: &CheckContext) -> Option<String> {
normalize(&resolve_raw(word, ctx)?)
}
fn under_roots(path: &str, ctx: &CheckContext) -> bool {
ctx.temp_roots.iter().any(|root| {
let Some(root) = normalize(&root.to_string_lossy()) else {
return false;
};
!root.is_empty()
&& (path == root
|| path
.strip_prefix(&root)
.is_some_and(|rest| rest.starts_with('\\')))
})
}
fn canonical_under_roots(canonical: &str, ctx: &CheckContext) -> bool {
normalize(&strip_verbatim(canonical)).is_some_and(|path| under_roots(&path, ctx))
}
fn resolves_inside_temp(path: &str, ctx: &CheckContext) -> bool {
let mut probe = PathBuf::from(path);
loop {
if let Ok(canonical) = probe.canonicalize() {
return canonical_under_roots(&canonical.to_string_lossy(), ctx);
}
match probe.parent() {
Some(parent) if !parent.as_os_str().is_empty() && parent != probe => {
probe = parent.to_path_buf();
}
_ => return true,
}
}
}
pub(super) fn under_temp(word: &str, ctx: &CheckContext) -> bool {
let Some(path) = resolve(word, ctx) else {
return false;
};
under_roots(&path, ctx) && resolves_inside_temp(&path, ctx)
}
fn strip_verbatim(canonical: &str) -> String {
if let Some(rest) = canonical.strip_prefix(r"\\?\UNC\") {
return format!(r"\\{rest}");
}
canonical
.strip_prefix(r"\\?\")
.unwrap_or(canonical)
.to_string()
}
fn expand_percent_vars(word: &str, ctx: &CheckContext) -> Option<String> {
let mut out = String::with_capacity(word.len());
let mut rest = word;
while let Some(start) = rest.find('%') {
let after = &rest[start + 1..];
let Some(end) = after.find('%') else {
break; };
out.push_str(&rest[..start]);
let name = &after[..end];
if is_var_name(name) {
let value = &ctx
.temp_vars
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case(name))?
.1;
out.push_str(value);
} else {
let literal_end = start + 1 + end + 1;
out.push_str(&rest[start..literal_end]);
}
rest = &after[end + 1..];
}
out.push_str(rest);
Some(out)
}
fn is_var_name(name: &str) -> bool {
!name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn refusal(cmd: &str, why: &str, suggestion: &str) -> String {
framed_refusal(REFUSAL_FRAME, cmd, why, suggestion)
}
pub(super) fn check_line(root: Node, src: &str, platform: ShellPlatform) -> Result<(), String> {
if platform != ShellPlatform::Windows {
return Ok(());
}
let mut stack = vec![root];
while let Some(node) = stack.pop() {
match node.kind() {
"comment" => {
return Err(refusal(
src,
"cmd.exe has no comment syntax — `#` is an ordinary character, so what the \
shell parser drops as a comment is an argument or a separator to cmd.exe.",
"write only the commands you want to run, and quote a `#` you mean literally \
(`echo \"a#b\"`).",
));
}
"heredoc_redirect" => {
return Err(refusal(
src,
"cmd.exe has no `<<` — it reads two input redirections, not a text block, so \
the body's later lines would run as commands this guard never read.",
"write the command in the plain spelling cmd.exe accepts.",
));
}
";" => {
return Err(refusal(
src,
"cmd.exe does not split a command on `;` — several of its internal commands \
take it as an argument delimiter, so what the shell parser reads as a second \
command would be an extra operand of the first.",
"write two commands with `&&` (or `&`), which both readers split on, and quote \
a `;` you mean literally (`echo \"a;b\"`).",
));
}
_ => {}
}
let mut cursor = node.walk();
stack.extend(node.children(&mut cursor));
}
Ok(())
}
#[expect(clippy::too_many_lines)] pub(super) fn check_segment(
words: &[&str],
verb_idx: usize,
cmd: &str,
ctx: &CheckContext,
) -> WinVerdict {
let first = words.first().copied().unwrap_or_default();
let (first_word, first_glue) = split_glued_switch(first);
let first_key = verb_key(first_word);
if let Some(first) = first_key.as_deref()
&& CLOCK_VERBS.contains(&first)
{
let args = glued_args(&first_glue, words.get(1..).unwrap_or_default());
let bare = args
.iter()
.all(|arg| cmd_token(arg).is_some_and(|token| token.eq_ignore_ascii_case("/t")));
return if bare {
WinVerdict::Allow
} else {
WinVerdict::Refuse(rejection_message(
cmd,
"`time`/`date` with an operand sets the machine clock.",
"drop the operand — the bare form (or `/t`) only reports.",
))
};
}
let (verb_word, glue, verb) = if verb_idx == 0 {
(first_word, first_glue, first_key)
} else {
let Some(word) = words.get(verb_idx) else {
return WinVerdict::Pass;
};
let (word, glue) = split_glued_switch(word);
(word, glue, verb_key(word))
};
let Some(verb) = verb else {
return WinVerdict::Pass;
};
if matches!(verb.as_str(), "cd" | "chdir" | "popd" | "pushd") {
return WinVerdict::Allow;
}
let args = glued_args(&glue, words.get(verb_idx + 1..).unwrap_or_default());
let args = args.as_ref();
if is_drive_switch(verb_word) {
return if args.is_empty() {
WinVerdict::Allow
} else {
WinVerdict::Refuse(rejection_message(
cmd,
"a drive switch (`D:`) takes no operand in read-only mode.",
"put the drive switch on its own (`C:` then the command), or name a fully-qualified path.",
))
};
}
if verb == "set" {
return check_set(args, cmd, ctx);
}
if DENIED_VERBS.contains(&verb.as_str()) {
return WinVerdict::Refuse(rejection_message(
cmd,
&format!(
"`{verb}` is not allowed in read-only mode — it changes system, service, \
registry, volume or account state outside any workspace scope."
),
"use a read-only inspection command; this operation has no temp-scoped grant on any platform.",
));
}
if is_glue_splittable(&verb) && args.iter().copied().any(glued_delimiter_switch) {
return WinVerdict::Refuse(rejection_message(
cmd,
&format!(
"`{verb}` with a switch glued to its operand by cmd.exe's parameter delimiter \
(`,` or `=`) is not allowed in read-only mode — cmd.exe hands the text behind the \
delimiter to the command as another argument, which this guard cannot read as a \
switch or as a path."
),
"separate the switch from its operand with a space (or drop the operand).",
));
}
let needs_destination = CWD_DESTINATION_VERBS.contains(&verb.as_str());
let temp_gated = TEMP_GATED_VERBS.contains(&verb.as_str()) || needs_destination;
if temp_gated {
let paths = path_args(args);
if paths.is_empty() {
return WinVerdict::Refuse(rejection_message(
cmd,
&format!(
"`{verb}` names no path, and read-only mode proves writes only under the \
daemon's temp location."
),
super::temp_path_or_alternatives_hint(ctx.platform),
));
}
if !all_paths_under_temp(&paths, ctx) {
return WinVerdict::Refuse(rejection_message(
cmd,
&format!(
"`{verb}` is not allowed outside the temp directory — it deletes, moves \
or creates files outside the daemon's temp location."
),
super::temp_path_or_alternatives_hint(ctx.platform),
));
}
if needs_destination && paths.len() < 2 {
return WinVerdict::Refuse(rejection_message(
cmd,
&format!(
"`{verb}` without a destination writes into cmd.exe's own directory — a \
location read-only mode never proved is the accepted one."
),
"name both the source and a destination under the daemon temp root the \
session's `%TMP%`/`%TEMP%` point at.",
));
}
return WinVerdict::Allow;
}
if TEMP_GATED_DESTINATION_VERBS.contains(&verb.as_str()) {
if let Some(offending) = args.iter().copied().find(|tok| denied_copy_switch(tok)) {
return WinVerdict::Refuse(rejection_message(
cmd,
&format!(
"`{verb} {offending}` is not allowed in read-only mode — that switch \
deletes the sources, writes the registry, or logs to an arbitrary file."
),
"drop the switch; only a plain copy into a temp destination is permitted.",
));
}
let paths = path_args(args);
return if destination_under_temp(&paths, ctx) {
WinVerdict::Allow
} else {
WinVerdict::Refuse(rejection_message(
cmd,
&format!(
"`{verb}` is not allowed — only a destination under the daemon temp \
location is permitted."
),
"name a destination under the daemon temp root the session's `%TMP%`/`%TEMP%` \
point at; sources may be read from anywhere.",
))
};
}
if LIST_ONLY_VERBS.contains(&verb.as_str()) {
return if args
.iter()
.any(|tok| is_switch(tok) || tok.starts_with(['+', '-']))
{
WinVerdict::Refuse(rejection_message(
cmd,
&format!(
"`{verb}` is not allowed in read-only mode — its read-only spelling cannot be \
told apart from a mutating one, so no switch or attribute operator is \
permitted."
),
"drop the switch or attribute operator and run the bare form.",
))
} else {
WinVerdict::Allow
};
}
if let Some((_, spellings)) = QUERY_VERBS.iter().find(|(name, _)| *name == verb) {
if args
.first()
.copied()
.and_then(cmd_token)
.is_some_and(|tok| {
spellings.iter().any(|spelling| {
spelling
.trim_start_matches('/')
.eq_ignore_ascii_case(tok.trim_start_matches('/'))
})
})
{
return WinVerdict::Allow;
}
let listed = spellings
.iter()
.map(|spelling| format!("`{verb} {spelling}`"))
.collect::<Vec<_>>()
.join(", ");
return WinVerdict::Refuse(rejection_message(
cmd,
&format!("`{verb}` is only permitted in its read-only inspection spelling — {listed}."),
"use the inspection spelling; the mutating forms change system state outside any workspace scope.",
));
}
WinVerdict::Pass
}
fn check_set(args: &[&str], cmd: &str, ctx: &CheckContext) -> WinVerdict {
let joined = args.join(" ");
let Some(joined) = cmd_token(&joined) else {
return WinVerdict::Refuse(rejection_message(
cmd,
"`set` with a quote character cmd.exe does not interpret — the binding cannot be read.",
"write a plain `set NAME=value` binding, quoting it with double quotes if it carries spaces.",
));
};
let (name, binding) = match joined.split_once('=') {
Some((name, _)) => (name, true),
None if joined.is_empty() => return WinVerdict::Allow,
None => (joined, false),
};
if !is_var_name(name) {
return WinVerdict::Refuse(rejection_message(
cmd,
&format!(
"`set {joined}` — only a bare name or a `NAME=value` binding is modelled: a switch \
form evaluates or reads instead of binding, and cmd rewrites the line before it \
binds, so a name outside the plain charset proves nothing."
),
"use a plain `set NAME=value` (letters, digits and underscores in the name), or a bare `set`.",
));
}
if !binding {
return WinVerdict::Allow;
}
if ctx
.temp_vars
.iter()
.any(|(temp_name, _)| temp_name.eq_ignore_ascii_case(name))
{
return WinVerdict::Refuse(rejection_message(
cmd,
&format!(
"`set {name}=…` rebinds a temp variable the guard resolves — the value the \
shells are handed — so a rebinding cannot be followed."
),
"leave the temp variables alone; name the location directly (a literal path under the \
daemon temp root, or `%TMP%`/`%TEMP%`).",
));
}
if super::git_env_name_denied(&name.to_ascii_uppercase()) {
return WinVerdict::Refuse(rejection_message(
cmd,
"`set` with a `GIT_*` variable is not allowed in read-only mode — git reads it as an exec vector.",
"drop the binding; inspect the repository with `git status`/`git log`/`git ls-remote` instead.",
));
}
WinVerdict::Allow
}
fn glued_args<'x, 'a>(glue: &'x [&'a str], rest: &'x [&'a str]) -> Cow<'x, [&'a str]> {
if glue.is_empty() {
return Cow::Borrowed(rest);
}
Cow::Owned(glue.iter().copied().chain(rest.iter().copied()).collect())
}
fn is_switch(tok: &str) -> bool {
cmd_token(tok).is_some_and(|tok| tok.starts_with('/') || tok.starts_with('-'))
}
fn is_drive_switch(word: &str) -> bool {
cmd_token(word).is_some_and(|word| {
word.len() == 2
&& word.ends_with(':')
&& word.starts_with(|c: char| c.is_ascii_alphabetic())
})
}
fn denied_copy_switch(token: &str) -> bool {
let Some(tok) = cmd_token(token) else {
return false;
};
let Some(name) = tok.strip_prefix(['/', '-']) else {
return false;
};
let name = name.split([':', '+']).next().unwrap_or_default();
COPY_DENIED_SWITCHES
.iter()
.any(|switch| name.eq_ignore_ascii_case(switch))
}
fn switch_is_operand(tok: &str) -> bool {
let Some(tok) = cmd_token(tok) else {
return false;
};
let Some(rest) = tok.strip_prefix(['/', '-']) else {
return false;
};
!rest.contains(':') && rest.contains(['.', '\\', '/'])
}
fn glued_delimiter_switch(tok: &str) -> bool {
cmd_token(tok)
.and_then(|tok| tok.strip_prefix(['/', '-']))
.is_some_and(|rest| rest.contains([',', '=']))
}
fn path_args<'a>(args: &[&'a str]) -> Vec<&'a str> {
args.iter()
.copied()
.filter(|tok| !is_switch(tok) || switch_is_operand(tok))
.collect()
}
fn all_paths_under_temp(paths: &[&str], ctx: &CheckContext) -> bool {
paths.iter().copied().all(|path| under_temp(path, ctx))
}
fn destination_under_temp(paths: &[&str], ctx: &CheckContext) -> bool {
paths.len() >= 2
&& paths
.last()
.copied()
.is_some_and(|dest| under_temp(dest, ctx))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::shell::readonly::{
CheckContext, ShellPlatform, ValidationState, check_command, check_line_divergences,
is_null_target,
};
use std::path::Path;
fn temp_root() -> String {
if cfg!(windows) {
let temp = std::env::temp_dir();
let canonical = temp.canonicalize().unwrap_or(temp);
strip_verbatim(&canonical.to_string_lossy())
} else {
r"C:\Temp".to_string()
}
}
fn win_ctx() -> CheckContext {
let mut ctx = CheckContext::for_platform(Path::new(r"C:\ws"), ShellPlatform::Windows);
ctx.temp_roots = vec![PathBuf::from(temp_root())];
ctx.temp_vars = vec![
("TMP".to_string(), temp_root()),
("TEMP".to_string(), temp_root()),
];
ctx
}
fn ok(cmd: &str) {
let ctx = win_ctx();
assert!(
check_command(cmd, &ctx).is_ok(),
"expected ALLOW but got REJECT for: `{cmd}`"
);
}
fn assert_rejected(cmd: &str) {
let ctx = win_ctx();
assert!(
check_command(cmd, &ctx).is_err(),
"expected REJECT but got ALLOW for: `{cmd}`"
);
}
#[test]
fn verb_key_strips_quotes_prefixes_and_executable_extensions() {
let cases = [
(r"C:\Windows\System32\FORMAT.COM", Some("format")),
(r#""C:\Program Files\x\del.exe""#, Some("del")),
("DEL.BAT", Some("del")),
("/usr/bin/rm", None),
("/usr/bin/rm.exe", Some("rm")),
("dir", Some("dir")),
("@del", Some("del")),
("@FORMAT.COM", Some("format")),
("del/q", Some("del")),
("RD/S/Q", Some("rd")),
("@del/q", Some("del")),
("format/q", Some("format")),
("attrib/q", Some("attrib")),
("\"del\"/q", Some("del")),
("C:/Windows/format.com", Some("format")),
("bin/rm", None),
("nul.txt", Some("nul.txt")),
("%BIN%", None),
("!BIN!", None),
("$del", None),
("de*", None),
("de@l", None),
("de l", None),
("''", None),
];
for (word, expected) in cases {
assert_eq!(verb_key(word).as_deref(), expected, "word `{word}`");
}
}
#[test]
fn glued_switches_keep_the_spaced_verdict() {
assert_rejected(r"del/q C:\ws\x.txt");
assert_rejected(r"rd/s/q C:\ws\tree");
assert_rejected(r#"move/y C:\ws\a.txt "%TEMP%\b.txt""#);
assert_rejected(r#"copy/y "%TEMP%\a.txt" C:\ws\b.txt"#);
assert_rejected(r"taskkill/f /im notepad.exe");
assert_rejected(r"format/q C:");
ok(r#"del/q "%TEMP%\x.txt""#);
ok(r#"rd/s/q "%TEMP%\junk""#);
ok(r#"md/q "%TEMP%\new""#);
ok(r#"copy/y C:\ws\a.txt "%TEMP%\b.txt""#);
assert_rejected("set/a x=1");
assert_rejected("set/p x=");
ok(r#"md "%TEMP%\new" && cd/d "%TEMP%\new""#);
}
#[test]
fn path_model_resolves_drive_and_unc_prefixes() {
assert_eq!(normalize(r"C:\A\..\B\.\c"), Some(r"c:\b\c".to_string()));
assert_eq!(normalize(r"c:\a\..\..\b"), None); assert_eq!(
normalize(r"\\Server\Share\A\..\B"),
Some(r"\\server\share\b".to_string())
);
assert_eq!(split_prefix(r"c:\x"), Some(("c:".to_string(), r"\x")));
assert_eq!(split_prefix("relative"), Some((String::new(), "relative")));
assert_eq!(split_prefix(r"\\"), None);
assert_eq!(normalize(""), Some(String::new()));
}
#[test]
fn percent_variables_expand_case_insensitively_and_fail_closed() {
let ctx = win_ctx();
let expected = format!("{}\\x", temp_root());
assert_eq!(
expand_percent_vars(r"%temp%\x", &ctx).as_deref(),
Some(expected.as_str())
);
assert_eq!(
expand_percent_vars(r"%TMP%\x", &ctx).as_deref(),
Some(expected.as_str())
);
assert_eq!(expand_percent_vars("100%", &ctx).as_deref(), Some("100%"));
assert_eq!(expand_percent_vars("%%", &ctx).as_deref(), Some("%%"));
assert_eq!(expand_percent_vars("%a b%", &ctx).as_deref(), Some("%a b%"));
assert_eq!(expand_percent_vars("%UNSET%", &ctx), None);
assert_eq!(expand_percent_vars("%FOO%", &ctx), None);
}
#[test]
fn denied_verbs_are_refused_in_every_spelling() {
for verb in DENIED_VERBS {
assert_rejected(verb);
assert_rejected(&format!("{verb} /?"));
assert_rejected(&format!(
r"C:\Windows\System32\{}.EXE",
verb.to_ascii_uppercase()
));
}
assert_rejected(r"@del C:\ws\x.txt");
assert_rejected(r"@format C:");
assert_rejected(r"C:\Program Files\format.com C:");
assert_rejected(r"del%X% C:\ws\x.txt");
}
#[test]
fn file_mutators_need_every_path_under_temp() {
let temp = temp_root();
ok(&format!(r#"del "{temp}\scratch.txt""#));
ok(r#"del "%TEMP%\scratch.txt""#);
ok(r#"DEL "%TEMP%\scratch.txt""#);
ok(r#"move "%TEMP%\a.txt" "%TEMP%\b.txt""#);
ok(r#"md "%TEMP%\new""#);
ok(r#"replace "%TEMP%\new.txt" "%TEMP%""#);
assert_rejected(r#"move "%TEMP%\a.txt" /ws/b.txt"#);
assert_rejected(r#"del "%TEMP%\a.txt" /b.txt"#);
ok(r#"move /-Y "%TEMP%\a.txt" "%TEMP%\b.txt""#);
assert_rejected(r"del C:\ws\src\main.rs");
assert_rejected(r"del %TEMP%\..\..\ws\main.rs");
assert_rejected("del src\\main.rs"); assert_rejected(r#"del C:\ws\src\main.rs "%TEMP%\a.txt""#); let err = check_command("del", &win_ctx()).unwrap_err();
assert!(err.contains("names no path"), "{err}");
assert_rejected(r#"erase "%TEMP%\a.txt" "%TEMP%d\b.txt""#);
assert_rejected(r#"move "%TEMP%\junk.rs""#);
assert_rejected(r#"move /y "%TEMP%\junk.rs""#);
assert_rejected(r#"replace "%TEMP%\junk.txt""#);
assert_rejected(r#"replace /A "%TEMP%\junk.txt""#);
assert_rejected(r#"ren "%TEMP%\a.txt" b.txt"#);
let err = check_command(r"move C:\ws\x.txt", &win_ctx()).unwrap_err();
assert!(
err.contains("not allowed outside the temp directory"),
"a single outside-temp operand should be refused as a workspace write: {err}"
);
}
#[test]
fn platform_rewritten_paths_fail_closed() {
assert_rejected(r"del c:Temp\scratch.txt");
assert_rejected(r"del %TEMP%\.. \x.txt");
assert_rejected(r"del %TEMP%\..\x.txt.");
}
#[test]
fn whitespace_in_an_operand_fails_closed() {
assert_rejected(r"del %TEMP%\a\ b.txt");
ok(r#"del "%TEMP%\a b.txt""#);
}
#[test]
fn single_quoted_tokens_are_ordinary_text() {
assert_rejected(r#"del "%TEMP%\a.txt" '/C:\ws\x'"#);
assert_rejected(r#"del "%TEMP%\a.txt" '-y'"#);
assert_rejected(r#"move "%TEMP%\a.txt" 'C:\ws\x'"#);
assert_rejected(r"dir > '%TEMP%\listing.txt'");
assert_rejected(r#"copy '%TEMP%\a.txt' "C:\ws\b.txt""#);
assert_rejected(r"'del' C:\ws\x.txt");
ok(r#"'del' "%TEMP%\x.txt""#);
}
#[test]
fn whitespace_after_expansion_fails_closed() {
let mut ctx = win_ctx();
let spaced = format!(r"{}\John Doe", temp_root());
ctx.temp_roots = vec![PathBuf::from(&spaced)];
ctx.temp_vars = vec![("TEMP".to_string(), spaced)];
assert!(
check_command(r"del %TEMP%\x.txt", &ctx).is_err(),
"expanded whitespace must split the operand, not be read as one word"
);
let quoted = resolve(r#""%TEMP%\x.txt""#, &ctx).expect("the quoted spelling resolves");
assert!(
under_roots("ed, &ctx),
"a quoted temp path must stay under a temp root that carries a space"
);
}
#[test]
fn caret_escapes_and_delayed_expansion_fail_closed() {
assert_rejected(r"del C:\ws\^..\..\ws\main.rs");
assert_rejected(r"del %TEMP%\^..\..\ws\main.rs");
assert_rejected(r"dir > C:\ws\^..\out.txt");
assert_rejected(r"del ^C:\ws\f.txt");
assert_rejected(r"del %TEMP%\sub\!A!\f.txt");
assert_rejected(
r"set DEST=C:\ws\src\main.rs && setlocal enabledelayedexpansion && del !DEST!",
);
}
#[test]
fn parameter_delimiters_and_separators_fail_closed() {
assert_rejected(r"copy %TEMP%\a.txt,C:\ws\b.txt");
assert_rejected(r"del %TEMP%\a.txt,C:\ws\b.txt");
assert_rejected(r"move %TEMP%\a.txt=C:\ws\b.txt");
assert_rejected(r#"del /f,C:\ws\x.txt "%TEMP%\junk.txt""#);
assert_rejected(r#"del /f=C:\ws\x.txt "%TEMP%\junk.txt""#);
assert_rejected(r#"rd /s,C:\ws\dir "%TEMP%\junk""#);
assert_rejected(r#"xcopy /move,C:\ws\y C:\ws\x.txt "%TEMP%\junk.txt""#);
assert_rejected(r#"robocopy /log=C:\ws\l C:\ws\x.txt "%TEMP%\junk.txt""#);
assert_rejected(r"del %TEMP%\x\;C:\ws\y.txt");
assert_rejected(r"del %TEMP%\a\&b.txt");
assert_rejected(r"del %TEMP%\x\>C:\ws\out.txt");
assert_rejected(r"del %TEMP%\x\|C:\ws\y.txt");
assert_rejected(r"del %TEMP%\x\<C:\ws\y.txt");
ok(r#"del "%TEMP%\a,b.txt""#);
ok(r#"copy C:\ws\a.txt "%TEMP%\b,c.txt""#);
ok(r#"del "%TEMP%\a&b.txt""#);
ok(r#"del "%TEMP%\a;b.txt""#);
}
#[test]
fn semicolon_split_lines_are_refused() {
assert_rejected(r"del %TEMP%\a;C:\ws\x.exe");
assert_rejected(r"del %TEMP%\a;..\..\ws\x.exe");
assert_rejected(r"dir C:\ws;");
assert_rejected(r#"del "%TEMP%\a";cmd /c del C:\ws\x.exe"#);
ok(r#"del "%TEMP%\a" && del "%TEMP%\b""#);
assert_rejected(r"echo a;b");
ok(r#"echo "a;b""#);
ok(r#"dir "C:\ws;a""#);
let err = check_command(r"echo a;b", &win_ctx()).unwrap_err();
assert!(err.starts_with("Command not run: "), "{err}");
assert!(err.contains("quote a `;` you mean literally"), "{err}");
}
#[test]
fn comment_lines_are_refused() {
assert_rejected(r"echo hi # & del C:\ws\x.txt");
assert_rejected(r"# & format C:");
assert_rejected(r"dir C:\ws # list");
ok(r#"echo "a # b""#);
ok(r"echo a#b");
let err = check_command(r"# & format C:", &win_ctx()).unwrap_err();
assert!(err.starts_with("Command not run: "), "{err}");
assert!(err.contains("quote a `#` you mean literally"), "{err}");
}
#[test]
fn line_divergences_are_refused_inside_every_construct() {
for cmd in [
"(dir C:\\ws # c\n)",
"echo $(dir C:\\ws # c\n)",
"{ dir C:\\ws # c\n}",
"f() { dir C:\\ws # c\n }",
"case x in a) dir C:\\ws ;; # c\n esac",
"dir C:\\ws | # c\n more",
] {
let err = check_command(cmd, &win_ctx()).unwrap_err();
assert!(err.contains("no comment syntax"), "{cmd}: {err}");
}
for cmd in [
"(del %TEMP%\\a;C:\\ws\\x.exe)",
"echo $(del %TEMP%\\a;C:\\ws\\x.exe)",
"{ del %TEMP%\\a;C:\\ws\\x.exe\n}",
"f() { del %TEMP%\\a;C:\\ws\\x.exe\n }",
"case x in a) del %TEMP%\\a;C:\\ws\\x.exe ;; esac",
"del %TEMP%\\a;C:\\ws\\x.exe | more",
] {
let err = check_command(cmd, &win_ctx()).unwrap_err();
assert!(err.contains("argument delimiter"), "{cmd}: {err}");
}
for cmd in [
"cat <<EOF\nx\nEOF",
"(cat <<EOF\nx\nEOF\n)",
"{ cat <<EOF\nx\nEOF\n}",
"echo $(cat <<EOF\nx\nEOF\n)",
"f() { cat <<EOF\nx\nEOF\n }",
"case x in a) cat <<EOF\nx\nEOF\n ;; esac",
"cat <<EOF\nx\nEOF | more",
] {
let err = check_command(cmd, &win_ctx()).unwrap_err();
assert!(err.contains("no `<<`"), "{cmd}: {err}");
}
}
#[test]
fn line_divergences_are_refused_in_both_modes() {
for cmd in [r"del %TEMP%\a;C:\ws\x.exe", r"echo hi # & del C:\ws\x.txt"] {
let guard = check_command(cmd, &win_ctx()).expect_err(cmd);
assert_eq!(
check_line_divergences(cmd, ShellPlatform::Windows).expect_err(cmd),
guard,
"cmd `{cmd}`"
);
let padded = format!(" {cmd} ");
assert_eq!(
check_line_divergences(&padded, ShellPlatform::Windows).unwrap_err(),
guard,
"padded `{padded}`"
);
assert!(
check_line_divergences(cmd, ShellPlatform::Unix).is_ok(),
"{cmd}"
);
}
assert!(check_command("if exist x (echo a;b)", &win_ctx()).is_err());
assert!(check_line_divergences("if exist x (echo a;b)", ShellPlatform::Windows).is_ok());
}
#[test]
fn documented_limits_are_not_closed() {
ok(r"dir \& del C:\ws\x.exe");
ok(r"whoami \> C:\ws\out.txt");
ok(r"%DEL% C:\ws\x.txt");
ok(r"!DEL! C:\ws\x.txt");
ok(r"d^el C:\ws\x.txt");
ok(r"DEL. C:\ws\x.txt");
ok(r"format.com. C:");
ok(r"certutil -decode C:\ws\x.b64 C:\ws\out.bin");
ok(r"sort /o C:\ws\out.txt C:\ws\a.txt");
ok(r"makecab C:\ws\x.txt");
ok(r"C:/Windows/System32/del C:\ws\x.txt");
ok(r"C:/Windows/System32/copy C:\ws\a.txt C:\ws\b.txt");
ok(r"C:/Windows/System32/format C:");
assert_rejected(r"C:/Windows/System32/rm C:\ws\x.txt");
assert_rejected(r"C:\Windows\System32\del C:\ws\x.txt");
ok("dir C:\\ws \\\n del C:\\ws\\x.exe");
ok("echo \"a\n del C:\\ws\\x.exe\"");
ok(r#"del "%TEMP%\%中%\x.txt""#);
}
#[test]
fn clock_builtins_refuse_an_operand() {
ok("date /t");
ok("time /t");
ok("date/t");
ok("time/t");
ok("date");
ok("time");
assert_rejected("date 12/31/2026");
assert_rejected("time 12:34:56.78");
assert_rejected("time/t 12:00");
assert_rejected("@time 12:00");
}
#[test]
fn copy_shaped_verbs_gate_the_destination_only() {
ok(r#"copy C:\ws\a.txt "%TEMP%\b.txt""#);
ok(r#"xcopy C:\ws\src "%TEMP%\backup" /E"#);
ok(r#"robocopy C:\ws "%TEMP%\mirror" /MIR"#);
assert_rejected(r#"copy "%TEMP%\a.txt" C:\ws\b.txt"#);
assert_rejected(r#"xcopy "%TEMP%\mirror" C:\ws\mirror /E"#);
assert_rejected(r"robocopy C:\ws C:\ws\backup /MIR");
assert_rejected(r#"copy "%TEMP%\report.txt""#);
assert_rejected(r#"xcopy "%TEMP%\report.txt""#);
assert_rejected(r"copy C:\ws\a.txt");
assert_rejected("copy");
assert_rejected(r#"robocopy C:\ws "%TEMP%\x" /MOVE"#);
assert_rejected(r#"robocopy C:\ws "%TEMP%\x" /MOV"#);
assert_rejected(r#"robocopy C:\ws "%TEMP%\x" /LOG:C:\ws\run.log"#);
assert_rejected(r#"robocopy C:\ws "%TEMP%\x" /REG"#);
assert_rejected(r#"robocopy C:\ws "%TEMP%\x" /MOT:5"#);
assert_rejected(r#"robocopy C:\ws "%TEMP%\x" /MON:3"#);
assert_rejected(r#"xcopy C:\ws "%TEMP%\x" /M"#);
ok(r#"xcopy C:\ws "%TEMP%\x" /A"#);
ok(r#"robocopy C:\ws "%TEMP%\x" /E /R:1 /W:1"#);
}
#[test]
fn list_only_verbs_allow_the_bare_form() {
ok("attrib");
ok(r"attrib C:\ws\a.txt");
ok(r"icacls C:\ws\a.txt");
assert_rejected(r"attrib +r C:\ws\a.txt");
assert_rejected(r"attrib -h C:\ws\a.txt");
assert_rejected(r"attrib /s C:\ws");
assert_rejected(r"icacls C:\ws\a.txt /grant everyone:f");
assert_rejected(r"icacls C:\ws\a.txt /deny everyone:d");
assert_rejected(r"cipher /w:C:\ws");
assert_rejected(r"compact /c C:\ws\a.txt");
}
#[test]
fn query_verbs_allow_only_the_inspection_spelling() {
ok(r"reg query HKLM\Software");
ok("sc query state= all");
ok("sc queryex wuauserv");
ok("schtasks /query /fo LIST");
ok("reg/query HKLM\\Software");
ok("schtasks/query /fo LIST");
assert_rejected(r"reg add HKLM\Software\X /v Y");
assert_rejected(r"reg delete HKLM\Software\X");
assert_rejected(r"reg import %TEMP%\x.reg");
assert_rejected(r"reg/add HKLM\Software\X /v Y");
assert_rejected("sc start wuauserv");
assert_rejected("sc stop wuauserv");
assert_rejected("schtasks /create /tn x /tr y");
assert_rejected("schtasks /delete /tn x");
}
#[test]
fn set_binds_only_what_the_model_can_follow() {
ok("set");
ok("set PATH");
ok("set FOO=bar");
ok(r#"set "FOO=bar baz""#);
ok(r"set FOO=%TEMP%\x");
assert_rejected(r"set TEMP=C:\ws");
assert_rejected(r"set temp=C:\ws");
assert_rejected(r#"set "TEMP=%TEMP%\x""#);
assert_rejected(r"set TMP=C:\ws && del %TMP%\x.txt");
assert_rejected(r"set FOO=%TEMP%\x && del %FOO%\x.txt");
assert_rejected("set /a x=1");
assert_rejected("set /p x=");
assert_rejected("set =value");
assert_rejected(r"set TEMP='C:\ws'");
assert_rejected(r#"set "TEMP='C:\ws'""#);
assert_rejected(r"set TEMP^=C:\ws");
assert_rejected(r"set TEMP =C:\ws");
assert_rejected(r"set TEM%P%=C:\ws");
assert_rejected(r"set GIT_SSH_COMMAND=C:\ws\evil.exe && git ls-remote x");
assert_rejected(r"set git_dir=C:\ws");
ok("set GIT_PAGER=cat");
}
#[test]
fn mixed_case_git_verbs_keep_their_verdict() {
for verb in ["Git", "GIT", "git.exe", "GIT.EXE", "Git.EXE"] {
for args in [
"push origin main",
"clean -fdx",
"checkout -f .",
"status",
"log --oneline",
] {
let ctx = win_ctx();
let mixed = check_command(&format!("{verb} {args}"), &ctx);
assert_eq!(
mixed.is_ok(),
check_command(&format!("git {args}"), &ctx).is_ok(),
"`{verb} {args}` must match the `git {args}` verdict"
);
}
}
assert_rejected("Git push origin main");
assert_rejected("GIT clean -fdx");
assert_rejected("GIT.EXE clean -fdx");
ok("Git status");
ok("git.exe log --oneline");
}
#[test]
fn relative_operands_are_refused() {
let ctx = win_ctx();
assert_eq!(resolve_raw("x.txt", &ctx), None);
assert!(!under_temp("x.txt", &ctx));
assert_eq!(resolve_raw(r"\x.txt", &ctx), None);
assert_eq!(resolve_raw("c:x.txt", &ctx), None);
assert!(!under_temp(r"\x.txt", &ctx));
let mut cleaner =
CheckContext::for_platform(Path::new(&temp_root()), ShellPlatform::Windows);
cleaner.temp_roots = vec![PathBuf::from(temp_root())];
cleaner.temp_vars = vec![
("TMP".to_string(), temp_root()),
("TEMP".to_string(), temp_root()),
];
assert!(
check_command(r"del x.txt", &cleaner).is_err(),
"a relative operand must be refused even when the workspace is a temp root"
);
assert!(
check_command(r"cd ..\.. && del x.txt", &cleaner).is_err(),
"the directory change must not make a relative operand provable"
);
assert!(
check_command(&format!(r#"del "{}\x.txt""#, temp_root()), &cleaner).is_ok(),
"the absolute spelling of the same write stays admitted"
);
assert_rejected(r"cd %TEMP% && del x.txt");
assert_rejected(r"cd /d %TEMP% && del x.txt");
assert_rejected(r"md %TEMP%\new && cd %TEMP%\new && del x.txt");
assert_rejected(r"CD %TEMP% && del x.txt");
assert_rejected(r"pushd %TEMP% && del x.txt");
assert_rejected(r"cd %TEMP% && cd.. && del x.txt");
assert_rejected(r"cd %TEMP% && dir > out.txt");
assert_rejected(r"cd %TEMP% && copy x.txt C:\ws\y.txt");
assert_rejected(r"cd %dir% && del x.txt");
ok(r#"cd %TEMP% && del "%TEMP%\x.txt""#);
}
#[test]
fn cd_family_is_owned_by_the_layer() {
ok("cd");
ok(r"cd C:\ws");
ok("cd..");
ok(r"cd /d %TEMP%");
ok("cdrecord /dev/x"); ok(r"CD C:\ws");
ok("cd/d C:\\ws");
ok(r"chdir C:\ws");
ok("pushd C:\\ws");
ok("popd");
assert_rejected(r"cd..\..");
assert_rejected(r"cd\Users");
ok("C:");
ok(r"C: && dir C:\ws");
assert_rejected(r"D: del C:\ws\x.txt");
assert_rejected(r#"D: del "%TEMP%\x.txt""#);
}
#[test]
fn non_ascii_command_words_are_judged_not_panicked() {
for cmd in [
"céd",
"céd/d",
"céd..",
"céd\\x",
"c€x /x",
"\u{4e2d}\u{6587} C:\\ws",
"del céd.txt",
"«rd» C:\\ws",
] {
let _ = check_command(cmd, &win_ctx());
let _ = verb_key(cmd);
let _ = split_glued_switch(cmd);
}
assert_rejected("del céd.txt"); assert_rejected(r#"del "céd.txt""#); assert_rejected(r"céd\del C:\ws\x.txt"); ok(r#"dir C:\ws\céd && del "%TEMP%\céd.txt""#);
}
#[test]
fn sanitation_windows_tool_block_still_works() {
let temp = temp_root();
ok("whoami /user");
ok(&format!(r#"dir /a /q /tw "{temp}\junk""#));
ok(&format!(r#"dir /a:l /s /b "{temp}""#));
ok(&format!(r#"dir /a "{temp}""#));
ok(&format!(
r#"forfiles /P "{temp}" /S /M * /C "cmd /c echo @fdate @ftime @path""#
));
ok(&format!(r#"del /f /q "{temp}\junk.txt""#));
ok(&format!(r#"rd /s /q "{temp}\junk""#));
ok(&format!(r#"rd "{temp}\junk""#));
}
#[test]
fn redirects_accept_the_platform_null_device_and_temp_paths() {
let temp = temp_root();
ok("dir > NUL");
ok("dir > nul:");
ok("dir > NUL.txt");
ok(&format!(r#"dir > "{temp}\listing.txt""#));
ok(r#"dir > "%TEMP%\listing.txt""#);
ok("dir 2>&1");
assert_rejected(r"dir > C:\ws\listing.txt");
assert_rejected("dir > NULX");
assert_rejected("dir > 'NUL'");
assert_rejected(r"dir > NUL\..\..\ws\x.txt");
assert_rejected("dir > NUL.x\\foo");
assert_rejected("dir > /dev/null");
}
#[test]
fn canonical_paths_are_folded_before_comparing() {
let ctx = win_ctx();
assert!(canonical_under_roots(
&format!(r"\\?\{}\MahBot\X.txt", temp_root().to_ascii_uppercase()),
&ctx
));
assert!(!canonical_under_roots(
r"\\?\C:\definitely-not-temp\x",
&ctx
));
}
#[test]
fn unmodelled_verbs_fall_through_to_the_shared_dispatch() {
ok(r"forfiles /p %TEMP% /m * /d -7");
ok(r"dir /b C:\ws");
ok(r"where cargo.exe");
ok(r"type C:\ws\Cargo.toml");
ok(r"findstr /s /i TODO C:\ws\src\*.rs");
ok(r"powershell -Command Get-ChildItem");
ok(r"cmd /c del C:\ws\x.txt");
assert_rejected(r"TAR -xf C:\ws\a.tar");
assert_rejected(r"CURL -o C:\ws\f https://example.com");
assert_rejected(r"SHUTDOWN /s");
assert_rejected(r"tar.exe -xf C:\ws\a.tar");
assert_rejected(r"Tar.EXE -xf C:\ws\a.tar");
assert_rejected(r"sed.exe -i C:\ws\f");
assert_rejected(r"dd.exe of=C:\ws\f");
assert_rejected(r"shutdown.exe /s");
ok(r"tar.exe -tf C:\ws\a.tar");
assert_rejected(r"rm.exe C:\ws\x.txt");
assert_rejected(r"C:\tools\rm.exe C:\ws\x.txt");
ok(r#"rm.exe "%TEMP%\x.txt""#);
ok(r#""C:\Program Files\mahbot\mahbot.exe" --version"#);
}
#[test]
fn unix_platform_verdicts_never_use_the_windows_rules() {
let unix = CheckContext::for_platform(Path::new("/__ws__"), ShellPlatform::Unix);
let state = ValidationState::new(&unix);
assert!(!is_null_target("NUL", &state));
assert!(!is_null_target("nul:", &state));
assert!(check_command("echo hi > NUL", &unix).is_err());
assert!(check_command("tar -xf a.tar", &unix).is_err());
assert!(check_command("TAR -xf a.tar", &unix).is_ok());
assert!(check_command("shutdown /s", &unix).is_err());
assert!(check_command("SHUTDOWN /s", &unix).is_ok());
assert!(check_command("shutdown.exe /s", &unix).is_ok());
assert!(check_command("echo a; echo b", &unix).is_ok());
assert!(check_command("echo a # b", &unix).is_ok());
assert!(check_command("cat <<EOF\nbody\nEOF", &unix).is_ok());
}
}