Skip to main content

embacle_server/uhp/
files.rs

1// ABOUTME: Session artifact listing and container file download over the UHP contract
2// ABOUTME: Downloads are raw bytes with nosniff, because an artifact is attacker-influenced
3//
4// SPDX-License-Identifier: Apache-2.0
5// Copyright (c) 2026 dravr.ai
6
7//! Files a session produced.
8//!
9//! An artifact is content a harness wrote, which means it is influenced by
10//! whatever the model was asked to do. Serving it inline would let a crafted
11//! artifact run as a page on this origin, so a download is raw bytes with
12//! `X-Content-Type-Options: nosniff` and a `Content-Disposition` of
13//! `attachment` — the browser is told, twice and unambiguously, not to
14//! interpret it.
15
16use std::collections::hash_map::DefaultHasher;
17use std::env;
18use std::fs::{self, Metadata};
19use std::hash::{Hash, Hasher};
20use std::path::{Path as FsPath, PathBuf};
21
22use axum::extract::{Path, State};
23use axum::http::header::{CONTENT_DISPOSITION, CONTENT_TYPE, X_CONTENT_TYPE_OPTIONS};
24use axum::http::{HeaderMap, HeaderValue};
25use axum::response::IntoResponse;
26use axum::Json;
27use serde::Serialize;
28
29use super::error::UhpFailure;
30use super::state::UhpState;
31use super::tasks::is_session_id;
32
33/// Environment variable naming where session working folders live.
34pub const WORKDIR_ENV: &str = "UHP_WORKDIR";
35
36/// One artifact a session produced.
37#[derive(Debug, Clone, Serialize)]
38pub struct Artifact {
39    /// `file_`-prefixed id.
40    pub id: String,
41    /// Always `file`.
42    pub object: &'static str,
43    /// The container it lives in, which is how it is addressed for download.
44    pub container_id: String,
45    /// Its name as the harness wrote it.
46    pub filename: String,
47    /// Size in bytes.
48    pub bytes: u64,
49}
50
51/// The artifacts of one session.
52#[derive(Debug, Clone, Serialize)]
53pub struct ArtifactList {
54    /// The session these belong to.
55    pub session_id: String,
56    /// Its artifacts.
57    pub files: Vec<Artifact>,
58}
59
60/// Where a session's tasks run, and therefore where its artifacts land.
61///
62/// One folder per session, because that is the unit the specification deletes:
63/// §6 says deleting a session removes "the working folder the session's tasks
64/// wrote".
65#[must_use]
66pub fn session_workdir(session_id: &str) -> PathBuf {
67    workdir_base().join(session_id)
68}
69
70/// The folder, beside every session's, where staged task inputs live.
71const STAGING_DIR: &str = ".staged";
72
73/// Where one task's staged inputs — the skill bundles its harness reads but
74/// did not write — are put.
75///
76/// Beside the session folders rather than inside one: everything inside a
77/// session's folder is listed as an artifact of that session, so a bundle
78/// staged there would come back to the client as though the harness had
79/// produced it. No minted session id can equal the staging folder's name.
80#[must_use]
81pub fn staging_dir(task_id: &str) -> PathBuf {
82    workdir_base().join(STAGING_DIR).join(task_id)
83}
84
85/// The folder every session's working folder sits directly inside.
86fn workdir_base() -> PathBuf {
87    env::var(WORKDIR_ENV).map_or_else(|_| env::temp_dir().join("embacle-uhp"), PathBuf::from)
88}
89
90/// Whether a session id a request carried names a session this server holds.
91///
92/// The shape is checked before the store is consulted, so an id that could
93/// never have been minted is refused on sight.
94fn holds_session(state: &UhpState, session_id: &str) -> bool {
95    is_session_id(session_id) && state.store.session_turns(session_id).is_some()
96}
97
98/// A held session's working folder, resolved through every symlink, when it is
99/// a real folder sitting directly inside the resolved workdir base.
100///
101/// Comparing against the base joined with the id, rather than checking a
102/// prefix, also refuses a session folder that is itself a link — to another
103/// session's folder as much as to anywhere else.
104fn confined_root(session_id: &str) -> Option<PathBuf> {
105    let base = fs::canonicalize(workdir_base()).ok()?;
106    let root = fs::canonicalize(base.join(session_id)).ok()?;
107    (root == base.join(session_id)).then_some(root)
108}
109
110/// `path` resolved through every symlink, with its metadata, when it lands on
111/// a regular file inside `root`, which must itself be resolved.
112///
113/// A harness writes links as easily as files, so a name inside the session
114/// folder proves nothing about where its bytes live.
115fn confined_file(path: &FsPath, root: &FsPath) -> Option<(PathBuf, Metadata)> {
116    let resolved = fs::canonicalize(path).ok()?;
117    if !resolved.starts_with(root) {
118        return None;
119    }
120    let metadata = fs::metadata(&resolved).ok()?;
121    metadata.is_file().then_some((resolved, metadata))
122}
123
124/// A stable id for an artifact, derived from its path inside the session.
125///
126/// Derived rather than random so the same file keeps the same id across two
127/// listings — a client that fetched an id must be able to use it.
128fn artifact_id(relative: &str) -> String {
129    let mut hasher = DefaultHasher::new();
130    relative.hash(&mut hasher);
131    format!("file_{:016x}", hasher.finish())
132}
133
134/// Every file a session's tasks wrote, walked from its working folder.
135///
136/// Only what lives inside the folder counts. A link to a folder is never
137/// descended into, since it could lead anywhere and a link cycle would multiply
138/// the walk exponentially, and a link to a file is listed only when it resolves
139/// to a regular file inside the same folder.
140fn walk(root: &FsPath, session_id: &str) -> Vec<Artifact> {
141    let mut found = Vec::new();
142    let Ok(root) = fs::canonicalize(root) else {
143        return found;
144    };
145    let mut stack = vec![root.clone()];
146
147    while let Some(dir) = stack.pop() {
148        let Ok(entries) = fs::read_dir(&dir) else {
149            continue;
150        };
151        for entry in entries.flatten() {
152            let path = entry.path();
153            // `file_type` does not follow a link, unlike `Path::is_dir`.
154            let Ok(kind) = entry.file_type() else {
155                continue;
156            };
157            if kind.is_dir() {
158                stack.push(path);
159                continue;
160            }
161            let Some((_, metadata)) = confined_file(&path, &root) else {
162                continue;
163            };
164            let Ok(relative) = path.strip_prefix(&root) else {
165                continue;
166            };
167            // Forward slashes on the wire, whatever the host separator. The
168            // filename is served over HTTP and is how the artifact is named to
169            // a client, so a Windows server emitting "notes\\deep.md" would
170            // hand back a path that differs from the same session's listing on
171            // Linux for the same file.
172            let relative = relative
173                .components()
174                .map(|c| c.as_os_str().to_string_lossy())
175                .collect::<Vec<_>>()
176                .join("/");
177            found.push(Artifact {
178                id: artifact_id(&relative),
179                object: "file",
180                container_id: session_id.to_owned(),
181                filename: relative,
182                bytes: metadata.len(),
183            });
184        }
185    }
186    found.sort_by(|a, b| a.filename.cmp(&b.filename));
187    found
188}
189
190/// Remove a session's working folder, artifacts and all.
191pub fn remove_workdir(session_id: &str) {
192    let dir = session_workdir(session_id);
193    if dir.exists() {
194        let _ = fs::remove_dir_all(dir);
195    }
196}
197
198/// Handle `GET /v1/sessions/{session_id}/files`.
199///
200/// # Errors
201///
202/// Answers `404 session_not_found` when this server holds no such session.
203pub async fn session_files(
204    State(state): State<UhpState>,
205    Path(session_id): Path<String>,
206) -> Result<Json<ArtifactList>, UhpFailure> {
207    if !holds_session(&state, &session_id) {
208        return Err(UhpFailure::not_found(
209            "session_not_found",
210            "no session with that id exists",
211        ));
212    }
213    let files = confined_root(&session_id).map_or_else(Vec::new, |root| walk(&root, &session_id));
214    Ok(Json(ArtifactList { session_id, files }))
215}
216
217/// Handle `GET /v1/containers/{container_id}/files/{file_id}/content`.
218///
219/// A container is a session's working folder, so the container id is held to
220/// everything a session id is: the shape this server mints, a session it
221/// holds, and a real folder inside the workdir base. The file is then served
222/// only if it still resolves inside that folder when it is read.
223///
224/// # Errors
225///
226/// Answers `404 file_not_found` when the container holds no artifact with that
227/// id — including when the session was deleted, since its folder went with it,
228/// and when the container id names no session this server holds. Every refusal
229/// is the same answer, so a probe learns nothing about what exists.
230pub async fn container_file(
231    State(state): State<UhpState>,
232    Path((container_id, file_id)): Path<(String, String)>,
233) -> Result<impl IntoResponse, UhpFailure> {
234    let not_found = || UhpFailure::not_found("file_not_found", "no artifact with that id exists");
235    if !holds_session(&state, &container_id) {
236        return Err(not_found());
237    }
238    let root = confined_root(&container_id).ok_or_else(not_found)?;
239    let artifact = walk(&root, &container_id)
240        .into_iter()
241        .find(|a| a.id == file_id)
242        .ok_or_else(not_found)?;
243
244    // The stored name is wire-shaped, so rebuild the host path from its
245    // segments rather than joining a string that carries forward slashes.
246    let mut path = root.clone();
247    for segment in artifact.filename.split('/') {
248        path.push(segment);
249    }
250    // Resolved again at read time: a link swapped in after the walk must not
251    // carry the read outside the folder.
252    let (resolved, _) = confined_file(&path, &root).ok_or_else(not_found)?;
253    let bytes = fs::read(&resolved).map_err(|_| not_found())?;
254
255    Ok((download_headers(&artifact.filename), bytes))
256}
257
258/// The headers every artifact download carries.
259///
260/// Kept as a named function so the download path and its test assert the same
261/// thing: an artifact is attacker-influenced content, and must never be
262/// sniffed into an executable type or rendered inline on this origin.
263#[must_use]
264pub fn download_headers(filename: &str) -> HeaderMap {
265    let mut headers = HeaderMap::new();
266    headers.insert(X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
267    headers.insert(
268        CONTENT_TYPE,
269        HeaderValue::from_static("application/octet-stream"),
270    );
271    if let Ok(value) = format!("attachment; filename=\"{filename}\"").parse() {
272        headers.insert(CONTENT_DISPOSITION, value);
273    }
274    headers
275}
276
277#[cfg(test)]
278mod tests {
279    use std::process;
280
281    use super::*;
282
283    #[test]
284    fn a_walk_finds_nested_files_and_ids_them_stably() {
285        let root = env::temp_dir().join(format!("uhp-walk-{}", process::id()));
286        let nested = root.join("notes");
287        fs::create_dir_all(&nested).expect("create"); // Safe: test assertion
288        fs::write(root.join("out.txt"), b"artifact-ok").expect("write"); // Safe: test assertion
289        fs::write(nested.join("deep.md"), b"nested").expect("write"); // Safe: test assertion
290
291        let found = walk(&root, "sess_x");
292        let names: Vec<&str> = found.iter().map(|a| a.filename.as_str()).collect();
293        assert_eq!(
294            names,
295            vec!["notes/deep.md", "out.txt"],
296            "a nested file is an artifact too, and its path is forward-slashed on every host \
297             because the filename goes out over HTTP"
298        );
299        assert_eq!(found[1].bytes, 11, "size is the file's, not a guess");
300        assert!(found.iter().all(|a| a.container_id == "sess_x"));
301
302        // Ids must be stable across listings, or an id a client fetched stops
303        // resolving on the next call.
304        let again = walk(&root, "sess_x");
305        assert_eq!(
306            found.iter().map(|a| a.id.clone()).collect::<Vec<_>>(),
307            again.iter().map(|a| a.id.clone()).collect::<Vec<_>>()
308        );
309
310        fs::remove_dir_all(&root).ok();
311    }
312
313    #[test]
314    fn a_removed_workdir_takes_its_artifacts_with_it() {
315        let session = format!("sess_rm{}", process::id());
316        let root = session_workdir(&session);
317        fs::create_dir_all(&root).expect("create"); // Safe: test assertion
318        fs::write(root.join("a.txt"), b"x").expect("write"); // Safe: test assertion
319        assert_eq!(walk(&root, &session).len(), 1);
320
321        remove_workdir(&session);
322        assert!(
323            !root.exists(),
324            "deleting a session must remove the folder its tasks wrote"
325        );
326    }
327
328    #[test]
329    fn an_artifact_download_is_never_sniffable_or_inline() {
330        // X-07 reads x-content-type-options directly. The other two headers are
331        // the same defence: an artifact is content a model was steered into
332        // writing, so it must not render as a page on this origin.
333        let h = download_headers("report.html");
334
335        assert_eq!(
336            h.get(X_CONTENT_TYPE_OPTIONS).and_then(|v| v.to_str().ok()),
337            Some("nosniff")
338        );
339        assert_eq!(
340            h.get(CONTENT_TYPE).and_then(|v| v.to_str().ok()),
341            Some("application/octet-stream"),
342            "never the artifact's own claimed type"
343        );
344        assert!(
345            h.get(CONTENT_DISPOSITION)
346                .and_then(|v| v.to_str().ok())
347                .is_some_and(|v| v.starts_with("attachment")),
348            "inline would let a crafted artifact run on this origin"
349        );
350    }
351}