Skip to main content

buildfix_domain_policy/
lib.rs

1//! Reusable domain policy helpers and deterministic plan-op utilities.
2
3use std::collections::{BTreeSet, HashMap};
4
5use anyhow::Result;
6use buildfix_fixer_api::PlannerConfig;
7use buildfix_types::ops::OpKind;
8use buildfix_types::plan::{PlanOp, blocked_tokens};
9use sha2::{Digest, Sha256};
10use uuid::Uuid;
11
12/// Apply all planner-level policy and deterministic-normalization passes.
13///
14/// This is the preferred crate-level entrypoint for `buildfix-domain` policy
15/// application, combining ordering, op-id generation, parameter filling,
16/// allow/deny filtering, and cap enforcement in a single call.
17pub fn apply_plan_policy(cfg: &PlannerConfig, ops: &mut [PlanOp]) -> Result<()> {
18    // Deterministic ordering.
19    ops.sort_by_key(stable_op_sort_key);
20
21    // Deterministic ids.
22    for op in ops.iter_mut() {
23        if op.id.trim().is_empty() {
24            op.id = deterministic_op_id(op).to_string();
25        }
26    }
27
28    // Resolve params and apply policy gates.
29    apply_params(&cfg.params, ops);
30    apply_allow_deny(&cfg.allow, &cfg.deny, ops);
31
32    // Enforce caps by blocking all ops when exceeded.
33    enforce_caps(cfg, ops)?;
34
35    Ok(())
36}
37
38/// Apply explicit user parameters to operations that require them.
39///
40/// Missing parameters leave the operation blocked with `MISSING_PARAMS`.
41pub fn apply_params(params: &HashMap<String, String>, ops: &mut [PlanOp]) {
42    for op in ops {
43        if op.params_required.is_empty() {
44            continue;
45        }
46
47        let mut missing = Vec::new();
48        let required = op.params_required.clone();
49        for key in required {
50            if let Some(value) = params.get(&key) {
51                fill_op_param(op, &key, value);
52            } else {
53                missing.push(key);
54            }
55        }
56
57        if missing.is_empty() {
58            op.params_required.clear();
59        } else {
60            op.blocked = true;
61            op.blocked_reason = Some(format!("missing params: {}", missing.join(", ")));
62            op.blocked_reason_token = Some(blocked_tokens::MISSING_PARAMS.to_string());
63        }
64    }
65}
66
67fn fill_op_param(op: &mut PlanOp, key: &str, value: &str) {
68    let OpKind::TomlTransform { rule_id, args } = &mut op.kind else {
69        return;
70    };
71
72    let mut map = match args.take() {
73        Some(serde_json::Value::Object(m)) => m,
74        _ => serde_json::Map::new(),
75    };
76
77    match (rule_id.as_str(), key) {
78        ("set_package_rust_version", "rust_version") => {
79            map.insert(
80                key.to_string(),
81                serde_json::Value::String(value.to_string()),
82            );
83        }
84        ("set_package_license", "license") => {
85            map.insert(
86                key.to_string(),
87                serde_json::Value::String(value.to_string()),
88            );
89        }
90        ("ensure_path_dep_has_version", "version") => {
91            map.insert(
92                key.to_string(),
93                serde_json::Value::String(value.to_string()),
94            );
95        }
96        _ => {
97            map.insert(
98                key.to_string(),
99                serde_json::Value::String(value.to_string()),
100            );
101        }
102    }
103
104    *args = Some(serde_json::Value::Object(map));
105}
106
107/// Apply allowlist/denylist policy gates on operations.
108///
109/// Existing blocked operations are preserved.
110pub fn apply_allow_deny(allow: &[String], deny: &[String], ops: &mut [PlanOp]) {
111    for op in ops {
112        if op.blocked {
113            continue;
114        }
115
116        let trigger_keys = op_fix_keys(op);
117        if deny
118            .iter()
119            .any(|pat| trigger_keys.iter().any(|k| glob_match(pat, k)))
120        {
121            op.blocked = true;
122            op.blocked_reason = Some("denied by policy".to_string());
123            op.blocked_reason_token = Some(blocked_tokens::DENYLIST.to_string());
124            continue;
125        }
126
127        if !allow.is_empty()
128            && !allow
129                .iter()
130                .any(|pat| trigger_keys.iter().any(|k| glob_match(pat, k)))
131        {
132            op.blocked = true;
133            op.blocked_reason = Some("not in allowlist".to_string());
134            op.blocked_reason_token = Some(blocked_tokens::ALLOWLIST_MISSING.to_string());
135        }
136    }
137}
138
139fn op_fix_keys(op: &PlanOp) -> Vec<String> {
140    if op.rationale.findings.is_empty() {
141        return vec![op.rationale.fix_key.clone()];
142    }
143    op.rationale
144        .findings
145        .iter()
146        .map(|f| {
147            let check = f.check_id.clone().unwrap_or_else(|| "-".to_string());
148            format!("{}/{}/{}", f.source, check, f.code)
149        })
150        .collect()
151}
152
153/// Enforce planning caps (max ops and max files).
154///
155/// Caps are blocking all operations when exceeded.
156pub fn enforce_caps(cfg: &PlannerConfig, ops: &mut [PlanOp]) -> Result<()> {
157    let mut cap_reason: Option<String> = None;
158    let mut cap_token: Option<&str> = None;
159
160    if let Some(max_ops) = cfg.max_ops {
161        let total_ops = ops.len() as u64;
162        if total_ops > max_ops {
163            cap_reason = Some(format!(
164                "caps exceeded: max_ops {} > {} allowed",
165                total_ops, max_ops
166            ));
167            cap_token = Some(blocked_tokens::MAX_OPS);
168        }
169    }
170
171    if cap_reason.is_none()
172        && let Some(max_files) = cfg.max_files
173    {
174        let files = ops
175            .iter()
176            .map(|o| o.target.path.as_str())
177            .collect::<BTreeSet<_>>();
178        let total_files = files.len() as u64;
179        if total_files > max_files {
180            cap_reason = Some(format!(
181                "caps exceeded: max_files {} > {} allowed",
182                total_files, max_files
183            ));
184            cap_token = Some(blocked_tokens::MAX_FILES);
185        }
186    }
187
188    if let Some(reason) = cap_reason {
189        for op in ops.iter_mut() {
190            op.blocked = true;
191            op.blocked_reason = Some(reason.clone());
192            op.blocked_reason_token = cap_token.map(|t| t.to_string());
193        }
194    }
195
196    Ok(())
197}
198
199/// Stable sort key for deterministic plan operation ordering.
200pub fn stable_op_sort_key(op: &PlanOp) -> String {
201    let op_key = op_sort_key(op);
202    format!("{}|{}|{}", op.rationale.fix_key, op.target.path, op_key)
203}
204
205fn op_sort_key(op: &PlanOp) -> String {
206    match &op.kind {
207        OpKind::TomlTransform { rule_id, args } => {
208            format!("transform|{}|{}", rule_id, args_fingerprint(args))
209        }
210        OpKind::TomlSet { toml_path, .. } => format!("set|{}", toml_path.join(".")),
211        OpKind::TomlRemove { toml_path } => format!("remove|{}", toml_path.join(".")),
212        OpKind::JsonSet { json_path, value } => format!(
213            "json_set|{}|{}",
214            json_path.join("."),
215            args_fingerprint(&Some(value.clone()))
216        ),
217        OpKind::JsonRemove { json_path } => format!("json_remove|{}", json_path.join(".")),
218        OpKind::YamlSet { yaml_path, value } => format!(
219            "yaml_set|{}|{}",
220            yaml_path.join("."),
221            args_fingerprint(&Some(value.clone()))
222        ),
223        OpKind::YamlRemove { yaml_path } => format!("yaml_remove|{}", yaml_path.join(".")),
224        OpKind::TextReplaceAnchored {
225            find,
226            replace,
227            anchor_before,
228            anchor_after,
229            max_replacements,
230        } => format!(
231            "text_replace_anchored|{}|{}|{}|{}|{}",
232            find,
233            replace,
234            anchor_before.join("\x1f"),
235            anchor_after.join("\x1f"),
236            max_replacements
237                .map(|n| n.to_string())
238                .unwrap_or_else(|| "none".to_string())
239        ),
240    }
241}
242
243/// Deterministic plan-op ID based on fix key, target path, rule kind and args.
244pub fn deterministic_op_id(op: &PlanOp) -> Uuid {
245    // Deterministic ID: v5(namespace, stable_key_bytes)
246    const NAMESPACE: Uuid = Uuid::from_bytes([
247        0x4b, 0x5d, 0x35, 0x58, 0x06, 0x58, 0x4c, 0x05, 0x8e, 0x8c, 0x0b, 0x1a, 0x44, 0x53, 0x52,
248        0xd1,
249    ]);
250
251    let rule_id = match &op.kind {
252        OpKind::TomlTransform { rule_id, .. } => rule_id.as_str(),
253        OpKind::TomlSet { .. } => "toml_set",
254        OpKind::TomlRemove { .. } => "toml_remove",
255        OpKind::JsonSet { .. } => "json_set",
256        OpKind::JsonRemove { .. } => "json_remove",
257        OpKind::YamlSet { .. } => "yaml_set",
258        OpKind::YamlRemove { .. } => "yaml_remove",
259        OpKind::TextReplaceAnchored { .. } => "text_replace_anchored",
260    };
261
262    let kind_fingerprint = match &op.kind {
263        OpKind::TomlTransform { args, .. } => args_fingerprint(args),
264        OpKind::JsonSet { json_path, value } => args_fingerprint(&Some(serde_json::json!({
265            "json_path": json_path,
266            "value": value,
267        }))),
268        OpKind::JsonRemove { json_path } => args_fingerprint(&Some(serde_json::json!({
269            "json_path": json_path,
270        }))),
271        OpKind::YamlSet { yaml_path, value } => args_fingerprint(&Some(serde_json::json!({
272            "yaml_path": yaml_path,
273            "value": value,
274        }))),
275        OpKind::YamlRemove { yaml_path } => args_fingerprint(&Some(serde_json::json!({
276            "yaml_path": yaml_path,
277        }))),
278        OpKind::TextReplaceAnchored {
279            find,
280            replace,
281            anchor_before,
282            anchor_after,
283            max_replacements,
284        } => args_fingerprint(&Some(serde_json::json!({
285            "find": find,
286            "replace": replace,
287            "anchor_before": anchor_before,
288            "anchor_after": anchor_after,
289            "max_replacements": max_replacements,
290        }))),
291        _ => args_fingerprint(&None),
292    };
293
294    let stable_key = format!(
295        "{}|{}|{}|{}",
296        op.rationale.fix_key, op.target.path, rule_id, kind_fingerprint
297    );
298    Uuid::new_v5(&NAMESPACE, stable_key.as_bytes())
299}
300
301/// Fingerprint arbitrary JSON with deterministic key order.
302pub fn args_fingerprint(args: &Option<serde_json::Value>) -> String {
303    let Some(value) = args else {
304        return "no_args".to_string();
305    };
306    let canonical = canonicalize_json(value);
307    let s = serde_json::to_string(&canonical).unwrap_or_default();
308    let mut hasher = Sha256::new();
309    hasher.update(s.as_bytes());
310    hex::encode(hasher.finalize())
311}
312
313fn canonicalize_json(value: &serde_json::Value) -> serde_json::Value {
314    match value {
315        serde_json::Value::Object(map) => {
316            let mut keys: Vec<_> = map.keys().cloned().collect();
317            keys.sort();
318            let mut out = serde_json::Map::new();
319            for k in keys {
320                if let Some(v) = map.get(&k) {
321                    out.insert(k, canonicalize_json(v));
322                }
323            }
324            serde_json::Value::Object(out)
325        }
326        serde_json::Value::Array(items) => {
327            serde_json::Value::Array(items.iter().map(canonicalize_json).collect())
328        }
329        other => other.clone(),
330    }
331}
332
333/// Lightweight wildcard matcher for policy keys.
334///
335/// Supports `*` and `?`.
336pub fn glob_match(pat: &str, text: &str) -> bool {
337    let p = pat.as_bytes();
338    let t = text.as_bytes();
339    let mut dp = vec![vec![false; t.len() + 1]; p.len() + 1];
340    dp[0][0] = true;
341
342    for i in 1..=p.len() {
343        if p[i - 1] == b'*' {
344            dp[i][0] = dp[i - 1][0];
345        }
346    }
347
348    for i in 1..=p.len() {
349        for j in 1..=t.len() {
350            dp[i][j] = match p[i - 1] {
351                b'*' => dp[i - 1][j] || dp[i][j - 1],
352                b'?' => dp[i - 1][j - 1],
353                c => dp[i - 1][j - 1] && c == t[j - 1],
354            };
355        }
356    }
357
358    dp[p.len()][t.len()]
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use std::collections::HashMap;
365
366    fn make_toml_plan_op(path: &str, rule_id: &str, fix_key: &str) -> buildfix_types::plan::PlanOp {
367        buildfix_types::plan::PlanOp {
368            id: String::new(),
369            safety: buildfix_types::ops::SafetyClass::Safe,
370            blocked: false,
371            blocked_reason: None,
372            blocked_reason_token: None,
373            target: buildfix_types::ops::OpTarget {
374                path: path.to_string(),
375            },
376            kind: buildfix_types::ops::OpKind::TomlTransform {
377                rule_id: rule_id.to_string(),
378                args: Some(serde_json::json!({
379                    "version": "1.0",
380                })),
381            },
382            rationale: buildfix_types::plan::Rationale {
383                fix_key: fix_key.to_string(),
384                description: None,
385                findings: vec![],
386            },
387            params_required: vec![],
388            preview: None,
389        }
390    }
391
392    #[test]
393    fn apply_plan_policy_assigns_ids_and_blocks_on_caps() {
394        let mut ops = vec![
395            make_toml_plan_op(
396                "b/Cargo.toml",
397                "set_package_rust_version",
398                "cargo.normalize_rust_version",
399            ),
400            make_toml_plan_op(
401                "a/Cargo.toml",
402                "set_package_rust_version",
403                "cargo.normalize_rust_version",
404            ),
405        ];
406
407        let cfg = PlannerConfig {
408            allow: vec![],
409            deny: vec![],
410            allow_guarded: false,
411            allow_unsafe: false,
412            allow_dirty: false,
413            max_ops: Some(1),
414            max_files: None,
415            max_patch_bytes: None,
416            params: HashMap::new(),
417        };
418
419        apply_plan_policy(&cfg, &mut ops).expect("apply policy");
420
421        assert!(ops.iter().all(|op| !op.id.is_empty()));
422        assert_eq!(ops[0].target.path, "a/Cargo.toml");
423        assert_eq!(ops[1].target.path, "b/Cargo.toml");
424        assert!(ops.iter().all(|op| op.blocked));
425        assert_eq!(
426            ops[0].blocked_reason_token.as_deref(),
427            Some(blocked_tokens::MAX_OPS)
428        );
429    }
430
431    #[test]
432    fn apply_plan_policy_applies_params_and_allow_policy() {
433        let op = buildfix_types::plan::PlanOp {
434            id: String::new(),
435            safety: buildfix_types::ops::SafetyClass::Safe,
436            blocked: false,
437            blocked_reason: None,
438            blocked_reason_token: None,
439            target: buildfix_types::ops::OpTarget {
440                path: "a/Cargo.toml".into(),
441            },
442            kind: buildfix_types::ops::OpKind::TomlTransform {
443                rule_id: "set_package_license".into(),
444                args: None,
445            },
446            rationale: buildfix_types::plan::Rationale {
447                fix_key: "cargo.normalize_license".into(),
448                description: None,
449                findings: vec![],
450            },
451            params_required: vec!["license".to_string()],
452            preview: None,
453        };
454
455        let mut ops = vec![op];
456        let cfg = PlannerConfig {
457            allow: vec!["cargo.*".into()],
458            deny: vec![],
459            allow_guarded: false,
460            allow_unsafe: false,
461            allow_dirty: false,
462            max_ops: None,
463            max_files: None,
464            max_patch_bytes: None,
465            params: {
466                let mut map = HashMap::new();
467                map.insert("license".to_string(), "MIT".to_string());
468                map
469            },
470        };
471
472        apply_plan_policy(&cfg, &mut ops).expect("apply policy");
473
474        match &ops[0].kind {
475            buildfix_types::ops::OpKind::TomlTransform {
476                args: Some(value), ..
477            } => {
478                assert_eq!(value["license"], serde_json::json!("MIT"));
479            }
480            _ => panic!("expected toml transform"),
481        }
482
483        assert!(ops[0].params_required.is_empty());
484        assert!(!ops[0].blocked);
485        assert!(ops[0].blocked_reason.is_none());
486    }
487
488    #[test]
489    fn glob_match_handles_wildcards() {
490        assert!(glob_match("a*b", "acb"));
491        assert!(!glob_match("a?b", "ab"));
492    }
493
494    #[test]
495    fn stable_ids_and_fingerprint_are_consistent() {
496        let _op = serde_json::json!({
497            "rationale": {
498                "fix_key": "cargo.workspace_resolver_v2",
499                "findings": []
500            },
501            "target": { "path": "Cargo.toml" },
502            "kind": {
503                "type": "toml_transform",
504                "rule_id": "ensure_workspace_resolver_v2",
505                "args": {
506                    "a": 1,
507                    "b": 2,
508                },
509            }
510        });
511
512        let op1 = buildfix_types::plan::PlanOp {
513            id: "".into(),
514            safety: buildfix_types::ops::SafetyClass::Safe,
515            blocked: false,
516            blocked_reason: None,
517            blocked_reason_token: None,
518            target: buildfix_types::ops::OpTarget {
519                path: "Cargo.toml".into(),
520            },
521            kind: buildfix_types::ops::OpKind::TomlTransform {
522                rule_id: "ensure_workspace_resolver_v2".into(),
523                args: Some(serde_json::json!({
524                    "a": 1,
525                    "b": 2,
526                })),
527            },
528            rationale: buildfix_types::plan::Rationale {
529                fix_key: "cargo.workspace_resolver_v2".into(),
530                description: None,
531                findings: vec![],
532            },
533            params_required: vec![],
534            preview: None,
535        };
536
537        let mut map1 = serde_json::Map::new();
538        map1.insert(
539            "b".to_string(),
540            serde_json::Value::Number(serde_json::Number::from(1)),
541        );
542        map1.insert(
543            "a".to_string(),
544            serde_json::Value::Number(serde_json::Number::from(2)),
545        );
546
547        let mut map2 = serde_json::Map::new();
548        map2.insert(
549            "a".to_string(),
550            serde_json::Value::Number(serde_json::Number::from(2)),
551        );
552        map2.insert(
553            "b".to_string(),
554            serde_json::Value::Number(serde_json::Number::from(1)),
555        );
556
557        assert_eq!(
558            args_fingerprint(&Some(serde_json::Value::Object(map1))),
559            args_fingerprint(&Some(serde_json::Value::Object(map2)))
560        );
561        assert!(op1.id.is_empty());
562        let other = buildfix_types::plan::PlanOp { ..op1.clone() };
563        assert_eq!(deterministic_op_id(&op1), deterministic_op_id(&other));
564    }
565
566    #[test]
567    fn policy_limits_block_all_ops_when_exceeded() {
568        let mut ops = vec![
569            buildfix_types::plan::PlanOp {
570                id: String::new(),
571                safety: buildfix_types::ops::SafetyClass::Safe,
572                blocked: false,
573                blocked_reason: None,
574                blocked_reason_token: None,
575                target: buildfix_types::ops::OpTarget { path: "a".into() },
576                kind: buildfix_types::ops::OpKind::TomlTransform {
577                    rule_id: "set_package_rust_version".into(),
578                    args: None,
579                },
580                rationale: buildfix_types::plan::Rationale {
581                    fix_key: "cargo.normalize_rust_version".into(),
582                    description: None,
583                    findings: vec![],
584                },
585                params_required: vec![],
586                preview: None,
587            },
588            buildfix_types::plan::PlanOp {
589                id: String::new(),
590                safety: buildfix_types::ops::SafetyClass::Safe,
591                blocked: false,
592                blocked_reason: None,
593                blocked_reason_token: None,
594                target: buildfix_types::ops::OpTarget { path: "b".into() },
595                kind: buildfix_types::ops::OpKind::TomlTransform {
596                    rule_id: "set_package_rust_version".into(),
597                    args: None,
598                },
599                rationale: buildfix_types::plan::Rationale {
600                    fix_key: "cargo.normalize_rust_version".into(),
601                    description: None,
602                    findings: vec![],
603                },
604                params_required: vec![],
605                preview: None,
606            },
607        ];
608
609        let cfg = PlannerConfig {
610            allow: vec![],
611            deny: vec![],
612            allow_guarded: false,
613            allow_unsafe: false,
614            allow_dirty: false,
615            max_ops: Some(1),
616            max_files: None,
617            max_patch_bytes: None,
618            params: HashMap::new(),
619        };
620
621        enforce_caps(&cfg, &mut ops).expect("caps");
622        assert!(ops.iter().all(|op| op.blocked));
623    }
624}