1use std::collections::{BTreeMap, HashMap};
10
11use crate::AcrossBuilds;
12use crate::{BuiltinPolicy, LoadReport, ParsedPolicy};
13use didbot_policy::{ActionSet, Applicability, KindSet, PolicyTree, Revision, Scope, Staleness};
14use didbot_policy_cedar::{ActionName as CedarAction, Document as CedarDocument, LoadError};
15use didbot_policy_regex::RegexEvaluator;
16use serde_json::Value;
17use sha2::{Digest, Sha256};
18
19use crate::engine::engine_for;
20use crate::records::{
21 Action, Document, PolicyBinding, PolicyRecord, PolicyRef, BINDING_COLLECTION, POLICY_COLLECTION,
22};
23
24#[derive(Debug, Clone, Default, PartialEq, Eq)]
29pub struct Observed {
30 pub author: String,
32 pub policies: BTreeMap<String, Value>,
34 pub bindings: BTreeMap<String, Value>,
36}
37
38#[derive(Debug, Clone, Default, PartialEq, Eq)]
40pub struct PolicyRevision {
41 pub policies: BTreeMap<String, PolicyRecord>,
43 pub bindings: BTreeMap<String, PolicyBinding>,
45 pub digest: String,
49}
50
51impl PolicyRevision {
52 #[must_use]
54 pub fn digest_of(
55 policies: &BTreeMap<String, PolicyRecord>,
56 bindings: &BTreeMap<String, PolicyBinding>,
57 ) -> String {
58 let mut hasher = Sha256::new();
59 let mut line = |collection: &str, key: &str, body: &Value| {
60 hasher.update(collection.as_bytes());
61 hasher.update(b"\t");
62 hasher.update(key.as_bytes());
63 hasher.update(b"\t");
64 hasher.update(didbot_policy::sorted_json(body).as_bytes());
65 hasher.update(b"\n");
66 };
67 for (key, policy) in policies {
68 line(POLICY_COLLECTION, key, &policy.to_json());
69 }
70 for (key, binding) in bindings {
71 line(BINDING_COLLECTION, key, &binding.to_json());
72 }
73 hex::encode(hasher.finalize())
74 }
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
79pub struct BuildFailure {
80 pub collection: &'static str,
82 pub key: String,
84 pub reason: String,
86}
87
88impl BuildFailure {
89 pub fn policy(key: &str, reason: impl Into<String>) -> Self {
91 Self {
92 collection: POLICY_COLLECTION,
93 key: key.to_owned(),
94 reason: reason.into(),
95 }
96 }
97
98 pub fn binding(key: &str, reason: impl Into<String>) -> Self {
100 Self {
101 collection: BINDING_COLLECTION,
102 key: key.to_owned(),
103 reason: reason.into(),
104 }
105 }
106}
107
108impl std::fmt::Display for BuildFailure {
109 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110 write!(f, "{}/{}: {}", self.collection, self.key, self.reason)
111 }
112}
113
114#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
117pub struct RejectedBuild {
118 pub failures: Vec<BuildFailure>,
120}
121
122impl RejectedBuild {
123 #[must_use]
125 pub fn of(mut failures: Vec<BuildFailure>) -> Self {
126 failures.sort_by(|a, b| {
127 (a.collection != POLICY_COLLECTION, &a.key)
128 .cmp(&(b.collection != POLICY_COLLECTION, &b.key))
129 });
130 Self { failures }
131 }
132}
133
134impl std::fmt::Display for RejectedBuild {
135 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136 write!(f, "{} record(s) refused", self.failures.len())?;
137 for failure in &self.failures {
138 write!(f, "; {failure}")?;
139 }
140 Ok(())
141 }
142}
143
144impl std::error::Error for RejectedBuild {}
145
146#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
148pub enum DeclarationError {
149 #[error("`{action}` is an action this server does not answer")]
151 Action {
152 at: usize,
154 action: String,
156 },
157 #[error("`{0}` names an engine this server does not carry")]
159 Engine(String),
160 #[error("statement {at}: {reason}")]
162 Statement {
163 at: usize,
165 reason: String,
167 },
168 #[error("statement {0}")]
171 Cedar(LoadError),
172 #[error("statement {0} decides none of the actions this policy names")]
174 Undecided(StatementLabel),
175}
176
177impl DeclarationError {
178 #[must_use]
181 pub fn path(&self) -> String {
182 match self {
183 Self::Action { at, .. } => format!("actions[{at}]"),
184 Self::Engine(_) => "document.$type".to_owned(),
185 Self::Statement { at, .. } => StatementLabel::Indexed(*at).path(),
186 Self::Cedar(_) => CEDAR_SOURCE.to_owned(),
187 Self::Undecided(statement) => statement.path(),
188 }
189 }
190}
191
192const CEDAR_SOURCE: &str = "document.source";
194
195#[derive(Debug, Clone, PartialEq, Eq)]
197pub enum StatementLabel {
198 Indexed(usize),
201 Cedar(String),
203}
204
205impl StatementLabel {
206 #[must_use]
209 pub fn path(&self) -> String {
210 match self {
211 Self::Indexed(at) => format!("document.statements[{at}]"),
212 Self::Cedar(_) => CEDAR_SOURCE.to_owned(),
213 }
214 }
215}
216
217impl std::fmt::Display for StatementLabel {
218 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219 match self {
220 Self::Indexed(at) => write!(f, "{at}"),
221 Self::Cedar(id) => f.write_str(id),
222 }
223 }
224}
225
226#[must_use]
228pub fn key_of(id: &str) -> &str {
229 id.split('/').next().unwrap_or(id)
230}
231
232#[must_use]
235pub fn statement_of(document: &Document, id: &str) -> Option<StatementLabel> {
236 let (_, statement_and_action) = id.split_once('/')?;
237 let (statement, _) = statement_and_action.rsplit_once('/')?;
238 match document {
239 Document::Cedar { .. } => Some(StatementLabel::Cedar(statement.to_owned())),
240 _ => statement.parse().ok().map(StatementLabel::Indexed),
241 }
242}
243
244#[must_use]
247pub fn answers(action: &Action) -> bool {
248 applicability_of(action).is_some()
249}
250
251fn applicability_of(action: &Action) -> Option<Applicability> {
254 match action {
255 Action::RecordWrite => Some(Applicability::Write {
256 kinds: KindSet::ANY,
257 collections: Scope::Any,
258 actions: ActionSet::ALL,
259 path_prefixes: Scope::Any,
260 }),
261 Action::BlobWrite => Some(Applicability::Blob),
262 Action::OauthAuthorize => Some(Applicability::Grant {
263 client_ids: Scope::Any,
264 }),
265 Action::OauthToken => Some(Applicability::Token {
266 client_ids: Scope::Any,
267 }),
268 Action::AccountCreate => Some(Applicability::Create),
269 Action::Other(_) => None,
270 }
271}
272
273struct Split {
276 label: StatementLabel,
277 predicate: Value,
278 writes: bool,
279 grants: bool,
280 tokens: bool,
281 blobs: bool,
282 creates: bool,
283 writes_by_client: bool,
286}
287
288fn split(record: &PolicyRecord) -> Result<(String, Vec<Split>), DeclarationError> {
294 let type_name = record.document.type_name();
295 let Some(engine) = engine_for(type_name) else {
296 return Err(DeclarationError::Engine(type_name.to_owned()));
297 };
298 let language = engine.language.name().to_owned();
299 match &record.document {
300 Document::Regex { statements } => {
301 let mut splits = Vec::new();
302 for (at, statement) in statements.iter().enumerate() {
303 let shapes = RegexEvaluator::shapes(statement)
304 .map_err(|reason| DeclarationError::Statement { at, reason })?;
305 splits.push(Split {
306 label: StatementLabel::Indexed(at),
307 predicate: statement.clone(),
308 writes: shapes.writes,
309 grants: shapes.grants,
310 tokens: shapes.tokens,
311 blobs: shapes.blobs,
312 creates: false,
313 writes_by_client: shapes.writes && (shapes.grants || shapes.tokens),
316 });
317 }
318 Ok((language, splits))
319 }
320 Document::Cedar { source } => {
321 let document = CedarDocument::load(source, &crate::engine::shapes())
322 .map_err(DeclarationError::Cedar)?;
323 let splits = document
324 .statements()
325 .iter()
326 .map(|statement| {
327 let writes = statement.actions().contains(&CedarAction::RecordWrite);
328 Split {
329 label: StatementLabel::Cedar(statement.id().to_owned()),
330 predicate: serde_json::json!({ "source": statement.source() }),
331 writes,
332 grants: statement.actions().contains(&CedarAction::OauthAuthorize),
333 tokens: statement.actions().contains(&CedarAction::OauthToken),
334 blobs: statement.actions().contains(&CedarAction::BlobWrite),
335 creates: statement.actions().contains(&CedarAction::AccountCreate),
336 writes_by_client: writes && statement.reads_client(),
337 }
338 })
339 .collect();
340 Ok((language, splits))
341 }
342 Document::Count { statements } => {
343 let mut splits = Vec::new();
344 for (at, statement) in statements.iter().enumerate() {
345 splits.push(Split {
346 label: StatementLabel::Indexed(at),
347 predicate: statement.clone(),
348 writes: true,
349 grants: false,
350 tokens: false,
351 blobs: false,
352 creates: false,
353 writes_by_client: false,
354 });
355 }
356 Ok((language, splits))
357 }
358 Document::Other { .. } => Err(DeclarationError::Engine(type_name.to_owned())),
359 }
360}
361
362#[derive(Debug, Clone)]
364pub struct Declared {
365 pub policies: Vec<(String, ParsedPolicy)>,
370 pub spares_held_tokens: bool,
375}
376
377pub fn declarations(key: &str, record: &PolicyRecord) -> Result<Declared, DeclarationError> {
384 let asked: Vec<(&Action, Applicability)> = record
385 .actions
386 .iter()
387 .enumerate()
388 .map(|(at, action)| {
389 applicability_of(action)
390 .map(|applicability| (action, applicability))
391 .ok_or_else(|| DeclarationError::Action {
392 at,
393 action: action.to_string(),
394 })
395 })
396 .collect::<Result<_, _>>()?;
397 let (language, statements) = split(record)?;
398 let mut declared = Vec::new();
399 let mut before_the_write = false;
400 let mut client_at_the_write = false;
401 for statement in statements {
402 let mut answers_one = false;
403 for (action, applicability) in &asked {
404 let answered = match applicability {
405 Applicability::Write { .. } => statement.writes,
406 Applicability::Grant { .. } => statement.grants,
407 Applicability::Token { .. } => statement.tokens,
408 Applicability::Blob => statement.blobs,
409 Applicability::Create => statement.creates,
410 };
411 if !answered {
412 continue;
413 }
414 answers_one = true;
415 match applicability {
416 Applicability::Write { .. } => client_at_the_write |= statement.writes_by_client,
417 Applicability::Grant { .. } | Applicability::Token { .. } => {
418 before_the_write = true;
419 }
420 Applicability::Blob | Applicability::Create => {}
421 }
422 declared.push((
423 format!("{key}/{}/{action}", statement.label),
424 ParsedPolicy {
425 applicability: applicability.clone(),
426 observes: applicability.clone(),
427 staleness: Staleness::Linearized,
428 language: language.clone(),
429 predicate: statement.predicate.clone(),
430 revision: Revision(0),
431 lookups: record.lookups.clone(),
432 },
433 ));
434 }
435 if !answers_one {
436 return Err(DeclarationError::Undecided(statement.label));
437 }
438 }
439 Ok(Declared {
440 policies: declared,
441 spares_held_tokens: before_the_write && !client_at_the_write,
442 })
443}
444
445pub fn reach(
453 policy: &PolicyRef,
454 author: &str,
455 written: impl Fn(&str) -> bool,
456) -> Result<(), String> {
457 if policy.repository != author {
458 Err(format!(
459 "names `{policy}`, which is not in its own repository {author}; a binding reaches \
460 only the policies written beside it"
461 ))
462 } else if !written(&policy.key) {
463 Err(format!(
464 "names `{policy}`, and no policy is written at that key"
465 ))
466 } else {
467 Ok(())
468 }
469}
470
471#[derive(Debug, Clone, Default)]
473pub struct Read {
474 pub policies: BTreeMap<String, PolicyRecord>,
476 pub bindings: BTreeMap<String, PolicyBinding>,
478 pub declared: HashMap<String, ParsedPolicy>,
480 pub ids: BTreeMap<String, Vec<String>>,
482 pub spares_held_tokens: Vec<String>,
485 pub failures: Vec<BuildFailure>,
487}
488
489#[must_use]
492pub fn read(observed: &Observed) -> Read {
493 let mut read = Read::default();
494 for (key, value) in &observed.policies {
495 match PolicyRecord::from_json(value) {
496 Ok(record) => {
497 read.policies.insert(key.clone(), record);
498 }
499 Err(error) => read
500 .failures
501 .push(BuildFailure::policy(key, error.to_string())),
502 }
503 }
504 for (key, value) in &observed.bindings {
505 match PolicyBinding::from_json(value) {
506 Ok(record) => {
507 read.bindings.insert(key.clone(), record);
508 }
509 Err(error) => read
510 .failures
511 .push(BuildFailure::binding(key, error.to_string())),
512 }
513 }
514
515 for (key, record) in &read.policies {
518 match declarations(key, record) {
519 Ok(declared) => {
520 if declared.spares_held_tokens {
521 read.spares_held_tokens.push(key.clone());
522 }
523 for (id, policy) in declared.policies {
524 read.ids.entry(key.clone()).or_default().push(id.clone());
525 read.declared.insert(id, policy);
526 }
527 }
528 Err(error) => read
529 .failures
530 .push(BuildFailure::policy(key, error.to_string())),
531 }
532 }
533
534 for (key, binding) in &read.bindings {
535 for policy in &binding.policies {
536 if let Err(reason) = reach(policy, &observed.author, |key| {
537 read.policies.contains_key(key)
538 }) {
539 read.failures.push(BuildFailure::binding(key, reason));
540 }
541 }
542 }
543 read
544}
545
546#[must_use]
553pub fn compile(
554 builtins: Vec<BuiltinPolicy>,
555 startup: &HashMap<String, ParsedPolicy>,
556 declared: &HashMap<String, ParsedPolicy>,
557 across: &AcrossBuilds,
558) -> (PolicyTree, LoadReport) {
559 crate::engine::build_tree(builtins, startup, declared, &across.last_good)
560}
561
562#[must_use]
564pub fn refused(load: &LoadReport) -> Vec<BuildFailure> {
565 let denied = load
566 .scoped_denials
567 .iter()
568 .map(|denial| BuildFailure::policy(key_of(&denial.id), denial.reason.clone()));
569 let malformed = load
570 .malformed
571 .iter()
572 .map(|malformed| BuildFailure::policy(key_of(malformed), malformed.clone()));
573 denied.chain(malformed).collect()
574}
575
576#[cfg(test)]
577mod tests {
578 use serde_json::json;
579
580 use super::{read, Observed, PolicyRevision};
581
582 #[test]
587 fn a_fixed_set_of_records_has_a_fixed_digest() {
588 let observed = Observed {
589 author: "did:example:operator".to_owned(),
590 policies: [(
591 "no-deletes".to_owned(),
592 json!({
593 "$type": "bot.did.policy",
594 "name": "no \"deletes\", café",
595 "actions": ["record.write"],
596 "document": {
597 "$type": "bot.did.policy#regex",
598 "statements": [{
599 "kind": "denyDelete",
600 "collection": "^app\\.bsky\\.feed\\.post$",
601 "reason": "kept",
602 }],
603 },
604 "createdAt": "2026-09-11T00:00:00.000Z",
605 }),
606 )]
607 .into(),
608 bindings: [(
609 "everyone".to_owned(),
610 json!({
611 "$type": "bot.did.policyBinding",
612 "policies": ["at://did:example:operator/bot.did.policy/no-deletes"],
613 "subjects": ["did:web:pds.example.com"],
614 "includes": ["subjects", "descendants"],
615 "createdAt": "2026-09-11T00:00:00.000Z",
616 }),
617 )]
618 .into(),
619 };
620 let read = read(&observed);
621 assert!(read.failures.is_empty(), "{:?}", read.failures);
622 assert_eq!(
623 PolicyRevision::digest_of(&read.policies, &read.bindings),
624 "fc3ad976f309541b6768a49ef38ffff6e8b667ac8ef60609bddb9086a5340ee8"
625 );
626 }
627}