1use crate::error::{ErrorCode, MitosisError, Result};
5use serde::Deserialize;
6use std::collections::BTreeMap;
7use std::path::Path;
8
9#[derive(Clone, Debug, PartialEq)]
11pub enum Strategy {
12 Email,
13 Name,
14 Hash,
15 Null,
16 Redact,
17 Const(String),
18 Keep,
19}
20
21#[derive(Clone, Debug, PartialEq)]
23pub enum SubsetRule {
24 Where(String),
26 Sample(f64),
28}
29
30#[derive(Clone, Debug, Default, PartialEq)]
32pub struct MaskFile {
33 pub tables: BTreeMap<String, BTreeMap<String, Strategy>>,
34 pub subset: BTreeMap<String, SubsetRule>,
35}
36
37#[derive(Clone, Debug, Deserialize)]
39pub struct ColumnInfo {
40 pub schema: String,
41 pub table: String,
42 pub column: String,
43 pub data_type: String,
45 pub not_null: bool,
46}
47
48#[derive(Clone, Debug, Deserialize)]
51pub struct ForeignKey {
52 pub child: String,
53 pub child_cols: Vec<String>,
54 pub parent: String,
55 pub parent_cols: Vec<String>,
56}
57
58const SAMPLE_TABLE: &str = "mitosis_sample";
60
61const MAX_PASSES: u32 = 100;
63
64fn invalid(msg: impl Into<String>) -> MitosisError {
65 MitosisError::new(ErrorCode::InvalidRequest, msg)
66}
67
68fn parse_strategy(s: &str) -> Option<Strategy> {
69 Some(match s {
70 "email" => Strategy::Email,
71 "name" => Strategy::Name,
72 "hash" => Strategy::Hash,
73 "null" => Strategy::Null,
74 "redact" => Strategy::Redact,
75 "keep" => Strategy::Keep,
76 _ => Strategy::Const(s.strip_prefix("const:")?.to_string()),
77 })
78}
79
80fn parse_subset(key: &str, s: &str) -> Result<SubsetRule> {
81 let t = s.trim();
82 if let Some(p) = t
83 .strip_suffix('%')
84 .and_then(|p| p.trim().parse::<f64>().ok())
85 {
86 if p.is_nan() || p <= 0.0 || p > 100.0 {
87 return Err(invalid(format!(
88 "subset.{key}: the sample percent must be above 0 and at most 100, not {p}"
89 )));
90 }
91 return Ok(SubsetRule::Sample(p));
92 }
93 if t.is_empty() {
94 return Err(invalid(format!(
95 "subset.{key} needs a filter (a SQL condition) or a percent like \"10%\""
96 )));
97 }
98 Ok(SubsetRule::Where(t.to_string()))
99}
100
101impl MaskFile {
102 pub fn parse(text: &str) -> Result<MaskFile> {
104 let root: toml::Table = toml::from_str(text).map_err(|e| invalid(e.to_string()))?;
105 let mut m = MaskFile::default();
106 for (key, value) in root {
107 match key.as_str() {
108 "tables" => {
109 let tables = value
110 .as_table()
111 .ok_or_else(|| invalid("tables must be a table, like [tables.users]"))?;
112 for (t, cols) in tables {
113 let cols = cols.as_table().ok_or_else(|| {
114 invalid(format!(
115 "tables.{t} must be a table of column = \"strategy\""
116 ))
117 })?;
118 let mut out = BTreeMap::new();
119 for (c, s) in cols {
120 let s = s.as_str().ok_or_else(|| {
121 if s.is_table() {
122 invalid(format!(
123 "tables.{t}.{c} must be a string; for a schema-qualified \
124 table, quote the key: [tables.\"{t}.{c}\"]"
125 ))
126 } else {
127 invalid(format!("tables.{t}.{c} must be a string"))
128 }
129 })?;
130 let strategy = parse_strategy(s).ok_or_else(|| {
131 invalid(format!(
132 "tables.{t}.{c}: unknown strategy {s:?} (use email, name, hash, \
133 null, redact, const:<value> or keep)"
134 ))
135 })?;
136 out.insert(c.clone(), strategy);
137 }
138 m.tables.insert(t.clone(), out);
139 }
140 }
141 "subset" => {
142 let rules = value.as_table().ok_or_else(|| {
143 invalid("subset must be a table of table = \"filter or percent\"")
144 })?;
145 for (t, s) in rules {
146 let s = s.as_str().ok_or_else(|| {
147 if s.is_table() {
148 invalid(format!(
149 "subset.{t} must be a string; for a schema-qualified table, \
150 quote the key: \"{t}.<table>\" = \"...\""
151 ))
152 } else {
153 invalid(format!("subset.{t} must be a string"))
154 }
155 })?;
156 m.subset.insert(t.clone(), parse_subset(t, s)?);
157 }
158 }
159 other => {
160 return Err(invalid(format!(
161 "unknown key {other} (a masking file has [tables.<table>] and [subset])"
162 )))
163 }
164 }
165 }
166 Ok(m)
167 }
168
169 pub fn load(path: &Path) -> Result<MaskFile> {
171 let named = |msg: String| invalid(format!("masking file {}: {msg}", path.display()));
172 let text = std::fs::read_to_string(path).map_err(|e| named(e.to_string()))?;
173 MaskFile::parse(&text).map_err(|e| named(e.message))
174 }
175
176 pub fn hash(path: &Path) -> Result<String> {
178 let data = std::fs::read(path)
179 .map_err(|e| invalid(format!("masking file {}: {e}", path.display())))?;
180 Ok(hash_bytes(&data))
181 }
182
183 pub fn load_hashed(path: &Path) -> Result<(MaskFile, String)> {
185 let named = |msg: String| invalid(format!("masking file {}: {msg}", path.display()));
186 let data = std::fs::read(path).map_err(|e| named(e.to_string()))?;
187 let hash = hash_bytes(&data);
188 let text = String::from_utf8(data).map_err(|e| named(e.to_string()))?;
189 let file = MaskFile::parse(&text).map_err(|e| named(e.message))?;
190 Ok((file, hash))
191 }
192}
193
194fn hash_bytes(data: &[u8]) -> String {
195 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
196 crate::templates::fnv1a(&mut h, data);
197 format!("{h:016x}")
198}
199
200fn quote_ident(s: &str) -> String {
201 format!("\"{}\"", s.replace('"', "\"\""))
202}
203
204fn quote_literal(s: &str) -> String {
205 format!("'{}'", s.replace('\'', "''"))
206}
207
208fn split_table(name: &str) -> (&str, &str) {
210 name.split_once('.').unwrap_or(("public", name))
211}
212
213fn qualified(name: &str) -> String {
214 let (schema, table) = split_table(name);
215 format!("{}.{}", quote_ident(schema), quote_ident(table))
216}
217
218fn is_text(data_type: &str) -> bool {
220 if data_type.ends_with(']') {
221 return false;
222 }
223 let base = data_type.split('(').next().unwrap_or("").trim();
224 let base = base.rsplit('.').next().unwrap_or(base);
225 matches!(
226 base,
227 "text" | "character varying" | "varchar" | "character" | "char" | "bpchar" | "citext"
228 )
229}
230
231fn strategy_name(s: &Strategy) -> &'static str {
232 match s {
233 Strategy::Email => "email",
234 Strategy::Name => "name",
235 Strategy::Hash => "hash",
236 Strategy::Null => "null",
237 Strategy::Redact => "redact",
238 Strategy::Const(_) => "const",
239 Strategy::Keep => "keep",
240 }
241}
242
243fn column_sql(
247 table: &str,
248 info: &ColumnInfo,
249 strategy: &Strategy,
250) -> Result<Option<(String, String)>> {
251 let c = quote_ident(&info.column);
252 let name = format!("{table}.{}", info.column);
253 let text_only = matches!(
254 strategy,
255 Strategy::Email | Strategy::Name | Strategy::Hash | Strategy::Redact
256 );
257 if text_only && !is_text(&info.data_type) {
258 return Err(invalid(format!(
259 "column {name} is {}, and {} works only on text, varchar, char and citext columns",
260 info.data_type,
261 strategy_name(strategy)
262 )));
263 }
264 let h = format!(
266 "encode(sha256(decode(current_setting('{SALT_SETTING}'),'hex') || convert_to({c}::text,'UTF8')),'hex')"
267 );
268 Ok(Some(match strategy {
269 Strategy::Keep => return Ok(None),
270 Strategy::Email => (
271 format!("{c}::text NOT LIKE '%@masked.invalid'"),
272 format!("'u_' || left({h},12) || '@masked.invalid'"),
273 ),
274 Strategy::Name => (
275 format!("{c}::text NOT LIKE 'Masked %'"),
276 format!("'Masked ' || left({h},8)"),
277 ),
278 Strategy::Hash => (
279 format!("{c}::text NOT LIKE 'mh\\_%'"),
280 format!("'mh_' || {h}"),
281 ),
282 Strategy::Redact => (
283 format!("{c}::text <> repeat('x', length({c}))"),
284 format!("repeat('x', length({c}))"),
285 ),
286 Strategy::Null => {
287 if info.not_null {
288 return Err(invalid(format!(
289 "column {name} is NOT NULL, so it cannot be masked with null"
290 )));
291 }
292 (format!("{c} IS NOT NULL"), "NULL".into())
293 }
294 Strategy::Const(v) => {
295 let lit = format!("{}::{}", quote_literal(v), info.data_type);
296 (format!("{c}::text <> ({lit})::text"), lit)
297 }
298 }))
299}
300
301const SALT_SETTING: &str = "mitosis.salt";
303
304pub fn mask_statements(
310 m: &MaskFile,
311 salt_hex: &str,
312 columns: &[ColumnInfo],
313) -> Result<Vec<String>> {
314 if salt_hex.is_empty() || !salt_hex.bytes().all(|b| b.is_ascii_hexdigit()) {
315 return Err(MitosisError::new(
316 ErrorCode::Internal,
317 "the masking salt is not hex",
318 ));
319 }
320 let mut out = Vec::new();
321 for (table, cols) in &m.tables {
322 let (schema, name) = split_table(table);
323 let known: Vec<&ColumnInfo> = columns
324 .iter()
325 .filter(|c| c.schema == schema && c.table == name)
326 .collect();
327 if known.is_empty() {
328 return Err(invalid(format!("masked table {table} does not exist")));
329 }
330 let (mut sets, mut needs) = (Vec::new(), Vec::new());
331 for (col, strategy) in cols {
332 let info = known
333 .iter()
334 .find(|c| c.column == *col)
335 .ok_or_else(|| invalid(format!("masked column {table}.{col} does not exist")))?;
336 let Some((need, value)) = column_sql(table, info, strategy)? else {
337 continue;
338 };
339 let c = quote_ident(col);
340 sets.push(format!("{c} = CASE WHEN {need} THEN {value} ELSE {c} END"));
341 needs.push(format!("({need})"));
342 }
343 if !sets.is_empty() {
344 out.push(format!(
345 "UPDATE {} SET {} WHERE {}",
346 qualified(table),
347 sets.join(", "),
348 needs.join(" OR ")
349 ));
350 }
351 }
352 if !out.is_empty() {
353 out.insert(0, format!("SET LOCAL {SALT_SETTING} = '{salt_hex}'"));
354 }
355 Ok(out)
356}
357
358pub fn subset_statements(m: &MaskFile, fks: &[ForeignKey]) -> Result<Vec<String>> {
362 let mut out = Vec::new();
363 if m.subset.is_empty() {
364 return Ok(Vec::new());
365 }
366 for (table, rule) in &m.subset {
367 let t = qualified(table);
368 out.push(match rule {
369 SubsetRule::Where(w) => vec![format!("DELETE FROM {t} WHERE ({w}) IS NOT TRUE")],
370 SubsetRule::Sample(p) => vec![
374 format!(
375 "CREATE TEMP TABLE {SAMPLE_TABLE} AS \
376 SELECT tableoid AS o, ctid AS k FROM {t} TABLESAMPLE SYSTEM ({p})"
377 ),
378 format!("ANALYZE {SAMPLE_TABLE}"),
379 format!(
380 "DELETE FROM {t} AS x WHERE NOT EXISTS \
381 (SELECT 1 FROM {SAMPLE_TABLE} s WHERE s.o = x.tableoid AND s.k = x.ctid)"
382 ),
383 format!("DROP TABLE {SAMPLE_TABLE}"),
384 ],
385 });
386 }
387 let mut out: Vec<String> = out.into_iter().flatten().collect();
388 if fks.is_empty() {
389 return Ok(out);
390 }
391 let mut body = String::new();
392 for fk in fks {
393 if fk.child_cols.is_empty() || fk.child_cols.len() != fk.parent_cols.len() {
394 return Err(MitosisError::new(
395 ErrorCode::Internal,
396 format!(
397 "foreign key from {} to {} has unmatched columns",
398 fk.child, fk.parent
399 ),
400 ));
401 }
402 let cols: Vec<String> = fk.child_cols.iter().map(|c| format!("c.{c}")).collect();
403 let on: Vec<String> = fk
404 .parent_cols
405 .iter()
406 .zip(&fk.child_cols)
407 .map(|(p, c)| format!("p.{p} = c.{c}"))
408 .collect();
409 body.push_str(&format!(
410 " DELETE FROM {} AS c WHERE ({}) IS NOT NULL AND NOT EXISTS \
411 (SELECT 1 FROM {} AS p WHERE {});\n GET DIAGNOSTICS n = ROW_COUNT;\n total := total + n;\n",
412 fk.child,
413 cols.join(", "),
414 fk.parent,
415 on.join(" AND ")
416 ));
417 }
418 out.push(format!(
419 "DO $mitosis$\nDECLARE n bigint; total bigint; passes int := 0;\nBEGIN\n LOOP\n total := 0;\n{body} \
420 EXIT WHEN total = 0;\n passes := passes + 1;\n IF passes >= {MAX_PASSES} THEN\n \
421 RAISE EXCEPTION 'subset: rows referencing deleted rows were still being deleted after {MAX_PASSES} passes';\n \
422 END IF;\n END LOOP;\nEND\n$mitosis$"
423 ));
424 Ok(out)
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430
431 const EXAMPLE: &str = r#"
432[tables.users]
433email = "email" # deterministic fake email
434full_name = "name" # deterministic fake name
435phone = "null"
436ssn = "hash" # keyed hash, hex
437notes = "redact" # same-length run of 'x'
438plan = "const:free"
439
440[subset]
441orders = "created_at > now() - interval '90 days'"
442events = "10%" # TABLESAMPLE SYSTEM
443"#;
444
445 fn col(table: &str, column: &str, data_type: &str, not_null: bool) -> ColumnInfo {
446 let (schema, table) = split_table(table);
447 ColumnInfo {
448 schema: schema.into(),
449 table: table.into(),
450 column: column.into(),
451 data_type: data_type.into(),
452 not_null,
453 }
454 }
455
456 fn users() -> Vec<ColumnInfo> {
457 vec![
458 col("users", "id", "integer", true),
459 col("users", "email", "text", true),
460 col("users", "full_name", "character varying(80)", false),
461 col("users", "phone", "text", false),
462 col("users", "ssn", "citext", false),
463 col("users", "notes", "character(20)", false),
464 col("users", "plan", "text", false),
465 ]
466 }
467
468 #[test]
469 fn parse_full_example() {
470 let m = MaskFile::parse(EXAMPLE).unwrap();
471 let u = &m.tables["users"];
472 assert_eq!(u["email"], Strategy::Email);
473 assert_eq!(u["full_name"], Strategy::Name);
474 assert_eq!(u["phone"], Strategy::Null);
475 assert_eq!(u["ssn"], Strategy::Hash);
476 assert_eq!(u["notes"], Strategy::Redact);
477 assert_eq!(u["plan"], Strategy::Const("free".into()));
478 assert_eq!(
479 m.subset["orders"],
480 SubsetRule::Where("created_at > now() - interval '90 days'".into())
481 );
482 assert_eq!(m.subset["events"], SubsetRule::Sample(10.0));
483 let q = MaskFile::parse("[tables.\"billing.cards\"]\nnumber = \"keep\"").unwrap();
484 assert_eq!(q.tables["billing.cards"]["number"], Strategy::Keep);
485 }
486
487 #[test]
488 fn parse_errors_name_key() {
489 let msg = |text: &str| MaskFile::parse(text).unwrap_err().message;
490 assert!(msg("[tables.users]\nemail = \"fake\"").contains("tables.users.email"));
491 assert!(msg("[tables.users]\nemail = 3").contains("tables.users.email"));
492 assert!(msg("[tables]\nusers = \"email\"").contains("tables.users"));
493 assert!(msg("[subset]\norders = \"\"").contains("subset.orders"));
494 assert!(msg("[subset]\norders = 1").contains("subset.orders"));
495 assert!(msg("[mask]\nx = 1").contains("unknown key mask"));
496 assert!(
498 msg("[tables.billing.cards]\nnumber = \"keep\"").contains("[tables.\"billing.cards\"]")
499 );
500 assert!(msg("[subset]\nbilling.cards = \"1%\"").contains("quote the key"));
501 assert!(
503 msg("[tables.users]\nemail = \n").contains("line 2"),
504 "{}",
505 msg("[tables.users]\nemail = \n")
506 );
507 }
508
509 #[test]
510 fn sample_percent_bounds() {
511 let rule = |s: &str| parse_subset("t", s);
512 assert_eq!(rule("100%").unwrap(), SubsetRule::Sample(100.0));
513 assert_eq!(rule("0.5 %").unwrap(), SubsetRule::Sample(0.5));
514 for bad in ["0%", "-1%", "100.1%", "NaN%", "inf%"] {
515 let e = rule(bad).unwrap_err();
516 assert!(e.message.contains("subset.t"), "{bad}: {}", e.message);
517 }
518 assert_eq!(
520 rule("name LIKE 'a%'").unwrap(),
521 SubsetRule::Where("name LIKE 'a%'".into())
522 );
523 }
524
525 #[test]
526 fn statements_skip_already_masked() {
527 let m = MaskFile::parse(EXAMPLE).unwrap();
528 let sql = mask_statements(&m, "00ff", &users()).unwrap();
529 assert_eq!(sql.len(), 2, "the salt, then one UPDATE per table");
530 assert_eq!(sql[0], "SET LOCAL mitosis.salt = '00ff'");
531 let s = &sql[1];
532 assert!(!s.contains("00ff"), "the salt stays out of the UPDATE: {s}");
533 assert!(s.starts_with("UPDATE \"public\".\"users\" SET "), "{s}");
534 assert!(
535 s.contains("\"email\"::text NOT LIKE '%@masked.invalid'"),
536 "{s}"
537 );
538 assert!(s.contains("'u_' || left(encode(sha256(decode(current_setting('mitosis.salt'),'hex') || convert_to(\"email\"::text,'UTF8')),'hex'),12) || '@masked.invalid'"), "{s}");
539 assert!(s.contains("\"full_name\"::text NOT LIKE 'Masked %'"), "{s}");
540 assert!(s.contains("'Masked ' || left(encode("), "{s}");
541 assert!(s.contains("\"ssn\"::text NOT LIKE 'mh\\_%'"), "{s}");
542 assert!(s.contains("'mh_' || encode("), "{s}");
543 assert!(
544 s.contains("\"notes\"::text <> repeat('x', length(\"notes\"))"),
545 "{s}"
546 );
547 assert!(
548 s.contains("\"phone\" = CASE WHEN \"phone\" IS NOT NULL THEN NULL"),
549 "{s}"
550 );
551 assert!(s.contains("'free'::text"), "{s}");
552 assert!(s.contains(" WHERE (\"email\"::text NOT LIKE"), "{s}");
553
554 let keep = MaskFile::parse("[tables.users]\nemail = \"keep\"").unwrap();
555 assert!(mask_statements(&keep, "00", &users()).unwrap().is_empty());
556 }
557
558 #[test]
559 fn email_rerun_on_char_column_matches_the_unpadded_value() {
560 let cols = vec![col("users", "alt", "character(40)", false)];
563 let m = MaskFile::parse("[tables.users]\nalt = \"email\"").unwrap();
564 let sql = mask_statements(&m, "00", &cols).unwrap();
565 assert!(
566 sql[1].contains("WHEN \"alt\"::text NOT LIKE '%@masked.invalid' THEN"),
567 "{}",
568 sql[1]
569 );
570 assert!(sql[1].contains("WHERE (\"alt\"::text NOT LIKE '%@masked.invalid')"));
571 }
572
573 #[test]
574 fn statements_check_columns_and_types() {
575 let err = |text: &str| {
576 mask_statements(&MaskFile::parse(text).unwrap(), "00", &users())
577 .unwrap_err()
578 .message
579 };
580 assert!(err("[tables.users]\nid = \"email\"").contains("users.id is integer"));
581 assert!(err("[tables.users]\nemail = \"null\"").contains("users.email is NOT NULL"));
582 assert!(err("[tables.users]\nnope = \"hash\"").contains("users.nope does not exist"));
583 assert!(err("[tables.people]\nemail = \"hash\"").contains("table people does not exist"));
584 assert!(!is_text("text[]") && !is_text("character varying(4)[]") && !is_text("\"char\""));
585 assert!(is_text("public.citext") && is_text("character(3)"));
586 }
587
588 #[test]
589 fn subset_statements_delete_orphans() {
590 let m = MaskFile::parse(EXAMPLE).unwrap();
591 let fks = vec![ForeignKey {
592 child: "\"public\".\"order_items\"".into(),
593 child_cols: vec!["\"order_id\"".into()],
594 parent: "\"public\".\"orders\"".into(),
595 parent_cols: vec!["\"id\"".into()],
596 }];
597 let sql = subset_statements(&m, &fks).unwrap();
598 assert_eq!(sql[0], "CREATE TEMP TABLE mitosis_sample AS SELECT tableoid AS o, ctid AS k FROM \"public\".\"events\" TABLESAMPLE SYSTEM (10)");
599 assert_eq!(sql[1], "ANALYZE mitosis_sample");
600 assert_eq!(sql[2], "DELETE FROM \"public\".\"events\" AS x WHERE NOT EXISTS (SELECT 1 FROM mitosis_sample s WHERE s.o = x.tableoid AND s.k = x.ctid)");
601 assert_eq!(sql[3], "DROP TABLE mitosis_sample");
602 assert_eq!(
603 sql[4],
604 "DELETE FROM \"public\".\"orders\" WHERE (created_at > now() - interval '90 days') IS NOT TRUE"
605 );
606 assert!(sql[5].contains("DELETE FROM \"public\".\"order_items\" AS c WHERE (c.\"order_id\") IS NOT NULL AND NOT EXISTS (SELECT 1 FROM \"public\".\"orders\" AS p WHERE p.\"id\" = c.\"order_id\")"), "{}", sql[5]);
607 assert!(sql[5].contains("passes >= 100"));
608 assert!(subset_statements(&MaskFile::default(), &fks)
609 .unwrap()
610 .is_empty());
611 }
612}