Skip to main content

faucet_cli/commands/
masking.rs

1//! `faucet masking` — validate a config's `masking:` block and print which
2//! rules apply to each destination sink (the destination-scoping check).
3//! Offline-safe: secrets are never fetched (compiling a policy needs no
4//! credentials, and the key is not required to list rules).
5
6use crate::cli::MaskingArgs;
7use crate::config::PipelineConfig;
8use crate::error::{CliError, CliResult};
9use faucet_core::masking::{CompiledMasking, MaskAction, MaskRule, MaskingSpec};
10
11/// Execute the `masking` subcommand.
12pub 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    // Compile first so a malformed policy fails before anything is printed.
31    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
38/// The destination sinks declared in the config: each named template under
39/// `pipeline.sinks`, plus the legacy singular `pipeline.sink` as `default`.
40/// Returns `(template_name, connector_kind)` pairs, sorted for stable output.
41fn 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
54/// Labels of the rules that apply to a sink identified by `name` + `kind`.
55fn 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
68/// Render the human summary. Pure — returned as a string for testability.
69fn 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        // default: unscoped rules (emails, rule_2) apply; ssn is analytics-only.
206        assert!(
207            out.contains("- default [postgres]: emails, rule_2"),
208            "{out}"
209        );
210        // analytics: all three (ssn scoped to analytics + the two unscoped).
211        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        // A rule scoped to the `bigquery` KIND applies to a template of that kind.
225        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        // Singular `sink:` → the `default` destination.
257        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        // Named `sinks:` templates → one destination each, sorted.
273        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}