Serve sled binary artifacts from ~/.huskies/artifacts/
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019fHdm92yjvguPi2LiXfLB9
This commit is contained in:
+81
-1
@@ -124,7 +124,8 @@ pub fn build_routes(
|
|||||||
|
|
||||||
route = route
|
route = route
|
||||||
.at("/api/upgrade", post(upgrade_trigger_handler))
|
.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 {
|
if let Some(wa_ctx) = whatsapp_ctx {
|
||||||
route = route.at(
|
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<String>,
|
||||||
|
) -> 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -353,6 +404,35 @@ mod tests {
|
|||||||
let _endpoint = build_routes(ctx, None, None, 3001, None);
|
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]
|
#[tokio::test]
|
||||||
async fn version_endpoint_reports_version_and_git_hash() {
|
async fn version_endpoint_reports_version_and_git_hash() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user