embacle_server/uhp/
files.rs1use 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
31pub const WORKDIR_ENV: &str = "UHP_WORKDIR";
33use super::state::UhpState;
34
35#[derive(Debug, Clone, Serialize)]
37pub struct Artifact {
38 pub id: String,
40 pub object: &'static str,
42 pub container_id: String,
44 pub filename: String,
46 pub bytes: u64,
48}
49
50#[derive(Debug, Clone, Serialize)]
52pub struct ArtifactList {
53 pub session_id: String,
55 pub files: Vec<Artifact>,
57}
58
59#[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
71fn artifact_id(relative: &str) -> String {
76 let mut hasher = DefaultHasher::new();
77 relative.hash(&mut hasher);
78 format!("file_{:016x}", hasher.finish())
79}
80
81fn 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 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
123pub 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
131pub 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
155pub 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 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#[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"); 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");
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 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"); fs::write(root.join("a.txt"), b"x").expect("write"); 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 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}