Skip to main content

lean_ctx/core/archive/
presented.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Presentation register: the authority of caller-supplied context sources.
3//!
4//! A source the caller hands to the context plan is not a project file. The
5//! Engine cannot re-read it and does not learn when the caller's or a
6//! provider's copy changes. What it can assert, and all a [`PresentedBinding`]
7//! asserts, is: *this exact content (full digest) under this object reference
8//! and source id was presented for this project and admitted under this
9//! policy.*
10//!
11//! Every time the plan admits such a source for delivery (the revalidation
12//! that closes a materialization, after the delivery policy has passed) it
13//! records the presentation here. A binding holds only while a presentation
14//! of exactly that binding exists that was admitted under the CURRENT policy
15//! identity and is at most `VALIDITY_SECONDS` old — a caller that
16//! materializes its plan each turn always passes; one that stopped
17//! presenting the source does not.
18//!
19//! # Presenters
20//!
21//! A presentation belongs to a PRESENTER, and holds only for a request that
22//! shows that presenter's capability ([`PresenterCapability`]): 32 bytes from
23//! the system CSPRNG. Two kinds exist.
24//!
25//! - An ISSUED capability (`context-materialize-sources-v2`): the Engine
26//!   mints it, returns it once to the caller and stores nothing it could be
27//!   rebuilt from. The caller shows it again on later requests — to this
28//!   Engine, the proxy, the MCP server, the CLI — and so reuses what it
29//!   presented, across processes, until the capability expires or is revoked.
30//! - The PROCESS capability: drawn once per process, never written, logged or
31//!   returned. It is the presenter of every request that shows no issued
32//!   capability, so such a presentation holds inside the presenting process
33//!   only. This is the default for a caller that does not negotiate.
34//!
35//! What a capability proves is exactly this: *the request comes from a holder
36//! of the capability under which the source was presented.* It is not an
37//! agent name, not an attested identity and not an authorization. Project,
38//! OS user, role, policy identity and freshness are checked as before, after
39//! it; file sources of the same lineage are re-admitted as before.
40//!
41//! Per source the register keeps a bounded SET of presenter entries. Each has
42//! its own verifier, presentations and expiry; one presenter's presentation,
43//! lapse or revocation never touches another's entry.
44//!
45//! The register holds hashes, policy identities and times. No content, no
46//! object reference, no source id, and nothing a capability can be derived
47//! from: a reader of the store learns verifiers and presenter references,
48//! never a capability.
49
50use std::collections::BTreeMap;
51use std::path::Path;
52use std::sync::OnceLock;
53
54use serde::{Deserialize, Serialize};
55use zeroize::Zeroizing;
56
57use super::register::{self, Register};
58
59/// How long one admitted presentation keeps a binding valid: ten minutes.
60pub(crate) const VALIDITY_SECONDS: i64 = 10 * 60;
61
62/// Longest lifetime of an issued capability: one day.
63pub(crate) const MAX_CAPABILITY_TTL_SECONDS: i64 = 24 * 60 * 60;
64
65/// Wire prefix of a capability; 64 lowercase hex characters follow.
66const CAPABILITY_PREFIX: &str = "lcp1_";
67/// Wire prefix of a presenter reference; 32 lowercase hex characters follow.
68const REFERENCE_PREFIX: &str = "presenter:";
69/// A capability file is this long at most (the capability and a line end).
70const MAX_CAPABILITY_FILE_BYTES: u64 = 128;
71
72/// Issued presenters one source can have at a time. A further one is not
73/// recorded; no live entry is ever removed to make room.
74const MAX_PRESENTERS_PER_SOURCE: usize = 16;
75/// Process presenters one source can have at a time. Their entries are of
76/// use to their own process only, so the oldest of THEM makes room.
77const MAX_PROCESS_PRESENTERS_PER_SOURCE: usize = 16;
78/// Policy identities kept per presenter entry (a policy change adds one).
79const MAX_IDENTITIES_PER_BINDING: usize = 8;
80
81/// Principal where the platform offers no verified per-caller fact (non-Unix
82/// builds): one local operator.
83#[cfg(not(unix))]
84const LOCAL_OPERATOR: &str = "local-operator";
85
86/// The caller principal of a plan request, as far as the Engine can VERIFY
87/// it without anything the caller holds: the OS user its own process runs as,
88/// `os-user:<euid>`, stable and not secret. A name the client supplies is
89/// never a principal. Callers of one OS user are told apart by the presenter
90/// capability (module documentation, "Presenters"), not by this value.
91#[must_use]
92pub(crate) fn verified_principal() -> String {
93    #[cfg(unix)]
94    {
95        // SAFETY: `geteuid` has no preconditions and cannot fail.
96        let uid = unsafe { libc::geteuid() };
97        format!("os-user:{uid}")
98    }
99    #[cfg(not(unix))]
100    {
101        LOCAL_OPERATOR.to_owned()
102    }
103}
104
105/// A presenter capability: a bearer secret. Whoever shows it is treated as
106/// the holder of the capability its presentations were recorded under — no
107/// more than that.
108///
109/// The secret is overwritten when the value is dropped, has no `Display`, is
110/// not serializable and formats as `<redacted>`. It leaves this type in one
111/// place only: [`Self::expose`], for the response that hands a freshly issued
112/// capability to its caller.
113#[derive(Clone)]
114pub struct PresenterCapability(Zeroizing<[u8; 32]>);
115
116impl std::fmt::Debug for PresenterCapability {
117    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        formatter.write_str("PresenterCapability(<redacted>)")
119    }
120}
121
122fn hex_nibble(digit: u8) -> Option<u8> {
123    match digit {
124        b'0'..=b'9' => Some(digit - b'0'),
125        b'a'..=b'f' => Some(digit - b'a' + 10),
126        _ => None,
127    }
128}
129
130impl PresenterCapability {
131    fn mint() -> Option<Self> {
132        let mut secret = Zeroizing::new([0u8; 32]);
133        getrandom::fill(&mut *secret).ok()?;
134        Some(Self(secret))
135    }
136
137    /// The capability a host presented, from its wire form. Form only: an
138    /// unknown capability parses and then matches no presenter entry.
139    #[must_use]
140    pub fn parse(text: &str) -> Option<Self> {
141        let digits = text.strip_prefix(CAPABILITY_PREFIX)?.as_bytes();
142        if digits.len() != 64 {
143            return None;
144        }
145        let mut secret = Zeroizing::new([0u8; 32]);
146        for (index, pair) in digits.as_chunks::<2>().0.iter().enumerate() {
147            secret[index] = (hex_nibble(pair[0])? << 4) | hex_nibble(pair[1])?;
148        }
149        Some(Self(secret))
150    }
151
152    /// The wire form. Only for the response that issues the capability.
153    #[must_use]
154    pub fn expose(&self) -> Zeroizing<String> {
155        const DIGITS: &[u8; 16] = b"0123456789abcdef";
156        let mut text = Zeroizing::new(String::with_capacity(CAPABILITY_PREFIX.len() + 64));
157        text.push_str(CAPABILITY_PREFIX);
158        for byte in self.0.iter() {
159            text.push(char::from(DIGITS[usize::from(byte >> 4)]));
160            text.push(char::from(DIGITS[usize::from(byte & 0x0f)]));
161        }
162        text
163    }
164
165    /// One-way root verifier of the capability. Everything public about a
166    /// presenter is derived from this, never from the secret directly.
167    fn root_verifier(&self) -> Zeroizing<[u8; 32]> {
168        Zeroizing::new(blake3::derive_key(
169            "leanctx 2026-10 presenter root verifier v1",
170            &*self.0,
171        ))
172    }
173
174    /// Register key of the presenter: a digest of the root verifier.
175    fn id(&self) -> String {
176        let mut hash = blake3::Hasher::new();
177        hash.update(b"leanctx-presenter-id-v1\0");
178        hash.update(&*self.root_verifier());
179        hash.finalize().to_hex().to_string()
180    }
181
182    /// Content-free reference of the presenter entry, for records: a digest
183    /// of a verifier, not of the capability. Naming it reveals no secret and
184    /// proves nothing by itself.
185    #[must_use]
186    pub fn reference(&self) -> String {
187        format!("{REFERENCE_PREFIX}{}", &self.id()[..32])
188    }
189
190    /// What the register stores for this presenter on one source: a keyed
191    /// hash, under the capability, of the source fields and the presenter
192    /// id. It verifies for no other source, project, caller or role.
193    fn source_verifier(&self, binding: &PresentedBinding, id: &str) -> blake3::Hash {
194        let mut hash = blake3::Hasher::new_keyed(&self.0);
195        hash.update(b"leanctx-presenter-source-v1\0");
196        binding.absorb_source(&mut hash);
197        hash.update(id.as_bytes());
198        hash.finalize()
199    }
200}
201
202/// The capability of this process: drawn once, held here only. `None`: the
203/// CSPRNG failed — the process then presents nothing and re-authorizes
204/// nothing without an issued capability.
205static PROCESS_SESSION: OnceLock<Option<PresenterCapability>> = OnceLock::new();
206
207fn process_session() -> Option<&'static PresenterCapability> {
208    PROCESS_SESSION
209        .get_or_init(PresenterCapability::mint)
210        .as_ref()
211}
212
213fn is_process_session(id: &str) -> bool {
214    PROCESS_SESSION
215        .get()
216        .and_then(Option::as_ref)
217        .is_some_and(|own| own.id() == id)
218}
219
220tokio::task_local! {
221    /// The capability the request in scope showed, if any. Request state,
222    /// like `policy::runtime::REQUEST_PROJECT`; never a global.
223    static REQUEST_PRESENTER: Option<PresenterCapability>;
224}
225
226/// Run `operation` as the request of a host that showed `capability`
227/// (`None`: it showed none — the process capability presents).
228pub fn with_presenter<T>(
229    capability: Option<PresenterCapability>,
230    operation: impl FnOnce() -> T,
231) -> T {
232    REQUEST_PRESENTER.sync_scope(capability, operation)
233}
234
235/// [`with_presenter`] around a future: the whole request task.
236pub async fn presenter_scope<F: std::future::Future>(
237    capability: Option<PresenterCapability>,
238    request: F,
239) -> F::Output {
240    REQUEST_PRESENTER.scope(capability, request).await
241}
242
243/// The capability the request in scope showed, to carry it onto a thread the
244/// request task hands work to: task-locals do not follow `spawn_blocking`,
245/// and a handler that ran without it would be served as this process's own
246/// presenter. The value goes back into [`with_presenter`] and nowhere else.
247#[must_use]
248pub fn request_presenter() -> Option<PresenterCapability> {
249    REQUEST_PRESENTER.try_with(Clone::clone).ok().flatten()
250}
251
252/// Run `use_capability` with the presenter of the request in scope: the
253/// capability it showed, else this process's own.
254fn with_current<T>(use_capability: impl FnOnce(&PresenterCapability) -> T) -> Option<T> {
255    let shown = REQUEST_PRESENTER.try_with(Clone::clone).ok().flatten();
256    match shown {
257        Some(capability) => Some(use_capability(&capability)),
258        None => process_session().map(use_capability),
259    }
260}
261
262/// Content-free reference of the presenter of the request in scope, for
263/// lineage, recovery and decision records.
264#[must_use]
265pub(crate) fn current_presenter_reference() -> Option<String> {
266    with_current(PresenterCapability::reference)
267}
268
269/// The reference of the presenter entry of an ISSUED capability the request
270/// in scope showed, while that capability may still present (known, not
271/// expired, not revoked). `None` for a request that showed nothing or
272/// something that is not such a capability: decision receipts name a
273/// presenter only then, and never the process capability.
274#[must_use]
275pub fn shown_presenter_reference() -> Option<String> {
276    let shown = REQUEST_PRESENTER.try_with(Clone::clone).ok().flatten()?;
277    let id = shown.id();
278    (!is_process_session(&id) && lifetime_end(&id, now()).is_some()).then(|| shown.reference())
279}
280
281/// What the store knows about an issued capability.
282#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
283#[serde(deny_unknown_fields)]
284struct PresenterRecord {
285    /// End of the lifetime fixed at issue (register clock, seconds).
286    expires_at: i64,
287    revoked: bool,
288}
289
290const PRESENTERS: Register = Register {
291    name: "presenters",
292    shard_entries: 128,
293    max_age_seconds: MAX_CAPABILITY_TTL_SECONDS,
294};
295
296/// Directory of the presenter sets, under the archive store. NOT the
297/// `presented` directory of binaries before presenters existed: those keep
298/// their own register (other value type), and two binaries writing one
299/// directory would each start the other's shards afresh. This binary neither
300/// reads nor writes nor removes `archives/presented`; what an older binary
301/// left there lapses by that binary's own ten-minute rule and is dead data
302/// afterwards.
303pub const REGISTER_DIRECTORY: &str = "presented-v2";
304
305const REGISTER: Register = Register {
306    name: REGISTER_DIRECTORY,
307    shard_entries: 128,
308    max_age_seconds: MAX_CAPABILITY_TTL_SECONDS,
309};
310
311/// End of the lifetime of the presenter `id` at `now`, or `None` when it may
312/// not present or reuse: unknown, expired, revoked. The process capability
313/// has no record and lives as long as its process.
314fn lifetime_end(id: &str, now: i64) -> Option<i64> {
315    match PRESENTERS.read::<PresenterRecord>(id, now) {
316        Some(record) if record.revoked || record.expires_at < now => None,
317        Some(record) => Some(record.expires_at),
318        None => is_process_session(id).then(|| now.saturating_add(MAX_CAPABILITY_TTL_SECONDS)),
319    }
320}
321
322/// A freshly issued capability with the end of its lifetime.
323pub struct IssuedCapability {
324    pub capability: PresenterCapability,
325    /// Register clock, seconds.
326    pub(crate) expires_at: i64,
327}
328
329/// Mint a capability that lives `ttl_seconds` (1 to
330/// `MAX_CAPABILITY_TTL_SECONDS`) and put its presenter on record. `None`:
331/// no randomness, a lifetime out of range, or the record could not be
332/// written — then no capability exists.
333#[must_use]
334pub fn issue(ttl_seconds: u32) -> Option<IssuedCapability> {
335    issue_at(ttl_seconds, now())
336}
337
338/// [`issue`] on the register clock `now`.
339fn issue_at(ttl_seconds: u32, now: i64) -> Option<IssuedCapability> {
340    let ttl = i64::from(ttl_seconds);
341    if !(1..=MAX_CAPABILITY_TTL_SECONDS).contains(&ttl) {
342        return None;
343    }
344    let capability = PresenterCapability::mint()?;
345    let expires_at = now.checked_add(ttl)?;
346    PRESENTERS
347        .update::<PresenterRecord>(&capability.id(), now, |known| {
348            known.is_none().then_some(PresenterRecord {
349                expires_at,
350                revoked: false,
351            })
352        })
353        .then_some(IssuedCapability {
354            capability,
355            expires_at,
356        })
357}
358
359/// End of the lifetime of an ISSUED capability that may still present, or
360/// `None` (unknown, expired, revoked, or not an issued capability).
361#[must_use]
362pub fn issued_lifetime_end(capability: &PresenterCapability) -> Option<i64> {
363    let id = capability.id();
364    if is_process_session(&id) {
365        return None;
366    }
367    lifetime_end(&id, now())
368}
369
370/// Revoke an issued capability: from now on it presents nothing and reuses
371/// nothing. `false`: no such presenter on record (nothing to revoke), or the
372/// record could not be written.
373pub fn revoke(capability: &PresenterCapability) -> bool {
374    let now = now();
375    PRESENTERS.update::<PresenterRecord>(&capability.id(), now, |known| {
376        known.map(|record| PresenterRecord {
377            revoked: true,
378            ..record
379        })
380    })
381}
382
383/// The capability in a host's capability file, or `None`. On every platform
384/// the path must name a regular file and not a symlink (checked on the path
385/// itself, and again on the opened file), at most
386/// `MAX_CAPABILITY_FILE_BYTES` long. On Unix the open itself refuses a
387/// symlink (`O_NOFOLLOW`) and the file must be owned by this user and closed
388/// to group and others. On Windows ownership and access rights are NOT
389/// checked, and the two checks of the path are not atomic. Nothing about a
390/// refused file is reported.
391#[must_use]
392pub fn read_capability_file(path: &Path) -> Option<PresenterCapability> {
393    use std::io::Read as _;
394
395    if !std::fs::symlink_metadata(path).ok()?.file_type().is_file() {
396        return None;
397    }
398    let mut options = std::fs::OpenOptions::new();
399    options.read(true);
400    #[cfg(unix)]
401    {
402        use std::os::unix::fs::OpenOptionsExt;
403        options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
404    }
405    let file = options.open(path).ok()?;
406    let metadata = file.metadata().ok()?;
407    if !metadata.is_file() || metadata.len() > MAX_CAPABILITY_FILE_BYTES {
408        return None;
409    }
410    #[cfg(unix)]
411    {
412        use std::os::unix::fs::MetadataExt;
413        // SAFETY: `geteuid` has no preconditions and cannot fail.
414        let own = unsafe { libc::geteuid() };
415        if metadata.uid() != own || metadata.mode() & 0o077 != 0 {
416            return None;
417        }
418    }
419    let mut text = Zeroizing::new(String::new());
420    let mut bounded = file.take(MAX_CAPABILITY_FILE_BYTES);
421    bounded.read_to_string(&mut text).ok()?;
422    PresenterCapability::parse(text.trim_end_matches(['\r', '\n']))
423}
424
425/// One caller-supplied source as the plan admission resolved it. Content-free.
426/// It says WHICH source under WHICH policy; who may reuse it is the presenter
427/// set of the register, not a field here.
428#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
429#[serde(deny_unknown_fields)]
430pub struct PresentedBinding {
431    /// Canonical project root the plan ran for.
432    project: String,
433    /// Caller principal ([`verified_principal`]).
434    caller: String,
435    /// Role active at admission.
436    role: String,
437    object_ref: String,
438    source_id: String,
439    /// The full content digest the plan binds (`sha256:…` of the caller's
440    /// original bytes).
441    content_digest: String,
442    /// Gateway policy, project policy pack and kernel context policy at
443    /// admission ([`policy_identity`]).
444    policy_identity: String,
445}
446
447/// What the register knows about a binding now, for the presenter of the
448/// request in scope.
449#[derive(Debug, Clone, Copy, PartialEq, Eq)]
450pub(super) enum Presentation {
451    /// Presented and admitted under the current policy identity, in time.
452    Current,
453    /// Presented in time, but only under another policy identity: under the
454    /// current one it has not been admitted.
455    OtherPolicy,
456    /// No presentation within the validity by the presenter that asks: none
457    /// at all, only by another presenter, or its capability is unknown,
458    /// expired or revoked. One answer for all of these.
459    None,
460}
461
462/// All presenter entries of one source.
463#[derive(Debug, Default, Serialize, Deserialize)]
464#[serde(deny_unknown_fields)]
465struct SourcePresenters {
466    /// Presenter id → entry.
467    presenters: BTreeMap<String, PresenterEntry>,
468}
469
470#[derive(Debug, Clone, Serialize, Deserialize)]
471#[serde(deny_unknown_fields)]
472struct PresenterEntry {
473    /// `PresenterCapability::source_verifier`, hex.
474    verifier: String,
475    /// Policy identity → last admitted presentation.
476    admitted: BTreeMap<String, i64>,
477    /// End of the presenter's lifetime as known when it last presented.
478    expires_at: i64,
479    /// Recorded under a process capability.
480    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
481    process_only: bool,
482}
483
484fn in_time(presented_at: i64, now: i64) -> bool {
485    now.checked_sub(presented_at)
486        .is_some_and(|age| (0..=VALIDITY_SECONDS).contains(&age))
487}
488
489impl PresenterEntry {
490    /// Still able to authorize something: not expired, and presented within
491    /// the validity. Only an entry that is NOT live is ever removed.
492    fn live(&self, now: i64) -> bool {
493        self.expires_at >= now && self.admitted.values().any(|at| in_time(*at, now))
494    }
495
496    fn last_presented(&self) -> i64 {
497        self.admitted.values().copied().max().unwrap_or(i64::MIN)
498    }
499}
500
501/// Identity of everything that decides a plan admission for `project`: the
502/// gateway admission policy, the project policy pack and the kernel context
503/// policy (sensitivity ceiling, budget cap, retention). `None`: one of them
504/// cannot be loaded — nothing is then bound or re-authorized.
505pub(crate) fn policy_identity(project: &Path) -> Option<String> {
506    let gateway = crate::core::context_admission::stores::StoreAdmission::current();
507    let pack = crate::core::policy::runtime::for_project(project).ok()?;
508    let pack = serde_json::to_vec(&pack.as_ref().map(|active| &active.resolved)).ok()?;
509    let kernel =
510        crate::core::engine_interface::context_plan::sources::kernel_policy_identity(project)?;
511    let mut hash = blake3::Hasher::new();
512    hash.update(b"leanctx-presented-policy-v1\0");
513    hash.update(gateway.digest().as_bytes());
514    hash.update(b"\0");
515    hash.update(&pack);
516    hash.update(b"\0");
517    hash.update(kernel.as_bytes());
518    Some(hash.finalize().to_hex().to_string())
519}
520
521fn canonical_project(project: &Path) -> Option<String> {
522    crate::core::pathutil::canonicalize_secure(project)
523        .ok()?
524        .to_str()
525        .map(str::to_owned)
526}
527
528impl PresentedBinding {
529    /// The binding of a source the plan has admitted in this very step, with
530    /// the caller, role and policy identity that hold now.
531    pub fn admitted_now(
532        project: &Path,
533        object_ref: &str,
534        source_id: &str,
535        content_digest: &str,
536    ) -> Option<Self> {
537        Self::admitted_by(
538            &verified_principal(),
539            project,
540            object_ref,
541            source_id,
542            content_digest,
543        )
544    }
545
546    /// [`Self::admitted_now`] for an explicit, already verified principal.
547    fn admitted_by(
548        principal: &str,
549        project: &Path,
550        object_ref: &str,
551        source_id: &str,
552        content_digest: &str,
553    ) -> Option<Self> {
554        Some(Self {
555            project: canonical_project(project)?,
556            caller: principal.to_owned(),
557            role: crate::core::roles::active_role_name(),
558            object_ref: object_ref.to_owned(),
559            source_id: source_id.to_owned(),
560            content_digest: content_digest.to_owned(),
561            policy_identity: policy_identity(project)?,
562        })
563    }
564
565    pub(super) fn project(&self) -> &str {
566        &self.project
567    }
568
569    /// The caller's id of the source. Content-free; it authorizes nothing.
570    pub(super) fn source_id(&self) -> &str {
571        &self.source_id
572    }
573
574    /// Same project, caller, role, object reference, source id and content
575    /// digest. The policy identity is not part of what a binding IS, and
576    /// neither is a presenter: two presenters of one source share the
577    /// binding and differ in the register only.
578    pub(super) fn same_source(&self, other: &Self) -> bool {
579        self.key() == other.key()
580    }
581
582    /// A stable, content-free identity of this binding INCLUDING the policy
583    /// it was admitted under: what a stored copy bound to it is addressed by.
584    pub(super) fn identity(&self) -> String {
585        format!("{}:{}", self.key(), self.policy_identity)
586    }
587
588    /// Feed `hash` the six fields that say WHICH source this is,
589    /// length-prefixed.
590    fn absorb_source(&self, hash: &mut blake3::Hasher) {
591        for field in [
592            &self.project,
593            &self.caller,
594            &self.role,
595            &self.object_ref,
596            &self.source_id,
597            &self.content_digest,
598        ] {
599            hash.update(&(field.len() as u64).to_be_bytes());
600            hash.update(field.as_bytes());
601        }
602    }
603
604    /// Register key: a hash over every identifying field, length-prefixed.
605    fn key(&self) -> String {
606        let mut hash = blake3::Hasher::new();
607        hash.update(b"leanctx-presented-binding-v2\0");
608        self.absorb_source(&mut hash);
609        hash.finalize().to_hex().to_string()
610    }
611
612    /// Whether project, caller and role of this binding are the ones of the
613    /// request in scope.
614    pub(super) fn matches_request(&self) -> bool {
615        let project = crate::core::policy::runtime::REQUEST_PROJECT
616            .try_with(|slot| slot.borrow().clone())
617            .ok()
618            .flatten()
619            .and_then(|path| canonical_project(&path));
620        project.as_deref() == Some(self.project.as_str())
621            && self.caller == verified_principal()
622            && self.role == crate::core::roles::active_role_name()
623    }
624
625    /// The entry of the presenter of the request in scope, if that presenter
626    /// may reuse this source at `now`: its capability matches the stored
627    /// verifier (compared in constant time, `blake3::Hash`), the entry has
628    /// not expired, and the presenter is neither expired nor revoked.
629    fn own_entry(&self, now: i64) -> Option<PresenterEntry> {
630        with_current(|capability| {
631            let id = capability.id();
632            let mut set = REGISTER.read::<SourcePresenters>(&self.key(), now)?;
633            let entry = set.presenters.remove(&id)?;
634            let stored = blake3::Hash::from_hex(entry.verifier.as_bytes()).ok()?;
635            (stored == capability.source_verifier(self, &id)
636                && entry.expires_at >= now
637                && lifetime_end(&id, now).is_some())
638            .then_some(entry)
639        })
640        .flatten()
641    }
642
643    /// The state of this binding under the policy that is current NOW, for
644    /// the presenter of the request in scope.
645    ///
646    /// Asked first: does the request hold a capability this source was
647    /// presented under? Without one nothing is presented, whatever the
648    /// register holds — the request learns neither the policy state nor the
649    /// age of another presenter's entry.
650    ///
651    /// Then, as before: a text delivered under one policy is not what another
652    /// policy would deliver from the same source (other redaction, other
653    /// ceiling). So a binding admitted under a policy identity that is no
654    /// longer the current one never holds, whatever is presented since: the
655    /// text has to be delivered — and recorded — again under the current
656    /// policy.
657    pub(super) fn presentation(&self) -> Presentation {
658        let now = now();
659        if self.own_entry(now).is_none() {
660            return Presentation::None;
661        }
662        let Some(current) = policy_identity(Path::new(&self.project)) else {
663            return Presentation::OtherPolicy;
664        };
665        if self.policy_identity != current {
666            return Presentation::OtherPolicy;
667        }
668        self.presentation_at(&current, now)
669    }
670
671    fn presentation_at(&self, current_identity: &str, now: i64) -> Presentation {
672        let Some(entry) = self.own_entry(now) else {
673            return Presentation::None;
674        };
675        if entry
676            .admitted
677            .get(current_identity)
678            .is_some_and(|at| in_time(*at, now))
679        {
680            Presentation::Current
681        } else if entry.admitted.values().any(|at| in_time(*at, now)) {
682            Presentation::OtherPolicy
683        } else {
684            Presentation::None
685        }
686    }
687}
688
689/// Record that `binding` was presented and admitted now under its policy
690/// identity, by the presenter of the request in scope. Call only from the
691/// plan admission, after it admitted the source. A restricted source is
692/// never passed here (E3). `false`: not recorded — no usable presenter, the
693/// source already has its full set of live issued presenters, a bound, the
694/// lock, the file system.
695pub fn record_presentation(binding: &PresentedBinding) -> bool {
696    record_presentation_at(binding, now())
697}
698
699/// "Now" of the validity check and of a recorded presentation: the
700/// register's clock. Tests can move it ahead with [`TestClock`].
701#[cfg(not(any(test, feature = "test-support")))]
702fn now() -> i64 {
703    register::now()
704}
705
706#[cfg(any(test, feature = "test-support"))]
707fn now() -> i64 {
708    register::now().saturating_add(TEST_CLOCK_AHEAD.load(std::sync::atomic::Ordering::SeqCst))
709}
710
711/// Seconds the presentation clock runs ahead of the system clock in tests.
712#[cfg(any(test, feature = "test-support"))]
713static TEST_CLOCK_AHEAD: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0);
714
715/// Moves the presentation clock ahead until the guard drops. The offset is
716/// process-wide: hold `core::data_dir::isolated_data_dir()` (which serializes
717/// every test that has a register) for as long as a guard lives.
718#[cfg(any(test, feature = "test-support"))]
719pub struct TestClock;
720
721#[cfg(any(test, feature = "test-support"))]
722impl TestClock {
723    /// One second past the validity of every presentation recorded so far.
724    #[must_use]
725    pub fn past_validity() -> Self {
726        TEST_CLOCK_AHEAD.fetch_add(VALIDITY_SECONDS + 1, std::sync::atomic::Ordering::SeqCst);
727        Self
728    }
729
730    /// One second past the longest lifetime of every capability issued so
731    /// far.
732    #[must_use]
733    pub fn past_capability_lifetime() -> Self {
734        TEST_CLOCK_AHEAD.fetch_add(
735            MAX_CAPABILITY_TTL_SECONDS + 1,
736            std::sync::atomic::Ordering::SeqCst,
737        );
738        Self
739    }
740}
741
742#[cfg(any(test, feature = "test-support"))]
743impl Drop for TestClock {
744    fn drop(&mut self) {
745        TEST_CLOCK_AHEAD.store(0, std::sync::atomic::Ordering::SeqCst);
746    }
747}
748
749fn record_presentation_at(binding: &PresentedBinding, now: i64) -> bool {
750    let Some((id, verifier, expires_at, process_only)) = with_current(|capability| {
751        let id = capability.id();
752        let expires_at = lifetime_end(&id, now)?;
753        let verifier = capability
754            .source_verifier(binding, &id)
755            .to_hex()
756            .to_string();
757        let process_only = is_process_session(&id);
758        Some((id, verifier, expires_at, process_only))
759    })
760    .flatten() else {
761        return false;
762    };
763    REGISTER.update::<SourcePresenters>(&binding.key(), now, |known| {
764        let mut set = known.unwrap_or_default();
765        // Only entries that authorize nothing any more go.
766        set.presenters.retain(|_, entry| entry.live(now));
767        let mut entry = set
768            .presenters
769            .remove(&id)
770            .filter(|known| known.verifier == verifier)
771            .unwrap_or_else(|| PresenterEntry {
772                verifier: verifier.clone(),
773                admitted: BTreeMap::new(),
774                expires_at,
775                process_only,
776            });
777        entry.expires_at = expires_at;
778        entry.admitted.retain(|_, at| in_time(*at, now));
779        entry.admitted.insert(binding.policy_identity.clone(), now);
780        while entry.admitted.len() > MAX_IDENTITIES_PER_BINDING {
781            let oldest = entry
782                .admitted
783                .iter()
784                .min_by_key(|(identity, presented_at)| (**presented_at, (*identity).clone()))
785                .map(|(identity, _)| identity.clone())?;
786            entry.admitted.remove(&oldest);
787        }
788        let of_kind = |set: &SourcePresenters| {
789            set.presenters
790                .values()
791                .filter(|known| known.process_only == process_only)
792                .count()
793        };
794        if process_only {
795            // A process entry serves its own process only: the oldest of
796            // them makes room, an issued presenter's entry never does.
797            while of_kind(&set) >= MAX_PROCESS_PRESENTERS_PER_SOURCE {
798                let oldest = set
799                    .presenters
800                    .iter()
801                    .filter(|(_, known)| known.process_only)
802                    .min_by_key(|(known_id, known)| (known.last_presented(), (*known_id).clone()))
803                    .map(|(known_id, _)| known_id.clone())?;
804                set.presenters.remove(&oldest);
805            }
806        } else if of_kind(&set) >= MAX_PRESENTERS_PER_SOURCE {
807            // Full of live issued presenters: this one is not recorded, and
808            // none of them is displaced.
809            return None;
810        }
811        set.presenters.insert(id.clone(), entry);
812        Some(set)
813    })
814}
815
816#[cfg(test)]
817mod tests {
818    use super::*;
819
820    fn binding(project: &Path, object_ref: &str, digest: &str, identity: &str) -> PresentedBinding {
821        PresentedBinding {
822            project: canonical_project(project).expect("project"),
823            caller: verified_principal(),
824            role: crate::core::roles::active_role_name(),
825            object_ref: object_ref.to_owned(),
826            source_id: "source-one".to_owned(),
827            content_digest: digest.to_owned(),
828            policy_identity: identity.to_owned(),
829        }
830    }
831
832    /// Two verified principals are two bindings: a presentation by one does
833    /// not satisfy the other's, and a binding of another principal is not the
834    /// request's own. Before, every binding carried the constant
835    /// `local-operator`, so the caller separated nothing.
836    #[test]
837    fn a_presentation_by_one_principal_does_not_satisfy_another() {
838        let _data = crate::core::data_dir::isolated_data_dir();
839        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
840        let directory = tempfile::tempdir().expect("project");
841        let root = directory.path().canonicalize().expect("canonical root");
842        let by = |principal: &str| {
843            PresentedBinding::admitted_by(principal, &root, "ref-quartz", "source-one", "sha256:aa")
844                .expect("policy identity available")
845        };
846        let mine = PresentedBinding::admitted_now(&root, "ref-quartz", "source-one", "sha256:aa")
847            .expect("policy identity available");
848        assert_eq!(mine, by(&verified_principal()));
849        assert_ne!(verified_principal(), "another-verified-principal");
850        let theirs = by("another-verified-principal");
851        assert!(!mine.same_source(&theirs));
852
853        assert!(record_presentation(&theirs));
854        assert_eq!(theirs.presentation(), Presentation::Current);
855        assert_eq!(
856            mine.presentation(),
857            Presentation::None,
858            "their presentation is not mine"
859        );
860        crate::core::policy::runtime::REQUEST_PROJECT.sync_scope(
861            std::cell::RefCell::new(Some(root.clone())),
862            || {
863                assert!(mine.matches_request());
864                assert!(
865                    !theirs.matches_request(),
866                    "a binding of another principal is refused for this request"
867                );
868            },
869        );
870
871        // On disk the principal is part of the keyed hash only, and a
872        // capability is in no form: not the secret, not its wire text.
873        let issued = issue(60).expect("issued");
874        with_presenter(Some(issued.capability.clone()), || {
875            assert!(record_presentation(&theirs));
876        });
877        let raw = std::fs::read_to_string(REGISTER.shard_path(&theirs.key())).expect("shard");
878        assert!(!raw.contains("another-verified-principal"), "{raw}");
879        let wire = issued.capability.expose();
880        let records = std::fs::read_to_string(PRESENTERS.shard_path(&issued.capability.id()))
881            .expect("presenter record");
882        for stored in [&raw, &records] {
883            assert!(!stored.contains(&wire[CAPABILITY_PREFIX.len()..]));
884        }
885        assert_eq!(
886            format!("{:?}", issued.capability),
887            "PresenterCapability(<redacted>)"
888        );
889        assert!(PresenterCapability::parse(&wire).is_some());
890        let upper = wire.to_uppercase();
891        for malformed in ["", "lcp1_", &wire[..wire.len() - 1], upper.as_str()] {
892            assert!(PresenterCapability::parse(malformed).is_none());
893        }
894    }
895
896    /// The register by itself, as a second process reads it: validity,
897    /// policy identity, exact binding, bounds, and no identifying text.
898    #[test]
899    fn a_presentation_holds_for_its_exact_binding_policy_and_validity_only() {
900        let _data = crate::core::data_dir::isolated_data_dir();
901        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
902        let directory = tempfile::tempdir().expect("project");
903        let root = directory.path().canonicalize().expect("canonical root");
904        let start = register::now();
905        let one = binding(&root, "ref-quartz", "sha256:aa", "policy-one");
906
907        assert_eq!(one.presentation_at("policy-one", start), Presentation::None);
908        assert!(record_presentation_at(&one, start));
909        assert_eq!(
910            one.presentation_at("policy-one", start),
911            Presentation::Current
912        );
913        assert_eq!(
914            one.presentation_at("policy-one", start + VALIDITY_SECONDS),
915            Presentation::Current
916        );
917        assert_eq!(
918            one.presentation_at("policy-one", start + VALIDITY_SECONDS + 1),
919            Presentation::None,
920            "not presented again within the validity"
921        );
922        assert_eq!(
923            one.presentation_at("policy-two", start),
924            Presentation::OtherPolicy,
925            "presented, but not admitted under the current policy"
926        );
927
928        // Not the same binding: other content, other reference, other
929        // caller, other role, other project.
930        let other_content = binding(&root, "ref-quartz", "sha256:bb", "policy-one");
931        let other_ref = binding(&root, "ref-basalt", "sha256:aa", "policy-one");
932        let mut other_caller = one.clone();
933        other_caller.caller = "another-principal".to_owned();
934        let mut other_role = one.clone();
935        other_role.role = "another-role".to_owned();
936        let elsewhere = tempfile::tempdir().expect("project");
937        let other_project = binding(elsewhere.path(), "ref-quartz", "sha256:aa", "policy-one");
938        for different in [
939            &other_content,
940            &other_ref,
941            &other_caller,
942            &other_role,
943            &other_project,
944        ] {
945            assert!(!one.same_source(different));
946            assert_eq!(
947                different.presentation_at("policy-one", start),
948                Presentation::None
949            );
950        }
951
952        // Presented again under the new policy: current again.
953        let again = binding(&root, "ref-quartz", "sha256:aa", "policy-two");
954        assert!(one.same_source(&again));
955        assert!(record_presentation_at(&again, start + 5));
956        assert_eq!(
957            one.presentation_at("policy-two", start + 5),
958            Presentation::Current
959        );
960
961        // Persisted, owner-only, and free of identifying text.
962        let shard = REGISTER.shard_path(&one.key());
963        let raw = std::fs::read_to_string(&shard).expect("persisted shard");
964        let principal = verified_principal();
965        for identifying in ["ref-quartz", "source-one", "sha256:aa", principal.as_str()] {
966            assert!(!raw.contains(identifying), "{identifying}");
967        }
968        #[cfg(unix)]
969        {
970            use std::os::unix::fs::PermissionsExt;
971            let mode = std::fs::metadata(&shard)
972                .expect("shard")
973                .permissions()
974                .mode();
975            assert_eq!(mode & 0o777, 0o600, "owner-only");
976        }
977
978        // Bound: policy identities per presenter entry.
979        for index in 0..MAX_IDENTITIES_PER_BINDING + 2 {
980            let identity = format!("policy-bound-{index}");
981            let presented = binding(&root, "ref-quartz", "sha256:aa", &identity);
982            assert!(record_presentation_at(&presented, start + 10));
983        }
984        let kept = REGISTER
985            .read::<SourcePresenters>(&one.key(), start + 10)
986            .expect("entry");
987        assert_eq!(kept.presenters.len(), 1, "one presenter: this process");
988        let own = kept.presenters.values().next().expect("own entry");
989        assert_eq!(own.admitted.len(), MAX_IDENTITIES_PER_BINDING);
990
991        // Bound: issued presenters per source. The set fills; a further one
992        // is not recorded and displaces nobody.
993        let issued: Vec<IssuedCapability> = (0..=MAX_PRESENTERS_PER_SOURCE)
994            // On the clock the presentations below are recorded at: a
995            // capability put on record later by the wall clock (a test
996            // past 10 s on a loaded runner) was "recorded in the future",
997            // not fresh, and its presentation refused (#2214).
998            .map(|_| issue_at(60, start + 10).expect("issued"))
999            .collect();
1000        let recorded: Vec<bool> = issued
1001            .iter()
1002            .map(|presenter| {
1003                with_presenter(Some(presenter.capability.clone()), || {
1004                    record_presentation_at(&one, start + 10)
1005                })
1006            })
1007            .collect();
1008        assert_eq!(
1009            recorded.iter().filter(|recorded| **recorded).count(),
1010            MAX_PRESENTERS_PER_SOURCE
1011        );
1012        assert_eq!(recorded.last(), Some(&false), "the one too many");
1013        with_presenter(Some(issued[0].capability.clone()), || {
1014            assert_eq!(
1015                one.presentation_at("policy-one", start + 10),
1016                Presentation::Current,
1017                "the first presenter kept its entry"
1018            );
1019        });
1020    }
1021}