1use std::collections::HashMap;
19
20use didbot_policy::{PolicyId, PolicyTree, Revision};
21
22use crate::builtin::BuiltinPolicy;
23use crate::compile::{
24 compile_policies, compiled_successfully, register, resolve_predicate, CompileReport, Language,
25 PolicyWarning, ScopedDenial,
26};
27use crate::last_good::LastGoodPolicies;
28use crate::record::ParsedPolicy;
29
30#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct LastGoodFallback {
37 pub id: String,
39 pub enforced_revision: Revision,
43 pub failed_revision: Revision,
46 pub reason: String,
48}
49
50#[derive(Debug, Clone, Default)]
56pub struct LoadReport {
57 pub scoped_denials: Vec<ScopedDenial>,
61 pub warnings: Vec<PolicyWarning>,
65 pub last_good_fallbacks: Vec<LastGoodFallback>,
68 pub malformed: Vec<String>,
72 pub registered: HashMap<String, PolicyId>,
77}
78
79pub fn build(
94 builtins: Vec<BuiltinPolicy>,
95 startup: &HashMap<String, ParsedPolicy>,
96 operator: &HashMap<String, ParsedPolicy>,
97 languages: &[&dyn Language],
98 last_good: &LastGoodPolicies,
99) -> (PolicyTree, LoadReport) {
100 let mut builder = PolicyTree::builder();
101 let mut report = LoadReport::default();
102
103 let builtins: HashMap<String, ParsedPolicy> = builtins
107 .into_iter()
108 .map(|builtin| (builtin.name, builtin.policy))
109 .collect();
110 let builtin_report = compile_policies(by_id(&builtins), languages, &mut builder);
111 report.scoped_denials.extend(builtin_report.denials);
112 report.warnings.extend(builtin_report.warnings);
113 report.registered.extend(builtin_report.registered);
114
115 let startup_report = compile_policies(by_id(startup), languages, &mut builder);
116 report.scoped_denials.extend(startup_report.denials);
117 report.warnings.extend(startup_report.warnings);
118 report.registered.extend(startup_report.registered);
119
120 for (id, policy) in by_id(operator) {
121 let mut attempt = CompileReport::default();
122 let predicate = resolve_predicate(id, policy, languages, &mut builder, &mut attempt);
123
124 if compiled_successfully(&attempt, id) {
125 report.warnings.extend(attempt.warnings);
128 last_good.record_success(id, policy);
129 let registered = register(&mut builder, policy, predicate);
130 report.registered.insert(id.to_string(), registered);
131 continue;
132 }
133
134 match last_good.get(id) {
143 Some((good_revision, good_policy)) => {
144 let mut fallback_report = CompileReport::default();
145 let fallback_predicate = resolve_predicate(
146 id,
147 &good_policy,
148 languages,
149 &mut builder,
150 &mut fallback_report,
151 );
152 let registered = register(&mut builder, &good_policy, fallback_predicate);
153 report.registered.insert(id.to_string(), registered);
154 report.last_good_fallbacks.push(LastGoodFallback {
155 id: id.to_string(),
156 enforced_revision: good_revision,
157 failed_revision: policy.revision,
158 reason: attempt
159 .denials
160 .iter()
161 .find(|denial| denial.id == id)
162 .map(|denial| denial.reason.clone())
163 .unwrap_or_default(),
164 });
165 }
166 None => {
167 report.scoped_denials.extend(attempt.denials);
172 let registered = register(&mut builder, policy, predicate);
173 report.registered.insert(id.to_string(), registered);
174 }
175 }
176 }
177 last_good.prune(operator);
178
179 (builder.build(), report)
180}
181
182fn by_id(records: &std::collections::HashMap<String, ParsedPolicy>) -> Vec<(&str, &ParsedPolicy)> {
194 let mut ordered: Vec<(&str, &ParsedPolicy)> = records
195 .iter()
196 .map(|(id, policy)| (id.as_str(), policy))
197 .collect();
198 ordered.sort_unstable_by_key(|(id, _)| *id);
199 ordered
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 use didbot_policy::{
206 AccountKind, ActionSet, Applicability, DeploymentAttributes, Diff, EvalError, Evaluator,
207 KindSet, Lifecycle, Observed, Outcome, Scope, Staleness, Subject, SubjectAttributes,
208 SubjectKind, Universal, WriteAction,
209 };
210 use std::collections::HashMap;
211 use std::sync::{Arc, Mutex};
212
213 struct RejectAll;
214 impl Evaluator for RejectAll {
215 fn compile(&self, _predicate: &serde_json::Value) -> didbot_policy::CompileOutcome {
216 didbot_policy::CompileOutcome::Compiled(didbot_policy::CompiledId(0))
217 }
218 fn evaluate(
219 &self,
220 _subject: &Subject<'_>,
221 _compiled: didbot_policy::CompiledId,
222 ) -> Result<Outcome, EvalError> {
223 Ok(Outcome::Reject {
224 reason: "builtin denial".into(),
225 })
226 }
227 fn observe(&self, _event: &Observed<'_>) {}
228 fn lifecycle(&self, _event: &Lifecycle<'_>) {}
229 fn staleness(&self) -> Staleness {
230 Staleness::Linearized
231 }
232 }
233
234 fn dummy_subject() -> Subject<'static> {
235 Subject::Write {
236 account: "did:plc:agent",
237 attributes: SubjectAttributes {
238 kind: SubjectKind::Known(AccountKind::Agent),
239 handle: None,
240 depth: None,
241 },
242 universal: Universal {
243 deployment: DeploymentAttributes::default(),
244 now: time::macros::datetime!(2026 - 01 - 01 0:00 UTC),
245 },
246 client_id: None,
247 collection: "app.bsky.feed.post",
248 action: WriteAction::Create,
249 diff: Diff::default(),
250 before: None,
251 after: None,
252 lookups: &[],
253 }
254 }
255
256 #[test]
260 fn a_builtin_alone_still_denies() {
261 struct RejectAllLanguage;
262 impl Language for RejectAllLanguage {
263 fn name(&self) -> &str {
264 "reject-all"
265 }
266 fn new_evaluator(&self) -> Arc<dyn Evaluator> {
267 Arc::new(RejectAll)
268 }
269 }
270 let applicability = Applicability::Write {
271 kinds: KindSet::ALL,
272 collections: Scope::Any,
273 actions: ActionSet::ALL,
274 path_prefixes: Scope::Any,
275 };
276 let builtins = vec![BuiltinPolicy {
277 name: "builtin/reject-all/0/record.write".to_owned(),
278 policy: ParsedPolicy {
279 observes: applicability.clone(),
280 applicability,
281 staleness: Staleness::Linearized,
282 language: "reject-all".to_owned(),
283 predicate: serde_json::json!({}),
284 revision: Revision(0),
285 lookups: Vec::new(),
286 },
287 }];
288 let (tree, report) = build(
289 builtins,
290 &HashMap::new(),
291 &HashMap::new(),
292 &[&RejectAllLanguage],
293 &LastGoodPolicies::new(),
294 );
295 assert!(report.scoped_denials.is_empty());
296 assert!(matches!(
297 didbot_policy::evaluate(&dummy_subject(), &tree),
298 didbot_policy::Verdict::Reject { .. }
299 ));
300 }
301
302 #[test]
309 fn the_reason_reported_is_stable_across_two_builds_of_the_same_records() {
310 fn records(n: usize) -> HashMap<String, ParsedPolicy> {
311 (0..n)
312 .map(|i| {
313 let policy = ParsedPolicy {
314 applicability: Applicability::Write {
315 kinds: KindSet::ALL,
316 collections: Scope::Any,
317 actions: ActionSet::ALL,
318 path_prefixes: Scope::Any,
319 },
320 observes: Applicability::Write {
321 kinds: KindSet::ALL,
322 collections: Scope::Any,
323 actions: ActionSet::ALL,
324 path_prefixes: Scope::Any,
325 },
326 staleness: Staleness::Linearized,
327 language: "regex".to_string(),
328 predicate: serde_json::json!({}),
329 revision: didbot_policy::Revision(1),
330 lookups: Vec::new(),
331 };
332 (format!("at://op/bot.did.policy/{i:02}"), policy)
333 })
334 .collect()
335 }
336
337 let first = build(
341 Vec::new(),
342 &HashMap::new(),
343 &records(16),
344 &[],
345 &LastGoodPolicies::new(),
346 );
347 let second = build(
348 Vec::new(),
349 &HashMap::new(),
350 &records(16),
351 &[],
352 &LastGoodPolicies::new(),
353 );
354
355 let reason_of = |tree: &didbot_policy::PolicyTree| match didbot_policy::evaluate(
356 &dummy_subject(),
357 tree,
358 ) {
359 didbot_policy::Verdict::Reject { reason, policy } => (reason, policy),
360 other => panic!("expected a refusal, got {other:?}"),
361 };
362 assert_eq!(reason_of(&first.0), reason_of(&second.0));
363
364 let ids = |report: &LoadReport| -> Vec<String> {
366 report.scoped_denials.iter().map(|d| d.id.clone()).collect()
367 };
368 assert_eq!(ids(&first.1), ids(&second.1));
369 }
370
371 #[test]
372 fn an_empty_build_allows_everything() {
373 let (tree, report) = build(
374 Vec::new(),
375 &HashMap::new(),
376 &HashMap::new(),
377 &[],
378 &LastGoodPolicies::new(),
379 );
380 assert!(report.scoped_denials.is_empty());
381 assert_eq!(
382 didbot_policy::evaluate(&dummy_subject(), &tree),
383 didbot_policy::Verdict::Allow
384 );
385 }
386
387 struct SwitchableEvaluator {
392 reason: Mutex<Option<String>>,
393 }
394 impl SwitchableEvaluator {
395 fn new() -> Self {
396 Self {
397 reason: Mutex::new(None),
398 }
399 }
400 }
401 impl Evaluator for SwitchableEvaluator {
402 fn compile(&self, predicate: &serde_json::Value) -> didbot_policy::CompileOutcome {
403 let valid = predicate
404 .get("valid")
405 .and_then(|v| v.as_bool())
406 .unwrap_or(false);
407 let reason = predicate
408 .get("reason")
409 .and_then(|v| v.as_str())
410 .unwrap_or("denied")
411 .to_string();
412 if valid {
413 *self.reason.lock().unwrap() = Some(reason);
414 didbot_policy::CompileOutcome::Compiled(didbot_policy::CompiledId(0))
415 } else {
416 didbot_policy::CompileOutcome::Rejected(reason)
417 }
418 }
419 fn evaluate(
420 &self,
421 _subject: &Subject<'_>,
422 _compiled: didbot_policy::CompiledId,
423 ) -> Result<Outcome, EvalError> {
424 let reason = self.reason.lock().unwrap().clone().unwrap_or_default();
425 Ok(Outcome::Reject { reason })
426 }
427 fn observe(&self, _event: &Observed<'_>) {}
428 fn lifecycle(&self, _event: &Lifecycle<'_>) {}
429 fn staleness(&self) -> Staleness {
430 Staleness::Linearized
431 }
432 }
433 struct SwitchableLanguage;
434 impl Language for SwitchableLanguage {
435 fn name(&self) -> &str {
436 "switchable"
437 }
438 fn new_evaluator(&self) -> Arc<dyn Evaluator> {
439 Arc::new(SwitchableEvaluator::new())
440 }
441 }
442
443 fn switchable_policy(valid: bool, reason: &str, revision: u64) -> ParsedPolicy {
444 ParsedPolicy {
445 applicability: Applicability::Write {
446 kinds: KindSet::ALL,
447 collections: Scope::Any,
448 actions: ActionSet::ALL,
449 path_prefixes: Scope::Any,
450 },
451 observes: Applicability::Write {
452 kinds: KindSet::ALL,
453 collections: Scope::Any,
454 actions: ActionSet::ALL,
455 path_prefixes: Scope::Any,
456 },
457 staleness: Staleness::Linearized,
458 language: "switchable".to_string(),
459 predicate: serde_json::json!({
460 "valid": valid,
461 "reason": reason,
462 }),
463 revision: didbot_policy::Revision(revision),
464 lookups: Vec::new(),
465 }
466 }
467
468 #[test]
473 fn a_bad_revision_falls_back_to_the_last_good_one() {
474 let last_good = LastGoodPolicies::new();
475 let languages: Vec<&dyn Language> = vec![&SwitchableLanguage];
476
477 let mut operator = HashMap::new();
478 operator.insert(
479 "at://operator/bot.did.policy/1".to_string(),
480 switchable_policy(true, "revision one enforced", 1),
481 );
482 let (tree, report) = build(
483 Vec::new(),
484 &HashMap::new(),
485 &operator,
486 &languages,
487 &last_good,
488 );
489 assert!(report.scoped_denials.is_empty());
490 assert!(report.last_good_fallbacks.is_empty());
491 assert!(matches!(
492 didbot_policy::evaluate(&dummy_subject(), &tree),
493 didbot_policy::Verdict::Reject { ref reason, .. } if reason == "revision one enforced"
494 ));
495
496 operator.insert(
498 "at://operator/bot.did.policy/1".to_string(),
499 switchable_policy(false, "revision two is broken", 2),
500 );
501 let (tree, report) = build(
502 Vec::new(),
503 &HashMap::new(),
504 &operator,
505 &languages,
506 &last_good,
507 );
508 assert!(
509 report.scoped_denials.is_empty(),
510 "a bad revision with a last-good fallback is not a plain scoped denial"
511 );
512 assert_eq!(report.last_good_fallbacks.len(), 1);
513 assert_eq!(
514 report.last_good_fallbacks[0].enforced_revision,
515 didbot_policy::Revision(1)
516 );
517 assert_eq!(
518 report.last_good_fallbacks[0].failed_revision,
519 didbot_policy::Revision(2)
520 );
521 assert!(matches!(
525 didbot_policy::evaluate(&dummy_subject(), &tree),
526 didbot_policy::Verdict::Reject { ref reason, .. } if reason == "revision one enforced"
527 ));
528 }
529
530 #[test]
535 fn a_policy_that_never_compiled_is_a_plain_scoped_denial() {
536 let last_good = LastGoodPolicies::new();
537 let languages: Vec<&dyn Language> = vec![&SwitchableLanguage];
538
539 let mut operator = HashMap::new();
540 operator.insert(
541 "at://operator/bot.did.policy/1".to_string(),
542 switchable_policy(false, "never worked", 1),
543 );
544 let (tree, report) = build(
545 Vec::new(),
546 &HashMap::new(),
547 &operator,
548 &languages,
549 &last_good,
550 );
551 assert_eq!(report.scoped_denials.len(), 1);
552 assert!(report.last_good_fallbacks.is_empty());
553 assert!(matches!(
558 didbot_policy::evaluate(&dummy_subject(), &tree),
559 didbot_policy::Verdict::Reject { .. }
560 ));
561 }
562
563 #[test]
567 fn a_later_good_revision_replaces_the_fallback() {
568 let last_good = LastGoodPolicies::new();
569 let languages: Vec<&dyn Language> = vec![&SwitchableLanguage];
570 let id = "at://operator/bot.did.policy/1".to_string();
571
572 let mut operator = HashMap::new();
573 operator.insert(id.clone(), switchable_policy(true, "v1", 1));
574 build(
575 Vec::new(),
576 &HashMap::new(),
577 &operator,
578 &languages,
579 &last_good,
580 );
581
582 operator.insert(id.clone(), switchable_policy(false, "v2 broken", 2));
583 build(
584 Vec::new(),
585 &HashMap::new(),
586 &operator,
587 &languages,
588 &last_good,
589 );
590
591 operator.insert(id.clone(), switchable_policy(true, "v3", 3));
592 let (tree, report) = build(
593 Vec::new(),
594 &HashMap::new(),
595 &operator,
596 &languages,
597 &last_good,
598 );
599 assert!(report.last_good_fallbacks.is_empty());
600 assert!(matches!(
601 didbot_policy::evaluate(&dummy_subject(), &tree),
602 didbot_policy::Verdict::Reject { ref reason, .. } if reason == "v3"
603 ));
604 }
605
606 #[test]
616 fn a_deleted_id_leaves_nothing_for_a_later_record_to_fall_back_onto() {
617 let last_good = LastGoodPolicies::new();
618 let languages: Vec<&dyn Language> = vec![&SwitchableLanguage];
619 let id = "at://operator/bot.did.policy/reused-rkey".to_string();
620
621 let mut operator = HashMap::new();
623 operator.insert(id.clone(), switchable_policy(true, "the deleted policy", 1));
624 let (tree, _) = build(
625 Vec::new(),
626 &HashMap::new(),
627 &operator,
628 &languages,
629 &last_good,
630 );
631 assert!(matches!(
632 didbot_policy::evaluate(&dummy_subject(), &tree),
633 didbot_policy::Verdict::Reject { ref reason, .. } if reason == "the deleted policy"
634 ));
635
636 operator.remove(&id);
638 let (tree, report) = build(
639 Vec::new(),
640 &HashMap::new(),
641 &operator,
642 &languages,
643 &last_good,
644 );
645 assert!(report.last_good_fallbacks.is_empty());
646 assert_eq!(
647 didbot_policy::evaluate(&dummy_subject(), &tree),
648 didbot_policy::Verdict::Allow,
649 "a deleted policy must not still be enforced"
650 );
651
652 operator.insert(id.clone(), switchable_policy(false, "a fresh typo", 1));
655 let (tree, report) = build(
656 Vec::new(),
657 &HashMap::new(),
658 &operator,
659 &languages,
660 &last_good,
661 );
662 assert!(
663 report.last_good_fallbacks.is_empty(),
664 "the deleted policy is not a revision this record can fall back to"
665 );
666 assert_eq!(report.scoped_denials.len(), 1);
667 match didbot_policy::evaluate(&dummy_subject(), &tree) {
668 didbot_policy::Verdict::Reject { reason, .. } => assert_ne!(
669 reason, "the deleted policy",
670 "a deleted policy's predicate was resurrected at a reused at-uri"
671 ),
672 other => panic!("a never-compiled predicate must still deny, got {other:?}"),
673 }
674 }
675}