diff --git a/src/tools/path_guard.rs b/src/tools/path_guard.rs index 9756c0d7..ff231fd5 100644 --- a/src/tools/path_guard.rs +++ b/src/tools/path_guard.rs @@ -2,6 +2,8 @@ use std::path::{Component, Path, PathBuf}; use anyhow::{Context, bail}; +mod bash_pattern; + const EXPECTED_PATH_FORM: &str = "use workspace-relative paths"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -210,6 +212,17 @@ pub(super) fn ensure_bash_write_target(root: &Path, raw: &str) -> anyhow::Result if raw.contains(['$', '`']) { bail!("dynamic Bash write target cannot be proven to remain in the workspace"); } + ensure_single_target(root, raw)?; + // A glob or brace target spells a name that does not exist yet, so the + // literal proof above stops at the workspace root. Expand the word the way + // the shell would and prove every result too. + if bash_pattern::contains_expansion(raw) { + bash_pattern::ensure_expanded_write_target(root, raw)?; + } + Ok(()) +} + +fn ensure_single_target(root: &Path, raw: &str) -> anyhow::Result<()> { let path = Path::new(raw); if !path.is_absolute() { validate_workspace_relative(raw)?; @@ -969,4 +982,41 @@ mod tests { std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap(); assert!(result.is_err()); } + + #[cfg(unix)] + #[test] + fn ensure_bash_write_target_expansion_table() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("ws"); + std::fs::create_dir_all(root.join("sub")).unwrap(); + std::fs::write(root.join("sub/f"), "x").unwrap(); + std::fs::create_dir_all(dir.path().join("outside")).unwrap(); + std::fs::write(dir.path().join("outside/secret"), "x").unwrap(); + std::os::unix::fs::symlink(dir.path().join("outside"), root.join("linked-outside")) + .unwrap(); + std::os::unix::fs::symlink(dir.path().join("outside"), root.join(".hidden-out")).unwrap(); + let root = root.canonicalize().unwrap(); + + for raw in [ + "lin*/secret", + "linked-outsid?/secret", + "linked-outsid[e]/secret", + "*/secret", + "**/secret", + ".hid*/f", + ".*/f", + ".?/f", + "{.,}./f", + "{.,.}./f", + "{linked-outside,x}/f", + "{k..m}inked-outside/f", + "{linked-outside/f,x}", + ] { + assert!(ensure_bash_write_target(&root, raw).is_err(), "{raw}"); + } + + for raw in ["sub/f", "nomatch*/f", "src/new.rs", "sub/{a,b}.txt"] { + assert!(ensure_bash_write_target(&root, raw).is_ok(), "{raw}"); + } + } } diff --git a/src/tools/path_guard/bash_pattern.rs b/src/tools/path_guard/bash_pattern.rs new file mode 100644 index 00000000..57306f5b --- /dev/null +++ b/src/tools/path_guard/bash_pattern.rs @@ -0,0 +1,735 @@ +//! Shell brace and glob expansion for Bash write-target confinement. +//! +//! The literal path proof in [`super`] cannot see where a Bash *write target* +//! actually lands when the word carries shell expansion syntax. A glob (`*`, +//! `?`, `[...]`) only matches names that already exist, and a brace +//! (`{a,b}`, `{1..9}`) is rewritten before the command runs, so the +//! pre-expansion spelling does not exist on disk. The literal proof then falls +//! back to the nearest existing parent -- the workspace root -- and reports the +//! target as inside the workspace. At run time the shell expands the word and +//! can reach an intermediate symlink that leaves the workspace, or a `..` +//! produced by a brace such as `{.,}./f`. +//! +//! This module expands the word the way the shell would and re-runs the +//! literal proof on every result. It counts a brace word's expansion before +//! expanding it, never builds an over-limit range, never reads a directory +//! outside the workspace root, and fails closed once an expansion limit is +//! exceeded, so a symlink loop, an unbounded glob, or an overflowing range +//! cannot hang the guard. +//! +//! Known limitations: shell options this guard does not model, so a word that +//! behaves differently under them may be mis-proven and fall back to allowing +//! the literal spelling. They are off in the default non-interactive shell this +//! guard targets: `extglob`, `GLOBIGNORE`, `nocaseglob`, and `dotglob` +//! (`shopt -s dotglob` would let `*` match leading-dot names the guard skips). + +use std::path::{Component, Path, PathBuf}; + +use anyhow::{Context, bail}; +use globset::Glob; + +/// Upper bound on the number of strings a single brace word may expand into. +pub(super) const MAX_BRACE_EXPANSIONS: usize = 256; +/// Upper bound on the number of names a single word's globs may match. +pub(super) const MAX_GLOB_MATCHES: usize = 4096; + +/// Whether the word carries any shell expansion syntax this module understands. +pub(super) fn contains_expansion(raw: &str) -> bool { + raw.chars().any(|ch| matches!(ch, '*' | '?' | '[' | '{')) +} + +/// Expand `raw` and prove every expansion with the literal write-target proof. +pub(super) fn ensure_expanded_write_target(root: &Path, raw: &str) -> anyhow::Result<()> { + for expanded in expand_braces(raw)? { + if has_glob_meta(&expanded) { + match expand_glob(root, &expanded)? { + Some(matches) => { + for matched in matches { + super::ensure_single_target(root, &matched)?; + } + } + None => super::ensure_single_target(root, &expanded)?, + } + } else { + super::ensure_single_target(root, &expanded)?; + } + } + Ok(()) +} + +fn has_glob_meta(value: &str) -> bool { + value.chars().any(|ch| matches!(ch, '*' | '?' | '[')) +} + +fn expand_braces(raw: &str) -> anyhow::Result> { + if count_expansions(raw, MAX_BRACE_EXPANSIONS).is_none() { + bail!( + "Bash write target brace expansion exceeds the {MAX_BRACE_EXPANSIONS}-expansion limit; the target cannot be proven to remain in the workspace" + ); + } + let mut expanded = Vec::new(); + expand_into(raw, &mut expanded); + Ok(expanded) +} + +fn expand_into(input: &str, out: &mut Vec) { + match find_brace(input) { + Some(span) => { + for alternative in span.shape_values() { + let next = format!( + "{}{alternative}{}", + &input[..span.start], + &input[span.end..] + ); + expand_into(&next, out); + } + } + None => out.push(input.to_string()), + } +} + +/// Count a word's full expansion without materializing it. +/// +/// Returns `None` when the count exceeds `limit` or the arithmetic overflows, +/// so the caller rejects before any queue or range vector can grow. +fn count_expansions(input: &str, limit: usize) -> Option { + let Some(span) = find_brace(input) else { + return Some(1); + }; + if span.shape_count(limit)? > limit { + return None; + } + // The prefix before the first brace holds no brace, so it contributes + // nothing to the count: only the alternative and the suffix matter. + let suffix = &input[span.end..]; + let mut total = 0usize; + for alternative in span.shape_values() { + let sub = count_expansions(&format!("{alternative}{suffix}"), limit)?; + total = total.checked_add(sub)?; + if total > limit { + return None; + } + } + Some(total) +} + +struct BraceSpan { + start: usize, + end: usize, + shape: BraceShape, +} + +impl BraceSpan { + /// Immediate alternative count, capped at `limit + 1` so a huge range is + /// never built. `None` marks an invalid shape (for example a zero step). + fn shape_count(&self, limit: usize) -> Option { + match &self.shape { + BraceShape::List(alternatives) => Some(alternatives.len().min(limit.saturating_add(1))), + BraceShape::Range(range) => range.bounded_count(limit), + } + } + + fn shape_values(&self) -> Vec { + match &self.shape { + BraceShape::List(alternatives) => alternatives.clone(), + BraceShape::Range(range) => range.values(), + } + } +} + +enum BraceShape { + List(Vec), + Range(RangeSpec), +} + +struct RangeSpec { + start: i64, + end: i64, + step: i64, + /// Zero-pad width when an endpoint carries a leading zero, else 0. + width: usize, + /// Character range (`{a..z}`) renders each value as a character. + is_char: bool, +} + +impl RangeSpec { + /// Number of expanded strings, capped at `limit + 1`. A padded range emits + /// both the zero-padded and the zero-stripped form, so it doubles. + fn bounded_count(&self, limit: usize) -> Option { + if self.step == 0 { + return None; + } + let span = (self.end as i128 - self.start as i128).unsigned_abs(); + let size = span / self.step as u128 + 1; + let forms = if self.width > 0 { 2u128 } else { 1u128 }; + let count = size * forms; + Some(if count > limit as u128 + 1 { + limit.saturating_add(1) + } else { + count as usize + }) + } + + fn values(&self) -> Vec { + let direction: i64 = if self.end >= self.start { 1 } else { -1 }; + let mut values = Vec::new(); + let mut value = self.start; + loop { + if (direction > 0 && value > self.end) || (direction < 0 && value < self.end) { + break; + } + let plain = value.to_string(); + if self.is_char { + values.push(char::from(value as u8).to_string()); + } else if self.width > 0 { + values.push(format!("{value:0>width$}", width = self.width)); + values.push(plain); + } else { + values.push(plain); + } + let Some(next) = direction + .checked_mul(self.step) + .and_then(|delta| value.checked_add(delta)) + else { + break; + }; + value = next; + } + values + } +} + +fn find_brace(input: &str) -> Option { + let bytes = input.as_bytes(); + let mut index = 0; + while index < bytes.len() { + if bytes[index] != b'{' { + index += 1; + continue; + } + let start = index; + let mut depth = 0usize; + let mut close = None; + let mut cursor = start; + while cursor < bytes.len() { + match bytes[cursor] { + b'{' => depth += 1, + b'}' => { + depth -= 1; + if depth == 0 { + close = Some(cursor); + break; + } + } + _ => {} + } + cursor += 1; + } + // An unterminated brace is literal, not an error: the word is proven + // as written, matching the shell, and never indexes past the input. + let close = close?; + if let Some(shape) = parse_brace_shape(&input[start + 1..close]) { + return Some(BraceSpan { + start, + end: close + 1, + shape, + }); + } + // This brace is literal; a nested brace may still be expandable, so + // resume just after its opening brace rather than skipping it whole. + index = start + 1; + } + None +} + +fn parse_brace_shape(content: &str) -> Option { + if let Some(parts) = split_top_level_commas(content) { + return Some(BraceShape::List(parts)); + } + range_spec(content).map(BraceShape::Range) +} + +fn split_top_level_commas(content: &str) -> Option> { + let mut depth = 0i32; + let mut current = String::new(); + let mut parts = Vec::new(); + let mut saw_comma = false; + for ch in content.chars() { + match ch { + '{' => { + depth += 1; + current.push(ch); + } + '}' => { + depth -= 1; + current.push(ch); + } + ',' if depth == 0 => { + saw_comma = true; + parts.push(std::mem::take(&mut current)); + } + _ => current.push(ch), + } + } + if !saw_comma { + return None; + } + parts.push(current); + Some(parts) +} + +fn range_spec(content: &str) -> Option { + let parts: Vec<&str> = content.split("..").collect(); + let (from, to, step) = match parts.as_slice() { + [from, to] => (*from, *to, 1i64), + [from, to, step] => (*from, *to, step.parse::().ok()?.checked_abs()?), + _ => return None, + }; + if step == 0 { + return None; + } + if let (Ok(start), Ok(end)) = (from.parse::(), to.parse::()) { + return Some(RangeSpec { + start, + end, + step, + width: numeric_pad_width(from, to), + is_char: false, + }); + } + let start = single_ascii_char(from)?; + let end = single_ascii_char(to)?; + Some(RangeSpec { + start: i64::from(start), + end: i64::from(end), + step, + width: 0, + is_char: true, + }) +} + +fn numeric_pad_width(from: &str, to: &str) -> usize { + if has_leading_zero(from) || has_leading_zero(to) { + from.len().max(to.len()) + } else { + 0 + } +} + +fn has_leading_zero(value: &str) -> bool { + let digits = value.strip_prefix('-').unwrap_or(value); + digits.len() > 1 && digits.starts_with('0') +} + +fn single_ascii_char(value: &str) -> Option { + let mut chars = value.chars(); + let ch = chars.next()?; + if chars.next().is_some() || !ch.is_ascii() { + return None; + } + Some(ch as u8) +} + +/// Expand a word that still contains glob metacharacters. +/// +/// Returns `Some(paths)` when the glob matched at least one existing name and +/// `None` when it matched nothing, so the caller can fall back to treating the +/// word as the shell would: an unmatched glob is passed through literally. +fn expand_glob(root: &Path, pattern: &str) -> anyhow::Result>> { + let root_canonical = root + .canonicalize() + .context("workspace root is not accessible")?; + let path = Path::new(pattern); + let remainder = if path.is_absolute() { + match path + .strip_prefix(&root_canonical) + .or_else(|_| path.strip_prefix(root)) + { + Ok(remainder) => remainder, + // An absolute word outside the root cannot be enumerated here; the + // literal proof in the caller rejects it. + Err(_) => return Ok(None), + } + } else { + path + }; + + let mut current = vec![root_canonical.clone()]; + let mut matched_total = 0usize; + let components: Vec = remainder.components().collect(); + for (index, component) in components.iter().enumerate() { + let is_last = index + 1 == components.len(); + let name = match component { + Component::Normal(name) => name.to_string_lossy().to_string(), + Component::CurDir => ".".to_string(), + Component::ParentDir => "..".to_string(), + Component::RootDir | Component::Prefix(_) => continue, + }; + if !has_glob_meta(&name) { + current = current.into_iter().map(|base| base.join(&name)).collect(); + continue; + } + let (next, total, matched) = + enumerate_component(&root_canonical, ¤t, &name, matched_total, !is_last)?; + if !matched { + return Ok(None); + } + matched_total = total; + current = next; + } + Ok(Some( + current + .into_iter() + .map(|path| path.to_string_lossy().to_string()) + .collect(), + )) +} + +fn enumerate_component( + root_canonical: &Path, + current: &[PathBuf], + name: &str, + mut matched_total: usize, + intermediate: bool, +) -> anyhow::Result<(Vec, usize, bool)> { + // globset does not implement POSIX bracket expressions (`[[:class:]]`, + // `[[=c=]]`, `[[.coll.]]`); it would read them as an ordinary class and + // silently match a different set than the shell. Refuse rather than fall + // back to the literal spelling and allow a word the shell expands. + if name.contains("[:") || name.contains("[=") || name.contains("[.") { + bail!( + "Bash write target glob uses a POSIX bracket expression (`[:`/`[=`/`[.`) that cannot be proven to remain in the workspace" + ); + } + let Some(pattern) = globset_pattern(name) else { + // Rewriting a brace, comma or backslash into a class literal inside an + // existing bracket would nest brackets (`[,l]` -> `[[,]l]`) and globset + // would read a different set than the shell. Refuse rather than fall + // back to the literal spelling and allow a word the shell expands. + bail!( + "Bash write target glob mixes `[` with `{{`, `}}`, `,`, or `\\`, which cannot be proven to remain in the workspace" + ); + }; + let Some(matcher) = Glob::new(&pattern).ok().map(|glob| glob.compile_matcher()) else { + // The component is not a valid glob; the shell treats it literally. + let next = current.iter().map(|base| base.join(name)).collect(); + return Ok((next, matched_total, true)); + }; + let dot_leading = name.starts_with('.'); + let mut next = Vec::new(); + for base in current { + let Ok(base_canonical) = base.canonicalize() else { + continue; + }; + if !base_canonical.starts_with(root_canonical) { + bail!( + "Bash write target glob expands outside the workspace root at `{}`", + base.display() + ); + } + let Ok(entries) = std::fs::read_dir(&base_canonical) else { + continue; + }; + let mut candidates = Vec::new(); + for entry in entries.flatten() { + let entry_name = entry.file_name().to_string_lossy().to_string(); + if !dot_leading && entry_name.starts_with('.') { + continue; + } + candidates.push(entry_name); + } + if dot_leading { + candidates.push(".".to_string()); + candidates.push("..".to_string()); + } + for candidate in candidates { + if !matcher.is_match(&candidate) { + continue; + } + if candidate == ".." { + bail!( + "Bash write target glob matches `..`, which cannot be proven to remain in the workspace" + ); + } + let child = base_canonical.join(&candidate); + let child_canonical = child.canonicalize().ok(); + if let Some(canonical) = &child_canonical + && !canonical.starts_with(root_canonical) + { + bail!( + "Bash write target glob match `{candidate}` resolves outside the workspace root" + ); + } + if intermediate { + // A glob used as a directory component only expands to + // directories the shell can descend into. A regular file (or a + // symlink the shell cannot traverse) drops the whole branch. + match &child_canonical { + Some(canonical) if canonical.is_dir() => {} + _ => continue, + } + } else if child_canonical.is_none() { + bail!( + "Bash write target glob match `{candidate}` cannot be resolved inside the workspace root" + ); + } + matched_total += 1; + if matched_total > MAX_GLOB_MATCHES { + bail!( + "Bash write target glob exceeds the {MAX_GLOB_MATCHES}-match limit; the target cannot be proven to remain in the workspace" + ); + } + next.push(child); + } + } + let matched = !next.is_empty(); + Ok((next, matched_total, matched)) +} + +/// Translate one path component into globset's syntax. +/// +/// The shell reads a leftover `{`, `}`, `,`, or `\` as an ordinary character, +/// while globset gives all four a special meaning (alternation and escaping). +/// Rewrite them into character-class literals (`[{]`, `[}]`, `[,]`, `[\\]`) so +/// both engines match the same names, and collapse `*` runs because `**` inside +/// one component behaves like `*` in the shell and globset rejects it there. +/// +/// Returns `None` when the component also contains `[`: rewriting a brace, +/// comma or backslash into a class literal would nest the brackets +/// (`[,l]` -> `[[,]l]`) and globset would read a different set than the shell, +/// so the word must be refused instead of allowed through the literal fallback. +fn globset_pattern(name: &str) -> Option { + let has_bracket = name.contains('['); + let mut out = String::with_capacity(name.len()); + let mut previous_star = false; + for ch in name.chars() { + match ch { + '{' | '}' | ',' | '\\' if has_bracket => return None, + '*' => { + if previous_star { + continue; + } + previous_star = true; + out.push('*'); + } + '{' => { + previous_star = false; + out.push_str("[{]"); + } + '}' => { + previous_star = false; + out.push_str("[}]"); + } + ',' => { + previous_star = false; + out.push_str("[,]"); + } + '\\' => { + previous_star = false; + out.push_str("[\\\\]"); + } + _ => { + previous_star = false; + out.push(ch); + } + } + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Instant; + + #[test] + fn contains_expansion_table() { + assert!(contains_expansion("src/[id]")); + assert!(contains_expansion("lin*/secret")); + assert!(contains_expansion("a?b")); + assert!(contains_expansion("{a,b}")); + assert!(!contains_expansion("plain/path.txt")); + } + + #[test] + fn globset_pattern_renders_shell_literals() { + assert_eq!(globset_pattern("plain").unwrap(), "plain"); + assert_eq!(globset_pattern("a**b").unwrap(), "a*b"); + assert_eq!(globset_pattern("**").unwrap(), "*"); + assert_eq!(globset_pattern("{x}*").unwrap(), "[{]x[}]*"); + assert_eq!(globset_pattern("{a,b}").unwrap(), "[{]a[,]b[}]"); + assert_eq!(globset_pattern("a\\b").unwrap(), "a[\\\\]b"); + // A bracket without `{ } , \` passes through unchanged. + assert_eq!(globset_pattern("[id]").unwrap(), "[id]"); + // A bracket mixed with `{ } , \` cannot be rewritten without nesting. + assert_eq!(globset_pattern("[,l]inked-outside"), None); + assert_eq!(globset_pattern("[{l]inked-outside"), None); + assert_eq!(globset_pattern("[l}]inked-outside"), None); + assert_eq!(globset_pattern("[!{]inked-outside"), None); + assert_eq!(globset_pattern("a[,\\b"), None); + } + + #[test] + fn expand_braces_table() { + assert_eq!(expand_braces("plain").unwrap(), ["plain"]); + assert_eq!(expand_braces("{a,b}").unwrap(), ["a", "b"]); + assert_eq!( + expand_braces("pre{a,b}post").unwrap(), + ["preapost", "prebpost"] + ); + assert_eq!(expand_braces("{a,{b,c}}").unwrap(), ["a", "b", "c"]); + assert_eq!(expand_braces("{{a,b},y}").unwrap(), ["a", "b", "y"]); + assert_eq!(expand_braces("{a{b,c}}").unwrap(), ["{ab}", "{ac}"]); + assert_eq!(expand_braces("{k..m}").unwrap(), ["k", "l", "m"]); + assert_eq!(expand_braces("{1..3}").unwrap(), ["1", "2", "3"]); + assert_eq!(expand_braces("{3..1}").unwrap(), ["3", "2", "1"]); + assert_eq!(expand_braces("{1..5..2}").unwrap(), ["1", "3", "5"]); + assert_eq!(expand_braces("{01..02}").unwrap(), ["01", "1", "02", "2"]); + assert_eq!(expand_braces("{01..2}").unwrap(), ["01", "1", "02", "2"]); + assert_eq!(expand_braces("{1..02}").unwrap(), ["01", "1", "02", "2"]); + assert_eq!(expand_braces("{a}").unwrap(), ["{a}"]); + assert_eq!(expand_braces("{x}y{z}").unwrap(), ["{x}y{z}"]); + } + + #[test] + fn unterminated_and_non_range_braces_stay_literal() { + assert_eq!(expand_braces("{a,b").unwrap(), ["{a,b"]); + assert_eq!(expand_braces("{a").unwrap(), ["{a"]); + assert_eq!(expand_braces("{ab..d}").unwrap(), ["{ab..d}"]); + assert_eq!(expand_braces("{a..}").unwrap(), ["{a..}"]); + // A step whose magnitude overflows i64 is literal, never a panic. + assert_eq!( + expand_braces("{1..5..-9223372036854775808}").unwrap(), + ["{1..5..-9223372036854775808}"] + ); + assert_eq!(expand_braces("{1..5..0}").unwrap(), ["{1..5..0}"]); + } + + #[test] + fn over_limit_braces_are_counted_and_rejected_without_expanding() { + let start = Instant::now(); + for word in [ + "{a,b}".repeat(12), + "{a,b}".repeat(30), + "{1..100000000}".to_string(), + "{1..100000000}{1..100000000}".to_string(), + "{-9223372036854775808..9223372036854775807}".to_string(), + ] { + let error = expand_braces(&word).unwrap_err(); + assert!(error.to_string().contains("256-expansion limit"), "{word}"); + } + assert!( + start.elapsed().as_secs() < 5, + "over-limit braces must be counted before expanding" + ); + } + + #[test] + fn extreme_range_endpoints_do_not_panic() { + assert_eq!( + expand_braces("{9223372036854775806..9223372036854775807}").unwrap(), + ["9223372036854775806", "9223372036854775807"] + ); + assert_eq!( + expand_braces("{9223372036854775807..9223372036854775807}").unwrap(), + ["9223372036854775807"] + ); + } + + fn range(start: i64, end: i64, step: i64, width: usize, is_char: bool) -> RangeSpec { + RangeSpec { + start, + end, + step, + width, + is_char, + } + } + + #[test] + fn range_values_enumerate_order_endpoints_step_and_padding() { + // Ascending and descending, endpoints included. + assert_eq!(range(1, 3, 1, 0, false).values(), ["1", "2", "3"]); + assert_eq!(range(3, 1, 1, 0, false).values(), ["3", "2", "1"]); + // Step, ascending and descending, still ending on the endpoint. + assert_eq!(range(1, 10, 3, 0, false).values(), ["1", "4", "7", "10"]); + assert_eq!(range(10, 1, 3, 0, false).values(), ["10", "7", "4", "1"]); + // A single value. + assert_eq!(range(5, 5, 1, 0, false).values(), ["5"]); + // Zero-padding proves both the padded and the stripped form. + assert_eq!( + range(1, 3, 1, 2, false).values(), + ["01", "1", "02", "2", "03", "3"] + ); + // Character ranges, ascending and descending. + assert_eq!( + range(i64::from(b'a'), i64::from(b'e'), 1, 0, true).values(), + ["a", "b", "c", "d", "e"] + ); + assert_eq!( + range(i64::from(b'e'), i64::from(b'a'), 1, 0, true).values(), + ["e", "d", "c", "b", "a"] + ); + } + + #[test] + fn range_bounded_count_sizes_before_expanding() { + const LIMIT: usize = MAX_BRACE_EXPANSIONS; + assert_eq!(range(1, 3, 1, 0, false).bounded_count(LIMIT), Some(3)); + assert_eq!(range(3, 1, 1, 0, false).bounded_count(LIMIT), Some(3)); + assert_eq!(range(1, 10, 3, 0, false).bounded_count(LIMIT), Some(4)); + assert_eq!(range(10, 1, 3, 0, false).bounded_count(LIMIT), Some(4)); + assert_eq!(range(5, 5, 1, 0, false).bounded_count(LIMIT), Some(1)); + assert_eq!( + range(i64::from(b'a'), i64::from(b'e'), 1, 0, true).bounded_count(LIMIT), + Some(5) + ); + // A width no step divides exactly still counts whole values. + assert_eq!(range(1, 8, 3, 0, false).bounded_count(LIMIT), Some(3)); + // Zero-padding proves both forms, so the count doubles. + assert_eq!(range(1, 3, 1, 2, false).bounded_count(LIMIT), Some(6)); + // A zero step is invalid. + assert_eq!(range(1, 3, 0, 0, false).bounded_count(LIMIT), None); + // The cap sits just above the limit: exactly the limit passes through, + // limit + 1 and beyond collapse to limit + 1. + assert_eq!(range(1, 4, 1, 0, false).bounded_count(4), Some(4)); + assert_eq!(range(1, 5, 1, 0, false).bounded_count(4), Some(5)); + assert_eq!(range(1, 6, 1, 0, false).bounded_count(4), Some(5)); + assert_eq!( + range(1, MAX_BRACE_EXPANSIONS as i64, 1, 0, false).bounded_count(LIMIT), + Some(MAX_BRACE_EXPANSIONS) + ); + } + + #[cfg(unix)] + fn fixture() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("ws"); + std::fs::create_dir_all(root.join("sub")).unwrap(); + std::fs::write(root.join("sub/f"), "x").unwrap(); + std::fs::create_dir_all(dir.path().join("outside")).unwrap(); + std::fs::write(dir.path().join("outside/secret"), "x").unwrap(); + std::os::unix::fs::symlink(dir.path().join("outside"), root.join("linked-outside")) + .unwrap(); + std::os::unix::fs::symlink(dir.path().join("outside"), root.join(".hidden-out")).unwrap(); + dir + } + + #[cfg(unix)] + #[test] + fn glob_expansion_matches_inside_and_refuses_escapes() { + let dir = fixture(); + let root = dir.path().join("ws"); + + assert!(expand_glob(&root, "lin*/secret").is_err()); + assert!(expand_glob(&root, "lin*/f").is_err()); + assert!(expand_glob(&root, ".*").is_err()); + assert!(expand_glob(&root, "[[:alpha:]]inked-outside").is_err()); + + assert_eq!(expand_glob(&root, "nomatch*/f").unwrap(), None); + + let matched = expand_glob(&root, "sub/*").unwrap().expect("sub/f match"); + assert_eq!(matched.len(), 1, "{matched:?}"); + assert!(matched[0].ends_with("sub/f"), "{matched:?}"); + } +} diff --git a/tests/issue567_bash_glob_write_targets.rs b/tests/issue567_bash_glob_write_targets.rs new file mode 100644 index 00000000..f90f48a8 --- /dev/null +++ b/tests/issue567_bash_glob_write_targets.rs @@ -0,0 +1,393 @@ +#![cfg(unix)] + +//! Issue #567: Bash write targets that use glob (`* ? [`) or brace (`{a,b}`, +//! `{a..z}`) syntax must be judged after expanding the word the way the shell +//! would, so that an intermediate symlink or a brace-produced `..` cannot carry +//! the write outside the workspace root. + +use std::path::PathBuf; +use std::time::Instant; + +use commandagent::mode::ExecutionMode; +use commandagent::tools::bash::path_confinement_rejection; +use commandagent::tools::registry::tool_error_kind; +use commandagent::tools::registry::{ToolContext, ToolRegistry}; +use commandagent::tools::workspace_policy::WorkspacePolicy; +use serde_json::json; + +struct Fixture { + _dir: tempfile::TempDir, + root: PathBuf, +} + +/// A workspace that contains an escaping symlink (visible and hidden), a +/// symlink loop, and an outside directory holding an existing `secret`. +fn escaping_fixture() -> Fixture { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("ws"); + std::fs::create_dir_all(root.join("sub")).unwrap(); + let outside = dir.path().join("outside"); + std::fs::create_dir_all(&outside).unwrap(); + std::fs::write(outside.join("secret"), "outside-secret").unwrap(); + std::os::unix::fs::symlink(&outside, root.join("linked-outside")).unwrap(); + std::os::unix::fs::symlink(&outside, root.join(".hidden-out")).unwrap(); + std::os::unix::fs::symlink(root.join("sub"), root.join("sub/loop")).unwrap(); + // Literal-brace and POSIX-class spellings the shell and globset disagree on. + std::os::unix::fs::symlink(&outside, root.join("{x}out")).unwrap(); + std::os::unix::fs::symlink(&outside, root.join("sub/esc")).unwrap(); + std::os::unix::fs::symlink(&outside, root.join("01")).unwrap(); + std::os::unix::fs::symlink(&outside, root.join("2")).unwrap(); + std::os::unix::fs::symlink(&outside, root.join("3")).unwrap(); + let root = root.canonicalize().unwrap(); + Fixture { _dir: dir, root } +} + +/// A workspace whose root contains only inside directories, so `*` matches +/// only real workspace entries. +fn inside_fixture() -> Fixture { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("ws"); + std::fs::create_dir_all(root.join("sub")).unwrap(); + std::fs::create_dir_all(root.join("src/[id]")).unwrap(); + std::fs::write(root.join("sub/f"), "x").unwrap(); + std::fs::write(root.join("a.txt"), "x").unwrap(); + std::os::unix::fs::symlink(root.join("sub"), root.join("sub/loop")).unwrap(); + let root = root.canonicalize().unwrap(); + Fixture { _dir: dir, root } +} + +#[test] +fn rejects_glob_and_brace_write_targets_that_escape() { + let fixture = escaping_fixture(); + let root = &fixture.root; + let root_absolute = root.display(); + let cases = [ + "tee lin*/f".to_string(), + "tee linked-outsid?/f".to_string(), + "tee linked-outsid[e]/f".to_string(), + "tee lin*/secret".to_string(), + "cp a.txt lin*/".to_string(), + "chmod 777 lin*/secret".to_string(), + "tee .hid*/f".to_string(), + "tee */secret".to_string(), + "tee **/secret".to_string(), + format!("tee {root_absolute}/lin*/f"), + "tee .*/f".to_string(), + "tee .?/f".to_string(), + "tee {.,}./f".to_string(), + "tee {.,.}./f".to_string(), + "tee {linked-outside,x}/f".to_string(), + "touch {linked-outside,x}/f".to_string(), + "tee {k..m}inked-outside/f".to_string(), + "tee {linked-outside/f,x}".to_string(), + "printf x > {linked-outside,x}/f".to_string(), + ]; + for command in cases { + assert!( + path_confinement_rejection(&command, root).is_some(), + "expected rejection: {command}" + ); + } +} + +#[test] +fn blocks_brace_and_absolute_glob_reads_that_escape() { + let fixture = escaping_fixture(); + let root = &fixture.root; + let cases = [ + "cat {linked-outside,x}/secret".to_string(), + format!("cat {}/lin*/secret", root.display()), + "cat {.,}./secret".to_string(), + ]; + for command in cases { + assert!( + path_confinement_rejection(&command, root).is_some(), + "expected rejection: {command}" + ); + } +} + +#[test] +fn rejects_targets_over_the_expansion_limits() { + let fixture = inside_fixture(); + let root = &fixture.root; + + // 2^12 brace expansions exceed the 256-expansion limit. + let brace_word = "{a,b}".repeat(12); + assert!( + path_confinement_rejection(&format!("tee {brace_word}"), root).is_some(), + "brace expansion over the limit must be rejected" + ); + + // A directory with more than 4096 matching names exceeds the glob limit. + let crowded = root.join("crowded"); + std::fs::create_dir_all(&crowded).unwrap(); + for index in 0..=4096 { + std::fs::write(crowded.join(format!("n{index}")), "x").unwrap(); + } + assert!( + path_confinement_rejection("tee crowded/*", root).is_some(), + "glob match count over the limit must be rejected" + ); +} + +#[test] +fn glob_expansion_terminates_on_a_symlink_loop() { + let fixture = inside_fixture(); + assert!( + path_confinement_rejection("tee sub/loop/loop/*/f", &fixture.root).is_none(), + "a per-component glob must terminate on a symlink loop" + ); +} + +#[test] +fn keeps_normal_workspace_write_targets() { + let fixture = inside_fixture(); + let root = &fixture.root; + let cases = [ + "tee out.txt", + "tee src/new.rs", + "tee sub/f", + "tee nomatch*/f", + "tee */f", + r#"tee "src/app/[id]/page.tsx""#, + "mkdir -p 'src/app/[id]'", + "printf x > 'src/[id]/route.ts'", + "printf x > \"src/{a,b}.txt\"", + "printf x > /dev/null", + // A bracket without `{ } , \`, and braces without a bracket, stay usable. + "tee src/*.rs", + r#"cp a.txt "src/[id]/x""#, + "printf x > 'src/[id]/page.tsx'", + ]; + for command in cases { + assert!( + path_confinement_rejection(command, root).is_none(), + "expected allow: {command}" + ); + } +} + +#[test] +fn keeps_normal_workspace_reads() { + let fixture = inside_fixture(); + let root = &fixture.root; + let cases = [ + r#"cat "src/[id]/route.ts""#.to_string(), + "cat src/{main,lib}.rs".to_string(), + "cat *.txt".to_string(), + "ls src/*.rs".to_string(), + "rg foo src/**/*.rs".to_string(), + format!("ls {}/src/*.rs", root.display()), + ]; + for command in cases { + assert!( + path_confinement_rejection(&command, root).is_none(), + "expected allow: {command}" + ); + } +} + +#[test] +fn escaping_glob_and_brace_targets_are_rejected_before_execution() { + let fixture = escaping_fixture(); + let events = fixture._dir.path().join("events.jsonl"); + let context = ToolContext { + root: fixture.root.clone(), + mode: ExecutionMode::Act, + auto_approve: true, + interactive_approval: false, + offline: true, + workspace_policy: WorkspacePolicy::NormalTask, + eval_events_path: Some(events.clone()), + expected_paths: Vec::new(), + protected_paths: Vec::new(), + }; + let registry = ToolRegistry::default(); + for command in ["tee lin*/secret", "printf x > {linked-outside,x}/f"] { + let error = registry + .execute("Bash", &json!({ "command": command }), &context) + .unwrap_err(); + assert_eq!( + tool_error_kind(&error), + "bash_path_confinement_error", + "{command}" + ); + } + assert!( + !fixture.root.join("linked-outside").join("secret").exists() + || std::fs::read_to_string(fixture.root.join("linked-outside/secret")).unwrap() + == "outside-secret", + "the escaping target must not have been overwritten" + ); +} + +#[test] +fn rejects_posix_bracket_expression_targets() { + let fixture = escaping_fixture(); + let root = &fixture.root; + for command in [ + "cp a.txt [[:alpha:]]inked-outside/", + "tee [[:lower:]]*/secret", + "tee [[=x=]]inked-outside/secret", + "tee [[.ch.]]inked-outside/secret", + ] { + assert!( + path_confinement_rejection(command, root).is_some(), + "expected rejection: {command}" + ); + } +} + +#[test] +fn rejects_literal_brace_glob_through_symlink() { + // `{x}` is a literal in the shell but an alternation in globset, so the + // guard must escape it to keep matching the same names. + let fixture = escaping_fixture(); + assert!( + path_confinement_rejection("tee {x}*/secret", &fixture.root).is_some(), + "a literal-brace glob must still reach the escaping symlink" + ); +} + +#[test] +fn rejects_glob_with_escaping_symlink_component() { + let fixture = escaping_fixture(); + assert!( + path_confinement_rejection("tee s*/esc/secret", &fixture.root).is_some(), + "a glob component that expands to an escaping symlink must be rejected" + ); +} + +#[test] +fn rejects_bracket_mixed_with_brace_comma_or_backslash() { + // Rewriting `{ } , \` into class literals would nest the brackets and let + // globset read a different set, silently matching nothing and falling back + // to the literal spelling. These words are refused instead. + let fixture = escaping_fixture(); + let root = &fixture.root; + for command in [ + "cp a.txt [,l]inked-outside/", + "tee [{l]inked-outside/secret", + "tee [!,]inked-outside/secret", + "tee [l}]inked-outside/secret", + "tee [^{]inked-outside/secret", + "tee [!{]*/secret", + "tee [k-m,]inked-outside/secret", + "rm -rf [,l]inked-outside/secret", + "chmod 777 [,l]inked-outside/secret", + ] { + assert!( + path_confinement_rejection(command, root).is_some(), + "expected rejection: {command}" + ); + } +} + +#[test] +fn rejects_nested_brace_escape() { + let fixture = escaping_fixture(); + for command in [ + "tee {{linked-outside,q},y}/secret", + "tee {x,{y,linked-outside}}/secret", + ] { + assert!( + path_confinement_rejection(command, &fixture.root).is_some(), + "expected rejection: {command}" + ); + } +} + +#[test] +fn rejects_zero_padded_range_escape() { + // A range is checked both zero-padded (`01`, bash 4+) and stripped (`1`), + // including when only one endpoint carries the leading zero. + let fixture = escaping_fixture(); + for command in [ + "tee {01..02}/secret", + "tee {01..2}/secret", + "tee {1..02}/secret", + ] { + assert!( + path_confinement_rejection(command, &fixture.root).is_some(), + "a zero-padded range must still reach the escaping symlink: {command}" + ); + } +} + +#[test] +fn rejects_numeric_range_escapes() { + // Each word reaches outside only through the range expansion: `{k..m}` to + // `linked-outside`, `{1..3}` to the `2` symlink, `{1..5..2}` to the `3` + // symlink. Were a range expanded to nothing these words would be allowed, + // so this pins the expansion itself (`{k..m}` is also in the table above). + let fixture = escaping_fixture(); + let root = &fixture.root; + for command in [ + "tee {k..m}inked-outside/f", + "tee {1..3}/secret", + "tee {1..5..2}/secret", + ] { + assert!( + path_confinement_rejection(command, root).is_some(), + "a numeric range must expand before the proof: {command}" + ); + } +} + +#[test] +fn allows_exactly_the_expansion_limits() { + let fixture = inside_fixture(); + let root = &fixture.root; + + let crowded = root.join("crowded"); + std::fs::create_dir_all(&crowded).unwrap(); + for index in 0..4096 { + std::fs::write(crowded.join(format!("n{index}")), "x").unwrap(); + } + assert!( + path_confinement_rejection("tee crowded/*", root).is_none(), + "exactly the glob match limit must be allowed" + ); + std::fs::write(crowded.join("overflow"), "x").unwrap(); + assert!( + path_confinement_rejection("tee crowded/*", root).is_some(), + "one match over the glob limit must be rejected" + ); + + let brace_word = "{a,b}".repeat(8); + assert!( + path_confinement_rejection(&format!("tee {brace_word}"), root).is_none(), + "exactly the brace expansion limit must be allowed" + ); +} + +#[test] +fn over_limit_brace_targets_fail_fast_without_panicking() { + let fixture = inside_fixture(); + let root = &fixture.root; + let start = Instant::now(); + + for command in [ + "tee {1..100000000}/f".to_string(), + format!("tee {}", "{a,b}".repeat(30)), + ] { + assert!( + path_confinement_rejection(&command, root).is_some(), + "expected rejection: {command}" + ); + } + + // A two-value range at the i64 boundary is decidable quickly and must not + // panic; an overflow-sized span is rejected, not panicked. + let _ = path_confinement_rejection("tee {9223372036854775806..9223372036854775807}/f", root); + assert!( + path_confinement_rejection("tee {-9223372036854775808..9223372036854775807}/f", root) + .is_some(), + "an overflowing range must be rejected" + ); + assert!( + start.elapsed().as_secs() < 10, + "over-limit brace targets must fail fast" + ); +}