Skip to main content

tenuo/sdk/
runtime.rs

1//! Long-lived holder runtime: identity, trust, revocation, and sessions.
2//!
3//! [`Tenuo::local`] still builds one guard bound to one chain. A [`Runtime`]
4//! holds the pieces that stay the same across warrants — holder key, trusted
5//! roots, receipt policy, TTL fallback — and binds each new warrant into a
6//! [`Session`]. Callers that obtain warrants and SRLs from the network
7//! should not assemble `Authorizer`, `LocalReceiptSigner`, and
8//! `MemoryReceiptSink`.
9
10use super::authority::{AuthorityError, PresentedAuthority};
11use super::call::Call;
12use super::decision::{Denial, DenialReporting, GuardError};
13use super::delegation::{DelegationError, DelegationProfile};
14use super::diagnostics::Diagnostics;
15use super::guard::{AuthorizedCall, Guard, GuardBuildError, Guarded, RevocationMode};
16use super::identity::{IdentityError, PersistentIdentity};
17use super::signer::LocalSigner;
18use crate::crypto::{PublicKey, SigningKey};
19use crate::planes::Authorizer;
20use crate::revocation::SignedRevocationList;
21use crate::revocation_tracker::{
22    FileFloorStore, InMemoryFloorStore, RevocationError, RevocationFloorStore, RevocationTracker,
23    RevocationUpdate,
24};
25use crate::warrant::Warrant;
26use crate::Error;
27use chrono::{DateTime, Utc};
28use std::fmt;
29use std::path::PathBuf;
30use std::sync::Arc;
31use std::time::Duration;
32
33#[cfg(feature = "receipts")]
34use super::evidence::{EvidencePolicy, LocalReceiptSigner, MemoryReceiptSink};
35#[cfg(feature = "receipts")]
36use crate::receipt::Receipt;
37
38const DEFAULT_SRL_MAX_AGE: Duration = Duration::from_secs(300);
39const DEFAULT_SRL_CLOCK_TOLERANCE: Duration = Duration::from_secs(30);
40
41/// Configured holder runtime. Clone shares identity, trust, and revocation.
42#[derive(Clone)]
43pub struct Runtime {
44    identity: PersistentIdentity,
45    roots: Vec<PublicKey>,
46    ttl_fallback: Duration,
47    revocation: Option<RevocationMode>,
48    tracker: Arc<RevocationTracker>,
49    denial_reporting: DenialReporting,
50    #[cfg(feature = "receipts")]
51    evidence: EvidencePolicy,
52    #[cfg(feature = "receipts")]
53    receipt_capacity: usize,
54    #[cfg(feature = "receipts")]
55    receipt_link: Arc<std::sync::Mutex<Option<[u8; 32]>>>,
56}
57
58/// One warrant bound to this runtime's holder key.
59pub struct Session {
60    enforcer: Guard,
61    authority: PresentedAuthority,
62    #[cfg(feature = "receipts")]
63    receipts: Option<Arc<MemoryReceiptSink>>,
64}
65
66/// Builds a [`Runtime`]. Identity, at least one trusted root, and either
67/// [`Self::ttl_fallback`] (selects [`RevocationMode::TtlUntilSrl`]) or
68/// [`Self::revocation`] are required.
69pub struct RuntimeBuilder {
70    identity: Option<PersistentIdentity>,
71    roots: Vec<PublicKey>,
72    ttl_fallback: Option<Duration>,
73    revocation: Option<RevocationMode>,
74    srl_max_age: Duration,
75    srl_clock_tolerance: Duration,
76    floor_path: Option<PathBuf>,
77    denial_reporting: DenialReporting,
78    #[cfg(feature = "receipts")]
79    evidence: EvidencePolicy,
80    #[cfg(feature = "receipts")]
81    receipt_capacity: usize,
82}
83
84impl Runtime {
85    /// Start a runtime builder.
86    pub fn builder() -> RuntimeBuilder {
87        RuntimeBuilder {
88            identity: None,
89            roots: Vec::new(),
90            ttl_fallback: None,
91            revocation: None,
92            srl_max_age: DEFAULT_SRL_MAX_AGE,
93            srl_clock_tolerance: DEFAULT_SRL_CLOCK_TOLERANCE,
94            floor_path: None,
95            denial_reporting: DenialReporting::default(),
96            #[cfg(feature = "receipts")]
97            evidence: EvidencePolicy::Disabled,
98            #[cfg(feature = "receipts")]
99            receipt_capacity: 10_000,
100        }
101    }
102
103    /// Holder identity this runtime signs with.
104    pub fn identity(&self) -> &PersistentIdentity {
105        &self.identity
106    }
107
108    /// Trusted roots used for warrants and signed revocation lists.
109    pub fn trusted_roots(&self) -> &[PublicKey] {
110        &self.roots
111    }
112
113    /// Install a decoded SRL using the current time as `fetched_at`.
114    pub fn apply_signed_revocation_list_now(
115        &self,
116        srl: SignedRevocationList,
117    ) -> Result<u64, RuntimeError> {
118        self.apply_signed_revocation_list(srl, Utc::now())
119    }
120
121    /// Install or refresh a decoded signed revocation list.
122    ///
123    /// Existing sessions see the new list on the next check. Until the first
124    /// successful call they keep the TTL fallback. How the list is fetched is
125    /// the caller's concern.
126    pub fn apply_signed_revocation_list(
127        &self,
128        srl: SignedRevocationList,
129        fetched_at: DateTime<Utc>,
130    ) -> Result<u64, RuntimeError> {
131        if matches!(self.revocation, Some(RevocationMode::TtlOnly { .. })) {
132            return Err(RuntimeError::TtlOnlyIgnoresSrl);
133        }
134        let version = srl.version();
135        self.tracker
136            .accept(RevocationUpdate { srl, fetched_at }, Utc::now())?;
137        Ok(version)
138    }
139
140    /// Decode a standard-base64 SRL and install it.
141    pub fn apply_encoded_revocation_list(
142        &self,
143        encoded: &str,
144        fetched_at: DateTime<Utc>,
145    ) -> Result<u64, RuntimeError> {
146        let srl = SignedRevocationList::from_base64(encoded).map_err(RuntimeError::Warrant)?;
147        self.apply_signed_revocation_list(srl, fetched_at)
148    }
149
150    /// Bind an encoded or decoded warrant to the holder key and return a session.
151    pub fn session_from_warrant(
152        &self,
153        warrant: impl SessionWarrant,
154    ) -> Result<Session, RuntimeError> {
155        let warrant = warrant.into_warrant()?;
156        let mut authorizer = Authorizer::new();
157        for root in &self.roots {
158            authorizer.add_trusted_root(root.clone());
159        }
160
161        let mut builder = Guard::builder()
162            .authorizer(authorizer)
163            .denial_reporting(self.denial_reporting);
164        builder = match &self.revocation {
165            None | Some(RevocationMode::TtlUntilSrl { .. }) => {
166                builder.ttl_until_signed_srl(self.ttl_fallback, self.tracker.clone())
167            }
168            Some(RevocationMode::TtlOnly { max_lifetime }) => {
169                builder.revocation(RevocationMode::TtlOnly {
170                    max_lifetime: *max_lifetime,
171                })
172            }
173            Some(RevocationMode::SignedSrl) => builder
174                .revocation(RevocationMode::SignedSrl)
175                .revocation_tracker(self.tracker.clone()),
176        };
177
178        #[cfg(feature = "receipts")]
179        let receipts = {
180            let receipts = match self.evidence {
181                EvidencePolicy::Disabled => None,
182                EvidencePolicy::BestEffort | EvidencePolicy::RequiredBeforeExecution => Some(
183                    Arc::new(MemoryReceiptSink::with_capacity(self.receipt_capacity)),
184                ),
185            };
186            if let Some(sink) = receipts.as_ref() {
187                builder = builder
188                    .evidence_policy(self.evidence)
189                    .receipt_signer(Arc::new(LocalReceiptSigner::new(
190                        self.identity.signing_key().clone(),
191                    )))
192                    .receipt_sink(sink.clone())
193                    .receipt_link(self.receipt_link.clone());
194            }
195            receipts
196        };
197
198        let enforcer = builder.build()?;
199        let authority = PresentedAuthority::new(
200            vec![warrant],
201            Arc::new(LocalSigner::new(self.identity.signing_key().clone())),
202        )?;
203
204        Ok(Session {
205            enforcer,
206            authority,
207            #[cfg(feature = "receipts")]
208            receipts,
209        })
210    }
211}
212
213impl Session {
214    /// The guard this session will use.
215    pub fn enforcer(&self) -> &Guard {
216        &self.enforcer
217    }
218
219    /// Warrant chain bound to the runtime holder key.
220    pub fn authority(&self) -> &PresentedAuthority {
221        &self.authority
222    }
223
224    /// Pin the local jail for this session.
225    ///
226    /// The runtime is process-wide. The workspace is one job: warrants speak
227    /// `logical_root`, and `local_root` is the directory on this machine.
228    #[cfg(feature = "filesystem")]
229    #[cfg_attr(docsrs, doc(cfg(feature = "filesystem")))]
230    pub fn with_filesystem(mut self, workspace: super::filesystem::Workspace) -> Self {
231        self.enforcer = self.enforcer.with_filesystem(workspace);
232        self
233    }
234
235    /// Authorize `call` with this session's warrant, then run `op`.
236    pub fn guard<T, E>(
237        &self,
238        call: &Call<'_>,
239        op: impl FnOnce(&AuthorizedCall<'_>) -> Result<T, E>,
240    ) -> Result<Guarded<T>, GuardError<E>> {
241        self.enforcer.guard(&self.authority, call, op)
242    }
243
244    /// Decide without running anything.
245    pub fn check(&self, call: &Call<'_>) -> Result<crate::sdk::Decision, Denial> {
246        self.enforcer.check(&self.authority, call)
247    }
248
249    /// Snapshot of pending receipts. They are removed only by
250    /// [`acknowledge_receipts`].
251    #[cfg(feature = "receipts")]
252    #[deprecated(since = "0.3.0", note = "use `peek_receipts`")]
253    pub fn drain_receipts(&self) -> Vec<Receipt> {
254        self.peek_receipts()
255    }
256
257    /// Snapshot of pending receipts. Removed only by [`acknowledge_receipts`].
258    #[cfg(feature = "receipts")]
259    pub fn peek_receipts(&self) -> Vec<Receipt> {
260        let Some(sink) = self.receipts.as_ref() else {
261            return Vec::new();
262        };
263        sink.pending()
264    }
265
266    /// Remove the first `count` pending receipts from the sink.
267    #[cfg(feature = "receipts")]
268    pub fn acknowledge_receipts(&self, count: usize) -> usize {
269        let Some(sink) = self.receipts.as_ref() else {
270            return 0;
271        };
272        sink.drop_prefix(count)
273    }
274
275    /// Receipts dropped because this session's outbox was full.
276    #[cfg(feature = "receipts")]
277    pub fn receipt_overflows(&self) -> usize {
278        self.receipts
279            .as_ref()
280            .map(|sink| sink.overflowed())
281            .unwrap_or(0)
282    }
283
284    /// Operator-side explanation for this session's warrant.
285    pub fn diagnostics(&self) -> Diagnostics<'_> {
286        self.enforcer.diagnostics(&self.authority)
287    }
288
289    /// Mint a local child after confirming this session's parent is live.
290    pub fn delegate(
291        &self,
292        profile: &DelegationProfile,
293    ) -> Result<PresentedAuthority, DelegationError> {
294        self.enforcer.delegate(&self.authority, profile)
295    }
296
297    /// Mint a remote child after confirming this session's parent is live.
298    pub fn delegate_to(
299        &self,
300        child_holder: &PublicKey,
301        profile: &DelegationProfile,
302    ) -> Result<Vec<Warrant>, DelegationError> {
303        self.enforcer
304            .delegate_to(&self.authority, child_holder, profile)
305    }
306}
307
308impl RuntimeBuilder {
309    /// Persistent or ephemeral holder identity.
310    pub fn identity(mut self, identity: PersistentIdentity) -> Self {
311        self.identity = Some(identity);
312        self
313    }
314
315    /// In-process holder key. Equivalent to [`PersistentIdentity::ephemeral`].
316    pub fn holder(self, key: SigningKey) -> Self {
317        self.identity(PersistentIdentity::ephemeral(key))
318    }
319
320    /// Replace the trusted-root set.
321    pub fn trusted_roots(mut self, roots: impl Into<Vec<PublicKey>>) -> Self {
322        self.roots = roots.into();
323        self
324    }
325
326    /// Add one trusted root.
327    pub fn trusted_root(mut self, root: PublicKey) -> Self {
328        self.roots.push(root);
329        self
330    }
331
332    /// Selects [`RevocationMode::TtlUntilSrl`]: TTL ceiling until an SRL arrives.
333    pub fn ttl_fallback(mut self, max_lifetime: Duration) -> Self {
334        self.ttl_fallback = Some(max_lifetime);
335        if self.revocation.is_none() {
336            self.revocation = Some(RevocationMode::TtlUntilSrl { max_lifetime });
337        }
338        self
339    }
340
341    /// Explicit revocation policy. Required unless [`Self::ttl_fallback`] already
342    /// selected [`RevocationMode::TtlUntilSrl`].
343    pub fn revocation(mut self, mode: RevocationMode) -> Self {
344        match &mode {
345            RevocationMode::TtlOnly { max_lifetime }
346            | RevocationMode::TtlUntilSrl { max_lifetime } => {
347                self.ttl_fallback = Some(*max_lifetime);
348            }
349            RevocationMode::SignedSrl => {}
350        }
351        self.revocation = Some(mode);
352        self
353    }
354
355    /// How long an accepted SRL stays fresh. Defaults to 300 seconds.
356    pub fn srl_max_age(mut self, max_age: Duration) -> Self {
357        self.srl_max_age = max_age;
358        self
359    }
360
361    /// Allowed clock skew when accepting an SRL. Defaults to 30 seconds.
362    pub fn srl_clock_tolerance(mut self, tolerance: Duration) -> Self {
363        self.srl_clock_tolerance = tolerance;
364        self
365    }
366
367    /// Persist revocation floors at `path` instead of beside the identity
368    /// key. Use this when the key lives on a read-only or root-owned mount.
369    pub fn revocation_floor_path(mut self, path: impl Into<PathBuf>) -> Self {
370        self.floor_path = Some(path.into());
371        self
372    }
373
374    /// Log level for denials. Never changes whether the operation runs.
375    ///
376    /// Defaults to [`DenialReporting::Debug`], which writes nothing.
377    pub fn denial_reporting(mut self, reporting: DenialReporting) -> Self {
378        self.denial_reporting = reporting;
379        self
380    }
381
382    #[cfg(feature = "receipts")]
383    /// Whether sessions emit receipts. Disabled unless set.
384    pub fn evidence_policy(mut self, policy: EvidencePolicy) -> Self {
385        self.evidence = policy;
386        self
387    }
388
389    /// Bound on the session receipt outbox. A full sink drops the new
390    /// receipt, counts it, and returns Unavailable. Best-effort still
391    /// proceeds; RequiredBeforeExecution denies.
392    #[cfg(feature = "receipts")]
393    pub fn receipt_capacity(mut self, max: usize) -> Self {
394        self.receipt_capacity = max.max(1);
395        self
396    }
397
398    /// Build the runtime.
399    pub fn build(self) -> Result<Runtime, RuntimeError> {
400        let identity = self.identity.ok_or(RuntimeError::MissingIdentity)?;
401        if self.roots.is_empty() {
402            return Err(RuntimeError::MissingRoots);
403        }
404        let ttl_fallback = match (&self.revocation, self.ttl_fallback) {
405            (
406                Some(RevocationMode::TtlOnly { max_lifetime })
407                | Some(RevocationMode::TtlUntilSrl { max_lifetime }),
408                _,
409            ) => *max_lifetime,
410            (_, Some(ttl)) => ttl,
411            (Some(RevocationMode::SignedSrl), None) => Duration::from_secs(60),
412            (None, None) => return Err(RuntimeError::MissingTtlFallback),
413        };
414        if ttl_fallback.is_zero() {
415            return Err(RuntimeError::InvalidTtlFallback);
416        }
417        if self.srl_max_age.is_zero() {
418            return Err(RuntimeError::InvalidSrlMaxAge);
419        }
420        let tracker = Arc::new(build_tracker(
421            &identity,
422            self.roots.clone(),
423            self.srl_max_age,
424            self.srl_clock_tolerance,
425            self.floor_path,
426        )?);
427        Ok(Runtime {
428            identity,
429            roots: self.roots,
430            ttl_fallback,
431            revocation: self.revocation,
432            tracker,
433            denial_reporting: self.denial_reporting,
434            #[cfg(feature = "receipts")]
435            evidence: self.evidence,
436            #[cfg(feature = "receipts")]
437            receipt_capacity: self.receipt_capacity,
438            #[cfg(feature = "receipts")]
439            receipt_link: Arc::new(std::sync::Mutex::new(None)),
440        })
441    }
442}
443
444fn build_tracker(
445    identity: &PersistentIdentity,
446    roots: Vec<PublicKey>,
447    max_age: Duration,
448    clock_tolerance: Duration,
449    floor_path: Option<PathBuf>,
450) -> Result<RevocationTracker, RuntimeError> {
451    let floors: Arc<dyn RevocationFloorStore> = if let Some(path) = floor_path {
452        Arc::new(FileFloorStore::open(path)?)
453    } else if identity.path().as_os_str().is_empty() {
454        Arc::new(InMemoryFloorStore::for_development())
455    } else {
456        let default_path = identity.path().with_extension("srl-floors");
457        Arc::new(FileFloorStore::open(default_path)?)
458    };
459    Ok(RevocationTracker::new(
460        roots,
461        max_age,
462        clock_tolerance,
463        floors,
464    )?)
465}
466
467/// Value that [`Runtime::session_from_warrant`] can bind.
468pub trait SessionWarrant {
469    /// Produce a decoded warrant.
470    fn into_warrant(self) -> Result<Warrant, RuntimeError>;
471}
472
473impl SessionWarrant for Warrant {
474    fn into_warrant(self) -> Result<Warrant, RuntimeError> {
475        Ok(self)
476    }
477}
478
479impl SessionWarrant for &Warrant {
480    fn into_warrant(self) -> Result<Warrant, RuntimeError> {
481        Ok(self.clone())
482    }
483}
484
485impl SessionWarrant for &str {
486    fn into_warrant(self) -> Result<Warrant, RuntimeError> {
487        crate::wire::decode_base64(self).map_err(RuntimeError::Warrant)
488    }
489}
490
491impl SessionWarrant for String {
492    fn into_warrant(self) -> Result<Warrant, RuntimeError> {
493        crate::wire::decode_base64(&self).map_err(RuntimeError::Warrant)
494    }
495}
496
497/// Failure constructing a runtime or session, or applying an SRL.
498#[derive(Debug)]
499#[non_exhaustive]
500pub enum RuntimeError {
501    /// No holder identity was supplied.
502    MissingIdentity,
503    /// No trusted roots were supplied.
504    MissingRoots,
505    /// No TTL fallback was supplied.
506    MissingTtlFallback,
507    /// TTL fallback must be greater than zero.
508    InvalidTtlFallback,
509    /// SRL freshness window must be greater than zero.
510    InvalidSrlMaxAge,
511    /// Guard construction failed.
512    Guard(GuardBuildError),
513    /// The warrant could not be bound to the holder key.
514    Authority(AuthorityError),
515    /// Warrant or SRL bytes could not be decoded.
516    Warrant(Error),
517    /// Signed revocation was rejected.
518    Revocation(RevocationError),
519    /// Holder identity could not be loaded.
520    Identity(IdentityError),
521    /// [`RevocationMode::TtlOnly`] does not consume signed revocation lists.
522    TtlOnlyIgnoresSrl,
523}
524
525impl From<GuardBuildError> for RuntimeError {
526    fn from(value: GuardBuildError) -> Self {
527        Self::Guard(value)
528    }
529}
530
531impl From<AuthorityError> for RuntimeError {
532    fn from(value: AuthorityError) -> Self {
533        Self::Authority(value)
534    }
535}
536
537impl From<RevocationError> for RuntimeError {
538    fn from(value: RevocationError) -> Self {
539        Self::Revocation(value)
540    }
541}
542
543impl From<IdentityError> for RuntimeError {
544    fn from(value: IdentityError) -> Self {
545        Self::Identity(value)
546    }
547}
548
549impl fmt::Display for RuntimeError {
550    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
551        match self {
552            Self::MissingIdentity => write!(f, "runtime requires a holder identity"),
553            Self::MissingRoots => write!(f, "runtime requires at least one trusted root"),
554            Self::MissingTtlFallback => {
555                write!(
556                    f,
557                    "runtime requires a TTL fallback before a signed SRL arrives"
558                )
559            }
560            Self::InvalidTtlFallback => write!(f, "TTL fallback must be greater than zero"),
561            Self::InvalidSrlMaxAge => write!(f, "SRL max age must be greater than zero"),
562            Self::Guard(err) => write!(f, "{err}"),
563            Self::Authority(err) => write!(f, "{err}"),
564            Self::Warrant(err) => write!(f, "{err}"),
565            Self::Revocation(err) => write!(f, "{err}"),
566            Self::Identity(err) => write!(f, "{err}"),
567            Self::TtlOnlyIgnoresSrl => {
568                write!(f, "TtlOnly runtime does not apply signed revocation lists")
569            }
570        }
571    }
572}
573
574impl std::error::Error for RuntimeError {
575    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
576        match self {
577            Self::Guard(err) => Some(err),
578            Self::Authority(err) => Some(err),
579            Self::Warrant(err) => Some(err),
580            Self::Revocation(err) => Some(err),
581            Self::Identity(err) => Some(err),
582            Self::MissingIdentity
583            | Self::MissingRoots
584            | Self::MissingTtlFallback
585            | Self::InvalidTtlFallback
586            | Self::InvalidSrlMaxAge
587            | Self::TtlOnlyIgnoresSrl => None,
588        }
589    }
590}
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595    use crate::constraints::ConstraintSet;
596    use crate::sdk::Call;
597    use crate::wire;
598    use std::collections::HashMap;
599
600    fn mint_pair() -> (SigningKey, SigningKey, Warrant) {
601        let issuer = SigningKey::generate();
602        let holder = SigningKey::generate();
603        let warrant = Warrant::builder()
604            .capability("read", ConstraintSet::new())
605            .holder(holder.public_key())
606            .ttl(Duration::from_secs(300))
607            .build(&issuer)
608            .unwrap();
609        (issuer, holder, warrant)
610    }
611
612    fn runtime(issuer: &SigningKey, holder: SigningKey) -> Runtime {
613        Runtime::builder()
614            .holder(holder)
615            .trusted_root(issuer.public_key())
616            .ttl_fallback(Duration::from_secs(600))
617            .build()
618            .unwrap()
619    }
620
621    #[test]
622    fn session_from_decoded_and_encoded_warrant() {
623        let (issuer, holder, warrant) = mint_pair();
624        let encoded = wire::encode_base64(&warrant).unwrap();
625        let runtime = runtime(&issuer, holder);
626        let from_decoded = runtime.session_from_warrant(warrant.clone()).unwrap();
627        let from_encoded = runtime.session_from_warrant(encoded.as_str()).unwrap();
628        assert_eq!(
629            from_decoded.authority().leaf().id(),
630            from_encoded.authority().leaf().id()
631        );
632        let args = HashMap::new();
633        let call = Call::borrowed("read", &args);
634        assert!(from_decoded.check(&call).is_ok());
635        let out = from_decoded
636            .guard(&call, |_| Ok::<_, &str>("ok"))
637            .unwrap()
638            .into_inner();
639        assert_eq!(out, "ok");
640    }
641
642    #[test]
643    fn wrong_holder_is_rejected() {
644        let (issuer, _holder, warrant) = mint_pair();
645        let runtime = runtime(&issuer, SigningKey::generate());
646        let err = runtime.session_from_warrant(warrant).err().unwrap();
647        assert!(matches!(err, RuntimeError::Authority(_)));
648    }
649
650    #[test]
651    fn ttl_fallback_without_srl_still_authorizes() {
652        let (issuer, holder, warrant) = mint_pair();
653        let runtime = runtime(&issuer, holder);
654        let session = runtime.session_from_warrant(warrant).unwrap();
655        let args = HashMap::new();
656        assert!(session.check(&Call::borrowed("read", &args)).is_ok());
657    }
658
659    #[test]
660    fn first_srl_enables_signed_enforcement_and_later_updates() {
661        let issuer = SigningKey::generate();
662        let holder = SigningKey::generate();
663        let warrant = Warrant::builder()
664            .capability("read", ConstraintSet::new())
665            .holder(holder.public_key())
666            .ttl(Duration::from_secs(300))
667            .build(&issuer)
668            .unwrap();
669        let runtime = runtime(&issuer, holder);
670        let now = Utc::now();
671
672        let empty = SignedRevocationList::builder()
673            .version(1)
674            .build(&issuer)
675            .unwrap();
676        assert_eq!(runtime.apply_signed_revocation_list(empty, now).unwrap(), 1);
677
678        let session = runtime.session_from_warrant(warrant.clone()).unwrap();
679        let args = HashMap::new();
680        assert!(session.check(&Call::borrowed("read", &args)).is_ok());
681
682        let revoked = SignedRevocationList::builder()
683            .version(2)
684            .revoke(warrant.id().to_string())
685            .build(&issuer)
686            .unwrap();
687        assert_eq!(
688            runtime.apply_signed_revocation_list(revoked, now).unwrap(),
689            2
690        );
691        let session = runtime.session_from_warrant(warrant).unwrap();
692        let denial = session.check(&Call::borrowed("read", &args)).err().unwrap();
693        assert_eq!(denial.code(), crate::ErrorCode::WarrantRevoked.name());
694    }
695
696    #[test]
697    fn early_session_sees_srl_applied_later() {
698        let issuer = SigningKey::generate();
699        let holder = SigningKey::generate();
700        let warrant = Warrant::builder()
701            .capability("read", ConstraintSet::new())
702            .holder(holder.public_key())
703            .ttl(Duration::from_secs(300))
704            .build(&issuer)
705            .unwrap();
706        let runtime = runtime(&issuer, holder);
707        let session = runtime.session_from_warrant(warrant.clone()).unwrap();
708        let args = HashMap::new();
709        assert!(session.check(&Call::borrowed("read", &args)).is_ok());
710
711        let revoked = SignedRevocationList::builder()
712            .version(1)
713            .revoke(warrant.id().to_string())
714            .build(&issuer)
715            .unwrap();
716        runtime
717            .apply_signed_revocation_list(revoked, Utc::now())
718            .unwrap();
719        let denial = session.check(&Call::borrowed("read", &args)).err().unwrap();
720        assert_eq!(denial.code(), crate::ErrorCode::WarrantRevoked.name());
721    }
722
723    #[test]
724    fn encoded_srl_round_trips() {
725        let issuer = SigningKey::generate();
726        let holder = SigningKey::generate();
727        let runtime = runtime(&issuer, holder);
728        let srl = SignedRevocationList::builder()
729            .version(3)
730            .build(&issuer)
731            .unwrap();
732        let encoded = srl.to_base64().unwrap();
733        assert_eq!(
734            runtime
735                .apply_encoded_revocation_list(&encoded, Utc::now())
736                .unwrap(),
737            3
738        );
739    }
740
741    #[cfg(feature = "receipts")]
742    #[test]
743    fn drain_receipts_does_not_repeat() {
744        let (issuer, holder, warrant) = mint_pair();
745        let runtime = Runtime::builder()
746            .holder(holder)
747            .trusted_root(issuer.public_key())
748            .ttl_fallback(Duration::from_secs(600))
749            .evidence_policy(EvidencePolicy::BestEffort)
750            .build()
751            .unwrap();
752        let session = runtime.session_from_warrant(warrant).unwrap();
753        let args = HashMap::new();
754        let call = Call::borrowed("read", &args);
755        session.check(&call).unwrap();
756        session.check(&call).unwrap();
757        let first = session.peek_receipts();
758        assert_eq!(first.len(), 2);
759        assert_eq!(session.peek_receipts().len(), 2);
760        assert_eq!(session.acknowledge_receipts(2), 2);
761        assert!(session.peek_receipts().is_empty());
762        session.check(&call).unwrap();
763        assert_eq!(session.peek_receipts().len(), 1);
764        assert_eq!(session.acknowledge_receipts(1), 1);
765        assert!(session.peek_receipts().is_empty());
766    }
767
768    #[cfg(feature = "receipts")]
769    #[test]
770    fn overflow_does_not_deny_and_is_counted() {
771        let (issuer, holder, warrant) = mint_pair();
772        let runtime = Runtime::builder()
773            .holder(holder)
774            .trusted_root(issuer.public_key())
775            .ttl_fallback(Duration::from_secs(600))
776            .evidence_policy(EvidencePolicy::BestEffort)
777            .receipt_capacity(1)
778            .build()
779            .unwrap();
780        let session = runtime.session_from_warrant(warrant).unwrap();
781        let args = HashMap::new();
782        let call = Call::borrowed("read", &args);
783        assert!(session.check(&call).is_ok());
784        assert!(session.check(&call).is_ok());
785        assert_eq!(session.peek_receipts().len(), 1);
786        assert_eq!(session.receipt_overflows(), 1);
787    }
788
789    #[cfg(feature = "receipts")]
790    #[test]
791    fn overflow_denies_when_evidence_is_required() {
792        let (issuer, holder, warrant) = mint_pair();
793        let runtime = Runtime::builder()
794            .holder(holder)
795            .trusted_root(issuer.public_key())
796            .ttl_fallback(Duration::from_secs(600))
797            .evidence_policy(EvidencePolicy::RequiredBeforeExecution)
798            .receipt_capacity(1)
799            .build()
800            .unwrap();
801        let session = runtime.session_from_warrant(warrant).unwrap();
802        let args = HashMap::new();
803        let call = Call::borrowed("read", &args);
804        assert!(session.check(&call).is_ok());
805        assert!(session.check(&call).is_err());
806        assert_eq!(session.peek_receipts().len(), 1);
807        assert_eq!(session.receipt_overflows(), 1);
808    }
809
810    #[test]
811    fn revocation_floor_path_does_not_write_beside_the_key() {
812        let dir = tempfile::tempdir().unwrap();
813        let key_path = dir.path().join("holder.key");
814        let floor = dir.path().join("custom.srl-floors");
815        let identity = PersistentIdentity::load_or_generate(&key_path).unwrap();
816        Runtime::builder()
817            .identity(identity)
818            .trusted_root(SigningKey::generate().public_key())
819            .ttl_fallback(Duration::from_secs(600))
820            .revocation_floor_path(&floor)
821            .build()
822            .unwrap();
823        assert!(floor.exists());
824        assert!(!key_path.with_extension("srl-floors").exists());
825    }
826
827    #[test]
828    fn builder_requires_the_three_pieces() {
829        assert!(matches!(
830            Runtime::builder().build().err().unwrap(),
831            RuntimeError::MissingIdentity
832        ));
833        let holder = SigningKey::generate();
834        assert!(matches!(
835            Runtime::builder()
836                .holder(holder.clone())
837                .build()
838                .err()
839                .unwrap(),
840            RuntimeError::MissingRoots
841        ));
842        assert!(matches!(
843            Runtime::builder()
844                .holder(holder)
845                .trusted_root(SigningKey::generate().public_key())
846                .build()
847                .err()
848                .unwrap(),
849            RuntimeError::MissingTtlFallback
850        ));
851    }
852
853    #[test]
854    fn ttl_only_rejects_srl_and_still_allows_revoked_leaf() {
855        let issuer = SigningKey::generate();
856        let holder = SigningKey::generate();
857        let warrant = Warrant::builder()
858            .capability("read", ConstraintSet::new())
859            .holder(holder.public_key())
860            .ttl(Duration::from_secs(300))
861            .build(&issuer)
862            .unwrap();
863        let runtime = Runtime::builder()
864            .holder(holder)
865            .trusted_root(issuer.public_key())
866            .revocation(RevocationMode::TtlOnly {
867                max_lifetime: Duration::from_secs(600),
868            })
869            .build()
870            .unwrap();
871        let revoked = SignedRevocationList::builder()
872            .version(1)
873            .revoke(warrant.id().to_string())
874            .build(&issuer)
875            .unwrap();
876        assert!(matches!(
877            runtime.apply_signed_revocation_list_now(revoked),
878            Err(RuntimeError::TtlOnlyIgnoresSrl)
879        ));
880        let session = runtime.session_from_warrant(warrant).unwrap();
881        let args = HashMap::new();
882        assert!(session.check(&Call::borrowed("read", &args)).is_ok());
883    }
884
885    #[test]
886    fn ttl_until_srl_is_selected_by_ttl_fallback() {
887        let issuer = SigningKey::generate();
888        let holder = SigningKey::generate();
889        let runtime = Runtime::builder()
890            .holder(holder)
891            .trusted_root(issuer.public_key())
892            .ttl_fallback(Duration::from_secs(600))
893            .build()
894            .unwrap();
895        assert!(matches!(
896            runtime.revocation,
897            Some(RevocationMode::TtlUntilSrl { .. })
898        ));
899    }
900
901    #[cfg(feature = "receipts")]
902    #[test]
903    fn guard_deny_emits_receipt_with_roots_and_chain() {
904        let issuer = SigningKey::generate();
905        let holder = SigningKey::generate();
906        let warrant = Warrant::builder()
907            .capability("read", ConstraintSet::new())
908            .holder(holder.public_key())
909            .ttl(Duration::from_secs(300))
910            .build(&issuer)
911            .unwrap();
912        let empty = SignedRevocationList::builder()
913            .version(4)
914            .build(&issuer)
915            .unwrap();
916        let runtime = Runtime::builder()
917            .holder(holder)
918            .trusted_root(issuer.public_key())
919            .ttl_fallback(Duration::from_secs(600))
920            .evidence_policy(EvidencePolicy::BestEffort)
921            .build()
922            .unwrap();
923        runtime.apply_signed_revocation_list_now(empty).unwrap();
924        let first = runtime.session_from_warrant(warrant.clone()).unwrap();
925        let second = runtime.session_from_warrant(warrant).unwrap();
926        let args = HashMap::new();
927        let denied = first
928            .guard(&Call::borrowed("write", &args), |_| Ok::<_, &str>("no"))
929            .err()
930            .unwrap();
931        assert!(matches!(denied, crate::sdk::GuardError::Denied(_)));
932        let receipts = first.peek_receipts();
933        assert_eq!(receipts.len(), 1);
934        let payload = receipts[0].verify_signature().unwrap();
935        assert_eq!(payload.outcome, crate::receipt::Outcome::Deny);
936        assert!(payload.trusted_roots_hash.is_some());
937        assert_eq!(payload.srl_version, Some(4));
938        assert!(payload.srl_hash.is_some());
939        assert!(payload.prev_receipt_hash.is_none());
940        let before = receipts.len();
941        assert!(first
942            .diagnostics()
943            .why_denied(&Call::borrowed("write", &args))
944            .is_some());
945        assert_eq!(first.peek_receipts().len(), before);
946
947        assert!(second.check(&Call::borrowed("read", &args)).is_ok());
948        let chained = second.peek_receipts();
949        assert_eq!(chained.len(), 1);
950        let next = chained[0].verify_signature().unwrap();
951        assert_eq!(next.prev_receipt_hash, Some(receipts[0].digest().unwrap()));
952    }
953
954    #[cfg(feature = "receipts")]
955    #[test]
956    fn deny_flood_does_not_starve_required_allow() {
957        let issuer = SigningKey::generate();
958        let holder = SigningKey::generate();
959        let warrant = Warrant::builder()
960            .capability("read", ConstraintSet::new())
961            .holder(holder.public_key())
962            .ttl(Duration::from_secs(300))
963            .build(&issuer)
964            .unwrap();
965        let runtime = Runtime::builder()
966            .holder(holder)
967            .trusted_root(issuer.public_key())
968            .ttl_fallback(Duration::from_secs(600))
969            .evidence_policy(EvidencePolicy::RequiredBeforeExecution)
970            .receipt_capacity(1)
971            .build()
972            .unwrap();
973        let session = runtime.session_from_warrant(warrant).unwrap();
974        let args = HashMap::new();
975        assert!(session.check(&Call::borrowed("write", &args)).is_err());
976        assert!(session.check(&Call::borrowed("write", &args)).is_err());
977        assert!(session.check(&Call::borrowed("read", &args)).is_ok());
978    }
979
980    #[cfg(feature = "receipts")]
981    #[test]
982    fn persist_failure_does_not_advance_receipt_chain() {
983        let issuer = SigningKey::generate();
984        let holder = SigningKey::generate();
985        let warrant = Warrant::builder()
986            .capability("read", ConstraintSet::new())
987            .holder(holder.public_key())
988            .ttl(Duration::from_secs(300))
989            .build(&issuer)
990            .unwrap();
991        let runtime = Runtime::builder()
992            .holder(holder)
993            .trusted_root(issuer.public_key())
994            .ttl_fallback(Duration::from_secs(600))
995            .evidence_policy(EvidencePolicy::BestEffort)
996            .receipt_capacity(1)
997            .build()
998            .unwrap();
999        let session = runtime.session_from_warrant(warrant).unwrap();
1000        let args = HashMap::new();
1001        let first = session.check(&Call::borrowed("read", &args)).unwrap();
1002        let a = first.receipt.clone().expect("first receipt");
1003        let second = session.check(&Call::borrowed("read", &args)).unwrap();
1004        assert!(second.receipt.is_some());
1005        assert_eq!(session.peek_receipts().len(), 1);
1006        session.acknowledge_receipts(1);
1007        let third = session.check(&Call::borrowed("read", &args)).unwrap();
1008        let c = third.receipt.expect("third receipt");
1009        assert_eq!(
1010            c.verify_signature().unwrap().prev_receipt_hash,
1011            Some(a.digest().unwrap())
1012        );
1013    }
1014
1015    #[cfg(feature = "receipts")]
1016    #[test]
1017    fn concurrent_receipts_share_one_predecessor_chain() {
1018        use std::collections::HashSet;
1019        use std::thread;
1020
1021        let issuer = SigningKey::generate();
1022        let holder = SigningKey::generate();
1023        let warrant = Warrant::builder()
1024            .capability("read", ConstraintSet::new())
1025            .holder(holder.public_key())
1026            .ttl(Duration::from_secs(300))
1027            .build(&issuer)
1028            .unwrap();
1029        let runtime = Runtime::builder()
1030            .holder(holder)
1031            .trusted_root(issuer.public_key())
1032            .ttl_fallback(Duration::from_secs(600))
1033            .evidence_policy(EvidencePolicy::BestEffort)
1034            .receipt_capacity(128)
1035            .build()
1036            .unwrap();
1037        let session = Arc::new(runtime.session_from_warrant(warrant).unwrap());
1038        let threads = 8;
1039        let per_thread = 10;
1040        let handles: Vec<_> = (0..threads)
1041            .map(|_| {
1042                let session = session.clone();
1043                thread::spawn(move || {
1044                    let args = HashMap::new();
1045                    for _ in 0..per_thread {
1046                        session.check(&Call::borrowed("read", &args)).unwrap();
1047                    }
1048                })
1049            })
1050            .collect();
1051        for handle in handles {
1052            handle.join().expect("worker");
1053        }
1054        let receipts = session.peek_receipts();
1055        assert_eq!(receipts.len(), threads * per_thread);
1056        let digests: HashSet<_> = receipts.iter().map(|r| r.digest().unwrap()).collect();
1057        assert_eq!(digests.len(), receipts.len());
1058        let mut predecessors = HashSet::new();
1059        let mut roots = 0usize;
1060        for receipt in &receipts {
1061            match receipt.verify_signature().unwrap().prev_receipt_hash {
1062                None => roots += 1,
1063                Some(prev) => assert!(predecessors.insert(prev), "duplicate predecessor"),
1064            }
1065        }
1066        assert_eq!(roots, 1);
1067        assert_eq!(predecessors.len(), receipts.len() - 1);
1068        for prev in &predecessors {
1069            assert!(digests.contains(prev), "link to unpublished receipt");
1070        }
1071    }
1072}