1use crate::cli::MaskingArgs;
7use crate::config::PipelineConfig;
8use crate::error::{CliError, CliResult};
9use faucet_core::masking::{CompiledMasking, MaskAction, MaskRule, MaskingSpec};
10
11pub async fn run(args: MaskingArgs) -> CliResult<()> {
13 let cwd = std::env::current_dir()?;
14 let env_path =
15 crate::env_loader::resolve_env_file(args.env_file.as_deref(), args.no_env_file, &cwd)?;
16 crate::env_loader::load_env_file_if_present(env_path.as_deref())?;
17
18 let path = match args.config {
19 Some(p) => p,
20 None => crate::env_loader::discover_config_path(&cwd).ok_or(CliError::NoConfigOrFromEnv)?,
21 };
22 let cfg = PipelineConfig::from_path_tolerating_secrets(&path, args.profile.as_deref())?;
23 let spec = cfg.pipeline.masking.as_ref().ok_or_else(|| {
24 CliError::Config(
25 "no `pipeline.masking:` block in this config — add one, or run \
26 `faucet schema masking` to see the block's JSON Schema"
27 .to_string(),
28 )
29 })?;
30 CompiledMasking::compile(spec).map_err(|e| CliError::Config(format!("masking: {e}")))?;
32
33 let destinations = destinations(&cfg);
34 print!("{}", render_summary(spec, &destinations));
35 Ok(())
36}
37
38fn destinations(cfg: &PipelineConfig) -> Vec<(String, String)> {
42 let mut out: Vec<(String, String)> = Vec::new();
43 if let Some(sink) = &cfg.pipeline.sink {
44 out.push(("default".to_string(), sink.kind.clone()));
45 }
46 for (name, spec) in &cfg.pipeline.sinks {
47 out.push((name.clone(), spec.kind.clone()));
48 }
49 out.sort();
50 out.dedup();
51 out
52}
53
54fn applied_rules(spec: &MaskingSpec, name: &str, kind: &str) -> Vec<String> {
56 spec.rules
57 .iter()
58 .enumerate()
59 .filter(|(_, r)| rule_applies(r, name, kind))
60 .map(|(i, r)| r.name.clone().unwrap_or_else(|| format!("rule_{i}")))
61 .collect()
62}
63
64fn rule_applies(rule: &MaskRule, name: &str, kind: &str) -> bool {
65 rule.applies_to.is_empty() || rule.applies_to.iter().any(|t| t == name || t == kind)
66}
67
68fn render_summary(spec: &MaskingSpec, destinations: &[(String, String)]) -> String {
70 use std::fmt::Write;
71 let mut out = String::new();
72 let n = spec.rules.len();
73 let _ = writeln!(
74 out,
75 "masking — valid ({n} rule{})",
76 if n == 1 { "" } else { "s" }
77 );
78 if let Some(d) = &spec.description {
79 let _ = writeln!(out, " description: {d}");
80 }
81 let _ = writeln!(
82 out,
83 " key: {}",
84 if spec.key.is_some() {
85 "configured (keyed HMAC-SHA256 for hash/tokenize)"
86 } else {
87 "none (unkeyed SHA-256 for hash/tokenize)"
88 }
89 );
90 let _ = writeln!(out, " rules:");
91 for (i, r) in spec.rules.iter().enumerate() {
92 let label = r.name.clone().unwrap_or_else(|| format!("rule_{i}"));
93 let scope = if r.applies_to.is_empty() {
94 "all sinks".to_string()
95 } else {
96 format!("sinks[{}]", r.applies_to.join(", "))
97 };
98 let _ = writeln!(
99 out,
100 " - {label}: {} → {} ({scope})",
101 describe_match(r),
102 describe_action(&r.action),
103 );
104 }
105
106 if destinations.is_empty() {
107 let _ = writeln!(
108 out,
109 " destinations: (none declared — every unscoped rule applies)"
110 );
111 } else {
112 let _ = writeln!(out, " destinations:");
113 for (name, kind) in destinations {
114 let applied = applied_rules(spec, name, kind);
115 let list = if applied.is_empty() {
116 "(no rules apply)".to_string()
117 } else {
118 applied.join(", ")
119 };
120 let _ = writeln!(out, " - {name} [{kind}]: {list}");
121 }
122 }
123 out
124}
125
126fn describe_match(rule: &MaskRule) -> String {
127 let m = &rule.matcher;
128 let mut parts: Vec<String> = Vec::new();
129 if let Some(p) = &m.field_pattern {
130 parts.push(format!("field_pattern /{p}/"));
131 }
132 if let Some(d) = m.value_detector {
133 parts.push(format!("detector {d}"));
134 }
135 if !m.fields.is_empty() {
136 parts.push(format!("fields[{}]", m.fields.join(", ")));
137 }
138 parts.join(" | ")
139}
140
141fn describe_action(action: &MaskAction) -> String {
142 match action {
143 MaskAction::Redact { .. } => "redact".to_string(),
144 MaskAction::Hash => "hash".to_string(),
145 MaskAction::Tokenize { prefix } => match prefix {
146 Some(p) => format!("tokenize (prefix '{p}')"),
147 None => "tokenize".to_string(),
148 },
149 MaskAction::Partial { keep_last, .. } => format!("partial (keep_last {keep_last})"),
150 }
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156 use serde_json::json;
157
158 fn spec() -> MaskingSpec {
159 serde_json::from_value(json!({
160 "description": "customer PII",
161 "key": "k",
162 "rules": [
163 { "name": "emails", "match": { "value_detector": "email" },
164 "action": { "type": "redact" } },
165 { "name": "ssn", "match": { "field_pattern": "(?i)ssn" },
166 "action": { "type": "hash" }, "applies_to": ["analytics"] },
167 { "match": { "fields": ["card"] },
168 "action": { "type": "partial", "keep_last": 4 } }
169 ]
170 }))
171 .unwrap()
172 }
173
174 #[test]
175 fn summary_lists_rules_key_and_scope() {
176 let dests = vec![
177 ("default".to_string(), "postgres".to_string()),
178 ("analytics".to_string(), "bigquery".to_string()),
179 ];
180 let out = render_summary(&spec(), &dests);
181 assert!(out.contains("masking — valid (3 rules)"), "{out}");
182 assert!(out.contains("description: customer PII"), "{out}");
183 assert!(out.contains("keyed HMAC-SHA256"), "{out}");
184 assert!(
185 out.contains("emails: detector email → redact (all sinks)"),
186 "{out}"
187 );
188 assert!(
189 out.contains("ssn: field_pattern /(?i)ssn/ → hash (sinks[analytics])"),
190 "{out}"
191 );
192 assert!(
193 out.contains("rule_2: fields[card] → partial (keep_last 4)"),
194 "{out}"
195 );
196 }
197
198 #[test]
199 fn summary_shows_applied_rules_per_destination() {
200 let dests = vec![
201 ("default".to_string(), "postgres".to_string()),
202 ("analytics".to_string(), "bigquery".to_string()),
203 ];
204 let out = render_summary(&spec(), &dests);
205 assert!(
207 out.contains("- default [postgres]: emails, rule_2"),
208 "{out}"
209 );
210 assert!(
212 out.contains("- analytics [bigquery]: emails, ssn, rule_2"),
213 "{out}"
214 );
215 }
216
217 #[test]
218 fn scope_matches_connector_kind_too() {
219 let s: MaskingSpec = serde_json::from_value(json!({
220 "rules": [{ "match": { "fields": ["x"] }, "action": { "type": "redact" },
221 "applies_to": ["bigquery"] }]
222 }))
223 .unwrap();
224 assert_eq!(applied_rules(&s, "warehouse", "bigquery"), vec!["rule_0"]);
226 assert!(applied_rules(&s, "warehouse", "postgres").is_empty());
227 }
228
229 #[test]
230 fn no_destinations_note() {
231 let out = render_summary(&spec(), &[]);
232 assert!(out.contains("none declared"), "{out}");
233 }
234
235 #[test]
236 fn unkeyed_and_tokenize_without_prefix_render() {
237 let s: MaskingSpec = serde_json::from_value(json!({
238 "rules": [{ "name": "tok", "match": { "fields": ["id"] },
239 "action": { "type": "tokenize" } }]
240 }))
241 .unwrap();
242 let out = render_summary(&s, &[("default".into(), "jsonl".into())]);
243 assert!(out.contains("masking — valid (1 rule)"), "{out}");
244 assert!(out.contains("none (unkeyed SHA-256"), "{out}");
245 assert!(
246 out.contains("tok: fields[id] → tokenize (all sinks)"),
247 "{out}"
248 );
249 assert!(out.contains("- default [jsonl]: tok"), "{out}");
250 }
251
252 #[test]
253 fn destinations_reads_singular_sink_and_named_sinks() {
254 use crate::config::PipelineConfig;
255 use std::path::Path;
256 let single = PipelineConfig::from_text(
258 r#"version: 1
259pipeline:
260 source: { type: csv, config: { path: ./in.csv } }
261 masking: { rules: [ { match: { fields: [x] }, action: { type: redact } } ] }
262 sink: { type: jsonl, config: { path: ./out.jsonl } }
263"#,
264 Path::new("test.yaml"),
265 )
266 .unwrap();
267 assert_eq!(
268 destinations(&single),
269 vec![("default".into(), "jsonl".into())]
270 );
271
272 let named = PipelineConfig::from_text(
274 r#"version: 1
275pipeline:
276 source: { type: csv, config: { path: ./in.csv } }
277 masking: { rules: [ { match: { fields: [x] }, action: { type: redact } } ] }
278 sinks:
279 warehouse: { type: bigquery, config: {} }
280 archive: { type: jsonl, config: { path: ./a.jsonl } }
281matrix:
282 - id: a
283 sink: { ref: archive }
284 - id: w
285 sink: { ref: warehouse }
286"#,
287 Path::new("test.yaml"),
288 )
289 .unwrap();
290 assert_eq!(
291 destinations(&named),
292 vec![
293 ("archive".into(), "jsonl".into()),
294 ("warehouse".into(), "bigquery".into()),
295 ]
296 );
297 }
298}