I was reading a design patterns book. I got to the UML class diagrams β and I stopped.
Because the diagram on the page was describing something Rust already says in its own syntax. A struct
is a class. Option<T>
is a 0..1
association. Vec<T>
is 1..N
. Ownership is composition. The formalism I was looking at, drawn as boxes and diamonds, was the same information the compiler already had β just in a different notation.
Which led to an uncomfortable thought. If the type system already encodes what UML draws, then translating my code into UML shouldn't tell me anything new. It should be redundant. Unless the translation didn't line up β and everywhere it failed to line up, there would be a bug hiding in the gap.
So I tried it. I took Runique β my Rust web framework, v2.1.21 β and I modeled the framework itself, module by module, in UML for structure and Merise for the data. The intuition was mine; I used Claude to help verify each translation against the actual code, line by line. We found twenty-plus latent bugs. Three of them were security-relevant. None of them had shown up in cargo test
, in tracing
, or in the compiler.
This is what the gaps looked like.
Runique is a Django-inspired framework: Axum, SeaORM, Tera, edition 2024. It works. It's powered a real restaurant's booking site and runique.io itself in production for months β that's my dogfooding. But the audit I'm describing was on the framework, not on those sites. And that's exactly the problem β "it works" is the state in which bugs are hardest to see. The compiler is happy. The tests are green. tracing
shows nothing because nothing is throwing.
The tools I reach for every day are all local. cargo check
sees one function. A unit test sees one path. tracing
sees one request. None of them step back and show you the shape of the whole thing at once β and some bugs only exist in the shape. A field that's pub
when it should be pub(crate)
. A file written one step too early in a sequence. A type that diverges from the type it's supposed to match, three files away, behind a feature flag.
I needed an outside view of the code β without leaving the code. Not a rewrite, not a new tool, not a linter. A different representation of the same thing, precise enough that the discrepancies would be mechanical to spot.
Here's the idea the book handed me, made explicit.
Rust's type system, UML class diagrams, and Merise data models are three notations for the same underlying facts: entities, their relationships, their cardinalities, their invariants. The GoF patterns are just the recurring shapes that show up across all three. Rust encodes them in the type system, where the compiler enforces them. UML and Merise encode them in a separate graphical formalism, where a human reads them. The translation between the two is almost mechanical:
| Concept | Rust | UML / Merise |
|---|---|---|
| Entity | struct |
|
| Class / Entity | ||
| Mandatory 1..1 | field by value: T |
|
cardinality 1..1 |
||
| Optional 0..1 | Option<T> |
|
cardinality 0..1 |
||
| 1..N | ||
Vec<T> / HashMap<K,V> |
||
cardinality 1..N |
||
| Composition (owned) | ||
struct A { b: B } or Box<B> |
||
| filled diamond | ||
| Aggregation (shared) | ||
&B / Arc<B> / Rc<B> |
||
| hollow diamond | ||
| Refinement | AST β HIR β MIR | MCD β MLD β MPD |
One caveat, because Rustaceans will (rightly) pounce otherwise: the ownership-to-composition mapping isn't perfect. Box<B>
still owns, so it's composition, not aggregation; aggregation β a shared reference with an independent lifetime β is &B
, Arc<B>
, Rc<B>
. The analogy is a lens, not a law. But it's a sharp enough lens that when the code and the diagram disagree, the disagreement is worth investigating.
That's the whole method in one sentence: model the code in a formalism the type system doesn't natively speak, and audit the places where the two representations refuse to agree.
Merise deserves a note here, because it's the unfamiliar one for most English-speaking readers. It's a French systems-modeling method from the 1970s, still taught in French CS programs today. It splits a data model into conceptual (MCD), logical (MLD), and physical (MPD) levels β and crucially, at the logical level it forces you to write down both the cardinality and the concrete type of every association. That second demand is what caught one of the bugs below.
I didn't spot-check. The point of an audit is coverage, so I went module by module across the whole framework, with three lenses, each matched to what it's good at:
eihwaz_*
framework table, with cardinalities, foreign keys, and physical types.Everything is Mermaid in Markdown, versioned next to the code. No binary tool, no separate app β the diagrams render on GitHub and travel with the repo. Each one ends with an "anomalies" section, consolidated into a single anomalies.md
with severity and a file:line
for every finding.
Now the interesting part.
pub
field that quietly bypassed CSRF I'll open with the most interesting one β not the most severe, the most interesting, because it's the bug that best shows what this whole exercise is really about.
I was drawing the UML class diagram for Prisme
, the type that holds a request's parsed-and-CSRF-checked body. The diagram made me write down, for each field, its visibility. And there it was:
Prisme
+ data: StrMap β public
+ csrf_valid: bool
+ checked_data() -> Option<&StrMap> β fail-closed accessor
data
was pub
. checked_data()
β the accessor that returns the body only if CSRF passed β was right next to it. The moment those two sat in the same box on the diagram, the problem was obvious: why build a fail-closed gate and then leave the raw field public next to it?
The CSRF enforcement in Runique doesn't live in the middleware for HTML forms β it lives at the point of access. req.form()
checks csrf_valid
and refuses to hand you validated data if the token is bad. But if Prisme::data
is pub
, any handler β or, more to the point, any third-party code built on Runique β can read the raw body directly and skip the gate entirely, without ever knowing it did.
I want to be exact about the severity, because the honest version is more interesting than the scary one. My own code was clean: admin, login, and form()
all check CSRF before touching the body. This was never an exploited hole in Runique itself. It was a footgun in the public API β a shape that let someone building on the framework bypass CSRF by accident. That's a framework-author's bug, not a break-in.
The fix is one keyword, and it's the whole point of the article:
pub struct Prisme {
/// Parsed body/query data. **Crate-private**: user code cannot read the raw
/// body without going through the CSRF gate (anomaly C2). External access only
/// via checked_data() (fail-closed) or req.form().
pub(crate) data: StrMap,
pub csrf_valid: bool,
}
impl Prisme {
/// Fail-closed accessor: returns body data only if CSRF is valid.
pub fn checked_data(&self) -> Option<&StrMap> {
if self.csrf_valid { Some(&self.data) } else { None }
}
}
pub
β pub(crate)
. The Rust visibility system now structurally guarantees that the only doors to the request body are the two that check CSRF first. It's not a convention, not a lint, not a code-review rule someone has to remember. The compiler will reject the bypass. Visibility as a security mechanism β that's what the UML diagram surfaced, because a class diagram forces you to write down the one property (pub
vs pub(crate)
) that the code lets you skim past.
The second lens is the one that shows time. Here's the sequence diagram for a multipart form POST, as I drew it from the real pipeline:
sequenceDiagram
participant C as Client
participant MW as csrf_middleware
participant P as prisme_pipeline
participant AG as parse_multipart
participant H as Handler
C->>MW: POST multipart (csrf_token as a field, no header)
Note over MW: mutating method, no X-CSRF-Token,<br/>form content-type β LET THROUGH<br/>("Prisme will validate")
MW->>P: next.run()
P->>AG: parse_multipart(req)
AG->>AG: stream file β tmp (size cap, NO extension check)
AG->>AG: rename tmp β MEDIA_ROOT/uuid.ext (COMMIT)
Note over AG: file committed to its final,<br/>servable location BEFORE CSRF,<br/>validation, and the handler
AG-->>P: final paths
P->>P: check_csrf() β csrf_valid (flag only)
P-->>H: Request
H->>H: if form.is_valid() { ... } else { reject }
Note over H: file already on disk in MEDIA_ROOT,<br/>never removed on rejection
Read top to bottom and the bug reads itself. The file gets renamed into MEDIA_ROOT
β its final, potentially publicly-served location β before CSRF is checked, before the form validates, and before the extension filter (which lives later, in FileField::validate
) ever runs. On any public endpoint that extracts a Request
and accepts multipart, that meant:
MEDIA_ROOT
no matter what happens next..html
, .svg
, or .js
could land in a served directory. If MEDIA_ROOT
is served statically, that's a path to stored XSS.No unit test caught this because no unit test is shaped like the request lifecycle. The class diagram didn't catch it either β every individual method is correct in isolation. Only the temporal view, the one that lays out order, made "written before validated" visible as a single glance.
The fix moves the commit to the end. parse_multipart
now writes to a non-served staging directory, and FileField::finalize
β running after CSRF and validation β is the only thing allowed to commit:
// No commit here: files stay in staging. FileField::finalize (the only committer)
// moves them to their served destination after CSRF + validation. On rejection,
// staging is purged by sweep_stale_staging (best-effort, TTL). Errors are logged,
// never swallowed.
Ok(data)
One committer, one gate, running in the right order β and a TTL sweep for staging that never got promoted, with every failure logged rather than dropped on the floor.
The last one is the bug Merise's "write down the type" discipline caught, and it's a clean demonstration of why a separate formalism earns its keep.
Here's the MLD fragment β the logical model β for the junction table between users and groups:
erDiagram
USER ||--o{ USER_GROUPE : "belongs to"
GROUPE ||--o{ USER_GROUPE : "groups"
USER {
pk id "int OR bigint under big-pk"
}
USER_GROUPE {
pk user_id FK "β users.id"
int groupe_id FK
}
Merise made me write two things next to user_id
: its cardinality (a mandatory FK into users.id
) and its physical type. And users.id
has a footnote β it's integer
normally, but bigint
under the big-pk
feature flag. So I went to check that the FK column followed the same rule. It didn't:
// user_id was hardcoded .integer() β it must follow eihwaz_users.id, which becomes
// BIGINT under the big-pk feature, or the FK is a type mismatch and fails to create.
let mut user_id_col = ColumnDef::new(Alias::new("user_id"));
user_id_col.integer(); // β always integer, even when users.id is bigint
sessions
, history
, and reset_tokens
all propagated the feature flag to their user_id
columns. This one junction table didn't. Under big-pk
, on a strict database like Postgres, the foreign key is a type mismatch and fails to create β a broken migration that only shows up for the subset of users who flip that flag. The fix is mechanical once you've seen it:
let mut user_id_col = ColumnDef::new(Alias::new("user_id"));
#[cfg(feature = "big-pk")]
user_id_col.big_integer();
#[cfg(not(feature = "big-pk"))]
user_id_col.integer();
user_id_col.not_null();
The compiler could never have caught this β both branches compile fine; they're just describing a database that won't build. Merise caught it because it made the type an explicit thing I had to write down and compare, instead of something buried in a ColumnDef
builder call three files away from its counterpart.
Here's the twist I didn't expect. The bugs were the goal of the exercise. But they weren't the most valuable thing it produced.
The most valuable thing was the map.
By the end I had an architectural understanding of Runique that I β its author β did not have when I started. Not a vague "I know my codebase" feeling, but a precise, drawn, versioned model of every layer and every data flow. The bugs were a byproduct of building that map. The map is the asset.
Two things convinced me of that. The first was the verified false positives. I didn't just log the real bugs; I logged every hypothesis I was sure of that turned out to be wrong. I was convinced makemigrations
couldn't handle ALTER COLUMN
β false, it uses a full schema diff I'd forgotten I wrote. I was convinced two session-write paths produced a divergent session_id
β false, the on_conflict
clause makes it deterministic. Documenting the things you were wrong about is what separates an audit from a victory lap, and it's the part most people would quietly delete.
The second was the transverse themes. Once the bugs were on one page, they stopped looking like twenty separate bugs and started looking like four repeated anti-patterns: errors swallowed in silence; two sources of truth for one fact; security operations in the wrong order; a feature flag that didn't propagate everywhere it needed to. That's the jump from "fix the bug" to "extract the rule" β and you can only make it when you can see all the instances at once. The map is what let me see them at once.
If you want to try it, the method compresses to a few rules:
And one honest warning: this takes real time. It is not worth it on a weekend project. It's worth it on a production codebase you've started to distrust β the one that works, that passes its tests, and that you've nonetheless got a quiet feeling about. That feeling is usually right, and this is how you find out what it's pointing at.
The loop back to the book that started it is this. When I modeled Runique, I found patterns already in it β Builder, Template Method, Composite, Strategy β that I had never deliberately placed. They'd emerged on their own, because they're the shapes a problem like this pushes you toward. The book didn't teach me to add them. It gave me the vocabulary to see the ones that were already there, and the modeling gave me the visual proof they existed.
The full set of diagrams β every UML class diagram, the Merise data model, and the sequence flows, each with its own anomalies section β lives in the diagramme/ folder on GitHub. Runique itself is on
One question for the comments, because I genuinely want to know: have you ever used a modeling formalism to audit code you'd already written β and what did it reveal that the code itself was hiding?