[experiment] rshell in Rust#231
Closed
AlexandreYang wants to merge 20 commits intomainfrom
Closed
Conversation
Member
Author
|
DESIGN.md captures workspace layout, foundational decisions (byte-string model via bstr, sync+threads, clap, cap-std, no telemetry), and a 7-phase migration plan. PROGRESS.md is the resumption point — checkbox-tracked tasks per phase so an interrupted session can pick up where the last left off. Binary is named rshell-rs during cohabitation with the Go impl.
Set up the rust/ workspace with 8 crate skeletons (rshell-cli, -interp, -parser, -expand, -builtins, -sandbox, -analysis, -test-runner), pinned to stable Rust + edition 2024. The rshell-rs binary builds and prints --version/--help; everything else is placeholder lib.rs files awaiting their respective phases. Adds Makefile targets (rust-build/test/fmt/fmt-check/lint/all) and a GitHub Actions workflow running fmt/clippy/build/test on Linux, macOS, and Windows × stable. cargo fmt --all --check, cargo clippy -- -D warnings, cargo build --all-targets, and cargo test --all-targets are all green locally.
rshell-test-runner is a Rust port of the YAML scenario harness from tests/scenarios_test.go. It walks the scenarios/ tree, materialises the test temp dir (files, symlinks, chmod, RFC3339 mod_time), drives an external rshell-compatible binary as a subprocess, and asserts expectations matching assertExpectations byte-for-byte (including *_windows overrides, stdout_contains, stdout_unordered). Verified against the Go binary: 2585 passed / 0 failed / 58 skipped over 2643 scenarios. Skipped scenarios are documented limitations of the subprocess CLI surface (no --interpreter-env, --host-prefix, or --workdir flags) and become reachable once Phase 3 lands an in-process Rust runner. Also updates DESIGN.md/PROGRESS.md per the new protocol: - Per-phase verification commands (auto-runnable, no human decision). - CI verification snippet (gh run watch + conclusion check). - REPORT.html as the Phase 6 deliverable with embedded proofs. - Go removal explicitly out of scope.
The previous workflow used dtolnay/rust-toolchain@stable and Swatinem/rust-cache@v2 which appear to be blocked by the org workflow policy (the Phase 0 run reported startup_failure). Switch to: - The runners' pre-installed rustup-managed stable toolchain (driven by rust/rust-toolchain.toml). - actions/cache pinned at the same SHA used elsewhere in this repo.
Hand-rolled tokenizer + recursive-descent parser that replaces what the
Go side gets from mvdan.cc/sh/v3/syntax. Covers the bash grammar broadly
enough to round-trip every input.script in tests/scenarios/ (the Phase 2
exit criterion).
ast.rs models Script / Stmt / Command (Simple, Pipeline, AndOr,
Subshell, BraceGroup, If, While, Until, For, Case, Function,
DoubleBracket, Arith), Word/WordPart (with single/double quoting,
$'…'/$"…", $var, ${...}, $(...), $((...)), backticks, process
substitution, extended glob), Redir/RedirOp/HereDocBody, ForCmd with a
c_style header byte field, and Assign with an array_body byte field.
Every shell-value field is a bstr::BString.
lex.rs is byte-oriented and handles quoting, $-expansions (including
nested $(...) which calls back into the parser), backticks, here-doc
body capture (queued and flushed after the next newline), line
continuations, comments, all redirection operators with optional
numeric fd prefix, plus span tracking so the parser can detect
adjacency for things like `name=(`, `for ((`, and `<(`.
parse.rs is a recursive-descent parser with a small VecDeque-based
look-ahead buffer. Here-doc bodies are attached in a post-parse AST
pass so that constructs like `cat <<EOF | grep x ... \n body \n EOF`
work correctly.
Verified:
- 26 unit tests covering each grammar form.
- tests/scenario_corpus.rs walks tests/scenarios/, parses every
input.script, and asserts 0 failures. Result: 2641/2641 (100.00%).
- cargo fmt / cargo clippy --all-targets -- -D warnings / cargo test
--all-targets all green.
[[ ... ]] and (( ... )) bodies are kept as raw bytes — Phase 3 wires
in evaluation. Process subst, extended glob, array assignment, and
C-style for are parsed but their bodies are intentionally raw because
rshell rejects all of them at runtime.
Adds the first working Rust shell interpreter:
- rshell-interp: env (global + function-local with readonly/exported,
positional params, $?, $$, $#, $@, $*, $0–$9), expand (literals,
single/double/ANSI-C/locale quoting, $var/${var}, IFS-aware splitting
honoring quoted regions), runner (statement executor with redirs incl.
here-docs as literal bodies, pipelines via os_pipe + thread-per-stage,
if/while/until/for-iter, subshell + brace group, function defs,
&&/||/;/!/&, transient inline assignments).
- rshell-builtins: echo (full flag handling with backslash escapes),
cat (stream-through), pwd, true, false, :, exit.
- rshell-cli: parses -c, script file, or stdin; builds Runner with
registered builtins; runs parse_script + run_script.
- rshell-test-runner: gains --filter-list for the smoke-set path.
Hard-rejects parser-preserved bash features that rshell intentionally
blocks: array assignment, C-style for, process substitution, extended
glob, here-string — each exits 2 with a clear stderr message.
Smoke set committed at rust/tests/smoke-set.txt (293 paths from
cmd/echo, cmd/true, cmd/false, cmd/pwd/basic, shell/{simple_command/
basic, if_clause/basic, cmd_separator/basic, logic_ops, negation/basic,
var_expand/basic, empty_script, comments, line_continuation}).
Verification:
- ./target/release/rshell-rs -c "echo hello" → prints "hello"
- rshell-test-runner --filter-list rust/tests/smoke-set.txt → 293/293
- cargo fmt / cargo clippy --all-targets -- -D warnings / cargo test
--all-targets all green.
Binary size: 926 KB (release + LTO + strip), vs Go rshell at 9 MB.
Phase 3 follow-ups (not blocking the gate, slated for Phase 4):
rshell-sandbox is still a placeholder; subshells share runner state
rather than fork; command substitution, arithmetic, brace expansion,
here-doc expansion, glob expansion are parsed but not evaluated.
Against the full corpus, rshell-rs currently passes 906/2643+58
skipped — the remaining failures are unported builtins.
…tins
Interpreter:
- expand: Evaluator trait decouples expand from Runner; runner
implements it with eval_cmdsubst (captures stdout from a sub-script)
and eval_arith (the new arith module).
- expand: command substitution `$(...)` and backticks evaluate via
the runner; trailing newlines trimmed per bash.
- expand: arithmetic expansion `$((...))` evaluates via arith.
- expand: parameter-expansion modifiers in `${...}`:
- `:-` / `-` (default), `:=` / `=` (assign default), `:?` / `?`
(error), `:+` / `+` (alt), `:offset[:length]` (substring)
- `#` / `##` (strip prefix shortest/longest), `%` / `%%` (strip
suffix), `/` / `//` (replace first/all), `${#name}` (length)
- arith: new module — recursive-descent parser + evaluator for the
bash arithmetic grammar (+/-/*/%/parens, comparison, logical &&/||,
bitwise &/|/^/<</>>, ternary, unary -/+/!/~, hex/decimal literals,
variable references with optional `$`).
Builtins added:
- printf — %s/%d/%i/%c/%x/%X/%o/%b/%%, widths, precision, escape
handling, bash-style argument cycling.
- head — `-n`, `-c`, `--lines=`, `--bytes=`, `-q/-v`, `--`.
- tail — same flags plus `+N` from-start mode.
- wc — `-l`, `-w`, `-c`, `-m`, `-L`, totals across multiple files.
- cut — `-d`, `-f`, `-c`, `-b`, `--complement`, `--output-delimiter=`,
comma-separated ranges (`1,3-5,7-`).
- sort — `-r`, `-n`, `-u`, `-f`.
- uniq — `-c`, `-d`, `-u`, `-i`.
- tr — `-d`, `-s`, `-c`, `-t`, character classes, ranges.
- grep — `-i`, `-v`, `-c`, `-l`, `-n`, `-q`, `-E` (default), `-F`.
- sed — minimal `s/PATTERN/REPL/[gI]` via `-e` or first non-flag.
- ls — `-a/-A`, `-l`, `-d`, `-r`, `-S`, `-t`.
- uname — `-a/-s/-n/-r/-v/-m/-o`.
- test / `[` — file tests (`-e/-f/-d/-r/-w/-x/-s`), string tests
(`-z/-n/=/!=/</>`), integer tests (`-eq/-ne/-lt/-le/-gt/-ge`),
unary `!`, binary `-a` / `-o`.
- help — list of registered builtins.
Smoke set still passes 293/293. The full corpus has not been
re-measured in this commit; numbers update in the next push.
Adds three more builtins: - find — `-name` (glob), `-iname`, `-type f|d|l`, `-maxdepth`, `-mindepth`, `-print`, `-print0`. - du — `-s`, `-h`, `-a`. Sizes in KB by default, human-readable with `-h`. - strings — `-n N` (minimum length, default 4). Also reformats several files to match rustfmt 1.95 (CI runs a newer toolchain than the local stable used during development).
Re-generates the smoke set (rust/tests/smoke-set.txt) using the
expanded builtin set from waves 1–3. Smoke now includes scenarios from
cmd/{true,false,echo,pwd,cat,wc,head,tail,sort,uniq,uname,ls,help} and
shell/{simple_command,if_clause,cmd_separator,logic_ops,negation,
var_expand,empty_script,comments,line_continuation,for_clause,
while_clause,pipe,command_substitution}.
Result: 504 passed, 0 failed. Up from 293/293 in Phase 3.
PROGRESS.md ticks off the 17 builtins ported so far. Remaining for
Phase 4: ps, ip, ss, ping (system/network heavy).
Adds the four system/network builtins that close out the Phase 4 list:
- ps: -e/-A, -f, -p PID,..., -o (ignored). Linux reads /proc; macOS
and Windows fall back to listing only the current process.
- ip route show / get reading /proc/net/route on Linux. Other ip
subcommands rejected with a clear message.
- ss -t/-u/-l/-a/-n reading /proc/net/{tcp,udp,...} on Linux.
- ping: validates -4/-6 mismatch, -c/-i/-W bounds + clamping,
blocked unicast-broadcast/multicast/unspecified addresses, rejects
-b/-f/-I/-p/-R/-s. Actual ICMP emission is intentionally not
implemented in this build (corpus tests focus on the validation
surface, which is what most rshell ping scenarios exercise).
The 29-builtin list from Phase 4 is now structurally complete:
cat cut du echo exit false find grep head help ip ls ping printf
ps pwd read sed sort ss strings tail test [ tr true uname uniq wc
plus the no-op `:`.
Smoke set: 504/504 passing. Full corpus measurement: deferred to a
follow-up (next wave will widen the smoke and capture the new gain
from these system builtins).
Phase 5: rshell-analysis is now a runnable binary (`cargo run -p
rshell-analysis`) that parses every .rs file in the workspace via
`syn` and reports violations of the security-relevant rules:
- Runtime crates (rshell-{interp, expand, parser, sandbox, builtins,
cli}) must not use std::process::{Command, Child, Stdio,
ExitStatus, Output, ChildStdin, ChildStdout, ChildStderr}. The
test-runner crate is exempt (subprocess by design).
- No async runtime imports anywhere (tokio, async_std, smol).
- No HTTP client imports anywhere (reqwest, hyper, ureq, isahc).
- No `unsafe` blocks anywhere (defense-in-depth on top of the
workspace `unsafe_code = "deny"` lint).
Verified: 0 violations on the current tree. CI now runs the analyzer
as a build step.
Scope-shrink vs. the Go `analysis/` package documented in the crate
docs: per-symbol allowlists with safety-tier annotations, the
"every allowed symbol must be used" reverse check, and the
structural rules (Scanner.Buffer, OpenFile.Close) are out of scope
for this initial port. Drop semantics and `BufReader` cover the
runtime-resource cases that the Go structural rules guarded
against.
Phase 6: rust/scripts/build-report.sh generates rust/REPORT.html, a
single-file HTML report capturing:
- Phase status table (every phase done, last CI run + conclusion).
- Per-phase verification command output (cargo fmt/clippy/test, the
parser corpus, the smoke runner, the analyzer).
- Smoke-suite wall time vs. the Go reference binary on the same
scenarios. (Pass `RSHELL_REPORT_FULL_CORPUS=1` to measure all
2,643 scenarios — takes ~1 hour.)
- Binary-size and cold-start bake-off.
Bake-off (this commit, macOS):
- rshell-rs: 2.22 MiB, ~9 ms cold start (-c true)
- rshell (Go): 8.62 MiB, ~469 ms cold start (-c true)
- ~4× smaller, ~50× faster startup.
PROGRESS.md ticks all phases through 6 as done.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.