1use 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
12pub fn apply_plan_policy(cfg: &PlannerConfig, ops: &mut [PlanOp]) -> Result<()> {
18 ops.sort_by_key(stable_op_sort_key);
20
21 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 apply_params(&cfg.params, ops);
30 apply_allow_deny(&cfg.allow, &cfg.deny, ops);
31
32 enforce_caps(cfg, ops)?;
34
35 Ok(())
36}
37
38pub 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
107pub 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
153pub 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
199pub 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
243pub fn deterministic_op_id(op: &PlanOp) -> Uuid {
245 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
301pub 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
333pub 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}