diff --git a/crates/bashkit/docs/compatibility.md b/crates/bashkit/docs/compatibility.md index 626ef5bc5..c59f957cb 100644 --- a/crates/bashkit/docs/compatibility.md +++ b/crates/bashkit/docs/compatibility.md @@ -70,7 +70,7 @@ for sandbox security reasons. See the compliance spec for details. | `eval` | `command...` | POSIX construct and execute command | | `readonly` | `VAR[=value]`, `-p` | POSIX mark variable read-only | | `times` | - | POSIX display process times | -| `grep` | `-G/-E/-F/-P`, `-e`, `-f`, `-i/-y`, `--no-ignore-case`, `-v`, `-w`, `-x`, `-c`, `-l`, `-L`, `-o`, `-q`, `-s`, `-m`, `-n`, `-b`, `-H/-h`, `--label`, `-T`, `-Z`, `-z`, `-a`, `-I`, `--binary-files`, `-A/-B/-C/-NUM`, `--group-separator`, `--no-group-separator`, `-r/-R`, `-d`, `-D`, `--include/--exclude/--exclude-from/--exclude-dir`; long-option prefixes | GNU grep 3.11 output; BRE/ERE leftmost-longest, back-references | +| `grep` | `-G/-E/-F/-P`, `-e`, `-f`, `-i/-y`, `--no-ignore-case`, `-v`, `-w`, `-x`, `-c`, `-l`, `-L`, `-o`, `-q`, `-s`, `-m`, `-n`, `-b`, `-H/-h`, `--label`, `-T`, `-Z`, `-z`, `-a`, `-I`, `--binary-files`, `-A/-B/-C/-NUM`, `--group-separator`, `--no-group-separator`, `-r/-R`, `-d`, `-D`, `--include/--exclude/--exclude-from/--exclude-dir`; long-option prefixes | GNU grep 3.11 output; BRE/ERE leftmost-longest, back-references; recursive ancestor loops skipped with a warning, files scanned under shared resource budgets | | `rg` | `-i/-S/-s`, `-n/-N`, `--column`, `-b/--byte-offset`, `--vimgrep`, basic `--json`, `--stats`, `--null`, `-c`, `--count-matches`, `--include-zero`, `-l`, `--files-with-matches`, `--files-without-match`, `--files`, `-F`, `-w`, `-x`, `-a/--text`, `--binary`, `-m`, `-M/--max-columns`, `--max-columns-preview`, `--max-depth`, `-A`, `-B`, `-C`, separator flags, `--heading/--no-heading`, `--sort/--sortr`, `--path-separator`, `-g/--glob`, `--ignore-file`, `.ignore`/`.gitignore`, `--no-ignore*`, `--require-git/--no-require-git`, `-u/--unrestricted`, `--messages/--no-messages`, `-t/--type`, `-T/--type-not`, `--type-add`, `--type-clear`, `--type-list`, `--hidden/--no-hidden`, `-o`, `-q`, `-e`, `-f`, `-E/--encoding` (`auto`, `none`, UTF-8, UTF-16LE/BE), `-r`, `--passthru`, `--trim`, `-H/-I`, `--no-config`, `--line-buffered`, `--block-buffered`, `--mmap/--no-mmap`, `-P/--pcre2`, `--no-pcre2`, `--engine`, `--auto-hybrid-regex`, `--no-auto-hybrid-regex`, `--help`, `--version` | Recursive ripgrep-style search | | `sed` | `s///[gpiImMN w]`, `y`, `d`, `D`, `p`, `P`, `n`, `N`, `q[N]`, `Q[N]`, `=`, `l[N]`, `z`, `F`, `a`, `i`, `c`, `r`, `R`, `w`, `W`, `h/H/g/G/x`, `b`/`t`/`T`/`:label`, `{}`, `#`/`#n`; addresses `N`, `$`, `/re/[IM]`, `\cREc`, `first~step`, `a,b`, `a,+N`, `a,~N`, `0,/re/`, `!`; options `-n -e -f -i[SUF] -E/-r -s -z -l N -u --` and long forms | Stream editing; one stream across operands unless `-s`/`-i` | | `awk` (also `gawk`, `mawk`, `nawk`) | `'{print}'`, `-F`, `-v`, loops, arrays, increment, ternary | Text processing | diff --git a/crates/bashkit/src/builtins/find/mod.rs b/crates/bashkit/src/builtins/find/mod.rs index 19220b999..f726f1c0b 100644 --- a/crates/bashkit/src/builtins/find/mod.rs +++ b/crates/bashkit/src/builtins/find/mod.rs @@ -47,8 +47,6 @@ use crate::interpreter::ExecResult; const HELP: &str = "Usage: find [-H] [-L] [-P] [PATH...] [EXPRESSION]\nSearch for files in a directory hierarchy.\n\nOperators: ( EXPR ), ! EXPR, -not EXPR, EXPR -a EXPR, EXPR -o EXPR, EXPR , EXPR\nOptions: -maxdepth N -mindepth N -depth -follow -daystart -regextype TYPE -xdev\nTests: -name -iname -path -ipath -lname -regex -iregex -type -xtype -size -empty\n -mtime -mmin -atime -amin -ctime -cmin -newer -newerXY -perm -readable\n -writable -executable -user -group -uid -gid -nouser -nogroup -true -false\nActions: -print -print0 -printf FMT -delete -prune -quit\n -exec CMD {} ; -exec CMD {} + -execdir CMD {} ; -execdir CMD {} +\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n"; -/// Symlink hops before ELOOP, matching Linux. -const MAX_SYMLINK_HOPS: usize = 40; /// Paths passed per `-exec ... {} +` invocation before a new one starts. const EXEC_BATCH_MAX: usize = 4096; const OUTPUT_CAP_MSG: &str = "find: output size limit exceeded\n"; @@ -175,53 +173,6 @@ fn type_matches(meta: &Metadata, types: &[char]) -> bool { types.contains(&c) } -/// Resolve every symlink in `path` (realpath), with an ELOOP hop cap. -async fn canonicalize(fs: &dyn FileSystem, path: &Path) -> std::result::Result { - let mut rest: VecDeque = path - .components() - .filter_map(|c| match c { - std::path::Component::Normal(s) => Some(s.to_string_lossy().into_owned()), - std::path::Component::ParentDir => Some("..".to_string()), - _ => None, - }) - .collect(); - let mut out = PathBuf::from("/"); - let mut hops = 0; - while let Some(comp) = rest.pop_front() { - if comp == ".." { - out.pop(); - continue; - } - let candidate = out.join(&comp); - match fs.lstat(&candidate).await { - Ok(m) if m.file_type.is_symlink() => { - hops += 1; - if hops > MAX_SYMLINK_HOPS { - return Err("Too many levels of symbolic links".to_string()); - } - let target = fs.read_link(&candidate).await.map_err(|e| e.to_string())?; - if target.is_absolute() { - out = PathBuf::from("/"); - } - let mut parts: Vec = target - .components() - .filter_map(|c| match c { - std::path::Component::Normal(s) => Some(s.to_string_lossy().into_owned()), - std::path::Component::ParentDir => Some("..".to_string()), - _ => None, - }) - .collect(); - while let Some(p) = parts.pop() { - rest.push_front(p); - } - } - Ok(_) => out = candidate, - Err(e) => return Err(e.to_string()), - } - } - Ok(out) -} - /// A directory on the current path, for `-L` loop detection. struct Ancestor { canon: PathBuf, @@ -841,7 +792,7 @@ impl FindRun { .await .ok() .map(|t| t.to_string_lossy().into_owned()); - match canonicalize(fs.as_ref(), &abs).await { + match crate::fs::canonicalize(fs.as_ref(), &abs).await { Ok(canon) => { let m = fs.stat(&canon).await.ok(); (target, m, canon) diff --git a/crates/bashkit/src/builtins/grep.rs b/crates/bashkit/src/builtins/grep.rs index 3a88484c3..6abe9fcbb 100644 --- a/crates/bashkit/src/builtins/grep.rs +++ b/crates/bashkit/src/builtins/grep.rs @@ -29,6 +29,8 @@ //! `-d skip` ignores them, `-d recurse`/`-r` recurse (`-R` also follows //! symlinks met during traversal). Traversal is in name order; GNU uses //! readdir order, which bashkit cannot reproduce (L-GREP-003). +//! - Recursive walks compare resolved ancestors, meter every entry, and scan +//! one admitted file at a time so quiet matches stop before further traversal. //! - A pipeline stage that prints matching lines streams them (see //! L-PIPE-001); every path shares the same per-line engine (`FileScan`). @@ -1077,73 +1079,68 @@ impl Builtin for Grep { used: false, }; - 'operands: for operand in &operands { - let mut sources: Vec = Vec::new(); - let mut is_dir_walk = false; - if operand == "-" { - sources.push(Source::Stdin(stdin_name.clone())); - } else { - let path = resolve(ctx.cwd, operand); - let is_dir = matches!(ctx.fs.stat(&path).await, Ok(m) if m.file_type.is_dir()); - if is_dir { - match opts.directories { - Directories::Skip => continue, - Directories::Read => { - read_failed = true; - if !opts.suppress_errors { - errors.push_str(&format!("grep: {operand}: Is a directory\n")); - } - continue; + for operand in &operands { + let path = resolve(ctx.cwd, operand); + let is_dir = + operand != "-" && matches!(ctx.fs.stat(&path).await, Ok(m) if m.file_type.is_dir()); + sh.with_filename = opts.with_filename.unwrap_or(multiple || is_dir); + let (n, gone) = if operand == "-" { + scan_input( + &ctx, + &mut sh, + Source::Stdin(stdin_name.clone()), + &mut out, + &mut errors, + stream.as_ref(), + ) + .await? + } else if is_dir { + match opts.directories { + Directories::Skip => continue, + Directories::Read => { + read_failed = true; + if !opts.suppress_errors { + errors.push_str(&format!("grep: {operand}: Is a directory\n")); } - Directories::Recurse => { - is_dir_walk = true; - let display = if implicit_root { "" } else { operand.as_str() }; - collect_recursive( + continue; + } + Directories::Recurse => { + let display = if implicit_root { "" } else { operand.as_str() }; + ( + scan_recursive( &ctx, - &opts, - &matcher, + &mut sh, display, &path, - &mut sources, + &mut out, &mut errors, &mut read_failed, ) - .await; - } - } - } else { - match ctx.fs.read_file(&path).await { - Ok(bytes) => sources.push(Source::File(operand.clone(), bytes)), - Err(e) => { - read_failed = true; - if !opts.suppress_errors { - let reason = crate::error::io_error_reason(&e); - errors.push_str(&format!("grep: {operand}: {reason}\n")); - } - continue; - } + .await?, + false, + ) } } - } - sh.with_filename = opts.with_filename.unwrap_or(multiple || is_dir_walk); - for source in sources { - let (n, gone) = scan_input( + } else { + scan_file( &ctx, &mut sh, - source, + operand, + &path, &mut out, &mut errors, + &mut read_failed, stream.as_ref(), ) - .await?; - if gone { - return Ok(ExecResult::err(errors, 141)); - } - if n > 0 { - any_match = true; - if opts.quiet { - break 'operands; - } + .await? + }; + if gone { + return Ok(ExecResult::err(errors, 141)); + } + if n > 0 { + any_match = true; + if opts.quiet { + break; } } } @@ -1199,55 +1196,207 @@ async fn read_operand_file(ctx: &Context<'_>, name: &str) -> std::result::Result .map_err(|e| crate::error::io_error_reason(&e)) } -/// Collect the files under a recursive operand, in name order (files of a -/// directory first, then its subdirectories depth-first). +/// A resolved directory ancestor. Sibling aliases remain independent searches. +struct GrepAncestor { + path: std::path::PathBuf, + parent: Option>, + _lease: Option, +} + +struct GrepDirectory { + path: std::path::PathBuf, + ancestors: Option>, + _lease: Option, +} + +impl GrepDirectory { + fn new( + ctx: &Context<'_>, + path: &std::path::Path, + ancestors: Option>, + ) -> Result { + let lease = ctx.lease_budget_bytes(path.as_os_str().len())?; + Ok(Self { + path: path.to_path_buf(), + ancestors, + _lease: lease, + }) + } +} + +fn read_error( + opts: &GrepOptions, + name: &str, + error: &crate::Error, + errors: &mut String, + failed: &mut bool, +) { + *failed = true; + if !opts.suppress_errors { + let reason = crate::error::io_error_reason(error); + errors.push_str(&super::cap_diagnostic( + format!("grep: {name}: {reason}\n"), + 1024, + )); + } +} + +fn budgeted_vec(ctx: &Context<'_>) -> Result> { + let budget = ctx + .execution_budget() + .map(|b| b.try_with(Clone::clone)) + .transpose() + .map_err(|_| crate::Error::Cancelled)?; + Ok(crate::limits::BudgetedVec::new(budget.as_ref())?) +} + +/// Admit the file before the VFS produces an owned copy, retaining the lease +/// through scanning. Traversal never accumulates file-content buffers. #[allow(clippy::too_many_arguments)] -async fn collect_recursive( +async fn scan_file( ctx: &Context<'_>, - opts: &GrepOptions, - matcher: &PatternMatcher, + sh: &mut Shared<'_>, + name: &str, + path: &std::path::Path, + out: &mut Vec, + errors: &mut String, + read_failed: &mut bool, + stream: Option<&super::StdoutStream>, +) -> Result<(usize, bool)> { + ctx.consume_budget_work(1)?; + let meta = match ctx.fs.stat(path).await { + Ok(meta) => meta, + Err(e) => { + read_error(sh.opts, name, &e, errors, read_failed); + return Ok((0, false)); + } + }; + let size = usize::try_from(meta.size).unwrap_or(usize::MAX); + ctx.consume_budget_input(size)?; + let _lease = ctx.lease_budget_bytes(size)?; + let bytes = match ctx.fs.read_file(path).await { + Ok(bytes) => bytes, + Err(e) => { + read_error(sh.opts, name, &e, errors, read_failed); + return Ok((0, false)); + } + }; + // A host-backed file may grow between metadata and read. Account for it + // before scanning; the backend owns admission of its read allocation. + ctx.consume_budget_input(bytes.len().saturating_sub(size))?; + let _extra = ctx.lease_budget_bytes(bytes.capacity().saturating_sub(size))?; + scan_input( + ctx, + sh, + Source::File(name.to_string(), bytes), + out, + errors, + stream, + ) + .await +} + +/// Files first, then subdirectories depth-first, preserving name order. +#[allow(clippy::too_many_arguments)] +async fn scan_recursive( + ctx: &Context<'_>, + sh: &mut Shared<'_>, operand: &str, root: &std::path::Path, - sources: &mut Vec, + out: &mut Vec, errors: &mut String, read_failed: &mut bool, -) { +) -> Result { + let opts = sh.opts; let root = crate::fs::normalize_path(root); - // Indexed search via SearchCapable when the backend offers one. Skip it - // for -P and back-references: the backend's engine cannot express them. - if !matches!(matcher, PatternMatcher::Fancy(_)) - && let Some(found) = try_indexed_search(&*ctx.fs, opts, &root, operand).await + let mut selected = 0usize; + if !matches!(sh.matcher, PatternMatcher::Fancy(_)) + && let Some(found) = try_indexed_search(ctx, opts, &root).await? { - sources.extend(found); - return; + for path in found.paths.iter() { + let name = recursive_display(operand, &root, path); + let (n, _) = scan_file(ctx, sh, &name, path, out, errors, read_failed, None).await?; + selected = selected.saturating_add(n); + if n > 0 && opts.quiet { + break; + } + } + return Ok(selected); } - let mut stack: Vec = vec![root.clone()]; + let mut stack = budgeted_vec(ctx)?; + stack.try_push(GrepDirectory::new(ctx, &root, None)?)?; + // THREAT[TM-DOS-011,TM-DOS-096]: A hop cap bounds one lookup, not a + // branching walk. Reject ancestor cycles and meter synchronous VFS work. while let Some(dir) = stack.pop() { - let mut entries = match ctx.fs.read_dir(&dir).await { - Ok(e) => e, + ctx.consume_budget_work(1)?; + let canonical = match crate::fs::canonicalize(ctx.fs.as_ref(), &dir.path).await { + Ok(path) => path, Err(e) => { - *read_failed = true; - if !opts.suppress_errors { - let shown = recursive_display(operand, &root, &dir); - let reason = crate::error::io_error_reason(&e); - errors.push_str(&format!("grep: {shown}: {reason}\n")); - } + let name = recursive_display(operand, &root, &dir.path); + read_error(opts, &name, &e, errors, read_failed); continue; } }; - // Name order keeps output stable across VFS backends. + let mut ancestor = dir.ancestors.as_deref(); + let mut cycle = false; + while let Some(a) = ancestor { + ctx.consume_budget_work(1)?; + if a.path == canonical { + cycle = true; + break; + } + ancestor = a.parent.as_deref(); + } + if cycle { + if !opts.suppress_errors { + let name = recursive_display(operand, &root, &dir.path); + errors.push_str(&super::cap_diagnostic( + format!("grep: {name}: warning: recursive directory loop\n"), + 1024, + )); + } + continue; + } + let ancestor_lease = ctx.lease_budget_bytes( + canonical + .as_os_str() + .len() + .saturating_add(std::mem::size_of::()), + )?; + let ancestors = std::sync::Arc::new(GrepAncestor { + path: canonical, + parent: dir.ancestors, + _lease: ancestor_lease, + }); + let mut entries = match ctx.fs.read_dir(&dir.path).await { + Ok(entries) => entries, + Err(e) => { + let name = recursive_display(operand, &root, &dir.path); + read_error(opts, &name, &e, errors, read_failed); + continue; + } + }; + ctx.consume_budget_work(entries.len() as u64)?; + let _listing = ctx.lease_budget_bytes( + entries.iter().fold( + entries + .capacity() + .saturating_mul(std::mem::size_of::()), + |bytes, entry| bytes.saturating_add(entry.name.capacity()), + ), + )?; entries.sort_by(|a, b| a.name.cmp(&b.name)); - let mut subdirs = Vec::new(); + let mut subdirs = budgeted_vec(ctx)?; for entry in entries { - let entry_path = vfs_join(&dir, &entry.name); + ctx.consume_budget_work(1)?; + let entry_path = vfs_join(&dir.path, &entry.name); let mut ft = entry.metadata.file_type; if ft.is_symlink() { - // -r skips symlinks met while recursing; -R follows them. if !opts.dereference { continue; } match ctx.fs.stat(&entry_path).await { - Ok(m) => ft = m.file_type, + Ok(meta) => ft = meta.file_type, Err(_) => continue, } } @@ -1259,28 +1408,29 @@ async fn collect_recursive( { continue; } - subdirs.push(entry_path); + subdirs.try_push(GrepDirectory::new( + ctx, + &entry_path, + Some(ancestors.clone()), + )?)?; } else if ft.is_file() && should_include_file(&entry.name, &opts.include_patterns, &opts.exclude_patterns) { - match ctx.fs.read_file(&entry_path).await { - Ok(bytes) => sources.push(Source::File( - recursive_display(operand, &root, &entry_path), - bytes, - )), - Err(e) => { - *read_failed = true; - if !opts.suppress_errors { - let shown = recursive_display(operand, &root, &entry_path); - let reason = crate::error::io_error_reason(&e); - errors.push_str(&format!("grep: {shown}: {reason}\n")); - } - } + let name = recursive_display(operand, &root, &entry_path); + let (n, _) = + scan_file(ctx, sh, &name, &entry_path, out, errors, read_failed, None).await?; + selected = selected.saturating_add(n); + if n > 0 && opts.quiet { + return Ok(selected); } } } - stack.extend(subdirs.into_iter().rev()); + let (subdirs, _subdir_storage) = subdirs.into_parts(); + for subdir in subdirs.into_iter().rev() { + stack.try_push(subdir)?; + } } + Ok(selected) } /// Name a file found under a recursive-grep operand the way GNU grep does: @@ -1307,16 +1457,21 @@ fn recursive_display(operand: &str, root: &std::path::Path, path: &std::path::Pa format!("{base}/{rel}") } +struct IndexedInputs { + paths: crate::limits::BudgetedVec, + _leases: crate::limits::BudgetedVec>, +} + /// Try to use an indexed search provider for recursive grep. /// /// Returns `Some(inputs)` if a `SearchCapable` provider handled the search, /// `None` to fall back to linear scan. async fn try_indexed_search( - fs: &dyn crate::fs::FileSystem, + ctx: &Context<'_>, opts: &GrepOptions, root: &std::path::Path, - operand: &str, -) -> Option> { +) -> Result> { + let fs = ctx.fs.as_ref(); if opts.invert_match || opts.files_without_match || opts.count_only @@ -1324,21 +1479,25 @@ async fn try_indexed_search( || !opts.pattern_files.is_empty() || opts.patterns[0].contains('\n') { - return None; + return Ok(None); } - let sc = fs.as_search_capable()?; - let provider = sc.search_provider(root)?; + let Some(sc) = fs.as_search_capable() else { + return Ok(None); + }; + let Some(provider) = sc.search_provider(root) else { + return Ok(None); + }; let caps = provider.capabilities(); if !caps.content_search { - return None; + return Ok(None); } let pattern = if opts.syntax == Syntax::Fixed { opts.patterns[0].clone() } else { if opts.syntax == Syntax::Perl || !caps.regex { - return None; + return Ok(None); } let dialect = if opts.syntax == Syntax::Extended { super::grep_pattern::Dialect::Extended @@ -1347,9 +1506,12 @@ async fn try_indexed_search( }; let t = super::grep_pattern::translate(&opts.patterns[0], dialect, opts.ignore_case, false, 0) - .ok()?; + .ok(); + let Some(t) = t else { + return Ok(None); + }; if t.backrefs { - return None; + return Ok(None); } let pattern = if opts.word_regex { format!(r"\b{{start-half}}(?:{})\b{{end-half}}", t.rust) @@ -1376,19 +1538,36 @@ async fn try_indexed_search( max_results: opts.max_count, }; - let results = provider.search(&query).ok()?; + ctx.consume_budget_work(1)?; + let Ok(results) = provider.search(&query) else { + return Ok(None); + }; let mut seen_paths = std::collections::HashSet::new(); - let mut inputs = Vec::new(); + let mut inputs = budgeted_vec(ctx)?; + let mut path_leases = budgeted_vec(ctx)?; for m in &results.matches { + ctx.consume_budget_work(1)?; let candidate = if m.path.is_absolute() { crate::fs::normalize_path(&m.path) } else { crate::fs::normalize_path(&vfs_join(root, &m.path)) }; - if !candidate.starts_with(root) || !seen_paths.insert(candidate.clone()) { + if !candidate.starts_with(root) || seen_paths.contains(&candidate) { continue; } + // Both the deduplication set and the selected paths retain a copy. + // Include hash-table slack before cloning into the set. + path_leases.try_push( + ctx.lease_budget_bytes( + candidate + .as_os_str() + .len() + .saturating_mul(2) + .saturating_add(4 * std::mem::size_of::()), + )?, + )?; + seen_paths.insert(candidate.clone()); let Some(name) = candidate.file_name().and_then(|n| n.to_str()) else { continue; @@ -1399,20 +1578,17 @@ async fn try_indexed_search( continue; } - if let Ok(content) = fs.read_file(&candidate).await { - inputs.push(Source::File( - recursive_display(operand, root, &candidate), - content, - )); - } + inputs.try_push(candidate)?; } if inputs.is_empty() { - return None; + return Ok(None); } - Some(inputs) + Ok(Some(IndexedInputs { + paths: inputs, + _leases: path_leases, + })) } - #[cfg(test)] mod tests { use super::*; diff --git a/crates/bashkit/src/error.rs b/crates/bashkit/src/error.rs index b1a773738..e50a4c31a 100644 --- a/crates/bashkit/src/error.rs +++ b/crates/bashkit/src/error.rs @@ -166,7 +166,8 @@ impl Error { /// `filesystem is read-only`, a custom [`crate::FileSystem`] backend's own /// wording — tells the caller *why* in a way the bare errno cannot, and is /// kept verbatim. -const ERRNO_RESTATING_MESSAGES: &[&str] = &["file not found", "parent directory not found"]; +const ERRNO_RESTATING_MESSAGES: &[&str] = + &["file not found", "not found", "parent directory not found"]; /// Render a filesystem error the way a real shell tool renders it. /// @@ -252,11 +253,10 @@ mod tests { /// depend on this to avoid printing the `io error: ` enum shape. #[test] fn io_error_reason_maps_errno_restating_messages() { - let err = Error::Io(std::io::Error::new( - std::io::ErrorKind::NotFound, - "file not found", - )); - assert_eq!(io_error_reason(&err), "No such file or directory"); + for message in ["file not found", "not found", "parent directory not found"] { + let err = Error::Io(std::io::Error::new(std::io::ErrorKind::NotFound, message)); + assert_eq!(io_error_reason(&err), "No such file or directory"); + } } /// A backend reason richer than its errno survives: `read-only` is the diff --git a/crates/bashkit/src/fs/mod.rs b/crates/bashkit/src/fs/mod.rs index 183241a86..e0405f064 100644 --- a/crates/bashkit/src/fs/mod.rs +++ b/crates/bashkit/src/fs/mod.rs @@ -644,6 +644,53 @@ pub async fn verify_filesystem_requirements(fs: &dyn FileSystem) -> Result<()> { Ok(()) } +/// Resolve every symlink in `path` (realpath), with an ELOOP hop cap. +pub(crate) async fn canonicalize(fs: &dyn FileSystem, path: &Path) -> crate::Result { + let mut rest: std::collections::VecDeque = path + .components() + .filter_map(|c| match c { + std::path::Component::Normal(s) => Some(s.to_string_lossy().into_owned()), + std::path::Component::ParentDir => Some("..".to_string()), + _ => None, + }) + .collect(); + let mut out = PathBuf::from("/"); + let mut hops = 0; + while let Some(comp) = rest.pop_front() { + if comp == ".." { + out.pop(); + continue; + } + let candidate = out.join(&comp); + match fs.lstat(&candidate).await { + Ok(m) if m.file_type.is_symlink() => { + hops += 1; + if hops > MAX_SYMLINK_HOPS { + return Err(std::io::Error::other("Too many levels of symbolic links").into()); + } + let target = fs.read_link(&candidate).await?; + if target.is_absolute() { + out = PathBuf::from("/"); + } + let mut parts: Vec = target + .components() + .filter_map(|c| match c { + std::path::Component::Normal(s) => Some(s.to_string_lossy().into_owned()), + std::path::Component::ParentDir => Some("..".to_string()), + _ => None, + }) + .collect(); + while let Some(p) = parts.pop() { + rest.push_front(p); + } + } + Ok(_) => out = candidate, + Err(e) => return Err(e), + } + } + Ok(out) +} + #[cfg(test)] mod vfs_join_tests { use super::*; diff --git a/crates/bashkit/src/limits.rs b/crates/bashkit/src/limits.rs index 0bdfcb75c..b131673ea 100644 --- a/crates/bashkit/src/limits.rs +++ b/crates/bashkit/src/limits.rs @@ -1142,6 +1142,10 @@ impl BudgetedVec { Ok(()) } + pub(crate) fn pop(&mut self) -> Option { + self.inner.pop() + } + pub(crate) fn into_parts(self) -> (Vec, Option) { (self.inner, self.lease) } diff --git a/crates/bashkit/tests/integration/grep_recursive_security_tests.rs b/crates/bashkit/tests/integration/grep_recursive_security_tests.rs new file mode 100644 index 000000000..7711912d9 --- /dev/null +++ b/crates/bashkit/tests/integration/grep_recursive_security_tests.rs @@ -0,0 +1,173 @@ +//! Recursive grep must bound traversal and keep only the current file alive. +use bashkit::{Bash, Error, ExecutionLimits, LimitExceeded}; +use std::path::Path; + +#[tokio::test] +async fn grep_recursive_cycle_is_not_revisited() { + let mut bash = Bash::new(); + bash.exec("mkdir /d; echo x > /d/f; ln -s . /d/a") + .await + .unwrap(); + // One alias has at most 41 visits on the unfixed implementation; safe to run. + let result = bash.exec("grep -R x /d").await.unwrap(); + assert_eq!(result.stdout.to_string(), "/d/f:x\n"); + assert_eq!( + result.stderr.to_string(), + "grep: /d/a: warning: recursive directory loop\n" + ); + assert_eq!(result.exit_code, 0); +} + +#[tokio::test] +async fn grep_recursive_collection_respects_work_budget() { + let mut bash = Bash::builder() + .limits(ExecutionLimits::new().max_work_units(100)) + .build(); + for i in 0..200 { + bash.fs() + .mkdir(Path::new(&format!("/d/{i}")), true) + .await + .unwrap(); + } + let result = bash.exec("grep -Rq x /d").await; + assert!( + matches!( + result, + Err(Error::ResourceLimit(LimitExceeded::ExecutionBudget(_))) + ), + "{result:?}" + ); +} + +#[tokio::test] +async fn grep_recursive_file_is_admitted_before_reading() { + let mut bash = Bash::builder() + .limits(ExecutionLimits::new().max_live_intermediate_bytes(4096)) + .build(); + bash.fs().mkdir(Path::new("/d"), true).await.unwrap(); + bash.fs() + .write_file(Path::new("/d/f"), &vec![b'x'; 8192]) + .await + .unwrap(); + let result = bash.exec("grep -Rq x /d").await; + assert!( + matches!( + result, + Err(Error::ResourceLimit(LimitExceeded::ExecutionBudget(_))) + ), + "{result:?}" + ); +} + +#[tokio::test] +async fn grep_recursive_two_aliases_terminate_with_and_without_a_match() { + let mut bash = Bash::builder() + .limits(ExecutionLimits::new().max_work_units(1000)) + .build(); + bash.exec("mkdir /d; echo x > /d/f; ln -s . /d/a; ln -s . /d/b") + .await + .unwrap(); + let quiet = bash.exec("grep -Rq x /d").await.unwrap(); + assert_eq!(quiet.exit_code, 0); + assert!(quiet.stdout.is_empty()); + assert!(quiet.stderr.is_empty()); + let absent = bash.exec("grep -R missing /d").await.unwrap(); + assert_eq!(absent.exit_code, 1); + assert!(absent.stdout.is_empty()); + assert_eq!( + absent.stderr.to_string(), + "grep: /d/a: warning: recursive directory loop\ngrep: /d/b: warning: recursive directory loop\n" + ); + let silent = bash.exec("grep -Rs missing /d").await.unwrap(); + assert_eq!(silent.exit_code, 1); + assert!(silent.stderr.is_empty()); +} + +#[tokio::test] +async fn grep_recursive_sibling_aliases_are_independent_and_r_skips_links() { + let mut bash = Bash::new(); + bash.exec("mkdir /d /target; echo x > /target/f; ln -s /target /d/a; ln -s /target /d/b") + .await + .unwrap(); + let result = bash.exec("grep -R x /d").await.unwrap(); + assert_eq!(result.stdout.to_string(), "/d/a/f:x\n/d/b/f:x\n"); + assert!(result.stderr.is_empty()); + let skipped = bash.exec("grep -r x /d").await.unwrap(); + assert_eq!(skipped.exit_code, 1); + assert!(skipped.stdout.is_empty()); +} + +#[tokio::test] +async fn grep_recursive_root_alias_and_parent_cycle_are_detected() { + let mut bash = Bash::new(); + bash.exec("mkdir -p /d/child; echo x > /d/f; ln -s .. /d/child/up; ln -s /d /alias") + .await + .unwrap(); + let result = bash.exec("grep -R x /alias").await.unwrap(); + assert_eq!(result.stdout.to_string(), "/alias/f:x\n"); + assert_eq!( + result.stderr.to_string(), + "grep: /alias/child/up: warning: recursive directory loop\n" + ); +} + +#[tokio::test] +async fn grep_recursive_releases_each_file_buffer() { + let mut bash = Bash::builder() + .limits(ExecutionLimits::new().max_live_intermediate_bytes(4096)) + .build(); + bash.fs().mkdir(Path::new("/d"), true).await.unwrap(); + for i in 0..6 { + bash.fs() + .write_file(Path::new(&format!("/d/f{i}")), &vec![b'x'; 1024]) + .await + .unwrap(); + } + let result = bash.exec("grep -Rc missing /d").await.unwrap(); + assert_eq!(result.exit_code, 1); + assert_eq!(result.stdout.to_string().lines().count(), 6); +} + +#[tokio::test] +async fn grep_recursive_files_share_aggregate_input_budget() { + let mut bash = Bash::builder() + .limits(ExecutionLimits::new().max_aggregate_input_bytes(1500)) + .build(); + bash.fs().mkdir(Path::new("/d"), true).await.unwrap(); + for i in 0..2 { + bash.fs() + .write_file(Path::new(&format!("/d/f{i}")), &vec![b'x'; 1024]) + .await + .unwrap(); + } + let result = bash.exec("grep -R missing /d").await; + assert!( + matches!( + result, + Err(Error::ResourceLimit(LimitExceeded::ExecutionBudget(_))) + ), + "{result:?}" + ); +} + +#[tokio::test] +async fn grep_recursive_cycle_diagnostic_is_bounded_utf8() { + let mut bash = Bash::new(); + let mut dir = std::path::PathBuf::from("/d"); + for _ in 0..6 { + dir.push("é".repeat(100)); + } + bash.fs().mkdir(&dir, true).await.unwrap(); + bash.fs() + .symlink(Path::new("."), &dir.join("loop")) + .await + .unwrap(); + let result = bash + .exec(&format!("grep -R missing {}", dir.display())) + .await + .unwrap(); + assert_eq!(result.exit_code, 1); + assert!(result.stderr.as_bytes().len() <= 1024); + assert!(std::str::from_utf8(result.stderr.as_bytes()).is_ok()); + assert!(result.stderr.to_string().starts_with("grep: ")); +} diff --git a/crates/bashkit/tests/integration/main.rs b/crates/bashkit/tests/integration/main.rs index 551d53f61..f45d5ba72 100644 --- a/crates/bashkit/tests/integration/main.rs +++ b/crates/bashkit/tests/integration/main.rs @@ -80,6 +80,7 @@ pub mod glob_fuzz_scaffold_tests; pub mod glob_intermediate_component_tests; pub mod grep_gnu_output_tests; pub mod grep_gnu_parity_tests; +pub mod grep_recursive_security_tests; pub mod harness_example_tests; pub mod headtail_resource_tests; pub mod history_tests; diff --git a/crates/bashkit/tests/spec_cases/grep/grep.test.sh b/crates/bashkit/tests/spec_cases/grep/grep.test.sh index 1e3d98902..6aca9154e 100644 --- a/crates/bashkit/tests/spec_cases/grep/grep.test.sh +++ b/crates/bashkit/tests/spec_cases/grep/grep.test.sh @@ -758,3 +758,25 @@ grep -c --binary-files=without-match needle data.bin; echo "rc=$?" 0 rc=1 ### end + +### grep_recursive_directory_cycle +# Resolved ancestors are skipped; independent aliases must still be searched. +mkdir -p /tmp/greploop/d +printf 'x\n' > /tmp/greploop/d/f +ln -s . /tmp/greploop/d/a +grep -R x /tmp/greploop/d 2>&1 | sort +### expect +/tmp/greploop/d/f:x +grep: /tmp/greploop/d/a: warning: recursive directory loop +### end + +### grep_recursive_sibling_aliases +mkdir -p /tmp/grepaliases/d /tmp/grepaliases/target +printf 'x\n' > /tmp/grepaliases/target/f +ln -s /tmp/grepaliases/target /tmp/grepaliases/d/a +ln -s /tmp/grepaliases/target /tmp/grepaliases/d/b +grep -R x /tmp/grepaliases/d | sort +### expect +/tmp/grepaliases/d/a/f:x +/tmp/grepaliases/d/b/f:x +### end diff --git a/docs/security.md b/docs/security.md index 37aa7a3e0..f2a0af59a 100644 --- a/docs/security.md +++ b/docs/security.md @@ -18,6 +18,13 @@ individual threat IDs and mitigation status, see the | **Resource limits** | Configurable caps on commands, loop iterations, recursion depth, AST depth, timeouts, and parser operations prevent denial-of-service from malicious scripts. | | **Filesystem limits** | Total bytes, per-file size, file count, path depth, and filename length are all capped to prevent storage exhaustion (zip bombs, tar bombs, recursive copies). | +Recursive `grep -R` skips directory symlinks that resolve to an ancestor and +reports `warning: recursive directory loop` (suppressed by `-s`). Separate +aliases of a non-ancestor directory remain searchable. Traversal uses the shared +execution work and deadline budget; file contents are admitted against aggregate +input and live intermediate limits and scanned one file at a time. `-q` stops +at the first match, before further traversal. + ## Threat model Bashkit maintains a living threat model in [`knowledge/security/threat-model.md`](../knowledge/security/threat-model.md) diff --git a/knowledge/foundations/builtins.md b/knowledge/foundations/builtins.md index 0bd7d8ab4..61e585c2b 100644 --- a/knowledge/foundations/builtins.md +++ b/knowledge/foundations/builtins.md @@ -494,6 +494,16 @@ probe of the 879 BRE/ERE regex cases except two whose pattern holds a raw - Options parse like getopt_long: mixed with operands, unique long-option prefixes, `-NUM`, `--label`, `-` for stdin, `-d read|skip|recurse`, `-R` following symlinks met while recursing (`-r` skips them). +- Recursive traversal compares canonical paths only against the current ancestor + chain, using the VFS resolver shared with `find`. Cycles produce GNU's + `warning: recursive directory loop` and are skipped; separate sibling aliases + still search the same target under each display name. Diagnostics use the shared + errno formatter and a UTF-8-safe 1 KiB cap. Traversal and ancestor + comparisons consume shared work (checking deadline/cancellation), pending paths + and listings carry live-byte leases, and files are admitted by metadata size + before reading and scanned one at a time. Indexed search retains admitted paths + instead of contents. `-q` stops on the first matching file. Regression coverage: + `grep_recursive_security_tests` in the consolidated integration binary. Gaps are L-GREP-001..003 in [Known Limitations](../operations/limitations.md); regex caps are TM-DOS-023/025 in the [Threat Model](../security/threat-model.md). diff --git a/knowledge/foundations/vfs.md b/knowledge/foundations/vfs.md index 692f21b3f..bbb4aa22e 100644 --- a/knowledge/foundations/vfs.md +++ b/knowledge/foundations/vfs.md @@ -360,6 +360,9 @@ their `stat` is an `lstat`, and they refuse to read or write through a link. - Targets are VFS paths: absolute from the VFS root, relative to the link's directory, `..` clamped at `/` (TM-ESC-002). Writes through a dangling link create the target. +- Recursive `find` and `grep` share `fs::canonicalize`, resolving links through + `lstat`/`read_link` with the same 40-hop cap. Walkers compare resolved ancestor + paths to reject directory cycles; the per-lookup cap alone cannot bound fan-out. - At most 40 links per lookup, then "Too many levels of symbolic links" (TM-DOS-011). `exists` is false for dangling and looping links. - Fast path: ops run on the inner fs first and resolve only on a diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index e83f7396d..25033c440 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -206,7 +206,7 @@ delegation (see TM-DOS-046). Regression tests: `path_validation_security` module | ID | Threat | Attack Vector | Mitigation | Status | |----|--------|--------------|------------|--------| -| TM-DOS-011 | Symlink loops | `ln -s /a /b; ln -s /b /a` | `FollowFs` follows at most 40 links per lookup (Linux MAXSYMLINKS), then fails with "Too many levels of symbolic links"; `find -L` adds canonical-path loop detection (TM-DOS-121). Regressions: `fs::follow::tests::loops_fail_with_eloop`, `hop_limit_allows_long_chains`, `symlink_loop_is_reported` spec | **MITIGATED** | +| TM-DOS-011 | Symlink loops | `ln -s /a /b; ln -s /b /a` | `FollowFs` follows at most 40 links per lookup (Linux MAXSYMLINKS), then fails with "Too many levels of symbolic links"; `find -L` adds canonical-path loop detection (TM-DOS-121). `grep -R` uses the same resolver and skips resolved ancestor cycles, charges traversal to the shared execution budget, leases pending paths/listings, and admits and scans one file at a time; quiet matches stop traversal. Regressions: `grep_recursive_security_tests` (single/two-link and parent cycles, sibling aliases, traversal work, live file bytes, aggregate input, per-file lease release). Regressions: `fs::follow::tests::loops_fail_with_eloop`, `hop_limit_allows_long_chains`, `symlink_loop_is_reported` spec | **MITIGATED** | | TM-DOS-012 | Deep directory nesting | `mkdir -p a/b/c/.../z` (1000 levels) | `max_path_depth` limit (100) | **MITIGATED** | | TM-DOS-013 | Long filenames | Create 10KB filename | `max_filename_length` (255) + `max_path_length` (4096) | **MITIGATED** | | TM-DOS-014 | Many directory entries | Create 1M files in one dir | `max_file_count` limit | **MITIGATED** |