1use std::sync::{Arc, Mutex, Weak};
28
29use didbot_policy::{
30 AccountKind, Asked, Change, ClientId, DeploymentAttributes, Diff, Found, Lookup, Subject,
31 SubjectAttributes, SubjectKind, Universal, WriteAction,
32};
33use serde_json::Value;
34use time::{Duration, OffsetDateTime};
35
36use crate::request::{AccountAttributes, Action, Operation, Prepared, Principal, Request};
37use crate::schema::Shapes;
38
39const KEEP: Duration = Duration::seconds(5);
52
53const KEEP_BELOW: usize = 64 * 1024;
62
63type Slot = Arc<Mutex<Option<Entry>>>;
65
66static SLOTS: Mutex<Vec<Weak<Mutex<Option<Entry>>>>> = Mutex::new(Vec::new());
69
70thread_local! {
71 static LAST: Slot = registered();
72}
73
74struct Entry {
75 shapes: Arc<Shapes>,
78 subject: Owned,
79 prepared: Arc<Prepared>,
80 used: OffsetDateTime,
83}
84
85fn registered() -> Slot {
87 let slot: Slot = Arc::new(Mutex::new(None));
88 if let Ok(mut slots) = SLOTS.lock() {
89 slots.push(Arc::downgrade(&slot));
90 }
91 slot
92}
93
94fn sweep(now: OffsetDateTime) {
100 let Ok(mut slots) = SLOTS.lock() else {
101 return;
102 };
103 slots.retain(|held| {
104 let Some(slot) = held.upgrade() else {
105 return false;
106 };
107 if let Ok(mut slot) = slot.try_lock() {
110 if slot.as_ref().is_some_and(|entry| now - entry.used >= KEEP) {
111 let _expired = slot.take();
114 }
115 }
116 true
117 });
118}
119
120fn judged_at(subject: &Subject<'_>) -> OffsetDateTime {
126 match subject {
127 Subject::Write { universal, .. }
128 | Subject::Blob { universal, .. }
129 | Subject::Grant { universal, .. }
130 | Subject::Token { universal, .. }
131 | Subject::Create { universal, .. } => universal.now,
132 }
133}
134
135fn principal<'a>(account: &'a str, attributes: SubjectAttributes<'a>) -> Principal<'a> {
137 Principal::Account {
138 did: account,
139 admitted_by: None,
140 attributes: AccountAttributes {
141 kind: Some(attributes.kind.as_str()),
142 handle: attributes.handle,
143 depth: attributes.depth,
144 },
145 }
146}
147
148fn operation(action: WriteAction) -> Operation {
150 match action {
151 WriteAction::Create => Operation::Create,
152 WriteAction::Update => Operation::Update,
153 WriteAction::Delete => Operation::Delete,
154 }
155}
156
157pub(crate) fn prepared(subject: &Subject<'_>, shapes: &Arc<Shapes>) -> Arc<Prepared> {
159 let now = judged_at(subject);
160 let held = LAST.try_with(|slot| {
161 let slot = slot
162 .lock()
163 .unwrap_or_else(std::sync::PoisonError::into_inner);
164 slot.as_ref()
165 .filter(|entry| Arc::ptr_eq(&entry.shapes, shapes) && entry.subject.is(subject))
166 .map(|entry| Arc::clone(&entry.prepared))
167 });
168 if let Ok(Some(prepared)) = held {
169 return prepared;
170 }
171 let prepared = Arc::new(with_request(subject, |request| request.prepare(shapes)));
172 tracing::trace!("prepared a cedar request for a new subject");
173 sweep(now);
174 let subject = Owned::of(subject);
175 let keep = subject.weight() <= KEEP_BELOW;
176 let entry = keep.then(|| Entry {
177 shapes: Arc::clone(shapes),
178 subject,
179 prepared: Arc::clone(&prepared),
180 used: now,
181 });
182 if !keep {
183 tracing::trace!("the subject is too big to keep its prepared request");
184 }
185 let _previous = LAST.try_with(|slot| {
189 let mut slot = slot
190 .lock()
191 .unwrap_or_else(std::sync::PoisonError::into_inner);
192 std::mem::replace(&mut *slot, entry)
193 });
194 prepared
195}
196
197pub(crate) fn with_request<R>(subject: &Subject<'_>, f: impl FnOnce(&Request<'_>) -> R) -> R {
200 let request = match subject {
201 Subject::Write {
202 account,
203 attributes,
204 universal,
205 collection,
206 client_id,
207 action,
208 before,
209 after,
210 ..
211 } => Request {
212 principal: principal(account, *attributes),
213 now: universal.now,
214 action: Action::RecordWrite {
215 collection,
216 rkey: None,
217 space: None,
218 operation: operation(*action),
219 client: client_id.map(ClientId::as_str),
220 value: *after,
221 existing: *before,
222 },
223 },
224 Subject::Blob {
225 account,
226 attributes,
227 universal,
228 mime_type,
229 sniffed,
230 size,
231 ..
232 } => Request {
233 principal: principal(account, *attributes),
234 now: universal.now,
235 action: Action::BlobWrite {
236 mime_type,
237 sniffed: sniffed.unwrap_or_default(),
241 size: size.unwrap_or_default(),
242 space: None,
243 },
244 },
245 Subject::Grant {
246 universal,
247 client_id,
248 requested_scopes,
249 } => Request {
250 principal: Principal::Unknown,
251 now: universal.now,
252 action: Action::OauthAuthorize {
253 client_id: client_id.as_str(),
254 scopes: requested_scopes,
255 },
256 },
257 Subject::Token {
258 account,
259 attributes,
260 universal,
261 client_id,
262 scopes,
263 } => Request {
264 principal: principal(account, *attributes),
265 now: universal.now,
266 action: Action::OauthToken {
267 client_id: client_id.as_str(),
268 grant: None,
269 scopes,
270 },
271 },
272 Subject::Create {
273 account,
274 attributes,
275 universal,
276 kind,
277 asked,
278 pool,
279 zone,
280 name,
281 label,
282 } => Request {
283 principal: attributes.map_or(
286 Principal::Account {
287 did: account,
288 admitted_by: None,
289 attributes: AccountAttributes::default(),
290 },
291 |attributes| principal(account, attributes),
292 ),
293 now: universal.now,
294 action: Action::AccountCreate {
295 kind: kind.as_str(),
296 asked: asked.as_str(),
297 pool,
298 zone,
299 name: *name,
300 label: *label,
301 },
302 },
303 };
304 f(&request)
305}
306
307#[derive(Debug)]
311enum Owned {
312 Write {
313 account: String,
314 attributes: Attributes,
315 universal: Facts,
316 client_id: Option<ClientId>,
317 collection: String,
318 action: WriteAction,
319 changes: Vec<OwnedChange>,
320 before: Option<Value>,
321 after: Option<Value>,
322 lookups: Vec<OwnedLookup>,
323 },
324 Blob {
325 account: String,
326 attributes: Attributes,
327 universal: Facts,
328 client_id: Option<ClientId>,
329 mime_type: String,
330 sniffed: Option<String>,
331 size: Option<u64>,
332 },
333 Grant {
334 universal: Facts,
335 client_id: ClientId,
336 requested_scopes: Vec<String>,
337 },
338 Token {
339 account: String,
340 attributes: Attributes,
341 universal: Facts,
342 client_id: ClientId,
343 scopes: Vec<String>,
344 },
345 Create {
346 account: String,
347 attributes: Option<Attributes>,
348 universal: Facts,
349 kind: Kind,
350 asked: Asked,
351 pool: String,
352 zone: String,
353 name: Option<String>,
354 label: Option<String>,
355 },
356}
357
358#[derive(Debug)]
359struct Attributes {
360 kind: Kind,
361 handle: Option<String>,
362 depth: Option<u32>,
363}
364
365#[derive(Debug, Clone)]
366enum Kind {
367 Known(AccountKind),
368 Unknown(String),
369}
370
371#[derive(Debug)]
372struct Facts {
373 pds_did: String,
374 pds_hostname: String,
375 operator_did: Option<String>,
376 now: OffsetDateTime,
377}
378
379#[derive(Debug)]
380struct OwnedChange {
381 path: String,
382 before: Option<Value>,
383 after: Option<Value>,
384}
385
386#[derive(Debug)]
387struct OwnedLookup {
388 name: String,
389 account: String,
390 handle: Option<String>,
391 uri: String,
392 found: OwnedFound,
393}
394
395#[derive(Debug)]
396enum OwnedFound {
397 Records(Value),
398 Nothing,
399 Unknown(String),
400}
401
402impl Owned {
403 fn of(subject: &Subject<'_>) -> Self {
404 match *subject {
405 Subject::Write {
406 account,
407 attributes,
408 universal,
409 client_id,
410 collection,
411 action,
412 diff: Diff { changes },
413 before,
414 after,
415 lookups,
416 } => Owned::Write {
417 account: account.to_owned(),
418 attributes: Attributes::of(attributes),
419 universal: Facts::of(universal),
420 client_id: client_id.cloned(),
421 collection: collection.to_owned(),
422 action,
423 changes: changes
424 .iter()
425 .map(
426 |&Change {
427 path,
428 before,
429 after,
430 }| OwnedChange {
431 path: path.to_owned(),
432 before: before.cloned(),
433 after: after.cloned(),
434 },
435 )
436 .collect(),
437 before: before.cloned(),
438 after: after.cloned(),
439 lookups: lookups
440 .iter()
441 .map(
442 |&Lookup {
443 name,
444 account,
445 handle,
446 uri,
447 found,
448 }| OwnedLookup {
449 name: name.to_owned(),
450 account: account.to_owned(),
451 handle: handle.map(str::to_owned),
452 uri: uri.to_owned(),
453 found: match found {
454 Found::Records(records) => OwnedFound::Records(records.clone()),
455 Found::Nothing => OwnedFound::Nothing,
456 Found::Unknown(why) => OwnedFound::Unknown(why.to_owned()),
457 },
458 },
459 )
460 .collect(),
461 },
462 Subject::Blob {
463 account,
464 attributes,
465 universal,
466 client_id,
467 mime_type,
468 sniffed,
469 size,
470 } => Owned::Blob {
471 account: account.to_owned(),
472 attributes: Attributes::of(attributes),
473 universal: Facts::of(universal),
474 client_id: client_id.cloned(),
475 mime_type: mime_type.to_owned(),
476 sniffed: sniffed.map(ToOwned::to_owned),
477 size,
478 },
479 Subject::Grant {
480 universal,
481 client_id,
482 requested_scopes,
483 } => Owned::Grant {
484 universal: Facts::of(universal),
485 client_id: client_id.clone(),
486 requested_scopes: requested_scopes.iter().map(|s| (*s).to_owned()).collect(),
487 },
488 Subject::Token {
489 account,
490 attributes,
491 universal,
492 client_id,
493 scopes,
494 } => Owned::Token {
495 account: account.to_owned(),
496 attributes: Attributes::of(attributes),
497 universal: Facts::of(universal),
498 client_id: client_id.clone(),
499 scopes: scopes.iter().map(|s| (*s).to_owned()).collect(),
500 },
501 Subject::Create {
502 account,
503 attributes,
504 universal,
505 kind,
506 asked,
507 pool,
508 zone,
509 name,
510 label,
511 } => Owned::Create {
512 account: account.to_owned(),
513 attributes: attributes.map(Attributes::of),
514 universal: Facts::of(universal),
515 kind: Kind::of(kind),
516 asked,
517 pool: pool.to_owned(),
518 zone: zone.to_owned(),
519 name: name.map(str::to_owned),
520 label: label.map(str::to_owned),
521 },
522 }
523 }
524
525 fn view<R>(&self, f: impl FnOnce(&Subject<'_>) -> R) -> R {
527 match self {
528 Owned::Write {
529 account,
530 attributes,
531 universal,
532 client_id,
533 collection,
534 action,
535 changes,
536 before,
537 after,
538 lookups,
539 } => {
540 let changes: Vec<Change<'_>> = changes
541 .iter()
542 .map(|change| Change {
543 path: &change.path,
544 before: change.before.as_ref(),
545 after: change.after.as_ref(),
546 })
547 .collect();
548 let lookups: Vec<Lookup<'_>> = lookups
549 .iter()
550 .map(|lookup| Lookup {
551 name: &lookup.name,
552 account: &lookup.account,
553 handle: lookup.handle.as_deref(),
554 uri: &lookup.uri,
555 found: match &lookup.found {
556 OwnedFound::Records(records) => Found::Records(records),
557 OwnedFound::Nothing => Found::Nothing,
558 OwnedFound::Unknown(why) => Found::Unknown(why),
559 },
560 })
561 .collect();
562 f(&Subject::Write {
563 account,
564 attributes: attributes.view(),
565 universal: universal.view(),
566 client_id: client_id.as_ref(),
567 collection,
568 action: *action,
569 diff: Diff { changes: &changes },
570 before: before.as_ref(),
571 after: after.as_ref(),
572 lookups: &lookups,
573 })
574 }
575 Owned::Blob {
576 account,
577 attributes,
578 universal,
579 client_id,
580 mime_type,
581 sniffed,
582 size,
583 } => f(&Subject::Blob {
584 account,
585 attributes: attributes.view(),
586 universal: universal.view(),
587 client_id: client_id.as_ref(),
588 mime_type,
589 sniffed: sniffed.as_deref(),
590 size: *size,
591 }),
592 Owned::Grant {
593 universal,
594 client_id,
595 requested_scopes,
596 } => {
597 let requested_scopes: Vec<&str> =
598 requested_scopes.iter().map(String::as_str).collect();
599 f(&Subject::Grant {
600 universal: universal.view(),
601 client_id,
602 requested_scopes: &requested_scopes,
603 })
604 }
605 Owned::Token {
606 account,
607 attributes,
608 universal,
609 client_id,
610 scopes,
611 } => {
612 let scopes: Vec<&str> = scopes.iter().map(String::as_str).collect();
613 f(&Subject::Token {
614 account,
615 attributes: attributes.view(),
616 universal: universal.view(),
617 client_id,
618 scopes: &scopes,
619 })
620 }
621 Owned::Create {
622 account,
623 attributes,
624 universal,
625 kind,
626 asked,
627 pool,
628 zone,
629 name,
630 label,
631 } => f(&Subject::Create {
632 account,
633 attributes: attributes.as_ref().map(Attributes::view),
634 universal: universal.view(),
635 kind: kind.view(),
636 asked: *asked,
637 pool,
638 zone,
639 name: name.as_deref(),
640 label: label.as_deref(),
641 }),
642 }
643 }
644
645 fn is(&self, subject: &Subject<'_>) -> bool {
646 self.view(|own| own == subject)
647 }
648
649 fn weight(&self) -> usize {
656 match self {
657 Owned::Write {
658 account,
659 collection,
660 changes,
661 before,
662 after,
663 ..
664 } => {
665 account.len()
666 + collection.len()
667 + changes
668 .iter()
669 .map(|change| {
670 change.path.len() + weigh(&change.before) + weigh(&change.after)
671 })
672 .sum::<usize>()
673 + weigh(before)
674 + weigh(after)
675 }
676 Owned::Blob {
677 account,
678 mime_type,
679 sniffed,
680 ..
681 } => account.len() + mime_type.len() + sniffed.as_ref().map_or(0, String::len),
682 Owned::Grant {
683 requested_scopes, ..
684 } => requested_scopes.iter().map(String::len).sum(),
685 Owned::Token {
686 account, scopes, ..
687 } => account.len() + scopes.iter().map(String::len).sum::<usize>(),
688 Owned::Create {
689 account,
690 pool,
691 zone,
692 name,
693 ..
694 } => account.len() + pool.len() + zone.len() + name.as_ref().map_or(0, String::len),
695 }
696 }
697}
698
699fn weigh(value: &Option<Value>) -> usize {
706 fn of(value: &Value) -> usize {
707 match value {
708 Value::Null | Value::Bool(_) | Value::Number(_) => 8,
709 Value::String(text) => text.len(),
710 Value::Array(items) => items.iter().map(of).sum(),
711 Value::Object(fields) => fields
712 .iter()
713 .map(|(name, held)| name.len() + of(held))
714 .sum(),
715 }
716 }
717 value.as_ref().map_or(0, of)
718}
719
720impl Attributes {
721 fn of(
722 SubjectAttributes {
723 kind,
724 handle,
725 depth,
726 }: SubjectAttributes<'_>,
727 ) -> Self {
728 Self {
729 kind: Kind::of(kind),
730 handle: handle.map(str::to_owned),
731 depth,
732 }
733 }
734
735 fn view(&self) -> SubjectAttributes<'_> {
736 SubjectAttributes {
737 kind: self.kind.view(),
738 handle: self.handle.as_deref(),
739 depth: self.depth,
740 }
741 }
742}
743
744impl Kind {
745 fn of(kind: SubjectKind<'_>) -> Self {
746 match kind {
747 SubjectKind::Known(kind) => Kind::Known(kind),
748 SubjectKind::Unknown(kind) => Kind::Unknown(kind.to_owned()),
749 }
750 }
751
752 fn view(&self) -> SubjectKind<'_> {
753 match self {
754 Kind::Known(kind) => SubjectKind::Known(*kind),
755 Kind::Unknown(kind) => SubjectKind::Unknown(kind),
756 }
757 }
758}
759
760impl Facts {
761 fn of(
762 Universal {
763 deployment:
764 DeploymentAttributes {
765 pds_did,
766 pds_hostname,
767 operator_did,
768 },
769 now,
770 }: Universal<'_>,
771 ) -> Self {
772 Self {
773 pds_did: pds_did.to_owned(),
774 pds_hostname: pds_hostname.to_owned(),
775 operator_did: operator_did.map(ToOwned::to_owned),
776 now,
777 }
778 }
779
780 fn view(&self) -> Universal<'_> {
781 Universal {
782 deployment: DeploymentAttributes {
783 pds_did: &self.pds_did,
784 pds_hostname: &self.pds_hostname,
785 operator_did: self.operator_did.as_deref(),
786 },
787 now: self.now,
788 }
789 }
790}
791
792#[cfg(test)]
793mod tests {
794 use std::collections::{BTreeMap, HashMap};
795 use std::panic::{catch_unwind, AssertUnwindSafe};
796
797 use didbot_policy::{CompileOutcome, CompiledId, Evaluator, Outcome};
798 use rand::rngs::StdRng;
799 use rand::seq::IndexedRandom;
800 use rand::{Rng, SeedableRng};
801 use serde_json::json;
802 use time::Duration;
803
804 use super::*;
805 use crate::{outcome, CedarEvaluator, Document, FieldType, Shape};
806
807 const POST: &str = "app.bsky.feed.post";
808 const PROFILE: &str = "app.bsky.actor.profile";
809 const REPO: &str = "sh.tangled.repo";
810 const APP: &str = "https://app.example/client-metadata.json";
811
812 const STATEMENTS: &[&str] = &[
817 include_str!("../../didbot-pds/tests/fixtures/c1-no-posting.cedar"),
818 include_str!("../../didbot-pds/tests/fixtures/c2-one-client.cedar"),
819 include_str!("../../didbot-pds/tests/fixtures/c3-no-late-edits.cedar"),
820 include_str!("../../didbot-pds/tests/fixtures/c4-public-only.cedar"),
821 r#"@id("subtree")
822 @reason("Nothing under the operator posts.")
823 forbid(principal in Didbot::Account::"did:plc:operator", action == Didbot::Action::"record.write", resource)
824 when { resource.collection == "app.bsky.feed.post" };"#,
825 r#"@id("not-the-agent")
826 @reason("The agent signs in nowhere.")
827 forbid(principal == Didbot::Account::"did:plc:agent", action in [Didbot::Action::"oauth.authorize", Didbot::Action::"oauth.token"], resource);"#,
828 r#"@id("no-crypto-names")
829 @reason("Display names may not mention crypto.")
830 forbid(principal, action == Didbot::Action::"record.write", resource)
831 when { resource.collection == "app.bsky.actor.profile" && context has value && context.value.displayNmae like "*crypto*" };"#,
832 r#"@id("no-secret-repos")
833 @reason("Repositories may not be named secret.")
834 forbid(principal, action == Didbot::Action::"record.write", resource)
835 when { resource.collection == "sh.tangled.repo" && context has value && context.value.name == "secret" };"#,
836 r#"@id("no-app-writes")
837 @reason("This app may not write here.")
838 forbid(principal, action == Didbot::Action::"record.write", resource)
839 when { context has client && context.client.id like "https://app.example/*" };"#,
840 r#"@id("no-app-sign-in")
841 @reason("This app may not sign in here.")
842 forbid(principal, action == Didbot::Action::"oauth.authorize", resource)
843 when { resource.id like "https://app.example/*" };"#,
844 r#"@id("unguarded")
845 @reason("This app may not name repositories.")
846 forbid(principal, action == Didbot::Action::"record.write", resource)
847 when { resource.collection == "sh.tangled.repo" && context has value && context.client.id like "https://app.example/*" };"#,
848 r#"@id("no-chat-tokens")
849 @reason("Chat apps get no tokens.")
850 forbid(principal, action == Didbot::Action::"oauth.token", resource)
851 when { resource.id like "https://chat.example/*" };"#,
852 r#"@id("no-refresh")
853 @reason("Tokens are not refreshed.")
854 forbid(principal, action == Didbot::Action::"oauth.token", resource)
855 when { context.grant == "refresh_token" };"#,
856 r#"@id("no-secret-text")
857 @reason("Nothing says secret.")
858 forbid(principal, action == Didbot::Action::"record.write", resource)
859 when { context has value && context.value has text && context.value.text like "*secret*" };"#,
860 ];
861
862 fn now() -> OffsetDateTime {
863 time::macros::datetime!(2026-09-11 12:00 UTC)
864 }
865
866 fn described() -> Arc<Shapes> {
868 let mut shapes = Shapes::new();
869 shapes
870 .describe(
871 POST,
872 Shape::new()
873 .field("text", FieldType::String)
874 .field("createdAt", FieldType::Datetime)
875 .optional("langs", FieldType::Set(Box::new(FieldType::String))),
876 )
877 .expect("the post shape is a valid record type");
878 Arc::new(shapes)
879 }
880
881 struct Judge {
883 evaluator: Arc<CedarEvaluator>,
884 compiled: CompiledId,
885 document: Arc<Document>,
886 shapes: Arc<Shapes>,
887 }
888
889 impl Judge {
890 fn new(
891 evaluator: &Arc<CedarEvaluator>,
892 shapes: &Arc<Shapes>,
893 source: &str,
894 ) -> Option<Self> {
895 let compiled = match evaluator.compile(&json!({ "source": source })) {
896 CompileOutcome::Compiled(id) | CompileOutcome::CompiledWithWarnings(id, _) => id,
897 CompileOutcome::Rejected(_) => return None,
898 };
899 Some(Self {
900 evaluator: Arc::clone(evaluator),
901 compiled,
902 document: evaluator.document(compiled).expect("just compiled"),
903 shapes: Arc::clone(shapes),
904 })
905 }
906
907 fn evaluate(&self, subject: &Owned) -> Outcome {
913 subject
914 .view(|subject| self.evaluator.evaluate(subject, self.compiled))
915 .unwrap_or_else(|error| Outcome::Reject {
916 reason: error.to_string(),
917 })
918 }
919
920 fn fresh(&self, subject: &Owned) -> Outcome {
923 subject.view(|subject| {
924 outcome(with_request(subject, |request| {
925 self.document.decide(request, &self.shapes)
926 }))
927 .unwrap_or_else(|error| Outcome::Reject {
928 reason: error.to_string(),
929 })
930 })
931 }
932 }
933
934 fn judges(rng: &mut StdRng) -> Vec<Judge> {
937 let mut judges = Vec::new();
938 for shapes in [described(), Arc::new(Shapes::new())] {
939 let loadable: Vec<&str> = STATEMENTS
940 .iter()
941 .copied()
942 .filter(|source| Document::load(source, &shapes).is_ok())
943 .collect();
944 assert!(loadable.len() >= STATEMENTS.len() - 1, "{loadable:?}");
945 for _ in 0..8 {
946 let evaluator = Arc::new(CedarEvaluator::new(Arc::clone(&shapes)));
947 for _ in 0..rng.random_range(1..=3) {
948 let size = rng.random_range(1..=3);
949 let source = loadable
950 .choose_multiple(rng, size)
951 .copied()
952 .collect::<Vec<_>>();
953 judges.extend(Judge::new(&evaluator, &shapes, &source.join("\n")));
954 }
955 }
956 }
957 judges
958 }
959
960 fn records() -> Vec<Value> {
961 let post = |text: &str, age: Duration| {
962 json!({
963 "text": text,
964 "createdAt": (now() - age).format(&time::format_description::well_known::Rfc3339).unwrap(),
965 "langs": ["en"],
966 })
967 };
968 vec![
969 post("hello", Duration::ZERO),
970 post("hello", Duration::days(2)),
971 post("secret plans", Duration::hours(1)),
972 json!({ "displayName": "Agent" }),
973 json!({ "displayName": "crypto fan" }),
974 json!({ "name": "secret" }),
975 json!({ "name": "open" }),
976 json!({ "text": "not a time", "createdAt": "yesterday" }),
977 json!({ "text": "hello", "score": 1.5 }),
979 ]
980 }
981
982 fn leaves(path: &str, value: &Value, out: &mut BTreeMap<String, Value>) {
983 match value {
984 Value::Object(map) if !map.is_empty() => {
985 for (key, child) in map {
986 let child_path = if path.is_empty() {
987 key.clone()
988 } else {
989 format!("{path}.{key}")
990 };
991 leaves(&child_path, child, out);
992 }
993 }
994 Value::Array(items) if !items.is_empty() => {
995 for (index, child) in items.iter().enumerate() {
996 leaves(&format!("{path}[{index}]"), child, out);
997 }
998 }
999 leaf => {
1000 out.insert(path.to_owned(), leaf.clone());
1001 }
1002 }
1003 }
1004
1005 fn diff(before: Option<&Value>, after: Option<&Value>) -> Vec<OwnedChange> {
1006 let (mut old, mut new) = (BTreeMap::new(), BTreeMap::new());
1007 if let Some(before) = before {
1008 leaves("", before, &mut old);
1009 }
1010 if let Some(after) = after {
1011 leaves("", after, &mut new);
1012 }
1013 let mut paths: Vec<&String> = old.keys().chain(new.keys()).collect();
1014 paths.sort_unstable();
1015 paths.dedup();
1016 paths
1017 .into_iter()
1018 .filter(|path| old.get(*path) != new.get(*path))
1019 .map(|path| OwnedChange {
1020 path: path.clone(),
1021 before: old.get(path).cloned(),
1022 after: new.get(path).cloned(),
1023 })
1024 .collect()
1025 }
1026
1027 fn client(id: &str) -> ClientId {
1028 ClientId::parse(id).expect("a canonical client id")
1029 }
1030
1031 fn facts(rng: &mut StdRng) -> Facts {
1032 let (pds_did, pds_hostname) = *[
1033 ("did:web:pds.example", "pds.example"),
1034 ("did:web:b.example", "b.example"),
1035 ]
1036 .choose(rng)
1037 .unwrap();
1038 Facts {
1039 pds_did: pds_did.to_owned(),
1040 pds_hostname: pds_hostname.to_owned(),
1041 operator_did: None,
1042 now: now()
1043 + *[Duration::ZERO, Duration::nanoseconds(1), Duration::days(1)]
1044 .choose(rng)
1045 .unwrap(),
1046 }
1047 }
1048
1049 fn attributes(rng: &mut StdRng) -> Attributes {
1050 Attributes {
1051 kind: [
1052 Kind::Known(AccountKind::Agent),
1053 Kind::Known(AccountKind::Host),
1054 Kind::Unknown("robot".to_owned()),
1055 ]
1056 .choose(rng)
1057 .unwrap()
1058 .clone(),
1059 handle: [None, Some("agent.example".to_owned())]
1060 .choose(rng)
1061 .unwrap()
1062 .clone(),
1063 depth: *[None, Some(1), Some(2)].choose(rng).unwrap(),
1064 }
1065 }
1066
1067 fn subject(rng: &mut StdRng, records: &[Value]) -> Owned {
1068 let clients = [
1069 APP,
1070 "https://other-app.example/client-metadata.json",
1071 "https://quernstone.example/client-metadata.json",
1072 "https://chat.example/app",
1073 ];
1074 let account = (*["did:plc:agent", "did:plc:other"].choose(rng).unwrap()).to_owned();
1075 let scopes = [&["atproto"][..], &["atproto", "transition:generic"], &[]]
1076 .choose(rng)
1077 .unwrap()
1078 .iter()
1079 .map(|s| (*s).to_owned())
1080 .collect();
1081 let client_id = client(clients.choose(rng).unwrap());
1082 match rng.random_range(0..6) {
1083 0 => Owned::Grant {
1084 universal: facts(rng),
1085 client_id,
1086 requested_scopes: scopes,
1087 },
1088 1 => Owned::Token {
1089 account,
1090 attributes: attributes(rng),
1091 universal: facts(rng),
1092 client_id,
1093 scopes,
1094 },
1095 _ => {
1096 let (before, after, action) = match rng.random_range(0..3) {
1097 0 => (None, records.choose(rng), WriteAction::Create),
1098 1 => (
1099 records.choose(rng),
1100 records.choose(rng),
1101 WriteAction::Update,
1102 ),
1103 _ => (records.choose(rng), None, WriteAction::Delete),
1104 };
1105 Owned::Write {
1106 account,
1107 attributes: attributes(rng),
1108 universal: facts(rng),
1109 client_id: rng.random_bool(0.6).then_some(client_id),
1110 collection: (*[POST, PROFILE, REPO, "app.example.other"]
1111 .choose(rng)
1112 .unwrap())
1113 .to_owned(),
1114 action,
1115 changes: diff(before, after),
1116 before: before.cloned(),
1117 after: after.cloned(),
1118 lookups: Vec::new(),
1119 }
1120 }
1121 }
1122 }
1123
1124 #[derive(Default, Debug)]
1126 struct Seen {
1127 allowed: usize,
1128 forbidden: usize,
1129 not_carried: usize,
1130 errored: usize,
1131 not_built: usize,
1132 }
1133
1134 impl Seen {
1135 fn count(&mut self, outcome: &Outcome) {
1136 match outcome {
1137 Outcome::Allow => self.allowed += 1,
1138 Outcome::Reject { reason } if reason.contains("does not carry") => {
1139 self.not_carried += 1;
1140 }
1141 Outcome::Reject { reason } if reason.contains("could not complete") => {
1142 self.errored += 1;
1143 }
1144 Outcome::Reject { reason } if reason.contains("could not be built") => {
1145 self.not_built += 1;
1146 }
1147 Outcome::Reject { .. } => self.forbidden += 1,
1148 Outcome::Freeze { .. } => panic!("a cedar document never freezes"),
1149 }
1150 }
1151
1152 fn every_kind(&self) -> bool {
1153 [
1154 self.allowed,
1155 self.forbidden,
1156 self.not_carried,
1157 self.errored,
1158 self.not_built,
1159 ]
1160 .iter()
1161 .all(|&n| n > 0)
1162 }
1163 }
1164
1165 #[test]
1170 fn every_statement_answers_as_a_fresh_build_would() {
1171 let mut rng = StdRng::seed_from_u64(0x0c3d_a75e);
1172 let judges = judges(&mut rng);
1173 let records = records();
1174 let subjects: Vec<Owned> = (0..80).map(|_| subject(&mut rng, &records)).collect();
1175 let mut fresh = HashMap::new();
1176 let mut seen = Seen::default();
1177 let mut on = 0;
1178 for _ in 0..4000 {
1179 if rng.random_bool(0.2) {
1180 on = rng.random_range(0..subjects.len());
1181 }
1182 let at = rng.random_range(0..judges.len());
1183 let judge = &judges[at];
1184 let want = fresh
1185 .entry((at, on))
1186 .or_insert_with(|| judge.fresh(&subjects[on]));
1187 let got = judge.evaluate(&subjects[on]);
1188 assert_eq!(&got, want, "subject {:?}", subjects[on]);
1189 seen.count(&got);
1190 }
1191 assert!(seen.every_kind(), "{seen:?}");
1192 }
1193
1194 #[test]
1197 fn a_request_never_answers_for_the_next_write() {
1198 let shapes = described();
1199 let evaluator = Arc::new(CedarEvaluator::new(Arc::clone(&shapes)));
1200 let judge = Judge::new(&evaluator, &shapes, STATEMENTS[13]).expect("loads");
1201 let write = |text: &str| Owned::Write {
1202 account: "did:plc:agent".to_owned(),
1203 attributes: Attributes {
1204 kind: Kind::Known(AccountKind::Agent),
1205 handle: None,
1206 depth: None,
1207 },
1208 universal: Facts {
1209 pds_did: "did:web:pds.example".to_owned(),
1210 pds_hostname: "pds.example".to_owned(),
1211 now: now(),
1212 operator_did: None,
1213 },
1214 client_id: None,
1215 collection: POST.to_owned(),
1216 action: WriteAction::Create,
1217 changes: diff(None, Some(&json!({ "text": text }))),
1218 before: None,
1219 after: Some(json!({ "text": text })),
1220 lookups: Vec::new(),
1221 };
1222 let (open, secret) = (write("hello"), write("secret plans"));
1223 for _ in 0..3 {
1224 assert_eq!(judge.evaluate(&open), Outcome::Allow);
1225 assert_eq!(
1226 judge.evaluate(&secret),
1227 Outcome::Reject {
1228 reason: "Nothing says secret.".to_owned()
1229 }
1230 );
1231 }
1232 }
1233
1234 fn posting(text: &str) -> Owned {
1236 Owned::Write {
1237 account: "did:plc:agent".to_owned(),
1238 attributes: Attributes {
1239 kind: Kind::Known(AccountKind::Agent),
1240 handle: None,
1241 depth: None,
1242 },
1243 universal: Facts {
1244 pds_did: "did:web:pds.example".to_owned(),
1245 pds_hostname: "pds.example".to_owned(),
1246 now: now(),
1247 operator_did: None,
1248 },
1249 client_id: None,
1250 collection: POST.to_owned(),
1251 action: WriteAction::Create,
1252 changes: diff(None, Some(&json!({ "text": text }))),
1253 before: None,
1254 after: Some(json!({ "text": text })),
1255 lookups: Vec::new(),
1256 }
1257 }
1258
1259 #[test]
1263 fn a_record_too_big_to_keep_is_judged_all_the_same() {
1264 let shapes = described();
1265 let ordinary = posting("hello");
1266 let first = ordinary.view(|subject| prepared(subject, &shapes));
1267 let again = ordinary.view(|subject| prepared(subject, &shapes));
1268 assert!(
1269 Arc::ptr_eq(&first, &again),
1270 "an ordinary write built its request twice"
1271 );
1272
1273 let huge = posting(&"o".repeat(KEEP_BELOW + 1));
1274 let first = huge.view(|subject| prepared(subject, &shapes));
1275 let again = huge.view(|subject| prepared(subject, &shapes));
1276 assert!(
1277 first.built.is_ok(),
1278 "a record too big to keep was not judged"
1279 );
1280 assert!(
1281 !Arc::ptr_eq(&first, &again),
1282 "a record too big to keep was kept anyway"
1283 );
1284 }
1285
1286 #[test]
1290 fn an_idle_workers_record_is_dropped_by_a_sweep_from_another_thread() {
1291 let shapes = described();
1292 let (ready, judged) = std::sync::mpsc::channel();
1293 let (stop, wait) = std::sync::mpsc::channel::<()>();
1294 let worker = std::thread::spawn(move || {
1295 let write = posting("a record nobody asks about again");
1296 let held = write.view(|subject| prepared(subject, &shapes));
1297 ready.send((held, LAST.with(Arc::clone))).expect("sent");
1298 wait.recv().ok();
1300 });
1301
1302 let (held, slot) = judged.recv().expect("the worker judged its write");
1303 assert_eq!(
1304 Arc::strong_count(&held),
1305 2,
1306 "the worker did not keep the request it prepared"
1307 );
1308
1309 slot.lock()
1312 .expect("not poisoned")
1313 .as_mut()
1314 .expect("held")
1315 .used -= KEEP;
1316 sweep(now() + Duration::days(7));
1317 assert_eq!(
1318 Arc::strong_count(&held),
1319 1,
1320 "an idle worker still holds the record it judged"
1321 );
1322
1323 stop.send(()).ok();
1324 worker.join().expect("the worker finished");
1325 }
1326
1327 #[test]
1331 fn engines_with_different_shapes_never_share_a_request() {
1332 let described = described();
1333 let undescribed = Arc::new(Shapes::new());
1334 let typed = Judge::new(
1335 &Arc::new(CedarEvaluator::new(Arc::clone(&described))),
1336 &described,
1337 STATEMENTS[2],
1338 )
1339 .expect("loads");
1340 let untyped = Judge::new(
1341 &Arc::new(CedarEvaluator::new(Arc::clone(&undescribed))),
1342 &undescribed,
1343 STATEMENTS[2],
1344 )
1345 .expect("loads");
1346 let records = records();
1347 let edit = Owned::Write {
1348 account: "did:plc:agent".to_owned(),
1349 attributes: Attributes {
1350 kind: Kind::Known(AccountKind::Agent),
1351 handle: None,
1352 depth: None,
1353 },
1354 universal: Facts {
1355 pds_did: "did:web:pds.example".to_owned(),
1356 pds_hostname: "pds.example".to_owned(),
1357 now: now(),
1358 operator_did: None,
1359 },
1360 client_id: None,
1361 collection: POST.to_owned(),
1362 action: WriteAction::Update,
1363 changes: diff(Some(&records[1]), Some(&records[0])),
1364 before: Some(records[1].clone()),
1365 after: Some(records[0].clone()),
1366 lookups: Vec::new(),
1367 };
1368 let (want_typed, want_untyped) = (typed.fresh(&edit), untyped.fresh(&edit));
1369 assert_ne!(want_typed, want_untyped, "the shapes change the answer");
1370 for _ in 0..3 {
1371 assert_eq!(typed.evaluate(&edit), want_typed);
1372 assert_eq!(untyped.evaluate(&edit), want_untyped);
1373 }
1374 }
1375
1376 #[test]
1381 fn concurrent_writes_are_each_judged_on_their_own_subject() {
1382 let mut rng = StdRng::seed_from_u64(0x7ead);
1383 let judges = judges(&mut rng);
1384 let records = records();
1385 let subjects: Vec<Owned> = (0..64).map(|_| subject(&mut rng, &records)).collect();
1386 let fresh: Vec<Vec<Outcome>> = subjects
1387 .iter()
1388 .map(|subject| judges.iter().map(|judge| judge.fresh(subject)).collect())
1389 .collect();
1390 std::thread::scope(|scope| {
1391 for thread in 0..8 {
1392 let (judges, subjects, fresh) = (&judges, &subjects, &fresh);
1393 let own: Vec<usize> = (thread..subjects.len()).step_by(8).collect();
1394 scope.spawn(move || {
1395 for &on in &own {
1396 for (at, judge) in judges.iter().enumerate() {
1397 assert_eq!(judge.evaluate(&subjects[on]), fresh[on][at]);
1398 }
1399 }
1400 for (at, judge) in judges.iter().enumerate() {
1401 for &on in &own {
1402 assert_eq!(judge.evaluate(&subjects[on]), fresh[on][at]);
1403 }
1404 }
1405 });
1406 }
1407 });
1408 }
1409
1410 #[test]
1413 fn a_panic_mid_judgment_leaves_nothing_behind_for_the_next_write() {
1414 let mut rng = StdRng::seed_from_u64(0x0bad);
1415 let judges = judges(&mut rng);
1416 let records = records();
1417 let subjects: Vec<Owned> = (0..16).map(|_| subject(&mut rng, &records)).collect();
1418 for pair in subjects.windows(2) {
1419 let (first, next) = (&pair[0], &pair[1]);
1420 let panicked = catch_unwind(AssertUnwindSafe(|| {
1421 judges[0].evaluate(first);
1422 panic!("a judgment dies partway");
1423 }));
1424 assert!(panicked.is_err());
1425 for judge in &judges {
1426 assert_eq!(judge.evaluate(next), judge.fresh(next));
1427 assert_eq!(judge.evaluate(first), judge.fresh(first));
1428 }
1429 }
1430 }
1431}