Skip to main content

sequel_mcp/approval/
digest.rs

1//! Canonical operation digests. A one-time approval is bound to the digest
2//! of everything material about the operation; changing any field
3//! invalidates the approval.
4
5use crate::policy::model::SqlCategory;
6use sha2::{Digest, Sha256};
7
8/// Fields folded into the operation digest.
9#[derive(Debug, Clone)]
10pub struct OperationDigestInput<'a> {
11    pub connection: &'a str,
12    pub driver: &'a str,
13    pub database: Option<&'a str>,
14    pub read_tables: &'a [crate::policy::classifier::TableRef],
15    pub mutated_tables: &'a [crate::policy::classifier::TableRef],
16    pub category: SqlCategory,
17    pub canonical_sql: &'a str,
18    /// Hashes of bound parameters, never raw values. The MCP surface has no
19    /// separate parameters today; kept for the executor's internal binds.
20    pub parameter_hashes: &'a [[u8; 32]],
21    pub backup_plan_identity: Option<&'a str>,
22    pub policy_revision: u64,
23    pub metadata_revision: u64,
24    pub nonce: [u8; 32],
25}
26
27fn sorted_tables(tables: &[crate::policy::classifier::TableRef]) -> Vec<String> {
28    let mut out: Vec<String> = tables
29        .iter()
30        .map(|t| format!("{}.{}", t.database.as_deref().unwrap_or("\u{0}"), t.table))
31        .collect();
32    out.sort();
33    out
34}
35
36/// Compute the canonical digest over every material field.
37pub fn operation_digest(input: &OperationDigestInput<'_>) -> [u8; 32] {
38    let mut h = Sha256::new();
39    h.update(b"sequel-mcp/operation/v1\n");
40    h.update(input.connection.as_bytes());
41    h.update(b"\n");
42    h.update(input.driver.as_bytes());
43    h.update(b"\n");
44    h.update(input.database.unwrap_or("\u{0}").as_bytes());
45    h.update(b"\n");
46    for t in sorted_tables(input.read_tables) {
47        h.update(t.as_bytes());
48        h.update(b"\x1e");
49    }
50    h.update(b"\n");
51    for t in sorted_tables(input.mutated_tables) {
52        h.update(t.as_bytes());
53        h.update(b"\x1e");
54    }
55    h.update(b"\n");
56    h.update(input.category.as_str().as_bytes());
57    h.update(b"\n");
58    h.update(input.canonical_sql.as_bytes());
59    h.update(b"\n");
60    for p in input.parameter_hashes {
61        h.update(p);
62    }
63    h.update(b"\n");
64    h.update(
65        input
66            .backup_plan_identity
67            .map(str::as_bytes)
68            .unwrap_or(&[0]),
69    );
70    h.update(b"\n");
71    h.update(input.policy_revision.to_le_bytes());
72    h.update(input.metadata_revision.to_le_bytes());
73    h.update(b"\n");
74    h.update(input.nonce);
75    h.finalize().into()
76}
77
78/// Canonical SQL: re-printed from the AST when parseable, otherwise the
79/// whitespace-normalized statement. Literals are NOT redacted here (the
80/// digest must change when literals change); redaction happens in audit.
81pub fn canonical_sql(statement: &str, dialect: crate::policy::classifier::Dialect) -> String {
82    use sqlparser::parser::Parser;
83    let parsed = match dialect {
84        crate::policy::classifier::Dialect::MySql => {
85            Parser::parse_sql(&sqlparser::dialect::MySqlDialect {}, statement)
86        }
87        crate::policy::classifier::Dialect::SQLite => {
88            Parser::parse_sql(&sqlparser::dialect::SQLiteDialect {}, statement)
89        }
90    };
91    match parsed {
92        Ok(stmts) if stmts.len() == 1 => normalize_ws(&stmts[0].to_string()),
93        _ => normalize_ws(statement),
94    }
95}
96
97fn normalize_ws(s: &str) -> String {
98    s.split_whitespace().collect::<Vec<_>>().join(" ")
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use crate::policy::classifier::{ClassifiedStatement, Dialect, TableRef, classify_statement};
105
106    fn input_for(sql: &str) -> (String, ClassifiedStatement) {
107        let c = classify_statement(sql, Dialect::MySql).unwrap();
108        (canonical_sql(sql, Dialect::MySql), c)
109    }
110
111    fn digest_of(sql: &str, nonce: [u8; 32]) -> [u8; 32] {
112        let (canon, c) = input_for(sql);
113        operation_digest(&OperationDigestInput {
114            connection: "c1",
115            driver: "mysql",
116            database: Some("app"),
117            read_tables: &c.read_tables,
118            mutated_tables: &c.mutated_tables,
119            category: c.category,
120            canonical_sql: &canon,
121            parameter_hashes: &[],
122            backup_plan_identity: None,
123            policy_revision: 1,
124            metadata_revision: 0,
125            nonce,
126        })
127    }
128
129    #[test]
130    fn different_sql_different_digest() {
131        let n = [0u8; 32];
132        assert_ne!(
133            digest_of("UPDATE app.jobs SET a = 1 WHERE id = 1", n),
134            digest_of("UPDATE app.jobs SET a = 2 WHERE id = 1", n)
135        );
136    }
137
138    #[test]
139    fn different_tables_different_digest() {
140        let n = [0u8; 32];
141        assert_ne!(
142            digest_of("UPDATE app.jobs SET a = 1", n),
143            digest_of("UPDATE app.users SET a = 1", n)
144        );
145    }
146
147    #[test]
148    fn same_operation_same_digest() {
149        let n = [5u8; 32];
150        assert_eq!(
151            digest_of("UPDATE app.jobs SET a = 1", n),
152            digest_of("UPDATE app.jobs SET a = 1", n)
153        );
154    }
155
156    #[test]
157    fn nonce_changes_digest() {
158        assert_ne!(
159            digest_of("UPDATE app.jobs SET a = 1", [0u8; 32]),
160            digest_of("UPDATE app.jobs SET a = 1", [1u8; 32])
161        );
162    }
163
164    #[test]
165    fn policy_revision_changes_digest() {
166        let (canon, c) = input_for("UPDATE app.jobs SET a = 1");
167        let base = operation_digest(&OperationDigestInput {
168            connection: "c1",
169            driver: "mysql",
170            database: Some("app"),
171            read_tables: &c.read_tables,
172            mutated_tables: &c.mutated_tables,
173            category: c.category,
174            canonical_sql: &canon,
175            parameter_hashes: &[],
176            backup_plan_identity: None,
177            policy_revision: 1,
178            metadata_revision: 0,
179            nonce: [0u8; 32],
180        });
181        let bumped = operation_digest(&OperationDigestInput {
182            connection: "c1",
183            driver: "mysql",
184            database: Some("app"),
185            read_tables: &c.read_tables,
186            mutated_tables: &c.mutated_tables,
187            category: c.category,
188            canonical_sql: &canon,
189            parameter_hashes: &[],
190            backup_plan_identity: None,
191            policy_revision: 2,
192            metadata_revision: 0,
193            nonce: [0u8; 32],
194        });
195        assert_ne!(base, bumped);
196        let _ = TableRef {
197            database: None,
198            table: String::new(),
199        };
200    }
201
202    #[test]
203    fn canonical_sql_is_stable_across_formatting() {
204        assert_eq!(
205            canonical_sql("SELECT 1", Dialect::MySql),
206            canonical_sql("SELECT    1", Dialect::MySql)
207        );
208    }
209}