use std::time::Duration;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::error::AttestationError;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AttestationClaims {
pub agent_uri: String,
pub capabilities: Vec<String>,
pub iss: String,
pub iat: DateTime<Utc>,
pub exp: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aud: Option<String>,
}
impl AttestationClaims {
#[must_use]
pub fn builder() -> AttestationClaimsBuilder {
AttestationClaimsBuilder::new()
}
#[must_use]
pub fn trust_root(&self) -> Option<&str> {
self.agent_uri
.strip_prefix("agent://")
.and_then(|rest| rest.split('/').next())
}
#[must_use]
pub fn is_expired(&self) -> bool {
Utc::now() >= self.exp
}
#[must_use]
pub fn is_not_yet_valid(&self) -> bool {
Utc::now() < self.iat
}
#[must_use]
pub fn is_expired_at(&self, now: DateTime<Utc>) -> bool {
now >= self.exp
}
}
#[derive(Debug, Clone)]
pub struct AttestationClaimsBuilder {
agent_uri: Option<String>,
capabilities: Vec<String>,
issuer: Option<String>,
ttl: Duration,
audience: Option<String>,
}
impl AttestationClaimsBuilder {
#[must_use]
pub fn new() -> Self {
Self {
agent_uri: None,
capabilities: Vec::new(),
issuer: None,
ttl: Duration::from_secs(86400), audience: None,
}
}
#[must_use]
pub fn agent_uri(mut self, uri: impl Into<String>) -> Self {
self.agent_uri = Some(uri.into());
self
}
#[must_use]
pub fn capabilities(mut self, caps: Vec<String>) -> Self {
self.capabilities = caps;
self
}
#[must_use]
pub fn add_capability(mut self, cap: impl Into<String>) -> Self {
self.capabilities.push(cap.into());
self
}
#[must_use]
pub fn issuer(mut self, issuer: impl Into<String>) -> Self {
self.issuer = Some(issuer.into());
self
}
#[must_use]
pub fn ttl(mut self, ttl: Duration) -> Self {
self.ttl = ttl;
self
}
#[must_use]
pub fn audience(mut self, aud: impl Into<String>) -> Self {
self.audience = Some(aud.into());
self
}
pub fn build(self) -> Result<AttestationClaims, AttestationError> {
let agent_uri = self.agent_uri.ok_or(AttestationError::MissingField {
field: "agent_uri",
})?;
let issuer = self.issuer.ok_or(AttestationError::MissingField {
field: "issuer",
})?;
let now = Utc::now();
let exp = now
+ chrono::Duration::from_std(self.ttl).map_err(|_| AttestationError::InvalidTtl)?;
Ok(AttestationClaims {
agent_uri,
capabilities: self.capabilities,
iss: issuer,
iat: now,
exp,
aud: self.audience,
})
}
}
impl Default for AttestationClaimsBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builder_creates_valid_claims() {
let claims = AttestationClaimsBuilder::new()
.agent_uri("agent://acme.com/test/agent_01h455vb4pex5vsknk084sn02q")
.issuer("acme.com")
.build()
.unwrap();
assert_eq!(
claims.agent_uri,
"agent://acme.com/test/agent_01h455vb4pex5vsknk084sn02q"
);
assert_eq!(claims.iss, "acme.com");
assert!(claims.capabilities.is_empty());
assert!(claims.aud.is_none());
}
#[test]
fn builder_requires_agent_uri() {
let result = AttestationClaimsBuilder::new().issuer("acme.com").build();
assert!(matches!(
result,
Err(AttestationError::MissingField { field: "agent_uri" })
));
}
#[test]
fn builder_requires_issuer() {
let result = AttestationClaimsBuilder::new()
.agent_uri("agent://acme.com/test/agent_01h455vb4pex5vsknk084sn02q")
.build();
assert!(matches!(
result,
Err(AttestationError::MissingField { field: "issuer" })
));
}
#[test]
fn builder_with_capabilities() {
let claims = AttestationClaimsBuilder::new()
.agent_uri("agent://acme.com/test/agent_01h455vb4pex5vsknk084sn02q")
.issuer("acme.com")
.add_capability("read")
.add_capability("write")
.build()
.unwrap();
assert_eq!(claims.capabilities, vec!["read", "write"]);
}
#[test]
fn builder_with_audience() {
let claims = AttestationClaimsBuilder::new()
.agent_uri("agent://acme.com/test/agent_01h455vb4pex5vsknk084sn02q")
.issuer("acme.com")
.audience("api.acme.com")
.build()
.unwrap();
assert_eq!(claims.aud, Some("api.acme.com".to_string()));
}
#[test]
fn builder_with_custom_ttl() {
let claims = AttestationClaimsBuilder::new()
.agent_uri("agent://acme.com/test/agent_01h455vb4pex5vsknk084sn02q")
.issuer("acme.com")
.ttl(Duration::from_secs(3600))
.build()
.unwrap();
let expected_exp = claims.iat + chrono::Duration::seconds(3600);
assert!((claims.exp - expected_exp).num_seconds().abs() < 2);
}
#[test]
fn trust_root_extraction() {
let claims = AttestationClaimsBuilder::new()
.agent_uri("agent://acme.com/workflow/approval/rule_01h455vb4pex5vsknk084sn02q")
.issuer("acme.com")
.build()
.unwrap();
assert_eq!(claims.trust_root(), Some("acme.com"));
}
#[test]
fn trust_root_with_port() {
let claims = AttestationClaimsBuilder::new()
.agent_uri("agent://localhost:8472/test/agent_01h455vb4pex5vsknk084sn02q")
.issuer("localhost:8472")
.build()
.unwrap();
assert_eq!(claims.trust_root(), Some("localhost:8472"));
}
#[test]
fn is_expired_returns_false_for_future_expiration() {
let claims = AttestationClaimsBuilder::new()
.agent_uri("agent://acme.com/test/agent_01h455vb4pex5vsknk084sn02q")
.issuer("acme.com")
.ttl(Duration::from_secs(3600))
.build()
.unwrap();
assert!(!claims.is_expired());
}
#[test]
fn claims_serialization_roundtrip() {
let original = AttestationClaimsBuilder::new()
.agent_uri("agent://acme.com/test/agent_01h455vb4pex5vsknk084sn02q")
.issuer("acme.com")
.add_capability("read")
.audience("api.acme.com")
.build()
.unwrap();
let json = serde_json::to_string(&original).unwrap();
let recovered: AttestationClaims = serde_json::from_str(&json).unwrap();
assert_eq!(original.agent_uri, recovered.agent_uri);
assert_eq!(original.iss, recovered.iss);
assert_eq!(original.capabilities, recovered.capabilities);
assert_eq!(original.aud, recovered.aud);
}
}