use std::collections::hash_map::DefaultHasher;
use std::env;
use std::fs::{self, Metadata};
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;
use super::state::UhpState;
use super::tasks::is_session_id;
pub const WORKDIR_ENV: &str = "UHP_WORKDIR";
#[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 {
workdir_base().join(session_id)
}
const STAGING_DIR: &str = ".staged";
#[must_use]
pub fn staging_dir(task_id: &str) -> PathBuf {
workdir_base().join(STAGING_DIR).join(task_id)
}
fn workdir_base() -> PathBuf {
env::var(WORKDIR_ENV).map_or_else(|_| env::temp_dir().join("embacle-uhp"), PathBuf::from)
}
fn holds_session(state: &UhpState, session_id: &str) -> bool {
is_session_id(session_id) && state.store.session_turns(session_id).is_some()
}
fn confined_root(session_id: &str) -> Option<PathBuf> {
let base = fs::canonicalize(workdir_base()).ok()?;
let root = fs::canonicalize(base.join(session_id)).ok()?;
(root == base.join(session_id)).then_some(root)
}
fn confined_file(path: &FsPath, root: &FsPath) -> Option<(PathBuf, Metadata)> {
let resolved = fs::canonicalize(path).ok()?;
if !resolved.starts_with(root) {
return None;
}
let metadata = fs::metadata(&resolved).ok()?;
metadata.is_file().then_some((resolved, metadata))
}
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 Ok(root) = fs::canonicalize(root) else {
return found;
};
let mut stack = vec![root.clone()];
while let Some(dir) = stack.pop() {
let Ok(entries) = fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(kind) = entry.file_type() else {
continue;
};
if kind.is_dir() {
stack.push(path);
continue;
}
let Some((_, metadata)) = confined_file(&path, &root) else {
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("/");
found.push(Artifact {
id: artifact_id(&relative),
object: "file",
container_id: session_id.to_owned(),
filename: relative,
bytes: metadata.len(),
});
}
}
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 !holds_session(&state, &session_id) {
return Err(UhpFailure::not_found(
"session_not_found",
"no session with that id exists",
));
}
let files = confined_root(&session_id).map_or_else(Vec::new, |root| walk(&root, &session_id));
Ok(Json(ArtifactList { session_id, files }))
}
pub async fn container_file(
State(state): State<UhpState>,
Path((container_id, file_id)): Path<(String, String)>,
) -> Result<impl IntoResponse, UhpFailure> {
let not_found = || UhpFailure::not_found("file_not_found", "no artifact with that id exists");
if !holds_session(&state, &container_id) {
return Err(not_found());
}
let root = confined_root(&container_id).ok_or_else(not_found)?;
let artifact = walk(&root, &container_id)
.into_iter()
.find(|a| a.id == file_id)
.ok_or_else(not_found)?;
let mut path = root.clone();
for segment in artifact.filename.split('/') {
path.push(segment);
}
let (resolved, _) = confined_file(&path, &root).ok_or_else(not_found)?;
let bytes = fs::read(&resolved).map_err(|_| not_found())?;
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"
);
}
}