Skip to main content

capsule_runtime/
peer_handoff.rs

1//! Peer file handoff: runtime-minted handles over a two-sided grant.
2//!
3//! A capsule that declares `exports.peer_files` can hand one named file to one named peer
4//! without a filesystem path ever crossing the wire. The runtime mints an opaque handle — a MAC'd
5//! token naming exactly one file, for one audience, with an expiry — and the agent puts that
6//! handle in an ordinary A2A message. A capsule that declares `capabilities.peer_fetch` can
7//! redeem such a handle against the minter's `/resources/peer/<handle>` endpoint and lands the
8//! bytes as a *file* in its own workdir.
9//!
10//! The same three-way split the operator plane rests on, with the audience added:
11//!
12//! * The **agent** owns what a file contains, and chooses which file to offer and to whom.
13//! * The **runtime** owns whether it may. It is the minting authority, so an agent cannot forge a
14//!   handle for a file the operator did not put under `exports.peer_files.root`, and cannot widen
15//!   its own envelope by naming a path — the path is resolved here, by the same code that
16//!   resolves a read.
17//! * The **operator on the other side** owns whether the bytes may be ingested at all, through
18//!   `capabilities.peer_fetch.allow`. Neither half implies the other and both default to deny.
19//!
20//! **The minting key is instance-scoped.** 32 bytes from the OS at launch, held only in memory,
21//! zeroed on drop. When the session ends every outstanding handle becomes unverifiable at once —
22//! revoke-all with no revocation list, and the reason a handle minted by one session of a capsule
23//! does not redeem against the next.
24//!
25//! **A handle authorises a file, not a version of one.** There is no generation in the payload
26//! and no `409` on this plane: a redeem always serves the file's current bytes, so a file
27//! rewritten two turns later simply serves the newer content and the `etag` is how the holder
28//! notices. Pinning would mean either failing an ordinary content update or retaining superseded
29//! bytes, and nothing else in the runtime does either.
30//!
31//! **Redeem is idempotent, not single-use.** No used-set, no per-handle server state. Single-use
32//! exists to bound a credential's blast radius over time, and the instance-scoped key plus the
33//! capsule's own lifetime already do that structurally; making a retried transfer a lost file
34//! buys nothing on top.
35
36use std::{
37    path::Path,
38    sync::{
39        atomic::{AtomicU64, Ordering},
40        Arc,
41    },
42};
43
44use murmur_artifact::{ContainmentClass, PeerFilesExport};
45use serde::{Deserialize, Serialize};
46
47use crate::{
48    identity::CapsuleIdentity,
49    mac_token::{self, MacTokenError, MintKey, NONCE_BYTES},
50    outgoing::parse_host_port,
51    resource_plane::{
52        read_export_file_with_policy, resolve_relpath_beneath_root, symlink_policy, DeclaredExport,
53        ReadResponse, ResourceError, ResourceResponse,
54    },
55    trace::{timestamp_ms, ResourceTraceAppender},
56};
57
58/// Path prefix the peer plane answers under, alongside the operator plane's `/resources/files/`.
59/// `GET`-only, and with no listing verb at any depth.
60pub const PEER_PATH_PREFIX: &str = "/resources/peer";
61
62/// Request header carrying the audience the caller asserts it is. Required on every redeem.
63pub const AUDIENCE_HEADER: &str = "x-murmur-audience";
64
65/// Response header naming the handle a served body was redeemed with.
66pub const HANDLE_ID_HEADER: &str = "x-murmur-handle-id";
67
68/// Version tag and first segment of every token.
69const TOKEN_VERSION_TAG: &str = "mh1";
70
71/// The only payload version this runtime mints or accepts.
72const PAYLOAD_VERSION: u8 = 1;
73
74/// Domain separator mixed into every MAC input, so a MAC over a peer handle can never be mistaken
75/// for a MAC any other authority computes.
76const MAC_DOMAIN: &[u8] = b"murmur-peer-handle-v1";
77
78/// Directory, relative to the accessible workdir, that fetched bytes land in.
79pub const PEER_INBOX_DIR: &str = "peer-in";
80
81// ── The minting key ───────────────────────────────────────────────────────────
82
83/// The key one capsule *instance* mints and verifies handles with.
84///
85/// Generated in `launch_session`, and only when `exports.peer_files` is declared. When the session
86/// ends the key goes with it and every outstanding handle becomes unverifiable at once — the
87/// reason a handle minted by one session of a capsule does not redeem against the next.
88pub type PeerMintKey = MintKey;
89
90// ── The token ─────────────────────────────────────────────────────────────────
91
92/// A handle's payload, carried in the clear in the token's middle segment.
93///
94/// Readable by anyone holding the token, and deliberately so: the payload is not the secret. The
95/// MAC is what makes it unforgeable, and the audience — which is *not* carried here — is what
96/// makes it non-bearer.
97///
98/// There is no generation field. A handle authorises a file, not a version of one; see the module
99/// documentation.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101pub struct HandlePayload {
102    /// Payload version. Only [`PAYLOAD_VERSION`] is minted or accepted.
103    pub v: u8,
104    /// The minting session's `session_id` — the capsule *instance*, not the capsule.
105    pub iss: String,
106    /// Path relative to `exports.peer_files.root`, canonicalised at mint time. Never absolute,
107    /// and never the accessible-workdir-relative form: the peer plane discloses no path structure
108    /// above the root.
109    pub p: String,
110    /// Absolute expiry, in unix milliseconds.
111    pub exp: u64,
112    /// Random bytes, lowercase hex, so two handles for the same file and audience are distinct.
113    pub n: String,
114}
115
116/// Why a token was not accepted.
117///
118/// Three variants and not more, on purpose: see [`verify`].
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub enum HandleError {
121    /// Not `mh1.<base64url>.<base64url>`, or a payload that is not JSON, or `v != 1`.
122    Malformed,
123    /// MAC verification failed, for any reason at all.
124    NotValid,
125    /// The MAC verified and `exp` is in the past.
126    Expired,
127}
128
129/// The stable identifier a mint, a redeem and a fetch are correlated by.
130///
131/// This — never the token — is what appears in a trace, a log line or an error body.
132pub fn handle_id(token: &str) -> String {
133    mac_token::token_id(token)
134}
135
136/// Shape alone: `mh1.<base64url>.<base64url>`, with nothing read from the payload.
137fn check_shape(token: &str) -> Result<(), HandleError> {
138    mac_token::payload_segment(TOKEN_VERSION_TAG, token)?;
139    Ok(())
140}
141
142/// Mints one token: `mh1.<base64url-nopad(payload JSON)>.<base64url-nopad(mac)>`.
143///
144/// **The audience is covered by the MAC but is not carried in the token.** That is what makes a
145/// handle audience-scoped rather than pure bearer: a token scraped out of persisted message
146/// history is not by itself redeemable, because redemption also requires asserting the exact
147/// audience string it was minted for.
148pub fn mint(key: &PeerMintKey, payload: &HandlePayload, audience: &str) -> Result<String, String> {
149    let json = serde_json::to_vec(payload)
150        .map_err(|error| format!("failed to serialize the handle payload: {error}"))?;
151    Ok(mac_token::seal(
152        key,
153        TOKEN_VERSION_TAG,
154        MAC_DOMAIN,
155        &json,
156        &[audience],
157    ))
158}
159
160/// Verifies one token against this instance's key and the audience the caller asserted.
161///
162/// Check order is fixed and nothing downstream of the MAC is evaluated before it, so a caller
163/// that fails the MAC learns nothing about the file: shape → MAC → payload → expiry.
164///
165/// **[`HandleError::NotValid`] is deliberately one outcome.** A tampered payload, a handle minted
166/// by a different capsule instance, and a correct handle presented with the wrong audience are
167/// indistinguishable to the caller — same status, same code, same message. Splitting them builds
168/// an oracle that tells a prober which field to change next. A payload whose `iss` is not this
169/// session's id answers the same way, and is unreachable in any case: only this instance's key
170/// can produce a MAC this instance accepts.
171///
172/// **Audience binding is not peer authentication.** Nothing here proves that the process
173/// asserting `x-murmur-audience: reporter@localhost:41234` *is* that capsule; an attacker who
174/// both intercepts a handle and knows which peer it was minted for can assert that identity and
175/// redeem. What the binding buys is that a handle is not a credential for whoever finds it — a
176/// third capsule with its own identity is refused, and a token alone is insufficient.
177pub fn verify(
178    key: &PeerMintKey,
179    token: &str,
180    audience: &str,
181    session_id: &str,
182) -> Result<HandlePayload, HandleError> {
183    let payload_bytes = mac_token::open(key, TOKEN_VERSION_TAG, MAC_DOMAIN, token, &[audience])?;
184
185    // Only now: the payload has been proven to be one this instance minted, so reading it is
186    // reading our own record rather than trusting the caller's.
187    let payload: HandlePayload =
188        serde_json::from_slice(&payload_bytes).map_err(|_| HandleError::Malformed)?;
189    if payload.v != PAYLOAD_VERSION {
190        return Err(HandleError::Malformed);
191    }
192    if payload.iss != session_id {
193        return Err(HandleError::NotValid);
194    }
195    if payload.exp <= timestamp_ms() {
196        return Err(HandleError::Expired);
197    }
198    Ok(payload)
199}
200
201/// The payload of a token this runtime did **not** mint and cannot verify.
202///
203/// Used on the fetching side for one thing only: naming the local file the bytes land in. The
204/// value it returns is caller-controlled and must never decide anything — the stored path is
205/// prefixed with the `handle_id` and the basename is sanitised precisely because this is not a
206/// fact about anything.
207pub fn decode_payload_unverified(token: &str) -> Option<HandlePayload> {
208    let payload_bytes = mac_token::payload_segment(TOKEN_VERSION_TAG, token).ok()?;
209    serde_json::from_slice(&payload_bytes).ok()
210}
211
212/// Replaces every peer handle in `text` with its `handle_id`.
213///
214/// A handle is a credential, and the trace is a durable record — so the two must never meet.
215/// Applied at the trace boundary rather than at each call site, because the token reaches the
216/// record by an ordinary route: it is an argument the model passed to `fetch-peer-file`, and the
217/// `tool_call` event records the arguments of every call. Substituting the `handle_id` keeps the
218/// record correlatable with the mint and the redeem while carrying nothing redeemable.
219pub(crate) fn redact_handle_tokens(text: &str) -> std::borrow::Cow<'_, str> {
220    if !text.contains(TOKEN_VERSION_TAG) {
221        return std::borrow::Cow::Borrowed(text);
222    }
223    let bytes = text.as_bytes();
224    let prefix = format!("{TOKEN_VERSION_TAG}.");
225    let mut out = String::with_capacity(text.len());
226    let mut index = 0;
227    let mut replaced = false;
228    while index < bytes.len() {
229        if text[index..].starts_with(&prefix) {
230            if let Some(end) = token_end(&text[index..]) {
231                let token = &text[index..index + end];
232                out.push_str(&format!("<handle:{}>", handle_id(token)));
233                index += end;
234                replaced = true;
235                continue;
236            }
237        }
238        // Advance by one whole character: indexing a `str` mid-codepoint would panic.
239        let step = text[index..].chars().next().map_or(1, char::len_utf8);
240        out.push_str(&text[index..index + step]);
241        index += step;
242    }
243    if replaced {
244        std::borrow::Cow::Owned(out)
245    } else {
246        std::borrow::Cow::Borrowed(text)
247    }
248}
249
250/// The byte length of the `mh1.<seg>.<seg>` token starting at `text`, or `None` if there is none.
251///
252/// Deliberately not greedy across the second separator: a token at the end of a sentence must not
253/// swallow the full stop after it, and a token is exactly three segments.
254fn token_end(text: &str) -> Option<usize> {
255    let mut cursor = TOKEN_VERSION_TAG.len() + 1;
256    let mut segments = 0;
257    while segments < 2 {
258        let start = cursor;
259        cursor += text[cursor..]
260            .bytes()
261            .take_while(|b| b.is_ascii_alphanumeric() || *b == b'-' || *b == b'_')
262            .count();
263        if cursor == start {
264            return None;
265        }
266        segments += 1;
267        if segments == 1 {
268            if text[cursor..].starts_with('.') {
269                cursor += 1;
270            } else {
271                return None;
272            }
273        }
274    }
275    Some(cursor)
276}
277
278/// [`redact_handle_tokens`] applied to every string in a JSON value, in place.
279pub(crate) fn redact_handles_in_json(value: &mut serde_json::Value) {
280    match value {
281        serde_json::Value::String(text) => {
282            if let std::borrow::Cow::Owned(redacted) = redact_handle_tokens(text) {
283                *text = redacted;
284            }
285        }
286        serde_json::Value::Array(items) => items.iter_mut().for_each(redact_handles_in_json),
287        serde_json::Value::Object(map) => {
288            map.values_mut().for_each(redact_handles_in_json);
289        }
290        _ => {}
291    }
292}
293
294// ── The audience ──────────────────────────────────────────────────────────────
295
296/// The audience string derived from a peer's own agent card: `<name>@<host:port>`, lowercased,
297/// where `host:port` is the `url` of the card's `JSONRPC` interface with its scheme and path
298/// stripped.
299///
300/// Both sides compute the same string without exchanging it, because both compute it from *the
301/// fetching capsule's own advertised identity* — the minter reads it off the card it fetched, and
302/// the fetcher asserts it from the same two fields of its own identity.
303pub fn audience_from_card(card: &serde_json::Value) -> Result<String, String> {
304    let name = card
305        .get("name")
306        .and_then(serde_json::Value::as_str)
307        .filter(|name| !name.trim().is_empty())
308        .ok_or_else(|| "the peer's agent card carries no 'name'".to_string())?;
309    let url = crate::identity::jsonrpc_interface_url(card)
310        .filter(|url| !url.trim().is_empty())
311        .ok_or_else(|| {
312            "the peer's agent card has no 'supportedInterfaces' entry with 'protocolBinding' \
313             JSONRPC and a 'url'"
314                .to_string()
315        })?;
316    let host_port = parse_host_port(url)?;
317    Ok(format!("{}@{}", name.trim(), host_port).to_lowercase())
318}
319
320/// This capsule's own audience, asserted on every redeem it issues.
321///
322/// Built from the same two identity fields `build_agent_card` publishes as the card's `name` and
323/// its `JSONRPC` interface `url`, so what a peer minted for and what this runtime asserts are the
324/// same string by construction.
325pub(crate) fn own_audience(identity: &CapsuleIdentity) -> String {
326    let host_port =
327        parse_host_port(&identity.capsule_url).unwrap_or_else(|_| identity.capsule_url.clone());
328    format!("{}@{}", identity.capsule_name, host_port).to_lowercase()
329}
330
331// ── Errors on the wire ────────────────────────────────────────────────────────
332
333/// Every way a redeem can be refused, in the vocabulary the caller and the trace share.
334#[derive(Debug, Clone, PartialEq, Eq)]
335pub enum PeerError {
336    /// The manifest declares no `exports.peer_files`. Absent means deny.
337    NoPeerPlane,
338    MalformedHandle,
339    MissingAudience,
340    /// The MAC did not verify — for any reason. Deliberately indivisible; see [`verify`].
341    HandleNotValid,
342    HandleExpired,
343    NotFound,
344    OutsideRoot,
345    SymlinkRefused,
346    NotARegularFile,
347    TooLarge {
348        max_bytes: u64,
349    },
350    MethodNotAllowed,
351    IoError(String),
352}
353
354impl PeerError {
355    /// The stable `error` string in the JSON body and the `outcome` in the trace — one vocabulary
356    /// for both, so a refusal an auditor reads is spelled the way the caller saw it.
357    pub fn code(&self) -> &'static str {
358        match self {
359            PeerError::NoPeerPlane => "no_peer_plane",
360            PeerError::MalformedHandle => "malformed_handle",
361            PeerError::MissingAudience => "missing_audience",
362            PeerError::HandleNotValid => "handle_not_valid",
363            PeerError::HandleExpired => "handle_expired",
364            PeerError::NotFound => "not_found",
365            PeerError::OutsideRoot => "outside_root",
366            PeerError::SymlinkRefused => "symlink_refused",
367            PeerError::NotARegularFile => "not_a_regular_file",
368            PeerError::TooLarge { .. } => "too_large",
369            PeerError::MethodNotAllowed => "method_not_allowed",
370            PeerError::IoError(_) => "io_error",
371        }
372    }
373
374    pub fn status(&self) -> u16 {
375        match self {
376            PeerError::NoPeerPlane | PeerError::NotFound => 404,
377            PeerError::MalformedHandle | PeerError::MissingAudience => 400,
378            PeerError::HandleNotValid
379            | PeerError::OutsideRoot
380            | PeerError::SymlinkRefused
381            | PeerError::NotARegularFile => 403,
382            PeerError::HandleExpired => 410,
383            PeerError::TooLarge { .. } => 413,
384            PeerError::MethodNotAllowed => 405,
385            PeerError::IoError(_) => 500,
386        }
387    }
388
389    /// One sentence per code. [`PeerError::HandleNotValid`]'s is a fixed string with nothing
390    /// interpolated into it: three different causes must produce byte-identical bodies.
391    pub fn message(&self) -> String {
392        match self {
393            PeerError::NoPeerPlane => {
394                "this capsule declares no exports.peer_files block, so it has no peer plane"
395                    .to_string()
396            }
397            PeerError::MalformedHandle => "the handle is not a well-formed mh1 token".to_string(),
398            PeerError::MissingAudience => {
399                format!("a redeem must assert its audience in the {AUDIENCE_HEADER} header")
400            }
401            PeerError::HandleNotValid => "the handle is not valid for this capsule".to_string(),
402            PeerError::HandleExpired => "the handle has expired".to_string(),
403            PeerError::NotFound => "no such file under the peer export root".to_string(),
404            PeerError::OutsideRoot => {
405                "the handle names a path outside the peer export root".to_string()
406            }
407            PeerError::SymlinkRefused => {
408                "a symlink was encountered and the achieved containment class is scoped".to_string()
409            }
410            PeerError::NotARegularFile => "the handle does not name a regular file".to_string(),
411            PeerError::TooLarge { max_bytes } => {
412                format!(
413                    "file exceeds the declared exports.peer_files.max_bytes of {max_bytes} bytes"
414                )
415            }
416            PeerError::MethodNotAllowed => {
417                "the peer plane serves GET only; it has no write path and no list verb".to_string()
418            }
419            PeerError::IoError(detail) => format!("read failed: {detail}"),
420        }
421    }
422}
423
424impl From<MacTokenError> for HandleError {
425    fn from(error: MacTokenError) -> Self {
426        match error {
427            MacTokenError::Malformed => HandleError::Malformed,
428            MacTokenError::NotValid => HandleError::NotValid,
429        }
430    }
431}
432
433impl From<HandleError> for PeerError {
434    fn from(error: HandleError) -> Self {
435        match error {
436            HandleError::Malformed => PeerError::MalformedHandle,
437            HandleError::NotValid => PeerError::HandleNotValid,
438            HandleError::Expired => PeerError::HandleExpired,
439        }
440    }
441}
442
443impl From<ResourceError> for PeerError {
444    /// The reader's refusals, restated in the peer plane's vocabulary.
445    ///
446    /// `NoResourcePlane` and `MethodNotAllowed` cannot arrive here — the reader is called with a
447    /// declared export and the method was settled before routing — but they are mapped rather
448    /// than panicked on so a later caller cannot turn a wrong call into a crash.
449    fn from(error: ResourceError) -> Self {
450        match error {
451            ResourceError::NotFound => PeerError::NotFound,
452            ResourceError::OutsideRoot => PeerError::OutsideRoot,
453            ResourceError::SymlinkRefused => PeerError::SymlinkRefused,
454            ResourceError::NotARegularFile => PeerError::NotARegularFile,
455            ResourceError::TooLarge { max_bytes } => PeerError::TooLarge { max_bytes },
456            ResourceError::MethodNotAllowed => PeerError::MethodNotAllowed,
457            ResourceError::NoResourcePlane => PeerError::NoPeerPlane,
458            ResourceError::IoError(detail) => PeerError::IoError(detail),
459        }
460    }
461}
462
463// ── The plane ─────────────────────────────────────────────────────────────────
464
465/// One minted handle, as `share-file` returns it.
466#[derive(Debug, Clone, PartialEq, Eq)]
467pub struct MintedHandle {
468    pub handle: String,
469    pub handle_id: String,
470    pub expires_at_ms: u64,
471    pub audience: String,
472    /// The canonical root-relative path the handle names. Recorded in the mint trace event; never
473    /// returned to the agent, which asked for it and does not need it echoed as a fact.
474    pub path: String,
475}
476
477/// The declared half of a peer plane: the subtree, the key and the lifetime ceiling.
478struct DeclaredPeerExport {
479    /// The peer subtree, root resolved against the session's accessible workdir.
480    export: DeclaredExport,
481    /// The instance key. Shared as an `Arc` because the listener and the agent loop both hold the
482    /// plane, and destroyed when the last of them goes.
483    key: Arc<PeerMintKey>,
484    /// Ceiling on a minted handle's lifetime, in seconds. A `ttl` argument may only narrow.
485    max_ttl_secs: u64,
486}
487
488/// Everything a mint or a redeem needs, and nothing a running task leaves behind.
489///
490/// Always built, declared or not — the same shape [`crate::resource_plane::ResourcePlane`] has,
491/// and for the same reason: an undeclared capsule still has to *record* the redeem it refused, and
492/// a plane that does not exist has nowhere to write.
493pub struct PeerPlane {
494    /// `None` means the capsule declared no `exports.peer_files`, which is the deny case: every
495    /// redeem answers `no_peer_plane`, nothing mints, and no `share-file` tool manifest was ever
496    /// written for the agent to see.
497    declared: Option<DeclaredPeerExport>,
498    /// The minting session's id, written into every payload's `iss` and compared on every redeem.
499    session_id: String,
500    /// The operator plane's counter, shared: a redeem reports the same provenance a read does.
501    generation: Arc<AtomicU64>,
502    /// The class this session actually achieved. Keys the symlink decision and rides on every
503    /// response.
504    containment_achieved: ContainmentClass,
505    /// `None` when there is nowhere to write a record — a plane built outside a session.
506    trace: Option<Arc<ResourceTraceAppender>>,
507}
508
509impl PeerPlane {
510    /// Everything a plane needs, and the complete list of it: a host path, the declared
511    /// export (`None` = undeclared = deny) paired with the instance key, the session id, the
512    /// achieved class, the generation counter and somewhere to write the record.
513    pub fn new(
514        accessible_workdir: &Path,
515        export: Option<(&PeerFilesExport, Arc<PeerMintKey>)>,
516        session_id: String,
517        containment_achieved: ContainmentClass,
518        generation: Arc<AtomicU64>,
519        trace: Option<Arc<ResourceTraceAppender>>,
520    ) -> Self {
521        Self {
522            declared: export.map(|(export, key)| DeclaredPeerExport {
523                export: DeclaredExport::for_peer_files(accessible_workdir, export),
524                key,
525                max_ttl_secs: export.effective_max_ttl_secs(),
526            }),
527            session_id,
528            generation,
529            containment_achieved,
530            trace,
531        }
532    }
533
534    /// Whether this capsule declared `exports.peer_files` at all.
535    pub fn is_declared(&self) -> bool {
536        self.declared.is_some()
537    }
538
539    pub fn generation(&self) -> u64 {
540        self.generation.load(Ordering::SeqCst)
541    }
542
543    /// Mints a handle for one file under `exports.peer_files.root`, for one audience.
544    ///
545    /// `relpath` is relative to the export root and is resolved by the same code that resolves a
546    /// read, so a path that escapes — `..`, absolute, percent-encoded, or a symlink leaving the
547    /// root — fails the mint. It is refused, never normalised into something mintable.
548    ///
549    /// `ttl_secs` may only narrow: a value above the declared `max_ttl` is clamped down, never up,
550    /// and `None` means the declared `max_ttl`.
551    pub fn mint_handle(
552        &self,
553        relpath: &str,
554        audience: &str,
555        ttl_secs: Option<u64>,
556    ) -> Result<MintedHandle, PeerError> {
557        let declared = self.declared.as_ref().ok_or(PeerError::NoPeerPlane)?;
558        let policy = symlink_policy(self.containment_achieved);
559        let (target, canonical_relpath) =
560            resolve_relpath_beneath_root(&declared.export, relpath, policy)?;
561        // Resolved beneath the root, but not yet known to be servable: a directory or a fifo
562        // under the root would otherwise mint a handle that can only ever be refused.
563        let metadata = std::fs::metadata(&target).map_err(|error| match error.kind() {
564            std::io::ErrorKind::NotFound => PeerError::NotFound,
565            _ => PeerError::IoError(error.to_string()),
566        })?;
567        if !metadata.is_file() {
568            return Err(PeerError::NotARegularFile);
569        }
570
571        let ttl = ttl_secs
572            .unwrap_or(declared.max_ttl_secs)
573            .min(declared.max_ttl_secs);
574        let payload = HandlePayload {
575            v: PAYLOAD_VERSION,
576            iss: self.session_id.clone(),
577            p: canonical_relpath.clone(),
578            exp: timestamp_ms().saturating_add(ttl.saturating_mul(1000)),
579            n: mac_token::random_hex(NONCE_BYTES).map_err(PeerError::IoError)?,
580        };
581        let token = mint(&declared.key, &payload, audience).map_err(PeerError::IoError)?;
582        Ok(MintedHandle {
583            handle_id: handle_id(&token),
584            handle: token,
585            expires_at_ms: payload.exp,
586            audience: audience.to_string(),
587            path: canonical_relpath,
588        })
589    }
590
591    /// Redeems one token: verify, then serve the named file's *current* bytes.
592    ///
593    /// Calls the operator plane's shared reader, so it inherits that reader's guarantees
594    /// unchanged — the file is opened once and the bytes, size, mtime and `sha256` all come from
595    /// that single descriptor, so the `etag` always describes the body it accompanies even while
596    /// the agent is rewriting the file. There is no second read path, no re-`stat` and no lock.
597    async fn redeem(
598        &self,
599        token: &str,
600        audience: Option<&str>,
601    ) -> Result<(HandlePayload, ReadResponse), PeerError> {
602        let declared = self.declared.as_ref().ok_or(PeerError::NoPeerPlane)?;
603
604        // Shape first, so a request that is not a token at all is told so without the audience
605        // requirement standing in front of it.
606        check_shape(token).map_err(PeerError::from)?;
607
608        let audience = audience
609            .map(str::trim)
610            .filter(|audience| !audience.is_empty())
611            .ok_or(PeerError::MissingAudience)?;
612
613        let payload = verify(&declared.key, token, audience, &self.session_id)?;
614
615        let export = declared.export.clone();
616        let relpath = payload.p.clone();
617        let policy = symlink_policy(self.containment_achieved);
618        let read = tokio::task::spawn_blocking(move || {
619            read_export_file_with_policy(&export, &relpath, policy)
620        })
621        .await
622        .unwrap_or_else(|join| Err(ResourceError::IoError(join.to_string())))?;
623
624        Ok((payload, read))
625    }
626}
627
628/// Whether `raw_path` addresses the peer plane rather than the operator plane.
629///
630/// Matched on the whole segment, so `/resources/peerless/x` is not a peer request and falls
631/// through to the operator plane's own routing, which refuses it.
632pub fn is_peer_path(raw_path: &str) -> bool {
633    let path = raw_path.split('?').next().unwrap_or("");
634    path == PEER_PATH_PREFIX || path.starts_with(&format!("{PEER_PATH_PREFIX}/"))
635}
636
637/// Answers one peer-plane request: a method, a raw request path, an asserted audience, and
638/// nothing else.
639///
640/// Knows no socket, no framing and no authoriser, on the same terms as the operator plane's
641/// `handle_resource_request`, so binding it on a second listener is a matter of calling exactly
642/// this and writing out the reply.
643pub async fn handle_peer_request(
644    plane: &PeerPlane,
645    method: &str,
646    raw_path: &str,
647    audience: Option<&str>,
648) -> ResourceResponse {
649    // A property of the request alone, so it is settled before anything about this capsule is
650    // consulted: a `PUT` is refused identically whether or not a peer plane is declared.
651    if method != "GET" {
652        return error_response(&PeerError::MethodNotAllowed, None, None);
653    }
654
655    let path = raw_path.split('?').next().unwrap_or("");
656    let rest = path.strip_prefix(PEER_PATH_PREFIX).unwrap_or("");
657    // **There is no `list` verb on the peer plane.** `/resources/peer` and `/resources/peer/` name
658    // no handle, and enumeration is the thing this plane exists to prevent — so they are
659    // `not_found` rather than an empty listing, and there is no handle to trace them against.
660    let Some(token) = rest.strip_prefix('/').filter(|token| !token.is_empty()) else {
661        return error_response(&PeerError::NotFound, None, None);
662    };
663
664    let generation = plane.generation();
665    let hid = handle_id(token);
666
667    match plane.redeem(token, audience).await {
668        Ok((payload, read)) => {
669            if let Some(trace) = &plane.trace {
670                trace
671                    .write_peer_handle_redeem(
672                        &hid,
673                        Some(payload.p.clone()),
674                        generation,
675                        audience.map(str::to_string),
676                        Some(read.bytes.len() as u64),
677                        Some(read.sha256.clone()),
678                        "ok",
679                        None,
680                    )
681                    .await;
682            }
683            ResourceResponse::framed(
684                200,
685                vec![
686                    (
687                        "content-type".to_string(),
688                        "application/octet-stream".to_string(),
689                    ),
690                    ("etag".to_string(), format!("\"sha256:{}\"", read.sha256)),
691                    ("x-murmur-mtime-ms".to_string(), read.mtime_ms.to_string()),
692                    ("x-murmur-generation".to_string(), generation.to_string()),
693                    (
694                        "x-murmur-containment".to_string(),
695                        plane.containment_achieved.to_string(),
696                    ),
697                    (HANDLE_ID_HEADER.to_string(), hid),
698                ],
699                read.bytes,
700            )
701        }
702        Err(error) => {
703            if let Some(trace) = &plane.trace {
704                // A payload that failed the MAC is caller-controlled and must not be copied into
705                // this capsule's own audit record as if it were fact — so `path` is null until the
706                // MAC has proven the payload is ours.
707                trace
708                    .write_peer_handle_redeem(
709                        &hid,
710                        None,
711                        generation,
712                        audience.map(str::to_string),
713                        None,
714                        None,
715                        error.code(),
716                        Some(error.message()),
717                    )
718                    .await;
719            }
720            error_response(&error, Some(generation), Some(hid))
721        }
722    }
723}
724
725fn error_response(
726    error: &PeerError,
727    generation: Option<u64>,
728    handle_id: Option<String>,
729) -> ResourceResponse {
730    let body = serde_json::json!({"error": error.code(), "message": error.message()});
731    let mut headers = vec![("content-type".to_string(), "application/json".to_string())];
732    if let Some(generation) = generation {
733        headers.push(("x-murmur-generation".to_string(), generation.to_string()));
734    }
735    if let Some(handle_id) = handle_id {
736        headers.push((HANDLE_ID_HEADER.to_string(), handle_id));
737    }
738    if matches!(error, PeerError::MethodNotAllowed) {
739        headers.push(("allow".to_string(), "GET".to_string()));
740    }
741    ResourceResponse::framed(
742        error.status(),
743        headers,
744        serde_json::to_vec(&body).unwrap_or_default(),
745    )
746}
747
748// ── Naming a fetched file ─────────────────────────────────────────────────────
749
750/// The workdir-relative path fetched bytes land at: `peer-in/<handle_id>-<sanitised basename>`.
751///
752/// Runtime-chosen, never peer-chosen. The basename is a hint read out of the token's unverified
753/// payload and is sanitised to `[A-Za-z0-9._-]` before use; the `handle_id` prefix is what keeps
754/// two fetches of two different handles from colliding even when the hint is identical or
755/// useless.
756pub fn stored_path_for(handle_id: &str, basename_hint: Option<&str>) -> String {
757    let sanitised: String = basename_hint
758        .and_then(|hint| hint.rsplit('/').next())
759        .map(|name| {
760            name.chars()
761                .map(|c| {
762                    if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' {
763                        c
764                    } else {
765                        '_'
766                    }
767                })
768                .take(120)
769                .collect()
770        })
771        .filter(|name: &String| !name.is_empty() && name != "." && name != "..")
772        .unwrap_or_else(|| "file".to_string());
773    format!("{PEER_INBOX_DIR}/{handle_id}-{sanitised}")
774}
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779    use std::fs;
780
781    fn key() -> PeerMintKey {
782        PeerMintKey::generate().unwrap()
783    }
784
785    /// Substitute the first character of a handle's `nth` dot-separated segment.
786    ///
787    /// The first character, never the last: only a segment's final base64url character carries
788    /// padding bits, and those must be zero to decode. Substituting there can set one, so the
789    /// segment stops decoding and the refusal comes from the parse rather than from the MAC —
790    /// which is a different error than these tests assert.
791    fn tamper_segment(handle: &str, nth: usize) -> String {
792        let mut segments: Vec<String> = handle.split('.').map(str::to_string).collect();
793        let segment = &mut segments[nth];
794        let first = segment.remove(0);
795        segment.insert(0, if first == 'A' { 'B' } else { 'A' });
796        segments.join(".")
797    }
798
799    fn payload_for(path: &str, session: &str, exp: u64) -> HandlePayload {
800        HandlePayload {
801            v: 1,
802            iss: session.to_string(),
803            p: path.to_string(),
804            exp,
805            n: "0123456789abcdef0123456789abcdef".to_string(),
806        }
807    }
808
809    fn in_an_hour() -> u64 {
810        timestamp_ms() + 3_600_000
811    }
812
813    #[test]
814    fn a_minted_handle_round_trips_through_verify() {
815        let key = key();
816        let payload = payload_for("report.md", "ses_1", in_an_hour());
817        let token = mint(&key, &payload, "reporter@localhost:41234").unwrap();
818        assert!(token.starts_with("mh1."));
819        assert_eq!(token.split('.').count(), 3);
820        let verified = verify(&key, &token, "reporter@localhost:41234", "ses_1").unwrap();
821        assert_eq!(verified, payload);
822    }
823
824    #[test]
825    fn a_token_uses_only_url_safe_unpadded_base64() {
826        let key = key();
827        // A payload long enough that padding would be required by the padded alphabet.
828        let token = mint(
829            &key,
830            &payload_for("a/b/c.md", "ses_padding", in_an_hour()),
831            "x@y",
832        )
833        .unwrap();
834        let segments: Vec<&str> = token.split('.').collect();
835        for segment in &segments[1..] {
836            assert!(
837                segment
838                    .chars()
839                    .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
840                "segment '{segment}' must be base64url with no padding"
841            );
842        }
843    }
844
845    #[test]
846    fn a_tampered_payload_is_not_valid() {
847        let key = key();
848        let token = mint(
849            &key,
850            &payload_for("report.md", "ses_1", in_an_hour()),
851            "a@b",
852        )
853        .unwrap();
854        let tampered = tamper_segment(&token, 1);
855        assert_eq!(
856            verify(&key, &tampered, "a@b", "ses_1"),
857            Err(HandleError::NotValid)
858        );
859    }
860
861    #[test]
862    fn a_tampered_mac_is_not_valid() {
863        let key = key();
864        let token = mint(
865            &key,
866            &payload_for("report.md", "ses_1", in_an_hour()),
867            "a@b",
868        )
869        .unwrap();
870        let tampered = tamper_segment(&token, 2);
871        assert_eq!(
872            verify(&key, &tampered, "a@b", "ses_1"),
873            Err(HandleError::NotValid)
874        );
875    }
876
877    #[test]
878    fn the_wrong_audience_is_not_valid() {
879        let key = key();
880        let token = mint(
881            &key,
882            &payload_for("report.md", "ses_1", in_an_hour()),
883            "a@b",
884        )
885        .unwrap();
886        assert_eq!(
887            verify(&key, &token, "attacker@localhost:1", "ses_1"),
888            Err(HandleError::NotValid)
889        );
890    }
891
892    /// The three causes the design requires to be indistinguishable: a tampered payload, a
893    /// foreign key, and a correct handle presented with the wrong audience. Same variant, same
894    /// code, same message, byte for byte.
895    #[test]
896    fn every_refusal_cause_is_indistinguishable_on_the_wire() {
897        let key = key();
898        let other = PeerMintKey::generate().unwrap();
899        let token = mint(
900            &key,
901            &payload_for("report.md", "ses_1", in_an_hour()),
902            "a@b",
903        )
904        .unwrap();
905
906        let mut chars: Vec<char> = token.chars().collect();
907        let payload_end = token.rfind('.').unwrap();
908        chars[payload_end - 1] = if chars[payload_end - 1] == 'A' {
909            'B'
910        } else {
911            'A'
912        };
913        let tampered: String = chars.into_iter().collect();
914
915        let causes = [
916            verify(&key, &tampered, "a@b", "ses_1"),
917            verify(&other, &token, "a@b", "ses_1"),
918            verify(&key, &token, "attacker@localhost:1", "ses_1"),
919        ];
920        for cause in &causes {
921            assert_eq!(*cause, Err(HandleError::NotValid));
922        }
923        let errors: Vec<PeerError> = causes
924            .iter()
925            .map(|cause| PeerError::from(*cause.as_ref().unwrap_err()))
926            .collect();
927        for error in &errors {
928            assert_eq!(error, &PeerError::HandleNotValid);
929            assert_eq!(error.status(), 403);
930            assert_eq!(error.code(), errors[0].code());
931            assert_eq!(error.message(), errors[0].message());
932        }
933    }
934
935    #[test]
936    fn a_handle_from_another_instance_of_the_same_capsule_is_not_valid() {
937        let one = key();
938        let two = key();
939        let token = mint(
940            &one,
941            &payload_for("report.md", "ses_1", in_an_hour()),
942            "a@b",
943        )
944        .unwrap();
945        assert_eq!(
946            verify(&two, &token, "a@b", "ses_1"),
947            Err(HandleError::NotValid)
948        );
949    }
950
951    /// A foreign `iss` with a valid MAC is unreachable in production — only this instance's key
952    /// produces a MAC this instance accepts — but the branch exists, and it must answer with the
953    /// same undivided refusal rather than a code of its own.
954    #[test]
955    fn a_foreign_issuer_is_not_valid_rather_than_a_code_of_its_own() {
956        let key = key();
957        let token = mint(
958            &key,
959            &payload_for("report.md", "ses_other", in_an_hour()),
960            "a@b",
961        )
962        .unwrap();
963        assert_eq!(
964            verify(&key, &token, "a@b", "ses_1"),
965            Err(HandleError::NotValid)
966        );
967    }
968
969    #[test]
970    fn an_expired_handle_reports_expiry() {
971        let key = key();
972        let token = mint(
973            &key,
974            &payload_for("report.md", "ses_1", timestamp_ms() - 1),
975            "a@b",
976        )
977        .unwrap();
978        assert_eq!(
979            verify(&key, &token, "a@b", "ses_1"),
980            Err(HandleError::Expired)
981        );
982    }
983
984    #[test]
985    fn every_malformed_shape_is_malformed() {
986        let key = key();
987        for candidate in [
988            "",
989            "report.md",
990            "mh1",
991            "mh1.",
992            "mh1.abc",
993            "mh1.abc.def.ghi",
994            "mh2.YWJj.YWJj",
995            "mh1.!!!.YWJj",
996            "mh1.YWJj.!!!",
997        ] {
998            assert_eq!(
999                verify(&key, candidate, "a@b", "ses_1"),
1000                Err(HandleError::Malformed),
1001                "'{candidate}' should be malformed"
1002            );
1003        }
1004    }
1005
1006    /// A payload that is not JSON, or carries a version this runtime does not mint, is malformed
1007    /// — but only once the MAC has proven it is ours. Minted here with a real key so the check
1008    /// downstream of the MAC is the one being exercised.
1009    #[test]
1010    fn a_non_json_or_wrong_version_payload_is_malformed_behind_the_mac() {
1011        let key = key();
1012        for payload_json in [
1013            b"not json".to_vec(),
1014            br#"{"v":2,"iss":"ses_1","p":"a","exp":99999999999999,"n":"00"}"#.to_vec(),
1015        ] {
1016            let token = mac_token::seal(&key, "mh1", MAC_DOMAIN, &payload_json, &["a@b"]);
1017            assert_eq!(
1018                verify(&key, &token, "a@b", "ses_1"),
1019                Err(HandleError::Malformed)
1020            );
1021        }
1022    }
1023
1024    #[test]
1025    fn the_audience_is_covered_by_the_mac_but_not_carried_in_the_token() {
1026        let key = key();
1027        let payload = payload_for("report.md", "ses_1", in_an_hour());
1028        let one = mint(&key, &payload, "reporter@localhost:1").unwrap();
1029        let two = mint(&key, &payload, "reporter@localhost:2").unwrap();
1030        let (one_payload, two_payload) = (
1031            one.split('.').nth(1).unwrap(),
1032            two.split('.').nth(1).unwrap(),
1033        );
1034        assert_eq!(
1035            one_payload, two_payload,
1036            "the payload segment must not vary with the audience"
1037        );
1038        assert_ne!(one, two, "the MAC must vary with the audience");
1039        let decoded = String::from_utf8(mac_token::payload_segment("mh1", &one).unwrap()).unwrap();
1040        assert!(
1041            !decoded.contains("reporter@localhost:1"),
1042            "the audience must not appear in the payload: {decoded}"
1043        );
1044    }
1045
1046    #[test]
1047    fn two_handles_for_the_same_file_and_audience_are_distinct() {
1048        let key = key();
1049        let plane_nonce_a = mac_token::random_hex(NONCE_BYTES).unwrap();
1050        let plane_nonce_b = mac_token::random_hex(NONCE_BYTES).unwrap();
1051        assert_ne!(plane_nonce_a, plane_nonce_b);
1052        let mut first = payload_for("report.md", "ses_1", in_an_hour());
1053        first.n = plane_nonce_a;
1054        let mut second = payload_for("report.md", "ses_1", in_an_hour());
1055        second.n = plane_nonce_b;
1056        assert_ne!(
1057            mint(&key, &first, "a@b").unwrap(),
1058            mint(&key, &second, "a@b").unwrap()
1059        );
1060    }
1061
1062    #[test]
1063    fn a_handle_id_is_sixteen_lowercase_hex_characters() {
1064        let id = handle_id("mh1.abc.def");
1065        assert_eq!(id.len(), 16);
1066        assert!(id
1067            .chars()
1068            .all(|c| c.is_ascii_hexdigit() && !c.is_uppercase()));
1069        assert_eq!(id, handle_id("mh1.abc.def"), "handle_id must be stable");
1070        assert_ne!(id, handle_id("mh1.abc.deg"));
1071    }
1072
1073    /// A card that is an A2A `AgentCard` in every respect `audience_from_card` reads.
1074    fn card_with_interfaces(name: &str, interfaces: serde_json::Value) -> serde_json::Value {
1075        serde_json::json!({ "name": name, "supportedInterfaces": interfaces })
1076    }
1077
1078    #[test]
1079    fn an_audience_comes_from_the_peers_own_card() {
1080        let card = card_with_interfaces(
1081            "Reporter",
1082            serde_json::json!([{
1083                "url": "http://localhost:41234/",
1084                "protocolBinding": "JSONRPC",
1085                "protocolVersion": "0.3",
1086            }]),
1087        );
1088        assert_eq!(
1089            audience_from_card(&card).unwrap(),
1090            "reporter@localhost:41234"
1091        );
1092    }
1093
1094    #[test]
1095    fn a_card_without_a_name_or_a_jsonrpc_url_is_refused() {
1096        let jsonrpc = serde_json::json!([{
1097            "url": "http://localhost:1",
1098            "protocolBinding": "JSONRPC",
1099            "protocolVersion": "0.3",
1100        }]);
1101        for card in [
1102            serde_json::json!({"name": "a"}),
1103            card_with_interfaces("a", serde_json::json!([])),
1104            card_with_interfaces(
1105                "a",
1106                serde_json::json!([{
1107                    "url": "https://localhost:1",
1108                    "protocolBinding": "GRPC",
1109                    "protocolVersion": "1.0",
1110                }]),
1111            ),
1112            card_with_interfaces(
1113                "a",
1114                serde_json::json!([{"url": "", "protocolBinding": "JSONRPC", "protocolVersion": "0.3"}]),
1115            ),
1116            serde_json::json!({"supportedInterfaces": jsonrpc}),
1117            card_with_interfaces("", jsonrpc.clone()),
1118            serde_json::json!("not an object"),
1119            serde_json::json!({"name": "a", "url": "localhost:1"}),
1120        ] {
1121            assert!(audience_from_card(&card).is_err(), "card: {card}");
1122        }
1123    }
1124
1125    #[test]
1126    fn a_refusal_names_what_the_card_lacks() {
1127        assert_eq!(
1128            audience_from_card(&serde_json::json!({"url": "localhost:1"})).unwrap_err(),
1129            "the peer's agent card carries no 'name'"
1130        );
1131        assert!(
1132            audience_from_card(&serde_json::json!({"name": "a", "url": "localhost:1"}))
1133                .unwrap_err()
1134                .contains("'supportedInterfaces' entry with 'protocolBinding' JSONRPC"),
1135        );
1136    }
1137
1138    #[test]
1139    fn own_audience_matches_what_a_peer_would_derive_from_the_card() {
1140        for capsule_url in ["localhost:41234", "http://127.0.0.1:41234"] {
1141            let identity = CapsuleIdentity {
1142                capsule_name: "Reporter".to_string(),
1143                capsule_version: "0.1.0".to_string(),
1144                session_id: "ses_1".to_string(),
1145                capsule_url: capsule_url.to_string(),
1146            };
1147            let card = crate::identity::build_agent_card(
1148                &identity,
1149                &[],
1150                &crate::types::CapabilityPolicy::default(),
1151                &murmur_artifact::TaskAcceptance::Single,
1152                crate::identity::DeclaredPlanes::default(),
1153                false,
1154                crate::identity::TransportCapabilities {
1155                    streams_text: true,
1156                    kind: crate::identity::TransportKind::Http,
1157                },
1158            );
1159            assert_eq!(own_audience(&identity), audience_from_card(&card).unwrap());
1160        }
1161        let identity = CapsuleIdentity {
1162            capsule_name: "Reporter".to_string(),
1163            capsule_version: "0.1.0".to_string(),
1164            session_id: "ses_1".to_string(),
1165            capsule_url: "localhost:41234".to_string(),
1166        };
1167        assert_eq!(own_audience(&identity), "reporter@localhost:41234");
1168    }
1169
1170    #[test]
1171    fn every_peer_error_has_the_status_the_wire_contract_states() {
1172        let expected: &[(PeerError, u16, &str)] = &[
1173            (PeerError::NoPeerPlane, 404, "no_peer_plane"),
1174            (PeerError::MalformedHandle, 400, "malformed_handle"),
1175            (PeerError::MissingAudience, 400, "missing_audience"),
1176            (PeerError::HandleNotValid, 403, "handle_not_valid"),
1177            (PeerError::HandleExpired, 410, "handle_expired"),
1178            (PeerError::NotFound, 404, "not_found"),
1179            (PeerError::OutsideRoot, 403, "outside_root"),
1180            (PeerError::SymlinkRefused, 403, "symlink_refused"),
1181            (PeerError::NotARegularFile, 403, "not_a_regular_file"),
1182            (PeerError::TooLarge { max_bytes: 1 }, 413, "too_large"),
1183            (PeerError::MethodNotAllowed, 405, "method_not_allowed"),
1184            (PeerError::IoError("x".to_string()), 500, "io_error"),
1185        ];
1186        for (error, status, code) in expected {
1187            assert_eq!(error.status(), *status, "{code}");
1188            assert_eq!(error.code(), *code);
1189            assert!(!error.message().is_empty(), "{code}");
1190        }
1191    }
1192
1193    #[test]
1194    fn a_handle_never_survives_the_trace_boundary() {
1195        let key = key();
1196        let token = mint(
1197            &key,
1198            &payload_for("report.md", "ses_1", in_an_hour()),
1199            "a@b",
1200        )
1201        .unwrap();
1202        let id = handle_id(&token);
1203
1204        let sentence = format!("fetching {token} from the peer.");
1205        let redacted = redact_handle_tokens(&sentence);
1206        assert_eq!(redacted, format!("fetching <handle:{id}> from the peer."));
1207        assert!(!redacted.contains("mh1."));
1208
1209        // Two in one string, and one at the very end with nothing after it.
1210        let pair = format!("{token} and {token}");
1211        assert_eq!(
1212            redact_handle_tokens(&pair),
1213            format!("<handle:{id}> and <handle:{id}>")
1214        );
1215
1216        // Text that merely mentions the tag is left alone and not copied.
1217        assert!(matches!(
1218            redact_handle_tokens("no handle here, just mh1 as a word"),
1219            std::borrow::Cow::Borrowed(_)
1220        ));
1221        assert!(matches!(
1222            redact_handle_tokens("nothing at all"),
1223            std::borrow::Cow::Borrowed(_)
1224        ));
1225        // Non-ASCII around a token must not panic on a mid-codepoint index.
1226        let dashed = format!("— {token} —");
1227        assert_eq!(redact_handle_tokens(&dashed), format!("— <handle:{id}> —"));
1228    }
1229
1230    #[test]
1231    fn a_handle_never_survives_the_trace_boundary_in_json() {
1232        let key = key();
1233        let token = mint(
1234            &key,
1235            &payload_for("report.md", "ses_1", in_an_hour()),
1236            "a@b",
1237        )
1238        .unwrap();
1239        let mut value = serde_json::json!({
1240            "peer": "localhost:1",
1241            "handle": token,
1242            "nested": {"list": [token.clone(), "unrelated"]},
1243        });
1244        redact_handles_in_json(&mut value);
1245        let rendered = value.to_string();
1246        assert!(!rendered.contains("mh1."), "{rendered}");
1247        assert!(rendered.contains(&format!("<handle:{}>", handle_id(&token))));
1248        assert_eq!(value["peer"], "localhost:1");
1249        assert_eq!(value["nested"]["list"][1], "unrelated");
1250    }
1251
1252    #[test]
1253    fn a_stored_path_is_runtime_chosen_and_sanitised() {
1254        assert_eq!(
1255            stored_path_for("3f2a", Some("report.md")),
1256            "peer-in/3f2a-report.md"
1257        );
1258        assert_eq!(
1259            stored_path_for("3f2a", Some("../../etc/passwd")),
1260            "peer-in/3f2a-passwd"
1261        );
1262        assert_eq!(stored_path_for("3f2a", Some("..")), "peer-in/3f2a-file");
1263        assert_eq!(stored_path_for("3f2a", Some("")), "peer-in/3f2a-file");
1264        assert_eq!(stored_path_for("3f2a", None), "peer-in/3f2a-file");
1265        assert_eq!(
1266            stored_path_for("3f2a", Some("a b;rm -rf.md")),
1267            "peer-in/3f2a-a_b_rm_-rf.md"
1268        );
1269    }
1270
1271    // ── The plane, against a real filesystem ─────────────────────────────────
1272
1273    fn plane_for(root: &Path, export: PeerFilesExport) -> PeerPlane {
1274        PeerPlane::new(
1275            root,
1276            Some((&export, Arc::new(key()))),
1277            "ses_test".to_string(),
1278            ContainmentClass::Sealed,
1279            Arc::new(AtomicU64::new(0)),
1280            None,
1281        )
1282    }
1283
1284    /// The deny case: a plane that exists — so a refusal still has somewhere to be recorded — with
1285    /// nothing declared to serve from.
1286    fn undeclared_plane() -> PeerPlane {
1287        PeerPlane::new(
1288            Path::new("/nonexistent"),
1289            None,
1290            "ses_test".to_string(),
1291            ContainmentClass::Sealed,
1292            Arc::new(AtomicU64::new(0)),
1293            None,
1294        )
1295    }
1296
1297    fn peer_export(root: &str) -> PeerFilesExport {
1298        PeerFilesExport {
1299            root: root.to_string(),
1300            max_ttl_secs: Some(60),
1301            max_bytes: 1024 * 1024,
1302        }
1303    }
1304
1305    #[test]
1306    fn a_mint_names_a_file_under_the_root_and_redeems_to_its_bytes() {
1307        let dir = tempfile::tempdir().unwrap();
1308        fs::create_dir_all(dir.path().join("out")).unwrap();
1309        fs::write(dir.path().join("out/report.md"), b"known bytes").unwrap();
1310        let plane = plane_for(dir.path(), peer_export("out/"));
1311
1312        let minted = plane.mint_handle("report.md", "a@b", None).unwrap();
1313        assert_eq!(minted.path, "report.md");
1314        assert!(minted.handle.starts_with("mh1."));
1315
1316        let runtime = tokio::runtime::Builder::new_current_thread()
1317            .enable_all()
1318            .build()
1319            .unwrap();
1320        let (payload, read) = runtime
1321            .block_on(plane.redeem(&minted.handle, Some("a@b")))
1322            .unwrap();
1323        assert_eq!(payload.p, "report.md");
1324        assert_eq!(read.bytes, b"known bytes");
1325    }
1326
1327    #[test]
1328    fn a_mint_cannot_name_anything_outside_the_declared_subtree() {
1329        let dir = tempfile::tempdir().unwrap();
1330        fs::create_dir_all(dir.path().join("out/handoff")).unwrap();
1331        fs::write(dir.path().join("out/handoff/ok.md"), b"fine").unwrap();
1332        fs::write(dir.path().join("out/secret.md"), b"SECRET").unwrap();
1333        fs::write(dir.path().join("secret-at-top.md"), b"SECRET").unwrap();
1334        std::os::unix::fs::symlink("../secret.md", dir.path().join("out/handoff/escape.md"))
1335            .unwrap();
1336        let plane = plane_for(dir.path(), peer_export("out/handoff/"));
1337
1338        for attempt in [
1339            "../secret.md",
1340            "%2e%2e%2fsecret.md",
1341            "/etc/passwd",
1342            "escape.md",
1343            "handoff/../../secret-at-top.md",
1344        ] {
1345            let error = plane
1346                .mint_handle(attempt, "a@b", None)
1347                .expect_err("'{attempt}' must not mint");
1348            assert!(
1349                matches!(error, PeerError::OutsideRoot | PeerError::SymlinkRefused),
1350                "'{attempt}' produced {error:?}"
1351            );
1352        }
1353        assert!(plane.mint_handle("ok.md", "a@b", None).is_ok());
1354    }
1355
1356    #[test]
1357    fn a_mint_refuses_a_directory_rather_than_handing_out_a_handle_that_cannot_redeem() {
1358        let dir = tempfile::tempdir().unwrap();
1359        fs::create_dir_all(dir.path().join("out/nested")).unwrap();
1360        let plane = plane_for(dir.path(), peer_export("out/"));
1361        assert_eq!(
1362            plane.mint_handle("nested", "a@b", None).unwrap_err(),
1363            PeerError::NotARegularFile
1364        );
1365        assert_eq!(
1366            plane.mint_handle("missing.md", "a@b", None).unwrap_err(),
1367            PeerError::NotFound
1368        );
1369    }
1370
1371    #[test]
1372    fn a_ttl_may_only_narrow() {
1373        let dir = tempfile::tempdir().unwrap();
1374        fs::create_dir_all(dir.path().join("out")).unwrap();
1375        fs::write(dir.path().join("out/report.md"), b"x").unwrap();
1376        let plane = plane_for(dir.path(), peer_export("out/"));
1377
1378        // Bracketed by clock readings taken either side of each mint, so the assertion is exact
1379        // rather than slack-tolerant: a loaded machine makes the mint slower, not the ceiling
1380        // looser.
1381        let before = timestamp_ms();
1382        let clamped = plane.mint_handle("report.md", "a@b", Some(86_400)).unwrap();
1383        let after = timestamp_ms();
1384        assert!(
1385            (before + 60_000..=after + 60_000).contains(&clamped.expires_at_ms),
1386            "a ttl above max_ttl must be clamped down to it"
1387        );
1388
1389        let before = timestamp_ms();
1390        let narrowed = plane.mint_handle("report.md", "a@b", Some(5)).unwrap();
1391        let after = timestamp_ms();
1392        assert!(
1393            (before + 5_000..=after + 5_000).contains(&narrowed.expires_at_ms),
1394            "a ttl below max_ttl is applied as given"
1395        );
1396
1397        let before = timestamp_ms();
1398        let defaulted = plane.mint_handle("report.md", "a@b", None).unwrap();
1399        let after = timestamp_ms();
1400        assert!(
1401            (before + 60_000..=after + 60_000).contains(&defaulted.expires_at_ms),
1402            "an absent ttl means max_ttl"
1403        );
1404    }
1405
1406    /// A handle authorises a file, not a version of one: the second redeem serves the newer bytes
1407    /// and a different `etag`, and never a conflict.
1408    #[test]
1409    fn a_rewritten_file_redeems_to_its_current_bytes() {
1410        let dir = tempfile::tempdir().unwrap();
1411        fs::create_dir_all(dir.path().join("out")).unwrap();
1412        fs::write(dir.path().join("out/report.md"), b"first").unwrap();
1413        let plane = plane_for(dir.path(), peer_export("out/"));
1414        let minted = plane.mint_handle("report.md", "a@b", None).unwrap();
1415
1416        let runtime = tokio::runtime::Builder::new_current_thread()
1417            .enable_all()
1418            .build()
1419            .unwrap();
1420        let (_, first) = runtime
1421            .block_on(plane.redeem(&minted.handle, Some("a@b")))
1422            .unwrap();
1423        assert_eq!(first.bytes, b"first");
1424
1425        fs::write(dir.path().join("out/report.md.tmp"), b"second and longer").unwrap();
1426        fs::rename(
1427            dir.path().join("out/report.md.tmp"),
1428            dir.path().join("out/report.md"),
1429        )
1430        .unwrap();
1431
1432        let (_, second) = runtime
1433            .block_on(plane.redeem(&minted.handle, Some("a@b")))
1434            .unwrap();
1435        assert_eq!(second.bytes, b"second and longer");
1436        assert_ne!(first.sha256, second.sha256);
1437    }
1438
1439    #[test]
1440    fn a_redeem_is_idempotent() {
1441        let dir = tempfile::tempdir().unwrap();
1442        fs::create_dir_all(dir.path().join("out")).unwrap();
1443        fs::write(dir.path().join("out/report.md"), b"same bytes").unwrap();
1444        let plane = plane_for(dir.path(), peer_export("out/"));
1445        let minted = plane.mint_handle("report.md", "a@b", None).unwrap();
1446
1447        let runtime = tokio::runtime::Builder::new_current_thread()
1448            .enable_all()
1449            .build()
1450            .unwrap();
1451        let first = runtime
1452            .block_on(plane.redeem(&minted.handle, Some("a@b")))
1453            .unwrap()
1454            .1;
1455        for _ in 0..4 {
1456            let again = runtime
1457                .block_on(plane.redeem(&minted.handle, Some("a@b")))
1458                .unwrap()
1459                .1;
1460            assert_eq!(again.bytes, first.bytes);
1461            assert_eq!(again.sha256, first.sha256);
1462        }
1463    }
1464
1465    #[test]
1466    fn a_file_above_max_bytes_is_refused() {
1467        let dir = tempfile::tempdir().unwrap();
1468        fs::create_dir_all(dir.path().join("out")).unwrap();
1469        fs::write(dir.path().join("out/big.bin"), vec![0u8; 64]).unwrap();
1470        let plane = plane_for(
1471            dir.path(),
1472            PeerFilesExport {
1473                root: "out/".to_string(),
1474                max_ttl_secs: Some(60),
1475                max_bytes: 16,
1476            },
1477        );
1478        let minted = plane.mint_handle("big.bin", "a@b", None).unwrap();
1479        let runtime = tokio::runtime::Builder::new_current_thread()
1480            .enable_all()
1481            .build()
1482            .unwrap();
1483        assert_eq!(
1484            runtime
1485                .block_on(plane.redeem(&minted.handle, Some("a@b")))
1486                .unwrap_err(),
1487            PeerError::TooLarge { max_bytes: 16 }
1488        );
1489    }
1490
1491    // ── Routing ──────────────────────────────────────────────────────────────
1492
1493    #[test]
1494    fn only_the_peer_segment_routes_to_the_peer_plane() {
1495        assert!(is_peer_path("/resources/peer"));
1496        assert!(is_peer_path("/resources/peer/"));
1497        assert!(is_peer_path("/resources/peer/mh1.a.b"));
1498        assert!(is_peer_path("/resources/peer?x=1"));
1499        assert!(!is_peer_path("/resources/peerless/x"));
1500        assert!(!is_peer_path("/resources/files/report.md"));
1501        assert!(!is_peer_path("/"));
1502    }
1503
1504    fn respond(
1505        plane: &PeerPlane,
1506        method: &str,
1507        path: &str,
1508        audience: Option<&str>,
1509    ) -> ResourceResponse {
1510        tokio::runtime::Builder::new_current_thread()
1511            .enable_all()
1512            .build()
1513            .unwrap()
1514            .block_on(handle_peer_request(plane, method, path, audience))
1515    }
1516
1517    fn body_error(response: &ResourceResponse) -> serde_json::Value {
1518        serde_json::from_slice(&response.body).unwrap()
1519    }
1520
1521    #[test]
1522    fn an_undeclared_capsule_answers_no_peer_plane_and_mints_nothing() {
1523        let plane = undeclared_plane();
1524        assert!(!plane.is_declared());
1525
1526        let response = respond(&plane, "GET", "/resources/peer/mh1.YWJj.YWJj", Some("a@b"));
1527        assert_eq!(response.status, 404);
1528        assert_eq!(body_error(&response)["error"], "no_peer_plane");
1529
1530        assert_eq!(
1531            plane.mint_handle("report.md", "a@b", None).unwrap_err(),
1532            PeerError::NoPeerPlane
1533        );
1534    }
1535
1536    #[test]
1537    fn there_is_no_list_verb() {
1538        let plane = undeclared_plane();
1539        for path in ["/resources/peer", "/resources/peer/"] {
1540            let response = respond(&plane, "GET", path, Some("a@b"));
1541            assert_eq!(response.status, 404, "{path}");
1542            assert_eq!(body_error(&response)["error"], "not_found", "{path}");
1543            let body = String::from_utf8_lossy(&response.body);
1544            assert!(
1545                !body.contains('['),
1546                "a listing must not be reachable: {body}"
1547            );
1548        }
1549    }
1550
1551    #[test]
1552    fn every_write_verb_is_refused_with_an_allow_header() {
1553        let plane = undeclared_plane();
1554        for method in ["POST", "PUT", "PATCH", "DELETE", "HEAD"] {
1555            let response = respond(&plane, method, "/resources/peer/mh1.YWJj.YWJj", Some("a@b"));
1556            assert_eq!(response.status, 405, "{method}");
1557            assert_eq!(body_error(&response)["error"], "method_not_allowed");
1558            assert!(response
1559                .headers
1560                .iter()
1561                .any(|(name, value)| name == "allow" && value == "GET"));
1562        }
1563    }
1564
1565    #[test]
1566    fn a_declared_plane_serves_a_redeem_and_refuses_every_tampered_form_identically() {
1567        let dir = tempfile::tempdir().unwrap();
1568        fs::create_dir_all(dir.path().join("out")).unwrap();
1569        fs::write(dir.path().join("out/report.md"), b"known bytes").unwrap();
1570        let plane = plane_for(dir.path(), peer_export("out/"));
1571        let minted = plane.mint_handle("report.md", "a@b", None).unwrap();
1572
1573        let ok = respond(
1574            &plane,
1575            "GET",
1576            &format!("/resources/peer/{}", minted.handle),
1577            Some("a@b"),
1578        );
1579        assert_eq!(ok.status, 200);
1580        assert_eq!(ok.body, b"known bytes");
1581        assert!(ok
1582            .headers
1583            .iter()
1584            .any(|(name, value)| name == HANDLE_ID_HEADER && value == &minted.handle_id));
1585        assert!(
1586            !ok.headers
1587                .iter()
1588                .any(|(name, _)| name == "x-murmur-export-root"),
1589            "the peer plane discloses no path structure"
1590        );
1591
1592        let bad_payload = tamper_segment(&minted.handle, 1);
1593        let bad_mac = tamper_segment(&minted.handle, 2);
1594
1595        let refusals = [
1596            respond(
1597                &plane,
1598                "GET",
1599                &format!("/resources/peer/{bad_payload}"),
1600                Some("a@b"),
1601            ),
1602            respond(
1603                &plane,
1604                "GET",
1605                &format!("/resources/peer/{bad_mac}"),
1606                Some("a@b"),
1607            ),
1608            respond(
1609                &plane,
1610                "GET",
1611                &format!("/resources/peer/{}", minted.handle),
1612                Some("attacker@localhost:1"),
1613            ),
1614        ];
1615        for refusal in &refusals {
1616            assert_eq!(refusal.status, 403);
1617            assert_eq!(refusal.body, refusals[0].body);
1618            assert_eq!(body_error(refusal)["error"], "handle_not_valid");
1619            assert!(
1620                !String::from_utf8_lossy(&refusal.body).contains("known bytes"),
1621                "a refusal must serve none of the file's bytes"
1622            );
1623        }
1624
1625        let no_audience = respond(
1626            &plane,
1627            "GET",
1628            &format!("/resources/peer/{}", minted.handle),
1629            None,
1630        );
1631        assert_eq!(no_audience.status, 400);
1632        assert_eq!(body_error(&no_audience)["error"], "missing_audience");
1633
1634        let not_a_token = respond(&plane, "GET", "/resources/peer/report.md", Some("a@b"));
1635        assert_eq!(not_a_token.status, 400);
1636        assert_eq!(body_error(&not_a_token)["error"], "malformed_handle");
1637    }
1638
1639    /// The audience header is required *before* the MAC is checked, so an omitted header and a
1640    /// wrong one are different answers — but neither reveals anything about the file.
1641    #[test]
1642    fn a_malformed_shape_is_reported_before_the_audience_is_required() {
1643        let dir = tempfile::tempdir().unwrap();
1644        fs::create_dir_all(dir.path().join("out")).unwrap();
1645        let plane = plane_for(dir.path(), peer_export("out/"));
1646        let response = respond(&plane, "GET", "/resources/peer/not-a-token", None);
1647        assert_eq!(body_error(&response)["error"], "malformed_handle");
1648    }
1649
1650    #[test]
1651    fn an_expired_handle_is_refused_with_its_own_code() {
1652        let dir = tempfile::tempdir().unwrap();
1653        fs::create_dir_all(dir.path().join("out")).unwrap();
1654        fs::write(dir.path().join("out/report.md"), b"bytes").unwrap();
1655        let plane = plane_for(
1656            dir.path(),
1657            PeerFilesExport {
1658                root: "out/".to_string(),
1659                max_ttl_secs: Some(1),
1660                max_bytes: 1024,
1661            },
1662        );
1663        let minted = plane.mint_handle("report.md", "a@b", Some(1)).unwrap();
1664        std::thread::sleep(std::time::Duration::from_millis(1100));
1665        let response = respond(
1666            &plane,
1667            "GET",
1668            &format!("/resources/peer/{}", minted.handle),
1669            Some("a@b"),
1670        );
1671        assert_eq!(response.status, 410);
1672        assert_eq!(body_error(&response)["error"], "handle_expired");
1673    }
1674}