From 07b9e1605d8bb120b3926f793950ab001ef408cb Mon Sep 17 00:00:00 2001 From: Timmy Date: Wed, 15 Jul 2026 16:12:00 +0100 Subject: [PATCH] Serve sled binary artifacts from ~/.huskies/artifacts/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/artifacts/:filename with filename validation (no path components, no dotfiles). Sleds only ever download binaries from their own gateway; this endpoint is where the gateway serves them from, replacing the current_exe()-based /api/huskies-binary which serves the gateway's own (macOS) binary — wrong platform for Linux sleds. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019fHdm92yjvguPi2LiXfLB9 --- server/src/http/mod.rs | 82 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/server/src/http/mod.rs b/server/src/http/mod.rs index cedc36f6..cf89a6ed 100644 --- a/server/src/http/mod.rs +++ b/server/src/http/mod.rs @@ -124,7 +124,8 @@ pub fn build_routes( route = route .at("/api/upgrade", post(upgrade_trigger_handler)) - .at("/api/huskies-binary", get(serve_binary_handler)); + .at("/api/huskies-binary", get(serve_binary_handler)) + .at("/api/artifacts/:filename", get(serve_artifact_handler)); if let Some(wa_ctx) = whatsapp_ctx { route = route.at( @@ -297,6 +298,56 @@ pub async fn serve_binary_handler() -> poem::Response { } } +/// Directory where the gateway stores distributable sled binaries. +/// +/// Host-global (`~/.huskies/artifacts/`), not per-project: one artifact serves +/// every sled behind this gateway. +pub fn artifacts_dir() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| "/home/huskies".to_string()); + PathBuf::from(home).join(".huskies").join("artifacts") +} + +/// Validate an artifact filename: plain name, no path components. +fn is_valid_artifact_name(name: &str) -> bool { + !name.is_empty() + && !name.starts_with('.') + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) +} + +/// `GET /api/artifacts/:filename` — serve a published sled binary. +/// +/// The gateway is the only place sleds download binaries from; where the +/// artifact came from (local build, release channel) is the gateway's concern. +/// Returns 400 for names with path components, 404 when the artifact does not +/// exist. +#[poem::handler] +pub async fn serve_artifact_handler( + poem::web::Path(filename): poem::web::Path, +) -> poem::Response { + if !is_valid_artifact_name(&filename) { + return poem::Response::builder() + .status(StatusCode::BAD_REQUEST) + .body("Invalid artifact name"); + } + + let path = artifacts_dir().join(&filename); + match tokio::fs::read(&path).await { + Ok(bytes) => poem::Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/octet-stream") + .header( + "Content-Disposition", + format!("attachment; filename=\"{filename}\""), + ) + .body(bytes), + Err(_) => poem::Response::builder() + .status(StatusCode::NOT_FOUND) + .body(format!("No artifact named {filename}")), + } +} + #[cfg(test)] mod tests { use super::*; @@ -353,6 +404,35 @@ mod tests { let _endpoint = build_routes(ctx, None, None, 3001, None); } + #[test] + fn artifact_name_validation() { + assert!(is_valid_artifact_name("huskies-linux-arm64")); + assert!(is_valid_artifact_name("huskies-linux-amd64.v2")); + assert!(!is_valid_artifact_name("")); + assert!(!is_valid_artifact_name("..")); + assert!(!is_valid_artifact_name(".hidden")); + assert!(!is_valid_artifact_name("a/b")); + assert!(!is_valid_artifact_name("../../etc/passwd")); + assert!(!is_valid_artifact_name("name with spaces")); + } + + #[tokio::test] + async fn artifact_endpoint_rejects_traversal_and_misses() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = context::AppContext::new_test(tmp.path().to_path_buf()); + let app = build_routes(ctx, None, None, 3001, None); + let cli = poem::test::TestClient::new(app); + + let resp = cli.get("/api/artifacts/..").send().await; + assert_eq!(resp.0.status(), StatusCode::BAD_REQUEST); + + let resp = cli + .get("/api/artifacts/definitely-not-a-real-artifact-xyz9") + .send() + .await; + assert_eq!(resp.0.status(), StatusCode::NOT_FOUND); + } + #[tokio::test] async fn version_endpoint_reports_version_and_git_hash() { let tmp = tempfile::tempdir().unwrap();