Proof tokens — the newtype pattern
A clinker pipeline config is full of file paths: sources to read, outputs to write. A hostile
or sloppy config could name ../../etc/passwd, or a path with a \0 byte, or a symlink that
escapes its sandbox. Somewhere there must be a validator. The real question is harder: how does
the engine guarantee that every path reaching the file loader went through that validator,
with no chance some code path forgot to call it? Clinker’s answer turns “was this screened?”
from a convention into a fact the compiler checks. The Rust tool underneath is the
newtype, the smallest possible struct. It’s the same one-field-bundle shape you met building
provenance records, but with its inner field kept private so the
constructor is the only door in.
What you’ll be able to do
Section titled “What you’ll be able to do”- Read the
ValidatedPathnewtype and name the two ingredients (a private inner field and a single constructor) that make it a proof token. - Trace the chain
SourceDb::loadneedsValidatedPath→ onlyvalidate_pathmakes one → so every loaded path was screened, and explain why a missing check becomes a compile error rather than a runtime hole. - Write a small newtype whose privacy you can test: prove the forged constructor is rejected and the validated one is accepted.
- Distinguish “parse, don’t validate” (parse once into a stricter type) from “validate and pass the loose type onward”, and say which one carries proof in the type.
New terms in this lesson (each is also expanded inline at the point you first need it):
- newtype: a one-field struct that gives an existing type a new, distinct identity.
- proof token: a value you can only hold once a check has passed, so the type itself is the proof.
- type-state: encoding a state (screened / unscreened) into the type so the compiler tracks it.
- “parse, don’t validate”: parse a loose value once into a stricter type that can only exist when the check passed, instead of re-checking a loose type everywhere. Pre-taught where it appears below.
Predict first
Section titled “Predict first”A newtype is a struct around one value
Section titled “A newtype is a struct around one value”A newtype is a struct that wraps a single existing type to give it a new identity:
clinker-plan ·security.rs ·ValidatedPath type @19acdcb4
#[derive(Debug, Clone, PartialEq, Eq, Hash)]pub struct ValidatedPath(PathBuf);A ValidatedPath is a PathBuf, but the type system treats it as distinct: you cannot
pass a raw PathBuf where a ValidatedPath is wanted. The crucial detail is invisible at
first glance: the inner field has no pub. It is private to the module. So nothing
outside security.rs can write ValidatedPath(some_path) to conjure one. The only doorway in
is the module’s constructor.
The single doorway: validate_path
Section titled “The single doorway: validate_path”There is exactly one public function that returns a ValidatedPath, and it does the screening
before handing one back:
clinker-plan ·security.rs ·validate_path fn @19acdcb4
pub fn validate_path( raw: &Path, base_dir: &Path, allow_absolute: bool,) -> Result<ValidatedPath, Diagnostic> { // rejects: null bytes; URL-encoded "%2e%2e" traversal; ".." components; // absolute / drive-anchored paths (unless allowed); then canonicalizes and // rejects anything that escapes base_dir via a symlink. // ... Ok(ValidatedPath(resolved)) // the ONE place a ValidatedPath is born}Read what that buys you. Because the field is private, the only way to hold a
ValidatedPath is to have called validate_path and gotten Ok. So the type itself is a
proof token: possessing one is compile-time evidence that the path was canonicalized,
scoped to its base directory, and screened for traversal and null-byte attacks. The module doc
names the pattern outright:
//! The only way to obtain a `ValidatedPath` is by calling `validate_path`.//! The newtype's inner field is private, so downstream code that consumes a//! `ValidatedPath` has compile-time proof that the path has been canonicalized,//! scoped to its base directory, and screened for directory-traversal and//! null-byte attacks.//! This is the "token of proof" pattern.The consumer demands the token
Section titled “The consumer demands the token”The payoff is at the file loader. SourceDb::load, the function that actually opens a file,
does not take a PathBuf. It takes a ValidatedPath, by value:
clinker-plan ·span.rs ·SourceDb type @19acdcb4
pub fn load(&mut self, path: ValidatedPath) -> std::io::Result<FileId> { // by the time we're here, the path is PROVEN screened — its type says so}Now trace the consequence. To call load, you need a ValidatedPath. To get a
ValidatedPath, you must call validate_path. There is no other path through the type system.
A programmer who forgets to validate doesn’t get a subtle runtime hole. They get a compile
error, because they’re holding a PathBuf where a ValidatedPath is required. The class of
bug “we loaded a file without screening its path” has been designed out of existence.
This is the essence of “parse, don’t validate”: instead of checking a value and then
passing the same loose type onward (hoping every later reader re-checks or trusts it), you
parse it once into a more specific type that can only exist when the check passed. The
proof rides along in the type. Put another way, ValidatedPath is a
type-state:
the screened state of a path is a different type from a raw, unscreened one, so the
compiler tracks “has this been checked?” for you, with no flag to read at runtime.
Worked → completion → faded
Section titled “Worked → completion → faded”The module-privacy mechanism is small enough to hold in your hand. We build it up one rung at a time: read a complete worked version, fill a single gap, then write the whole thing.
Worked: forge one, and watch the compiler refuse
Section titled “Worked: forge one, and watch the compiler refuse”Here is the full pattern in miniature: a ValidatedPath newtype with a private inner field,
its one constructor, and a load that demands the token. Run it, then uncomment the forged
line and run again. The compiler will refuse.
> output appears here — press Run
The first path loads, the second is rejected by the validator. And the commented forgery
will not compile if you uncomment it, because the field is private to the security
module. That refusal is the whole guarantee. In real clinker the same refusal is locked in by
a compile_fail doctest that tries the forgery on purpose and asserts the compiler rejects it.
Completion: write the screening check
Section titled “Completion: write the screening check”Same shape, one gap. The newtype, its privacy, the constructor’s signature, and load are
all in place, but validate_path currently lets everything through. Complete the one
guard so that a path containing .. is rejected with an Err, and only a clean path becomes
a ValidatedPath. One new idea on this rung: the check lives inside the only doorway, so
every token that exists has passed it.
> output appears here — press Run
💡 Hint 1
if. str::contains answers “does this string contain that substring?”; return early with Err(...) when it does, before the Ok(...) line ever runs.Show solution
pub fn validate_path(raw: &str) -> Result<ValidatedPath, String> { if raw.contains("..") { return Err(format!("rejected traversal: {raw}")); } Ok(ValidatedPath(raw.to_string()))}Because this is the only function that can build a ValidatedPath (the field is private),
putting the check here means no ValidatedPath can exist without having passed it. That
is the proof-token guarantee in one early-return.
Faded: build the whole token from a skeleton
Section titled “Faded: build the whole token from a skeleton”Now write it yourself. A Port is a u16 that must be in the range 1024..=65535 (the
non-privileged ports). Make a ValidPort newtype that can only hold a screened port, with a
single constructor parse_port that rejects out-of-range values, and a bind function that
demands the token. The skeleton names the pieces; you supply the bodies, and the privacy.
> output appears here — press Run
Show solution
mod net { #[derive(Debug)] pub struct ValidPort(u16); // inner field private — the whole mechanism
impl ValidPort { pub fn get(&self) -> u16 { self.0 } }
pub fn parse_port(raw: u16) -> Result<ValidPort, String> { if (1024..=65535).contains(&raw) { Ok(ValidPort(raw)) } else { Err(format!("port {raw} is outside 1024..=65535")) } }}8080 and 65000 bind; 80 is rejected because it is below 1024. The shape is identical
to ValidatedPath: public type, private field, one screening constructor, a consumer
that demands the token. Change the wrapped type and the check, and you have minted a new proof
token. That is the reusable pattern.
Encapsulation is the load-bearing part
Section titled “Encapsulation is the load-bearing part”Strip away the security specifics and the reusable idea is this: a newtype is only a proof
token if its inner value is private. A pub struct ValidatedPath(pub PathBuf) would prove
nothing, since anyone could build one from any path. Privacy is what makes the constructor the sole
gate, and the sole gate is what makes the type mean something. You’ll see the same shape in the
next two lessons: Spanned<T> and
CompiledPlan are both “you can only get one by going
through the right door” types.
// quick check
Why can't a programmer accidentally call SourceDb::load with an unscreened path?
The private field means ValidatedPath can only be born inside validate_path. Since load demands that type, code holding a raw PathBuf will not compile. The screening is enforced by the type system, not by a runtime check or convention.
Retrieval checkpoint
Section titled “Retrieval checkpoint”Connections
Section titled “Connections”A private field turned a security check into a type. Next, Span-preserving parse, we’ll see the parser side of the same philosophy: how clinker funnels all YAML through one chokepoint and keeps the source location of every value, so a typo in a config points at the exact line that’s wrong.