didbot_policy_records/lookups.rs
1//! Which records a write's policies read, gathered from the whole set.
2//!
3//! A policy declares its lookups in its own record; the write path has to
4//! know them before it judges anything, because reading somebody else's
5//! repository is I/O and the gate is judged inside a synchronous section.
6//! So this module answers one question — *for a write to this collection,
7//! which records does any applicable policy want?* — and the write path
8//! reads exactly those, once, before the write is judged.
9//!
10//! The answer is the union over every policy that could be asked about the
11//! write, not over the ones that will actually deny it. A policy's
12//! statements are opaque here, so there is no cheaper filter than its
13//! declared applicability, and reading a record for a policy that then has
14//! nothing to say costs one cached fetch rather than a wrong verdict.
15
16use std::collections::HashMap;
17
18use didbot_policy::{Applicability, Scope};
19
20use crate::records::Lookup;
21use crate::{BuiltinPolicy, ParsedPolicy};
22
23/// Every lookup the policies a deployment has loaded declare, with the
24/// applicability of the policy that declared each.
25#[derive(Debug, Clone, Default)]
26pub struct Lookups {
27 declared: Vec<(Applicability, Lookup)>,
28}
29
30impl Lookups {
31 /// The lookups of every policy in one build of the tree: the built-ins,
32 /// the declarations supplied for the length of the run, and the ones
33 /// read from the operator's records. The same three sources
34 /// [`crate::merge::build`] takes, so a tree and its lookups are built
35 /// from one view.
36 #[must_use]
37 pub fn of(
38 builtins: &[BuiltinPolicy],
39 startup: &HashMap<String, ParsedPolicy>,
40 operator: &HashMap<String, ParsedPolicy>,
41 ) -> Self {
42 let mut declared = Vec::new();
43 let builtins = builtins.iter().map(|builtin| &builtin.policy);
44 for policy in builtins.chain(startup.values()).chain(operator.values()) {
45 for lookup in &policy.lookups {
46 declared.push((policy.applicability.clone(), lookup.clone()));
47 }
48 }
49 Self { declared }
50 }
51
52 /// Whether any policy declares a lookup at all. The ordinary answer,
53 /// and the one that lets a write skip the whole seam.
54 #[must_use]
55 pub fn is_empty(&self) -> bool {
56 self.declared.is_empty()
57 }
58
59 /// The lookups a write to `collection` may need, each once.
60 ///
61 /// Two policies declaring the same record under the same name is one
62 /// read, because the write path attaches one answer per lookup and an
63 /// engine asks for it by name.
64 #[must_use]
65 pub fn for_write(&self, collection: &str) -> Vec<&Lookup> {
66 let mut wanted: Vec<&Lookup> = Vec::new();
67 for (applicability, lookup) in &self.declared {
68 let Applicability::Write { collections, .. } = applicability else {
69 continue;
70 };
71 let reaches = match collections {
72 Scope::Any => true,
73 Scope::Only(named) => named.iter().any(|named| named == collection),
74 };
75 if reaches && !wanted.contains(&lookup) {
76 wanted.push(lookup);
77 }
78 }
79 wanted
80 }
81}
82
83#[cfg(test)]
84mod tests {
85 use super::*;
86 use didbot_policy::{ActionSet, KindSet};
87
88 fn lookup(name: &str) -> Lookup {
89 Lookup {
90 name: name.to_owned(),
91 accounts: crate::records::Accounts::Named,
92 collection: "community.lexicon.preference.ai".to_owned(),
93 rkey: None,
94 }
95 }
96
97 fn writes(collections: Scope<String>) -> Applicability {
98 Applicability::Write {
99 kinds: KindSet::ANY,
100 collections,
101 actions: ActionSet::ALL,
102 path_prefixes: Scope::Any,
103 }
104 }
105
106 /// A policy scoped to one collection does not make a write to another
107 /// collection pay for its lookup, and two policies wanting the same
108 /// record are one read.
109 #[test]
110 fn a_write_pays_only_for_the_lookups_that_could_reach_it() {
111 let lookups = Lookups {
112 declared: vec![
113 (writes(Scope::Any), lookup("aiPreference")),
114 (
115 writes(Scope::Only(vec!["app.bsky.feed.post".to_owned()])),
116 lookup("aiPreference"),
117 ),
118 (
119 writes(Scope::Only(vec!["app.bsky.feed.post".to_owned()])),
120 lookup("replyGate"),
121 ),
122 (
123 Applicability::Grant {
124 client_ids: Scope::Any,
125 },
126 lookup("neverAtAWrite"),
127 ),
128 ],
129 };
130
131 let post: Vec<&str> = lookups
132 .for_write("app.bsky.feed.post")
133 .iter()
134 .map(|lookup| lookup.name.as_str())
135 .collect();
136 assert_eq!(post, ["aiPreference", "replyGate"]);
137
138 let follow: Vec<&str> = lookups
139 .for_write("app.bsky.graph.follow")
140 .iter()
141 .map(|lookup| lookup.name.as_str())
142 .collect();
143 assert_eq!(follow, ["aiPreference"]);
144 }
145}