1use std::collections::BTreeSet;
44use std::fmt;
45
46use serde::{Deserialize, Serialize};
47use thiserror::Error;
48
49macro_rules! string_id {
58 ($(#[$meta:meta])* $name:ident) => {
59 $(#[$meta])*
60 #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
61 pub struct $name(pub String);
62
63 impl $name {
64 pub fn as_str(&self) -> &str {
66 &self.0
67 }
68 }
69
70 impl fmt::Display for $name {
71 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72 self.0.fmt(f)
73 }
74 }
75
76 impl From<String> for $name {
77 fn from(s: String) -> Self {
78 Self(s)
79 }
80 }
81
82 impl From<&str> for $name {
83 fn from(s: &str) -> Self {
84 Self(s.to_owned())
85 }
86 }
87 };
88}
89
90string_id! {
91 OperatorId
97}
98
99string_id! {
100 PlatformId
104}
105
106string_id! {
107 DelegateId
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
117pub struct ExternalId(pub String);
118
119impl ExternalId {
120 pub fn as_str(&self) -> &str {
121 &self.0
122 }
123}
124
125impl fmt::Display for ExternalId {
126 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127 self.0.fmt(f)
128 }
129}
130
131impl From<String> for ExternalId {
132 fn from(s: String) -> Self {
133 Self(s)
134 }
135}
136
137impl From<&str> for ExternalId {
138 fn from(s: &str) -> Self {
139 Self(s.to_owned())
140 }
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
151#[serde(tag = "kind", rename_all = "snake_case")]
152pub enum TrustRoot {
153 Oidc { issuer: String },
157 Ado { realm: String },
159 GitHub { org: String },
163}
164
165impl TrustRoot {
166 fn to_uri_segments(&self) -> String {
169 match self {
170 TrustRoot::Oidc { issuer } => format!("oidc/{}", percent_encode(issuer)),
171 TrustRoot::Ado { realm } => format!("ado/{}", percent_encode(realm)),
172 TrustRoot::GitHub { org } => format!("github/{}", percent_encode(org)),
173 }
174 }
175
176 fn from_uri_segments(kind: &str, rest: &str) -> Result<Self, PrincipalParseError> {
177 match kind {
178 "oidc" => Ok(TrustRoot::Oidc {
179 issuer: percent_decode(rest)?,
180 }),
181 "ado" => Ok(TrustRoot::Ado {
182 realm: percent_decode(rest)?,
183 }),
184 "github" => Ok(TrustRoot::GitHub {
185 org: percent_decode(rest)?,
186 }),
187 other => Err(PrincipalParseError::UnknownTrustRoot(other.to_owned())),
188 }
189 }
190}
191
192#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
202#[serde(transparent)]
203pub struct AuthorityScope {
204 capabilities: BTreeSet<Capability>,
205}
206
207impl AuthorityScope {
208 pub fn empty() -> Self {
212 Self {
213 capabilities: BTreeSet::new(),
214 }
215 }
216
217 pub fn root() -> Self {
221 Self {
222 capabilities: BTreeSet::from([Capability::ToolWildcard]),
223 }
224 }
225
226 pub fn from_capabilities<I: IntoIterator<Item = Capability>>(caps: I) -> Self {
228 Self {
229 capabilities: caps.into_iter().collect(),
230 }
231 }
232
233 pub fn contains(&self, cap: &Capability) -> bool {
234 self.capabilities.contains(cap)
235 || (self.capabilities.contains(&Capability::ToolWildcard) && cap.is_tool())
236 }
237
238 pub fn is_superset_of(&self, other: &AuthorityScope) -> bool {
241 other.capabilities.iter().all(|c| self.contains(c))
242 }
243
244 pub fn iter(&self) -> impl Iterator<Item = &Capability> {
245 self.capabilities.iter()
246 }
247
248 pub fn is_empty(&self) -> bool {
249 self.capabilities.is_empty()
250 }
251
252 pub fn len(&self) -> usize {
253 self.capabilities.len()
254 }
255}
256
257#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
261pub enum Capability {
262 ToolApply,
263 ToolGet,
264 ToolLogs,
265 ToolEvents,
266 ToolWildcard,
268}
269
270impl Capability {
271 pub fn as_token(&self) -> &'static str {
274 match self {
275 Capability::ToolApply => "tool:apply",
276 Capability::ToolGet => "tool:get",
277 Capability::ToolLogs => "tool:logs",
278 Capability::ToolEvents => "tool:events",
279 Capability::ToolWildcard => "tool:*",
280 }
281 }
282
283 pub fn from_token(token: &str) -> Result<Self, PrincipalParseError> {
284 match token {
285 "tool:apply" => Ok(Capability::ToolApply),
286 "tool:get" => Ok(Capability::ToolGet),
287 "tool:logs" => Ok(Capability::ToolLogs),
288 "tool:events" => Ok(Capability::ToolEvents),
289 "tool:*" => Ok(Capability::ToolWildcard),
290 other => Err(PrincipalParseError::UnknownCapability(other.to_owned())),
291 }
292 }
293
294 fn is_tool(&self) -> bool {
299 matches!(
300 self,
301 Capability::ToolApply
302 | Capability::ToolGet
303 | Capability::ToolLogs
304 | Capability::ToolEvents
305 | Capability::ToolWildcard
306 )
307 }
308}
309
310impl Serialize for Capability {
311 fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
312 ser.serialize_str(self.as_token())
313 }
314}
315
316impl<'de> Deserialize<'de> for Capability {
317 fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
318 let s = String::deserialize(de)?;
319 Capability::from_token(&s).map_err(serde::de::Error::custom)
320 }
321}
322
323impl fmt::Display for Capability {
324 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
325 f.write_str(self.as_token())
326 }
327}
328
329#[derive(Debug, Clone, PartialEq, Eq, Error)]
336#[error(
337 "delegate scope not narrowing: requested capability `{missing}` \
338 not held by authorizing principal"
339)]
340pub struct AuthorityScopeViolation {
341 pub missing: Capability,
345}
346
347#[derive(Debug, Clone, PartialEq, Eq, Error)]
351pub enum PrincipalParseError {
352 #[error("principal URI must start with `principal://`, got `{0}`")]
353 MissingScheme(String),
354 #[error("principal URI has no variant segment: `{0}`")]
355 EmptyPath(String),
356 #[error("unknown principal kind `{0}` (expected operator|platform|delegate|federated)")]
357 UnknownKind(String),
358 #[error("malformed `{kind}` principal URI: {reason}")]
359 Malformed { kind: &'static str, reason: String },
360 #[error("unknown trust root `{0}` (expected oidc|ado|github)")]
361 UnknownTrustRoot(String),
362 #[error("unknown capability token `{0}`")]
363 UnknownCapability(String),
364 #[error("invalid percent-encoding in URI segment: `{0}`")]
365 BadPercentEncoding(String),
366}
367
368#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
384#[serde(tag = "kind", rename_all = "snake_case")]
385pub enum Principal {
386 Operator { id: OperatorId },
390
391 Platform { id: PlatformId },
394
395 Delegate {
400 authorizing: Box<Principal>,
401 delegate: DelegateId,
402 scope: AuthorityScope,
403 },
404
405 Federated {
408 trust_root: TrustRoot,
409 identity: ExternalId,
410 },
411}
412
413impl Principal {
414 pub fn root_operator(&self) -> Option<&OperatorId> {
419 match self {
420 Principal::Operator { id } => Some(id),
421 Principal::Delegate { authorizing, .. } => authorizing.root_operator(),
422 Principal::Platform { .. } | Principal::Federated { .. } => None,
423 }
424 }
425
426 pub fn effective_scope(&self) -> AuthorityScope {
437 match self {
438 Principal::Delegate { scope, .. } => scope.clone(),
439 _ => AuthorityScope::root(),
440 }
441 }
442
443 pub fn compose(
450 authorizing: Principal,
451 delegate: DelegateId,
452 requested_scope: AuthorityScope,
453 ) -> Result<Principal, AuthorityScopeViolation> {
454 let upstream = authorizing.effective_scope();
455 if let Some(missing) = requested_scope.iter().find(|cap| !upstream.contains(cap)) {
456 return Err(AuthorityScopeViolation {
457 missing: (*missing).clone(),
458 });
459 }
460 Ok(Principal::Delegate {
461 authorizing: Box::new(authorizing),
462 delegate,
463 scope: requested_scope,
464 })
465 }
466
467 pub fn to_source_uri(&self) -> String {
481 format!("principal://{}", self.uri_body_with_query())
482 }
483
484 fn uri_body_with_query(&self) -> String {
487 match self {
488 Principal::Operator { id } => format!("operator/{}", percent_encode(id.as_str())),
489 Principal::Platform { id } => format!("platform/{}", percent_encode(id.as_str())),
490 Principal::Federated {
491 trust_root,
492 identity,
493 } => format!(
494 "federated/{}/identity/{}",
495 trust_root.to_uri_segments(),
496 percent_encode(identity.as_str())
497 ),
498 Principal::Delegate {
499 authorizing,
500 delegate,
501 scope,
502 } => {
503 let (auth_body, auth_query) = authorizing.uri_split();
504 let mut body = format!(
505 "{}/delegate/{}",
506 auth_body,
507 percent_encode(delegate.as_str())
508 );
509 let scope_query = if scope.is_empty() {
513 String::new()
514 } else {
515 let tokens: Vec<&str> = scope.iter().map(|c| c.as_token()).collect();
516 format!("scope={}", tokens.join(","))
517 };
518 let merged = match (auth_query.is_empty(), scope_query.is_empty()) {
519 (true, true) => String::new(),
520 (false, true) => auth_query,
521 (true, false) => scope_query,
522 (false, false) => format!("{}&{}", auth_query, scope_query),
523 };
524 if merged.is_empty() {
525 body
526 } else {
527 body.push('?');
528 body.push_str(&merged);
529 body
530 }
531 }
532 }
533 }
534
535 fn uri_split(&self) -> (String, String) {
537 let full = self.uri_body_with_query();
538 match full.find('?') {
539 Some(i) => (full[..i].to_owned(), full[i + 1..].to_owned()),
540 None => (full, String::new()),
541 }
542 }
543
544 pub fn from_source_uri(uri: &str) -> Result<Principal, PrincipalParseError> {
548 let rest = uri
549 .strip_prefix("principal://")
550 .ok_or_else(|| PrincipalParseError::MissingScheme(uri.to_owned()))?;
551 if rest.is_empty() {
552 return Err(PrincipalParseError::EmptyPath(uri.to_owned()));
553 }
554
555 let (path, query) = match rest.find('?') {
557 Some(i) => (&rest[..i], &rest[i + 1..]),
558 None => (rest, ""),
559 };
560
561 let mut scope_clauses: Vec<AuthorityScope> = Vec::new();
565 if !query.is_empty() {
566 for clause in query.split('&') {
567 let (k, v) =
568 clause
569 .split_once('=')
570 .ok_or_else(|| PrincipalParseError::Malformed {
571 kind: "query",
572 reason: format!("clause `{}` missing `=`", clause),
573 })?;
574 if k != "scope" {
575 return Err(PrincipalParseError::Malformed {
576 kind: "query",
577 reason: format!("unknown query parameter `{}`", k),
578 });
579 }
580 let caps: Result<BTreeSet<Capability>, _> = v
581 .split(',')
582 .filter(|s| !s.is_empty())
583 .map(Capability::from_token)
584 .collect();
585 scope_clauses.push(AuthorityScope {
586 capabilities: caps?,
587 });
588 }
589 }
590
591 let segments: Vec<&str> = path.split('/').collect();
592 Self::parse_segments(&segments, &mut scope_clauses.into_iter())
593 }
594
595 fn parse_segments(
599 segments: &[&str],
600 scopes: &mut std::vec::IntoIter<AuthorityScope>,
601 ) -> Result<Principal, PrincipalParseError> {
602 if segments.is_empty() {
603 return Err(PrincipalParseError::Malformed {
604 kind: "principal",
605 reason: "no segments".to_owned(),
606 });
607 }
608 match segments[0] {
609 "operator" => {
610 if segments.len() < 2 {
611 return Err(PrincipalParseError::Malformed {
612 kind: "operator",
613 reason: "missing id segment".to_owned(),
614 });
615 }
616 let id = percent_decode(segments[1])?;
617 if segments.len() > 2 && segments[2] != "delegate" {
621 return Err(PrincipalParseError::Malformed {
622 kind: "operator",
623 reason: format!("unexpected segment `{}` after operator id", segments[2]),
624 });
625 }
626 let principal = Principal::Operator { id: OperatorId(id) };
627 Self::wrap_delegates(principal, &segments[2..], scopes)
628 }
629 "platform" => {
630 if segments.len() < 2 {
631 return Err(PrincipalParseError::Malformed {
632 kind: "platform",
633 reason: "missing id segment".to_owned(),
634 });
635 }
636 let id = percent_decode(segments[1])?;
637 if segments.len() > 2 && segments[2] != "delegate" {
638 return Err(PrincipalParseError::Malformed {
639 kind: "platform",
640 reason: format!("unexpected segment `{}` after platform id", segments[2]),
641 });
642 }
643 let principal = Principal::Platform { id: PlatformId(id) };
644 Self::wrap_delegates(principal, &segments[2..], scopes)
645 }
646 "federated" => {
647 if segments.len() < 5 || segments[3] != "identity" {
649 return Err(PrincipalParseError::Malformed {
650 kind: "federated",
651 reason: format!(
652 "expected `federated/<root_kind>/<root_id>/identity/<external_id>`, got `{}`",
653 segments.join("/")
654 ),
655 });
656 }
657 let trust_root = TrustRoot::from_uri_segments(segments[1], segments[2])?;
658 let identity = ExternalId(percent_decode(segments[4])?);
659 if segments.len() > 5 && segments[5] != "delegate" {
660 return Err(PrincipalParseError::Malformed {
661 kind: "federated",
662 reason: format!(
663 "unexpected segment `{}` after federated identity",
664 segments[5]
665 ),
666 });
667 }
668 let principal = Principal::Federated {
669 trust_root,
670 identity,
671 };
672 Self::wrap_delegates(principal, &segments[5..], scopes)
673 }
674 other => Err(PrincipalParseError::UnknownKind(other.to_owned())),
675 }
676 }
677
678 fn wrap_delegates(
683 mut principal: Principal,
684 mut remaining: &[&str],
685 scopes: &mut std::vec::IntoIter<AuthorityScope>,
686 ) -> Result<Principal, PrincipalParseError> {
687 while !remaining.is_empty() {
688 if remaining[0] != "delegate" {
689 return Err(PrincipalParseError::Malformed {
690 kind: "delegate",
691 reason: format!("expected `delegate`, got `{}`", remaining[0]),
692 });
693 }
694 if remaining.len() < 2 {
695 return Err(PrincipalParseError::Malformed {
696 kind: "delegate",
697 reason: "missing delegate id segment".to_owned(),
698 });
699 }
700 let delegate_id = DelegateId(percent_decode(remaining[1])?);
701 let scope = scopes.next().unwrap_or_else(AuthorityScope::empty);
702 principal = Principal::Delegate {
703 authorizing: Box::new(principal),
704 delegate: delegate_id,
705 scope,
706 };
707 remaining = &remaining[2..];
708 }
709 Ok(principal)
710 }
711}
712
713fn percent_encode(s: &str) -> String {
720 let mut out = String::with_capacity(s.len());
721 for &b in s.as_bytes() {
722 let ok = b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~');
723 if ok {
724 out.push(b as char);
725 } else {
726 out.push('%');
727 out.push_str(&format!("{:02X}", b));
728 }
729 }
730 out
731}
732
733fn percent_decode(s: &str) -> Result<String, PrincipalParseError> {
736 let bytes = s.as_bytes();
737 let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
738 let mut i = 0;
739 while i < bytes.len() {
740 if bytes[i] == b'%' {
741 if i + 2 >= bytes.len() {
742 return Err(PrincipalParseError::BadPercentEncoding(s.to_owned()));
743 }
744 let hi = hex_nibble(bytes[i + 1])
745 .ok_or_else(|| PrincipalParseError::BadPercentEncoding(s.to_owned()))?;
746 let lo = hex_nibble(bytes[i + 2])
747 .ok_or_else(|| PrincipalParseError::BadPercentEncoding(s.to_owned()))?;
748 out.push((hi << 4) | lo);
749 i += 3;
750 } else {
751 out.push(bytes[i]);
752 i += 1;
753 }
754 }
755 String::from_utf8(out).map_err(|_| PrincipalParseError::BadPercentEncoding(s.to_owned()))
756}
757
758fn hex_nibble(b: u8) -> Option<u8> {
759 match b {
760 b'0'..=b'9' => Some(b - b'0'),
761 b'a'..=b'f' => Some(10 + b - b'a'),
762 b'A'..=b'F' => Some(10 + b - b'A'),
763 _ => None,
764 }
765}
766
767#[cfg(test)]
770mod tests {
771 use super::*;
772
773 fn op(id: &str) -> Principal {
774 Principal::Operator {
775 id: OperatorId(id.to_owned()),
776 }
777 }
778
779 fn plat(id: &str) -> Principal {
780 Principal::Platform {
781 id: PlatformId(id.to_owned()),
782 }
783 }
784
785 fn fed_ado(realm: &str, ext: &str) -> Principal {
786 Principal::Federated {
787 trust_root: TrustRoot::Ado {
788 realm: realm.to_owned(),
789 },
790 identity: ExternalId(ext.to_owned()),
791 }
792 }
793
794 #[test]
795 fn operator_round_trip() {
796 let p = op("op-123");
797 let uri = p.to_source_uri();
798 assert_eq!(uri, "principal://operator/op-123");
799 assert_eq!(Principal::from_source_uri(&uri).unwrap(), p);
800 }
801
802 #[test]
803 fn platform_round_trip() {
804 let p = plat("hosted-ctrl-plane-prod");
805 let uri = p.to_source_uri();
806 assert_eq!(uri, "principal://platform/hosted-ctrl-plane-prod");
807 assert_eq!(Principal::from_source_uri(&uri).unwrap(), p);
808 }
809
810 #[test]
811 fn federated_round_trip() {
812 let p = fed_ado("realm-acme", "user-7842");
813 let uri = p.to_source_uri();
814 assert_eq!(
815 uri,
816 "principal://federated/ado/realm-acme/identity/user-7842"
817 );
818 assert_eq!(Principal::from_source_uri(&uri).unwrap(), p);
819 }
820
821 #[test]
822 fn federated_oidc_round_trip_with_url_issuer() {
823 let p = Principal::Federated {
824 trust_root: TrustRoot::Oidc {
825 issuer: "https://login.example.com/".to_owned(),
826 },
827 identity: ExternalId("user-42".to_owned()),
828 };
829 let uri = p.to_source_uri();
830 assert!(uri.starts_with("principal://federated/oidc/"));
832 assert!(uri.contains("/identity/user-42"));
833 assert_eq!(Principal::from_source_uri(&uri).unwrap(), p);
834 }
835
836 #[test]
837 fn federated_github_round_trip() {
838 let p = Principal::Federated {
839 trust_root: TrustRoot::GitHub {
840 org: "anthropic".to_owned(),
841 },
842 identity: ExternalId("octocat".to_owned()),
843 };
844 let uri = p.to_source_uri();
845 assert_eq!(
846 uri,
847 "principal://federated/github/anthropic/identity/octocat"
848 );
849 assert_eq!(Principal::from_source_uri(&uri).unwrap(), p);
850 }
851
852 #[test]
853 fn delegate_round_trip_one_level() {
854 let scope = AuthorityScope::from_capabilities([Capability::ToolApply, Capability::ToolGet]);
855 let p = Principal::compose(op("op-123"), DelegateId("llm-claude-456".to_owned()), scope)
856 .unwrap();
857 let uri = p.to_source_uri();
858 assert_eq!(
859 uri,
860 "principal://operator/op-123/delegate/llm-claude-456?scope=tool:apply,tool:get"
861 );
862 assert_eq!(Principal::from_source_uri(&uri).unwrap(), p);
863 }
864
865 #[test]
866 fn delegate_round_trip_two_levels() {
867 let outer = Principal::compose(
868 op("op-123"),
869 DelegateId("bridge-A".to_owned()),
870 AuthorityScope::from_capabilities([Capability::ToolApply, Capability::ToolGet]),
871 )
872 .unwrap();
873 let inner = Principal::compose(
874 outer,
875 DelegateId("bridge-B".to_owned()),
876 AuthorityScope::from_capabilities([Capability::ToolGet]),
877 )
878 .unwrap();
879 let uri = inner.to_source_uri();
880 assert_eq!(Principal::from_source_uri(&uri).unwrap(), inner);
881 }
882
883 #[test]
884 fn delegate_with_empty_scope_round_trips() {
885 let p = Principal::compose(
888 op("op-123"),
889 DelegateId("noop-bridge".to_owned()),
890 AuthorityScope::empty(),
891 )
892 .unwrap();
893 let uri = p.to_source_uri();
894 assert_eq!(uri, "principal://operator/op-123/delegate/noop-bridge");
895 assert_eq!(Principal::from_source_uri(&uri).unwrap(), p);
896 }
897
898 #[test]
899 fn compose_narrows_scope_returns_ok() {
900 let p = Principal::compose(
902 op("op-123"),
903 DelegateId("session-1".to_owned()),
904 AuthorityScope::from_capabilities([Capability::ToolApply]),
905 );
906 assert!(p.is_ok());
907 }
908
909 #[test]
910 fn compose_broadens_scope_returns_err() {
911 let narrow = Principal::compose(
914 op("op-123"),
915 DelegateId("session-A".to_owned()),
916 AuthorityScope::from_capabilities([Capability::ToolGet]),
917 )
918 .unwrap();
919 let err = Principal::compose(
920 narrow,
921 DelegateId("session-B".to_owned()),
922 AuthorityScope::from_capabilities([Capability::ToolApply]),
923 )
924 .unwrap_err();
925 assert_eq!(err.missing, Capability::ToolApply);
926 }
927
928 #[test]
929 fn compose_wildcard_authorizes_specific_tool() {
930 let wide = Principal::compose(
931 op("op-123"),
932 DelegateId("session-wild".to_owned()),
933 AuthorityScope::from_capabilities([Capability::ToolWildcard]),
934 )
935 .unwrap();
936 let narrow = Principal::compose(
937 wide,
938 DelegateId("session-narrow".to_owned()),
939 AuthorityScope::from_capabilities([Capability::ToolApply, Capability::ToolEvents]),
940 );
941 assert!(narrow.is_ok());
942 }
943
944 #[test]
945 fn root_operator_finds_human_at_depth() {
946 let inner = Principal::compose(
947 Principal::compose(
948 op("op-human"),
949 DelegateId("d1".to_owned()),
950 AuthorityScope::from_capabilities([Capability::ToolApply]),
951 )
952 .unwrap(),
953 DelegateId("d2".to_owned()),
954 AuthorityScope::from_capabilities([Capability::ToolApply]),
955 )
956 .unwrap();
957 assert_eq!(
958 inner.root_operator(),
959 Some(&OperatorId("op-human".to_owned()))
960 );
961 }
962
963 #[test]
964 fn root_operator_returns_none_for_platform() {
965 assert_eq!(plat("hosted-ctrl-plane-prod").root_operator(), None);
966 }
967
968 #[test]
969 fn root_operator_returns_none_for_federated() {
970 assert_eq!(fed_ado("realm-acme", "user-7842").root_operator(), None);
971 }
972
973 #[test]
974 fn root_operator_returns_none_for_platform_rooted_delegate() {
975 let p = Principal::compose(
978 plat("hosted-ctrl-plane-prod"),
979 DelegateId("compactor".to_owned()),
980 AuthorityScope::from_capabilities([Capability::ToolApply]),
981 )
982 .unwrap();
983 assert_eq!(p.root_operator(), None);
984 }
985
986 #[test]
987 fn serde_json_round_trip_operator() {
988 let p = op("op-123");
989 let json = serde_json::to_string(&p).unwrap();
990 let back: Principal = serde_json::from_str(&json).unwrap();
991 assert_eq!(back, p);
992 }
993
994 #[test]
995 fn serde_json_round_trip_delegate() {
996 let p = Principal::compose(
997 op("op-123"),
998 DelegateId("session-1".to_owned()),
999 AuthorityScope::from_capabilities([Capability::ToolApply, Capability::ToolGet]),
1000 )
1001 .unwrap();
1002 let json = serde_json::to_string(&p).unwrap();
1003 let back: Principal = serde_json::from_str(&json).unwrap();
1004 assert_eq!(back, p);
1005 }
1006
1007 #[test]
1008 fn from_source_uri_rejects_missing_scheme() {
1009 let err = Principal::from_source_uri("http://operator/op-123").unwrap_err();
1010 assert!(matches!(err, PrincipalParseError::MissingScheme(_)));
1011 }
1012
1013 #[test]
1014 fn from_source_uri_rejects_unknown_kind() {
1015 let err = Principal::from_source_uri("principal://martian/op-123").unwrap_err();
1016 assert!(matches!(err, PrincipalParseError::UnknownKind(_)));
1017 }
1018
1019 #[test]
1020 fn from_source_uri_rejects_unknown_capability() {
1021 let err =
1022 Principal::from_source_uri("principal://operator/op-123/delegate/d?scope=tool:explode")
1023 .unwrap_err();
1024 assert!(matches!(err, PrincipalParseError::UnknownCapability(_)));
1025 }
1026
1027 #[test]
1028 fn percent_encoding_round_trips_slash_in_id() {
1029 let p = Principal::Operator {
1032 id: OperatorId("ns/op with space".to_owned()),
1033 };
1034 let uri = p.to_source_uri();
1035 assert_eq!(Principal::from_source_uri(&uri).unwrap(), p);
1036 }
1037}