//! The grammar's own suite: parsing, canonical form, containment and //! intersection. use crate::*; fn scope(s: &str) -> Scope { Scope::parse(s).unwrap() } fn set(s: &str) -> ScopeSet { ScopeSet::parse(s).unwrap() } /// What a token may write, by the scope it carries. `atproto` alone /// writes nothing; an unexpanded `include:` names no collection. #[test] fn a_token_writes_only_what_its_scope_covers() { let post = |action| Scope::repo_write("app.bsky.feed.post", action); let cases = [ ("atproto", Action::Create, false), ("atproto repo:app.bsky.feed.post", Action::Delete, true), ( "repo:app.bsky.feed.post?action=create", Action::Create, true, ), ( "repo:app.bsky.feed.post?action=create", Action::Update, false, ), ("repo:app.bsky.feed.like", Action::Create, false), ("repo:app.bsky.feed.*", Action::Update, true), ("repo:*?action=delete", Action::Delete, true), ("transition:generic", Action::Create, true), ("transition:chat.bsky", Action::Create, false), ("include:app.bsky.authFullApp", Action::Create, false), ]; for (granted, action, expected) in cases { assert_eq!( set(granted).covers(&post(action)), expected, "`{granted}` writing app.bsky.feed.post by {action:?}" ); } } #[test] fn atproto_parses_bare() { assert_eq!(scope("atproto"), Scope::Atproto); } #[test] fn repo_defaults_to_all_actions() { let s = scope("repo:app.bsky.feed.post"); assert_eq!( s, Scope::Repo { collection: NsidPattern::Exact("app.bsky.feed.post".to_owned()), actions: ActionSet::All, } ); } #[test] fn repo_parses_action_query() { let s = scope("repo:app.bsky.feed.post?action=create&action=update"); let ActionSet::Only(actions) = (match s { Scope::Repo { actions, .. } => actions, _ => panic!("not a repo scope"), }) else { panic!("expected Only"); }; assert!(actions.contains(&Action::Create)); assert!(actions.contains(&Action::Update)); assert!(!actions.contains(&Action::Delete)); } /// The permission spec's spelling of several actions is the parameter /// repeated, and that is the one spelling this grammar prints. /// /// Fails if: the printer goes back to a comma list, which jacquard's /// parser refuses as an unknown action. #[test] fn repeated_actions_print_exactly_as_parsed() { for spec in [ "repo:app.bsky.feed.post?action=create&action=update", "repo:*?action=create&action=update&action=delete", ] { assert_eq!(scope(spec).to_string(), spec); } } /// A comma list is an older spelling a stored grant may still carry. /// It reads as the same set and prints back in the repeated form. #[test] fn a_comma_list_of_actions_is_read_and_printed_as_repeated_parameters() { assert_eq!( scope("repo:app.bsky.feed.post?action=create,update").to_string(), "repo:app.bsky.feed.post?action=create&action=update" ); assert!(matches!( Scope::parse("repo:app.bsky.feed.post?action=create,"), Err(ScopeParseError::UnknownAction(_)) )); } /// Both spellings in one atom, in any order, name one set; the printed /// form is the same whichever way it was written. #[test] fn mixed_action_spellings_name_one_set_regardless_of_order() { let canonical = "repo:app.bsky.feed.post?action=create&action=update&action=delete"; for spelling in [ "repo:app.bsky.feed.post?action=delete&action=create,update", "repo:app.bsky.feed.post?action=create,update&action=delete", "repo:app.bsky.feed.post?action=update&action=delete,create&action=update", ] { let parsed = scope(spelling); assert_eq!(parsed, scope(canonical), "{spelling}"); assert_eq!(parsed.to_string(), canonical, "{spelling}"); } } /// Every scope string didbot itself asks a PDS for is accepted by the /// OAuth client that sends it, `jacquard-oauth`, as well as by this /// grammar. The strings are the ones `didbot operate` builds /// (`didbot-operator/src/operate/scope.rs`) and the policy site's /// sign-in (`policy-site/src/oauth-config.ts`) sends. /// /// Fails if: this grammar prints a spelling jacquard refuses, which is /// how a comma-listed `action` once broke `didbot operate`'s sign-in. #[test] fn every_scope_didbot_requests_parses_under_jacquard() { for requested in [ "atproto repo:bot.did.operator?action=create&action=update&action=delete", "atproto repo:bot.did.operator?action=create&action=update&action=delete \ rpc:bot.did.createAccount?aud=did:web:pds.example", "atproto repo:bot.did.policy repo:bot.did.policyBinding repo:bot.did.operator", ] { let printed = ScopeSet::parse(requested) .unwrap_or_else(|err| panic!("`{requested}` does not parse here: {err}")) .to_string(); for text in [requested, printed.as_str()] { jacquard_oauth::scopes::Scopes::new(smol_str::SmolStr::new(text)) .unwrap_or_else(|err| panic!("jacquard refuses `{text}`: {err:?}")); } } } #[test] fn repo_wildcard_prefix_contains_recursively() { let ceiling = scope("repo:app.bsky.*"); assert!(ceiling.contains(&scope("repo:app.bsky.feed.post"))); assert!(ceiling.contains(&scope("repo:app.bsky.feed.post.reply"))); assert!(!ceiling.contains(&scope("repo:app.other.thing"))); } #[test] fn repo_any_contains_everything() { let ceiling = scope("repo:*"); assert!(ceiling.contains(&scope("repo:app.bsky.feed.post"))); assert!(ceiling.contains(&scope("repo:*?action=delete"))); } #[test] fn action_ceiling_narrows_but_does_not_widen() { let ceiling = scope("repo:app.bsky.feed.post?action=create"); assert!(!ceiling.contains(&scope( "repo:app.bsky.feed.post?action=create&action=delete" ))); assert!(ceiling.contains(&scope("repo:app.bsky.feed.post?action=create"))); } #[test] fn blob_mime_wildcard_contains_concrete_type() { let ceiling = scope("blob:image/*"); assert!(ceiling.contains(&scope("blob:image/png"))); assert!(!ceiling.contains(&scope("blob:video/mp4"))); } /// The two spellings are one scope, and a scope accepting several types /// is held to every one of them. #[test] fn a_blob_scope_accepts_as_many_types_as_it_names() { assert_eq!(scope("blob?accept=image/png"), scope("blob:image/png")); let ceiling = scope("blob?accept=image/*&accept=text/html"); assert!(ceiling.contains(&scope("blob:image/png"))); assert!(ceiling.contains(&scope("blob:text/html"))); assert!(ceiling.contains(&scope("blob?accept=image/png&accept=text/html"))); assert!(!ceiling.contains(&scope("blob:text/plain"))); assert!(!ceiling.contains(&scope("blob?accept=image/png&accept=text/plain"))); // A type another one already covers is not carried twice. assert_eq!( scope("blob?accept=image/*&accept=image/png").to_string(), "blob:image/*" ); } #[test] fn transition_generic_covers_granular_repo_rpc_blob() { let generic = scope("transition:generic"); assert!(generic.contains(&scope("repo:app.bsky.feed.post?action=create"))); assert!(generic.contains(&scope("rpc:app.bsky.feed.getTimeline"))); assert!(generic.contains(&scope("blob:image/png"))); // But not the hard-blocked kinds, and not another transition scope. assert!(!generic.contains(&scope("identity:*"))); assert!(!generic.contains(&scope("account:email?action=manage"))); } #[test] fn transition_chat_bsky_is_narrower_than_generic() { let chat = scope("transition:chat.bsky"); assert!(chat.contains(&scope("rpc:chat.bsky.convo.sendMessage"))); assert!(!chat.contains(&scope("rpc:app.bsky.feed.getTimeline"))); } /// The OAuth spec's `transition:email` is "access to the account email /// address": reading it, which is `account:email`, and not changing it. #[test] fn transition_email_covers_reading_the_account_email_only() { let email = scope("transition:email"); assert!(email.contains(&scope("account:email"))); assert!(!email.contains(&scope("account:email?action=manage"))); assert!(!email.contains(&scope("account:repo"))); } /// The permission spec's `account:`: an attribute, `email` or `repo`, and an /// action, `read` unless it says `manage`. The attribute may be positional /// or named, and `manage` includes `read`. #[test] fn account_reads_the_spec_attributes_and_actions() { let read = Scope::Account { attr: AccountAttr::Email, action: AccountAction::Read, }; assert_eq!(scope("account:email"), read); assert_eq!(scope("account:email?action=read"), read); assert_eq!( scope("account:email?action=read").to_string(), "account:email" ); let import = scope("account:repo?action=manage"); assert_eq!(scope("account?action=manage&attr=repo"), import); assert_eq!(import.to_string(), "account:repo?action=manage"); assert!(scope("account:email?action=manage").contains(&read)); assert!(!read.contains(&scope("account:email?action=manage"))); assert!(!import.contains(&scope("account:email"))); for unread in [ "account:*", "account:status", "account:email?action=create", "account:email?action=manage,manage", "account?action=read", "account:", ] { assert!(Scope::parse(unread).is_err(), "`{unread}` parsed"); } } /// The permission spec's `identity:`: one attribute, `handle` or `*`, /// positional or named. `*` includes `handle`. #[test] fn identity_reads_the_spec_attribute() { let handle = Scope::Identity { attr: IdentityAttr::Handle, }; let any = Scope::Identity { attr: IdentityAttr::Any, }; for (spelled, read) in [ ("identity:handle", &handle), ("identity:handle?", &handle), ("identity?attr=handle", &handle), ("identity:*", &any), ("identity:*?", &any), ("identity?attr=*", &any), ("identity:%2A", &any), ("identity:?attr=handle", &handle), ] { assert_eq!(&scope(spelled), read, "`{spelled}`"); } assert_eq!(handle.to_string(), "identity:handle"); assert_eq!(any.to_string(), "identity:*"); assert!(any.contains(&handle)); assert!(!handle.contains(&any)); for (atom, attr) in [ ("identity:email", "email"), ("identity:Handle", "Handle"), ("identity?attr=did", "did"), ] { assert_eq!( Scope::parse(atom), Err(ScopeParseError::UnknownAttribute(attr.to_owned())), "`{atom}`" ); } for atom in ["identity:", "identity?", "identity:?", "identity?attr="] { assert_eq!( Scope::parse(atom), Err(ScopeParseError::MissingValue(atom.to_owned())), "`{atom}`" ); } } /// An `include:` keeps the set's name and audience decoded, whichever way /// they were written, and prints them in the positional form. #[test] fn an_include_reads_its_set_and_audience() { let include = Scope::Include { nsid: "app.example.authFull".to_owned(), aud: Some("did:web:api.example.com#svc_chat".to_owned()), }; for spelled in [ "include:app.example.authFull?aud=did:web:api.example.com%23svc_chat", "include?nsid=app.example.authFull&aud=did:web:api.example.com%23svc_chat", "include?aud=did:web:api.example.com%23svc_chat&nsid=app.example.authFull", ] { assert_eq!(scope(spelled), include, "`{spelled}`"); } assert_eq!( include.to_string(), "include:app.example.authFull?aud=did:web:api.example.com%23svc_chat" ); assert_eq!( scope("include:my-bundle"), Scope::Include { nsid: "my-bundle".to_owned(), aud: None, } ); } /// What `atoms` read as, printed, and the warnings their forms carry. fn read(atoms: &str) -> (String, Vec) { let (set, warnings) = ScopeSet::read(atoms).unwrap_or_else(|error| panic!("`{atoms}` fails whole: {error}")); ( set.to_string(), warnings.into_iter().map(|(_, warning)| warning).collect(), ) } /// Every example the permission spec gives reads without a warning, as the /// scope it describes. The query forms name one scope per value. #[test] fn every_form_the_spec_defines_reads_without_a_warning() { for (spelled, printed) in [ ("identity:*", "identity:*"), ("identity:*?", "identity:*"), ("identity:handle", "identity:handle"), ( "rpc?lxm=*&aud=did:web:api.example.com%23svc_appview", "rpc:*?aud=did:web:api.example.com%23svc_appview", ), ( "rpc:app.example.moderation.createReport?aud=*", "rpc:app.example.moderation.createReport?aud=*", ), ( "rpc?lxm=app.example.a&lxm=app.example.b&aud=*", "rpc:app.example.a?aud=* rpc:app.example.b?aud=*", ), ( "blob?accept=video/*&accept=text/html", "blob?accept=text/html&accept=video/*", ), ("blob:*/*", "blob:*/*"), ("blob:image%2Fpng", "blob:image/png"), ( "repo:app.example.profile?action=create&action=update&action=delete", "repo:app.example.profile?action=create&action=update&action=delete", ), ("repo:app.example.profile", "repo:app.example.profile"), ( "repo?collection=app.example.profile&collection=app.example.post", "repo:app.example.post repo:app.example.profile", ), ( "repo?collection=app.example.post&action=delete", "repo:app.example.post?action=delete", ), ("repo:*", "repo:*"), ("repo:*?action=delete", "repo:*?action=delete"), ("account:email", "account:email"), ("account:repo?action=manage", "account:repo?action=manage"), ( "account?action=manage&attr=repo", "account:repo?action=manage", ), ( "include:app.example.authFull?aud=did:web:api.example.com%23svc_chat", "include:app.example.authFull?aud=did:web:api.example.com%23svc_chat", ), ( "include?nsid=app.example.authFull", "include:app.example.authFull", ), ] { assert_eq!(read(spelled), (printed.to_owned(), vec![]), "`{spelled}`"); } } /// Each form the spec does not define reads as its warning says, and the /// spec form of the same scope reads without one. #[test] fn each_form_the_spec_does_not_define_reads_with_its_warning() { use ScopeWarning::*; let post = "app.bsky.feed.post"; let appview = "did:web:api.bsky.app%23bsky_appview"; // The form as written, how it prints, its warnings, and the spec's own // spelling of the same scope where there is one. let cases: Vec<(String, &str, Vec, Option)> = vec![ ( "repo:app.bsky.*".to_owned(), "repo:app.bsky.*", vec![PartialWildcard("app.bsky.*".to_owned())], None, ), ( "rpc:app.bsky.*?aud=*".to_owned(), "rpc:app.bsky.*?aud=*", vec![PartialWildcard("app.bsky.*".to_owned())], None, ), ( "rpc:app.bsky.feed.getTimeline".to_owned(), "rpc:app.bsky.feed.getTimeline", vec![NoAudience], None, ), ( "rpc:*?aud=*".to_owned(), "rpc:*?aud=*", vec![EveryMethodAndAudience], None, ), ( "rpc:*".to_owned(), "rpc:*", vec![NoAudience, EveryMethodAndAudience], None, ), ( "rpc:app.bsky.feed.getTimeline?aud=did:web:api.bsky.app".to_owned(), "rpc:app.bsky.feed.getTimeline?aud=did:web:api.bsky.app", vec![NotAServiceReference("did:web:api.bsky.app".to_owned())], None, ), ( format!("repo:{post}?action=manage"), "repo:app.bsky.feed.post?action=manage", vec![RepoManage], None, ), ( format!("repo:{post}?action=create,update"), "repo:app.bsky.feed.post?action=create&action=update", vec![ActionList], Some(format!("repo:{post}?action=create&action=update")), ), ( format!("repo:{post}?lang=en"), "repo:app.bsky.feed.post", vec![UnknownParameter("lang".to_owned())], Some(format!("repo:{post}")), ), ( "blob:*/*?maxSize=1000000".to_owned(), "blob:*/*", vec![UnknownParameter("maxSize".to_owned())], Some("blob:*/*".to_owned()), ), ( "blob:image/png?accept=text/html".to_owned(), "blob?accept=image/png&accept=text/html", vec![RepeatedList("accept".to_owned())], Some("blob?accept=image/png&accept=text/html".to_owned()), ), ( "repo:app.example.profile?collection=app.example.post".to_owned(), "repo:app.example.post repo:app.example.profile", vec![RepeatedList("collection".to_owned())], Some("repo?collection=app.example.profile&collection=app.example.post".to_owned()), ), ( format!("rpc:app.example.get?aud={appview}&aud=*"), "rpc:app.example.get?aud=did:web:api.bsky.app%23bsky_appview", vec![RepeatedValue("aud".to_owned())], Some(format!("rpc:app.example.get?aud={appview}")), ), ( "identity:handle?action=manage".to_owned(), "identity:handle", vec![UnknownParameter("action".to_owned())], Some("identity:handle".to_owned()), ), ( "identity:handle?attr=*".to_owned(), "identity:handle", vec![RepeatedValue("attr".to_owned())], Some("identity:handle".to_owned()), ), ( "account:email?action=read&action=manage".to_owned(), "account:email", vec![RepeatedValue("action".to_owned())], Some("account:email?action=read".to_owned()), ), ( "include:app.example.authFull?lang=en".to_owned(), "include:app.example.authFull", vec![UnknownParameter("lang".to_owned())], Some("include:app.example.authFull".to_owned()), ), ]; for (spelled, printed, warnings, spec) in cases { assert_eq!( read(&spelled), (printed.to_owned(), warnings), "`{spelled}`" ); if let Some(spec) = spec { assert_eq!(read(&spec), (printed.to_owned(), vec![]), "`{spec}`"); } } } /// A partial wildcard is the prefix and every NSID beneath it; an `rpc:` /// scope with no `aud` admits every audience; `manage` adds no write. #[test] fn a_form_the_spec_does_not_define_admits_what_its_warning_says() { let wildcard = scope("repo:app.bsky.*"); assert!(wildcard.contains(&scope("repo:app.bsky.feed.post"))); assert!(wildcard.contains(&scope("repo:app.bsky.graph.follow"))); assert!(!wildcard.contains(&scope("repo:app.bskyx.feed.post"))); let unbound = scope("rpc:app.bsky.feed.getTimeline"); assert!(unbound.contains(&scope( "rpc:app.bsky.feed.getTimeline?aud=did:web:api.bsky.app%23bsky_appview" ))); assert!(unbound.contains(&scope("rpc:app.bsky.feed.getTimeline?aud=*"))); let manage = set("repo:app.bsky.feed.post?action=manage"); for action in [Action::Create, Action::Update, Action::Delete] { assert!(!manage.covers(&Scope::repo_write("app.bsky.feed.post", action))); } } /// An atom this grammar cannot read is kept as written, with a warning, and /// the rest of the string still reads. A bound, or a character no scope /// token may hold, still fails the whole string. #[test] fn an_atom_that_names_no_scope_is_kept_as_written() { for (atom, reason) in [ ( "space:*?authority=*&action=read", "`space` is not a recognised scope kind", ), ("account:status", "`status` is not a recognised attribute"), ("identity:email", "`email` is not a recognised attribute"), ( "transition:everything", "`everything` is not a recognised transition scope", ), ( "repo:app.bsky.feed.post?action=publish", "`publish` is not a recognised action", ), ( "repo:notansid", "`notansid` is not a well-formed NSID or NSID wildcard", ), ] { let (set, warnings) = ScopeSet::read(&format!("atproto {atom}")).unwrap(); assert_eq!( set, ScopeSet::new(vec![Scope::Atproto, Scope::Unknown(atom.to_owned())]), "`{atom}`" ); assert_eq!(set.to_string(), format!("atproto {atom}")); assert_eq!( warnings, vec![(atom.to_owned(), ScopeWarning::Unreadable(reason.to_owned()))] ); let unknown = Scope::Unknown(atom.to_owned()); assert!(unknown.contains(&unknown)); assert!(!unknown.contains(&Scope::Atproto)); assert!(!Scope::Atproto.contains(&unknown)); } assert_eq!( ScopeSet::read("atproto identity:\x1b[2Jhandle"), Err(ScopeParseError::ForbiddenCharacter('\x1b')) ); } /// A `transition:` scope does not stand in for the granular scopes asked for /// beside it: this server never grants it, so they must survive on their own. #[test] fn an_atom_is_merged_only_into_one_of_its_own_kind() { let requested = set("atproto transition:generic repo:app.bsky.feed.post blob:image/png"); assert_eq!( requested.to_string(), "atproto transition:generic repo:app.bsky.feed.post blob:image/png" ); assert_eq!( set("transition:email account:email").to_string(), "transition:email account:email" ); assert_eq!(set("repo:* repo:app.bsky.feed.post").to_string(), "repo:*"); } /// As an atom, `include:` admits only itself: a different set, or any /// other scope kind, is not admitted through it in either direction. What a /// set grants is [`Include::grants`], which runs before a ceiling sees the /// request. Widening containment here would let a request name a set the /// ceiling never evaluated. #[test] fn include_containment_is_reflexive_only() { let a = scope("include:my-bundle"); let b = scope("include:other-bundle"); assert!(a.contains(&a)); assert!(!a.contains(&b)); assert!(!b.contains(&a)); assert!(!a.contains(&scope("repo:app.bsky.feed.post"))); assert!(!scope("repo:app.bsky.feed.post").contains(&a)); assert_eq!(a.intersect(&b), None); assert_eq!(a.intersect(&a), Some(a.clone())); } /// An `include:` atom in a ceiling grants nothing beyond its own name: a /// request for anything else is refused, not widened to whatever the set /// turns out to mean. #[test] fn include_ceiling_does_not_widen_an_unrelated_request() { let requested = set("repo:app.bsky.feed.post"); let ceiling = set("include:my-bundle"); assert!(requested.intersect(&ceiling).is_err()); } /// `rpc:`'s `aud` is a narrowing dimension like `action`: no /// `aud` on the ceiling admits any audience (or none), a ceiling with an /// `aud` only admits a request for that exact audience, and two /// different concrete audiences share nothing to intersect. #[test] fn rpc_aud_narrows_like_any_other_dimension() { let unbound_ceiling = scope("rpc:app.bsky.feed.getTimeline"); assert!(unbound_ceiling.contains(&scope( "rpc:app.bsky.feed.getTimeline?aud=did:web:example.com" ))); assert!(unbound_ceiling.contains(&scope("rpc:app.bsky.feed.getTimeline"))); let bound_ceiling = scope("rpc:app.bsky.feed.getTimeline?aud=did:web:example.com"); assert!(bound_ceiling.contains(&scope( "rpc:app.bsky.feed.getTimeline?aud=did:web:example.com" ))); // A ceiling bound to one audience must not admit an unbound // request -- that would let the caller reach every audience through // a ceiling meant to name exactly one. assert!(!bound_ceiling.contains(&scope("rpc:app.bsky.feed.getTimeline"))); assert!(!bound_ceiling.contains(&scope( "rpc:app.bsky.feed.getTimeline?aud=did:web:other.example" ))); assert_eq!( bound_ceiling.intersect(&scope( "rpc:app.bsky.feed.getTimeline?aud=did:web:other.example" )), None ); } /// Tangled asks for `rpc:sh.tangled.repo.create?aud=*`: one method, at /// whichever knot. `*` admits every audience and no other method. #[test] fn an_rpc_wildcard_audience_admits_every_service_for_its_method() { let any = scope("rpc:sh.tangled.repo.create?aud=*"); let knot = scope("rpc:sh.tangled.repo.create?aud=did:web:knot.example"); assert!(any.contains(&knot)); assert!(!knot.contains(&any)); assert!(!any.contains(&scope( "rpc:sh.tangled.repo.delete?aud=did:web:knot.example" ))); assert_eq!(any.intersect(&knot), Some(knot.clone())); assert_eq!(knot.intersect(&any), Some(knot)); } /// A service fragment's `#` is `%23` in a scope string. Both spellings are /// one audience, it prints encoded, and a DID's own escape survives. #[test] fn an_rpc_audience_is_read_decoded_and_printed_encoded() { let encoded = "rpc:app.bsky.feed.getTimeline?aud=did:web:api.bsky.app%23bsky_appview"; assert_eq!( scope(encoded), scope("rpc:app.bsky.feed.getTimeline?aud=did:web:api.bsky.app#bsky_appview") ); assert_eq!(scope(encoded).to_string(), encoded); assert!(set(encoded).permits_rpc( Some("app.bsky.feed.getTimeline"), "did:web:api.bsky.app#bsky_appview" )); let ported = "rpc:com.example.get?aud=did:web:localhost%253A3000%23svc"; assert_eq!(scope(ported).to_string(), ported); assert!(set(ported).permits_rpc(Some("com.example.get"), "did:web:localhost%3A3000#svc")); } /// `transition:generic` reaches every other service's methods but /// `chat.bsky.*`, which takes `transition:chat.bsky`. A token bound to no /// method is `transition:generic`'s too, as the reference PDS reads it, and /// otherwise takes `rpc:*` for its audience. #[test] fn transition_generic_acts_elsewhere_except_in_chat_bsky() { let appview = "did:web:api.bsky.app#bsky_appview"; let chat = "did:web:api.bsky.chat#bsky_chat"; let generic = set("atproto transition:generic"); assert!(generic.permits_rpc(Some("app.bsky.feed.getTimeline"), appview)); assert!(!generic.permits_rpc(Some("chat.bsky.convo.sendMessage"), chat)); assert!(generic.permits_rpc(None, chat)); assert!(!scope("transition:generic").contains(&scope("rpc:chat.bsky.*"))); assert!(set("atproto transition:generic transition:chat.bsky") .permits_rpc(Some("chat.bsky.convo.sendMessage"), chat)); let knot = "did:web:knot.example"; assert!(set("rpc:*?aud=did:web:knot.example").permits_rpc(None, knot)); assert!(!set("rpc:*?aud=did:web:knot.example").permits_rpc(None, "did:web:other.example")); assert!(!set("rpc:sh.tangled.repo.create?aud=*").permits_rpc(None, knot)); } /// A ceiling with nothing in it refuses every non-empty request -- the /// asymmetric case `plan/scope-policy.md` relies on for an agent a /// policy has denied every scope. #[test] fn empty_ceiling_refuses_any_nonempty_request() { let requested = set("repo:app.bsky.feed.post"); let ceiling = ScopeSet::default(); let err = requested.intersect(&ceiling).unwrap_err(); assert_eq!(err.0, scope("repo:app.bsky.feed.post")); } /// An empty request against a real ceiling grants nothing and refuses /// nothing -- there is no atom to be outside the ceiling. #[test] fn empty_request_against_nonempty_ceiling_grants_nothing() { let requested = ScopeSet::default(); let ceiling = set("repo:app.bsky.*"); let granted = requested.intersect(&ceiling).unwrap(); assert_eq!(granted, ScopeSet::default()); } /// An unauthenticated caller sends this string to `POST /oauth/par`, so /// its size is refused before any atom is parsed or compared. #[test] fn oversized_or_unprintable_scopes_are_refused() { let long = format!("atproto {}", "a".repeat(MAX_SCOPE_BYTES)); assert!(matches!( ScopeSet::parse(&long), Err(ScopeParseError::TooLong { .. }) )); let many: Vec = (0..=MAX_SCOPE_ATOMS) .map(|i| format!("include:x{i}")) .collect(); assert!(matches!( ScopeSet::parse(&many.join(" ")), Err(ScopeParseError::TooManyAtoms { .. }) )); let long_atom = format!("identity:{}", "a".repeat(MAX_ATOM_BYTES)); assert!(matches!( ScopeSet::parse(&long_atom), Err(ScopeParseError::AtomTooLong(_)) )); assert_eq!( ScopeSet::parse("atproto identity:\x1b[2Jhandle"), Err(ScopeParseError::ForbiddenCharacter('\x1b')) ); assert_eq!( ScopeSet::parse("include:\"quoted\""), Err(ScopeParseError::ForbiddenCharacter('"')) ); } /// A stored grant is read under larger bounds than a request, since each /// `include:` in the request became the atoms its set grants. A grant past /// a request's bound still authorizes what it holds. #[test] fn a_grant_is_read_under_bounds_a_request_is_not() { let grant: Vec = (0..MAX_SCOPE_ATOMS * 2) .map(|i| format!("rpc:com.example.get{i}?aud=*")) .collect(); let grant = grant.join(" "); assert!(matches!( ScopeSet::parse(&grant), Err(ScopeParseError::TooManyAtoms { max: MAX_SCOPE_ATOMS, .. }) )); assert_eq!( ScopeSet::parse_grant(&grant).expect("a grant").0.len(), MAX_SCOPE_ATOMS * 2 ); let over: Vec = (0..=MAX_GRANT_ATOMS) .map(|i| format!("repo:com.example.c{i}")) .collect(); assert!(matches!( ScopeSet::parse_grant(&over.join(" ")), Err(ScopeParseError::TooManyAtoms { max: MAX_GRANT_ATOMS, .. }) )); } #[test] fn a_realistic_granular_scope_parses() { let collections = [ "app.bsky.actor.profile", "app.bsky.feed.like", "app.bsky.feed.post", "app.bsky.feed.postgate", "app.bsky.feed.repost", "app.bsky.feed.threadgate", "app.bsky.graph.block", "app.bsky.graph.follow", "app.bsky.graph.list", "app.bsky.graph.listitem", ]; let mut atoms = vec![ "atproto".to_owned(), "blob:image/*".to_owned(), "include:app.bsky.authFullApp?aud=did:web:api.bsky.app%23bsky_appview".to_owned(), ]; atoms.extend(collections.iter().map(|c| format!("repo:{c}"))); atoms.extend( collections .iter() .map(|c| format!("rpc:{c}.get?aud=did:web:api.bsky.app#bsky_appview")), ); let parsed = ScopeSet::parse(&atoms.join(" ")).expect("a realistic scope parses"); assert_eq!(parsed.0.len(), atoms.len()); } #[test] fn unknown_kind_is_a_parse_error() { assert!(matches!( Scope::parse("nonsense:thing"), Err(ScopeParseError::UnknownKind(_)) )); } #[test] fn malformed_nsid_is_a_parse_error() { assert!(matches!( Scope::parse("repo:"), Err(ScopeParseError::MissingValue(_)) )); assert!(matches!( Scope::parse("repo:app..bad"), Err(ScopeParseError::MalformedNsid(_)) )); } #[test] fn scope_set_round_trips_through_display() { let s = set("atproto repo:app.bsky.feed.post?action=create rpc:app.bsky.feed.getTimeline"); let reparsed = ScopeSet::parse(&s.to_string()).unwrap(); assert_eq!(s, reparsed); } #[test] fn ceiling_intersection_narrows_every_dimension_that_overlaps() { // Both atoms overlap the ceiling but exceed it in one dimension each // (actions, accepted types) — `plan/scope-policy.md`'s "narrowing is // never silent" rather than a refusal: the grant comes back smaller // than what was asked, not absent. let requested = set( "repo:app.bsky.feed.post?action=create&action=delete blob?accept=image/png&accept=video/mp4", ); let ceiling = set("repo:app.bsky.*?action=create blob:image/*"); let granted = requested.intersect(&ceiling).unwrap(); assert_eq!( granted, set("repo:app.bsky.feed.post?action=create blob:image/png") ); } #[test] fn a_scope_that_only_partly_overlaps_the_ceiling_is_still_refused_if_disjoint() { // `blob:video/mp4` shares nothing with an `image/*` ceiling at all. let requested = set("blob:video/mp4"); let ceiling = set("blob:image/*"); let err = requested.intersect(&ceiling).unwrap_err(); assert_eq!(err.0, scope("blob:video/mp4")); } #[test] fn ceiling_intersection_succeeds_and_narrows() { let requested = set("repo:app.bsky.feed.post?action=create&action=delete"); let ceiling = set("repo:app.bsky.*?action=create"); let granted = requested.intersect(&ceiling).unwrap(); assert_eq!(granted, set("repo:app.bsky.feed.post?action=create")); } #[test] fn a_scope_entirely_outside_the_ceiling_is_refused_by_name() { let requested = set("repo:app.other.thing"); let ceiling = set("repo:app.bsky.*"); let err = requested.intersect(&ceiling).unwrap_err(); assert_eq!(err.0, scope("repo:app.other.thing")); } #[test] fn transition_generic_ceiling_admits_granular_requests() { let requested = set("repo:app.bsky.feed.post?action=create rpc:app.bsky.feed.getTimeline"); let ceiling = set("transition:generic"); let granted = requested.intersect(&ceiling).unwrap(); assert_eq!(granted, requested); } #[test] fn identity_and_account_are_never_reachable_through_a_repo_or_transition_ceiling_alone() { let requested = set("identity:* account:repo?action=manage"); let generic_ceiling = set("transition:generic"); assert!(requested.intersect(&generic_ceiling).is_err()); } /// The exact repro from the bug report: a ceiling that grants `create` /// on one atom and `delete` on another used to keep only whichever atom /// the inner loop visited last, so the grant silently depended on /// vector order. Both permutations must now grant both actions. /// /// Built with the raw tuple constructor rather than `set()`/`parse` on /// purpose: `ScopeSet::parse` canonicalises (and so sorts) on the way /// in, which would make the two ceilings identical before `intersect` /// ever saw them and mask exactly the bug this test exists to catch. #[test] fn intersect_does_not_depend_on_ceiling_atom_order() { let create = scope("repo:com.example.thing?action=create"); let delete = scope("repo:com.example.thing?action=delete"); let request = ScopeSet(vec![scope("repo:com.example.thing")]); let forward = ScopeSet(vec![create.clone(), delete.clone()]); let backward = ScopeSet(vec![delete, create]); let granted_forward = request.intersect(&forward).unwrap(); let granted_backward = request.intersect(&backward).unwrap(); assert_eq!(granted_forward, granted_backward); assert!(granted_forward .0 .contains(&scope("repo:com.example.thing?action=create"))); assert!(granted_forward .0 .contains(&scope("repo:com.example.thing?action=delete"))); } /// Same property, over a ceiling with three atoms that all overlap the /// same request — every permutation of the ceiling must grant every /// action, not just whichever pair the loop happened to compare last. #[test] fn intersect_does_not_depend_on_ceiling_atom_order_with_three_overlapping_atoms() { let create = scope("repo:com.example.thing?action=create"); let update = scope("repo:com.example.thing?action=update"); let delete = scope("repo:com.example.thing?action=delete"); let request = ScopeSet(vec![scope("repo:com.example.thing")]); let orderings = [ vec![create.clone(), update.clone(), delete.clone()], vec![create.clone(), delete.clone(), update.clone()], vec![update.clone(), create.clone(), delete.clone()], vec![update.clone(), delete.clone(), create.clone()], vec![delete.clone(), create.clone(), update.clone()], vec![delete.clone(), update.clone(), create.clone()], ]; let grants: Vec = orderings .into_iter() .map(|atoms| request.intersect(&ScopeSet(atoms)).unwrap()) .collect(); for grant in &grants { assert!(grant.0.contains(&create)); assert!(grant.0.contains(&update)); assert!(grant.0.contains(&delete)); } for pair in grants.windows(2) { assert_eq!(pair[0], pair[1]); } } /// The grant of a bare `repo:X` request against a ceiling split across /// `create` and `delete` atoms must carry both — a single `best` /// overlap dropped whichever action lost the inner loop's comparison. #[test] fn intersect_does_not_silently_drop_a_ceiling_atom_s_action() { let requested = set("repo:com.example.thing"); let ceiling = set("repo:com.example.thing?action=create repo:com.example.thing?action=delete"); let granted = requested.intersect(&ceiling).unwrap(); assert!(granted .0 .contains(&scope("repo:com.example.thing?action=create"))); assert!(granted .0 .contains(&scope("repo:com.example.thing?action=delete"))); } /// `ScopeSet` equality is set equality: differently ordered and /// differently deduplicated inputs that name the same ceiling must /// compare equal, not merely produce equal grants. #[test] fn scope_sets_with_the_same_members_are_equal_regardless_of_order_or_duplicates() { let a = set("atproto repo:app.bsky.feed.post rpc:app.bsky.feed.getTimeline"); let b = set("rpc:app.bsky.feed.getTimeline repo:app.bsky.feed.post atproto"); assert_eq!(a, b); let deduped = set("repo:app.bsky.feed.post atproto repo:app.bsky.feed.post"); let not_deduped = set("atproto repo:app.bsky.feed.post"); assert_eq!(deduped, not_deduped); } /// Canonicalisation also absorbs an atom already admitted by a wider /// atom in the same set, so a ceiling can't represent the same grant as /// both "just the wildcard" and "the wildcard plus something it already /// covers". #[test] fn scope_set_absorbs_an_atom_contained_by_a_wider_sibling() { let with_redundant_atom = set("repo:app.bsky.feed.post repo:app.bsky.*"); let wildcard_only = set("repo:app.bsky.*"); assert_eq!(with_redundant_atom, wildcard_only); }