nsh is a POSIX shell written in Rust. The repository provides both:
nsh, an embeddable shell library; andnsh-cli, thenshcommand-line shell.
The implementation began as a port of dash 0.5.13.5. POSIX behavior remains the default, while an opt-in GNU Bash 5.3 compatibility mode is under development.
Development and testing currently target Linux. Building requires Rust 1.85 or newer.
The POSIX implementation is checked against the POSIX.1-2024 rule corpus, a C dash reference build, and the Smoosh and Oils survey suites. Deliberate differences from dash are listed in docs/divergences.md.
Bash mode is experimental and incomplete. Its target behavior is defined in the Bash compatibility profile; it should not yet be treated as a drop-in replacement for Bash.
cargo build --release
./target/release/nsh -c 'printf "hello\n"'
./target/release/nsh script.sh
./target/release/nshTo install the command-line shell from this checkout:
cargo install --path crates/nsh-cliBash mode is disabled by default. It can be selected on invocation, enabled
from a running shell, or inferred from an exact bash or -bash invocation
name:
nsh -o bash script.bash
ln -s "$(command -v nsh)" ./bash
./bash script.bashWithin a running shell, use set -o bash to enable Bash mode and set +o bash
to return to POSIX mode for subsequently parsed input.
Shell owns its variables, options, parser state, jobs, and logical file
descriptors. Shell data is represented as byte strings, so arguments and
variable values do not have to be UTF-8.
use bstr::BStr;
use nsh::{Error, Shell};
fn run_script() -> Result<(), Error> {
let mut shell = Shell::builder()
.arg0(BStr::new(b"myapp"))
.inherit_env()
.build()?;
shell.run(b"greeting=hello")?;
shell.run(b"printf '%s from nsh\\n' \"$greeting\"")?;
Ok(())
}The builder does not inherit the process environment unless
inherit_env() is requested. It also uses a non-privileged host by default,
so operations such as replacing the process or taking control of the terminal
must be granted explicitly. See the runnable
embedding example for captured streams, word
expansion, positional arguments, and a custom Host implementation:
cargo run -p nsh --example embedEmbedding has three process-level constraints:
- The shell may reap any child of the process. Do not concurrently manage other child processes from the same host process.
- Shell jobs use
fork; the usual restrictions on forking a multithreaded process apply. - Dropping a
Shellneither waits for nor kills background jobs.
The public API is documented by the crate and checked with
#![deny(missing_docs)].
Warning
Test cases are hostile input. Never run them outside the repository's containment wrapper. Some cases deliberately exercise process-wide signals and job-control behavior.
Bootstrap the C reference and the generated locales, then run builds and tests
through scripts/sandboxed. The reference bootstrap downloads the pinned
official Dash archive, verifies its SHA-256 and the two documented oracle
patches, and builds it inside the same containment boundary:
./tests/build-reference.sh
export LOCPATH=$(./tests/build-locales.sh)
scripts/sandboxed -- cargo build
scripts/sandboxed -- cargo test --workspace
scripts/sandboxed --writable tests/.build -- tests/harness/runall.sh 12
scripts/sandboxed --writable posix -- \
python3 posix/harness/run.py --shell target/debug/nshThe wrapper requires the sandbox tool and fails closed if containment cannot
be established. Read tests/README.md before adding or
changing differential cases.
Cargo carries that boundary itself: .cargo/config.toml names
scripts/sandboxed --cargo-runner as the target runner, so every binary
cargo test, cargo run and cargo bench execute goes into a PID namespace
whether or not the wrapper was typed in front. There is no spelling of
cargo test that runs a shell case with the session's own process table, and a
sandbox that cannot be established is a failed command rather than an
unsandboxed one. Keep the wrapper on the outside anyway — it is what asks
whether the machine is carrying an abandoned case, a question about the whole
command rather than about each of its sixty-two test binaries, and it is what
contains the commands cargo has nothing to do with. Two boundaries nest without
complaint; the run above is unchanged by the runner except that it costs about
40 ms per test binary.
tests/harness/containment-selftest.sh is the runner's self-test and, like
tests/harness/abandoned-selftest.sh, must not be run through the wrapper: it
asks the host whether a descendant survived, which is what the boundary hides.
LOCPATH is an export rather than a path the tests look up themselves. A
locale is opened by name, and glibc resolves every name under its own locale
directory, so a generated one is reachable only through that variable in the
process environment — which a test cannot set without the ambient mutation
[dec:nsh:no-ambient-state] refuses. Tests that need the generated
en_US.ISO-8859-1 therefore fail, with that command, rather than skipping:
they compare a single-byte charmap against UTF-8, so without it there is
nothing for them to measure and passing would mean nothing.
The pinned survey suites have native runners:
scripts/sandboxed -- target/release/nsh-survey run-smoosh
scripts/sandboxed -- target/release/nsh-survey run-oils \
--group bash-comparison --expect-shell bashOne case runs the same way. The runner selects it by the id the baseline and the gate use, so there is never a reason to start a shell on a case by hand:
scripts/sandboxed -- target/release/nsh-survey run-oils \
--group bash-comparison --expect-shell bash \
--case process-sub.test.sh:2 \
--shell target/release/nsh --verboseA case started by hand has none of this: no PID namespace, no read-only root
and, above all, no budget. On 2026-08-31 four such shells spun at 98% CPU for
forty-seven hours after the worktree, the binary and the case files they came
from had all been deleted. scripts/sandboxed now refuses to run while one
of them is on the machine, because nothing else can see them — inside the
boundary the process table is the sandbox's own. Orphaned is not abandoned:
a process qualifies only once it has outlived the wrapper's own budget
(NSH_TEST_ABANDONED_AFTER, defaulting to --timeout rounded up to a whole
second), so a probe somebody is watching is left alone.
NSH_TEST_ABANDONED=kill clears them and continues; =ignore skips the
check. tests/harness/abandoned-selftest.sh is its self-test.
The wrapper also asks whether the machine has room, because a full filesystem does not report itself as one. It reports, for every link running at that moment:
collect2: fatal error: ld terminated with signal 7 [Bus error], core dumped
PLEASE submit a bug report to https://github.com/llvm/llvm-project/issues/
#4 llvm::StringTableBuilder::write(unsigned char*) const
#7 lld::elf::MergeNoTailSection::writeTo(unsigned char*)
SIGBUS under writeTo is the linker writing into an mmap'd output file it can
no longer extend, and the stack trace and the invitation to file an upstream
bug are both beside the point. The same cause also appears as a rustc ICE
carrying no message at all, and — only sometimes — as cargo's own
No space left on device. On 2026-09-02 all three came out of one afternoon,
and an hour went into the first of them before anybody ran df. Run df
before believing that the compiler or the linker crashed.
The default threshold is 2 GiB free on each filesystem the run may write to —
target/ always, plus anything --writable named, asked once per filesystem
rather than once per path. It is not a claim that 2 GiB is enough to build:
measured here, a from-clean cargo test --workspace costs 2.79 GiB, and up to
1.50 GiB of linker output is in flight at once (32 parallel jobs against a
largest link output of 48.1 MiB). It is the mark below which a failure stops
naming its own cause — the recorded ENOSPC happened at 1.6 GiB free and the
bus error above at 670 MiB. Above the mark a build may still not fit, and
cargo will say so in those words. NSH_TEST_DISK=warn prints the verdict
and runs anyway, =ignore skips the check, and NSH_TEST_DISK_MIN sets the
threshold in mebibytes. tests/harness/disk-selftest.sh is its self-test, and
unlike the other two it may be run through the wrapper.
Cargo carries that question too, and it has to, because the wrapper only
answers it when somebody types the wrapper. The measuring commands in this
file and elsewhere are spelled bare — cargo build --release -p nsh-cli — and
the runner entry cannot stand in, since cargo calls a runner to run a binary,
which is after every link has already happened. So .cargo/config.toml also
names scripts/room-to-build as build.rustc-wrapper, and every crate cargo
compiles is asked whether its output directory has room first. It reads
NSH_TEST_DISK and NSH_TEST_DISK_MIN from the same place the wrapper does,
so the two can never disagree; warn builds here rather than warning, because
the check runs once per crate and a warning would arrive forty-three times for
a build that was going to succeed. An invocation with no --out-dir — cargo's
startup rustc -vV — and a directory it cannot statfs are both let through:
neither is evidence of a full disk, and a build stopped by a broken check is
worse than the failure this exists to name.
It costs nothing to add and nothing to remove: cargo does not put the wrapper
in a unit's fingerprint, so switching it on recompiled nothing. What it costs
per build is 2.29 ms per rustc, and rustc runs once per crate rather than once
per file — measured at load 21-28, 43 invocations and 98 ms against a 20.9 s
from-clean cargo build --release -p nsh-cli, and 150 invocations and 344 ms
against a 33.7 s from-clean cargo test --workspace --no-run whose output is
2.9 GiB. tests/harness/room-to-build-selftest.sh is its self-test; its last
two cases run a bare cargo build against a throwaway package, because
"it reaches a build nobody wrapped" is the claim and nothing else tests it.
A refusal leaves you with a number and nowhere to go, so scripts/disk-headroom
answers the other half: where the space went, and which of it is safe to take
back.
scripts/disk-headroom # report
scripts/disk-headroom --reclaim # remove what it has shown to be safeIt looks only at checkouts git worktree list names — other projects live on
this machine and their build output is not ours — and it offers a target/
only when the worktree is clean and nothing is building into it. "Building into
it" is two tests: a build tool whose command line names the directory
(rustc, cargo, ld, cc and friends — naming the path is not using it,
or the session that typed the question would keep hiding the answer), and a
recent write inside it. The window is as long as a wrong answer is expensive:
an hour for a worktree's whole target/, whose loss is a full rebuild, and ten
minutes for an incremental cache, whose loss is only some incrementality —
NSH_DISK_RECENT_MINUTES and NSH_DISK_CACHE_RECENT_MINUTES set them. The
shared checkout's target/ is reported but never offered whole: only its
caches are pure cache, and the rest is a rebuild somebody else would pay for.
Budget for this. The practice that makes measurements here trustworthy — a
detached worktree with its own CARGO_TARGET_DIR, because the shared checkout
is transiently unbuildable from another session's in-flight files — is also
what fills the disk, at up to 2.79 GiB a time with six to eight worktrees live
at once. Remove a worktree when its node is done, and keep its build tree out
of /tmp — for the reason below, which is the containment boundary and not the
cost of the space. tests/harness/disk-headroom-selftest.sh is its self-test;
every case in it is about something the tool must refuse to delete.
This paragraph used to add that a checkout under /tmp spends RAM rather than
disk, "which disk-headroom says out loud". Both halves were false when
measured on 2026-09-05, and the second is the one worth keeping a note about.
/tmp here is /dev/sda2[/data/tmp] xfs, a bind on the same device as the
checkout, so a tree there costs the same disk as everywhere else. And
disk-headroom never said it: it reads stat -f -c %T for each checkout and
prints that space is RAM only where that answers tmpfs, so on this host it
prints nothing — the README was the only thing still asserting it, and was
citing a tool that measures as though it agreed. Which is the general point: a
host fact does not belong in prose next to a script that can measure it, because
the prose cannot notice when the host changes. scripts/sandboxed carries the
same correction, dated, from 69165cc.
Two ways that practice used to come back red for reasons that were not the
change are now the harness's problem rather than the reader's. The pinned
Bash is a build artefact, so a fresh worktree has none of its own. The
differential tests and the survey gate ask git which checkout the repository
shares — git rev-parse --git-common-dir — and use the reference built there,
after the worktree's own target/ and after NSH_FUZZ_BASH, which still names
one outright and is still the way to point a run at a particular build. When no
checkout has one the run fails and names every place it looked, because "could
not measure" is a result and never a pass. And CARGO_TARGET_DIR may not be
under /tmp: the boundary replaces /tmp with an empty tmpfs and binds back
only the directory holding the program cargo handed it, so
<profile>/deps/<test binary> runs while the CARGO_BIN_EXE_nsh beside it
does not exist — No such file or directory (os error 2) for a binary that is
present, executable and runs from a prompt. scripts/sandboxed now refuses
that layout by name instead; tests/harness/tmp-build-tree-selftest.sh is its
self-test.
The budget itself is spent inside the boundary: timeout stands in front of
the command within the namespace, with a wider one outside as a backstop for
a sandbox that never got as far as running anything. Stopping a command by
signalling its sandbox from outside only works once the sandbox has finished
setting up, and --timeout/NSH_TEST_TIMEOUT are exactly how a focused run
asks for a budget short enough to land in that window — measured against the
old shape at load 21, a five-millisecond budget left a descendant running 17
times in 20. --timeout therefore takes a fractional number of seconds, so a
focused run can ask for a case-sized budget without also giving the sandbox
that long to start. Zero keeps GNU timeout's own meaning — no limit, which
is what fuzz/run.sh passes to run until interrupted — and no longer drags
the abandoned-process threshold down with it.
tests/harness/budget-selftest.sh is the self-test for that.
The abandoned-process and budget self-tests may not be run through the wrapper: both ask the host what survived a finished command, which is what the boundary hides. The free-space, build-tree and room-to-build ones ask nothing the boundary hides, and run either way.
crates/nsh/ shell library
crates/nsh-cli/ command-line frontend
crates/nsh-platform/ safe wrappers around syscalls, locale, and terminal APIs
crates/nsh-survey/ survey import and execution tools
posix/ POSIX.1-2024 rule corpus and conformance harness
tests/ differential tests, reference lock, and survey suites
docs/ API design, specifications, and divergence records
plan/ nplan work breakdown and decision records
scripts/sandboxed test containment wrapper
scripts/disk-headroom where the disk went and what is safe to reclaim
The C oracle is fetched at its pinned upstream tag into ignored test build
state and receives two small, hash-locked compatibility patches; the full C
tree is not vendored in the repository. Rust-specific behavior and API
contracts live under docs/spec/nsh/; current project state is kept under
plan/.
Licensed under the BSD 3-Clause License.
nsh began as a Rust port of
dash. The inherited dash
work is by Herbert Xu and Christos Zoulas and derives from software Kenneth
Almquist contributed to the University of California, Berkeley. The complete
copyright and attribution notices are in LICENSE.
Vendored POSIX text and survey corpora retain their respective licenses.