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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ breaking entries are marked **BREAKING**.
discipline. `tools::DEFAULT_KILL_GRACE` is 2s. Only a struct literal changes.

### Added
- **`random` builtin** — `random [--min N] [--max N]` prints one uniformly
chosen integer, typed; the default range is bash's `$RANDOM` (0 to 32767).

- **Wrapped commands** (`kaish_kernel::tools::wrapped`, `subprocess` feature):
register an external program as a tool with a declared grammar. Verbs and flags
are deny-by-default, refused with exit 2 before any spawn; the kernel renders
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ an `awk` that never surprises.
| **Text** | awk, base64, cut, diff, grep, head, sed, sort, split, tac, tail, tr, uniq, wc, xxd |
| **Files** | basename, cat, cd, checksum, cmp, cp, dd, dirname, file, find, glob, ln, ls, mkdir, mktemp, mv, patch, pwd, readlink, realpath, rm, stat, tee, touch, tree, write |
| **JSON** | fromjson, fromjsonl, jq, keys, tojson, tojsonl, typeof, values |
| **System** | alias, bg, date, echo, env, exec, export, fg, help, hostname, jobs, kill, plan, printf, ps, push, read, seq, set, sleep, spawn, timeout, tokens, uname, unalias, unset, wait, which |
| **System** | alias, bg, date, echo, env, exec, export, fg, help, hostname, jobs, kill, plan, printf, ps, push, random, read, seq, set, sleep, spawn, timeout, tokens, uname, unalias, unset, wait, which |
| **Parallel** | scatter, gather |
| **Meta** | `:`, assert, false, test, true |
| **kaish-*** | kaish-ast, kaish-clear, kaish-ignore, kaish-last, kaish-mounts, kaish-output-limit, kaish-status, kaish-tools, kaish-trash, kaish-validate, kaish-vars, kaish-version, kaish-vfs |
Expand Down
2 changes: 2 additions & 0 deletions crates/kaish-kernel/src/tools/builtin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ mod plan;
mod printf;
mod push;
mod pwd;
mod random;
mod read;
mod readlink;
mod realpath;
Expand Down Expand Up @@ -325,6 +326,7 @@ pub fn register_builtins(registry: &mut ToolRegistry) {
#[cfg(all(target_os = "linux", feature = "host"))]
registry.register(kaish_tools_host::Ps);
registry.register(pwd::Pwd);
registry.register(random::Random);
registry.register(read::Read);
registry.register(readlink::Readlink);
registry.register(realpath::Realpath);
Expand Down
262 changes: 262 additions & 0 deletions crates/kaish-kernel/src/tools/builtin/random.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
//! random — print one integer from `--min` to `--max`, inclusive.
//! kaish has no `$RANDOM`; `$(random --max 100)` is the typed replacement.
//!
//! ```kaish
//! random --max 6 # roll a die: 0..=6
//! ```

use async_trait::async_trait;
use clap::{CommandFactory, Parser};

use crate::ast::Value;
use crate::interpreter::{value_to_string, ExecResult};
use crate::tools::{schema_from_clap, ExecContext, GlobalFlags, Tool, ToolArgs, ToolCtx, ToolSchema};

/// Random tool: print one random integer, uniformly, from a range.
pub struct Random;

/// `$RANDOM`-compatible default: bash's range is 0..=32767.
const DEFAULT_MIN: i64 = 0;
const DEFAULT_MAX: i64 = 32767;

/// clap-derived argv layer for random.
#[derive(Parser, Debug)]
#[command(
name = "random",
about = "Print one random integer from --min to --max, inclusive. `$(random --max 100)` replaces bash's `$RANDOM`."
)]
struct RandomArgs {
/// Lowest value that can be returned, inclusive. Default 0.
#[arg(long = "min")]
min: Option<i64>,

/// Highest value that can be returned, inclusive. Default 32767.
#[arg(long = "max")]
max: Option<i64>,

#[command(flatten)]
global: GlobalFlags,
}

#[async_trait]
impl Tool for Random {
fn name(&self) -> &str {
"random"
}

fn schema(&self) -> ToolSchema {
schema_from_clap(
&RandomArgs::command(),
"random",
"Print one random integer from --min to --max, inclusive. `$(random --max 100)` replaces bash's `$RANDOM`.",
[
("Default range (like $RANDOM)", "random"),
("Roll a die", "random --max 6"),
("Capture and use", "x=$(random --max 6); echo $((x + 1))"),
],
)
.with_typed_substitution()
}

async fn execute(&self, args: ToolArgs, ctx: &mut dyn ToolCtx) -> ExecResult {
let Some(ctx) = ctx.as_any_mut().downcast_mut::<ExecContext>() else {
return ExecResult::failure(1, "internal error: kernel builtin requires ExecContext");
};

// No positional args; curate the error before clap ever sees one.
if let Some(v) = args.positional.first() {
let value = value_to_string(v);
return ExecResult::failure(
2,
format!("random: takes no positional argument; write `--max {value}`"),
);
}

let argv = match args.to_argv() {
Ok(v) => v,
Err(e) => return ExecResult::failure(2, format!("random: {e}")),
};
let parsed = match RandomArgs::try_parse_from(
std::iter::once("random".to_string()).chain(argv),
) {
Ok(p) => p,
Err(e) => return ExecResult::failure(2, format!("random: {e}")),
};
parsed.global.apply(ctx);

let min = parsed.min.unwrap_or(DEFAULT_MIN);
let max = parsed.max.unwrap_or(DEFAULT_MAX);

if min > max {
return ExecResult::failure(
2,
format!(
"random: --min {min} is greater than --max {max}; swap them or widen the range"
),
);
}

let value = match draw_random(min, max) {
Ok(v) => v,
Err(e) => {
return ExecResult::failure(
1,
format!("random: could not obtain system entropy: {e}"),
);
}
};

ExecResult::success_with_data(format!("{value}\n"), Value::Int(value))
}
}

/// Draws one integer in `min..=max` from the OS CSPRNG. No fallback: a
/// `getrandom` failure is a hard error, never a silently guessable value.
fn draw_random(min: i64, max: i64) -> Result<i64, getrandom::Error> {
loop {
let mut entropy = [0u8; 8];
getrandom::fill(&mut entropy)?;
let draw = u64::from_le_bytes(entropy);
if let Some(value) = map_draw_to_range(draw, min, max) {
return Ok(value);
}
}
}

/// Maps one 64-bit draw onto `min..=max`, inclusive, via Lemire's method:
/// widen the multiply to 128 bits and reject the low slice that would bias
/// a bucket. `None` means redraw.
fn map_draw_to_range(draw: u64, min: i64, max: i64) -> Option<i64> {
debug_assert!(min <= max);

// u128: width can be exactly 2^64, which doesn't fit u64.
let width: u128 = (max as i128 - min as i128) as u128 + 1;

if width > u64::MAX as u128 {
// Full i64 span: every draw already maps 1:1, no bias possible.
return Some((min as i128 + draw as i128) as i64);
}
let width = width as u64;
if width == 1 {
return Some(min);
}

let product = (draw as u128) * (width as u128);
let hi = (product >> 64) as u64;
let lo = product as u64;

// Below threshold: redraw to avoid skewing the low bucket.
let threshold = width.wrapping_neg() % width;
if lo < threshold {
return None;
}

Some((min as i128 + hi as i128) as i64)
}

#[cfg(test)]
mod tests {
use super::*;
use crate::vfs::{MemoryFs, VfsRouter};
use std::sync::Arc;

fn make_ctx() -> ExecContext {
let mut vfs = VfsRouter::new();
vfs.mount("/", MemoryFs::new());
ExecContext::new(Arc::new(vfs))
}

#[test]
fn map_draw_accepts_and_maps_a_known_draw() {
// width 201, hi 14 (verified independently against the algorithm,
// not re-derived from this code): min + hi = -86.
assert_eq!(map_draw_to_range(0x1234_5678_9abc_def0, -100, 100), Some(-86));
}

#[test]
fn map_draw_rejects_a_known_biased_draw() {
// width 7, threshold 2: draw 0 has lo 0 < 2, so it must reject.
assert_eq!(map_draw_to_range(0, 0, 6), None);
}

#[test]
fn map_draw_min_equals_max_ignores_the_draw() {
assert_eq!(map_draw_to_range(0, 7, 7), Some(7));
assert_eq!(map_draw_to_range(u64::MAX, 7, 7), Some(7));
}

#[test]
fn map_draw_stays_in_bounds() {
for draw in [0u64, 1, u64::MAX / 2, u64::MAX - 1, u64::MAX] {
for (min, max) in [(0i64, 6i64), (-5, 5), (i64::MIN, -1), (0, i64::MAX)] {
if let Some(v) = map_draw_to_range(draw, min, max) {
assert!(v >= min && v <= max, "{v} outside {min}..={max}");
}
}
}
}

#[test]
fn map_draw_full_i64_span_never_panics_and_never_rejects() {
for draw in [0u64, 1, u64::MAX / 2, u64::MAX - 1, u64::MAX] {
let v = map_draw_to_range(draw, i64::MIN, i64::MAX);
assert!(v.is_some(), "the full span must never reject a draw");
}
}

#[test]
fn map_draw_full_i64_span_pins_boundary_values() {
assert_eq!(map_draw_to_range(0, i64::MIN, i64::MAX), Some(i64::MIN));
assert_eq!(map_draw_to_range(u64::MAX, i64::MIN, i64::MAX), Some(i64::MAX));
}

#[test]
fn map_draw_extreme_spans_do_not_panic() {
let spans = [
(i64::MIN, i64::MIN),
(i64::MAX, i64::MAX),
(i64::MIN, i64::MAX),
(i64::MIN, i64::MIN + 1),
(i64::MAX - 1, i64::MAX),
];
for (min, max) in spans {
for draw in [0u64, u64::MAX] {
let _ = map_draw_to_range(draw, min, max);
}
}
}

#[tokio::test]
async fn execute_defaults_are_random_range() {
let mut ctx = make_ctx();
let result = Random.execute(ToolArgs::new(), &mut ctx).await;
assert!(result.ok());
let n: i64 = result.text_out().trim().parse().expect("integer output");
assert!((DEFAULT_MIN..=DEFAULT_MAX).contains(&n));
}

#[tokio::test]
async fn execute_rejects_min_greater_than_max() {
let mut ctx = make_ctx();
let mut args = ToolArgs::new();
args.named.insert("min".to_string(), Value::Int(10));
args.named.insert("max".to_string(), Value::Int(5));

let result = Random.execute(args, &mut ctx).await;
assert!(!result.ok());
assert_eq!(result.code, 2);
assert!(result.err.contains("--min 10 is greater than --max 5"));
}

#[tokio::test]
async fn execute_rejects_positional_argument() {
let mut ctx = make_ctx();
let mut args = ToolArgs::new();
args.positional.push(Value::Int(100));

let result = Random.execute(args, &mut ctx).await;
assert!(!result.ok());
assert_eq!(result.code, 2);
assert!(result.err.contains("--max 100"));
}
}
2 changes: 2 additions & 0 deletions crates/kaish-kernel/tests/json_sweep_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,8 @@ const CASES: &[Case] = &[
// push mutates in place and is silent on success, like unset.
Case { name: "push", setup: &["xs=[a b]"], cmd: "push xs c --json", expect: Expect::Empty },
Case { name: "pwd", setup: &[], cmd: "pwd --json", expect: Expect::String },
// Fixed min==max range so the draw is deterministic.
Case { name: "random", setup: &[], cmd: "random --min 3 --max 3 --json", expect: Expect::Number },
Case { name: "read", setup: &[], cmd: "echo hi | read X --json", expect: Expect::Empty },
Case { name: "readlink", setup: &["ln -s tmp/data.json link.json"], cmd: "readlink link.json --json", expect: Expect::String },
Case { name: "realpath", setup: &[], cmd: "realpath tmp/data.json --json", expect: Expect::String },
Expand Down
Loading