Files
continuum-proxy/src/uplink/download.rs
T
octoturge 47290175aa
Windows discovery tool / windows (push) Successful in 57s
Accept partial ams objects; allow plain-HTTP downloads from a local store on request
A P1P mid-print pushes ams objects with no unit list; every such update was
dropped as "missing field ams", silencing telemetry for the rest of the print
(found on the first real dispatch). The unit list now defaults to empty and a
partial object does not wipe known units.

CONTINUUM_ALLOW_INSECURE_LOCAL_DOWNLOADS=true lets the downloader fetch a
presigned URL over plain HTTP, but only from a loopback or RFC 1918 address.
2026-10-29 22:13:47 +00:00

151 lines
6.3 KiB
Rust

//! Fetching a job file from the presigned URL a `start_print` command carries.
//!
//! The URL is attacker-influenceable in the worst case (a misconfigured or
//! compromised cloud, a bad object store) and the body is held in memory
//! because `upload_project_file` takes a `Vec<u8>`, so everything here is
//! bounded: size, time, redirects and scheme.
use std::time::Duration;
/// Hard ceiling on the body. A sliced `.3mf` is tens of megabytes (62 MB has
/// been seen for a multi-plate project); 256 MiB is roughly four times that,
/// so no honest job is refused, while a hostile URL cannot make a gateway on
/// small hardware (a Pi-class box is the target) buffer its way into the OOM
/// killer. Also well above anything a printer's own storage would take.
pub const MAX_JOB_FILE_BYTES: u64 = 256 * 1024 * 1024;
/// Whole-request budget, connect through last byte. 256 MiB at the slowest
/// link worth supporting (~2 MiB/s) is just over two minutes, so 3 minutes
/// covers the cap itself; anything slower than that is a stalled transfer, not
/// a slow one. Presigned URLs live for minutes, so a longer wait would only
/// be spent on a URL about to expire. Note `dispatch` runs inside the uplink
/// loop, so this is also the longest a download can hold up heartbeats.
pub const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(180);
/// Separate and short: an unreachable host should fail in seconds, not use
/// up the whole budget above.
pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
/// Dev-only escape hatch for a local object store (MinIO) that speaks plain
/// HTTP: needs `CONTINUUM_ALLOW_INSECURE_LOCAL_DOWNLOADS=true` AND a loopback
/// or private-network (RFC 1918) address literal, so a presigned URL can never
/// cross a public network in the clear.
fn insecure_local_allowed(url: &reqwest::Url) -> bool {
use std::net::IpAddr;
if url.scheme() != "http"
|| !std::env::var("CONTINUUM_ALLOW_INSECURE_LOCAL_DOWNLOADS").is_ok_and(|v| v == "true")
{
return false;
}
let Some(ip) = url.host_str().and_then(|h| h.trim_matches(['[', ']']).parse::<IpAddr>().ok()) else {
return false;
};
match ip {
IpAddr::V4(v4) => v4.is_loopback() || v4.is_private(),
IpAddr::V6(v6) => v6.is_loopback(),
}
}
/// Downloads `url` fully into memory, or says why not.
///
/// The error is a `String` naming the failure; the caller prefixes the stage.
pub async fn fetch_job_file(url: &str) -> Result<Vec<u8>, String> {
fetch_job_file_capped(url, MAX_JOB_FILE_BYTES).await
}
/// Same as [`fetch_job_file`] with the cap as a parameter, so tests can
/// exercise the limit without serving 256 MiB.
pub async fn fetch_job_file_capped(url: &str, max_bytes: u64) -> Result<Vec<u8>, String> {
let parsed = reqwest::Url::parse(url).map_err(|err| format!("not a valid URL: {err}"))?;
// A presigned URL carries its authority in the query string; sending it
// over plain HTTP would expose it to anyone on the path.
if parsed.scheme() != "https" && !insecure_local_allowed(&parsed) {
return Err(format!("refusing non-HTTPS URL (scheme '{}')", parsed.scheme()));
}
let client = reqwest::Client::builder()
.connect_timeout(CONNECT_TIMEOUT)
.timeout(DOWNLOAD_TIMEOUT)
// A presigned GET answers directly. Following a redirect would carry
// us somewhere the signature never vouched for.
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|err| format!("could not build HTTP client: {err}"))?;
let mut response = client.get(parsed).send().await.map_err(|err| describe(&err))?;
// Status before body. An expired presigned URL is a 403 with an XML
// error document, which must never be mistaken for a project file.
let status = response.status();
if !status.is_success() {
let hint = if status == reqwest::StatusCode::FORBIDDEN {
" (the presigned URL has probably expired)"
} else {
""
};
return Err(format!("server answered HTTP {status}{hint}"));
}
let expected = response.content_length();
if let Some(len) = expected.filter(|len| *len > max_bytes) {
return Err(format!("file is {len} bytes, over the {max_bytes} byte limit"));
}
// Stream into a buffer so the cap holds even when the server sends no
// Content-Length or lies about it.
let mut body = Vec::with_capacity(expected.unwrap_or(0).min(max_bytes) as usize);
while let Some(chunk) = response.chunk().await.map_err(|err| describe(&err))? {
if body.len() as u64 + chunk.len() as u64 > max_bytes {
return Err(format!("file exceeds the {max_bytes} byte limit"));
}
body.extend_from_slice(&chunk);
}
// A `.3mf` is a zip: a truncated one has lost its central directory and
// is unreadable, so a short body is an error, not a smaller file.
if let Some(len) = expected.filter(|len| *len != body.len() as u64) {
return Err(format!("received {} bytes but Content-Length said {len}", body.len()));
}
if body.is_empty() {
return Err("server returned an empty body".into());
}
Ok(body)
}
/// reqwest's `Display` for a transport error is just "error sending request";
/// the useful part (timeout, connect, TLS) is in the flags and source chain.
fn describe(err: &reqwest::Error) -> String {
let kind = if err.is_timeout() {
"timed out"
} else if err.is_connect() {
"could not connect"
} else {
"transfer failed"
};
let mut msg = format!("{kind}: {err}");
let mut source = std::error::Error::source(err);
while let Some(cause) = source {
msg.push_str(&format!(": {cause}"));
source = cause.source();
}
// Never echo the URL back: its query string is the credential.
match err.url() {
Some(url) => msg.replace(url.as_str(), "<url>"),
None => msg,
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Checked before any connection is made, so no server is needed.
#[tokio::test]
async fn a_non_https_url_is_refused_before_any_request() {
let err = fetch_job_file("http://example.invalid/plate.3mf?sig=abc").await.unwrap_err();
assert!(err.contains("non-HTTPS"), "got: {err}");
assert!(fetch_job_file("not a url").await.is_err());
}
}