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
1 change: 1 addition & 0 deletions crates/bashkit/docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ Chrono's validated strftime implementation, plus GNU `%N`, `%3N`, `%6N`, and
| `$_` | ❌ | Last argument |
| `$RANDOM` | ✅ | Random number (0-32767) |
| `$SRANDOM` | ✅ | 32-bit random number from the OS CSPRNG (bash 5.1); assignment ignored |
| `$SECONDS` | ✅ | Whole seconds since the shell (the `Bash` instance) started; `SECONDS=N` restarts the count from N |
| `$LINENO` | ✅ | Current line number |

---
Expand Down
2 changes: 1 addition & 1 deletion crates/bashkit/src/builtins/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ mod patch;
mod path;
mod pipeline;
mod printf;
mod random;
pub(crate) mod random;
mod read;
mod retry;
mod rg;
Expand Down
13 changes: 12 additions & 1 deletion crates/bashkit/src/fs/rootfs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ use super::{SearchCapable, normalize_path, vfs_join};
use crate::error::Result;

/// Directory trees served by the system layer.
/// Fresh random UUID per read, as on Linux.
const PROC_UUID: &str = "/proc/sys/kernel/random/uuid";

const SYS_TREES: &[&str] = &["/etc", "/proc", "/bin", "/usr/bin", "/root", "/dev/zero"];
/// Ancestors of system trees, listed with the system entries merged in.
const SYS_ANCESTORS: &[&str] = &["/", "/usr", "/dev"];
Expand Down Expand Up @@ -168,7 +171,7 @@ impl RootFs {
sys.add_dir("/root", 0o700);
sys.add_dir("/etc/ssl/certs", 0o755);
let version = env!("CARGO_PKG_VERSION");
let files: [(&str, String); 12] = [
let files: [(&str, String); 13] = [
(
"/etc/os-release",
format!(
Expand Down Expand Up @@ -223,6 +226,8 @@ impl RootFs {
),
("/proc/loadavg", "0.00 0.00 0.00 1/1 1\n".to_string()),
("/proc/sys/kernel/hostname", format!("{hostname}\n")),
// Placeholder of the right size; reads return a fresh UUID.
(PROC_UUID, format!("{}\n", "0".repeat(36))),
];
for (path, content) in files {
sys.add_file(path, content, 0o644);
Expand Down Expand Up @@ -258,6 +263,12 @@ impl RootFs {
}

async fn sys_read(&self, p: &Path) -> Result<Vec<u8>> {
if p == Path::new(PROC_UUID) {
// Like Linux: every read is a new random (v4) UUID from the OS
// CSPRNG, the same source as `uuidgen`.
let uuid = crate::builtins::random::uuid_v4().map_err(std::io::Error::other)?;
return Ok(format!("{uuid}\n").into_bytes());
}
match self.stub_name(p) {
Some(name) => Ok(stub(&name).into_bytes()),
None => self.sys.read_file(p).await,
Expand Down
22 changes: 17 additions & 5 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1315,6 +1315,7 @@ struct SubshellSnapshot {
random_state: u32,
getopts_char_idx: usize,
last_bg_pid: Option<String>,
seconds_base: (crate::time_compat::Instant, i64),
}

/// Interpreter state.
Expand Down Expand Up @@ -1492,6 +1493,9 @@ pub struct Interpreter {
shell_features: ShellFeatures,
/// Hardened profiles intentionally reduce elapsed-time precision.
hardened_timing: bool,
/// `$SECONDS` reads `seconds_base.1 + whole seconds since seconds_base.0`;
/// set at shell start and on `SECONDS=N`.
seconds_base: (crate::time_compat::Instant, i64),
/// `&` jobs run concurrently (default) or to completion at spawn.
concurrent_jobs: bool,
/// Nesting depth of `execute_script_body`; finished background job output
Expand Down Expand Up @@ -1956,6 +1960,7 @@ impl Interpreter {
random_state: AtomicU32::new(random_seed),
shell_features,
hardened_timing,
seconds_base: (crate::time_compat::Instant::now(), 0),
concurrent_jobs: true,
script_depth: 0,
}
Expand Down Expand Up @@ -2106,6 +2111,7 @@ impl Interpreter {
random_state: AtomicU32::new(random_seed),
shell_features: self.shell_features,
hardened_timing: self.hardened_timing,
seconds_base: self.seconds_base,
concurrent_jobs: self.concurrent_jobs,
script_depth: 1,
}
Expand Down Expand Up @@ -9922,6 +9928,7 @@ impl Interpreter {
random_state: self.random_state.load(Ordering::Relaxed),
getopts_char_idx: self.getopts_char_idx,
last_bg_pid: self.last_bg_pid.clone(),
seconds_base: self.seconds_base,
}
}

Expand All @@ -9935,6 +9942,7 @@ impl Interpreter {
.store(snap.random_state, Ordering::Relaxed);
self.getopts_char_idx = snap.getopts_char_idx;
self.last_bg_pid = snap.last_bg_pid;
self.seconds_base = snap.seconds_base;
}

/// Perform the redirections of a null command (no command word) and
Expand Down Expand Up @@ -10226,6 +10234,12 @@ impl Interpreter {
.store(value.parse::<u32>().unwrap_or(0), Ordering::Relaxed);
return;
}
// SECONDS=N restarts the count from N (non-numbers count as 0).
if resolved == "SECONDS" {
let base = value.trim().parse::<i64>().unwrap_or(0);
self.seconds_base = (crate::time_compat::Instant::now(), base);
return;
}
// Attribute lookup is now a single map probe + bit test.
let attrs = self.var_attrs_get(resolved);
// THREAT[TM-INJ-019/020/021]: Block assignment to readonly variables
Expand Down Expand Up @@ -10952,11 +10966,9 @@ impl Interpreter {
return COMPAT_BASH_VERSION.to_string();
}
"SECONDS" => {
// Seconds since shell started - always 0 in stateless model
if let Some(v) = self.scoped.variables.get("SECONDS") {
return v.clone();
}
return "0".to_string();
let (start, base) = self.seconds_base;
let elapsed = i64::try_from(start.elapsed().as_secs()).unwrap_or(i64::MAX);
return base.saturating_add(elapsed).to_string();
}
_ => {}
}
Expand Down
5 changes: 3 additions & 2 deletions crates/bashkit/tests/integration/snapshot_history_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -304,8 +304,9 @@ async fn checkout_matches_a_full_snapshot_taken_at_the_same_point() {
let mut via_blob = Bash::new();
via_blob.restore_snapshot(&packed).unwrap();

// Scope to real content: /dev entries are synthetic and /dev/random differs per read.
let listing = "find / -path /dev -prune -o -type f -print | sort | xargs -I{} sh -c 'echo {}; cat {}'";
// Scope to real content: /dev entries are synthetic, and /dev/random
// and /proc/sys/kernel/random/uuid differ per read.
let listing = "find / \\( -path /dev -o -path /proc/sys/kernel/random \\) -prune -o -type f -print | sort | xargs -I{} sh -c 'echo {}; cat {}'";
assert_eq!(
via_graph.exec(listing).await.unwrap().stdout,
via_blob.exec(listing).await.unwrap().stdout,
Expand Down
11 changes: 11 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/type.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,14 @@ env-exec
denied
conf
### end

### rootfs_proc_random_uuid
# /proc/sys/kernel/random/uuid yields a fresh random UUID per read
a=$(cat /proc/sys/kernel/random/uuid)
b=$(cat /proc/sys/kernel/random/uuid)
[[ $a =~ ^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ ]] && echo shape
[ "$a" != "$b" ] && echo fresh
### expect
shape
fresh
### end
18 changes: 16 additions & 2 deletions crates/bashkit/tests/spec_cases/bash/variables.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -508,11 +508,25 @@ uid_set
### end

### var_seconds
# $SECONDS is set (always 0 in bashkit)
### bash_diff
# $SECONDS counts whole seconds since the shell started
test -n "$SECONDS" && echo "seconds_set"
s=$SECONDS
sleep 1.1
[ $((SECONDS - s)) -ge 1 ] && echo advanced
### expect
seconds_set
advanced
### end

### var_seconds_assign
# Assigning SECONDS restarts the count from that value
SECONDS=100
[ "$SECONDS" -ge 100 ] && [ "$SECONDS" -le 101 ] && echo assigned
(SECONDS=5000)
[ "$SECONDS" -lt 5000 ] && echo subshell_kept_own
### expect
assigned
subshell_kept_own
### end

### var_pwd_updates_with_cd
Expand Down
3 changes: 2 additions & 1 deletion knowledge/foundations/vfs.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,8 @@ mounts and custom filesystems always win.
means a host leak), `group`, `hostname`, `hosts`, `shells`, `timezone`.
- `/proc`: static `cpuinfo` (`VIRTUAL_NPROC` CPUs), `meminfo`, `version`
(`VIRTUAL_KERNEL_RELEASE`, same as `uname -r`), `loadavg`,
`sys/kernel/hostname`. No pid directories.
`sys/kernel/hostname`, and `sys/kernel/random/uuid` (a fresh v4 UUID from
the OS CSPRNG on every read, like Linux). No pid directories.
- `/bin`, `/usr/bin`: one virtual stub per registered builtin that is not
shell-only, answered from a shared name set on lookup and never stored as
files (materializing them cost ~2 ms per `Bash` build). A stub's content starts with `STUB_MARKER`; executing it by
Expand Down
1 change: 1 addition & 0 deletions knowledge/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## 2026-10-07

* **Fix**: `$SECONDS` counts whole seconds since the `Bash` instance was built (it was always 0), and `SECONDS=N` restarts the count from N; subshells keep their own. The rootfs gained `/proc/sys/kernel/random/uuid`, a fresh v4 UUID per read from the same CSPRNG as `uuidgen`. See [VFS](foundations/vfs.md).
* **Feature**: Pipelines stream. From the first stage that runs shell code, stages run concurrently on forked shells over 4 KiB pipes, with backpressure and SIGPIPE: `while :; do echo x; done | head -n 2` stops the loop (`PIPESTATUS` `141 0`) instead of running it into the command limit. Single-builtin leading stages keep running in sequence (no fork). The pipe is 4 KiB rather than Linux's 64 KiB because every byte an endless producer runs ahead costs budgeted commands. Nested streaming is capped at 4 levels after a debug-build stack measurement (TM-DOS-124). L-PIPE-001 narrowed. See [Parallel Execution](foundations/parallel-execution.md#pipelines).
* **Fix**: Pipeline stages are subshells, as in bash: `echo x | read v` leaves `v` unset and `cd /tmp | true` keeps the cwd; `shopt -s lastpipe` keeps the last stage in the shell. Streaming callers no longer see a non-last stage's output (`for ...; done | tac` streamed the loop's lines in input order). Stages still run sequentially (new L-PIPE-001). See [Parallel Execution](foundations/parallel-execution.md#pipelines).
* **Performance**: CPython `random` seeds lazily. The snapshot's `random._inst` gets a subclass whose entry points (`random`, `getrandbits`, `getstate`, `seed`, `setstate`) seed from `os.urandom` on first use, then switch it back to `Random` and rebind those five module functions. Calls that never touch `random` skip the reseed (`pass` 3.7 to 3.5 ms). See [CPython WebAssembly Runtime](runtimes/cpython-wasm.md).
Expand Down
Loading