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