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;
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;
30
31/// Environment variable naming where session working folders live.
32pub const WORKDIR_ENV: &str = "UHP_WORKDIR";
33use super::state::UhpState;
34
35/// One artifact a session produced.
36#[derive(Debug, Clone, Serialize)]
37pub struct Artifact {
38    /// `file_`-prefixed id.
39    pub id: String,
40    /// Always `file`.
41    pub object: &'static str,
42    /// The container it lives in, which is how it is addressed for download.
43    pub container_id: String,
44    /// Its name as the harness wrote it.
45    pub filename: String,
46    /// Size in bytes.
47    pub bytes: u64,
48}
49
50/// The artifacts of one session.
51#[derive(Debug, Clone, Serialize)]
52pub struct ArtifactList {
53    /// The session these belong to.
54    pub session_id: String,
55    /// Its artifacts.
56    pub files: Vec<Artifact>,
57}
58
59/// Where a session's tasks run, and therefore where its artifacts land.
60///
61/// One folder per session, because that is the unit the specification deletes:
62/// ยง6 says deleting a session removes "the working folder the session's tasks
63/// wrote".
64#[must_use]
65pub fn session_workdir(session_id: &str) -> PathBuf {
66    let base =
67        env::var(WORKDIR_ENV).map_or_else(|_| env::temp_dir().join("embacle-uhp"), PathBuf::from);
68    base.join(session_id)
69}
70
71/// A stable id for an artifact, derived from its path inside the session.
72///
73/// Derived rather than random so the same file keeps the same id across two
74/// listings โ€” a client that fetched an id must be able to use it.
75fn artifact_id(relative: &str) -> String {
76    let mut hasher = DefaultHasher::new();
77    relative.hash(&mut hasher);
78    format!("file_{:016x}", hasher.finish())
79}
80
81/// Every file a session's tasks wrote, walked from its working folder.
82fn walk(root: &FsPath, session_id: &str) -> Vec<Artifact> {
83    let mut found = Vec::new();
84    let mut stack = vec![root.to_path_buf()];
85
86    while let Some(dir) = stack.pop() {
87        let Ok(entries) = fs::read_dir(&dir) else {
88            continue;
89        };
90        for entry in entries.flatten() {
91            let path = entry.path();
92            if path.is_dir() {
93                stack.push(path);
94                continue;
95            }
96            let Ok(relative) = path.strip_prefix(root) else {
97                continue;
98            };
99            // Forward slashes on the wire, whatever the host separator. The
100            // filename is served over HTTP and is how the artifact is named to
101            // a client, so a Windows server emitting "notes\\deep.md" would
102            // hand back a path that differs from the same session's listing on
103            // Linux for the same file.
104            let relative = relative
105                .components()
106                .map(|c| c.as_os_str().to_string_lossy())
107                .collect::<Vec<_>>()
108                .join("/");
109            let bytes = entry.metadata().map_or(0, |m| m.len());
110            found.push(Artifact {
111                id: artifact_id(&relative),
112                object: "file",
113                container_id: session_id.to_owned(),
114                filename: relative,
115                bytes,
116            });
117        }
118    }
119    found.sort_by(|a, b| a.filename.cmp(&b.filename));
120    found
121}
122
123/// Remove a session's working folder, artifacts and all.
124pub fn remove_workdir(session_id: &str) {
125    let dir = session_workdir(session_id);
126    if dir.exists() {
127        let _ = fs::remove_dir_all(dir);
128    }
129}
130
131/// Handle `GET /v1/sessions/{session_id}/files`.
132///
133/// # Errors
134///
135/// Answers `404 session_not_found` when this server holds no such session.
136pub async fn session_files(
137    State(state): State<UhpState>,
138    Path(session_id): Path<String>,
139) -> Result<Json<ArtifactList>, UhpFailure> {
140    if state.store.session_turns(&session_id).is_none() {
141        return Err(UhpFailure::not_found(
142            "session_not_found",
143            "no session with that id exists",
144        ));
145    }
146    let root = session_workdir(&session_id);
147    let files = if root.is_dir() {
148        walk(&root, &session_id)
149    } else {
150        Vec::new()
151    };
152    Ok(Json(ArtifactList { session_id, files }))
153}
154
155/// Handle `GET /v1/containers/{container_id}/files/{file_id}/content`.
156///
157/// # Errors
158///
159/// Answers `404 file_not_found` when the container holds no artifact with that
160/// id โ€” including when the session was deleted, since its folder went with it.
161pub async fn container_file(
162    Path((container_id, file_id)): Path<(String, String)>,
163) -> Result<impl IntoResponse, UhpFailure> {
164    let root = session_workdir(&container_id);
165    let artifact = walk(&root, &container_id)
166        .into_iter()
167        .find(|a| a.id == file_id)
168        .ok_or_else(|| {
169            UhpFailure::not_found("file_not_found", "no artifact with that id exists")
170        })?;
171
172    // The stored name is wire-shaped, so rebuild the host path from its
173    // segments rather than joining a string that carries forward slashes.
174    let mut path = root;
175    for segment in artifact.filename.split('/') {
176        path.push(segment);
177    }
178    let bytes = fs::read(&path)
179        .map_err(|_| UhpFailure::not_found("file_not_found", "no artifact with that id exists"))?;
180
181    Ok((download_headers(&artifact.filename), bytes))
182}
183
184/// The headers every artifact download carries.
185///
186/// Kept as a named function so the download path and its test assert the same
187/// thing: an artifact is attacker-influenced content, and must never be
188/// sniffed into an executable type or rendered inline on this origin.
189#[must_use]
190pub fn download_headers(filename: &str) -> HeaderMap {
191    let mut headers = HeaderMap::new();
192    headers.insert(X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
193    headers.insert(
194        CONTENT_TYPE,
195        HeaderValue::from_static("application/octet-stream"),
196    );
197    if let Ok(value) = format!("attachment; filename=\"{filename}\"").parse() {
198        headers.insert(CONTENT_DISPOSITION, value);
199    }
200    headers
201}
202
203#[cfg(test)]
204mod tests {
205    use std::process;
206
207    use super::*;
208
209    #[test]
210    fn a_walk_finds_nested_files_and_ids_them_stably() {
211        let root = env::temp_dir().join(format!("uhp-walk-{}", process::id()));
212        let nested = root.join("notes");
213        fs::create_dir_all(&nested).expect("create"); // Safe: test assertion
214        fs::write(root.join("out.txt"), b"artifact-ok").expect("write"); // Safe: test assertion
215        fs::write(nested.join("deep.md"), b"nested").expect("write"); // Safe: test assertion
216
217        let found = walk(&root, "sess_x");
218        let names: Vec<&str> = found.iter().map(|a| a.filename.as_str()).collect();
219        assert_eq!(
220            names,
221            vec!["notes/deep.md", "out.txt"],
222            "a nested file is an artifact too, and its path is forward-slashed on every host \
223             because the filename goes out over HTTP"
224        );
225        assert_eq!(found[1].bytes, 11, "size is the file's, not a guess");
226        assert!(found.iter().all(|a| a.container_id == "sess_x"));
227
228        // Ids must be stable across listings, or an id a client fetched stops
229        // resolving on the next call.
230        let again = walk(&root, "sess_x");
231        assert_eq!(
232            found.iter().map(|a| a.id.clone()).collect::<Vec<_>>(),
233            again.iter().map(|a| a.id.clone()).collect::<Vec<_>>()
234        );
235
236        fs::remove_dir_all(&root).ok();
237    }
238
239    #[test]
240    fn a_removed_workdir_takes_its_artifacts_with_it() {
241        let session = format!("sess_rm{}", process::id());
242        let root = session_workdir(&session);
243        fs::create_dir_all(&root).expect("create"); // Safe: test assertion
244        fs::write(root.join("a.txt"), b"x").expect("write"); // Safe: test assertion
245        assert_eq!(walk(&root, &session).len(), 1);
246
247        remove_workdir(&session);
248        assert!(
249            !root.exists(),
250            "deleting a session must remove the folder its tasks wrote"
251        );
252    }
253
254    #[test]
255    fn an_artifact_download_is_never_sniffable_or_inline() {
256        // X-07 reads x-content-type-options directly. The other two headers are
257        // the same defence: an artifact is content a model was steered into
258        // writing, so it must not render as a page on this origin.
259        let h = download_headers("report.html");
260
261        assert_eq!(
262            h.get(X_CONTENT_TYPE_OPTIONS).and_then(|v| v.to_str().ok()),
263            Some("nosniff")
264        );
265        assert_eq!(
266            h.get(CONTENT_TYPE).and_then(|v| v.to_str().ok()),
267            Some("application/octet-stream"),
268            "never the artifact's own claimed type"
269        );
270        assert!(
271            h.get(CONTENT_DISPOSITION)
272                .and_then(|v| v.to_str().ok())
273                .is_some_and(|v| v.starts_with("attachment")),
274            "inline would let a crafted artifact run on this origin"
275        );
276    }
277}