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
32 changes: 26 additions & 6 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ use std::collections::{HashMap, HashSet};
use std::panic::AssertUnwindSafe;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};

/// Monotonic counter for unique process substitution file paths
static PROC_SUB_COUNTER: AtomicU64 = AtomicU64::new(0);
Expand Down Expand Up @@ -500,6 +500,11 @@ pub struct Interpreter {
/// virtual file path, run these commands with the file content as stdin.
/// Each entry is (virtual_path, commands_to_run).
deferred_proc_subs: Vec<(String, Vec<Command>)>,
/// PRNG state for $RANDOM (LCG seeded per-instance from OS entropy).
/// NOT cryptographically secure — matches real bash behavior.
/// Uses `AtomicU32` for interior mutability so $RANDOM can advance state
/// in `expand_variable(&self, ...)` while remaining `Send + Sync`.
random_state: AtomicU32,
}

impl Interpreter {
Expand Down Expand Up @@ -771,6 +776,13 @@ impl Interpreter {
let mut arrays = HashMap::new();
arrays.insert("BASH_VERSINFO".to_string(), bash_versinfo);

// Seed PRNG for $RANDOM from OS entropy via RandomState
let random_seed = {
use std::collections::hash_map::RandomState;
use std::hash::{BuildHasher, Hasher};
RandomState::new().build_hasher().finish() as u32
};

Self {
fs,
env: HashMap::new(),
Expand Down Expand Up @@ -816,6 +828,7 @@ impl Interpreter {
pending_fd_targets: Vec::new(),
cancelled: Arc::new(AtomicBool::new(false)),
deferred_proc_subs: Vec::new(),
random_state: AtomicU32::new(random_seed),
}
}

Expand Down Expand Up @@ -8435,6 +8448,12 @@ impl Interpreter {
}
// Resolve nameref: if `name` is a nameref, assign to the target instead
let resolved = self.resolve_nameref(&name).to_string();
// RANDOM=N reseeds the PRNG (matches bash behavior)
if resolved == "RANDOM" {
self.random_state
.store(value.parse::<u32>().unwrap_or(0), Ordering::Relaxed);
return;
}
// THREAT[TM-INJ-019/020/021]: Block assignment to readonly variables
if self
.variables
Expand Down Expand Up @@ -8772,11 +8791,12 @@ impl Interpreter {
return flags;
}
"RANDOM" => {
// $RANDOM - random number between 0 and 32767
use std::collections::hash_map::RandomState;
use std::hash::{BuildHasher, Hasher};
let random = RandomState::new().build_hasher().finish() as u32;
return (random % 32768).to_string();
// $RANDOM - LCG matching bash behavior, seeded per-instance.
// LCG: state = state * 1103515245 + 12345 (glibc constants)
let prev = self.random_state.load(Ordering::Relaxed);
let next = prev.wrapping_mul(1103515245).wrapping_add(12345);
self.random_state.store(next, Ordering::Relaxed);
return ((next >> 16) & 0x7fff).to_string();
}
"LINENO" => {
// $LINENO - current line number from command span
Expand Down
49 changes: 49 additions & 0 deletions crates/bashkit/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4214,6 +4214,55 @@ fn
.expect("$RANDOM should be a number");
}

#[tokio::test]
async fn test_random_different_instances() {
// Two separate Bash instances should produce different PRNG sequences
// (with very high probability, since each is seeded from OS entropy)
let mut bash1 = Bash::new();
let mut bash2 = Bash::new();
let r1 = bash1.exec("echo $RANDOM").await.unwrap();
let r2 = bash2.exec("echo $RANDOM").await.unwrap();
let v1: u32 = r1.stdout.trim().parse().expect("should be a number");
let v2: u32 = r2.stdout.trim().parse().expect("should be a number");
assert!(v1 < 32768);
assert!(v2 < 32768);
// Extremely unlikely to collide with independent OS-entropy seeds
assert_ne!(v1, v2, "separate instances should produce different values");
}

#[tokio::test]
async fn test_random_reseed() {
// RANDOM=N should reseed the PRNG, producing a deterministic sequence
let mut bash1 = Bash::new();
let mut bash2 = Bash::new();
bash1.exec("RANDOM=42").await.unwrap();
bash2.exec("RANDOM=42").await.unwrap();
let r1 = bash1.exec("echo $RANDOM").await.unwrap();
let r2 = bash2.exec("echo $RANDOM").await.unwrap();
assert_eq!(
r1.stdout, r2.stdout,
"same seed should produce same first value"
);
}

#[tokio::test]
async fn test_random_sequential_varies() {
// Sequential $RANDOM calls within a single instance should differ
let mut bash = Bash::new();
let result = bash.exec("echo $RANDOM $RANDOM $RANDOM").await.unwrap();
let values: Vec<u32> = result
.stdout
.split_whitespace()
.map(|s| s.parse().expect("should be a number"))
.collect();
assert_eq!(values.len(), 3);
// At least two of three should differ (LCG never produces same value twice in a row)
assert!(
values[0] != values[1] || values[1] != values[2],
"sequential RANDOM calls should produce different values"
);
}

#[tokio::test]
async fn test_special_var_lineno() {
// $LINENO - current line number
Expand Down
Loading