use crate::path;
use moq_pattern::Patterns;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Default, Clone, PartialEq, Eq)]
#[serde(try_from = "crate::wire::Scope", into = "crate::wire::Scope")]
pub struct Scope {
pub root: String,
pub publish: Patterns,
pub subscribe: Patterns,
}
impl Scope {
pub fn validate(&self) -> crate::Result<()> {
if self.publish.is_empty() && self.subscribe.is_empty() {
return Err(crate::Error::UselessScope);
}
Ok(())
}
pub fn allows(&self, claims: &Claims) -> bool {
let covers = |granted: &Patterns, requested: &Patterns| {
match (granted.rooted(&self.root), requested.rooted(&claims.root)) {
(Ok(granted), Ok(requested)) => granted.covers(&requested),
_ => false,
}
};
covers(&self.publish, &claims.publish) && covers(&self.subscribe, &claims.subscribe)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Permissions {
pub subscribe: Patterns,
pub publish: Patterns,
}
impl Permissions {
pub fn new(publish: Patterns, subscribe: Patterns) -> Self {
Self { publish, subscribe }
}
pub fn is_empty(&self) -> bool {
self.publish.is_empty() && self.subscribe.is_empty()
}
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
#[serde(try_from = "crate::wire::Claims", into = "crate::wire::Claims")]
#[non_exhaustive]
pub struct Claims {
pub root: String,
pub publish: Patterns,
pub subscribe: Patterns,
pub expires: Option<std::time::SystemTime>,
pub issued: Option<std::time::SystemTime>,
}
impl Claims {
pub fn with_root(mut self, root: impl Into<String>) -> Self {
self.root = root.into();
self
}
pub fn with_publish(mut self, patterns: impl IntoIterator<Item = moq_pattern::Pattern>) -> Self {
self.publish = patterns.into_iter().collect();
self
}
pub fn with_subscribe(mut self, patterns: impl IntoIterator<Item = moq_pattern::Pattern>) -> Self {
self.subscribe = patterns.into_iter().collect();
self
}
pub fn with_expires(mut self, at: impl Into<Option<std::time::SystemTime>>) -> Self {
self.expires = at.into();
self
}
pub fn with_issued(mut self, at: impl Into<Option<std::time::SystemTime>>) -> Self {
self.issued = at.into();
self
}
pub fn validate(&self) -> crate::Result<()> {
if self.publish.is_empty() && self.subscribe.is_empty() {
return Err(crate::Error::UselessToken);
}
Ok(())
}
pub fn authorize(&self, path: &str) -> crate::Result<Permissions> {
let path = path::normalize(path);
let root = path::normalize(&self.root);
let (suffix, prefix) = if let Some(suffix) = path::strip_prefix(&path, &root) {
(suffix, "")
} else if let Some(prefix) = path::strip_prefix(&root, &path) {
("", prefix)
} else {
return Err(crate::Error::RootMismatch(path));
};
let scope = |patterns: &Patterns| -> crate::Result<Patterns> {
if prefix.is_empty() {
Ok(patterns.rebase(suffix))
} else {
Ok(patterns.rooted(prefix)?)
}
};
let permissions = Permissions {
subscribe: scope(&self.subscribe)?,
publish: scope(&self.publish)?,
};
if permissions.subscribe.is_empty() && permissions.publish.is_empty() {
return Err(crate::Error::NoAccess(path));
}
Ok(permissions)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{Duration, SystemTime};
fn patterns(texts: &[&str]) -> Patterns {
texts.iter().map(|text| text.parse().unwrap()).collect()
}
fn create_test_claims() -> Claims {
Claims {
root: "test-path".to_string(),
publish: patterns(&["test-pub/**"]),
subscribe: patterns(&["test-sub/**"]),
expires: Some(SystemTime::now() + Duration::from_secs(3600)),
issued: Some(SystemTime::now()),
}
}
#[test]
fn scope_allows_contained_claims() {
let scope = Scope {
root: "project".into(),
publish: patterns(&["live/**"]),
subscribe: patterns(&["watch/**"]),
};
let claims = Claims {
root: "project/live/room".into(),
publish: patterns(&["**"]),
..Default::default()
};
assert!(scope.allows(&claims));
}
#[test]
fn scope_rejects_sibling_and_role_escalation() {
let scope = Scope {
root: "project".into(),
publish: patterns(&["live/**"]),
subscribe: Patterns::new(),
};
let sibling = Claims {
root: "project/lively".into(),
publish: patterns(&["**"]),
..Default::default()
};
let role = Claims {
root: "project/live".into(),
subscribe: patterns(&["**"]),
..Default::default()
};
assert!(!scope.allows(&sibling));
assert!(!scope.allows(&role));
}
#[test]
fn scope_ignores_how_the_root_is_split() {
let scope = Scope {
root: "project".into(),
publish: patterns(&["live/**"]),
subscribe: Patterns::new(),
};
for claims in [
Claims {
root: "project".into(),
publish: patterns(&["live/room/**"]),
..Default::default()
},
Claims {
root: String::new(),
publish: patterns(&["project/live/room/**"]),
..Default::default()
},
Claims {
root: "/project/live/".into(),
publish: patterns(&["room/**"]),
..Default::default()
},
] {
assert!(scope.allows(&claims), "{claims:?}");
}
}
#[test]
fn scope_rejects_escaping_above_its_root() {
let scope = Scope {
root: "project".into(),
publish: patterns(&["live/**"]),
subscribe: Patterns::new(),
};
let claims = Claims {
root: String::new(),
publish: patterns(&["**"]),
..Default::default()
};
assert!(!scope.allows(&claims));
}
#[test]
fn scope_globstar_grants_everything_beneath_it() {
let scope = Scope {
root: "project".into(),
publish: patterns(&["**"]),
subscribe: Patterns::new(),
};
let claims = Claims {
root: "project/anything/deep".into(),
publish: patterns(&["**"]),
..Default::default()
};
assert!(scope.allows(&claims));
}
#[test]
fn scope_requires_every_requested_pattern() {
let scope = Scope {
root: "project".into(),
publish: patterns(&["live/**"]),
subscribe: Patterns::new(),
};
let claims = Claims {
root: "project".into(),
publish: patterns(&["live/room/**", "other/**"]),
..Default::default()
};
assert!(!scope.allows(&claims));
}
#[test]
fn scope_is_exact_about_a_literal() {
let scope = Scope {
root: "project".into(),
publish: patterns(&["live"]),
subscribe: Patterns::new(),
};
let exact = Claims {
root: "project".into(),
publish: patterns(&["live"]),
..Default::default()
};
let subtree = Claims {
root: "project".into(),
publish: patterns(&["live/**"]),
..Default::default()
};
assert!(scope.allows(&exact));
assert!(!scope.allows(&subtree));
}
#[test]
fn scope_without_grants_is_useless() {
assert!(matches!(Scope::default().validate(), Err(crate::Error::UselessScope)));
}
#[test]
fn scope_refuses_null_grants() {
assert!(serde_json::from_str::<Scope>(r#"{"put":null,"publish":["room"]}"#).is_err());
}
#[test]
fn scope_reads_legacy_prefixes_as_subtrees() {
let scope: Scope = serde_json::from_str(r#"{"root":"demo","put":["room"],"get":[""]}"#).unwrap();
assert_eq!(scope.publish, patterns(&["room/**"]));
assert_eq!(scope.subscribe, patterns(&["**"]));
}
#[test]
fn scope_writes_legacy_prefixes_only_when_faithful() {
let subtrees = Scope {
root: "demo".into(),
publish: patterns(&["room/**"]),
subscribe: patterns(&["**"]),
};
assert_eq!(
serde_json::to_string(&subtrees).unwrap(),
r#"{"root":"demo","put":["room"],"get":[""]}"#
);
let exact = Scope {
root: "demo".into(),
publish: patterns(&["room"]),
subscribe: Patterns::new(),
};
assert_eq!(
serde_json::to_string(&exact).unwrap(),
r#"{"root":"demo","publish":["room"]}"#
);
}
#[test]
fn test_claims_validation_success() {
let claims = create_test_claims();
assert!(claims.validate().is_ok());
}
#[test]
fn test_claims_validation_no_publish_or_subscribe() {
let claims = Claims {
root: "test-path".to_string(),
..Default::default()
};
let result = claims.validate();
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("no publish or subscribe allowed; token is useless")
);
}
#[test]
fn test_claims_validation_only_publish() {
let claims = Claims {
root: "test-path".to_string(),
publish: patterns(&["test-pub"]),
..Default::default()
};
assert!(claims.validate().is_ok());
}
#[test]
fn test_claims_validation_only_subscribe() {
let claims = Claims {
root: "test-path".to_string(),
subscribe: patterns(&["test-sub"]),
..Default::default()
};
assert!(claims.validate().is_ok());
}
#[test]
fn test_claims_serde() {
let claims = create_test_claims();
let json = serde_json::to_string(&claims).unwrap();
let deserialized: Claims = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.root, claims.root);
assert_eq!(deserialized.publish, claims.publish);
assert_eq!(deserialized.subscribe, claims.subscribe);
}
#[test]
fn test_claims_serde_names() {
let claims = Claims {
root: "live".into(),
publish: patterns(&["camera1"]),
subscribe: patterns(&["camera1", "camera2"]),
..Default::default()
};
assert_eq!(
serde_json::to_string(&claims).unwrap(),
r#"{"root":"live","publish":["camera1"],"subscribe":["camera1","camera2"]}"#
);
}
#[test]
fn test_claims_read_legacy_prefixes_as_subtrees() {
let claims: Claims =
serde_json::from_str(r#"{"root":"test","put":["pub1","/a//b/"],"get":"","exp":1700000000}"#).unwrap();
assert_eq!(claims.publish, patterns(&["pub1/**", "a/b/**"]));
assert_eq!(claims.subscribe, patterns(&["**"]));
assert!(claims.expires.is_some());
}
#[test]
fn test_claims_write_legacy_prefixes_only_when_faithful() {
let subtrees = Claims {
root: "live".into(),
publish: patterns(&["camera1/**"]),
subscribe: patterns(&["**"]),
..Default::default()
};
let json = serde_json::to_string(&subtrees).unwrap();
assert_eq!(json, r#"{"root":"live","put":["camera1"],"get":[""]}"#);
let back: Claims = serde_json::from_str(&json).unwrap();
assert_eq!(back.publish, subtrees.publish);
assert_eq!(back.subscribe, subtrees.subscribe);
let mixed = Claims {
root: "live".into(),
publish: patterns(&["camera1/**"]),
subscribe: patterns(&["*/chat"]),
..Default::default()
};
assert_eq!(
serde_json::to_string(&mixed).unwrap(),
r#"{"root":"live","publish":["camera1/**"],"subscribe":["*/chat"]}"#
);
}
#[test]
fn test_claims_refuse_mixed_or_unknown_fields() {
for json in [
r#"{"root":"test","publish":["pub1"],"get":["sub1"]}"#,
r#"{"root":"test","put":[],"subscribe":["sub1"]}"#,
r#"{"root":"test","put":["pub1"],"cluster":true}"#,
r#"{"root":"test","put":null,"publish":["pub1"]}"#,
r#"{"root":"test","publish":null,"subscribe":["sub1"]}"#,
] {
assert!(serde_json::from_str::<Claims>(json).is_err(), "{json}");
}
}
#[test]
fn test_claims_refuse_a_wildcard_in_a_legacy_prefix() {
assert!(serde_json::from_str::<Claims>(r#"{"put":["a/*"]}"#).is_err());
}
#[test]
fn test_claims_refuse_a_bad_pattern() {
let err = serde_json::from_str::<Claims>(r#"{"publish":["a/**/b/**"]}"#).unwrap_err();
assert!(err.to_string().contains("**"), "{err}");
}
#[test]
fn test_claims_default() {
let claims = Claims::default();
assert_eq!(claims.root, "");
assert!(claims.publish.is_empty());
assert!(claims.subscribe.is_empty());
assert_eq!(claims.expires, None);
assert_eq!(claims.issued, None);
}
fn authorize_claims(root: &str, subscribe: &[&str], publish: &[&str]) -> Claims {
Claims {
root: root.to_string(),
subscribe: patterns(subscribe),
publish: patterns(publish),
..Default::default()
}
}
#[test]
fn test_authorize_path_equals_root() {
let claims = authorize_claims("room/123", &["**"], &["alice/**"]);
let permissions = claims.authorize("room/123").unwrap();
assert_eq!(permissions.subscribe, patterns(&["**"]));
assert_eq!(permissions.publish, patterns(&["alice/**"]));
}
#[test]
fn test_authorize_path_extends_root() {
let claims = authorize_claims("room/123", &["bob/**"], &["alice/**"]);
let permissions = claims.authorize("room/123/alice").unwrap();
assert_eq!(permissions.subscribe, Patterns::new());
assert_eq!(permissions.publish, patterns(&["**"]));
}
#[test]
fn test_authorize_literal_becomes_the_path_itself() {
let claims = authorize_claims("room", &[], &["alice"]);
let permissions = claims.authorize("room/alice").unwrap();
assert_eq!(permissions.publish, patterns(&[""]));
}
#[test]
fn test_authorize_path_is_parent_of_root() {
let claims = authorize_claims("demo", &["**"], &["alice/**"]);
let permissions = claims.authorize("/").unwrap();
assert_eq!(permissions.subscribe, patterns(&["demo/**"]));
assert_eq!(permissions.publish, patterns(&["demo/alice/**"]));
}
#[test]
fn test_authorize_empty_root() {
let claims = authorize_claims("", &["demo/**"], &[]);
let permissions = claims.authorize("demo/room").unwrap();
assert_eq!(permissions.subscribe, patterns(&["**"]));
assert_eq!(permissions.publish, Patterns::new());
}
#[test]
fn test_authorize_slashes_are_implicit() {
let claims = authorize_claims("/room/123/", &["bob/**"], &[]);
let permissions = claims.authorize("//room/123//").unwrap();
assert_eq!(permissions.subscribe, patterns(&["bob/**"]));
}
#[test]
fn test_authorize_respects_segment_boundaries() {
let claims = authorize_claims("foo", &["**"], &["**"]);
assert!(matches!(claims.authorize("foobar"), Err(crate::Error::RootMismatch(_))));
}
#[test]
fn test_authorize_unrelated_path() {
let claims = authorize_claims("demo", &["**"], &["**"]);
assert!(matches!(claims.authorize("other"), Err(crate::Error::RootMismatch(_))));
}
#[test]
fn test_authorize_no_access_at_path() {
let claims = authorize_claims("", &["demo/**"], &[]);
assert!(matches!(claims.authorize("other"), Err(crate::Error::NoAccess(_))));
}
#[test]
fn test_authorize_wildcards_rebase_as_a_set() {
let claims = authorize_claims("", &["**/chat"], &[]);
let permissions = claims.authorize("chat").unwrap();
assert_eq!(permissions.subscribe.len(), 2);
assert_eq!(permissions.subscribe, patterns(&["", "**/chat"]));
}
}