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///
298/// Both sides compute the same string without exchanging it, because both compute it from *the
299/// fetching capsule's own advertised identity* — the minter reads it off the card it fetched, and
300/// the fetcher asserts it from the same two fields of its own identity.
301pub fn audience_from_card(card: &serde_json::Value) -> Result<String, String> {
302    let name = card
303        .get("name")
304        .and_then(serde_json::Value::as_str)
305        .filter(|name| !name.trim().is_empty())
306        .ok_or_else(|| "the peer's agent card carries no 'name'".to_string())?;
307    let url = card
308        .get("url")
309        .and_then(serde_json::Value::as_str)
310        .filter(|url| !url.trim().is_empty())
311        .ok_or_else(|| "the peer's agent card carries no 'url'".to_string())?;
312    let host_port = parse_host_port(url)?;
313    Ok(format!("{}@{}", name.trim(), host_port).to_lowercase())
314}
315
316/// This capsule's own audience, asserted on every redeem it issues.
317///
318/// Built from the same two fields `build_agent_card` publishes, so what a peer minted for and
319/// what this runtime asserts are the same string by construction.
320pub(crate) fn own_audience(identity: &CapsuleIdentity) -> String {
321    let host_port =
322        parse_host_port(&identity.capsule_url).unwrap_or_else(|_| identity.capsule_url.clone());
323    format!("{}@{}", identity.capsule_name, host_port).to_lowercase()
324}
325
326// ── Errors on the wire ────────────────────────────────────────────────────────
327
328/// Every way a redeem can be refused, in the vocabulary the caller and the trace share.
329#[derive(Debug, Clone, PartialEq, Eq)]
330pub enum PeerError {
331    /// The manifest declares no `exports.peer_files`. Absent means deny.
332    NoPeerPlane,
333    MalformedHandle,
334    MissingAudience,
335    /// The MAC did not verify — for any reason. Deliberately indivisible; see [`verify`].
336    HandleNotValid,
337    HandleExpired,
338    NotFound,
339    OutsideRoot,
340    SymlinkRefused,
341    NotARegularFile,
342    TooLarge {
343        max_bytes: u64,
344    },
345    MethodNotAllowed,
346    IoError(String),
347}
348
349impl PeerError {
350    /// The stable `error` string in the JSON body and the `outcome` in the trace — one vocabulary
351    /// for both, so a refusal an auditor reads is spelled the way the caller saw it.
352    pub fn code(&self) -> &'static str {
353        match self {
354            PeerError::NoPeerPlane => "no_peer_plane",
355            PeerError::MalformedHandle => "malformed_handle",
356            PeerError::MissingAudience => "missing_audience",
357            PeerError::HandleNotValid => "handle_not_valid",
358            PeerError::HandleExpired => "handle_expired",
359            PeerError::NotFound => "not_found",
360            PeerError::OutsideRoot => "outside_root",
361            PeerError::SymlinkRefused => "symlink_refused",
362            PeerError::NotARegularFile => "not_a_regular_file",
363            PeerError::TooLarge { .. } => "too_large",
364            PeerError::MethodNotAllowed => "method_not_allowed",
365            PeerError::IoError(_) => "io_error",
366        }
367    }
368
369    pub fn status(&self) -> u16 {
370        match self {
371            PeerError::NoPeerPlane | PeerError::NotFound => 404,
372            PeerError::MalformedHandle | PeerError::MissingAudience => 400,
373            PeerError::HandleNotValid
374            | PeerError::OutsideRoot
375            | PeerError::SymlinkRefused
376            | PeerError::NotARegularFile => 403,
377            PeerError::HandleExpired => 410,
378            PeerError::TooLarge { .. } => 413,
379            PeerError::MethodNotAllowed => 405,
380            PeerError::IoError(_) => 500,
381        }
382    }
383
384    /// One sentence per code. [`PeerError::HandleNotValid`]'s is a fixed string with nothing
385    /// interpolated into it: three different causes must produce byte-identical bodies.
386    pub fn message(&self) -> String {
387        match self {
388            PeerError::NoPeerPlane => {
389                "this capsule declares no exports.peer_files block, so it has no peer plane"
390                    .to_string()
391            }
392            PeerError::MalformedHandle => "the handle is not a well-formed mh1 token".to_string(),
393            PeerError::MissingAudience => {
394                format!("a redeem must assert its audience in the {AUDIENCE_HEADER} header")
395            }
396            PeerError::HandleNotValid => "the handle is not valid for this capsule".to_string(),
397            PeerError::HandleExpired => "the handle has expired".to_string(),
398            PeerError::NotFound => "no such file under the peer export root".to_string(),
399            PeerError::OutsideRoot => {
400                "the handle names a path outside the peer export root".to_string()
401            }
402            PeerError::SymlinkRefused => {
403                "a symlink was encountered and the achieved containment class is scoped".to_string()
404            }
405            PeerError::NotARegularFile => "the handle does not name a regular file".to_string(),
406            PeerError::TooLarge { max_bytes } => {
407                format!(
408                    "file exceeds the declared exports.peer_files.max_bytes of {max_bytes} bytes"
409                )
410            }
411            PeerError::MethodNotAllowed => {
412                "the peer plane serves GET only; it has no write path and no list verb".to_string()
413            }
414            PeerError::IoError(detail) => format!("read failed: {detail}"),
415        }
416    }
417}
418
419impl From<MacTokenError> for HandleError {
420    fn from(error: MacTokenError) -> Self {
421        match error {
422            MacTokenError::Malformed => HandleError::Malformed,
423            MacTokenError::NotValid => HandleError::NotValid,
424        }
425    }
426}
427
428impl From<HandleError> for PeerError {
429    fn from(error: HandleError) -> Self {
430        match error {
431            HandleError::Malformed => PeerError::MalformedHandle,
432            HandleError::NotValid => PeerError::HandleNotValid,
433            HandleError::Expired => PeerError::HandleExpired,
434        }
435    }
436}
437
438impl From<ResourceError> for PeerError {
439    /// The reader's refusals, restated in the peer plane's vocabulary.
440    ///
441    /// `NoResourcePlane` and `MethodNotAllowed` cannot arrive here — the reader is called with a
442    /// declared export and the method was settled before routing — but they are mapped rather
443    /// than panicked on so a later caller cannot turn a wrong call into a crash.
444    fn from(error: ResourceError) -> Self {
445        match error {
446            ResourceError::NotFound => PeerError::NotFound,
447            ResourceError::OutsideRoot => PeerError::OutsideRoot,
448            ResourceError::SymlinkRefused => PeerError::SymlinkRefused,
449            ResourceError::NotARegularFile => PeerError::NotARegularFile,
450            ResourceError::TooLarge { max_bytes } => PeerError::TooLarge { max_bytes },
451            ResourceError::MethodNotAllowed => PeerError::MethodNotAllowed,
452            ResourceError::NoResourcePlane => PeerError::NoPeerPlane,
453            ResourceError::IoError(detail) => PeerError::IoError(detail),
454        }
455    }
456}
457
458// ── The plane ─────────────────────────────────────────────────────────────────
459
460/// One minted handle, as `share-file` returns it.
461#[derive(Debug, Clone, PartialEq, Eq)]
462pub struct MintedHandle {
463    pub handle: String,
464    pub handle_id: String,
465    pub expires_at_ms: u64,
466    pub audience: String,
467    /// The canonical root-relative path the handle names. Recorded in the mint trace event; never
468    /// returned to the agent, which asked for it and does not need it echoed as a fact.
469    pub path: String,
470}
471
472/// The declared half of a peer plane: the subtree, the key and the lifetime ceiling.
473struct DeclaredPeerExport {
474    /// The peer subtree, root resolved against the session's accessible workdir.
475    export: DeclaredExport,
476    /// The instance key. Shared as an `Arc` because the listener and the agent loop both hold the
477    /// plane, and destroyed when the last of them goes.
478    key: Arc<PeerMintKey>,
479    /// Ceiling on a minted handle's lifetime, in seconds. A `ttl` argument may only narrow.
480    max_ttl_secs: u64,
481}
482
483/// Everything a mint or a redeem needs, and nothing a running task leaves behind.
484///
485/// Always built, declared or not — the same shape [`crate::resource_plane::ResourcePlane`] has,
486/// and for the same reason: an undeclared capsule still has to *record* the redeem it refused, and
487/// a plane that does not exist has nowhere to write.
488pub struct PeerPlane {
489    /// `None` means the capsule declared no `exports.peer_files`, which is the deny case: every
490    /// redeem answers `no_peer_plane`, nothing mints, and no `share-file` tool manifest was ever
491    /// written for the agent to see.
492    declared: Option<DeclaredPeerExport>,
493    /// The minting session's id, written into every payload's `iss` and compared on every redeem.
494    session_id: String,
495    /// The operator plane's counter, shared: a redeem reports the same provenance a read does.
496    generation: Arc<AtomicU64>,
497    /// The class this session actually achieved. Keys the symlink decision and rides on every
498    /// response.
499    containment_achieved: ContainmentClass,
500    /// `None` when there is nowhere to write a record — a plane built outside a session.
501    trace: Option<Arc<ResourceTraceAppender>>,
502}
503
504impl PeerPlane {
505    /// Everything a plane needs, and the complete list of it: a host path, the declared
506    /// export (`None` = undeclared = deny) paired with the instance key, the session id, the
507    /// achieved class, the generation counter and somewhere to write the record.
508    pub fn new(
509        accessible_workdir: &Path,
510        export: Option<(&PeerFilesExport, Arc<PeerMintKey>)>,
511        session_id: String,
512        containment_achieved: ContainmentClass,
513        generation: Arc<AtomicU64>,
514        trace: Option<Arc<ResourceTraceAppender>>,
515    ) -> Self {
516        Self {
517            declared: export.map(|(export, key)| DeclaredPeerExport {
518                export: DeclaredExport::for_peer_files(accessible_workdir, export),
519                key,
520                max_ttl_secs: export.effective_max_ttl_secs(),
521            }),
522            session_id,
523            generation,
524            containment_achieved,
525            trace,
526        }
527    }
528
529    /// Whether this capsule declared `exports.peer_files` at all.
530    pub fn is_declared(&self) -> bool {
531        self.declared.is_some()
532    }
533
534    pub fn generation(&self) -> u64 {
535        self.generation.load(Ordering::SeqCst)
536    }
537
538    /// Mints a handle for one file under `exports.peer_files.root`, for one audience.
539    ///
540    /// `relpath` is relative to the export root and is resolved by the same code that resolves a
541    /// read, so a path that escapes — `..`, absolute, percent-encoded, or a symlink leaving the
542    /// root — fails the mint. It is refused, never normalised into something mintable.
543    ///
544    /// `ttl_secs` may only narrow: a value above the declared `max_ttl` is clamped down, never up,
545    /// and `None` means the declared `max_ttl`.
546    pub fn mint_handle(
547        &self,
548        relpath: &str,
549        audience: &str,
550        ttl_secs: Option<u64>,
551    ) -> Result<MintedHandle, PeerError> {
552        let declared = self.declared.as_ref().ok_or(PeerError::NoPeerPlane)?;
553        let policy = symlink_policy(self.containment_achieved);
554        let (target, canonical_relpath) =
555            resolve_relpath_beneath_root(&declared.export, relpath, policy)?;
556        // Resolved beneath the root, but not yet known to be servable: a directory or a fifo
557        // under the root would otherwise mint a handle that can only ever be refused.
558        let metadata = std::fs::metadata(&target).map_err(|error| match error.kind() {
559            std::io::ErrorKind::NotFound => PeerError::NotFound,
560            _ => PeerError::IoError(error.to_string()),
561        })?;
562        if !metadata.is_file() {
563            return Err(PeerError::NotARegularFile);
564        }
565
566        let ttl = ttl_secs
567            .unwrap_or(declared.max_ttl_secs)
568            .min(declared.max_ttl_secs);
569        let payload = HandlePayload {
570            v: PAYLOAD_VERSION,
571            iss: self.session_id.clone(),
572            p: canonical_relpath.clone(),
573            exp: timestamp_ms().saturating_add(ttl.saturating_mul(1000)),
574            n: mac_token::random_hex(NONCE_BYTES).map_err(PeerError::IoError)?,
575        };
576        let token = mint(&declared.key, &payload, audience).map_err(PeerError::IoError)?;
577        Ok(MintedHandle {
578            handle_id: handle_id(&token),
579            handle: token,
580            expires_at_ms: payload.exp,
581            audience: audience.to_string(),
582            path: canonical_relpath,
583        })
584    }
585
586    /// Redeems one token: verify, then serve the named file's *current* bytes.
587    ///
588    /// Calls the operator plane's shared reader, so it inherits that reader's guarantees
589    /// unchanged — the file is opened once and the bytes, size, mtime and `sha256` all come from
590    /// that single descriptor, so the `etag` always describes the body it accompanies even while
591    /// the agent is rewriting the file. There is no second read path, no re-`stat` and no lock.
592    async fn redeem(
593        &self,
594        token: &str,
595        audience: Option<&str>,
596    ) -> Result<(HandlePayload, ReadResponse), PeerError> {
597        let declared = self.declared.as_ref().ok_or(PeerError::NoPeerPlane)?;
598
599        // Shape first, so a request that is not a token at all is told so without the audience
600        // requirement standing in front of it.
601        check_shape(token).map_err(PeerError::from)?;
602
603        let audience = audience
604            .map(str::trim)
605            .filter(|audience| !audience.is_empty())
606            .ok_or(PeerError::MissingAudience)?;
607
608        let payload = verify(&declared.key, token, audience, &self.session_id)?;
609
610        let export = declared.export.clone();
611        let relpath = payload.p.clone();
612        let policy = symlink_policy(self.containment_achieved);
613        let read = tokio::task::spawn_blocking(move || {
614            read_export_file_with_policy(&export, &relpath, policy)
615        })
616        .await
617        .unwrap_or_else(|join| Err(ResourceError::IoError(join.to_string())))?;
618
619        Ok((payload, read))
620    }
621}
622
623/// Whether `raw_path` addresses the peer plane rather than the operator plane.
624///
625/// Matched on the whole segment, so `/resources/peerless/x` is not a peer request and falls
626/// through to the operator plane's own routing, which refuses it.
627pub fn is_peer_path(raw_path: &str) -> bool {
628    let path = raw_path.split('?').next().unwrap_or("");
629    path == PEER_PATH_PREFIX || path.starts_with(&format!("{PEER_PATH_PREFIX}/"))
630}
631
632/// Answers one peer-plane request: a method, a raw request path, an asserted audience, and
633/// nothing else.
634///
635/// Knows no socket, no framing and no authoriser, on the same terms as the operator plane's
636/// `handle_resource_request`, so binding it on a second listener is a matter of calling exactly
637/// this and writing out the reply.
638pub async fn handle_peer_request(
639    plane: &PeerPlane,
640    method: &str,
641    raw_path: &str,
642    audience: Option<&str>,
643) -> ResourceResponse {
644    // A property of the request alone, so it is settled before anything about this capsule is
645    // consulted: a `PUT` is refused identically whether or not a peer plane is declared.
646    if method != "GET" {
647        return error_response(&PeerError::MethodNotAllowed, None, None);
648    }
649
650    let path = raw_path.split('?').next().unwrap_or("");
651    let rest = path.strip_prefix(PEER_PATH_PREFIX).unwrap_or("");
652    // **There is no `list` verb on the peer plane.** `/resources/peer` and `/resources/peer/` name
653    // no handle, and enumeration is the thing this plane exists to prevent — so they are
654    // `not_found` rather than an empty listing, and there is no handle to trace them against.
655    let Some(token) = rest.strip_prefix('/').filter(|token| !token.is_empty()) else {
656        return error_response(&PeerError::NotFound, None, None);
657    };
658
659    let generation = plane.generation();
660    let hid = handle_id(token);
661
662    match plane.redeem(token, audience).await {
663        Ok((payload, read)) => {
664            if let Some(trace) = &plane.trace {
665                trace
666                    .write_peer_handle_redeem(
667                        &hid,
668                        Some(payload.p.clone()),
669                        generation,
670                        audience.map(str::to_string),
671                        Some(read.bytes.len() as u64),
672                        Some(read.sha256.clone()),
673                        "ok",
674                        None,
675                    )
676                    .await;
677            }
678            ResourceResponse::framed(
679                200,
680                vec![
681                    (
682                        "content-type".to_string(),
683                        "application/octet-stream".to_string(),
684                    ),
685                    ("etag".to_string(), format!("\"sha256:{}\"", read.sha256)),
686                    ("x-murmur-mtime-ms".to_string(), read.mtime_ms.to_string()),
687                    ("x-murmur-generation".to_string(), generation.to_string()),
688                    (
689                        "x-murmur-containment".to_string(),
690                        plane.containment_achieved.to_string(),
691                    ),
692                    (HANDLE_ID_HEADER.to_string(), hid),
693                ],
694                read.bytes,
695            )
696        }
697        Err(error) => {
698            if let Some(trace) = &plane.trace {
699                // A payload that failed the MAC is caller-controlled and must not be copied into
700                // this capsule's own audit record as if it were fact — so `path` is null until the
701                // MAC has proven the payload is ours.
702                trace
703                    .write_peer_handle_redeem(
704                        &hid,
705                        None,
706                        generation,
707                        audience.map(str::to_string),
708                        None,
709                        None,
710                        error.code(),
711                        Some(error.message()),
712                    )
713                    .await;
714            }
715            error_response(&error, Some(generation), Some(hid))
716        }
717    }
718}
719
720fn error_response(
721    error: &PeerError,
722    generation: Option<u64>,
723    handle_id: Option<String>,
724) -> ResourceResponse {
725    let body = serde_json::json!({"error": error.code(), "message": error.message()});
726    let mut headers = vec![("content-type".to_string(), "application/json".to_string())];
727    if let Some(generation) = generation {
728        headers.push(("x-murmur-generation".to_string(), generation.to_string()));
729    }
730    if let Some(handle_id) = handle_id {
731        headers.push((HANDLE_ID_HEADER.to_string(), handle_id));
732    }
733    if matches!(error, PeerError::MethodNotAllowed) {
734        headers.push(("allow".to_string(), "GET".to_string()));
735    }
736    ResourceResponse::framed(
737        error.status(),
738        headers,
739        serde_json::to_vec(&body).unwrap_or_default(),
740    )
741}
742
743// ── Naming a fetched file ─────────────────────────────────────────────────────
744
745/// The workdir-relative path fetched bytes land at: `peer-in/<handle_id>-<sanitised basename>`.
746///
747/// Runtime-chosen, never peer-chosen. The basename is a hint read out of the token's unverified
748/// payload and is sanitised to `[A-Za-z0-9._-]` before use; the `handle_id` prefix is what keeps
749/// two fetches of two different handles from colliding even when the hint is identical or
750/// useless.
751pub fn stored_path_for(handle_id: &str, basename_hint: Option<&str>) -> String {
752    let sanitised: String = basename_hint
753        .and_then(|hint| hint.rsplit('/').next())
754        .map(|name| {
755            name.chars()
756                .map(|c| {
757                    if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' {
758                        c
759                    } else {
760                        '_'
761                    }
762                })
763                .take(120)
764                .collect()
765        })
766        .filter(|name: &String| !name.is_empty() && name != "." && name != "..")
767        .unwrap_or_else(|| "file".to_string());
768    format!("{PEER_INBOX_DIR}/{handle_id}-{sanitised}")
769}
770
771#[cfg(test)]
772mod tests {
773    use super::*;
774    use std::fs;
775
776    fn key() -> PeerMintKey {
777        PeerMintKey::generate().unwrap()
778    }
779
780    /// Substitute the first character of a handle's `nth` dot-separated segment.
781    ///
782    /// The first character, never the last: only a segment's final base64url character carries
783    /// padding bits, and those must be zero to decode. Substituting there can set one, so the
784    /// segment stops decoding and the refusal comes from the parse rather than from the MAC —
785    /// which is a different error than these tests assert.
786    fn tamper_segment(handle: &str, nth: usize) -> String {
787        let mut segments: Vec<String> = handle.split('.').map(str::to_string).collect();
788        let segment = &mut segments[nth];
789        let first = segment.remove(0);
790        segment.insert(0, if first == 'A' { 'B' } else { 'A' });
791        segments.join(".")
792    }
793
794    fn payload_for(path: &str, session: &str, exp: u64) -> HandlePayload {
795        HandlePayload {
796            v: 1,
797            iss: session.to_string(),
798            p: path.to_string(),
799            exp,
800            n: "0123456789abcdef0123456789abcdef".to_string(),
801        }
802    }
803
804    fn in_an_hour() -> u64 {
805        timestamp_ms() + 3_600_000
806    }
807
808    #[test]
809    fn a_minted_handle_round_trips_through_verify() {
810        let key = key();
811        let payload = payload_for("report.md", "ses_1", in_an_hour());
812        let token = mint(&key, &payload, "reporter@localhost:41234").unwrap();
813        assert!(token.starts_with("mh1."));
814        assert_eq!(token.split('.').count(), 3);
815        let verified = verify(&key, &token, "reporter@localhost:41234", "ses_1").unwrap();
816        assert_eq!(verified, payload);
817    }
818
819    #[test]
820    fn a_token_uses_only_url_safe_unpadded_base64() {
821        let key = key();
822        // A payload long enough that padding would be required by the padded alphabet.
823        let token = mint(
824            &key,
825            &payload_for("a/b/c.md", "ses_padding", in_an_hour()),
826            "x@y",
827        )
828        .unwrap();
829        let segments: Vec<&str> = token.split('.').collect();
830        for segment in &segments[1..] {
831            assert!(
832                segment
833                    .chars()
834                    .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
835                "segment '{segment}' must be base64url with no padding"
836            );
837        }
838    }
839
840    #[test]
841    fn a_tampered_payload_is_not_valid() {
842        let key = key();
843        let token = mint(
844            &key,
845            &payload_for("report.md", "ses_1", in_an_hour()),
846            "a@b",
847        )
848        .unwrap();
849        let tampered = tamper_segment(&token, 1);
850        assert_eq!(
851            verify(&key, &tampered, "a@b", "ses_1"),
852            Err(HandleError::NotValid)
853        );
854    }
855
856    #[test]
857    fn a_tampered_mac_is_not_valid() {
858        let key = key();
859        let token = mint(
860            &key,
861            &payload_for("report.md", "ses_1", in_an_hour()),
862            "a@b",
863        )
864        .unwrap();
865        let tampered = tamper_segment(&token, 2);
866        assert_eq!(
867            verify(&key, &tampered, "a@b", "ses_1"),
868            Err(HandleError::NotValid)
869        );
870    }
871
872    #[test]
873    fn the_wrong_audience_is_not_valid() {
874        let key = key();
875        let token = mint(
876            &key,
877            &payload_for("report.md", "ses_1", in_an_hour()),
878            "a@b",
879        )
880        .unwrap();
881        assert_eq!(
882            verify(&key, &token, "attacker@localhost:1", "ses_1"),
883            Err(HandleError::NotValid)
884        );
885    }
886
887    /// The three causes the design requires to be indistinguishable: a tampered payload, a
888    /// foreign key, and a correct handle presented with the wrong audience. Same variant, same
889    /// code, same message, byte for byte.
890    #[test]
891    fn every_refusal_cause_is_indistinguishable_on_the_wire() {
892        let key = key();
893        let other = PeerMintKey::generate().unwrap();
894        let token = mint(
895            &key,
896            &payload_for("report.md", "ses_1", in_an_hour()),
897            "a@b",
898        )
899        .unwrap();
900
901        let mut chars: Vec<char> = token.chars().collect();
902        let payload_end = token.rfind('.').unwrap();
903        chars[payload_end - 1] = if chars[payload_end - 1] == 'A' {
904            'B'
905        } else {
906            'A'
907        };
908        let tampered: String = chars.into_iter().collect();
909
910        let causes = [
911            verify(&key, &tampered, "a@b", "ses_1"),
912            verify(&other, &token, "a@b", "ses_1"),
913            verify(&key, &token, "attacker@localhost:1", "ses_1"),
914        ];
915        for cause in &causes {
916            assert_eq!(*cause, Err(HandleError::NotValid));
917        }
918        let errors: Vec<PeerError> = causes
919            .iter()
920            .map(|cause| PeerError::from(*cause.as_ref().unwrap_err()))
921            .collect();
922        for error in &errors {
923            assert_eq!(error, &PeerError::HandleNotValid);
924            assert_eq!(error.status(), 403);
925            assert_eq!(error.code(), errors[0].code());
926            assert_eq!(error.message(), errors[0].message());
927        }
928    }
929
930    #[test]
931    fn a_handle_from_another_instance_of_the_same_capsule_is_not_valid() {
932        let one = key();
933        let two = key();
934        let token = mint(
935            &one,
936            &payload_for("report.md", "ses_1", in_an_hour()),
937            "a@b",
938        )
939        .unwrap();
940        assert_eq!(
941            verify(&two, &token, "a@b", "ses_1"),
942            Err(HandleError::NotValid)
943        );
944    }
945
946    /// A foreign `iss` with a valid MAC is unreachable in production — only this instance's key
947    /// produces a MAC this instance accepts — but the branch exists, and it must answer with the
948    /// same undivided refusal rather than a code of its own.
949    #[test]
950    fn a_foreign_issuer_is_not_valid_rather_than_a_code_of_its_own() {
951        let key = key();
952        let token = mint(
953            &key,
954            &payload_for("report.md", "ses_other", in_an_hour()),
955            "a@b",
956        )
957        .unwrap();
958        assert_eq!(
959            verify(&key, &token, "a@b", "ses_1"),
960            Err(HandleError::NotValid)
961        );
962    }
963
964    #[test]
965    fn an_expired_handle_reports_expiry() {
966        let key = key();
967        let token = mint(
968            &key,
969            &payload_for("report.md", "ses_1", timestamp_ms() - 1),
970            "a@b",
971        )
972        .unwrap();
973        assert_eq!(
974            verify(&key, &token, "a@b", "ses_1"),
975            Err(HandleError::Expired)
976        );
977    }
978
979    #[test]
980    fn every_malformed_shape_is_malformed() {
981        let key = key();
982        for candidate in [
983            "",
984            "report.md",
985            "mh1",
986            "mh1.",
987            "mh1.abc",
988            "mh1.abc.def.ghi",
989            "mh2.YWJj.YWJj",
990            "mh1.!!!.YWJj",
991            "mh1.YWJj.!!!",
992        ] {
993            assert_eq!(
994                verify(&key, candidate, "a@b", "ses_1"),
995                Err(HandleError::Malformed),
996                "'{candidate}' should be malformed"
997            );
998        }
999    }
1000
1001    /// A payload that is not JSON, or carries a version this runtime does not mint, is malformed
1002    /// — but only once the MAC has proven it is ours. Minted here with a real key so the check
1003    /// downstream of the MAC is the one being exercised.
1004    #[test]
1005    fn a_non_json_or_wrong_version_payload_is_malformed_behind_the_mac() {
1006        let key = key();
1007        for payload_json in [
1008            b"not json".to_vec(),
1009            br#"{"v":2,"iss":"ses_1","p":"a","exp":99999999999999,"n":"00"}"#.to_vec(),
1010        ] {
1011            let token = mac_token::seal(&key, "mh1", MAC_DOMAIN, &payload_json, &["a@b"]);
1012            assert_eq!(
1013                verify(&key, &token, "a@b", "ses_1"),
1014                Err(HandleError::Malformed)
1015            );
1016        }
1017    }
1018
1019    #[test]
1020    fn the_audience_is_covered_by_the_mac_but_not_carried_in_the_token() {
1021        let key = key();
1022        let payload = payload_for("report.md", "ses_1", in_an_hour());
1023        let one = mint(&key, &payload, "reporter@localhost:1").unwrap();
1024        let two = mint(&key, &payload, "reporter@localhost:2").unwrap();
1025        let (one_payload, two_payload) = (
1026            one.split('.').nth(1).unwrap(),
1027            two.split('.').nth(1).unwrap(),
1028        );
1029        assert_eq!(
1030            one_payload, two_payload,
1031            "the payload segment must not vary with the audience"
1032        );
1033        assert_ne!(one, two, "the MAC must vary with the audience");
1034        let decoded = String::from_utf8(mac_token::payload_segment("mh1", &one).unwrap()).unwrap();
1035        assert!(
1036            !decoded.contains("reporter@localhost:1"),
1037            "the audience must not appear in the payload: {decoded}"
1038        );
1039    }
1040
1041    #[test]
1042    fn two_handles_for_the_same_file_and_audience_are_distinct() {
1043        let key = key();
1044        let plane_nonce_a = mac_token::random_hex(NONCE_BYTES).unwrap();
1045        let plane_nonce_b = mac_token::random_hex(NONCE_BYTES).unwrap();
1046        assert_ne!(plane_nonce_a, plane_nonce_b);
1047        let mut first = payload_for("report.md", "ses_1", in_an_hour());
1048        first.n = plane_nonce_a;
1049        let mut second = payload_for("report.md", "ses_1", in_an_hour());
1050        second.n = plane_nonce_b;
1051        assert_ne!(
1052            mint(&key, &first, "a@b").unwrap(),
1053            mint(&key, &second, "a@b").unwrap()
1054        );
1055    }
1056
1057    #[test]
1058    fn a_handle_id_is_sixteen_lowercase_hex_characters() {
1059        let id = handle_id("mh1.abc.def");
1060        assert_eq!(id.len(), 16);
1061        assert!(id
1062            .chars()
1063            .all(|c| c.is_ascii_hexdigit() && !c.is_uppercase()));
1064        assert_eq!(id, handle_id("mh1.abc.def"), "handle_id must be stable");
1065        assert_ne!(id, handle_id("mh1.abc.deg"));
1066    }
1067
1068    #[test]
1069    fn an_audience_comes_from_the_peers_own_card() {
1070        let card = serde_json::json!({"name": "Reporter", "url": "http://localhost:41234/"});
1071        assert_eq!(
1072            audience_from_card(&card).unwrap(),
1073            "reporter@localhost:41234"
1074        );
1075    }
1076
1077    #[test]
1078    fn a_card_without_a_name_or_a_url_is_refused() {
1079        for card in [
1080            serde_json::json!({"url": "localhost:1"}),
1081            serde_json::json!({"name": "a"}),
1082            serde_json::json!({"name": "", "url": "localhost:1"}),
1083            serde_json::json!({"name": "a", "url": ""}),
1084            serde_json::json!("not an object"),
1085        ] {
1086            assert!(audience_from_card(&card).is_err(), "card: {card}");
1087        }
1088    }
1089
1090    #[test]
1091    fn own_audience_matches_what_a_peer_would_derive_from_the_card() {
1092        let identity = CapsuleIdentity {
1093            capsule_name: "Reporter".to_string(),
1094            capsule_version: "0.1.0".to_string(),
1095            session_id: "ses_1".to_string(),
1096            capsule_url: "localhost:41234".to_string(),
1097        };
1098        let card = serde_json::json!({
1099            "name": identity.capsule_name,
1100            "url": identity.capsule_url,
1101        });
1102        assert_eq!(own_audience(&identity), audience_from_card(&card).unwrap());
1103    }
1104
1105    #[test]
1106    fn every_peer_error_has_the_status_the_wire_contract_states() {
1107        let expected: &[(PeerError, u16, &str)] = &[
1108            (PeerError::NoPeerPlane, 404, "no_peer_plane"),
1109            (PeerError::MalformedHandle, 400, "malformed_handle"),
1110            (PeerError::MissingAudience, 400, "missing_audience"),
1111            (PeerError::HandleNotValid, 403, "handle_not_valid"),
1112            (PeerError::HandleExpired, 410, "handle_expired"),
1113            (PeerError::NotFound, 404, "not_found"),
1114            (PeerError::OutsideRoot, 403, "outside_root"),
1115            (PeerError::SymlinkRefused, 403, "symlink_refused"),
1116            (PeerError::NotARegularFile, 403, "not_a_regular_file"),
1117            (PeerError::TooLarge { max_bytes: 1 }, 413, "too_large"),
1118            (PeerError::MethodNotAllowed, 405, "method_not_allowed"),
1119            (PeerError::IoError("x".to_string()), 500, "io_error"),
1120        ];
1121        for (error, status, code) in expected {
1122            assert_eq!(error.status(), *status, "{code}");
1123            assert_eq!(error.code(), *code);
1124            assert!(!error.message().is_empty(), "{code}");
1125        }
1126    }
1127
1128    #[test]
1129    fn a_handle_never_survives_the_trace_boundary() {
1130        let key = key();
1131        let token = mint(
1132            &key,
1133            &payload_for("report.md", "ses_1", in_an_hour()),
1134            "a@b",
1135        )
1136        .unwrap();
1137        let id = handle_id(&token);
1138
1139        let sentence = format!("fetching {token} from the peer.");
1140        let redacted = redact_handle_tokens(&sentence);
1141        assert_eq!(redacted, format!("fetching <handle:{id}> from the peer."));
1142        assert!(!redacted.contains("mh1."));
1143
1144        // Two in one string, and one at the very end with nothing after it.
1145        let pair = format!("{token} and {token}");
1146        assert_eq!(
1147            redact_handle_tokens(&pair),
1148            format!("<handle:{id}> and <handle:{id}>")
1149        );
1150
1151        // Text that merely mentions the tag is left alone and not copied.
1152        assert!(matches!(
1153            redact_handle_tokens("no handle here, just mh1 as a word"),
1154            std::borrow::Cow::Borrowed(_)
1155        ));
1156        assert!(matches!(
1157            redact_handle_tokens("nothing at all"),
1158            std::borrow::Cow::Borrowed(_)
1159        ));
1160        // Non-ASCII around a token must not panic on a mid-codepoint index.
1161        let dashed = format!("— {token} —");
1162        assert_eq!(redact_handle_tokens(&dashed), format!("— <handle:{id}> —"));
1163    }
1164
1165    #[test]
1166    fn a_handle_never_survives_the_trace_boundary_in_json() {
1167        let key = key();
1168        let token = mint(
1169            &key,
1170            &payload_for("report.md", "ses_1", in_an_hour()),
1171            "a@b",
1172        )
1173        .unwrap();
1174        let mut value = serde_json::json!({
1175            "peer": "localhost:1",
1176            "handle": token,
1177            "nested": {"list": [token.clone(), "unrelated"]},
1178        });
1179        redact_handles_in_json(&mut value);
1180        let rendered = value.to_string();
1181        assert!(!rendered.contains("mh1."), "{rendered}");
1182        assert!(rendered.contains(&format!("<handle:{}>", handle_id(&token))));
1183        assert_eq!(value["peer"], "localhost:1");
1184        assert_eq!(value["nested"]["list"][1], "unrelated");
1185    }
1186
1187    #[test]
1188    fn a_stored_path_is_runtime_chosen_and_sanitised() {
1189        assert_eq!(
1190            stored_path_for("3f2a", Some("report.md")),
1191            "peer-in/3f2a-report.md"
1192        );
1193        assert_eq!(
1194            stored_path_for("3f2a", Some("../../etc/passwd")),
1195            "peer-in/3f2a-passwd"
1196        );
1197        assert_eq!(stored_path_for("3f2a", Some("..")), "peer-in/3f2a-file");
1198        assert_eq!(stored_path_for("3f2a", Some("")), "peer-in/3f2a-file");
1199        assert_eq!(stored_path_for("3f2a", None), "peer-in/3f2a-file");
1200        assert_eq!(
1201            stored_path_for("3f2a", Some("a b;rm -rf.md")),
1202            "peer-in/3f2a-a_b_rm_-rf.md"
1203        );
1204    }
1205
1206    // ── The plane, against a real filesystem ─────────────────────────────────
1207
1208    fn plane_for(root: &Path, export: PeerFilesExport) -> PeerPlane {
1209        PeerPlane::new(
1210            root,
1211            Some((&export, Arc::new(key()))),
1212            "ses_test".to_string(),
1213            ContainmentClass::Sealed,
1214            Arc::new(AtomicU64::new(0)),
1215            None,
1216        )
1217    }
1218
1219    /// The deny case: a plane that exists — so a refusal still has somewhere to be recorded — with
1220    /// nothing declared to serve from.
1221    fn undeclared_plane() -> PeerPlane {
1222        PeerPlane::new(
1223            Path::new("/nonexistent"),
1224            None,
1225            "ses_test".to_string(),
1226            ContainmentClass::Sealed,
1227            Arc::new(AtomicU64::new(0)),
1228            None,
1229        )
1230    }
1231
1232    fn peer_export(root: &str) -> PeerFilesExport {
1233        PeerFilesExport {
1234            root: root.to_string(),
1235            max_ttl_secs: Some(60),
1236            max_bytes: 1024 * 1024,
1237        }
1238    }
1239
1240    #[test]
1241    fn a_mint_names_a_file_under_the_root_and_redeems_to_its_bytes() {
1242        let dir = tempfile::tempdir().unwrap();
1243        fs::create_dir_all(dir.path().join("out")).unwrap();
1244        fs::write(dir.path().join("out/report.md"), b"known bytes").unwrap();
1245        let plane = plane_for(dir.path(), peer_export("out/"));
1246
1247        let minted = plane.mint_handle("report.md", "a@b", None).unwrap();
1248        assert_eq!(minted.path, "report.md");
1249        assert!(minted.handle.starts_with("mh1."));
1250
1251        let runtime = tokio::runtime::Builder::new_current_thread()
1252            .enable_all()
1253            .build()
1254            .unwrap();
1255        let (payload, read) = runtime
1256            .block_on(plane.redeem(&minted.handle, Some("a@b")))
1257            .unwrap();
1258        assert_eq!(payload.p, "report.md");
1259        assert_eq!(read.bytes, b"known bytes");
1260    }
1261
1262    #[test]
1263    fn a_mint_cannot_name_anything_outside_the_declared_subtree() {
1264        let dir = tempfile::tempdir().unwrap();
1265        fs::create_dir_all(dir.path().join("out/handoff")).unwrap();
1266        fs::write(dir.path().join("out/handoff/ok.md"), b"fine").unwrap();
1267        fs::write(dir.path().join("out/secret.md"), b"SECRET").unwrap();
1268        fs::write(dir.path().join("secret-at-top.md"), b"SECRET").unwrap();
1269        std::os::unix::fs::symlink("../secret.md", dir.path().join("out/handoff/escape.md"))
1270            .unwrap();
1271        let plane = plane_for(dir.path(), peer_export("out/handoff/"));
1272
1273        for attempt in [
1274            "../secret.md",
1275            "%2e%2e%2fsecret.md",
1276            "/etc/passwd",
1277            "escape.md",
1278            "handoff/../../secret-at-top.md",
1279        ] {
1280            let error = plane
1281                .mint_handle(attempt, "a@b", None)
1282                .expect_err("'{attempt}' must not mint");
1283            assert!(
1284                matches!(error, PeerError::OutsideRoot | PeerError::SymlinkRefused),
1285                "'{attempt}' produced {error:?}"
1286            );
1287        }
1288        assert!(plane.mint_handle("ok.md", "a@b", None).is_ok());
1289    }
1290
1291    #[test]
1292    fn a_mint_refuses_a_directory_rather_than_handing_out_a_handle_that_cannot_redeem() {
1293        let dir = tempfile::tempdir().unwrap();
1294        fs::create_dir_all(dir.path().join("out/nested")).unwrap();
1295        let plane = plane_for(dir.path(), peer_export("out/"));
1296        assert_eq!(
1297            plane.mint_handle("nested", "a@b", None).unwrap_err(),
1298            PeerError::NotARegularFile
1299        );
1300        assert_eq!(
1301            plane.mint_handle("missing.md", "a@b", None).unwrap_err(),
1302            PeerError::NotFound
1303        );
1304    }
1305
1306    #[test]
1307    fn a_ttl_may_only_narrow() {
1308        let dir = tempfile::tempdir().unwrap();
1309        fs::create_dir_all(dir.path().join("out")).unwrap();
1310        fs::write(dir.path().join("out/report.md"), b"x").unwrap();
1311        let plane = plane_for(dir.path(), peer_export("out/"));
1312
1313        // Bracketed by clock readings taken either side of each mint, so the assertion is exact
1314        // rather than slack-tolerant: a loaded machine makes the mint slower, not the ceiling
1315        // looser.
1316        let before = timestamp_ms();
1317        let clamped = plane.mint_handle("report.md", "a@b", Some(86_400)).unwrap();
1318        let after = timestamp_ms();
1319        assert!(
1320            (before + 60_000..=after + 60_000).contains(&clamped.expires_at_ms),
1321            "a ttl above max_ttl must be clamped down to it"
1322        );
1323
1324        let before = timestamp_ms();
1325        let narrowed = plane.mint_handle("report.md", "a@b", Some(5)).unwrap();
1326        let after = timestamp_ms();
1327        assert!(
1328            (before + 5_000..=after + 5_000).contains(&narrowed.expires_at_ms),
1329            "a ttl below max_ttl is applied as given"
1330        );
1331
1332        let before = timestamp_ms();
1333        let defaulted = plane.mint_handle("report.md", "a@b", None).unwrap();
1334        let after = timestamp_ms();
1335        assert!(
1336            (before + 60_000..=after + 60_000).contains(&defaulted.expires_at_ms),
1337            "an absent ttl means max_ttl"
1338        );
1339    }
1340
1341    /// A handle authorises a file, not a version of one: the second redeem serves the newer bytes
1342    /// and a different `etag`, and never a conflict.
1343    #[test]
1344    fn a_rewritten_file_redeems_to_its_current_bytes() {
1345        let dir = tempfile::tempdir().unwrap();
1346        fs::create_dir_all(dir.path().join("out")).unwrap();
1347        fs::write(dir.path().join("out/report.md"), b"first").unwrap();
1348        let plane = plane_for(dir.path(), peer_export("out/"));
1349        let minted = plane.mint_handle("report.md", "a@b", None).unwrap();
1350
1351        let runtime = tokio::runtime::Builder::new_current_thread()
1352            .enable_all()
1353            .build()
1354            .unwrap();
1355        let (_, first) = runtime
1356            .block_on(plane.redeem(&minted.handle, Some("a@b")))
1357            .unwrap();
1358        assert_eq!(first.bytes, b"first");
1359
1360        fs::write(dir.path().join("out/report.md.tmp"), b"second and longer").unwrap();
1361        fs::rename(
1362            dir.path().join("out/report.md.tmp"),
1363            dir.path().join("out/report.md"),
1364        )
1365        .unwrap();
1366
1367        let (_, second) = runtime
1368            .block_on(plane.redeem(&minted.handle, Some("a@b")))
1369            .unwrap();
1370        assert_eq!(second.bytes, b"second and longer");
1371        assert_ne!(first.sha256, second.sha256);
1372    }
1373
1374    #[test]
1375    fn a_redeem_is_idempotent() {
1376        let dir = tempfile::tempdir().unwrap();
1377        fs::create_dir_all(dir.path().join("out")).unwrap();
1378        fs::write(dir.path().join("out/report.md"), b"same bytes").unwrap();
1379        let plane = plane_for(dir.path(), peer_export("out/"));
1380        let minted = plane.mint_handle("report.md", "a@b", None).unwrap();
1381
1382        let runtime = tokio::runtime::Builder::new_current_thread()
1383            .enable_all()
1384            .build()
1385            .unwrap();
1386        let first = runtime
1387            .block_on(plane.redeem(&minted.handle, Some("a@b")))
1388            .unwrap()
1389            .1;
1390        for _ in 0..4 {
1391            let again = runtime
1392                .block_on(plane.redeem(&minted.handle, Some("a@b")))
1393                .unwrap()
1394                .1;
1395            assert_eq!(again.bytes, first.bytes);
1396            assert_eq!(again.sha256, first.sha256);
1397        }
1398    }
1399
1400    #[test]
1401    fn a_file_above_max_bytes_is_refused() {
1402        let dir = tempfile::tempdir().unwrap();
1403        fs::create_dir_all(dir.path().join("out")).unwrap();
1404        fs::write(dir.path().join("out/big.bin"), vec![0u8; 64]).unwrap();
1405        let plane = plane_for(
1406            dir.path(),
1407            PeerFilesExport {
1408                root: "out/".to_string(),
1409                max_ttl_secs: Some(60),
1410                max_bytes: 16,
1411            },
1412        );
1413        let minted = plane.mint_handle("big.bin", "a@b", None).unwrap();
1414        let runtime = tokio::runtime::Builder::new_current_thread()
1415            .enable_all()
1416            .build()
1417            .unwrap();
1418        assert_eq!(
1419            runtime
1420                .block_on(plane.redeem(&minted.handle, Some("a@b")))
1421                .unwrap_err(),
1422            PeerError::TooLarge { max_bytes: 16 }
1423        );
1424    }
1425
1426    // ── Routing ──────────────────────────────────────────────────────────────
1427
1428    #[test]
1429    fn only_the_peer_segment_routes_to_the_peer_plane() {
1430        assert!(is_peer_path("/resources/peer"));
1431        assert!(is_peer_path("/resources/peer/"));
1432        assert!(is_peer_path("/resources/peer/mh1.a.b"));
1433        assert!(is_peer_path("/resources/peer?x=1"));
1434        assert!(!is_peer_path("/resources/peerless/x"));
1435        assert!(!is_peer_path("/resources/files/report.md"));
1436        assert!(!is_peer_path("/"));
1437    }
1438
1439    fn respond(
1440        plane: &PeerPlane,
1441        method: &str,
1442        path: &str,
1443        audience: Option<&str>,
1444    ) -> ResourceResponse {
1445        tokio::runtime::Builder::new_current_thread()
1446            .enable_all()
1447            .build()
1448            .unwrap()
1449            .block_on(handle_peer_request(plane, method, path, audience))
1450    }
1451
1452    fn body_error(response: &ResourceResponse) -> serde_json::Value {
1453        serde_json::from_slice(&response.body).unwrap()
1454    }
1455
1456    #[test]
1457    fn an_undeclared_capsule_answers_no_peer_plane_and_mints_nothing() {
1458        let plane = undeclared_plane();
1459        assert!(!plane.is_declared());
1460
1461        let response = respond(&plane, "GET", "/resources/peer/mh1.YWJj.YWJj", Some("a@b"));
1462        assert_eq!(response.status, 404);
1463        assert_eq!(body_error(&response)["error"], "no_peer_plane");
1464
1465        assert_eq!(
1466            plane.mint_handle("report.md", "a@b", None).unwrap_err(),
1467            PeerError::NoPeerPlane
1468        );
1469    }
1470
1471    #[test]
1472    fn there_is_no_list_verb() {
1473        let plane = undeclared_plane();
1474        for path in ["/resources/peer", "/resources/peer/"] {
1475            let response = respond(&plane, "GET", path, Some("a@b"));
1476            assert_eq!(response.status, 404, "{path}");
1477            assert_eq!(body_error(&response)["error"], "not_found", "{path}");
1478            let body = String::from_utf8_lossy(&response.body);
1479            assert!(
1480                !body.contains('['),
1481                "a listing must not be reachable: {body}"
1482            );
1483        }
1484    }
1485
1486    #[test]
1487    fn every_write_verb_is_refused_with_an_allow_header() {
1488        let plane = undeclared_plane();
1489        for method in ["POST", "PUT", "PATCH", "DELETE", "HEAD"] {
1490            let response = respond(&plane, method, "/resources/peer/mh1.YWJj.YWJj", Some("a@b"));
1491            assert_eq!(response.status, 405, "{method}");
1492            assert_eq!(body_error(&response)["error"], "method_not_allowed");
1493            assert!(response
1494                .headers
1495                .iter()
1496                .any(|(name, value)| name == "allow" && value == "GET"));
1497        }
1498    }
1499
1500    #[test]
1501    fn a_declared_plane_serves_a_redeem_and_refuses_every_tampered_form_identically() {
1502        let dir = tempfile::tempdir().unwrap();
1503        fs::create_dir_all(dir.path().join("out")).unwrap();
1504        fs::write(dir.path().join("out/report.md"), b"known bytes").unwrap();
1505        let plane = plane_for(dir.path(), peer_export("out/"));
1506        let minted = plane.mint_handle("report.md", "a@b", None).unwrap();
1507
1508        let ok = respond(
1509            &plane,
1510            "GET",
1511            &format!("/resources/peer/{}", minted.handle),
1512            Some("a@b"),
1513        );
1514        assert_eq!(ok.status, 200);
1515        assert_eq!(ok.body, b"known bytes");
1516        assert!(ok
1517            .headers
1518            .iter()
1519            .any(|(name, value)| name == HANDLE_ID_HEADER && value == &minted.handle_id));
1520        assert!(
1521            !ok.headers
1522                .iter()
1523                .any(|(name, _)| name == "x-murmur-export-root"),
1524            "the peer plane discloses no path structure"
1525        );
1526
1527        let bad_payload = tamper_segment(&minted.handle, 1);
1528        let bad_mac = tamper_segment(&minted.handle, 2);
1529
1530        let refusals = [
1531            respond(
1532                &plane,
1533                "GET",
1534                &format!("/resources/peer/{bad_payload}"),
1535                Some("a@b"),
1536            ),
1537            respond(
1538                &plane,
1539                "GET",
1540                &format!("/resources/peer/{bad_mac}"),
1541                Some("a@b"),
1542            ),
1543            respond(
1544                &plane,
1545                "GET",
1546                &format!("/resources/peer/{}", minted.handle),
1547                Some("attacker@localhost:1"),
1548            ),
1549        ];
1550        for refusal in &refusals {
1551            assert_eq!(refusal.status, 403);
1552            assert_eq!(refusal.body, refusals[0].body);
1553            assert_eq!(body_error(refusal)["error"], "handle_not_valid");
1554            assert!(
1555                !String::from_utf8_lossy(&refusal.body).contains("known bytes"),
1556                "a refusal must serve none of the file's bytes"
1557            );
1558        }
1559
1560        let no_audience = respond(
1561            &plane,
1562            "GET",
1563            &format!("/resources/peer/{}", minted.handle),
1564            None,
1565        );
1566        assert_eq!(no_audience.status, 400);
1567        assert_eq!(body_error(&no_audience)["error"], "missing_audience");
1568
1569        let not_a_token = respond(&plane, "GET", "/resources/peer/report.md", Some("a@b"));
1570        assert_eq!(not_a_token.status, 400);
1571        assert_eq!(body_error(&not_a_token)["error"], "malformed_handle");
1572    }
1573
1574    /// The audience header is required *before* the MAC is checked, so an omitted header and a
1575    /// wrong one are different answers — but neither reveals anything about the file.
1576    #[test]
1577    fn a_malformed_shape_is_reported_before_the_audience_is_required() {
1578        let dir = tempfile::tempdir().unwrap();
1579        fs::create_dir_all(dir.path().join("out")).unwrap();
1580        let plane = plane_for(dir.path(), peer_export("out/"));
1581        let response = respond(&plane, "GET", "/resources/peer/not-a-token", None);
1582        assert_eq!(body_error(&response)["error"], "malformed_handle");
1583    }
1584
1585    #[test]
1586    fn an_expired_handle_is_refused_with_its_own_code() {
1587        let dir = tempfile::tempdir().unwrap();
1588        fs::create_dir_all(dir.path().join("out")).unwrap();
1589        fs::write(dir.path().join("out/report.md"), b"bytes").unwrap();
1590        let plane = plane_for(
1591            dir.path(),
1592            PeerFilesExport {
1593                root: "out/".to_string(),
1594                max_ttl_secs: Some(1),
1595                max_bytes: 1024,
1596            },
1597        );
1598        let minted = plane.mint_handle("report.md", "a@b", Some(1)).unwrap();
1599        std::thread::sleep(std::time::Duration::from_millis(1100));
1600        let response = respond(
1601            &plane,
1602            "GET",
1603            &format!("/resources/peer/{}", minted.handle),
1604            Some("a@b"),
1605        );
1606        assert_eq!(response.status, 410);
1607        assert_eq!(body_error(&response)["error"], "handle_expired");
1608    }
1609}