use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; #[derive(Debug, Clone, PartialEq, Eq)] pub enum ParsedScope { Atproto, TransitionGeneric, TransitionChat, TransitionEmail, Repo(RepoScope), Blob(BlobScope), Rpc(RpcScope), Account(AccountScope), Identity(IdentityScope), Include(IncludeScope), Unknown(String), } #[derive(Debug, Clone, PartialEq, Eq)] pub struct IncludeScope { pub nsid: String, pub aud: Option, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct RepoScope { pub collection: Option, pub actions: HashSet, } impl RepoScope { pub fn to_scope_string(&self) -> String { let mut actions: Vec = self.actions.iter().copied().collect(); actions.sort(); let rendered: Vec<&str> = actions.iter().map(RepoAction::as_str).collect(); format!( "repo:{}?action={}", self.collection.as_deref().unwrap_or("*"), rendered.join("&action=") ) } } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum RepoAction { Create, Update, Delete, } impl RepoAction { pub const ALL: [RepoAction; 3] = [Self::Create, Self::Update, Self::Delete]; pub fn parse_str(s: &str) -> Option { match s { "create" => Some(Self::Create), "update" => Some(Self::Update), "delete" => Some(Self::Delete), _ => None, } } pub fn as_str(&self) -> &'static str { match self { Self::Create => "create", Self::Update => "update", Self::Delete => "delete", } } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct BlobScope { pub accept: HashSet, } impl BlobScope { pub fn matches_mime(&self, mime: &str) -> bool { if self.accept.is_empty() || self.accept.contains("*/*") { return true; } self.accept.iter().any(|pattern| { pattern == mime || pattern.strip_suffix("/*").is_some_and(|prefix| { mime.starts_with(prefix) && mime.chars().nth(prefix.len()) == Some('/') }) }) } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct RpcScope { pub lxm: String, pub aud: Option, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct AccountScope { pub attr: AccountAttr, pub action: AccountAction, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum AccountAttr { Email, Handle, Repo, Status, Wildcard, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct IdentityScope { pub attr: IdentityAttr, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum IdentityAttr { Handle, Wildcard, } impl AccountAttr { pub fn parse_str(s: &str) -> Option { match s { "email" => Some(Self::Email), "handle" => Some(Self::Handle), "repo" => Some(Self::Repo), "status" => Some(Self::Status), "*" => Some(Self::Wildcard), _ => None, } } } impl IdentityAttr { pub fn parse_str(s: &str) -> Option { match s { "handle" => Some(Self::Handle), "*" => Some(Self::Wildcard), _ => None, } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum AccountAction { Read, Manage, } impl AccountAction { pub fn parse_str(s: &str) -> Option { match s { "read" => Some(Self::Read), "manage" => Some(Self::Manage), _ => None, } } } fn parse_query_params(query: &str) -> HashMap> { query .split('&') .filter_map(|part| part.split_once('=')) .fold(HashMap::new(), |mut acc, (key, value)| { let decoded = urlencoding::decode(value) .map(|s| s.into_owned()) .unwrap_or_else(|_| value.to_string()); acc.entry(key.to_string()).or_default().push(decoded); acc }) } /// The query parameters one scope prefix understands, and which of them may /// appear more than once. /// /// Every scope prefix that takes parameters must check its query against one of /// these before trusting `params`, because `parse_query_params` is lossy in /// three fail-OPEN directions — each hands back a grant BROADER than the client /// asked for, which is the one direction an authorization parser must never err /// in: /// /// - An **unknown** key is simply never looked up. For `repo:` an absent /// `action` means ALL THREE actions, so `?actionn=create` granted /// create+update+delete. For `blob:` an absent `accept` is an empty set, /// which `BlobScope::matches_mime` treats as every mime type, so /// `?acceptt=image/*` granted every upload. /// - A **valueless** key never survives `split_once('=')`, so `?action` is /// indistinguishable from sending nothing. It cannot be seen in `params` at /// all, which is why the check runs against the RAW query string. /// - A **repeated** single-valued key resolves through `.first()`, so /// `rpc:x?aud=*&aud=did:web:y` granted ANY audience — and the order deciding /// that is the client's to choose. /// /// Upstream nulls the whole permission in all three cases: `Parser.parse` /// returns null on a key outside its schema, and `getSingle` returns null when /// a `multiple: false` param repeats. So this does too, rather than keeping the /// part it understood. struct QuerySchema { /// Every parameter this prefix reads. A key outside this list nulls the /// scope, including a parameter upstream defines that this parser does not /// implement — accepting and ignoring one would put a permission on the /// consent screen that nothing enforces. known: &'static [&'static str], /// The subset of `known` that may repeat. A parameter belongs here only if /// this parser actually reads every value: upstream's `multiple: true` is /// not enough on its own, since collapsing a repeat to `.first()` is how /// the escalation above happens. repeatable: &'static [&'static str], } /// `repo:`'s `collection` is positional here and never read from the query, so /// it is deliberately absent from `known`. const REPO_QUERY: QuerySchema = QuerySchema { known: &["action"], repeatable: &["action"], }; /// `blob:`'s `accept` is collected from both the positional slot and every /// query value, so it repeats. const BLOB_QUERY: QuerySchema = QuerySchema { known: &["accept"], repeatable: &["accept"], }; /// `aud` is `multiple: false` upstream. `lxm` is `multiple: true` there, but /// this parser reads only the first, so a repeat is refused rather than /// silently collapsed. const RPC_QUERY: QuerySchema = QuerySchema { known: &["lxm", "aud"], repeatable: &[], }; /// `action` is `multiple: true` upstream, but as with `rpc:`'s `lxm` only the /// first is read here. const ACCOUNT_QUERY: QuerySchema = QuerySchema { known: &["action"], repeatable: &[], }; /// `identity:` takes no query parameters at all. const IDENTITY_QUERY: QuerySchema = QuerySchema { known: &[], repeatable: &[], }; const INCLUDE_QUERY: QuerySchema = QuerySchema { known: &["aud"], repeatable: &[], }; impl QuerySchema { /// Whether `query` is one this prefix can be trusted to have parsed whole. fn accepts(&self, query: &str, params: &HashMap>) -> bool { if query.is_empty() { return true; } // Checked against the raw query rather than `params`, because a key // with no `=` never reaches `params` to be noticed. let every_param_known = query.split('&').all(|part| { part.split_once('=') .is_some_and(|(key, _)| self.known.contains(&key)) }); if !every_param_known { return false; } let repeats_a_single_valued_param = self.known.iter().any(|key| { !self.repeatable.contains(key) && params.get(*key).is_some_and(|values| values.len() > 1) }); if repeats_a_single_valued_param { return false; } // A wildcard must be written as `*`. // // `parse_query_params` decodes values, so `aud=%2A` yields a real // wildcard — while `consent.rs` describes the string the CLIENT sent. // So a user reads `rpc:app.bsky.feed.getTimeline?aud=%2A`, takes `%2A` // for a literal audience, and approves every audience. // // Deliberately NOT a ban on percent-encoding: a DID is full of colons, // and a client sending `aud=did%3Aweb%3Aabc` is being careful, not // hostile — nulling that would break conformant clients silently. Only // a value that GAINS a `*` by being decoded is refused, which catches // `%2a` as well as `%2A` and the `blob:` spellings `%2A%2F%2A` and // `image%2F%2A`. Upstream's formatter emits a wildcard as a literal // `*`, so no conformant client is nulled by this. let hides_a_wildcard = query .split('&') .filter_map(|part| part.split_once('=')) .any(|(_, raw)| { urlencoding::decode(raw) .is_ok_and(|decoded| decoded.matches('*').count() > raw.matches('*').count()) }); !hides_a_wildcard } } /// Whether `base` names exactly `prefix`, as upstream's `isScopeStringFor` /// requires: the prefix must be the whole thing or be followed by a `:`. /// /// A plain `starts_with` let `blobbity` parse as a blob scope with an empty /// accept set, which `BlobScope::matches_mime` treats as EVERY mime type — so /// an unrecognised scope string granted all blob uploads. fn names_prefix(base: &str, prefix: &str) -> bool { base == prefix || base .strip_prefix(prefix) .is_some_and(|r| r.starts_with(':')) } fn parse_repo_actions(params: &HashMap>) -> Option> { match params.get("action") { None => Some(RepoAction::ALL.into_iter().collect()), Some(values) if values.is_empty() => None, Some(values) => values.iter().map(|s| RepoAction::parse_str(s)).collect(), } } pub fn parse_scope(scope: &str) -> ParsedScope { match scope { "atproto" => return ParsedScope::Atproto, "transition:generic" => return ParsedScope::TransitionGeneric, "transition:chat.bsky" => return ParsedScope::TransitionChat, "transition:email" => return ParsedScope::TransitionEmail, _ => {} } let (base, query) = scope.split_once('?').unwrap_or((scope, "")); let params = parse_query_params(query); if let Some(rest) = base.strip_prefix("repo:") { if !REPO_QUERY.accepts(query, ¶ms) { return ParsedScope::Unknown(scope.to_string()); } let collection = if rest == "*" || rest.is_empty() { None } else { Some(rest.to_string()) }; let Some(actions) = parse_repo_actions(¶ms) else { return ParsedScope::Unknown(scope.to_string()); }; return ParsedScope::Repo(RepoScope { collection, actions, }); } if base == "repo" { if !REPO_QUERY.accepts(query, ¶ms) { return ParsedScope::Unknown(scope.to_string()); } let Some(actions) = parse_repo_actions(¶ms) else { return ParsedScope::Unknown(scope.to_string()); }; return ParsedScope::Repo(RepoScope { collection: None, actions, }); } if names_prefix(base, "blob") { if !BLOB_QUERY.accepts(query, ¶ms) { return ParsedScope::Unknown(scope.to_string()); } let positional = base.strip_prefix("blob:").unwrap_or(""); let accept: HashSet = std::iter::once(positional) .filter(|s| !s.is_empty()) .map(String::from) .chain( params .get("accept") .into_iter() .flatten() .map(String::clone), ) .collect(); return ParsedScope::Blob(BlobScope { accept }); } if names_prefix(base, "rpc") { if !RPC_QUERY.accepts(query, ¶ms) { return ParsedScope::Unknown(scope.to_string()); } let lxm_positional = base.strip_prefix("rpc:").map(|s| s.to_string()); let lxm = lxm_positional.or_else(|| params.get("lxm").and_then(|v| v.first().cloned())); let aud = params.get("aud").and_then(|v| v.first().cloned()); let Some(lxm) = lxm else { return ParsedScope::Unknown(scope.to_string()); }; if lxm == "*" && aud.as_deref() == Some("*") { return ParsedScope::Unknown(scope.to_string()); } return ParsedScope::Rpc(RpcScope { lxm, aud }); } if let Some(attr_str) = base.strip_prefix("account:") && let Some(attr) = AccountAttr::parse_str(attr_str) { if !ACCOUNT_QUERY.accepts(query, ¶ms) { return ParsedScope::Unknown(scope.to_string()); } let action = params .get("action") .and_then(|v| v.first()) .and_then(|s| AccountAction::parse_str(s)) .unwrap_or(AccountAction::Read); return ParsedScope::Account(AccountScope { attr, action }); } if let Some(attr_str) = base.strip_prefix("identity:") && let Some(attr) = IdentityAttr::parse_str(attr_str) { if !IDENTITY_QUERY.accepts(query, ¶ms) { return ParsedScope::Unknown(scope.to_string()); } return ParsedScope::Identity(IdentityScope { attr }); } if let Some(nsid) = base.strip_prefix("include:") { if !INCLUDE_QUERY.accepts(query, ¶ms) { return ParsedScope::Unknown(scope.to_string()); } let aud = params.get("aud").and_then(|v| v.first().cloned()); return ParsedScope::Include(IncludeScope { nsid: nsid.to_string(), aud, }); } ParsedScope::Unknown(scope.to_string()) } pub fn parse_scope_string(scope_str: &str) -> Vec { scope_str.split_whitespace().map(parse_scope).collect() } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_atproto() { assert_eq!(parse_scope("atproto"), ParsedScope::Atproto); } #[test] fn test_parse_transition_scopes() { assert_eq!( parse_scope("transition:generic"), ParsedScope::TransitionGeneric ); assert_eq!( parse_scope("transition:chat.bsky"), ParsedScope::TransitionChat ); assert_eq!( parse_scope("transition:email"), ParsedScope::TransitionEmail ); } #[test] fn test_parse_repo_wildcard() { let scope = parse_scope("repo:*?action=create"); match scope { ParsedScope::Repo(r) => { assert!(r.collection.is_none()); assert!(r.actions.contains(&RepoAction::Create)); assert!(!r.actions.contains(&RepoAction::Update)); } _ => panic!("Expected Repo scope"), } } #[test] fn test_parse_repo_collection() { let scope = parse_scope("repo:app.bsky.feed.post?action=create&action=delete"); match scope { ParsedScope::Repo(r) => { assert_eq!(r.collection, Some("app.bsky.feed.post".to_string())); assert!(r.actions.contains(&RepoAction::Create)); assert!(r.actions.contains(&RepoAction::Delete)); assert!(!r.actions.contains(&RepoAction::Update)); } _ => panic!("Expected Repo scope"), } } #[test] fn test_parse_repo_no_actions_means_all() { let scope = parse_scope("repo:app.bsky.feed.post"); match scope { ParsedScope::Repo(r) => { assert!(r.actions.contains(&RepoAction::Create)); assert!(r.actions.contains(&RepoAction::Update)); assert!(r.actions.contains(&RepoAction::Delete)); } _ => panic!("Expected Repo scope"), } } #[test] fn test_parse_repo_unrecognized_action_is_not_a_repo_scope() { assert!(matches!( parse_scope("repo:app.bsky.feed.post?action=read"), ParsedScope::Unknown(_) )); assert!(matches!( parse_scope("repo:app.bsky.feed.post?action="), ParsedScope::Unknown(_) )); assert!(matches!( parse_scope("repo?action=read"), ParsedScope::Unknown(_) )); } #[test] fn test_parse_blob_wildcard() { let scope = parse_scope("blob:*/*"); match scope { ParsedScope::Blob(b) => { assert!(b.accept.contains("*/*")); assert!(b.matches_mime("image/png")); assert!(b.matches_mime("video/mp4")); } _ => panic!("Expected Blob scope"), } } #[test] fn test_parse_blob_specific() { let scope = parse_scope("blob?accept=image/*&accept=video/*"); match scope { ParsedScope::Blob(b) => { assert!(b.matches_mime("image/png")); assert!(b.matches_mime("image/jpeg")); assert!(b.matches_mime("video/mp4")); assert!(!b.matches_mime("text/plain")); } _ => panic!("Expected Blob scope"), } } #[test] fn test_parse_rpc() { let scope = parse_scope("rpc:app.bsky.feed.getTimeline?aud=did:web:api.bsky.app"); match scope { ParsedScope::Rpc(r) => { assert_eq!(r.lxm, "app.bsky.feed.getTimeline"); assert_eq!(r.aud, Some("did:web:api.bsky.app".to_string())); } _ => panic!("Expected Rpc scope"), } } #[test] fn test_parse_account() { let scope = parse_scope("account:email?action=read"); match scope { ParsedScope::Account(a) => { assert_eq!(a.attr, AccountAttr::Email); assert_eq!(a.action, AccountAction::Read); } _ => panic!("Expected Account scope"), } let scope2 = parse_scope("account:repo?action=manage"); match scope2 { ParsedScope::Account(a) => { assert_eq!(a.attr, AccountAttr::Repo); assert_eq!(a.action, AccountAction::Manage); } _ => panic!("Expected Account scope"), } } #[test] fn test_parse_scope_string() { let scopes = parse_scope_string("atproto repo:*?action=create blob:*/*"); assert_eq!(scopes.len(), 3); assert_eq!(scopes[0], ParsedScope::Atproto); match &scopes[1] { ParsedScope::Repo(_) => {} _ => panic!("Expected Repo"), } match &scopes[2] { ParsedScope::Blob(_) => {} _ => panic!("Expected Blob"), } } #[test] fn test_parse_include() { let scope = parse_scope("include:app.bsky.authFullApp?aud=did:web:api.bsky.app"); match scope { ParsedScope::Include(i) => { assert_eq!(i.nsid, "app.bsky.authFullApp"); assert_eq!(i.aud, Some("did:web:api.bsky.app".to_string())); } _ => panic!("Expected Include scope"), } let scope2 = parse_scope("include:com.example.authBasicFeatures"); match scope2 { ParsedScope::Include(i) => { assert_eq!(i.nsid, "com.example.authBasicFeatures"); assert_eq!(i.aud, None); } _ => panic!("Expected Include scope"), } } #[test] fn test_parse_identity() { let scope = parse_scope("identity:handle"); match scope { ParsedScope::Identity(i) => { assert_eq!(i.attr, IdentityAttr::Handle); } _ => panic!("Expected Identity scope"), } let scope2 = parse_scope("identity:*"); match scope2 { ParsedScope::Identity(i) => { assert_eq!(i.attr, IdentityAttr::Wildcard); } _ => panic!("Expected Identity scope"), } } #[test] fn test_parse_account_status() { let scope = parse_scope("account:status?action=read"); match scope { ParsedScope::Account(a) => { assert_eq!(a.attr, AccountAttr::Status); assert_eq!(a.action, AccountAction::Read); } _ => panic!("Expected Account scope"), } } #[test] fn test_rpc_wildcard_aud_forbidden() { let scope = parse_scope("rpc:*?aud=*"); assert!(matches!(scope, ParsedScope::Unknown(_))); let scope2 = parse_scope("rpc?aud=*"); assert!(matches!(scope2, ParsedScope::Unknown(_))); let scope3 = parse_scope("rpc:app.bsky.feed.getTimeline?aud=*"); assert!(matches!(scope3, ParsedScope::Rpc(_))); let scope4 = parse_scope("rpc:*?aud=did:web:api.bsky.app"); assert!(matches!(scope4, ParsedScope::Rpc(_))); } #[test] fn test_rpc_lxm_required() { let bare = parse_scope("rpc"); assert!(matches!(bare, ParsedScope::Unknown(_))); } #[test] fn test_url_encoded_aud_with_fragment() { let scope = parse_scope("include:app.bsky.authFullApp?aud=did:web:api.bsky.app%23bsky_appview"); match scope { ParsedScope::Include(i) => { assert_eq!(i.nsid, "app.bsky.authFullApp"); assert_eq!(i.aud, Some("did:web:api.bsky.app#bsky_appview".to_string())); } _ => panic!("Expected Include scope"), } let scope2 = parse_scope( "rpc:com.atproto.moderation.createReport?aud=did:web:api.bsky.app%23bsky_appview", ); match scope2 { ParsedScope::Rpc(r) => { assert_eq!(r.lxm, "com.atproto.moderation.createReport"); assert_eq!(r.aud, Some("did:web:api.bsky.app#bsky_appview".to_string())); } _ => panic!("Expected Rpc scope"), } } /// An unknown, valueless or repeated `repo:` param nulls the whole grant. /// /// This is the worst of the fail-OPEN cases, because an ABSENT `action` /// means ALL THREE actions (`parse_repo_actions`). So a key that fails to /// arrive is not merely ignored — it WIDENS the grant: /// `?actionn=create` was never looked up and `?action` never survived /// `split_once('=')`, and both yielded create+update+delete where the /// client asked for create alone and the reference grants nothing at all. /// /// Asserted as `Unknown` rather than "not all three actions", because a /// fix that narrowed to some other action set would still be wrong. #[test] fn an_unknown_or_valueless_repo_param_nulls_the_grant() { for bad in [ // The near-miss key that motivated this: silently dropped, so // `action` read as absent, so every action was granted. "repo:app.bsky.feed.post?actionn=create", "repo?actionn=create", // A key with no `=` never reaches `params` at all, so it can only // be caught against the raw query string. "repo:app.bsky.feed.post?action", "repo?action", // A recognised param sitting beside a bad one must NOT survive. "repo:app.bsky.feed.post?action=create&orphan", "repo:app.bsky.feed.post?action=create&unexpected=1", "repo:*?action=create&actionn=delete", // `collection` is a REFERENCE param we do not implement — it is // positional only here. Accepting and ignoring it would put // `collection=com.example.other` on the consent screen while // granting the positional collection instead. "repo:app.bsky.feed.post?collection=com.example.other", "repo?collection=com.example.other", ] { assert!( matches!(parse_scope(bad), ParsedScope::Unknown(_)), "{bad} must null the grant, got {:?}", parse_scope(bad) ); } // `action` is `multiple: true` in the reference, so repeating it is how // a client asks for several and must stay legal. Over-tightening here // would break every real multi-action grant. let ParsedScope::Repo(r) = parse_scope("repo:app.bsky.feed.post?action=create&action=delete") else { panic!("repeating `action` must stay legal"); }; assert_eq!(r.actions.len(), 2); assert!(r.actions.contains(&RepoAction::Create)); assert!(r.actions.contains(&RepoAction::Delete)); } /// `rpc:`'s single-valued params may not repeat, and unknown or valueless /// ones null the grant. /// /// `aud` is `multiple: false` in the reference, so `getSingle` nulls on a /// repeat. Here it resolved through `.first()`, which means /// `?aud=*&aud=did:web:y` granted ANY audience — and the client chose the /// order that decided it. `lxm` is `multiple: true` upstream, but this /// parser only ever reads the first, so repeating it is rejected rather /// than silently collapsed to whichever came first. #[test] fn a_repeated_unknown_or_valueless_rpc_param_nulls_the_grant() { for bad in [ // Both orders, because the bug was that the first silently won. "rpc:app.bsky.feed.getTimeline?aud=*&aud=did:web:api.bsky.app", "rpc:app.bsky.feed.getTimeline?aud=did:web:api.bsky.app&aud=*", "rpc?lxm=*&lxm=app.bsky.feed.getTimeline&aud=did:web:api.bsky.app", "rpc?lxm=app.bsky.feed.getTimeline&lxm=*&aud=did:web:api.bsky.app", // Unknown and valueless keys. "rpc:app.bsky.feed.getTimeline?audd=did:web:api.bsky.app", "rpc:app.bsky.feed.getTimeline?aud", "rpc:app.bsky.feed.getTimeline?aud=did:web:api.bsky.app&orphan", "rpc:app.bsky.feed.getTimeline?aud=did:web:api.bsky.app&unexpected=1", ] { assert!( matches!(parse_scope(bad), ParsedScope::Unknown(_)), "{bad} must null the grant, got {:?}", parse_scope(bad) ); } } /// A wildcard must be written as `*`, not as its percent-encoded spelling. /// /// `parse_query_params` decodes values, while `consent.rs` describes the /// string the CLIENT sent (`described` is the raw scope). So a user reads /// `rpc:app.bsky.feed.getTimeline?aud=%2A`, takes `%2A` for a literal /// audience, and approves EVERY audience. /// /// Deliberately not a ban on percent-encoding: a DID is full of colons and /// a client sending `aud=did%3Aweb%3Aapi.bsky.app` is being careful, not /// hostile. Only a value that gains a `*` by being decoded is refused, so /// `%2a` is caught as well as `%2A`. Upstream's own formatter emits a /// wildcard as a literal `*` (`URLSearchParams` does not encode it), so no /// conformant client is nulled by this. #[test] fn an_encoded_wildcard_nulls_the_grant() { for bad in [ "rpc:app.bsky.feed.getTimeline?aud=%2A", "rpc:app.bsky.feed.getTimeline?aud=%2a", "rpc?lxm=%2A&aud=did:web:api.bsky.app", // The blob wildcard is `*/*`, so the encoded spelling hides a // grant over every mime type. "blob?accept=%2A%2F%2A", "blob?accept=image%2F%2A", ] { assert!( matches!(parse_scope(bad), ParsedScope::Unknown(_)), "{bad} must null the grant, got {:?}", parse_scope(bad) ); } // An encoded DID is a careful client, not a hostile one. let ParsedScope::Rpc(r) = parse_scope("rpc:app.bsky.feed.getTimeline?aud=did%3Aweb%3Aapi.bsky.app") else { panic!("an encoded DID must still parse"); }; assert_eq!(r.aud, Some("did:web:api.bsky.app".to_string())); // And a literal wildcard is still the supported spelling. assert!(matches!( parse_scope("rpc:app.bsky.feed.getTimeline?aud=*"), ParsedScope::Rpc(_) )); } /// An unknown or valueless `blob:` param nulls the grant rather than /// granting every mime type. /// /// `blob` with no `accept` at all is an EMPTY accept set, and /// `BlobScope::matches_mime` returns true for everything when the set is /// empty (see `test_blob_empty_accept`, which pins that on purpose). So /// `blob?acceptt=image/*` was not merely ignored — it granted EVERY mime /// type, making `blob:` fail open just as hard as `repo:`. #[test] fn an_unknown_or_valueless_blob_param_nulls_the_grant() { for bad in [ "blob?acceptt=image/*", "blob?accept", "blob?accept=image/*&orphan", "blob?accept=image/*&unexpected=1", "blob:image/*?acceptt=video/*", ] { assert!( matches!(parse_scope(bad), ParsedScope::Unknown(_)), "{bad} must null the grant, got {:?}", parse_scope(bad) ); } // `accept` is `multiple: true` upstream and this parser collects every // value, so repeating it must stay legal. let ParsedScope::Blob(b) = parse_scope("blob?accept=image/*&accept=video/*") else { panic!("repeating `accept` must stay legal"); }; assert_eq!(b.accept.len(), 2); } /// `account:`, `identity:` and `include:` default NARROW, so a dropped key /// never widened them — but the key is still checked, so the same typo is /// not quietly accepted in one place and refused in another. /// /// `account:`'s `action` is the interesting one: it resolved through /// `.first()`, so `?action=read&action=manage` and `?action=manage&action=read` /// disagreed about whether `manage` was granted, with the client choosing. #[test] fn narrow_defaulting_prefixes_still_check_their_params() { for bad in [ // account: `action` is single-valued here even though it is // `multiple: true` upstream, because only the first is ever read. "account:email?action=read&action=manage", "account:email?action=manage&action=read", "account:email?actionn=manage", "account:email?action", "account:email?action=read&unexpected=1", // identity: takes no query params at all. "identity:handle?action=read", "identity:handle?orphan", // include: `aud` is `multiple: false` upstream. "include:app.bsky.authFullApp?aud=did:web:a&aud=did:web:b", "include:app.bsky.authFullApp?audd=did:web:a", "include:app.bsky.authFullApp?aud", ] { assert!( matches!(parse_scope(bad), ParsedScope::Unknown(_)), "{bad} must null the grant, got {:?}", parse_scope(bad) ); } // The ordinary forms are untouched. assert!(matches!( parse_scope("account:email?action=manage"), ParsedScope::Account(_) )); assert!(matches!( parse_scope("identity:handle"), ParsedScope::Identity(_) )); assert!(matches!( parse_scope("include:app.bsky.authFullApp?aud=did:web:api.bsky.app"), ParsedScope::Include(_) )); // And an absent `action` still means `read`, the narrow default. let ParsedScope::Account(a) = parse_scope("account:email") else { panic!("expected an account scope"); }; assert_eq!(a.action, AccountAction::Read); } /// A prefix must end at a `:` or a `?`, as upstream's `isScopeStringFor` /// requires. /// /// `blob` and `rpc` were matched with `starts_with`, so `blobbity` parsed /// as a blob scope with an EMPTY accept set — which `matches_mime` treats /// as every mime type. An unrecognised scope string therefore granted all /// blob uploads. `rpcish?lxm=…` was the same shape for RPC. #[test] fn a_prefix_must_end_at_a_colon_or_question_mark() { for bad in [ "blobbity", "blobbity?accept=image/*", "blobs:image/*", "rpcish?lxm=app.bsky.feed.getTimeline", "rpcish:app.bsky.feed.getTimeline", "repository:app.bsky.feed.post", ] { assert!( matches!(parse_scope(bad), ParsedScope::Unknown(_)), "{bad} must not be recognised, got {:?}", parse_scope(bad) ); } // The real prefixes, in all three permitted shapes. assert!(matches!(parse_scope("blob"), ParsedScope::Blob(_))); assert!(matches!(parse_scope("blob:image/*"), ParsedScope::Blob(_))); assert!(matches!( parse_scope("blob?accept=image/*"), ParsedScope::Blob(_) )); assert!(matches!( parse_scope("rpc:app.bsky.feed.getTimeline"), ParsedScope::Rpc(_) )); assert!(matches!( parse_scope("rpc?lxm=app.bsky.feed.getTimeline"), ParsedScope::Rpc(_) )); } }