Skip to main content

capsule_runtime/
resource_plane.rs

1//! The resource plane: the read-only file surface a capsule declares under `exports.files`.
2//!
3//! Two verbs, `list` and `read`, served host-side off the ordinary host path the runtime already
4//! holds for the workdir. No wasm is instantiated, no store is locked and no running turn is
5//! consulted, so the plane answers whether the capsule is idle or mid-task — and a read never
6//! costs an inference turn.
7//!
8//! Three responsibilities are kept apart:
9//!
10//! * The **agent** owns what a file contains. Nothing here asks it anything.
11//! * The **runtime** owns who may read it. Path resolution lives entirely in this module: a
12//!   caller never sanitises a path, because a gateway that normalises `..` into something
13//!   servable has taken over the containment boundary, and the component holding that boundary
14//!   must not think. Every escaping request is refused, never repaired.
15//! * The **trace** owns the record. Every read and every refusal lands in `trace.jsonl` at the
16//!   moment of the request, because the agent has been removed from the loop and the trace is
17//!   the only account of what left the capsule and when.
18//!
19//! Declaring an export does not change the achieved containment class — see
20//! [`crate::containment`]. Containment describes the guest's reach outward; an export widens the
21//! operator's reach inward and hands the guest nothing.
22//!
23//! The filesystem work is synchronous `std::fs` run on [`tokio::task::spawn_blocking`], and the
24//! resolve-then-verify-prefix path is the only one: `openat2(RESOLVE_BENEATH)` is Linux-only and
25//! could never be more than an extra layer over a portable path that has to be correct by
26//! itself, so this module keeps the portable path alone rather than two that can disagree.
27
28use std::{
29    io::Read,
30    path::{Component, Path, PathBuf},
31    sync::{
32        atomic::{AtomicU64, Ordering},
33        Arc,
34    },
35    time::UNIX_EPOCH,
36};
37
38use murmur_artifact::{ContainmentClass, ExportMode, FileExport, PeerFilesExport};
39use serde::Serialize;
40
41use crate::{errors::RuntimeError, trace::ResourceTraceAppender};
42
43/// Path prefix the plane answers under. Everything below it is `GET`-only.
44pub const RESOURCE_PATH_PREFIX: &str = "/resources/";
45
46/// The one file-surface route, relative to [`RESOURCE_PATH_PREFIX`].
47const FILES_ROUTE: &str = "files";
48
49// ── Declared export ───────────────────────────────────────────────────────────
50
51/// One `exports.files` block, with its root already resolved against the session's accessible
52/// workdir.
53///
54/// The root is *joined*, not canonicalised, at construction: `exports.files.root` is not required
55/// to exist when the capsule launches, because the agent may create it during a task. Every
56/// request canonicalises it afresh, which is also what lets a `list` answer `200` with no entries
57/// before the agent has written anything.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct DeclaredExport {
60    /// `exports.files.root` verbatim, as the operator wrote it (`out/`). Echoed in every
61    /// response and every trace line; never used to touch the filesystem.
62    pub declared_root: String,
63    /// `accessible_workdir.join(declared_root)`.
64    pub root: PathBuf,
65    pub mode: ExportMode,
66    /// Per-file read ceiling. A file above it is still *listed*, with its real size — discovery
67    /// must not lie about what is there — and refused on read.
68    pub max_bytes: u64,
69}
70
71impl DeclaredExport {
72    /// Resolves a manifest's `exports.files` block against the session's accessible workdir —
73    /// the directory the agent's own tools see at `.`, and therefore the one `out/result.txt`
74    /// means. Not `StagedSession::workdir`, which is the internal `.murmur/<session_id>`
75    /// bookkeeping directory when `--workdir` is given, and holds `trace.jsonl`.
76    pub fn resolve(accessible_workdir: &Path, export: &FileExport) -> Self {
77        Self {
78            declared_root: export.root.clone(),
79            root: accessible_workdir.join(&export.root),
80            mode: export.mode,
81            max_bytes: export.max_bytes,
82        }
83    }
84
85    /// [`Self::resolve`] for the peer plane's separate subtree.
86    ///
87    /// The same type because it is the same resolution: `exports.peer_files` opens a different
88    /// root under a different authoriser, but a path beneath it is decided by exactly the rules
89    /// that decide one beneath `exports.files`. `mode` is [`ExportMode::ReadOnly`] because there
90    /// is no other kind of peer export and none is declarable — a redeem serves bytes and
91    /// nothing else.
92    pub fn for_peer_files(accessible_workdir: &Path, export: &PeerFilesExport) -> Self {
93        Self {
94            declared_root: export.root.clone(),
95            root: accessible_workdir.join(&export.root),
96            mode: ExportMode::ReadOnly,
97            max_bytes: export.max_bytes,
98        }
99    }
100}
101
102/// Refuses a launch whose declared `exports.peer_files.root` already resolves outside the
103/// accessible workdir — [`check_export_root`] for the peer plane's root, and the same refusal.
104pub fn check_peer_files_root(
105    accessible_workdir: &Path,
106    export: &PeerFilesExport,
107) -> Result<(), RuntimeError> {
108    check_root_within_workdir(accessible_workdir, "exports.peer_files.root", &export.root)
109}
110
111/// Refuses a launch whose declared export root already resolves outside the accessible workdir.
112///
113/// Called from `stage_session` before the workdir is created, so a capsule whose `out/` is a
114/// symlink into someone else's directory never runs at all — the alternative is discovering it
115/// one served file at a time, by which point the first file has left. A root that does not yet
116/// exist is accepted: the agent may create it during a task, and every request resolves the root
117/// afresh.
118pub fn check_export_root(
119    accessible_workdir: &Path,
120    export: &FileExport,
121) -> Result<(), RuntimeError> {
122    check_root_within_workdir(accessible_workdir, "exports.files.root", &export.root)
123}
124
125fn check_root_within_workdir(
126    accessible_workdir: &Path,
127    field: &str,
128    root: &str,
129) -> Result<(), RuntimeError> {
130    let Ok(workdir_canon) = std::fs::canonicalize(accessible_workdir) else {
131        // No `--workdir`: the session directory does not exist yet, so nothing under it can
132        // already point elsewhere.
133        return Ok(());
134    };
135    let Ok(resolved) = std::fs::canonicalize(workdir_canon.join(root)) else {
136        return Ok(());
137    };
138    if !resolved.starts_with(&workdir_canon) {
139        return Err(RuntimeError::ExportRootOutsideWorkdir {
140            field: field.to_string(),
141            declared: root.to_string(),
142            resolved: resolved.display().to_string(),
143            workdir: workdir_canon.display().to_string(),
144        });
145    }
146    Ok(())
147}
148
149/// Everything the resource plane needs to answer a request, and nothing that a running task
150/// leaves behind.
151///
152/// Deliberately constructible from an export declaration, a workdir path and a containment class
153/// alone: a later reader-only launch mode must be able to build one over an existing workdir
154/// without starting the engine, and would be correct to report `generation: 0` there, since no
155/// task has completed *in that process*.
156pub struct ResourcePlane {
157    /// `None` means the capsule declared no `exports.files`, which is the deny case: every
158    /// request answers `no_resource_plane` and still lands in the trace.
159    export: Option<DeclaredExport>,
160    /// The class this session actually achieved — never the declared floor. Reported on every
161    /// response and used to key the symlink decision (see [`symlink_policy`]).
162    containment_achieved: ContainmentClass,
163    /// Incremented by the agent loop when a task reaches a terminal state. Provenance, never a
164    /// pin: see [`ResourcePlane::generation`].
165    generation: Arc<AtomicU64>,
166    /// `None` when there is nowhere to write a record — a plane built outside a session. A
167    /// session-backed plane always has one.
168    trace: Option<Arc<ResourceTraceAppender>>,
169}
170
171impl ResourcePlane {
172    /// The five inputs a plane needs, and the complete list of them: a host path, the declared
173    /// export (`None` = undeclared = deny), the achieved containment class, the generation
174    /// counter and somewhere to write the record. None of them is state a completed task left
175    /// behind, which is what lets a later reader-only launch mode build one over an existing
176    /// workdir without starting the engine.
177    pub fn new(
178        accessible_workdir: &Path,
179        export: Option<&FileExport>,
180        containment_achieved: ContainmentClass,
181        generation: Arc<AtomicU64>,
182        trace: Option<Arc<ResourceTraceAppender>>,
183    ) -> Self {
184        Self::with_export(
185            export.map(|export| DeclaredExport::resolve(accessible_workdir, export)),
186            containment_achieved,
187            generation,
188            trace,
189        )
190    }
191
192    /// [`Self::new`] with the root already resolved.
193    pub fn with_export(
194        export: Option<DeclaredExport>,
195        containment_achieved: ContainmentClass,
196        generation: Arc<AtomicU64>,
197        trace: Option<Arc<ResourceTraceAppender>>,
198    ) -> Self {
199        Self {
200            export,
201            containment_achieved,
202            generation,
203            trace,
204        }
205    }
206
207    /// Which turn the bytes belong to: `0` until the first task in this process reaches a
208    /// terminal state, then one more per completed task.
209    ///
210    /// It answers *these bytes are as of turn N* and nothing else. It is not an argument — no
211    /// request selects a generation, no response conflicts because the generation has moved on,
212    /// and superseded bytes are not retained. A capsule that is finished but alive and rewrites a
213    /// file two turns later simply serves the newer file; the `etag` is how a caller notices.
214    pub fn generation(&self) -> u64 {
215        self.generation.load(Ordering::SeqCst)
216    }
217
218    pub fn containment_achieved(&self) -> ContainmentClass {
219        self.containment_achieved
220    }
221}
222
223// ── Wire shapes ───────────────────────────────────────────────────────────────
224
225/// A status, headers and a body — everything a transport needs and nothing about the transport.
226///
227/// [`handle_resource_request`] returns this rather than writing to a socket, so the same logic
228/// can be bound on another listener behind its own authoriser without touching any of it.
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct ResourceResponse {
231    pub status: u16,
232    pub headers: Vec<(String, String)>,
233    pub body: Vec<u8>,
234}
235
236impl ResourceResponse {
237    /// Builds a response with `content-length` taken from the body it carries.
238    ///
239    /// Framing is settled here rather than left to the transport so that a later listener binding
240    /// [`handle_resource_request`] gets a self-delimiting response without having to know it owed
241    /// one. A body delimited only by the connection closing is a body a caller cannot tell apart
242    /// from a truncated one, which would undo the point of serving a validator alongside it.
243    pub(crate) fn framed(status: u16, mut headers: Vec<(String, String)>, body: Vec<u8>) -> Self {
244        headers.push(("content-length".to_string(), body.len().to_string()));
245        Self {
246            status,
247            headers,
248            body,
249        }
250    }
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
254pub struct ListResponse {
255    pub root: String,
256    pub mode: ExportMode,
257    pub max_bytes: u64,
258    pub generation: u64,
259    pub containment_achieved: ContainmentClass,
260    pub entries: Vec<ListEntry>,
261}
262
263/// One regular file under the export root. `size_bytes`, `mtime_ms` and `sha256` all come from a
264/// single open of that file, so they describe one version of it and never a mixture of two.
265#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
266pub struct ListEntry {
267    /// Relative to the export root, `/`-separated.
268    pub path: String,
269    pub size_bytes: u64,
270    pub mtime_ms: u64,
271    pub sha256: String,
272}
273
274/// The bytes of one file together with the validator that describes *those* bytes.
275///
276/// The hash is computed from the buffer actually served, not from a second pass over the path, so
277/// the `etag` and the body are the same version by construction.
278#[derive(Debug, Clone, PartialEq, Eq)]
279pub struct ReadResponse {
280    pub bytes: Vec<u8>,
281    pub sha256: String,
282    pub mtime_ms: u64,
283}
284
285// ── Errors ────────────────────────────────────────────────────────────────────
286
287#[derive(Debug, Clone, PartialEq, Eq)]
288pub enum ResourceError {
289    /// The manifest declares no `exports.files`. Absent means deny.
290    NoResourcePlane,
291    NotFound,
292    OutsideRoot,
293    /// A symlink was encountered and the achieved class is `scoped`.
294    SymlinkRefused,
295    NotARegularFile,
296    TooLarge {
297        max_bytes: u64,
298    },
299    MethodNotAllowed,
300    IoError(String),
301}
302
303impl ResourceError {
304    /// The stable `error` string in the JSON body and the `outcome` in the trace — one vocabulary
305    /// for both, so a refusal an auditor reads is spelled the way the caller saw it.
306    pub fn code(&self) -> &'static str {
307        match self {
308            ResourceError::NoResourcePlane => "no_resource_plane",
309            ResourceError::NotFound => "not_found",
310            ResourceError::OutsideRoot => "outside_root",
311            ResourceError::SymlinkRefused => "symlink_refused",
312            ResourceError::NotARegularFile => "not_a_regular_file",
313            ResourceError::TooLarge { .. } => "too_large",
314            ResourceError::MethodNotAllowed => "method_not_allowed",
315            ResourceError::IoError(_) => "io_error",
316        }
317    }
318
319    pub fn status(&self) -> u16 {
320        match self {
321            ResourceError::NoResourcePlane | ResourceError::NotFound => 404,
322            ResourceError::OutsideRoot
323            | ResourceError::SymlinkRefused
324            | ResourceError::NotARegularFile => 403,
325            ResourceError::TooLarge { .. } => 413,
326            ResourceError::MethodNotAllowed => 405,
327            ResourceError::IoError(_) => 500,
328        }
329    }
330
331    pub fn message(&self) -> String {
332        match self {
333            ResourceError::NoResourcePlane => {
334                "this capsule declares no exports.files block, so it has no resource plane"
335                    .to_string()
336            }
337            ResourceError::NotFound => "no such file under the export root".to_string(),
338            ResourceError::OutsideRoot => {
339                "the requested path resolves outside the export root".to_string()
340            }
341            ResourceError::SymlinkRefused => {
342                "a symlink was encountered and the achieved containment class is scoped".to_string()
343            }
344            ResourceError::NotARegularFile => {
345                "the requested path is not a regular file".to_string()
346            }
347            ResourceError::TooLarge { max_bytes } => {
348                format!("file exceeds the declared exports.files.max_bytes of {max_bytes} bytes")
349            }
350            ResourceError::MethodNotAllowed => {
351                "the resource plane serves GET only; it has no write path".to_string()
352            }
353            ResourceError::IoError(detail) => format!("read failed: {detail}"),
354        }
355    }
356}
357
358fn io_to_resource_error(error: &std::io::Error) -> ResourceError {
359    match error.kind() {
360        std::io::ErrorKind::NotFound => ResourceError::NotFound,
361        // A component of the path is not a directory, or O_NOFOLLOW hit a symlink on the final
362        // component: both mean the name does not designate a servable regular file here.
363        std::io::ErrorKind::NotADirectory => ResourceError::NotFound,
364        std::io::ErrorKind::PermissionDenied => ResourceError::IoError(error.to_string()),
365        _ if error.raw_os_error() == Some(libc::ELOOP) => ResourceError::SymlinkRefused,
366        _ => ResourceError::IoError(error.to_string()),
367    }
368}
369
370// ── The class-keyed symlink decision ──────────────────────────────────────────
371
372#[derive(Debug, Clone, Copy, PartialEq, Eq)]
373pub enum SymlinkPolicy {
374    /// Refuse any symlink encountered on the resolved path, and omit symlinked entries from a
375    /// listing.
376    Refuse,
377    /// Follow symlinks, and serve only when the fully-resolved target is still beneath the export
378    /// root.
379    FollowWithinRoot,
380}
381
382/// What a symlink under the export root means at each achieved containment class.
383///
384/// Pure and class-keyed, mirroring the seam `containment::achieved_class_for_tier` uses, so the
385/// decision is testable across all three variants without a host probe — which matters because
386/// `scoped` is unreachable on darwin and on any Linux without a usable Landlock ABI.
387///
388/// * `scoped` — host-path grants are possible and the filesystem's shape stays visible, so a
389///   symlink under the export root could target a granted host path. Refuse it outright.
390/// * `sealed` — the workdir is the only writable path and there is no outside to name, so
391///   everything under the export root is capsule-authored. Follow, and check the target.
392/// * `advisory` — a convention on top of a convention. Same rule as `sealed`, and every response
393///   says `advisory` so a caller knows what it is trusting.
394pub fn symlink_policy(class: ContainmentClass) -> SymlinkPolicy {
395    match class {
396        ContainmentClass::Scoped => SymlinkPolicy::Refuse,
397        ContainmentClass::Sealed | ContainmentClass::Advisory => SymlinkPolicy::FollowWithinRoot,
398    }
399}
400
401// ── Path resolution ───────────────────────────────────────────────────────────
402
403/// Percent-decoding, applied **before** any validation.
404///
405/// Order is the whole point: validating first is exactly what lets `%2e%2e%2f` through as an
406/// opaque-looking component that the filesystem then reads as `../`. An incomplete or non-hex
407/// escape is left as the literal bytes it was written with rather than rejected here — whatever
408/// it decodes to still has to survive [`export_relpath_components`].
409fn percent_decode(raw: &str) -> Vec<u8> {
410    let bytes = raw.as_bytes();
411    let mut out = Vec::with_capacity(bytes.len());
412    let mut index = 0;
413    while index < bytes.len() {
414        if bytes[index] == b'%' && index + 2 < bytes.len() {
415            let hex = std::str::from_utf8(&bytes[index + 1..index + 3])
416                .ok()
417                .and_then(|pair| u8::from_str_radix(pair, 16).ok());
418            if let Some(byte) = hex {
419                out.push(byte);
420                index += 3;
421                continue;
422            }
423        }
424        out.push(bytes[index]);
425        index += 1;
426    }
427    out
428}
429
430/// Decodes a request path and lowers it to the components that may be joined onto the export
431/// root, refusing everything else without touching the filesystem.
432///
433/// Refused: an empty path, an absolute path, a NUL byte, bytes that are not UTF-8, and any
434/// component equal to `""`, `"."` or `".."`. `.` is refused rather than skipped for the same
435/// reason `..` is: the runtime does not repair a request into something servable, and a caller
436/// that wanted `a/b` should ask for `a/b`.
437pub(crate) fn export_relpath_components(raw: &str) -> Result<Vec<String>, ResourceError> {
438    let decoded = percent_decode(raw);
439    if decoded.is_empty() || decoded.contains(&0) {
440        return Err(ResourceError::OutsideRoot);
441    }
442    let decoded = String::from_utf8(decoded).map_err(|_| ResourceError::OutsideRoot)?;
443    if decoded.starts_with('/') || Path::new(&decoded).is_absolute() {
444        return Err(ResourceError::OutsideRoot);
445    }
446    // One trailing slash names nothing new, so it is dropped before splitting. Every empty segment
447    // that survives is a `//` inside the path and is refused with the rest.
448    let mut components = Vec::new();
449    for part in decoded.strip_suffix('/').unwrap_or(&decoded).split('/') {
450        if part.is_empty() || part == "." || part == ".." {
451            return Err(ResourceError::OutsideRoot);
452        }
453        components.push(part.to_string());
454    }
455    if components.is_empty() {
456        return Err(ResourceError::OutsideRoot);
457    }
458    // Nothing above can produce a `..`, a root or a Windows prefix, but the join below is the
459    // step that would act on one, so the invariant is asserted where it is relied upon.
460    for component in &components {
461        if Path::new(component)
462            .components()
463            .any(|c| !matches!(c, Component::Normal(_)))
464        {
465            return Err(ResourceError::OutsideRoot);
466        }
467    }
468    Ok(components)
469}
470
471/// Canonicalises the export root and the target and requires the target to be the root or beneath
472/// it — the portable resolve-then-verify-prefix path, and the only one.
473///
474/// `Path::starts_with` compares whole components, so `/w/outside/x` is correctly *not* beneath
475/// `/w/out`.
476fn resolve_beneath(
477    root_canon: &Path,
478    components: &[String],
479    policy: SymlinkPolicy,
480) -> Result<PathBuf, ResourceError> {
481    let mut target = root_canon.to_path_buf();
482    if policy == SymlinkPolicy::Refuse {
483        // Walk component by component so a symlink is caught wherever it sits on the path, not
484        // only on the final element — an intermediate symlinked directory redirects everything
485        // below it just as effectively.
486        for component in components {
487            target.push(component);
488            let metadata =
489                std::fs::symlink_metadata(&target).map_err(|e| io_to_resource_error(&e))?;
490            if metadata.file_type().is_symlink() {
491                return Err(ResourceError::SymlinkRefused);
492            }
493        }
494    } else {
495        for component in components {
496            target.push(component);
497        }
498    }
499    let canonical = std::fs::canonicalize(&target).map_err(|e| io_to_resource_error(&e))?;
500    if !canonical.starts_with(root_canon) {
501        return Err(ResourceError::OutsideRoot);
502    }
503    Ok(canonical)
504}
505
506/// Opens `path` for reading without following a symlink on its final component and without
507/// blocking on a FIFO, and returns the descriptor with the metadata `fstat` reports for it.
508///
509/// `O_NONBLOCK` is what keeps a FIFO from parking the caller until a writer appears. Check the
510/// file type and owner on the returned metadata, never on a second lookup of `path`, which may by
511/// then name something else.
512pub(crate) fn open_no_follow(path: &Path) -> std::io::Result<(std::fs::File, std::fs::Metadata)> {
513    use std::os::unix::fs::OpenOptionsExt;
514
515    let file = std::fs::OpenOptions::new()
516        .read(true)
517        .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
518        .open(path)?;
519    let metadata = file.metadata()?;
520    Ok((file, metadata))
521}
522
523/// [`open_no_follow`], then confirms from the open descriptor that it is a regular file, so a
524/// FIFO, socket or device node under the export root is `not_a_regular_file`.
525fn open_regular_file(path: &Path) -> Result<(std::fs::File, std::fs::Metadata), ResourceError> {
526    let (file, metadata) = open_no_follow(path).map_err(|e| io_to_resource_error(&e))?;
527    if !metadata.is_file() {
528        return Err(ResourceError::NotARegularFile);
529    }
530    Ok((file, metadata))
531}
532
533fn mtime_ms(metadata: &std::fs::Metadata) -> u64 {
534    metadata
535        .modified()
536        .ok()
537        .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
538        .map(|d| d.as_millis() as u64)
539        .unwrap_or(0)
540}
541
542// ── read ──────────────────────────────────────────────────────────────────────
543
544/// Resolves one root-relative request path to a canonical target beneath the export root,
545/// returning that target together with its canonical root-relative form.
546///
547/// The step the reader takes before it opens anything, exposed so the peer plane's *mint* can
548/// name a file without reading it. Sharing it is the point: a path that would be refused on a
549/// read is refused on a mint by the same code, so a handle can never authorise something the
550/// reader would then decline — or, worse, something it would not.
551pub(crate) fn resolve_relpath_beneath_root(
552    export: &DeclaredExport,
553    relpath: &str,
554    policy: SymlinkPolicy,
555) -> Result<(PathBuf, String), ResourceError> {
556    let components = export_relpath_components(relpath)?;
557    let root_canon = std::fs::canonicalize(&export.root).map_err(|e| io_to_resource_error(&e))?;
558    let target = resolve_beneath(&root_canon, &components, policy)?;
559    let relative = target
560        .strip_prefix(&root_canon)
561        .map_err(|_| ResourceError::OutsideRoot)?
562        .components()
563        .map(|component| component.as_os_str().to_string_lossy().into_owned())
564        .collect::<Vec<_>>()
565        .join("/");
566    if relative.is_empty() {
567        return Err(ResourceError::OutsideRoot);
568    }
569    Ok((target, relative))
570}
571
572/// Serves one file's current bytes.
573///
574/// Atomicity against a concurrent rewrite rests on two rules and no locking: the file is opened
575/// exactly once and its bytes, size and mtime all come from that one descriptor — a path can name
576/// a different inode by the time it is looked at twice — and the hash is taken over the buffer
577/// actually returned. An agent that rewrites by writing a temp file and renaming it into place
578/// therefore holds this reader on the old inode for the whole read; one that truncates and
579/// rewrites in place can still be read mid-write. That is an authoring convention, documented for
580/// capsule authors, not a mechanism enforced here.
581pub(crate) fn read_export_file_with_policy(
582    export: &DeclaredExport,
583    relpath: &str,
584    policy: SymlinkPolicy,
585) -> Result<ReadResponse, ResourceError> {
586    let (target, _) = resolve_relpath_beneath_root(export, relpath, policy)?;
587
588    let (file, metadata) = open_regular_file(&target)?;
589    if metadata.len() > export.max_bytes {
590        return Err(ResourceError::TooLarge {
591            max_bytes: export.max_bytes,
592        });
593    }
594    let mtime = mtime_ms(&metadata);
595
596    // Bounded by the declared ceiling plus one byte, so a file that grew between the fstat above
597    // and this read is refused rather than served past the ceiling.
598    let mut bytes = Vec::with_capacity(metadata.len() as usize);
599    let mut limited = file.take(export.max_bytes.saturating_add(1));
600    limited
601        .read_to_end(&mut bytes)
602        .map_err(|e| io_to_resource_error(&e))?;
603    if bytes.len() as u64 > export.max_bytes {
604        return Err(ResourceError::TooLarge {
605            max_bytes: export.max_bytes,
606        });
607    }
608
609    let sha256 = murmur_artifact::sha256_hex(&bytes);
610    Ok(ReadResponse {
611        bytes,
612        sha256,
613        mtime_ms: mtime,
614    })
615}
616
617/// Serves one file named by a relative path beneath `root`, with no manifest export behind it.
618///
619/// The entry point for a host-process read of a path some *other* party chose — the plan
620/// scheduler dereferencing a tool's `data_path`. The host holds ambient filesystem authority, so
621/// `root` is the whole of the caller's boundary: the path is lowered, canonicalised and required
622/// to land beneath `root` by [`resolve_relpath_beneath_root`], the same predicate the resource
623/// plane's reader and the peer plane's mint use, and refusals come back with the same
624/// [`ResourceError::code`] vocabulary.
625///
626/// `policy` is a parameter of the caller's boundary, not of a containment class: pass
627/// [`SymlinkPolicy::Refuse`] unless the reader is inside the sandbox that owns `root`.
628pub(crate) fn read_file_beneath_root(
629    root: &Path,
630    relpath: &str,
631    max_bytes: u64,
632    policy: SymlinkPolicy,
633) -> Result<ReadResponse, ResourceError> {
634    let export = DeclaredExport {
635        declared_root: ".".to_string(),
636        root: root.to_path_buf(),
637        mode: ExportMode::ReadOnly,
638        max_bytes,
639    };
640    read_export_file_with_policy(&export, relpath, policy)
641}
642
643// ── list ──────────────────────────────────────────────────────────────────────
644
645/// Every regular file under the export root, sorted by path.
646///
647/// A missing root is `Ok(vec![])`, never a `404`: `exports.files.root` is not required to exist at
648/// launch, and only an *undeclared* plane refuses a listing. A file above `max_bytes` is listed
649/// with its real size — discovery must not lie about what is there — and refused on read.
650fn list_export_files(
651    export: &DeclaredExport,
652    policy: SymlinkPolicy,
653) -> Result<Vec<ListEntry>, ResourceError> {
654    let root_canon = match std::fs::canonicalize(&export.root) {
655        Ok(path) => path,
656        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
657        Err(error) => return Err(io_to_resource_error(&error)),
658    };
659    if !root_canon.is_dir() {
660        return Ok(Vec::new());
661    }
662
663    let mut entries = Vec::new();
664    let mut pending = vec![(root_canon.clone(), String::new())];
665    while let Some((dir, prefix)) = pending.pop() {
666        let reader = match std::fs::read_dir(&dir) {
667            Ok(reader) => reader,
668            // A directory that vanished mid-walk (the agent is still working) drops out of the
669            // listing rather than failing the whole request.
670            Err(_) => continue,
671        };
672        for entry in reader.flatten() {
673            let name = entry.file_name().to_string_lossy().to_string();
674            let relative = if prefix.is_empty() {
675                name.clone()
676            } else {
677                format!("{prefix}/{name}")
678            };
679            let Ok(file_type) = entry.file_type() else {
680                continue;
681            };
682            let path = entry.path();
683
684            if file_type.is_symlink() {
685                if policy == SymlinkPolicy::Refuse {
686                    continue;
687                }
688                // Follow, but only onto a regular file still beneath the root. A symlinked
689                // *directory* is never descended into: it would list the same bytes twice under
690                // two names and, if it pointed at an ancestor, would not terminate.
691                let Ok(canonical) = std::fs::canonicalize(&path) else {
692                    continue;
693                };
694                if !canonical.starts_with(&root_canon) {
695                    continue;
696                }
697                if let Some(entry) = describe_entry(&canonical, relative) {
698                    entries.push(entry);
699                }
700                continue;
701            }
702
703            if file_type.is_dir() {
704                pending.push((path, relative));
705            } else if file_type.is_file() {
706                if let Some(entry) = describe_entry(&path, relative) {
707                    entries.push(entry);
708                }
709            }
710        }
711    }
712    entries.sort_by(|a, b| a.path.cmp(&b.path));
713    Ok(entries)
714}
715
716/// One listing entry, taken from one open of the file: the size is the number of bytes hashed,
717/// so `size_bytes` and `sha256` describe the same version of it. `None` when the file went away
718/// or turned out not to be a regular file between the walk and the open.
719fn describe_entry(path: &Path, relative: String) -> Option<ListEntry> {
720    let (mut file, metadata) = open_regular_file(path).ok()?;
721    let mtime = mtime_ms(&metadata);
722    // Streamed rather than buffered: a listing is not bounded by `max_bytes` — an oversized file
723    // is still listed — so it must not be bounded by memory either.
724    let (size_bytes, sha256) = murmur_artifact::sha256_hex_of_reader(&mut file).ok()?;
725    Some(ListEntry {
726        path: relative,
727        size_bytes,
728        mtime_ms: mtime,
729        sha256,
730    })
731}
732
733// ── The transport-agnostic handler ────────────────────────────────────────────
734
735/// Answers one resource-plane request: a method, a raw request path, and nothing else.
736///
737/// Knows no socket, no framing and no authoriser, so binding it on a second listener with its own
738/// authentication is a matter of calling exactly this function and writing out the reply.
739///
740/// `raw_path` is the request target verbatim, still percent-encoded and possibly carrying a query
741/// string. Decoding is this function's job, never the caller's.
742pub async fn handle_resource_request(
743    plane: &ResourcePlane,
744    method: &str,
745    raw_path: &str,
746) -> ResourceResponse {
747    let path = raw_path.split('?').next().unwrap_or("");
748    let Some(rest) = path.strip_prefix(RESOURCE_PATH_PREFIX) else {
749        return error_response(&ResourceError::NotFound, None);
750    };
751
752    if rest == FILES_ROUTE || rest == "files/" {
753        return handle_list(plane, method).await;
754    }
755    if let Some(relpath) = rest.strip_prefix("files/") {
756        return handle_read(plane, method, relpath).await;
757    }
758    // No route to name, so nothing is traced — but the method still answers first. A `PUT` at an
759    // unknown path under the prefix is an attempt to write, and `405` with an `allow: GET` says
760    // there is no write path anywhere here, where a `404` would only say "not that one".
761    if method != "GET" {
762        return error_response(&ResourceError::MethodNotAllowed, None);
763    }
764    error_response(&ResourceError::NotFound, None)
765}
766
767/// The declared export a request may be answered from, or the refusal that stands in its place.
768///
769/// The method check comes first because it is a property of the request alone: a `PUT` is refused
770/// identically whether or not the capsule exports anything.
771fn servable_export<'a>(
772    plane: &'a ResourcePlane,
773    method: &str,
774) -> Result<&'a DeclaredExport, ResourceError> {
775    if method != "GET" {
776        return Err(ResourceError::MethodNotAllowed);
777    }
778    plane.export.as_ref().ok_or(ResourceError::NoResourcePlane)
779}
780
781async fn handle_list(plane: &ResourcePlane, method: &str) -> ResourceResponse {
782    let generation = plane.generation();
783    let class = plane.containment_achieved;
784
785    let export = match servable_export(plane, method) {
786        Ok(export) => export.clone(),
787        // No declared root to name: the record still says what was asked for and why it was
788        // refused, which is the whole of what an auditor needs from a denied request.
789        Err(error) => return refuse_list(plane, "", &error, generation, class).await,
790    };
791
792    let listed = {
793        let export = export.clone();
794        let policy = symlink_policy(class);
795        tokio::task::spawn_blocking(move || list_export_files(&export, policy))
796            .await
797            .unwrap_or_else(|join| Err(ResourceError::IoError(join.to_string())))
798    };
799
800    let entries = match listed {
801        Ok(entries) => entries,
802        Err(error) => {
803            return refuse_list(plane, &export.declared_root, &error, generation, class).await
804        }
805    };
806
807    if let Some(trace) = &plane.trace {
808        trace
809            .write_resource_list(
810                &export.declared_root,
811                entries.len(),
812                entries.iter().map(|entry| entry.size_bytes).sum(),
813                generation,
814                class,
815                "ok",
816                None,
817            )
818            .await;
819    }
820
821    let body = ListResponse {
822        root: export.declared_root.clone(),
823        mode: export.mode,
824        max_bytes: export.max_bytes,
825        generation,
826        containment_achieved: class,
827        entries,
828    };
829    ResourceResponse::framed(
830        200,
831        vec![
832            ("content-type".to_string(), "application/json".to_string()),
833            ("x-murmur-generation".to_string(), generation.to_string()),
834            ("x-murmur-containment".to_string(), class.to_string()),
835            ("x-murmur-export-root".to_string(), export.declared_root),
836        ],
837        serde_json::to_vec(&body).unwrap_or_default(),
838    )
839}
840
841async fn handle_read(plane: &ResourcePlane, method: &str, relpath: &str) -> ResourceResponse {
842    let generation = plane.generation();
843    let class = plane.containment_achieved;
844    // Decoded, but never validated or normalised: `%2e%2e%2f` and `../` are one attempt and read
845    // as one finding, while a refused path appears in the record as what it asked for rather than
846    // as something this module made servable.
847    let traced_path = decoded_request_path(relpath);
848
849    let export = match servable_export(plane, method) {
850        Ok(export) => export.clone(),
851        Err(error) => return refuse_read(plane, &traced_path, &error, generation, class).await,
852    };
853
854    let read = {
855        let export = export.clone();
856        let policy = symlink_policy(class);
857        let relpath = relpath.to_string();
858        tokio::task::spawn_blocking(move || read_export_file_with_policy(&export, &relpath, policy))
859            .await
860            .unwrap_or_else(|join| Err(ResourceError::IoError(join.to_string())))
861    };
862
863    let response = match read {
864        Ok(response) => response,
865        Err(error) => return refuse_read(plane, &traced_path, &error, generation, class).await,
866    };
867
868    if let Some(trace) = &plane.trace {
869        trace
870            .write_resource_read(
871                &traced_path,
872                "ok",
873                Some(response.bytes.len() as u64),
874                Some(response.sha256.clone()),
875                generation,
876                class,
877                None,
878            )
879            .await;
880    }
881
882    ResourceResponse::framed(
883        200,
884        vec![
885            (
886                "content-type".to_string(),
887                "application/octet-stream".to_string(),
888            ),
889            (
890                "etag".to_string(),
891                format!("\"sha256:{}\"", response.sha256),
892            ),
893            (
894                "x-murmur-mtime-ms".to_string(),
895                response.mtime_ms.to_string(),
896            ),
897            ("x-murmur-generation".to_string(), generation.to_string()),
898            ("x-murmur-containment".to_string(), class.to_string()),
899            ("x-murmur-export-root".to_string(), export.declared_root),
900        ],
901        response.bytes,
902    )
903}
904
905async fn refuse_list(
906    plane: &ResourcePlane,
907    root: &str,
908    error: &ResourceError,
909    generation: u64,
910    class: ContainmentClass,
911) -> ResourceResponse {
912    if let Some(trace) = &plane.trace {
913        trace
914            .write_resource_list(
915                root,
916                0,
917                0,
918                generation,
919                class,
920                error.code(),
921                Some(error.message()),
922            )
923            .await;
924    }
925    error_response(error, Some(generation))
926}
927
928async fn refuse_read(
929    plane: &ResourcePlane,
930    path: &str,
931    error: &ResourceError,
932    generation: u64,
933    class: ContainmentClass,
934) -> ResourceResponse {
935    if let Some(trace) = &plane.trace {
936        trace
937            .write_resource_read(
938                path,
939                error.code(),
940                None,
941                None,
942                generation,
943                class,
944                Some(error.message()),
945            )
946            .await;
947    }
948    error_response(error, Some(generation))
949}
950
951/// The requested path as the trace records it: percent-decoded, lossily where the bytes are not
952/// UTF-8, and otherwise untouched.
953fn decoded_request_path(raw: &str) -> String {
954    String::from_utf8_lossy(&percent_decode(raw)).into_owned()
955}
956
957fn error_response(error: &ResourceError, generation: Option<u64>) -> ResourceResponse {
958    let body = serde_json::json!({"error": error.code(), "message": error.message()});
959    let mut headers = vec![("content-type".to_string(), "application/json".to_string())];
960    if let Some(generation) = generation {
961        headers.push(("x-murmur-generation".to_string(), generation.to_string()));
962    }
963    if matches!(error, ResourceError::MethodNotAllowed) {
964        headers.push(("allow".to_string(), "GET".to_string()));
965    }
966    ResourceResponse::framed(
967        error.status(),
968        headers,
969        serde_json::to_vec(&body).unwrap_or_default(),
970    )
971}
972
973/// The reason phrase for a status either plane can return. Only these appear; anything else is a
974/// programming error rather than a response shape callers depend on.
975pub fn reason_phrase(status: u16) -> &'static str {
976    match status {
977        200 => "OK",
978        400 => "Bad Request",
979        401 => "Unauthorized",
980        403 => "Forbidden",
981        404 => "Not Found",
982        405 => "Method Not Allowed",
983        409 => "Conflict",
984        410 => "Gone",
985        413 => "Payload Too Large",
986        415 => "Unsupported Media Type",
987        422 => "Unprocessable Entity",
988        _ => "Internal Server Error",
989    }
990}
991
992#[cfg(test)]
993mod tests {
994    use super::*;
995    use std::io::Write;
996
997    fn export_for(root: &Path, max_bytes: u64) -> DeclaredExport {
998        DeclaredExport {
999            declared_root: "out/".to_string(),
1000            root: root.to_path_buf(),
1001            mode: ExportMode::ReadOnly,
1002            max_bytes,
1003        }
1004    }
1005
1006    // ── percent-decoding and path rejection ──────────────────────────────────
1007
1008    #[test]
1009    fn percent_decoding_happens_before_validation() {
1010        // The whole reason decoding is first: validated as written, this looks like one opaque
1011        // component and reaches the filesystem as `../secret.txt`.
1012        assert_eq!(
1013            export_relpath_components("%2e%2e%2fsecret.txt"),
1014            Err(ResourceError::OutsideRoot)
1015        );
1016        assert_eq!(
1017            export_relpath_components("%2E%2E/secret.txt"),
1018            Err(ResourceError::OutsideRoot)
1019        );
1020    }
1021
1022    #[test]
1023    fn percent_decoding_accepts_ordinary_escapes() {
1024        assert_eq!(
1025            export_relpath_components("sub%20dir/report.md").unwrap(),
1026            vec!["sub dir".to_string(), "report.md".to_string()]
1027        );
1028    }
1029
1030    #[test]
1031    fn a_lone_percent_is_left_as_written() {
1032        assert_eq!(percent_decode("100%"), b"100%".to_vec());
1033        assert_eq!(percent_decode("%zz"), b"%zz".to_vec());
1034    }
1035
1036    #[test]
1037    fn every_escaping_shape_is_refused() {
1038        for path in [
1039            "",
1040            "..",
1041            "../secret.txt",
1042            "out/../../secret.txt",
1043            "/etc/passwd",
1044            "/",
1045            "./report.md",
1046            "a//b",
1047            "a/./b",
1048            "a/../b",
1049            "report.md%00.txt",
1050            "%00",
1051        ] {
1052            assert_eq!(
1053                export_relpath_components(path),
1054                Err(ResourceError::OutsideRoot),
1055                "path {path:?} must be refused"
1056            );
1057        }
1058    }
1059
1060    #[test]
1061    fn a_trailing_slash_names_the_same_file() {
1062        assert_eq!(
1063            export_relpath_components("dir/report.md/").unwrap(),
1064            vec!["dir".to_string(), "report.md".to_string()]
1065        );
1066    }
1067
1068    #[test]
1069    fn ordinary_relative_paths_survive() {
1070        assert_eq!(
1071            export_relpath_components("a/b/c.txt").unwrap(),
1072            vec!["a".to_string(), "b".to_string(), "c.txt".to_string()]
1073        );
1074    }
1075
1076    // ── the class-keyed symlink decision ─────────────────────────────────────
1077
1078    #[test]
1079    fn symlink_policy_covers_every_containment_class() {
1080        assert_eq!(
1081            symlink_policy(ContainmentClass::Scoped),
1082            SymlinkPolicy::Refuse
1083        );
1084        assert_eq!(
1085            symlink_policy(ContainmentClass::Sealed),
1086            SymlinkPolicy::FollowWithinRoot
1087        );
1088        assert_eq!(
1089            symlink_policy(ContainmentClass::Advisory),
1090            SymlinkPolicy::FollowWithinRoot
1091        );
1092    }
1093
1094    #[test]
1095    fn an_escaping_symlink_is_refused_at_every_class() {
1096        let dir = tempfile::tempdir().unwrap();
1097        let secret = dir.path().join("secret.txt");
1098        std::fs::write(&secret, b"top secret").unwrap();
1099        let root = dir.path().join("out");
1100        std::fs::create_dir(&root).unwrap();
1101        std::os::unix::fs::symlink(&secret, root.join("escape.txt")).unwrap();
1102        let export = export_for(&root, 1024);
1103
1104        for class in [
1105            ContainmentClass::Advisory,
1106            ContainmentClass::Scoped,
1107            ContainmentClass::Sealed,
1108        ] {
1109            let error = read_export_file_with_policy(&export, "escape.txt", symlink_policy(class))
1110                .expect_err("an escaping symlink must never be served");
1111            let expected = match class {
1112                ContainmentClass::Scoped => ResourceError::SymlinkRefused,
1113                _ => ResourceError::OutsideRoot,
1114            };
1115            assert_eq!(error, expected, "class {class}");
1116
1117            let entries = list_export_files(&export, symlink_policy(class)).unwrap();
1118            assert!(
1119                entries.is_empty(),
1120                "class {class}: an escaping symlink must not be listed; got {entries:?}"
1121            );
1122        }
1123    }
1124
1125    #[test]
1126    fn an_in_root_symlink_is_refused_under_scoped_and_followed_otherwise() {
1127        let dir = tempfile::tempdir().unwrap();
1128        let root = dir.path().join("out");
1129        std::fs::create_dir(&root).unwrap();
1130        std::fs::write(root.join("real.txt"), b"real bytes").unwrap();
1131        std::os::unix::fs::symlink("real.txt", root.join("alias.txt")).unwrap();
1132        let export = export_for(&root, 1024);
1133
1134        assert_eq!(
1135            read_export_file_with_policy(&export, "alias.txt", SymlinkPolicy::Refuse),
1136            Err(ResourceError::SymlinkRefused)
1137        );
1138        assert_eq!(
1139            list_export_files(&export, SymlinkPolicy::Refuse)
1140                .unwrap()
1141                .iter()
1142                .map(|entry| entry.path.clone())
1143                .collect::<Vec<_>>(),
1144            vec!["real.txt".to_string()]
1145        );
1146
1147        let followed =
1148            read_export_file_with_policy(&export, "alias.txt", SymlinkPolicy::FollowWithinRoot)
1149                .unwrap();
1150        assert_eq!(followed.bytes, b"real bytes");
1151        assert_eq!(
1152            list_export_files(&export, SymlinkPolicy::FollowWithinRoot)
1153                .unwrap()
1154                .len(),
1155            2
1156        );
1157    }
1158
1159    #[test]
1160    fn a_symlinked_directory_on_the_path_is_refused_under_scoped() {
1161        let dir = tempfile::tempdir().unwrap();
1162        let root = dir.path().join("out");
1163        std::fs::create_dir_all(root.join("real")).unwrap();
1164        std::fs::write(root.join("real/report.md"), b"hello").unwrap();
1165        std::os::unix::fs::symlink("real", root.join("alias")).unwrap();
1166        let export = export_for(&root, 1024);
1167
1168        assert_eq!(
1169            read_export_file_with_policy(&export, "alias/report.md", SymlinkPolicy::Refuse),
1170            Err(ResourceError::SymlinkRefused)
1171        );
1172    }
1173
1174    // ── read semantics ───────────────────────────────────────────────────────
1175
1176    #[test]
1177    fn a_read_hashes_the_bytes_it_serves() {
1178        let dir = tempfile::tempdir().unwrap();
1179        let root = dir.path().join("out");
1180        std::fs::create_dir(&root).unwrap();
1181        std::fs::write(root.join("report.md"), b"# report\n").unwrap();
1182        let response = read_export_file_with_policy(
1183            &export_for(&root, 1024),
1184            "report.md",
1185            SymlinkPolicy::FollowWithinRoot,
1186        )
1187        .unwrap();
1188        assert_eq!(response.bytes, b"# report\n");
1189        assert_eq!(response.sha256, murmur_artifact::sha256_hex(b"# report\n"));
1190        assert!(response.mtime_ms > 0);
1191    }
1192
1193    #[test]
1194    fn max_bytes_refuses_the_read_but_not_the_listing() {
1195        let dir = tempfile::tempdir().unwrap();
1196        let root = dir.path().join("out");
1197        std::fs::create_dir(&root).unwrap();
1198        std::fs::write(root.join("small.txt"), vec![b'a'; 100]).unwrap();
1199        std::fs::write(root.join("big.bin"), vec![b'b'; 4096]).unwrap();
1200        let export = export_for(&root, 1024);
1201
1202        assert_eq!(
1203            read_export_file_with_policy(&export, "big.bin", SymlinkPolicy::FollowWithinRoot),
1204            Err(ResourceError::TooLarge { max_bytes: 1024 })
1205        );
1206        assert_eq!(
1207            read_export_file_with_policy(&export, "small.txt", SymlinkPolicy::FollowWithinRoot)
1208                .unwrap()
1209                .bytes
1210                .len(),
1211            100
1212        );
1213
1214        let entries = list_export_files(&export, SymlinkPolicy::FollowWithinRoot).unwrap();
1215        let big = entries.iter().find(|e| e.path == "big.bin").unwrap();
1216        assert_eq!(
1217            big.size_bytes, 4096,
1218            "discovery must not hide an oversized file"
1219        );
1220    }
1221
1222    #[test]
1223    fn a_directory_is_not_a_regular_file() {
1224        let dir = tempfile::tempdir().unwrap();
1225        let root = dir.path().join("out");
1226        std::fs::create_dir_all(root.join("nested")).unwrap();
1227        assert_eq!(
1228            read_export_file_with_policy(
1229                &export_for(&root, 1024),
1230                "nested",
1231                SymlinkPolicy::FollowWithinRoot
1232            ),
1233            Err(ResourceError::NotARegularFile)
1234        );
1235    }
1236
1237    #[test]
1238    fn a_missing_root_lists_empty_and_reads_not_found() {
1239        let dir = tempfile::tempdir().unwrap();
1240        let root = dir.path().join("out");
1241        let export = export_for(&root, 1024);
1242        assert_eq!(
1243            list_export_files(&export, SymlinkPolicy::FollowWithinRoot).unwrap(),
1244            Vec::new()
1245        );
1246        assert_eq!(
1247            read_export_file_with_policy(&export, "report.md", SymlinkPolicy::FollowWithinRoot),
1248            Err(ResourceError::NotFound)
1249        );
1250    }
1251
1252    #[test]
1253    fn a_listing_is_recursive_and_sorted() {
1254        let dir = tempfile::tempdir().unwrap();
1255        let root = dir.path().join("out");
1256        std::fs::create_dir_all(root.join("b/c")).unwrap();
1257        std::fs::write(root.join("z.txt"), b"z").unwrap();
1258        std::fs::write(root.join("b/y.txt"), b"y").unwrap();
1259        std::fs::write(root.join("b/c/x.txt"), b"x").unwrap();
1260        let entries = list_export_files(&export_for(&root, 1024), SymlinkPolicy::Refuse).unwrap();
1261        assert_eq!(
1262            entries
1263                .iter()
1264                .map(|entry| entry.path.clone())
1265                .collect::<Vec<_>>(),
1266            vec![
1267                "b/c/x.txt".to_string(),
1268                "b/y.txt".to_string(),
1269                "z.txt".to_string()
1270            ]
1271        );
1272        assert_eq!(entries[2].sha256, murmur_artifact::sha256_hex(b"z"));
1273    }
1274
1275    #[test]
1276    fn a_fifo_is_refused_without_blocking() {
1277        let dir = tempfile::tempdir().unwrap();
1278        let root = dir.path().join("out");
1279        std::fs::create_dir(&root).unwrap();
1280        let fifo = root.join("pipe");
1281        let status = std::process::Command::new("mkfifo")
1282            .arg(&fifo)
1283            .status()
1284            .expect("mkfifo should be runnable");
1285        if !status.success() {
1286            return;
1287        }
1288        assert_eq!(
1289            read_export_file_with_policy(
1290                &export_for(&root, 1024),
1291                "pipe",
1292                SymlinkPolicy::FollowWithinRoot
1293            ),
1294            Err(ResourceError::NotARegularFile)
1295        );
1296        assert!(
1297            list_export_files(&export_for(&root, 1024), SymlinkPolicy::Refuse)
1298                .unwrap()
1299                .is_empty()
1300        );
1301    }
1302
1303    // ── error table ──────────────────────────────────────────────────────────
1304
1305    #[test]
1306    fn every_error_maps_to_its_documented_status() {
1307        for (error, code, status) in [
1308            (ResourceError::NoResourcePlane, "no_resource_plane", 404),
1309            (ResourceError::NotFound, "not_found", 404),
1310            (ResourceError::OutsideRoot, "outside_root", 403),
1311            (ResourceError::SymlinkRefused, "symlink_refused", 403),
1312            (ResourceError::NotARegularFile, "not_a_regular_file", 403),
1313            (ResourceError::TooLarge { max_bytes: 1 }, "too_large", 413),
1314            (ResourceError::MethodNotAllowed, "method_not_allowed", 405),
1315            (ResourceError::IoError("x".into()), "io_error", 500),
1316        ] {
1317            assert_eq!(error.code(), code);
1318            assert_eq!(error.status(), status);
1319        }
1320    }
1321
1322    // ── the launch-time root check ───────────────────────────────────────────
1323
1324    fn file_export(root: &str) -> FileExport {
1325        FileExport {
1326            root: root.to_string(),
1327            mode: ExportMode::ReadOnly,
1328            max_bytes: 1024,
1329        }
1330    }
1331
1332    /// The refusal is at launch rather than per request, so the first file never leaves: a root
1333    /// that already exists as a symlink out of the workdir stops the session before it starts.
1334    #[test]
1335    fn a_root_that_already_escapes_the_workdir_refuses_the_launch() {
1336        let workdir = tempfile::tempdir().unwrap();
1337        let outside = tempfile::tempdir().unwrap();
1338        std::os::unix::fs::symlink(outside.path(), workdir.path().join("out")).unwrap();
1339
1340        let error = check_export_root(workdir.path(), &file_export("out/")).unwrap_err();
1341        let RuntimeError::ExportRootOutsideWorkdir {
1342            declared, resolved, ..
1343        } = &error
1344        else {
1345            panic!("expected ExportRootOutsideWorkdir, got {error:?}");
1346        };
1347        // Both paths are named, because "outside the workdir" is not actionable without saying
1348        // where the root actually landed.
1349        assert_eq!(declared, "out/");
1350        assert_eq!(
1351            Path::new(resolved),
1352            std::fs::canonicalize(outside.path()).unwrap()
1353        );
1354    }
1355
1356    /// A root the agent has not created yet is not an escape. `exports.files.root` is explicitly
1357    /// not required to exist at launch, and every request resolves it afresh.
1358    #[test]
1359    fn a_root_that_does_not_exist_yet_is_accepted() {
1360        let workdir = tempfile::tempdir().unwrap();
1361        assert!(check_export_root(workdir.path(), &file_export("out/")).is_ok());
1362
1363        std::fs::create_dir(workdir.path().join("out")).unwrap();
1364        assert!(check_export_root(workdir.path(), &file_export("out/")).is_ok());
1365    }
1366
1367    /// A workdir that does not exist yet — no `--workdir`, so the session directory is created
1368    /// after this check — has nothing under it that could already point elsewhere.
1369    #[test]
1370    fn a_workdir_that_does_not_exist_yet_is_accepted() {
1371        let parent = tempfile::tempdir().unwrap();
1372        let missing = parent.path().join("not-created-yet");
1373        assert!(check_export_root(&missing, &file_export("out/")).is_ok());
1374    }
1375
1376    /// A symlink that stays inside the workdir is not an escape: the check asks where the root
1377    /// landed, not how it was spelled.
1378    #[test]
1379    fn a_root_symlinked_within_the_workdir_is_accepted() {
1380        let workdir = tempfile::tempdir().unwrap();
1381        std::fs::create_dir(workdir.path().join("real")).unwrap();
1382        std::os::unix::fs::symlink(workdir.path().join("real"), workdir.path().join("out"))
1383            .unwrap();
1384
1385        assert!(check_export_root(workdir.path(), &file_export("out/")).is_ok());
1386    }
1387
1388    // ── the handler ──────────────────────────────────────────────────────────
1389
1390    fn plane_for(export: Option<DeclaredExport>, class: ContainmentClass) -> ResourcePlane {
1391        ResourcePlane::with_export(export, class, Arc::new(AtomicU64::new(0)), None)
1392    }
1393
1394    #[tokio::test]
1395    async fn an_undeclared_plane_denies_both_verbs() {
1396        let plane = plane_for(None, ContainmentClass::Advisory);
1397        for path in ["/resources/files", "/resources/files/report.md"] {
1398            let response = handle_resource_request(&plane, "GET", path).await;
1399            assert_eq!(response.status, 404);
1400            let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1401            assert_eq!(body["error"], "no_resource_plane");
1402        }
1403    }
1404
1405    #[tokio::test]
1406    async fn every_write_verb_is_refused_with_an_allow_header() {
1407        let dir = tempfile::tempdir().unwrap();
1408        let root = dir.path().join("out");
1409        std::fs::create_dir(&root).unwrap();
1410        std::fs::write(root.join("report.md"), b"hello").unwrap();
1411        let plane = plane_for(Some(export_for(&root, 1024)), ContainmentClass::Advisory);
1412
1413        for method in ["POST", "PUT", "PATCH", "DELETE"] {
1414            let response =
1415                handle_resource_request(&plane, method, "/resources/files/report.md").await;
1416            assert_eq!(response.status, 405, "{method}");
1417            assert!(response
1418                .headers
1419                .iter()
1420                .any(|(name, value)| name == "allow" && value == "GET"));
1421        }
1422        assert_eq!(
1423            std::fs::read(root.join("report.md")).unwrap(),
1424            b"hello".to_vec()
1425        );
1426        assert!(!root.join("new.txt").exists());
1427    }
1428
1429    #[tokio::test]
1430    async fn a_read_carries_its_validator_and_provenance() {
1431        let dir = tempfile::tempdir().unwrap();
1432        let root = dir.path().join("out");
1433        std::fs::create_dir(&root).unwrap();
1434        let mut file = std::fs::File::create(root.join("report.md")).unwrap();
1435        file.write_all(b"# report\n").unwrap();
1436        drop(file);
1437
1438        let generation = Arc::new(AtomicU64::new(7));
1439        let plane = ResourcePlane::with_export(
1440            Some(export_for(&root, 1024)),
1441            ContainmentClass::Scoped,
1442            Arc::clone(&generation),
1443            None,
1444        );
1445        let response = handle_resource_request(&plane, "GET", "/resources/files/report.md").await;
1446        assert_eq!(response.status, 200);
1447        assert_eq!(response.body, b"# report\n");
1448        let header = |name: &str| {
1449            response
1450                .headers
1451                .iter()
1452                .find(|(key, _)| key == name)
1453                .map(|(_, value)| value.clone())
1454                .unwrap_or_default()
1455        };
1456        assert_eq!(
1457            header("etag"),
1458            format!("\"sha256:{}\"", murmur_artifact::sha256_hex(b"# report\n"))
1459        );
1460        assert_eq!(header("x-murmur-generation"), "7");
1461        assert_eq!(header("x-murmur-containment"), "scoped");
1462        assert_eq!(header("x-murmur-export-root"), "out/");
1463    }
1464
1465    #[tokio::test]
1466    async fn a_query_string_does_not_select_a_generation() {
1467        let dir = tempfile::tempdir().unwrap();
1468        let root = dir.path().join("out");
1469        std::fs::create_dir(&root).unwrap();
1470        std::fs::write(root.join("report.md"), b"now").unwrap();
1471        let plane = ResourcePlane::with_export(
1472            Some(export_for(&root, 1024)),
1473            ContainmentClass::Advisory,
1474            Arc::new(AtomicU64::new(3)),
1475            None,
1476        );
1477        let response =
1478            handle_resource_request(&plane, "GET", "/resources/files/report.md?generation=1").await;
1479        assert_eq!(response.status, 200);
1480        assert_eq!(response.body, b"now");
1481    }
1482
1483    #[tokio::test]
1484    async fn a_list_reports_the_declared_block_verbatim() {
1485        let dir = tempfile::tempdir().unwrap();
1486        let root = dir.path().join("out");
1487        std::fs::create_dir(&root).unwrap();
1488        std::fs::write(root.join("report.md"), b"hello").unwrap();
1489        let plane = plane_for(Some(export_for(&root, 4096)), ContainmentClass::Advisory);
1490        let response = handle_resource_request(&plane, "GET", "/resources/files").await;
1491        assert_eq!(response.status, 200);
1492        let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1493        assert_eq!(body["root"], "out/");
1494        assert_eq!(body["mode"], "read-only");
1495        assert_eq!(body["max_bytes"], 4096);
1496        assert_eq!(body["generation"], 0);
1497        assert_eq!(body["containment_achieved"], "advisory");
1498        assert_eq!(body["entries"][0]["path"], "report.md");
1499        assert_eq!(body["entries"][0]["size_bytes"], 5);
1500    }
1501
1502    #[tokio::test]
1503    async fn an_unknown_route_under_the_prefix_is_not_found() {
1504        let plane = plane_for(None, ContainmentClass::Advisory);
1505        let response = handle_resource_request(&plane, "GET", "/resources/secrets").await;
1506        assert_eq!(response.status, 404);
1507        let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1508        assert_eq!(body["error"], "not_found");
1509    }
1510
1511    /// There is no write path anywhere under the prefix, including at a path that names no route.
1512    #[tokio::test]
1513    async fn an_unknown_route_still_answers_the_method_first() {
1514        let plane = plane_for(None, ContainmentClass::Advisory);
1515        let response = handle_resource_request(&plane, "PUT", "/resources/secrets").await;
1516        assert_eq!(response.status, 405);
1517        assert!(response
1518            .headers
1519            .iter()
1520            .any(|(name, value)| name == "allow" && value == "GET"));
1521    }
1522
1523    /// Every response frames itself, so a transport never has to know it owed a `content-length`
1524    /// and a caller can tell a complete body from a truncated one.
1525    #[tokio::test]
1526    async fn every_response_carries_its_own_content_length() {
1527        let dir = tempfile::tempdir().unwrap();
1528        let root = dir.path().join("out");
1529        std::fs::create_dir(&root).unwrap();
1530        std::fs::write(root.join("report.md"), b"hello").unwrap();
1531        let plane = plane_for(Some(export_for(&root, 1024)), ContainmentClass::Advisory);
1532
1533        for (method, path) in [
1534            ("GET", "/resources/files"),
1535            ("GET", "/resources/files/report.md"),
1536            ("GET", "/resources/files/../secret.txt"),
1537            ("PUT", "/resources/files/report.md"),
1538        ] {
1539            let response = handle_resource_request(&plane, method, path).await;
1540            let declared = response
1541                .headers
1542                .iter()
1543                .find(|(name, _)| name == "content-length")
1544                .map(|(_, value)| value.clone())
1545                .unwrap_or_else(|| panic!("{method} {path} must declare a content-length"));
1546            assert_eq!(
1547                declared,
1548                response.body.len().to_string(),
1549                "{method} {path}: content-length must match the body served"
1550            );
1551        }
1552    }
1553}