diff --git a/crates/bashkit/docs/compatibility.md b/crates/bashkit/docs/compatibility.md index eb5c3c618..eb5b265f1 100644 --- a/crates/bashkit/docs/compatibility.md +++ b/crates/bashkit/docs/compatibility.md @@ -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 | --- diff --git a/crates/bashkit/src/builtins/mod.rs b/crates/bashkit/src/builtins/mod.rs index 521a31c80..829b950d8 100644 --- a/crates/bashkit/src/builtins/mod.rs +++ b/crates/bashkit/src/builtins/mod.rs @@ -94,7 +94,7 @@ mod patch; mod path; mod pipeline; mod printf; -mod random; +pub(crate) mod random; mod read; mod retry; mod rg; diff --git a/crates/bashkit/src/fs/rootfs.rs b/crates/bashkit/src/fs/rootfs.rs index 1f00bf062..2ab70285d 100644 --- a/crates/bashkit/src/fs/rootfs.rs +++ b/crates/bashkit/src/fs/rootfs.rs @@ -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"]; @@ -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!( @@ -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); @@ -258,6 +263,12 @@ impl RootFs { } async fn sys_read(&self, p: &Path) -> Result> { + 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, diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 43eed3bd2..454b745ab 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -1315,6 +1315,7 @@ struct SubshellSnapshot { random_state: u32, getopts_char_idx: usize, last_bg_pid: Option, + seconds_base: (crate::time_compat::Instant, i64), } /// Interpreter state. @@ -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 @@ -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, } @@ -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, } @@ -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, } } @@ -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 @@ -10226,6 +10234,12 @@ impl Interpreter { .store(value.parse::().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::().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 @@ -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(); } _ => {} } diff --git a/crates/bashkit/tests/integration/snapshot_history_tests.rs b/crates/bashkit/tests/integration/snapshot_history_tests.rs index 0519397d3..42886e8be 100644 --- a/crates/bashkit/tests/integration/snapshot_history_tests.rs +++ b/crates/bashkit/tests/integration/snapshot_history_tests.rs @@ -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, diff --git a/crates/bashkit/tests/spec_cases/bash/type.test.sh b/crates/bashkit/tests/spec_cases/bash/type.test.sh index 0852a8325..575ac0977 100644 --- a/crates/bashkit/tests/spec_cases/bash/type.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/type.test.sh @@ -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 diff --git a/crates/bashkit/tests/spec_cases/bash/variables.test.sh b/crates/bashkit/tests/spec_cases/bash/variables.test.sh index 3b0831920..6ab1e24c5 100644 --- a/crates/bashkit/tests/spec_cases/bash/variables.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/variables.test.sh @@ -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 diff --git a/knowledge/foundations/vfs.md b/knowledge/foundations/vfs.md index de8f37bda..1d96599a1 100644 --- a/knowledge/foundations/vfs.md +++ b/knowledge/foundations/vfs.md @@ -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 diff --git a/knowledge/log.md b/knowledge/log.md index f6b85471a..293b6350f 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -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).