Skip to content

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.

  • Read the ValidatedPath newtype and name the two ingredients (a private inner field and a single constructor) that make it a proof token.
  • Trace the chain SourceDb::load needs ValidatedPath → only validate_path makes 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.

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.

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 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
crates/clinker-plan/src/span.rs
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.

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.

rust // editable

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.

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.

rust // editable
💡 Hint 1
The guard is a single 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.

rust // editable
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.

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?

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.