use std::collections::hash_map::DefaultHasher;
use std::env;
use std::fs;
use std::hash::{Hash, Hasher};
use std::path::{Path as FsPath, PathBuf};
use axum::extract::{Path, State};
use axum::http::header::{CONTENT_DISPOSITION, CONTENT_TYPE, X_CONTENT_TYPE_OPTIONS};
use axum::http::{HeaderMap, HeaderValue};
use axum::response::IntoResponse;
use axum::Json;
use serde::Serialize;
use super::error::UhpFailure;
pub const WORKDIR_ENV: &str = "UHP_WORKDIR";
use super::state::UhpState;
#[derive(Debug, Clone, Serialize)]
pub struct Artifact {
pub id: String,
pub object: &'static str,
pub container_id: String,
pub filename: String,
pub bytes: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct ArtifactList {
pub session_id: String,
pub files: Vec<Artifact>,
}
#[must_use]
pub fn session_workdir(session_id: &str) -> PathBuf {
let base =
env::var(WORKDIR_ENV).map_or_else(|_| env::temp_dir().join("embacle-uhp"), PathBuf::from);
base.join(session_id)
}
fn artifact_id(relative: &str) -> String {
let mut hasher = DefaultHasher::new();
relative.hash(&mut hasher);
format!("file_{:016x}", hasher.finish())
}
fn walk(root: &FsPath, session_id: &str) -> Vec<Artifact> {
let mut found = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
continue;
}
let Ok(relative) = path.strip_prefix(root) else {
continue;
};
let relative = relative
.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/");
let bytes = entry.metadata().map_or(0, |m| m.len());
found.push(Artifact {
id: artifact_id(&relative),
object: "file",
container_id: session_id.to_owned(),
filename: relative,
bytes,
});
}
}
found.sort_by(|a, b| a.filename.cmp(&b.filename));
found
}
pub fn remove_workdir(session_id: &str) {
let dir = session_workdir(session_id);
if dir.exists() {
let _ = fs::remove_dir_all(dir);
}
}
pub async fn session_files(
State(state): State<UhpState>,
Path(session_id): Path<String>,
) -> Result<Json<ArtifactList>, UhpFailure> {
if state.store.session_turns(&session_id).is_none() {
return Err(UhpFailure::not_found(
"session_not_found",
"no session with that id exists",
));
}
let root = session_workdir(&session_id);
let files = if root.is_dir() {
walk(&root, &session_id)
} else {
Vec::new()
};
Ok(Json(ArtifactList { session_id, files }))
}
pub async fn container_file(
Path((container_id, file_id)): Path<(String, String)>,
) -> Result<impl IntoResponse, UhpFailure> {
let root = session_workdir(&container_id);
let artifact = walk(&root, &container_id)
.into_iter()
.find(|a| a.id == file_id)
.ok_or_else(|| {
UhpFailure::not_found("file_not_found", "no artifact with that id exists")
})?;
let mut path = root;
for segment in artifact.filename.split('/') {
path.push(segment);
}
let bytes = fs::read(&path)
.map_err(|_| UhpFailure::not_found("file_not_found", "no artifact with that id exists"))?;
Ok((download_headers(&artifact.filename), bytes))
}
#[must_use]
pub fn download_headers(filename: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
headers.insert(
CONTENT_TYPE,
HeaderValue::from_static("application/octet-stream"),
);
if let Ok(value) = format!("attachment; filename=\"{filename}\"").parse() {
headers.insert(CONTENT_DISPOSITION, value);
}
headers
}
#[cfg(test)]
mod tests {
use std::process;
use super::*;
#[test]
fn a_walk_finds_nested_files_and_ids_them_stably() {
let root = env::temp_dir().join(format!("uhp-walk-{}", process::id()));
let nested = root.join("notes");
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");
let names: Vec<&str> = found.iter().map(|a| a.filename.as_str()).collect();
assert_eq!(
names,
vec!["notes/deep.md", "out.txt"],
"a nested file is an artifact too, and its path is forward-slashed on every host \
because the filename goes out over HTTP"
);
assert_eq!(found[1].bytes, 11, "size is the file's, not a guess");
assert!(found.iter().all(|a| a.container_id == "sess_x"));
let again = walk(&root, "sess_x");
assert_eq!(
found.iter().map(|a| a.id.clone()).collect::<Vec<_>>(),
again.iter().map(|a| a.id.clone()).collect::<Vec<_>>()
);
fs::remove_dir_all(&root).ok();
}
#[test]
fn a_removed_workdir_takes_its_artifacts_with_it() {
let session = format!("sess_rm{}", process::id());
let root = session_workdir(&session);
fs::create_dir_all(&root).expect("create"); fs::write(root.join("a.txt"), b"x").expect("write"); assert_eq!(walk(&root, &session).len(), 1);
remove_workdir(&session);
assert!(
!root.exists(),
"deleting a session must remove the folder its tasks wrote"
);
}
#[test]
fn an_artifact_download_is_never_sniffable_or_inline() {
let h = download_headers("report.html");
assert_eq!(
h.get(X_CONTENT_TYPE_OPTIONS).and_then(|v| v.to_str().ok()),
Some("nosniff")
);
assert_eq!(
h.get(CONTENT_TYPE).and_then(|v| v.to_str().ok()),
Some("application/octet-stream"),
"never the artifact's own claimed type"
);
assert!(
h.get(CONTENT_DISPOSITION)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.starts_with("attachment")),
"inline would let a crafted artifact run on this origin"
);
}
}