Skip to content

[experiment] rshell in Rust#231

Closed
AlexandreYang wants to merge 20 commits intomainfrom
alex/rust
Closed

[experiment] rshell in Rust#231
AlexandreYang wants to merge 20 commits intomainfrom
alex/rust

Conversation

@AlexandreYang
Copy link
Copy Markdown
Member

@AlexandreYang AlexandreYang commented May 6, 2026

No description provided.

@AlexandreYang
Copy link
Copy Markdown
Member Author

⏺ Got it — that's enough to sketch a plan. A few I'm picking defaults on rather than asking; push back if any feel wrong.

  Workspace layout (rust/ subdir)

  rust/
  ├── Cargo.toml                 # virtual workspace
  ├── crates/
  │   ├── rshell-cli/            # binary crate; clap-derive; entry point
  │   ├── rshell-interp/         # runner, redirections, expansion driver, var scope
  │   ├── rshell-parser/         # syntax → AST (replaces mvdan/sh/v3/syntax)
  │   ├── rshell-expand/         # word expansion, globbing, splitting (replaces mvdan/sh/v3/expand)
  │   ├── rshell-builtins/       # all 30 builtins; one module per command
  │   ├── rshell-sandbox/        # AllowedPaths over cap-std
  │   ├── rshell-analysis/       # 1:1 port of analysis/ (symbol allowlist verifier)
  │   ├── rshell-telemetry/      # 1:1 port of pkg/fleet/installer/telemetry
  │   └── rshell-test-runner/    # YAML scenario harness, bash comparison
  └── README.md

  Why split this way: lets us land crates incrementally, keeps rshell-parser/rshell-expand reusable, and isolates the analyzer (which has nothing to do with the
  runtime).

  Foundational picks (please confirm)

  - String model: bytes, not UTF-8. Shell variables, file paths, and command output are byte streams in bash. Use bstr::BString/BStr everywhere a shell value
  flows; only convert to &str at output boundaries when we know it's UTF-8. Path arguments to syscalls go through OsStr/OsString. This is the single biggest
  correctness decision and it ripples through every crate.
  - CLI parser: clap (derive). Direct cobra analogue. For the internal pflag use inside builtins, also clap/pflag-style parsing via clap builder API per builtin
  (lighter than derive for repetitive use).
  - Regex: regex crate (RE2, linear time — matches your existing rule). For glob, use globset.
  - Logging: tracing for app logs, while still porting the DataDog telemetry span API 1:1 as a separate crate (since you specified that). The two coexist;
  telemetry is for the embedded host's tracer, tracing is for our diagnostics.
  - MSRV: pin to current stable (1.85+ for edition 2024). Document in rust-toolchain.toml.
  - Error model: thiserror for typed errors at crate boundaries, anyhow only in the CLI binary.

  Phased migration

  1. Phase 0 — scaffolding. Workspace, CI matrix (lint + test on linux/mac/windows), rust-toolchain.toml, Makefile targets that proxy to cargo.
  2. Phase 1 — test runner. Port the YAML scenario runner to Rust first, but have it shell out to the existing Go rshell binary. This proves the harness works
  and gives us our oracle for everything that follows.
  3. Phase 2 — parser. rshell-parser to the point it round-trips every script in tests/scenarios/. No interp yet.
  4. Phase 3 — expansion + sandbox + minimal interp. Enough to run pipelines, redirects, simple commands, with cap-std-backed AllowedPaths.
  5. Phase 4 — builtins. Port in dependency order: echo/true/false/pwd/exit first; then file-readers (cat/head/tail/wc/grep/cut/sort/uniq/tr/sed/find/ls/du);
  then exotics (ip/ss/ps/ping/uname/strings_cmd/printf/help).
  6. Phase 5 — analysis port. Last because it doesn't block runtime parity.
  7. Phase 6 — bake-off. Run the YAML harness against both binaries until output matches; benchmark binary size, startup, RSS.

  Open questions before I write code

  1. pkg/fleet/installer/telemetry source access. Is it OSS (datadog-agent repo) so I can read it to do the 1:1 port? If not, do you have an internal pointer /
  docs?
  2. Binary name. Same rshell, or rshell-rs during the cohabitation period to avoid $PATH collisions while testing?
  3. Should I start by writing a rust/DESIGN.md doc capturing the above, or jump to scaffolding the workspace?

  For #3, my lean is doc first so we can iterate on text rather than code, but I'll do whichever you want.
  │   ├── rshell-analysis/       # 1:1 port of analysis/ (symbol allowlist verifier)
  │   ├── rshell-telemetry/      # 1:1 port of pkg/fleet/installer/telemetry
  │   └── rshell-test-runner/    # YAML scenario harness, bash comparison
  └── README.md

  Why split this way: lets us land crates incrementally, keeps rshell-parser/rshell-expand reusable, and isolates the analyzer (which has nothing to do with the
  runtime).

  Foundational picks (please confirm)

  - String model: bytes, not UTF-8. Shell variables, file paths, and command output are byte streams in bash. Use bstr::BString/BStr everywhere a shell value
  flows; only convert to &str at output boundaries when we know it's UTF-8. Path arguments to syscalls go through OsStr/OsString. This is the single biggest
  correctness decision and it ripples through every crate.
  - CLI parser: clap (derive). Direct cobra analogue. For the internal pflag use inside builtins, also clap/pflag-style parsing via clap builder API per builtin
  (lighter than derive for repetitive use).
  - Regex: regex crate (RE2, linear time — matches your existing rule). For glob, use globset.
  - Logging: tracing for app logs, while still porting the DataDog telemetry span API 1:1 as a separate crate (since you specified that). The two coexist;
  telemetry is for the embedded host's tracer, tracing is for our diagnostics.
  - MSRV: pin to current stable (1.85+ for edition 2024). Document in rust-toolchain.toml.
  - Error model: thiserror for typed errors at crate boundaries, anyhow only in the CLI binary.

  Phased migration

  1. Phase 0 — scaffolding. Workspace, CI matrix (lint + test on linux/mac/windows), rust-toolchain.toml, Makefile targets that proxy to cargo.
  2. Phase 1 — test runner. Port the YAML scenario runner to Rust first, but have it shell out to the existing Go rshell binary. This proves the harness works
  and gives us our oracle for everything that follows.
  3. Phase 2 — parser. rshell-parser to the point it round-trips every script in tests/scenarios/. No interp yet.
  4. Phase 3 — expansion + sandbox + minimal interp. Enough to run pipelines, redirects, simple commands, with cap-std-backed AllowedPaths.
  5. Phase 4 — builtins. Port in dependency order: echo/true/false/pwd/exit first; then file-readers (cat/head/tail/wc/grep/cut/sort/uniq/tr/sed/find/ls/du);
  then exotics (ip/ss/ps/ping/uname/strings_cmd/printf/help).
  6. Phase 5 — analysis port. Last because it doesn't block runtime parity.
  7. Phase 6 — bake-off. Run the YAML harness against both binaries until output matches; benchmark binary size, startup, RSS.

  Open questions before I write code

  1. pkg/fleet/installer/telemetry source access. Is it OSS (datadog-agent repo) so I can read it to do the 1:1 port? If not, do you have an internal pointer /
  docs?
  2. Binary name. Same rshell, or rshell-rs during the cohabitation period to avoid $PATH collisions while testing?
  3. Should I start by writing a rust/DESIGN.md doc capturing the above, or jump to scaffolding the workspace?

  For #3, my lean is doc first so we can iterate on text rather than code, but I'll do whichever you want.

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.
Comment thread .github/workflows/rust.yml Fixed
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.
@AlexandreYang AlexandreYang deleted the alex/rust branch May 6, 2026 21:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants