1use crate::policy::model::SqlCategory;
6use sha2::{Digest, Sha256};
7
8#[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 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
36pub 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
78pub 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}