use crate::parser::{
AccountAction, AccountAttr, AccountScope, BlobScope, IdentityAttr, IdentityScope, ParsedScope,
RepoAction, RepoScope, RpcScope,
};
use std::collections::HashSet;
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 {
match &g.collection {
None => true,
Some(gc) => match &r.collection {
None => false,
Some(rc) => match gc.strip_suffix(".*") {
Some(prefix) => {
rc.starts_with(prefix) && rc.as_bytes().get(prefix.len()) == Some(&b'.')
}
None => gc == rc,
},
},
}
}
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: HashSet<RepoAction> = 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 {
collection: r.collection.clone(),
actions,
})),
};
}
if granted.iter().any(|g| covers(g, requested)) {
Coverage::Full
} else {
Coverage::Withheld
}
}
pub fn narrow(granted: &[ParsedScope], requested: &ParsedScope) -> Option<ParsedScope> {
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 = g.lxm == "*" || g.lxm == r.lxm;
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 account_covers(g: &AccountScope, r: &AccountScope) -> bool {
let attr_ok = g.attr == AccountAttr::Wildcard || g.attr == r.attr;
let action_ok = match (g.action, r.action) {
(AccountAction::Manage, _) => true,
(AccountAction::Read, AccountAction::Read) => true,
(AccountAction::Read, AccountAction::Manage) => false,
};
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<String> {
let granted: Vec<ParsedScope> = 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<ParsedScope> = 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.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"
));
}
#[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
);
}
}