1use std::fmt;
7
8use serde::{Deserialize, Serialize};
9
10pub use auths_verifier::types::{AssuranceLevel, AssuranceLevelParseError, CanonicalDid};
12
13pub type DidParseError = auths_verifier::DidParseError;
15
16#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
22#[serde(try_from = "String", into = "String")]
23pub struct CanonicalCapability(String);
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct CapabilityParseError(pub String);
28
29impl std::fmt::Display for CapabilityParseError {
30 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31 write!(f, "{}", self.0)
32 }
33}
34
35impl std::error::Error for CapabilityParseError {}
36
37impl CanonicalCapability {
38 pub fn parse(raw: &str) -> Result<Self, CapabilityParseError> {
46 let trimmed = raw.trim();
47 if trimmed.is_empty() || trimmed.len() > 64 {
48 return Err(CapabilityParseError(format!(
49 "capability must be 1-64 chars, got {}",
50 trimmed.len()
51 )));
52 }
53 if !trimmed
54 .chars()
55 .all(|c| c.is_alphanumeric() || c == ':' || c == '-' || c == '_')
56 {
57 return Err(CapabilityParseError(format!(
58 "invalid chars in capability: '{}'",
59 trimmed
60 )));
61 }
62 Ok(Self(trimmed.to_lowercase()))
64 }
65
66 pub fn as_str(&self) -> &str {
68 &self.0
69 }
70}
71
72impl TryFrom<String> for CanonicalCapability {
73 type Error = CapabilityParseError;
74 fn try_from(s: String) -> Result<Self, Self::Error> {
75 Self::parse(&s)
76 }
77}
78
79impl From<CanonicalCapability> for String {
80 fn from(c: CanonicalCapability) -> Self {
81 c.0
82 }
83}
84
85impl fmt::Display for CanonicalCapability {
86 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87 f.write_str(&self.0)
88 }
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
96#[non_exhaustive]
97pub enum SignerType {
98 Human,
100 Agent,
102 Workload,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(try_from = "String", into = "String")]
116pub struct ValidatedGlob(String);
117
118#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct GlobParseError(pub String);
121
122impl std::fmt::Display for GlobParseError {
123 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124 write!(f, "{}", self.0)
125 }
126}
127
128impl std::error::Error for GlobParseError {}
129
130impl ValidatedGlob {
131 pub fn parse(raw: &str) -> Result<Self, GlobParseError> {
140 let trimmed = raw.trim();
141 if trimmed.is_empty() || trimmed.len() > 256 {
142 return Err(GlobParseError(format!(
143 "glob must be 1-256 chars, got {}",
144 trimmed.len()
145 )));
146 }
147 if !trimmed.chars().all(|c| c.is_ascii() && !c.is_control()) {
148 return Err(GlobParseError(
149 "glob contains non-ASCII or control chars".into(),
150 ));
151 }
152 if trimmed.contains("..") {
153 return Err(GlobParseError("glob contains path traversal (..)".into()));
154 }
155 let normalised: String = trimmed
157 .split('/')
158 .filter(|s| !s.is_empty())
159 .collect::<Vec<_>>()
160 .join("/");
161 Ok(Self(normalised))
162 }
163
164 pub fn as_str(&self) -> &str {
166 &self.0
167 }
168}
169
170impl TryFrom<String> for ValidatedGlob {
171 type Error = GlobParseError;
172 fn try_from(s: String) -> Result<Self, Self::Error> {
173 Self::parse(&s)
174 }
175}
176
177impl From<ValidatedGlob> for String {
178 fn from(g: ValidatedGlob) -> Self {
179 g.0
180 }
181}
182
183impl fmt::Display for ValidatedGlob {
184 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
185 f.write_str(&self.0)
186 }
187}
188
189#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
206pub struct QuorumPolicy {
207 pub required_humans: u32,
209 pub required_agents: u32,
211 pub required_total: u32,
213 pub base_expression: crate::expr::Expr,
215}
216
217impl QuorumPolicy {
218 pub fn evaluate<F>(&self, contexts: &[crate::context::EvalContext], eval_fn: F) -> bool
225 where
226 F: Fn(&crate::expr::Expr, &crate::context::EvalContext) -> bool,
227 {
228 let mut human_count: u32 = 0;
229 let mut agent_count: u32 = 0;
230 let mut total_count: u32 = 0;
231
232 for ctx in contexts {
233 if eval_fn(&self.base_expression, ctx) {
234 total_count += 1;
235 match ctx.signer_type {
236 Some(SignerType::Human) => human_count += 1,
237 Some(SignerType::Agent) => agent_count += 1,
238 Some(SignerType::Workload) | None => {}
239 }
240 }
241 }
242
243 human_count >= self.required_humans
244 && agent_count >= self.required_agents
245 && total_count >= self.required_total
246 }
247}
248
249#[cfg(test)]
250#[allow(clippy::disallowed_methods)]
251mod tests {
252 use super::*;
253
254 mod canonical_did {
255 use super::*;
256
257 #[test]
258 fn parses_valid_did() {
259 let did = CanonicalDid::parse("did:keri:EOrg123").unwrap();
260 assert_eq!(did.as_str(), "did:keri:EOrg123");
261 }
262
263 #[test]
264 fn lowercases_method() {
265 let did = CanonicalDid::parse("did:KERI:EOrg123").unwrap();
266 assert_eq!(did.as_str(), "did:keri:EOrg123");
267 }
268
269 #[test]
270 fn preserves_id_case() {
271 let did = CanonicalDid::parse("did:key:zABC123XYZ").unwrap();
272 assert_eq!(did.as_str(), "did:key:zABC123XYZ");
273 }
274
275 #[test]
276 fn trims_whitespace() {
277 let did = CanonicalDid::parse(" did:keri:EOrg123 ").unwrap();
278 assert_eq!(did.as_str(), "did:keri:EOrg123");
279 }
280
281 #[test]
282 fn rejects_empty() {
283 assert!(CanonicalDid::parse("").is_err());
284 assert!(CanonicalDid::parse(" ").is_err());
285 }
286
287 #[test]
288 fn rejects_missing_parts() {
289 assert!(CanonicalDid::parse("did").is_err());
290 assert!(CanonicalDid::parse("did:keri").is_err());
291 assert!(CanonicalDid::parse("did::id").is_err());
292 assert!(CanonicalDid::parse("did:keri:").is_err());
293 }
294
295 #[test]
296 fn rejects_wrong_prefix() {
297 assert!(CanonicalDid::parse("uri:keri:id").is_err());
298 }
299
300 #[test]
301 fn rejects_control_chars() {
302 assert!(CanonicalDid::parse("did:keri:id\x00").is_err());
303 assert!(CanonicalDid::parse("did:keri:id\n").is_err());
304 }
305
306 #[test]
307 fn serde_roundtrip() {
308 let did = CanonicalDid::parse("did:keri:EOrg123").unwrap();
309 let json = serde_json::to_string(&did).unwrap();
310 let parsed: CanonicalDid = serde_json::from_str(&json).unwrap();
311 assert_eq!(did, parsed);
312 }
313 }
314
315 mod canonical_capability {
316 use super::*;
317
318 #[test]
319 fn parses_valid_capability() {
320 let cap = CanonicalCapability::parse("sign_commit").unwrap();
321 assert_eq!(cap.as_str(), "sign_commit");
322 }
323
324 #[test]
325 fn lowercases() {
326 let cap = CanonicalCapability::parse("Sign_Commit").unwrap();
327 assert_eq!(cap.as_str(), "sign_commit");
328 }
329
330 #[test]
331 fn allows_colons_and_hyphens() {
332 let cap = CanonicalCapability::parse("repo:read-write").unwrap();
333 assert_eq!(cap.as_str(), "repo:read-write");
334 }
335
336 #[test]
337 fn trims_whitespace() {
338 let cap = CanonicalCapability::parse(" sign_commit ").unwrap();
339 assert_eq!(cap.as_str(), "sign_commit");
340 }
341
342 #[test]
343 fn rejects_empty() {
344 assert!(CanonicalCapability::parse("").is_err());
345 }
346
347 #[test]
348 fn rejects_too_long() {
349 let long = "a".repeat(65);
350 assert!(CanonicalCapability::parse(&long).is_err());
351 }
352
353 #[test]
354 fn accepts_max_length() {
355 let max = "a".repeat(64);
356 assert!(CanonicalCapability::parse(&max).is_ok());
357 }
358
359 #[test]
360 fn rejects_invalid_chars() {
361 assert!(CanonicalCapability::parse("sign commit").is_err()); assert!(CanonicalCapability::parse("sign.commit").is_err()); assert!(CanonicalCapability::parse("sign/commit").is_err()); }
365
366 #[test]
367 fn serde_roundtrip() {
368 let cap = CanonicalCapability::parse("sign_commit").unwrap();
369 let json = serde_json::to_string(&cap).unwrap();
370 let parsed: CanonicalCapability = serde_json::from_str(&json).unwrap();
371 assert_eq!(cap, parsed);
372 }
373 }
374
375 mod validated_glob {
376 use super::*;
377
378 #[test]
379 fn parses_simple_path() {
380 let glob = ValidatedGlob::parse("refs/heads/main").unwrap();
381 assert_eq!(glob.as_str(), "refs/heads/main");
382 }
383
384 #[test]
385 fn parses_wildcards() {
386 let glob = ValidatedGlob::parse("refs/heads/*").unwrap();
387 assert_eq!(glob.as_str(), "refs/heads/*");
388
389 let glob = ValidatedGlob::parse("refs/**/main").unwrap();
390 assert_eq!(glob.as_str(), "refs/**/main");
391 }
392
393 #[test]
394 fn normalises_consecutive_slashes() {
395 let glob = ValidatedGlob::parse("refs//heads///main").unwrap();
396 assert_eq!(glob.as_str(), "refs/heads/main");
397 }
398
399 #[test]
400 fn strips_leading_trailing_slashes() {
401 let glob = ValidatedGlob::parse("/refs/heads/main/").unwrap();
402 assert_eq!(glob.as_str(), "refs/heads/main");
403 }
404
405 #[test]
406 fn trims_whitespace() {
407 let glob = ValidatedGlob::parse(" refs/heads/main ").unwrap();
408 assert_eq!(glob.as_str(), "refs/heads/main");
409 }
410
411 #[test]
412 fn rejects_empty() {
413 assert!(ValidatedGlob::parse("").is_err());
414 }
415
416 #[test]
417 fn rejects_too_long() {
418 let long = "a/".repeat(129); assert!(ValidatedGlob::parse(&long).is_err());
420 }
421
422 #[test]
423 fn rejects_path_traversal() {
424 assert!(ValidatedGlob::parse("refs/../secrets").is_err());
425 assert!(ValidatedGlob::parse("..").is_err());
426 assert!(ValidatedGlob::parse("foo/..").is_err());
427 }
428
429 #[test]
430 fn rejects_non_ascii() {
431 assert!(ValidatedGlob::parse("refs/héads/main").is_err());
432 }
433
434 #[test]
435 fn rejects_control_chars() {
436 assert!(ValidatedGlob::parse("refs/heads/main\x00").is_err());
437 }
438
439 #[test]
440 fn serde_roundtrip() {
441 let glob = ValidatedGlob::parse("refs/heads/*").unwrap();
442 let json = serde_json::to_string(&glob).unwrap();
443 let parsed: ValidatedGlob = serde_json::from_str(&json).unwrap();
444 assert_eq!(glob, parsed);
445 }
446 }
447
448 mod signer_type {
449 use super::*;
450
451 #[test]
452 fn serde_roundtrip() {
453 for st in [SignerType::Human, SignerType::Agent, SignerType::Workload] {
454 let json = serde_json::to_string(&st).unwrap();
455 let parsed: SignerType = serde_json::from_str(&json).unwrap();
456 assert_eq!(st, parsed);
457 }
458 }
459
460 #[test]
461 fn equality() {
462 assert_eq!(SignerType::Human, SignerType::Human);
463 assert_ne!(SignerType::Human, SignerType::Agent);
464 assert_ne!(SignerType::Agent, SignerType::Workload);
465 }
466 }
467
468 mod quorum_policy {
469 use super::*;
470 use crate::context::EvalContext;
471 use crate::expr::Expr;
472 use chrono::Utc;
473
474 fn did(s: &str) -> CanonicalDid {
475 CanonicalDid::parse(s).unwrap()
476 }
477
478 fn make_ctx(signer_type: SignerType) -> EvalContext {
479 EvalContext::new(Utc::now(), did("did:keri:issuer"), did("did:keri:subject"))
480 .signer_type(signer_type)
481 }
482
483 fn always_pass(_expr: &Expr, _ctx: &EvalContext) -> bool {
484 true
485 }
486
487 fn always_fail(_expr: &Expr, _ctx: &EvalContext) -> bool {
488 false
489 }
490
491 #[test]
492 fn quorum_met_with_mixed_signers() {
493 let quorum = QuorumPolicy {
494 required_humans: 1,
495 required_agents: 1,
496 required_total: 2,
497 base_expression: Expr::True,
498 };
499 let contexts = vec![make_ctx(SignerType::Human), make_ctx(SignerType::Agent)];
500 assert!(quorum.evaluate(&contexts, always_pass));
501 }
502
503 #[test]
504 fn quorum_not_met_missing_human() {
505 let quorum = QuorumPolicy {
506 required_humans: 1,
507 required_agents: 1,
508 required_total: 2,
509 base_expression: Expr::True,
510 };
511 let contexts = vec![make_ctx(SignerType::Agent), make_ctx(SignerType::Agent)];
512 assert!(!quorum.evaluate(&contexts, always_pass));
513 }
514
515 #[test]
516 fn quorum_not_met_base_expression_fails() {
517 let quorum = QuorumPolicy {
518 required_humans: 1,
519 required_agents: 0,
520 required_total: 1,
521 base_expression: Expr::True,
522 };
523 let contexts = vec![make_ctx(SignerType::Human)];
524 assert!(!quorum.evaluate(&contexts, always_fail));
525 }
526
527 #[test]
528 fn quorum_empty_contexts() {
529 let quorum = QuorumPolicy {
530 required_humans: 0,
531 required_agents: 0,
532 required_total: 0,
533 base_expression: Expr::True,
534 };
535 assert!(quorum.evaluate(&[], always_pass));
536 }
537
538 #[test]
539 fn quorum_total_threshold() {
540 let quorum = QuorumPolicy {
541 required_humans: 0,
542 required_agents: 0,
543 required_total: 3,
544 base_expression: Expr::True,
545 };
546 let contexts = vec![make_ctx(SignerType::Human), make_ctx(SignerType::Agent)];
547 assert!(!quorum.evaluate(&contexts, always_pass));
548
549 let contexts = vec![
550 make_ctx(SignerType::Human),
551 make_ctx(SignerType::Agent),
552 make_ctx(SignerType::Workload),
553 ];
554 assert!(quorum.evaluate(&contexts, always_pass));
555 }
556
557 #[test]
558 fn serde_roundtrip() {
559 let quorum = QuorumPolicy {
560 required_humans: 1,
561 required_agents: 1,
562 required_total: 2,
563 base_expression: Expr::And(vec![Expr::NotRevoked, Expr::NotExpired]),
564 };
565 let json = serde_json::to_string(&quorum).unwrap();
566 let parsed: QuorumPolicy = serde_json::from_str(&json).unwrap();
567 assert_eq!(quorum, parsed);
568 }
569 }
570}