I started reading the Rust Book to understand where the language comes from, and ended up doing something else: stopping at every command before running it. Chapter 1 is short and the book clearly expects you to breeze through it. I didn't — and what was hiding in there had less to do with Rust than with how a program reaches your machine at all.
The command I had never read
Installing is a single line:
curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh
curl is an HTTP client for the terminal. It does what a browser does when you
open a URL: asks for a resource and receives bytes. The difference is that by
default it dumps those bytes on screen rather than saving them to a file.
That already answered something I had never stopped to think about: an HTTP endpoint neither knows nor cares who is on the other end. Whether the bytes it returns are HTML, JSON or a shell script is a convention, not a rule.
The flags are all care about the channel: --proto '=https' refuses anything
that isn't HTTPS, and --tlsv1.2 sets the minimum encryption version accepted.
The -sSf silences the progress bar while keeping errors visible, and makes the
command fail on an HTTP error — without it, a 404 page would be treated as if it
were the script.
The | is the part that matters. It's a pipe: it wires curl's output straight
into sh's input. Which means the script never touches disk. The shell reads
the text as it arrives over the network and executes it as it goes.
That's why the pattern is contentious. You are running code you haven't read, from a server, with your own user permissions.
So I downloaded it without running it, just to see what was in there:
curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf -o /tmp/rustup.sh
wc -l /tmp/rustup.sh
910 lines. And the surprise is that none of them install Rust.
The file is a dispatcher. Pure shell, portable, nothing compiled inside it. The first thing it does is ask the machine who it is:
uname -s # operating system
uname -m # processor architecture
It keeps both answers, and further down it joins them into a single string:
_arch="${_cputype}-${_ostype}" # line 576
_url="${_url}/${_arch}/rustup-init${_ext}" # line 104
On my machine that gives x86_64-unknown-linux-gnu. That string is what becomes
the URL of the right binary.
The reason is something I knew from C but had never watched happen in front of
me: a compiled binary is machine code, specific to a processor and to an
executable format. A Windows .exe won't run on Linux, and an ARM binary won't
run on x86. Since there are dozens of possible combinations, the only way to fit
this into one command is to send a light script first — one that works out the
target and fetches just the right piece.
One detail caught me: it doesn't look at the distro. There is no "build for
Arch" and another "for Ubuntu". What matters is the system, the processor and
libc — the system's C library, gnu or musl. The distro is irrelevant to the
binary.
Where Rust actually went
Once the install finished, the book says to run rustc --version and move on. I
went to look at the folder first:
ls ~/.cargo/bin
cargo -> rustup rust-analyzer -> rustup
cargo-clippy -> rustup rust-gdb -> rustup
cargo-fmt -> rustup rustc -> rustup
clippy-driver -> rustup rustdoc -> rustup
rls -> rustup rustfmt -> rustup
They are all symlinks — shortcuts holding nothing, just pointing at another file. And they all point at the same place.
So the rustc on my PATH is not the compiler. It's rustup in costume. That
has a name: a shim, a middleman that takes the call and passes it on.
The real compiler lives somewhere else:
ls ~/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin/
This is where the confusion I had been carrying came apart. I thought rustup
was Rust, because everything sat inside its folder. It isn't. rustup is the
storekeeper: it compiles nothing, it keeps the tools and hands you the right one
when you ask. ~/.rustup is just the cupboard.
The three names, with no overlap:
rustc— the compiler. Turns.rsinto a binary.cargo— build system and package manager. Callsrustcfor you and handles dependencies.rustup— version manager. Installs toolchains and decides which one is active. It's the counterpart ofnvm, not ofnpm.
Which raises the obvious question: why the shortcut theatre? If the compiler sits in a folder, why not put that folder on PATH and be done?
Because several toolchains can coexist — stable, nightly, beta, a pinned
version like 1.75.0 — each with its own rustc. If the toolchain folder were on
PATH directly, switching versions would mean rewriting PATH every time, and you
couldn't have one project on stable and another on nightly at once.
The shim solves it by deciding at call time. It checks whether there's a
rust-toolchain.toml in the folder, whether an override is configured for that
directory, whether the command came in as cargo +nightly. Only then does it
pick which rustc to run. Same trick as nvm, except per directory and with no
nvm use to remember.
From text to binary
The book's rustc main.rs produces a file called main. What I wanted to know
was what exactly it had done.
The starting point is that the processor doesn't understand text. It understands
numbers, and each number is an instruction: add this, jump there, write here.
That's all it can read. My main.rs is 45 bytes of letters that I understand and
it doesn't — fn and println! mean nothing to silicon.
Compiling is translating. You can see both sides:
cat main.rs # readable, it's my code
head -c 200 main # ELF, and then what looks like garbage
It isn't garbage. Those are the numbers the processor understands, with the
terminal trying to render each byte as if it were a letter. The ELF at the
front is the label of Linux's executable format — it's how the system recognises
the file later.
The translation doesn't happen in one go. rustc runs the code down a line:
- Read the text. Break it into meaningful pieces — this is a function, this is a call, this is a string — and build a tree in memory. A missing brace dies here.
- Check that it makes sense. Do the types line up? Does the variable exist? Who owns each value? This is where the borrow checker lives, and this stage is what makes Rust, Rust.
- Simplify and optimise. Rewrite the code into a rawer internal form and cut the waste.
- Emit machine code. The numbers.
- Link it together. My code alone doesn't run: it calls
println!, which lives in the standard library. The linker glues the pieces into a single executable — which is why the book warns you need a linker installed.
You can watch stage 2 on its own by declaring an integer and stuffing a string into it: the compiler understands the code perfectly and refuses the meaning.
After stage 5, rustc finishes and disappears. Compiling and running are two
separate acts, and the second isn't its job. When I type ./main, the operating
system is what acts: it reads the file, sees the ELF label, loads the numbers
into memory and points the processor at the entry point.
The practical consequence is that I can delete main.rs and ./main still
runs. And I can hand that binary to someone with no Rust installed.
One silly question kept bothering me: why did 45 bytes of text become a 4.4 MB
file? There are two reasons, and you can measure them apart by running strip
over the binary, which removes debug information.
The first is that it isn't only my code in there. At stage 5 the linker glued the
standard library in — println! isn't mine, it's ready-made code that now lives
inside my executable. That accounts for some 340 KB.
The rest, nearly the whole size, is a map: an index tying each stretch of machine
code back to the .rs file, with function names and line numbers. It's what lets
a crashing program say "line 3 of main.rs" instead of spitting out a memory
address.
Cargo and the three files
rustc by hand only gets you as far as one lone file. From there the book moves
to cargo, and this is the part I already recognised from elsewhere.
The problem a package manager solves is always the same: nobody writes everything from scratch, and the moment you depend on someone else's code four pains show up — where do I find it, how do I download it, which version, and what happens when that library depends on three others. In Rust a published library is called a crate, and they live on crates.io.
The parallel is direct: cargo is to npm what a crate is to a package and
crates.io to the registry. The difference is that cargo does more — it is also
the build system, the thing that calls rustc for you. In the JS world that
would be npm and vite in one tool.
A new project starts with a Cargo.toml:
[package]
name = "hello_cargo"
version = "0.1.0"
edition = "2024"
[dependencies]
TOML is a configuration format, like JSON or YAML but meant for a human to read:
[something] opens a section and inside it come key = value lines. The
edition is Rust's vintage — the language changes dialect every few years
without breaking old code.
The central point is that this file is written by me. It's a statement of
intent, and cargo never invents anything in there on its own. Running
cargo add rand, the empty section gains a line:
[dependencies]
rand = "0.10.2"
It looks like an exact version, but it isn't. It's a range: 0.10.2 or any later
compatible version — 0.10.7 is fine, 0.11 is not. Cargo.toml is deliberately
vague: it says what I accept, not what I have.
And a file I never asked for appeared, Cargo.lock. I counted the packages it
lists:
grep -c '^\[\[package\]\]' Cargo.lock
Nine. I had asked for one. rand depends on other crates, which depend on more,
and cargo followed the chain to the end and wrote them all down — that's a
transitive dependency.
The difference between the two files is exactly that: the .toml is what I
want, short and vague; the .lock is what I have, long and exact, with the
precise version of each of the nine plus a checksum for each. Without the
.lock I'd compile today against 0.10.2 and someone else would compile tomorrow
against 0.10.3 — and if 0.10.3 carries a bug, the program breaks on their
machine and not on mine. It only changes when I say so, with cargo update.
After the first cargo build the third piece appears, target/. It's 27 MB for
a program that prints one sentence, and that adds up: inside are the nine crates
compiled one by one, my binary, and cache so the next build doesn't redo
everything. What matters is that it's disposable — nothing of unique value lives
there, and deleting it only costs time. The .gitignore that cargo generates
has a single line, and it's that folder.
Closing the three out: Cargo.toml is what I want and I write it; Cargo.lock
is what I have and cargo writes it; both go to Git. target/ is build output
and stays out, because it can be regenerated from the other two.
That also cleared up what "build" means, which is broader than compiling. When I
run cargo build, it reads the .toml and the .lock to know what's needed,
downloads what's missing into the global cache at ~/.cargo/registry, calls
rustc once for each of the nine crates, calls it again for my code, and links
it all into an executable. cargo is the site manager and rustc is the
bricklayer: that five-stage line runs ten times here, once per crate.
The two build modes were the last thing missing, and that one I preferred to measure. I wrote a program that sums 500 million squares and timed both binaries:
debug 3.65 s
release 1.81 ms
Two thousand times apart. But the conclusion isn't "release is faster" — 500 million iterations in 1.8 ms would be 275 billion laps per second, which my processor cannot do. The loop never ran. The optimiser saw that the result depends on nothing external, worked the value out at compile time, and the release binary essentially just prints a number it already had.
That's what optimising means: the compiler is free to rewrite the code however
it likes, as long as the observable result is the same. The price is compile
time, which is why the two modes exist — debug compiles fast, runs slow and
carries the map that makes error messages decent; release compiles slow and runs
fast. That's why cargo run defaults to debug: day to day I recompile dozens of
times an hour and run once.
Chapter 1 of the book fits in fifteen minutes. It took me an afternoon, and what
stuck wasn't Rust syntax — it was understanding that the install command is a
script that figures out my machine, that the rustc on PATH is a shortcut, that
the binary carries the standard library inside it, and that build and
compilation aren't the same thing. None of that is specific to Rust. It was just
always hidden behind a command I used to copy without reading.
