{"slug": "how-reading-a-design-patterns-book-led-me-to-audit-my-rust-framework-with-uml", "title": "How Reading a Design Patterns Book Led Me to Audit My Rust Framework with UML and Merise", "summary": "A developer audited their Rust web framework Runique by modeling it in UML and Merise, finding over 20 latent bugs including three security-relevant ones that had not been caught by tests or the compiler. The method translates Rust type constructs into diagram notations and audits discrepancies between the two representations.", "body_md": "I was reading a design patterns book. I got to the UML class diagrams — and I stopped.\n\nBecause the diagram on the page was describing something Rust already says in its own syntax. A `struct`\n\nis a class. `Option<T>`\n\nis a `0..1`\n\nassociation. `Vec<T>`\n\nis `1..N`\n\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.\n\nWhich 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.\n\nSo 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`\n\n, in `tracing`\n\n, or in the compiler.\n\nThis is what the gaps looked like.\n\nRunique 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`\n\nshows nothing because nothing is throwing.\n\nThe tools I reach for every day are all *local*. `cargo check`\n\nsees one function. A unit test sees one path. `tracing`\n\nsees 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`\n\nwhen it should be `pub(crate)`\n\n. 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.\n\nI 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.\n\nHere's the idea the book handed me, made explicit.\n\nRust'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:\n\n| Concept | Rust | UML / Merise |\n|---|---|---|\n| Entity | `struct` |\nClass / Entity |\n| Mandatory 1..1 | field by value: `T`\n|\ncardinality `1..1`\n|\n| Optional 0..1 | `Option<T>` |\ncardinality `0..1`\n|\n| 1..N |\n`Vec<T>` / `HashMap<K,V>`\n|\ncardinality `1..N`\n|\n| Composition (owned) |\n`struct A { b: B }` or `Box<B>`\n|\nfilled diamond |\n| Aggregation (shared) |\n`&B` / `Arc<B>` / `Rc<B>`\n|\nhollow diamond |\n| Refinement | AST → HIR → MIR | MCD → MLD → MPD |\n\nOne caveat, because Rustaceans will (rightly) pounce otherwise: the ownership-to-composition mapping isn't perfect. `Box<B>`\n\nstill *owns*, so it's composition, not aggregation; aggregation — a shared reference with an independent lifetime — is `&B`\n\n, `Arc<B>`\n\n, `Rc<B>`\n\n. 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.\n\nThat'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.**\n\nMerise 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.\n\nI 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:\n\n`eihwaz_*`\n\nframework 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`\n\nwith severity and a `file:line`\n\nfor every finding.\n\nNow the interesting part.\n\n`pub`\n\nfield that quietly bypassed CSRF\nI'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.\n\nI was drawing the UML class diagram for `Prisme`\n\n, 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:\n\n```\nPrisme\n  + data: StrMap        ← public\n  + csrf_valid: bool\n  + checked_data() -> Option<&StrMap>   ← fail-closed accessor\n```\n\n`data`\n\nwas `pub`\n\n. `checked_data()`\n\n— 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?\n\nThe CSRF enforcement in Runique doesn't live in the middleware for HTML forms — it lives at the point of access. `req.form()`\n\nchecks `csrf_valid`\n\nand refuses to hand you validated data if the token is bad. But if `Prisme::data`\n\nis `pub`\n\n, 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.\n\nI 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()`\n\nall 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.\n\nThe fix is one keyword, and it's the whole point of the article:\n\n```\npub struct Prisme {\n    /// Parsed body/query data. **Crate-private**: user code cannot read the raw\n    /// body without going through the CSRF gate (anomaly C2). External access only\n    /// via checked_data() (fail-closed) or req.form().\n    pub(crate) data: StrMap,\n    pub csrf_valid: bool,\n}\n\nimpl Prisme {\n    /// Fail-closed accessor: returns body data only if CSRF is valid.\n    pub fn checked_data(&self) -> Option<&StrMap> {\n        if self.csrf_valid { Some(&self.data) } else { None }\n    }\n}\n```\n\n`pub`\n\n→ `pub(crate)`\n\n. 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`\n\nvs `pub(crate)`\n\n) that the code lets you skim past.\n\nThe 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:\n\n```\nsequenceDiagram\n    participant C as Client\n    participant MW as csrf_middleware\n    participant P as prisme_pipeline\n    participant AG as parse_multipart\n    participant H as Handler\n\n    C->>MW: POST multipart (csrf_token as a field, no header)\n    Note over MW: mutating method, no X-CSRF-Token,<br/>form content-type → LET THROUGH<br/>(\"Prisme will validate\")\n    MW->>P: next.run()\n    P->>AG: parse_multipart(req)\n    AG->>AG: stream file → tmp (size cap, NO extension check)\n    AG->>AG: rename tmp → MEDIA_ROOT/uuid.ext (COMMIT)\n    Note over AG: file committed to its final,<br/>servable location BEFORE CSRF,<br/>validation, and the handler\n    AG-->>P: final paths\n    P->>P: check_csrf() → csrf_valid (flag only)\n    P-->>H: Request\n    H->>H: if form.is_valid() { ... } else { reject }\n    Note over H: file already on disk in MEDIA_ROOT,<br/>never removed on rejection\n```\n\nRead top to bottom and the bug reads itself. The file gets renamed into `MEDIA_ROOT`\n\n— 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`\n\n) ever runs. On any public endpoint that extracts a `Request`\n\nand accepts multipart, that meant:\n\n`MEDIA_ROOT`\n\nno matter what happens next.`.html`\n\n, `.svg`\n\n, or `.js`\n\ncould land in a served directory. If `MEDIA_ROOT`\n\nis 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.\n\nThe fix moves the commit to the end. `parse_multipart`\n\nnow writes to a non-served staging directory, and `FileField::finalize`\n\n— running *after* CSRF and validation — is the only thing allowed to commit:\n\n```\n// No commit here: files stay in staging. FileField::finalize (the only committer)\n// moves them to their served destination after CSRF + validation. On rejection,\n// staging is purged by sweep_stale_staging (best-effort, TTL). Errors are logged,\n// never swallowed.\nOk(data)\n```\n\nOne 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.\n\nThe 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.\n\nHere's the MLD fragment — the logical model — for the junction table between users and groups:\n\n```\nerDiagram\n    USER ||--o{ USER_GROUPE : \"belongs to\"\n    GROUPE ||--o{ USER_GROUPE : \"groups\"\n    USER {\n        pk id \"int OR bigint under big-pk\"\n    }\n    USER_GROUPE {\n        pk user_id FK \"→ users.id\"\n        int groupe_id FK\n    }\n```\n\nMerise made me write two things next to `user_id`\n\n: its cardinality (a mandatory FK into `users.id`\n\n) *and* its physical type. And `users.id`\n\nhas a footnote — it's `integer`\n\nnormally, but `bigint`\n\nunder the `big-pk`\n\nfeature flag. So I went to check that the FK column followed the same rule. It didn't:\n\n```\n// user_id was hardcoded .integer() — it must follow eihwaz_users.id, which becomes\n// BIGINT under the big-pk feature, or the FK is a type mismatch and fails to create.\nlet mut user_id_col = ColumnDef::new(Alias::new(\"user_id\"));\nuser_id_col.integer();   // ← always integer, even when users.id is bigint\n```\n\n`sessions`\n\n, `history`\n\n, and `reset_tokens`\n\nall propagated the feature flag to their `user_id`\n\ncolumns. This one junction table didn't. Under `big-pk`\n\n, 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:\n\n``` js\nlet mut user_id_col = ColumnDef::new(Alias::new(\"user_id\"));\n#[cfg(feature = \"big-pk\")]\nuser_id_col.big_integer();\n#[cfg(not(feature = \"big-pk\"))]\nuser_id_col.integer();\nuser_id_col.not_null();\n```\n\nThe 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`\n\nbuilder call three files away from its counterpart.\n\nHere'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.\n\nThe most valuable thing was the map.\n\nBy 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.\n\nTwo 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`\n\ncouldn't handle `ALTER COLUMN`\n\n— false, it uses a full schema diff I'd forgotten I wrote. I was convinced two session-write paths produced a divergent `session_id`\n\n— false, the `on_conflict`\n\nclause 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.\n\nThe 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.\n\nIf you want to try it, the method compresses to a few rules:\n\nAnd 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.\n\nThe 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.\n\nThe 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/](https://github.com/seb-alliot/runique/tree/main/diagramme) folder on GitHub. Runique itself is on\n\nOne 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?**", "url": "https://wpnews.pro/news/how-reading-a-design-patterns-book-led-me-to-audit-my-rust-framework-with-uml", "canonical_source": "https://dev.to/seballiot/how-reading-a-design-patterns-book-led-me-to-audit-my-rust-framework-with-uml-and-merise-45dj", "published_at": "2026-07-22 23:20:33+00:00", "updated_at": "2026-07-22 23:58:59.844401+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Runique", "Claude", "Axum", "SeaORM", "Tera"], "alternates": {"html": "https://wpnews.pro/news/how-reading-a-design-patterns-book-led-me-to-audit-my-rust-framework-with-uml", "markdown": "https://wpnews.pro/news/how-reading-a-design-patterns-book-led-me-to-audit-my-rust-framework-with-uml.md", "text": "https://wpnews.pro/news/how-reading-a-design-patterns-book-led-me-to-audit-my-rust-framework-with-uml.txt", "jsonld": "https://wpnews.pro/news/how-reading-a-design-patterns-book-led-me-to-audit-my-rust-framework-with-uml.jsonld"}}