embacle_server/uhp/
files.rs1use 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
33pub const WORKDIR_ENV: &str = "UHP_WORKDIR";
35
36#[derive(Debug, Clone, Serialize)]
38pub struct Artifact {
39 pub id: String,
41 pub object: &'static str,
43 pub container_id: String,
45 pub filename: String,
47 pub bytes: u64,
49}
50
51#[derive(Debug, Clone, Serialize)]
53pub struct ArtifactList {
54 pub session_id: String,
56 pub files: Vec<Artifact>,
58}
59
60#[must_use]
66pub fn session_workdir(session_id: &str) -> PathBuf {
67 workdir_base().join(session_id)
68}
69
70const STAGING_DIR: &str = ".staged";
72
73#[must_use]
81pub fn staging_dir(task_id: &str) -> PathBuf {
82 workdir_base().join(STAGING_DIR).join(task_id)
83}
84
85fn workdir_base() -> PathBuf {
87 env::var(WORKDIR_ENV).map_or_else(|_| env::temp_dir().join("embacle-uhp"), PathBuf::from)
88}
89
90fn holds_session(state: &UhpState, session_id: &str) -> bool {
95 is_session_id(session_id) && state.store.session_turns(session_id).is_some()
96}
97
98fn 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
110fn 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
124fn artifact_id(relative: &str) -> String {
129 let mut hasher = DefaultHasher::new();
130 relative.hash(&mut hasher);
131 format!("file_{:016x}", hasher.finish())
132}
133
134fn 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 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 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
190pub 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
198pub 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
217pub 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 let mut path = root.clone();
247 for segment in artifact.filename.split('/') {
248 path.push(segment);
249 }
250 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#[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"); fs::write(root.join("out.txt"), b"artifact-ok").expect("write"); fs::write(nested.join("deep.md"), b"nested").expect("write"); 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 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"); fs::write(root.join("a.txt"), b"x").expect("write"); 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 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}