use crate::parser::{ AccountAction, AccountAttr, AccountScope, BlobScope, IdentityAttr, IdentityScope, ParsedScope, RepoAction, RepoScope, RpcScope, }; use std::collections::BTreeSet; pub fn covers(granted: &ParsedScope, requested: &ParsedScope) -> bool { use ParsedScope::*; match (granted, requested) { (Atproto, Atproto) => true, (TransitionGeneric, TransitionGeneric) => true, (TransitionChat, TransitionChat) => true, (TransitionEmail, TransitionEmail) => true, (Repo(g), Repo(r)) => repo_covers(g, r), (Blob(g), Blob(r)) => blob_covers(g, r), (Rpc(g), Rpc(r)) => rpc_covers(g, r), (Account(g), Account(r)) => account_covers(g, r), (Identity(g), Identity(r)) => identity_covers(g, r), (Include(g), Include(r)) => g.nsid == r.nsid && g.aud == r.aud, (Unknown(g), Unknown(r)) => g == r, _ => false, } } fn repo_collection_covers(g: &RepoScope, r: &RepoScope) -> bool { r.collections.iter().all(|requested| { g.collections.iter().any(|granted| { granted == "*" || match granted.strip_suffix(".*") { Some(prefix) => { requested.starts_with(prefix) && requested.as_bytes().get(prefix.len()) == Some(&b'.') } None => granted == requested, } }) }) } fn repo_covers(g: &RepoScope, r: &RepoScope) -> bool { repo_collection_covers(g, r) && r.actions.is_subset(&g.actions) } #[derive(Debug, Clone, PartialEq, Eq)] pub enum Coverage { Full, Narrowed(ParsedScope), Withheld, } pub fn coverage(granted: &[ParsedScope], requested: &ParsedScope) -> Coverage { if let ParsedScope::Repo(r) = requested { let actions: BTreeSet = granted .iter() .filter_map(|g| match g { ParsedScope::Repo(g) if repo_collection_covers(g, r) => Some(&g.actions), _ => None, }) .flat_map(|granted_actions| granted_actions.intersection(&r.actions).copied()) .collect(); return match actions.len() { 0 => Coverage::Withheld, _ if actions == r.actions => Coverage::Full, _ => Coverage::Narrowed(ParsedScope::Repo(RepoScope { collections: r.collections.clone(), actions, })), }; } if let ParsedScope::Rpc(r) = requested { if granted .iter() .any(|g| matches!(g, ParsedScope::Rpc(g) if rpc_covers(g, r))) { return Coverage::Full; } let intersections: Vec = granted .iter() .filter_map(|g| match g { ParsedScope::Rpc(g) => rpc_intersection(g, r), _ => None, }) .collect(); let Some(first) = intersections.first() else { return Coverage::Withheld; }; if intersections.iter().any(|scope| scope.aud != first.aud) { return Coverage::Withheld; } let aud = first.aud.clone(); let lxms = intersections .into_iter() .flat_map(|scope| scope.lxms) .collect(); let narrowed = RpcScope { lxms, aud }; return if narrowed == *r { Coverage::Full } else { Coverage::Narrowed(ParsedScope::Rpc(narrowed)) }; } if granted.iter().any(|g| covers(g, requested)) { Coverage::Full } else { Coverage::Withheld } } pub fn narrow(granted: &[ParsedScope], requested: &ParsedScope) -> Option { match coverage(granted, requested) { Coverage::Full => Some(requested.clone()), Coverage::Narrowed(scope) => Some(scope), Coverage::Withheld => None, } } fn blob_covers(g: &BlobScope, r: &BlobScope) -> bool { if g.accept.is_empty() || g.accept.contains("*/*") { return true; } !r.accept.is_empty() && r.accept.iter().all(|pat| g.matches_mime(pat)) } fn rpc_covers(g: &RpcScope, r: &RpcScope) -> bool { let lxm_ok = r.lxms.iter().all(|requested| { g.lxms .iter() .any(|granted| rpc_lxm_covers(granted, requested)) }); let aud_ok = match &g.aud { None => true, Some(ga) if ga == "*" => true, Some(ga) => r.aud.as_deref() == Some(ga.as_str()), }; lxm_ok && aud_ok } fn rpc_lxm_covers(granted: &str, requested: &str) -> bool { granted == "*" || granted == requested || granted.strip_suffix(".*").is_some_and(|prefix| { requested.starts_with(prefix) && requested.as_bytes().get(prefix.len()) == Some(&b'.') }) } fn rpc_intersection(granted: &RpcScope, requested: &RpcScope) -> Option { let lxms = granted .lxms .iter() .flat_map(|granted_lxm| { requested.lxms.iter().filter_map(move |requested_lxm| { if rpc_lxm_covers(granted_lxm, requested_lxm) { Some(requested_lxm.clone()) } else if rpc_lxm_covers(requested_lxm, granted_lxm) { Some(granted_lxm.clone()) } else { None } }) }) .collect::>(); if lxms.is_empty() { return None; } let aud = match (&granted.aud, &requested.aud) { (Some(granted), Some(requested)) if granted != "*" && requested != "*" => { (granted == requested).then(|| Some(granted.clone()))? } (Some(granted), _) if granted != "*" => Some(granted.clone()), (_, Some(requested)) if requested != "*" => Some(requested.clone()), (_, requested) => requested.clone(), }; Some(RpcScope { lxms, aud }) } fn account_covers(g: &AccountScope, r: &AccountScope) -> bool { let attr_ok = g.attr == AccountAttr::Wildcard || g.attr == r.attr; let action_ok = g.actions.contains(&AccountAction::Manage) || r.actions.is_subset(&g.actions); attr_ok && action_ok } fn identity_covers(g: &IdentityScope, r: &IdentityScope) -> bool { g.attr == IdentityAttr::Wildcard || g.attr == r.attr } #[cfg(test)] mod tests { use super::{Coverage, coverage, covers, narrow}; use crate::parser::{ParsedScope, parse_scope}; fn c(granted: &str, requested: &str) -> bool { covers(&parse_scope(granted), &parse_scope(requested)) } fn narrowed(granted: &str, requested: &str) -> Option { let granted: Vec = granted.split_whitespace().map(parse_scope).collect(); match narrow(&granted, &parse_scope(requested)) { Some(ParsedScope::Repo(repo)) => Some(repo.to_scope_string()), Some(_) => Some(requested.to_string()), None => None, } } fn covered(granted: &str, requested: &str) -> Coverage { let granted: Vec = granted.split_whitespace().map(parse_scope).collect(); coverage(&granted, &parse_scope(requested)) } fn narrowed_to(scope: &str) -> Coverage { Coverage::Narrowed(parse_scope(scope)) } #[test] fn repo_wildcard_covers_specific() { assert!(c("repo:*", "repo:app.bsky.feed.post")); assert!(c("repo:*", "repo:app.bsky.feed.post?action=create")); } #[test] fn repo_specific_does_not_cover_wildcard() { assert!(!c("repo:app.bsky.feed.post", "repo:*")); } #[test] fn repo_action_subset_required() { assert!(c("repo:*", "repo:*?action=create")); assert!(!c("repo:*?action=create", "repo:*?action=delete")); assert!(c( "repo:*?action=create&action=delete", "repo:*?action=create" )); assert!(!c( "repo:*?action=create", "repo:*?action=create&action=delete" )); } #[test] fn repo_partial_action_grant_does_not_cover_actionless_request() { assert!(!c( "repo:*?action=create&action=delete", "repo:app.bsky.feed.post" )); } #[test] fn repo_collection_prefix_wildcard() { assert!(c("repo:app.bsky.*", "repo:app.bsky.feed.post")); assert!(!c("repo:app.bsky.*", "repo:app.bsky")); assert!(!c("repo:app.bsky.*", "repo:com.example.foo")); } #[test] fn blob_wildcard_covers_all() { assert!(c("blob:*/*", "blob:image/png")); assert!(c("blob:*/*", "blob:*/*")); } #[test] fn blob_type_prefix() { assert!(c("blob:image/*", "blob:image/png")); assert!(!c("blob:image/*", "blob:video/mp4")); } #[test] fn rpc_wildcards() { assert!(c("rpc:*?aud=did:web:x", "rpc:app.bsky.getX?aud=did:web:x")); assert!(c( "rpc:app.bsky.*?aud=*", "rpc:app.bsky.feed.getTimeline?aud=did:web:api.bsky.app" )); assert!(c( "rpc:app.bsky.getX?aud=*", "rpc:app.bsky.getX?aud=did:web:x" )); assert!(!c( "rpc:app.bsky.getX?aud=did:web:x", "rpc:app.bsky.getY?aud=did:web:x" )); assert!(!c( "rpc:app.bsky.*?aud=*", "rpc:app.bskyextra.getX?aud=did:web:x" )); } #[test] fn rpc_namespace_request_is_narrowed_to_granted_methods() { assert_eq!( covered( "rpc:app.bsky.feed.getTimeline?aud=*", "rpc:app.bsky.*?aud=*" ), narrowed_to("rpc:app.bsky.feed.getTimeline?aud=*") ); assert_eq!( covered( "rpc:app.bsky.feed.getTimeline?aud=did:web:api.bsky.app", "rpc:app.bsky.*?aud=*" ), narrowed_to("rpc:app.bsky.feed.getTimeline?aud=did:web:api.bsky.app") ); assert_eq!( covered( "rpc:app.bsky.feed.getTimeline?aud=did:web:other.example", "rpc:app.bsky.*?aud=did:web:api.bsky.app" ), Coverage::Withheld ); } #[test] fn account_manage_covers_read_and_wildcard_attr() { assert!(c("account:*?action=manage", "account:email?action=read")); assert!(c("account:*?action=manage", "account:email?action=manage")); assert!(!c( "account:email?action=read", "account:email?action=manage" )); } #[test] fn identity_wildcard_covers_handle() { assert!(c("identity:*", "identity:handle")); assert!(!c("identity:handle", "identity:*")); } #[test] fn cross_type_never_covers() { assert!(!c("repo:*", "blob:*/*")); assert!(!c("blob:*/*", "repo:app.bsky.feed.post")); } #[test] fn atproto_and_transition_self_cover() { assert!(c("atproto", "atproto")); assert!(c("transition:generic", "transition:generic")); assert!(!c("transition:generic", "atproto")); } #[test] fn repo_prefix_wildcard_respects_dot_boundary() { assert!(!c("repo:app.bsky.*", "repo:app.bskyEXTRA")); assert!(c("repo:app.bsky.*", "repo:app.bsky.feed.post")); assert!(!c("repo:app.bsky.*", "repo:app.bsky")); } #[test] fn include_exact_match_only() { assert!(c("include:io.x.set", "include:io.x.set")); assert!(!c("include:io.x.set", "include:io.y.set")); } #[test] fn unknown_exact_match_only() { assert!(c("weird:token", "weird:token")); assert!(!c("weird:token", "other:token")); } #[test] fn narrow_intersects_repo_actions() { assert_eq!( narrowed( "repo:*?action=create repo:*?action=update repo:*?action=delete", "repo:io.atcr.manifest?action=create&action=delete" ), Some("repo:io.atcr.manifest?action=create&action=delete".to_string()) ); assert_eq!( narrowed( "repo:*?action=create", "repo:io.atcr.manifest?action=create&action=delete" ), Some("repo:io.atcr.manifest?action=create".to_string()) ); assert_eq!( narrowed( "repo:*?action=create", "repo:io.atcr.manifest?action=delete" ), None ); assert_eq!(narrowed("repo:app.bsky.*?action=create", "repo:*"), None); assert_eq!( narrowed("identity:*", "identity:handle"), Some("identity:handle".to_string()) ); } #[test] fn coverage_distinguishes_full_from_narrowed_repo_actions() { assert_eq!( covered( "repo:*?action=create repo:*?action=update repo:*?action=delete", "repo:io.atcr.manifest?action=create&action=delete" ), Coverage::Full ); assert_eq!( covered( "repo:*?action=create", "repo:io.atcr.manifest?action=create&action=delete" ), narrowed_to("repo:io.atcr.manifest?action=create") ); assert_eq!( covered( "repo:*?action=create&action=delete", "repo:io.atcr.manifest" ), narrowed_to("repo:io.atcr.manifest?action=create&action=delete") ); assert_eq!( covered( "repo:*?action=create", "repo:io.atcr.manifest?action=delete" ), Coverage::Withheld ); } }