我正在閱讀一本設計模式書籍。讀到 UML 類別圖時——我停住了。
因為頁面上的圖表正在描述 Rust 已經用自己的語法表達的事物。struct 就是類別。Option<T> 是一種 0..1 關聯。Vec<T> 是 1..N。所有權就是組合。我所看到的正式化描述,以方框和菱形繪製,與編譯器已經擁有的資訊相同——只是採用不同的表示法。
這引發了一個令人不安的想法。如果型別系統已經編碼了 UML 所繪製的內容,那麼將我的程式碼轉譯為 UML 不應該告訴我任何新東西。它應該是多餘的。除非轉譯結果不吻合——而每一個無法吻合之處,都會隱藏一個錯誤在差距之中。
所以我嘗試了。我拿了 Runique——我的 Rust 網頁框架,v2.1.21——並用 UML 為結構、Merise 為資料,逐模組建構框架本身的模型。直覺是我的;我請 Claude 協助逐行驗證每個轉譯是否符合實際程式碼。我們發現了二十多個潛在錯誤。其中三個與安全性相關。它們都沒有在 cargo test、tracing 或編譯器中顯現。
這些差距看起來是這樣的。
我在自己的程式碼中看不到的事物
Runique 是一個受 Django 啟發的框架:Axum、SeaORM、Tera,edition 2024。它運作正常。它已支援一家真實餐廳的預約網站以及 runique.io 本身在生產環境中運行數月——這是我的實踐。但我所描述的審計是針對框架本身,而不是那些網站。而這正是問題所在——「它運作正常」是錯誤最難被發現的狀態。編譯器很滿意。測試是綠色的。tracing 沒有顯示任何東西,因為沒有任何拋出。
我每天使用的工具都是局部的。cargo check 只看一個函式。單元測試只看一條路徑。tracing 只看一個請求。它們都不會退一步向你展示整個事物的形狀——而有些錯誤只存在於形狀之中。一個應該是 pub(crate) 卻是 pub 的欄位。在序列中太早寫入的檔案。與它應該匹配的型別不同的型別,三個檔案之外,在功能旗標之後。
我需要程式碼的外部視角——而無需離開程式碼。不是重寫,不是新工具,不是 linter。而是同一事物的不同表示法,精確到足以機械性地發現差異。
三種表示法,一種結構
這是書籍傳達給我的想法,明確化了。
Rust 的型別系統、UML 類別圖,以及 Merise 資料模型是三種表示法,描述相同的基本事實:實體、它們的關係、它們的基數、它們的不變量。GoF 模式只是跨越這三者出現的重複形狀。Rust 在型別系統中編碼它們,由編譯器強制執行。UML 和 Merise 在獨立的圖形形式主義中編碼它們,由人類閱讀。兩者之間的轉譯幾乎是機械性的:
| 概念 | Rust | UML / Merise |
|---|---|---|
| 實體 | struct |
Class / Entity |
| 強制 1..1 | 以值指定的欄位:T
|
基數 1..1
|
| 可選 0..1 | Option<T> |
基數 0..1
|
| 1..N |
Vec<T> / HashMap<K,V>
|
基數 1..N
|
| 組合 (擁有) |
struct A { b: B } 或 Box<B>
|
實心菱形 |
| 聚合 (共享) |
&B / Arc<B> / Rc<B>
|
空心菱形 |
| 精煉 | AST → HIR → MIR | MCD → MLD → MPD |
有一個但書,因為 Rustacean 們(理所當然地)會跳出來反對:所有權到組合的對應並不完美。Box<B> 仍然擁有,所以它是組合,而不是聚合;聚合——具有獨立生命週期的共享參考——是 &B、Arc<B>、Rc<B>。這個類比是一面透鏡,而不是一條法則。但它是一面足夠銳利的透鏡,當程式碼和圖表不一致時,這種不一致值得調查。
這就是整個方法的一句話:用型別系統原生不說的正式化來建模程式碼,並審計兩種表示法拒絕同意之處。
Merise 在此值得一提,因為它是大多數英語讀者不熟悉的。它是 1970 年代的法國系統建模方法,至今仍在法國的電腦科學課程中教授。它將資料模型分為概念層級 (MCD)、邏輯層級 (MLD) 和物理層級 (MPD)——關鍵的是,在邏輯層級,它強制你寫下每個關聯的基數和具體型別。這第二個要求就是抓到下面其中一個錯誤的原因。
方法:三個透鏡,無抽樣
我沒有抽樣檢查。審計的重點在於涵蓋率,所以我用三個透鏡逐模組檢視整個框架,每個透鏡都與它的專長相匹配:
-
Merise 用於資料——每個
eihwaz_*框架資料表的 MCD 和 MLD,包含基數、外鍵和物理型別。 - UML 類別圖用於結構——每一層一個:引擎、表單、管理、後台、中介軟體、遷移、設定。
- 序列圖用於動態——請求/CSRF/上傳流程、登入/工作階段、管理 CRUD。這是向你展示順序的透鏡,程式碼或類別圖都無法讓它顯現。
一切都是 Markdown 中的 Mermaid,與程式碼一起版本控制。沒有二進位工具,沒有獨立應用程式——圖表會在 GitHub 上渲染,並與儲存庫一起傳遞。每個圖表都以「異常」區段結束,合併到單一的 anomalies.md,包含每個發現的嚴重性和 file:line。
現在來看有趣的部分。
錯誤 1 — UML:一個悄然繞過 CSRF 的 pub 欄位
我將以最有趣的開始——不是最嚴重的,而是最有趣的,因為它是最好展現這整個練習真正意義的錯誤。
我正在繪製 Prisme 的 UML 類別圖,這是保存請求已解析且 CSRF 已檢查過的 body 的型別。圖表讓我為每個欄位寫下其可見性。而它就在那裡:
Prisme
+ data: StrMap ← public
+ csrf_valid: bool
+ checked_data() -> Option<&StrMap> ← fail-closed accessor
Enter fullscreen mode Exit fullscreen mode
data 是 pub。checked_data()——只在 CSRF 通過時才回傳 body 的存取子——就在它旁邊。當這兩個同時出現在圖表中的同一個方框時,問題就很明顯了:為什麼要建立一個 fail-closed 的門,然後在它旁邊留下原始欄位公開?
Runique 中的 CSRF 強制執行並不在 HTML 表單的中介軟體中——它存在於存取點。req.form() 會檢查 csrf_valid,如果權杖錯誤,就拒絕交出已驗證的資料。但如果 Prisme::data 是 pub,任何處理程式——或更確切地說,任何建構於 Runique 之上的第三方程式碼——都可以直接讀取原始 body,完全跳過這個門,而永遠不會知道自己這麼做了。
我想精確地說明嚴重性,因為誠實的版本比可怕的版本更有趣。我自己的程式碼是乾淨的:admin、login 和 form() 都在觸碰 body 之前檢查 CSRF。這從來不是 Runique 本身的一個被利用的漏洞。它是一個公共 API 中的陷阱——一種讓建構於框架上的人意外地繞過 CSRF 的形狀。這是一個框架作者的錯誤,而不是入侵。
修正只需要一個關鍵字,而這就是文章的全部重點:
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 }
}
}
Enter fullscreen mode Exit fullscreen mode
pub → pub(crate)。Rust 的可見性系統現在在結構上保證了存取請求 body 的唯一門是那兩個先檢查 CSRF 的門。這不是慣例,不是 lint,不是某人必須記住的程式碼審查規則。編譯器將拒絕這個繞過。可見性作為安全機制——這就是 UML 圖表所揭示的,因為類別圖強制你寫下程式碼讓你略過的那個屬性(pub vs pub(crate))。
錯誤 2 — 序列圖:在被允許之前寫入的檔案
第二個透鏡是展示時間的。這是我根據真實管道繪製的多部分表單 POST 的序列圖:
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
Enter fullscreen mode Exit fullscreen mode
從上到下閱讀,錯誤就會自己讀出來。檔案被重新命名到 MEDIA_ROOT——它的最終、可能被公開服務的位置——在 CSRF 被檢查之前,在表單驗證之前,以及在擴充檔過濾器(位於稍後的 FileField::validate)執行之前。在任何提取 Request 並接受多部分的公開端點上,這意味著:
-
未經驗證的檔案寫入。 無論接下來發生什麼,檔案都會落在
MEDIA_ROOT。 -
在此階段沒有擴充檔過濾——只有大小上限。一個
.html、.svg或.js可能落在被服務的目錄中。如果MEDIA_ROOT是靜態服務的,那就是儲存型 XSS 的途徑。 - 拒絕時的孤兒。 CSRF 失敗、驗證失敗、蜜罐觸發——檔案已經寫入,沒有任何東西清理它。
沒有單元測試抓到這個,因為沒有單元測試形狀像請求生命週期。類別圖也沒有抓到它——每個單獨的方法在隔離狀態下都是正確的。只有時間視角,那個展示順序的視角,才讓「在驗證之前寫入」作為一眼就能看到的東西顯現。
修正將提交移到最後。parse_multipart 現在寫入一個非服務的暫存目錄,而 FileField::finalize——在 CSRF 和驗證之後執行——是唯一被允許提交的東西:
// 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)
Enter fullscreen mode Exit fullscreen mode
一個提交者,一個門,在正確的順序中執行——以及一個 TTL 掃描,用於從未被提升的暫存,每個失敗都被記錄而不是被丟在地上。
錯誤 3 — Merise:一個與其目標不匹配的外鍵
最後一個是 Merise 的「寫下型別」紀律抓到的錯誤,它清楚地展示了為什麼一個獨立的正式化值得保留。
這是用戶和群組之間的接合資料表的 MLD 片段——邏輯模型:
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
}
Enter fullscreen mode Exit fullscreen mode
Merise 讓我在 user_id 旁邊寫下兩件事:它的基數(一個強制性的 FK 到 users.id)以及它的物理型別。而 users.id 有一個註腳——它通常是 integer,但在 big-pk 功能旗標下是 bigint。所以我去檢查 FK 欄位是否遵循相同的規則。它沒有:
// 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
Enter fullscreen mode Exit fullscreen mode
sessions、history 和 reset_tokens 都將功能旗標傳播到它們的 user_id 欄位。這個接合資料表沒有。在 big-pk 下,在像 Postgres 這樣的嚴格資料庫上,外鍵是型別不匹配,無法建立——一個只對翻轉該旗標的使用者子集顯現的損壞遷移。一旦你看到它,修正就是機械性的:
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();
Enter fullscreen mode Exit fullscreen mode
編譯器永遠無法抓到這個——兩個分支都能編譯;它們只是在描述一個不會建構的資料庫。Merise 抓到它,因為它讓型別成為我必須寫下並比較的明確事物,而不是埋在距離其對應物三個檔案之外的 ColumnDef 建構器呼叫中。
真正的發現不是錯誤
這是我沒想到的轉折。錯誤是練習的目標。但它們不是它所產生最有價值的東西。
最有價值的東西是地圖。
到最後,我對 Runique 有了架構上的理解,這是身為作者的我在開始時所沒有的。不是一種模糊的「我知道我的程式碼庫」的感覺,而是一個精確的、已繪製的、已版本控制的每個層級和每個資料流的模型。錯誤是建構那張地圖的副產品。地圖才是資產。
有兩件事讓我確信這一點。第一個是已驗證的假陽性。我不只記錄真正的錯誤;我記錄了每一個我確信卻被證明是錯誤的假設。我確信 makemigrations 無法處理 ALTER COLUMN——錯誤,它使用了我忘記自己寫的完整結構描述差異。我確信兩個工作階段寫入路徑產生了一個不同的 session_id——錯誤,on_conflict 子句讓它具有確定性。仔細記錄你錯誤的事情是區分審計和勝利巡禮的原因,而這是大多數人會悄悄刪除的部分。
第二個是橫向主題。一旦錯誤被放在同一頁上,它們就不再看起來像二十個獨立的錯誤,而是開始看起來像四個重複的反模式:錯誤被默默吞噬;一個事實的兩個真相來源;順序錯誤的安全操作;一個沒有傳播到它需要去的所有地方的功能旗標。這就是從「修正錯誤」跳到「提取規則」——而只有當你能同時看到所有實例時才能做到這一點。地圖就是讓我同時看到它們的東西。
如何對你自己的專案做這件事
如果你想嘗試,這個方法可以壓縮成幾個規則:
- 從一個模組、一個層級開始。 不要在第一天就建模整個東西;建模你開始不信任的那部分。
- 將透鏡與問題相匹配。 資料完整性 → Merise。結構和可見性 → UML 類別圖。順序和生命週期 → 序列圖。大多數錯誤只存在於這三個視圖之一中,而在其他兩個中是不可見的。
- 保持工具簡單。 Markdown 中的 Mermaid,與程式碼一起提交。它會在 GitHub 上渲染,並像原始碼一樣進行差異比對,這意味著圖表會保持誠實,而不是在 wiki 中腐爛。
- 記錄你的假陽性要像記錄你的錯誤一樣仔細。 紀律就是全部的價值。只記錄勝利的審計是行銷。
以及一個誠實的警告:這需要真正的時間。它不值得花在週末專案上。它值得花在你開始不信任的生產程式碼庫——那個運作正常、通過測試、但你卻有種不安感覺的程式碼庫。那種感覺通常是對的,而這就是你找出它指向什麼的方法。
結語
回到啟發這一切的書籍,其迴路是這樣的。當我建模 Runique 時,我發現了已經存在於其中的模式——Builder、Template Method、Composite、Strategy——而這些是我從未刻意放置的。它們自己浮現了,因為它們是像這樣的問題會將你推向的形狀。書籍沒有教我添加它們。它給了我看到那些已經存在的模式的詞彙,而建模給了我它們存在的視覺證明。
完整的圖表集——每個 UML 類別圖、Merise 資料模型,以及序列流程,每個都有自己的異常區段——位於 GitHub 上的 diagramme/ 資料夾中。Runique 本身位於 crates.io 和 GitHub,文件位於 runique.io。這次審計的更多內容正在成為它自己的系列:透過可見性的安全性、對每個被吞噬錯誤的掃描,以及已驗證假陽性的完整清單。
給評論區的一個問題,因為我真的想知道:你是否曾經使用建模正式化來審計你已經寫好的程式碼——它揭示了程式碼本身所隱藏的什麼?
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.