Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion crates/bashkit/docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ for sandbox security reasons. See the compliance spec for details.
| `times` | - | POSIX display process times |
| `grep` | `-i`, `-v`, `-c`, `-n`, `-E`, `-q` | Pattern matching |
| `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///[g]`, `d`, `p`, `q`, `a`, `i`, `c`, `h/H/g/G/x`, `-E`, `-n`, `!` | Stream editing |
| `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` | `'{print}'`, `-F`, `-v`, loops, arrays, increment, ternary | Text processing |
| `jq` | `.field`, `.[n]`, pipes, file args, `-r`, `-c`, `-n`, `-s`, `-S`, `-e`, `-j`, `--tab`, `--arg`, `--argjson`, `-V`, combined flags | JSON processing |
| `sleep` | `N`, `N.N` | Pause execution (max 60s) |
Expand Down Expand Up @@ -502,6 +502,11 @@ Identified from eval analysis, all items now implemented:
- [x] `sed` `0~2` step addressing, even/odd line processing
- [x] `sed` `Q` quiet quit command
- [x] `sed` `0,/pattern/` first match addressing
- [x] `sed` GNU rewrite, issue #2427: literal `$` in the replacement, BRE/ERE
translation shared by `s///` and address regexes, stateful range addresses,
single-stream multi-file input, preserved missing final newline, `s///Ng`,
the `y = l n N P D T z F r R w W` commands, `#` comments and `#n`, atomic
mode-preserving `-i`, and full getopt-style option parsing
- [x] AWK `$0` modification with field re-splitting

### Not Planned
Expand Down
2 changes: 1 addition & 1 deletion crates/bashkit/docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -883,7 +883,7 @@ builtin silently fails.
| Threat | Attack Example | Mitigation | Status |
|--------|---------------|------------|--------|
| Awk byte-boundary panic (TM-UNI-001) | Multi-byte chars in awk input | `catch_unwind` catches panic | PARTIAL |
| Sed byte-boundary panic (TM-UNI-002) | Box-drawing chars in sed pattern | `catch_unwind` catches panic | PARTIAL |
| Sed byte-boundary panic (TM-UNI-002) | Multi-byte `s` delimiter, e.g. `sed 's≠a≠X≠'` | Char-based script parser; no byte slicing of script text | FIXED |
| Expr substr panic (TM-UNI-015) | `expr substr "café" 4 1` | `catch_unwind` catches panic | PARTIAL |
| Printf precision panic (TM-UNI-016) | `printf "%.1s" "é"` | `catch_unwind` catches panic | PARTIAL |
| Cut/tr byte-level parsing (TM-UNI-017) | `tr 'é' 'e'`, multi-byte in char set | `catch_unwind` catches; silent data loss | PARTIAL |
Expand Down
16 changes: 16 additions & 0 deletions crates/bashkit/fuzz/fuzz_targets/sed_fuzz.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,22 @@ fuzz_target!(|data: &[u8]| {
expr.replace('\'', "'\\''"),
);
bashkit::testing::fuzz_exec(&mut bash, &script3, "sed_fuzz", &[]).await;

// Test 4: seeds that historically panicked or diverged (issue #2427):
// multi-byte `s` delimiters (TM-UNI-002), truncated scripts, and
// option clustering.
for seed in [
"s≠a≠X≠",
"s😀a😀X😀g",
"y≠a≠b≠",
"s/a",
"s/[//",
":a;ba",
"b missing",
] {
let script = format!("echo 'a' | sed '{seed}'");
bashkit::testing::fuzz_exec(&mut bash, &script, "sed_fuzz_seed", &[]).await;
}
});
}
});
92 changes: 92 additions & 0 deletions crates/bashkit/src/builtins/atomic_write.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
//! Shared atomic in-place file replacement for builtins that rewrite files.
//!
//! THREAT[TM-FS-016]: write a sibling temporary file, copy the original mode
//! onto it, and rename only after the new content is fully written. Any failure
//! leaves the source file untouched and removes the temporary.
//!
//! Extracted from `yq` so `sed -i` gets the same guarantees (issue #2427,
//! finding E): the previous `sed -i` wrote straight through
//! `FileSystem::write_file`, which recreates the entry with mode 0644 and loses
//! the original content if the write fails part-way.

use std::path::Path;

use crate::fs::vfs_join;

/// Failpoint names for one call site. Each builtin supplies its own so the
/// security failpoint suite can target them independently.
#[cfg_attr(not(feature = "failpoints"), allow(dead_code))]
pub(crate) struct AtomicFailpoints {
pub(crate) allocate: &'static str,
pub(crate) chmod: &'static str,
pub(crate) rename: &'static str,
}

pub(crate) async fn atomic_replace(
fs: &dyn crate::fs::FileSystem,
target: &Path,
content: &[u8],
tool: &str,
failpoints: AtomicFailpoints,
) -> std::result::Result<(), String> {
let parent = target.parent().unwrap_or_else(|| Path::new("/"));
#[cfg(feature = "failpoints")]
if injected_failure(failpoints.allocate, "exhausted") {
return Err("cannot allocate temporary file".to_string());
}
let mut temp = None;
for _ in 0..16 {
let mut suffix = [0u8; 8];
getrandom::fill(&mut suffix)
.map_err(|_| "cannot generate temporary filename".to_string())?;
let suffix = suffix
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
let candidate = vfs_join(parent, format!(".bashkit-{tool}-{suffix}.tmp"));
if !fs
.exists(&candidate)
.await
.map_err(|error| error.to_string())?
{
temp = Some(candidate);
break;
}
}
let temp = temp.ok_or_else(|| "cannot allocate temporary file".to_string())?;
if let Err(error) = fs.write_file(&temp, content).await {
let _ = fs.remove(&temp, false).await;
return Err(format!("cannot write temporary file: {error}"));
}
#[cfg(feature = "failpoints")]
if injected_failure(failpoints.chmod, "error") {
let _ = fs.remove(&temp, false).await;
return Err("cannot preserve file mode: injected failure".to_string());
}
if let Ok(metadata) = fs.stat(target).await
&& let Err(error) = fs.chmod(&temp, metadata.mode).await
{
let _ = fs.remove(&temp, false).await;
return Err(format!("cannot preserve file mode: {error}"));
}
#[cfg(feature = "failpoints")]
if injected_failure(failpoints.rename, "error") {
let _ = fs.remove(&temp, false).await;
return Err(format!(
"cannot replace '{}': injected failure",
target.display()
));
}
if let Err(error) = fs.rename(&temp, target).await {
let _ = fs.remove(&temp, false).await;
return Err(format!("cannot replace '{}': {error}", target.display()));
}
#[cfg(not(feature = "failpoints"))]
let _ = failpoints;
Ok(())
}

#[cfg(feature = "failpoints")]
fn injected_failure(name: &str, expected: &str) -> bool {
fail::eval(name, |action| action.as_deref() == Some(expected)).unwrap_or(false)
}
4 changes: 4 additions & 0 deletions crates/bashkit/src/builtins/limits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ pub(crate) const RETRY_MAX_ATTEMPTS: u32 = 10_000;
/// sed: max group-nesting depth in `s` replacements.
pub(crate) const SED_MAX_GROUP_NESTING_DEPTH: usize = 128;

/// sed: max instructions executed for one input line, bounding `b`/`t` loops.
/// THREAT[TM-DOS]: sed scripts are untrusted text and `:a;ba` never terminates.
pub(crate) const SED_MAX_CYCLE_STEPS: usize = 1_000;

/// sleep: max sleep duration.
pub(crate) const SLEEP_MAX_SECONDS: f64 = 60.0;

Expand Down
1 change: 1 addition & 0 deletions crates/bashkit/src/builtins/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ mod alias;
mod archive;
pub(crate) mod arg_parser;
mod assert;
mod atomic_write;
mod awk;
mod base64;
mod bc;
Expand Down
Loading
Loading