ADR-010: Input Provenance — Trust Is a Property of Sources

Status: Accepted Resolves: the default hasher, left open by ADR-296 Related: ADR-283 (the edges trust travels along), ADR-005 D3 and D8 (the ledger and its determinism), ADR-017 (the second consumer, decided separately per D8), ADR-316 (the function within a class), ADR-037 D7-D8 (the same question for a reference count)

1. Context

The threat is hash flooding: an adversary who chooses keys can drive an open-addressed table into quadratic behaviour.

The axis is provenance — where the keys came from — and not how the program is built. A single-threaded server hashing attacker-supplied header names is exactly as vulnerable as a parallel one, and a compute job over an operator-chosen file has no adversary in either. Provenance is a property of the input, known where the input enters the program, and it is the user’s call at that point rather than a global default the language picks for them.

Rust’s standard map hashes everything with a DoS-resistant function because Rust has no whole-program provenance information; with nothing to distinguish the two cases, assuming the worst everywhere is the only safe global default, and every compute workload pays for it. Nikaia already ties every view to the buffer it points into (ADR-283) and already solves whole-program contracts, so it can be exact instead of pessimistic — and pay for hardening only where an adversary exists.

2. Decisions

D1 — Trust is a two-state lattice over sources.

Trusted ⊑ Untrusted. Every buffer entering the program carries one state, derived values inherit it, and a container’s state is the join of what goes into it — so a map that receives one untrusted key is untrusted. Which maps count as one container is D9’s.

This rides machinery that exists. The trust bit travels the same edges ADR-283 already records, lands in the same ledger, and is solved by the same monotone fixpoint, so ADR-005 D8’s determinism, ban list and CI cover it unchanged. A two-element lattice adds no solver and no new pass.

Barriers widen to Untrusted — a dyn boundary, an FFI value, a Rust dependency’s return. This is the opposite direction from ADR-283 D5’s representation barrier, deliberately: there the conservative answer is the wider representation, here it is the safer one. An analysis that fails open is a vulnerability generator.

D2 — The standard library classifies its own sources.

Users do not classify std. The library states, per source, who supplied the bytes:

Source Default Why
http request line, headers, query, body; net sockets; IPC; WASM host imports Untrusted a remote peer chose these bytes
cli::args, env, compile-time data Trusted the operator chose these bytes
standard input, fs reads and maps Untrusted (Changed by ADR-365 D1) a path or a pipe does not say who wrote the bytes
db result rows Untrusted second-order: a row may hold what a user stored earlier

The database row is why “trusted = local” would be too crude: stored data is attacker-chosen data with a delay. Any source whose content can be influenced by someone other than the operator is untrusted, whatever transport it arrives on.

D3 — The user states the requirement at the source, in both directions.

// A benchmark reading a file it wrote itself (ADR-365 D2).
let data = fs::map(path; trusted: true)

// A benchmark harness reading a fixture it wrote itself.
let body = socket.read_all(; trusted: true)

The default is a default, not a verdict, and the decision belongs where the data enters. Both forms use the existing named-parameter convention and both are recorded in the ledger with their source location — which makes the security review of a Nikaia program a grep over one generated file, diffable across commits, rather than an audit of every call site.

The same marker is available on a function parameter, so a library can state what it assumes rather than inheriting the join of its callers.

D4 — A grammar may declare a floor.

@untrusted
grammar HttpHeaders { … }

A grammar is the specification of an input format, and its author often knows the threat model better than the person applying it. Effective trust is join(grammar floor, buffer trust), so a grammar marked @untrusted cannot be used in trusting mode by accident, whatever buffer it is pointed at.

There is deliberately no @trusted floor: a format cannot make its input safe.

D5 — What the compiler does with it: choose the hasher, and nothing else.

Effective trust Hash
Trusted fast non-cryptographic hash, fixed seed
Untrusted keyed hash, per-process random seed

Everything else about the map is identical: same table, same absence of per-entry allocation, same API, and the same full-content equality (ADR-283 D11, ADR-296 D14). Trust never weakens a correctness property; it selects the class of hash function.

Within a class, the function follows the keys’ shape, and a program may name its own hasher where it makes the map. Changed by ADR-316 D1 and D4.

How the program is built enters only as an implementation detail of how, never as the thing that decides whether. When and for which maps together the choice is made is D9’s.

The random seed must be real. On a target with no entropy source the build fails rather than falling back to a fixed seed: a keyed hash with a predictable key is the vulnerability wearing the mitigation’s clothes.

D6 — Map iteration order is unspecified and may vary between runs.

Randomised seeding means an untrusted map iterates differently from run to run. Stated in the spec rather than discovered; a program that needs an order asks for one. The compiler’s own ledger paths are already forbidden from iterating hash order at all (ADR-005 D8), so the rule the language enforces on itself is the rule it teaches.

D7 — The choice is visible, never a mystery.

--trust (on build, run, test and lower, ADR-260 D3) prints the provenance of every buffer and the hasher chosen for every map. Where a hardened map sits in a hot loop, a lint names the source that made it untrusted — so “why is this slower than I expected” is one command, and the fix is in the message.

D8 — Build the mechanism; decide only the hasher here.

Provenance is a taint lattice and hashing is its consumer. It is built as a mechanism rather than a hasher special case so that a further consumer — SQL parameterisation, log injection — can be decided on its own. A path is not one: where a name reaches the filesystem it names its root at the call (ADR-108), and no provenance travels a path.

One caution belongs on the record now, and is settled by ADR-017: escaping at a template hole must stay unconditional, defence in depth. A provenance analysis that silently skips escaping on “trusted” data turns one wrong trusted: true into an XSS hole. Provenance may add evidence and lints there; it may not switch escaping off.

D9 — A map’s hasher is decided once the whole package is read, for every map that must be one type; at the package’s boundary it is a type parameter each use fills in.

Added October 11, 2026 (#581).

// stock.nika
pub struct Tally { pub counts: collections::HashMap[String, i64] }

pub fn count_in(mut t: Tally, word: ref String) {
    t.counts[word.clone()] = 1
}

// main.nika, the same package
let mut t = Tally { counts: collections::HashMap() }
count_in(t, line)        // `line` from a file: fast; from a socket: keyed

D5’s choice is a choice of type in the code the compiler writes: the fast map and the keyed map are two types there, and every place that names a map has to name the same one as the value it receives. So the choice is made for classes and at one time:

  1. Positions and classes. Every place a map type is written is a position: a field, an enum’s payload, a parameter, a result, a let, a map inside another type (Vec[HashMap[K, V]], HashMap[K, V]?). Positions a map value passes between - initialised from, assigned, passed, returned, stored, compared with ==, the arms of one if or match - are one class, and a class is one type.
  2. One time: after the package. A class is keyed where any key that reaches any value of it is untrusted, or where it holds a position D1 calls a barrier; otherwise it is fast. The answer is taken after every file of the package has been read and every call in it resolved against all of them. It is a union of positions and a join over a two-element lattice, so neither the order the files are read in nor which file builds or fills a map can change it (ADR-005 D8). Inside the package, a position of a declaration that is not pub has its class’s type, the same at every use.
  3. At the boundary, a type parameter. A map position in a pub type is a hidden type parameter of that type, one per position, in the order the declaration writes them. A map position in the signature of a pub function or method is one of that item’s, one per group of positions its body makes one map (a map assigned to another, compared with ==, returned from a parameter): a body that joins none gives each position a parameter of its own. Each use fills them, in the package and outside it, as a use of a generic fills its type: from the class the value it hands in or takes out belongs to. A map the item’s own body makes at such a position is made with the hasher the parameter holds.
  4. The ledger says what the body puts in. A pub function’s entry gets maps, one line for each group of positions: the positions it joins (t.counts is a map field of a parameter, <result> the result) and what its body adds to them: from(p, ...) keys as trusted as the arguments named, from() none (nothing that is not the caller’s choice), keyed keys it does not trust, or a barrier. A caller joins the line into its own classes. A position no line covers is keyed. The key is read as a permission, as sync is: a wrong from(..) would let a caller choose the fast hash for keys an adversary picked, so only the compiler writes it, and only where the body is read to its end: a map handed to a call, kept in a local, matched or captured makes every line of the function keyed, and then the positions of one written type are one group.
  5. Barriers fix a position keyed: a method of a trait (its implementations may sit in packages this build never reads, and a call through dyn has one compiled body), a described Rust crate’s signature (its type is the one the crate wrote, and std’s map is keyed), and a binding for another language (ADR-284 D26). A class holding one of them is keyed. A pub function or method that hands a map it was given to a barrier, or to a private function that has a map of its own in its signature, has that position fixed keyed: a hidden parameter cannot be shown to be std’s hasher or the class of a private declaration. A method that hands a map of self on does so for its type: the impl’s parameters are the type’s, so the type declares none and its maps are the keyed map, and a program that builds it is no more trusted than that. The type a grammar rule returns, and the accumulator of its fold, are written with the hasher the package fills every other mention with.
  6. The names Rust code sees are nikaia_std::hash::Fast and nikaia_std::hash::Keyed. A pub type’s map parameters default to Keyed, so Rust code that names Tally gets the map that is safe for any key, and names Tally<Fast> where it vouches for its keys, as any Rust code that picks a hasher does.
  7. A conflict is not possible by join - keyed wins, which is D1’s safe direction - and none is resolved by a conversion: a copy into a new map makes no key more trusted. A class can only be fixed to two things by a hasher the program names (ADR-316 D4): two different ones in one class, or one in a class a barrier fixes to another, is refused, NK1246.
  8. --trust reports classes (D7): for each, the positions in it, the type it got, and the key or barrier that made it keyed.

Why this shape.

Rejected:

D10 — A map kept in order asks its key for an order, and the checker refuses a key that has none.

BTreeMap and BTreeSet keep their keys sorted, so the key type needs Rust’s Ord. The checker answers for it as it does for == (Part III NK1188), walked structurally: the language’s own types from a list, a float and a HashMap/HashSet as no, a declared struct or enum as no unless the program writes impl Ord for it (ADR-341; it derives equality and copy, not an order), and a type whose parts are Rust from the ledger’s orders column, whose absence is no. The refusal is NK1247, at every position a program writes a type, and it names a HashMap, or a key by a text or a number.

A library type that is Ord says so in its ledger (winnow_grammar::Symbol does: its order is the order of interning, not the text’s, so a map keyed by it must not be iterated into output). nikaia describe writes the column where the crate derives Ord.

Rejected: deriving Ord for a declared type whose parts are ordered. It would answer the question for programs, but field order would then be a promise, and no program has asked for it. Rejected: leaving the error to rustc: an error about generated code reaching the user is a compiler defect (Part III C.1).

Changed by ADR-341 D6: a declared type with an impl Ord is a key.

D11 — A hash map hashes a declared type from its fields, and the checker refuses a key that does not hash.

HashMap and HashSet find a key by its hash, so the key type needs Rust’s Hash and an equality that is an equivalence. The checker answers for it by the same structural walk as == (Part III NK1188): the language’s own types from a list (a number but no float, String, bool, char, Bytes, and a tuple, list or option of such); a float, a map and a set as no; a declared struct or enum as yes where every part hashes; a type whose parts are Rust from the ledger’s hashes column, whose absence is no. For a declared type that hashes, the hash is derived from its fields, every field in the order written, as equality is (ADR-204), so two equal values always hash alike. A declared type with a hand-written impl Eq (ADR-341 D5) does not hash unless it also has an impl Hash (D12): its equality is no longer the fields’, and a hash from the fields would disagree with it. The refusal is NK1260, at every position a program writes a type; it names the part that does not hash, and for an impl Eq the hand-written equality and the impl Hash that would answer it.

A text view hashes its text, not where it points. The hash function is the one D1-D4 choose for the map’s keys; a derived hash feeds the fields to it and does not depend on which one it is. nikaia describe writes hashes where the crate derives Hash.

Rejected: refusing every declared type as a key. A record of a few fields is the ordinary key of a map in every language that has one, and the language derives what the type’s fields already say. Rejected: a hash derived from the fields beside a hand-written equality: two values the impl Eq calls equal may differ in a field, hash apart, and the map would not find a key it holds. Rejected: leaving the error to rustc (Part III C.1). Unlike an order (D10), a hash makes no promise about the fields’ order: it is never shown and never compared.

D12 — impl Hash gives a declared type its hash by hand.

trait Hash { fn hash(ref self, mut into: Hasher) sync }

Hash is a trait of the language, in scope everywhere, as Eq and Ord are (ADR-341 D1). Hasher is the language’s too: its one method, add, takes a value that hashes by D11 and feeds it to the map’s hash function, whichever D1-D5 chose. Hash lowers to Rust’s Hash (into.add(x) is x.hash(state)), so a type with an impl Hash is a hash key to Rust code as well.

struct Name { text: String }

impl Eq for Name {
    fn eq(ref self, other: ref Self) -> bool sync {
        return self.text.to_lowercase() == other.text.to_lowercase()
    }
}

impl Hash for Name {
    fn hash(ref self, mut into: Hasher) sync {
        into.add(self.text.to_lowercase())
    }
}

A type with an impl Hash hashes, whether its equality is derived or hand-written; the impl replaces the hash D11 would have derived. It is written for a type the program declares and that is Eq (by the walk or by an impl Eq); any other is NK1249, the rule of ADR-341 D5. A body that can pause is NK1129. A value handed to add that does not hash is NK1260.

The law is the program’s: two values eq calls equal feed add the same values. The compiler does not check it, as it does not check Eq’s laws (ADR-341 D5). Beside a derived equality the law holds by itself, since the body reads only the value’s fields.

Why. A hand-written equality says that values whose fields differ are the same; only the program knows which part of a value that sameness reads, so only the program can write a hash that agrees with it. Refusing such a type as a key would make it the one kind of value a map cannot hold. add and not a number the body computes: the hash function stays the map’s (D5), and a program that writes a hash cannot weaken a hardened map.

Rejected: a body that returns a number (Java’s hashCode, Python’s __hash__): it would choose the hash function and bypass D5. Rejected: deriving Hash from the fields where an impl Eq exists (D11). Not decided here: Hash as a bound ([T: Hash]), which stays ADR-341 §6’s.

3. What this makes of two programs

A program that maps a file named on the command line gets a fast hash: the benchmark does not pay for a threat it does not have, and nobody wrote a line about hashing. Deployed to crunch uploads from strangers, the change is one named argument, and the ledger records it.

A server hashing query parameters and header names off a socket is Untrusted by default, hardened without anyone remembering to ask, and the DSL for the wire format can pin it so no later use can lower it.

Two shapes of program get different code from the same source text, and the difference is derived from where the bytes came from — not from a build switch, and not from a global default that is wrong for one of them.

4. Rejected alternatives

5. Risks

This site is open source. Improve this page.