Skip to main content

studio_worker/engine/
download.rs

1//! Shared model-file provisioning used by every real engine.
2//!
3//! The studio attaches a [`ModelSource`](crate::types::ModelSource) to
4//! each real offer listing the files the worker needs (diffusion model,
5//! GGUF, VAE, ...) with a public URL + filename each.  Engines fetch
6//! them on first use and cache them under their per-engine directory, so
7//! a fresh worker provisions itself with no manual model placement.
8//!
9//! The streamed body is checked against the server's `Content-Length`,
10//! so a truncated download is rejected and cleaned up instead of being
11//! renamed into place as a corrupt model that every later job fails to
12//! load.
13//!
14//! Every download emits a structured `tracing` breadcrumb at the
15//! `studio_worker::engine::download` target: `info` on `starting` and
16//! `done`, and a symmetric `warn` on each failure (non-success status,
17//! a streaming error, or a length / sha256 mismatch) so an operator
18//! never sees a dangling `starting` with no terminal event explaining
19//! what went wrong — mirroring the `ApiClient` HTTP surface.
20
21use anyhow::{bail, Context, Result};
22use sha2::{Digest, Sha256};
23use std::io::Write;
24use std::path::{Component, Path, PathBuf};
25use std::time::Instant;
26use tracing::{info, warn};
27
28use crate::types::ModelFile;
29
30/// Tracing target for model downloads.  Stable so operators can filter
31/// with `RUST_LOG=studio_worker::engine::download=debug`.
32const TRACE_TARGET: &str = "studio_worker::engine::download";
33
34/// HTTP client timeout per request — a GGUF / safetensors file is up to
35/// a few GiB so a 30-minute ceiling is generous.
36const DOWNLOAD_TIMEOUT_SECS: u64 = 30 * 60;
37
38/// Map a model id onto a safe single directory-segment name: every
39/// character outside `[A-Za-z0-9._-]` becomes `_`, and a leading `.`
40/// is neutralised so an id like `..` or `.hidden` can't escape or hide.
41/// Two models that share a `filename` (e.g. `model.safetensors`) then
42/// live under distinct `<models_root>/<id>/` dirs instead of
43/// overwriting each other in one flat cache.
44pub fn sanitise_model_dir(model_id: &str) -> String {
45    let mut out: String = model_id
46        .chars()
47        .map(|c| {
48            if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
49                c
50            } else {
51                '_'
52            }
53        })
54        .collect();
55    if out.is_empty() {
56        out.push('_');
57    }
58    // Neutralise a leading dot so `.` / `..` / `.git` can't act as a
59    // relative segment or a hidden dir.
60    if out.starts_with('.') {
61        out.replace_range(0..1, "_");
62    }
63    out
64}
65
66/// Per-model cache directory: `<models_root>/<sanitised-model-id>/`.
67pub fn model_dir(models_root: &Path, model_id: &str) -> PathBuf {
68    models_root.join(sanitise_model_dir(model_id))
69}
70
71/// A copy of `file` already on disk: in the engine's cache `dir`, else at
72/// the top of `models_root` (where an operator keeps models they placed by
73/// hand).  A root copy whose size differs from the declared size is a
74/// different file and is not reused.
75pub fn existing_copy(dir: &Path, models_root: &Path, file: &ModelFile) -> Result<Option<PathBuf>> {
76    let cached = model_cache_path(dir, &file.filename)?;
77    if cached.is_file() {
78        return Ok(Some(cached));
79    }
80    let root_copy = model_cache_path(models_root, &file.filename)?;
81    let Ok(meta) = std::fs::metadata(&root_copy) else {
82        return Ok(None);
83    };
84    if !meta.is_file() {
85        return Ok(None);
86    }
87    match file.approx_bytes {
88        Some(want) if want != meta.len() => {
89            tracing::info!(
90                target: TRACE_TARGET,
91                op = "ensure_file",
92                path = %root_copy.display(),
93                want,
94                have = meta.len(),
95                "same-named file in the models root has another size; not reused"
96            );
97            Ok(None)
98        }
99        _ => {
100            tracing::debug!(
101                target: TRACE_TARGET,
102                op = "ensure_file",
103                path = %root_copy.display(),
104                "reusing the copy in the models root"
105            );
106            Ok(Some(root_copy))
107        }
108    }
109}
110
111/// [`ensure_file`] into `dir`, unless [`existing_copy`] finds one.
112#[cfg_attr(coverage_nightly, coverage(off))]
113pub fn ensure_file_reusing(dir: &Path, models_root: &Path, file: &ModelFile) -> Result<PathBuf> {
114    match existing_copy(dir, models_root, file)? {
115        Some(path) => Ok(path),
116        None => ensure_file(dir, file),
117    }
118}
119
120/// Like [`ensure_file`] but scopes the download to a per-model subdir
121/// so two models naming the same file don't collide.  A file already
122/// present in the **legacy flat** `<models_root>/<filename>` (from a
123/// worker that predates this layout) is reused in place, so upgrading
124/// never re-downloads a multi-GiB weight that's already on disk.
125#[cfg_attr(coverage_nightly, coverage(off))]
126pub fn ensure_file_for_model(
127    models_root: &Path,
128    model_id: &str,
129    file: &ModelFile,
130) -> Result<PathBuf> {
131    let filename = file.filename.as_str();
132    let subdir = model_dir(models_root, model_id);
133    let target = model_cache_path(&subdir, filename)?;
134    if target.is_file() {
135        tracing::debug!(
136            target: TRACE_TARGET,
137            op = "ensure_file",
138            model_id,
139            filename,
140            path = %target.display(),
141            "cached (per-model)"
142        );
143        return Ok(target);
144    }
145    // Legacy flat cache: reuse an existing download rather than re-pull.
146    let legacy = model_cache_path(models_root, filename)?;
147    if legacy.is_file() {
148        tracing::debug!(
149            target: TRACE_TARGET,
150            op = "ensure_file",
151            model_id,
152            filename,
153            path = %legacy.display(),
154            "cached (legacy flat layout)"
155        );
156        return Ok(legacy);
157    }
158    preflight_disk_space(&subdir, filename, file.approx_bytes)?;
159    download_file_verified(file.url.as_str(), &target, file.sha256.as_deref()).with_context(
160        || {
161            format!(
162                "downloading {filename} ({}) -> {}",
163                file.url,
164                target.display()
165            )
166        },
167    )?;
168    Ok(target)
169}
170
171/// Resolve `filename` to a path inside `dir`, refusing anything that
172/// is not a plain file name (no `/`, `\`, `..`, or absolute paths) so a
173/// malicious or buggy `ModelSource` can't write outside the cache.
174pub fn model_cache_path(dir: &Path, filename: &str) -> Result<PathBuf> {
175    let path = Path::new(filename);
176    let mut components = path.components();
177    match (components.next(), components.next()) {
178        (Some(Component::Normal(name)), None)
179            if !filename.contains('/') && !filename.contains('\\') =>
180        {
181            Ok(dir.join(name))
182        }
183        _ => bail!("model filename must be a plain file name: {filename:?}"),
184    }
185}
186
187/// Pure core of the disk-space preflight: given the free bytes on the
188/// cache filesystem and a file's declared size, refuse the download
189/// when it cannot fit (with 10% headroom for the `.part` → rename
190/// dance and concurrent growth).  Failing here — with both numbers in
191/// the message — beats streaming gigabytes into ENOSPC and surfacing
192/// an inscrutable io error mid-body.
193pub fn check_disk_space(available: u64, approx_bytes: u64, filename: &str) -> Result<()> {
194    let required = approx_bytes.saturating_add(approx_bytes / 10);
195    if available < required {
196        bail!(
197            "not enough disk space for {filename}: need ~{required} bytes \
198             (declared {approx_bytes} + 10% headroom) but only {available} \
199             bytes are free on the models filesystem — free up space or \
200             move models_root"
201        );
202    }
203    Ok(())
204}
205
206/// IO half of the preflight: probe the free space under `dir` and run
207/// [`check_disk_space`].  A `dir` that does not exist yet (a model's
208/// first download) is measured at its nearest existing ancestor, the
209/// filesystem it will be created on.  A file with no (or zero) declared
210/// size, or a failed probe (exotic filesystems), skips the check — the
211/// preflight is an early-warning gate, not a correctness gate; the
212/// length + sha256 verification after the stream stays authoritative.
213pub fn preflight_disk_space(dir: &Path, filename: &str, approx_bytes: Option<u64>) -> Result<()> {
214    let Some(needed) = approx_bytes.filter(|b| *b > 0) else {
215        return Ok(());
216    };
217    let probed = dir.ancestors().find(|p| p.exists()).unwrap_or(dir);
218    match fs4::available_space(probed) {
219        Ok(available) => check_disk_space(available, needed, filename),
220        Err(e) => {
221            warn!(
222                target: TRACE_TARGET,
223                op = "preflight",
224                dir = %dir.display(),
225                filename,
226                error = %e,
227                "free-space probe failed; skipping the disk preflight"
228            );
229            Ok(())
230        }
231    }
232}
233
234/// Verify a streamed download wrote exactly the body the server
235/// promised.  `expected` is the response's `Content-Length`; it is
236/// `None` for chunked transfers, where there's nothing to check and we
237/// accept whatever arrived.  A mismatch in either direction means the
238/// download is truncated or corrupt, so we surface a clear error rather
239/// than cache a bad model.
240pub fn verify_download_len(copied: u64, expected: Option<u64>) -> Result<()> {
241    match expected {
242        Some(expected) if copied != expected => bail!(
243            "size mismatch: wrote {copied} bytes but the server declared \
244             Content-Length {expected} (download truncated or corrupt)"
245        ),
246        _ => Ok(()),
247    }
248}
249
250/// Verify a downloaded body's sha256 against the registry's expected
251/// hex digest (case-insensitive).  `None` means the registry row
252/// predates integrity hashes — nothing to check.  A mismatch means a
253/// corrupted or tampered body that must never be committed to the
254/// cache.
255pub fn verify_sha256(actual_hex: &str, expected: Option<&str>) -> Result<()> {
256    match expected {
257        Some(expected) if !actual_hex.eq_ignore_ascii_case(expected.trim()) => bail!(
258            "sha256 mismatch: downloaded body hashes to {actual_hex} but the registry \
259             expects {expected} (corrupted or tampered download)"
260        ),
261        _ => Ok(()),
262    }
263}
264
265/// Writer adapter that feeds every chunk through a [`Sha256`] hasher
266/// on its way to the underlying file, so verification needs no second
267/// read pass over a multi-GiB model.
268struct HashingWriter<W: Write> {
269    inner: W,
270    hasher: Sha256,
271}
272
273impl<W: Write> Write for HashingWriter<W> {
274    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
275        let written = self.inner.write(buf)?;
276        self.hasher.update(&buf[..written]);
277        Ok(written)
278    }
279
280    fn flush(&mut self) -> std::io::Result<()> {
281        self.inner.flush()
282    }
283}
284
285/// Sniff an image's container format from its leading magic bytes and
286/// return the file extension `sd-cli` expects for it, or `None` when
287/// the bytes match no format we hand to `sd-cli`.
288///
289/// `sd-cli`'s `media_io` loader picks its decoder purely from the file
290/// **extension**, not the content — so a JPEG saved as `foo.webp`, or a
291/// webp saved as `foo.png`, fails with `load image from '...' failed`.
292/// The studio serves asset URLs like `latest.webp` whose bytes are
293/// often actually JPEG, so the worker must name the on-disk tempfile
294/// after the real content for the decoder to pick correctly.
295pub fn sniff_image_extension(bytes: &[u8]) -> Option<&'static str> {
296    let starts = |sig: &[u8]| bytes.len() >= sig.len() && &bytes[..sig.len()] == sig;
297    if starts(&[0xff, 0xd8, 0xff]) {
298        Some("jpg")
299    } else if starts(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]) {
300        Some("png")
301    } else if bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
302        Some("webp")
303    } else if starts(b"GIF87a") || starts(b"GIF89a") {
304        Some("gif")
305    } else if starts(b"BM") {
306        Some("bmp")
307    } else if starts(&[0x49, 0x49, 0x2a, 0x00]) || starts(&[0x4d, 0x4d, 0x00, 0x2a]) {
308        Some("tif")
309    } else {
310        None
311    }
312}
313
314/// Make a downloaded input image (init / mask / reference) safe to hand
315/// to `sd-cli` by naming it after its **actual** content format.
316///
317/// The worker first names the tempfile from the URL's extension, but
318/// studio asset URLs lie (`latest.webp` is frequently JPEG bytes).
319/// `sd-cli` selects its image decoder from the file extension, so a
320/// mismatched name makes every img2img / edit / inpaint job fail with
321/// `load image from '...' failed`.  Here we sniff the real format from
322/// the file's magic bytes and, when it disagrees with the current
323/// extension, rename the file to a sibling with the correct one,
324/// returning the path the engine should consume.  Unknown or
325/// already-correct content passes straight through.
326///
327/// The caller owns cleanup: when the returned path differs from the
328/// input it is the same bytes under a new name, so it (not the
329/// original) must be registered with the job's [`TempFileGuard`].
330pub fn ensure_correct_image_extension(path: &Path) -> Result<PathBuf> {
331    let mut header = [0u8; 16];
332    let read = {
333        use std::io::Read;
334        let mut file = std::fs::File::open(path)
335            .with_context(|| format!("opening input image {}", path.display()))?;
336        file.read(&mut header)
337            .with_context(|| format!("reading input image header {}", path.display()))?
338    };
339    let Some(actual_ext) = sniff_image_extension(&header[..read]) else {
340        return Ok(path.to_path_buf());
341    };
342    let current_ext = path
343        .extension()
344        .and_then(|e| e.to_str())
345        .map(|e| e.to_ascii_lowercase());
346    // `jpeg` and `jpg` are the same decoder to sd-cli — don't churn the
347    // file when only the spelling differs.
348    let matches = current_ext.as_deref() == Some(actual_ext)
349        || (actual_ext == "jpg" && current_ext.as_deref() == Some("jpeg"));
350    if matches {
351        return Ok(path.to_path_buf());
352    }
353    let corrected = path.with_extension(actual_ext);
354    std::fs::rename(path, &corrected)
355        .with_context(|| format!("renaming {} -> {}", path.display(), corrected.display()))?;
356    info!(
357        target: TRACE_TARGET,
358        op = "sniff",
359        from = %path.display(),
360        to = %corrected.display(),
361        actual_ext,
362        "renamed input image to match its actual format for sd-cli"
363    );
364    Ok(corrected)
365}
366
367/// Best-effort removal of a temporary file — a partial `.part`
368/// download, an engine's per-job scratch image, or a downloaded init /
369/// mask.  A `NotFound` is the desired end state (something already
370/// cleaned it up); any other failure is surfaced so a stuck temp file
371/// can't silently fill the worker's disk over a long session.
372pub fn remove_temp_file(path: &Path) {
373    if let Err(e) = std::fs::remove_file(path) {
374        if e.kind() != std::io::ErrorKind::NotFound {
375            warn!(
376                target: TRACE_TARGET,
377                op = "cleanup",
378                path = %path.display(),
379                error = %e,
380                "failed to remove temp file"
381            );
382        }
383    }
384}
385
386/// RAII owner of a job's scratch files.  Registering a job's temp
387/// paths up front means every exit path — the success return, an
388/// engine error, even a panic mid-dispatch — removes them on drop
389/// instead of leaking them into the temp dir and slowly filling the
390/// worker's disk over a long-running session.  Removal is best-effort
391/// via [`remove_temp_file`], so a path that never materialised (the
392/// job failed before the file was written) is silently tolerated.
393#[derive(Default)]
394pub struct TempFileGuard {
395    paths: Vec<PathBuf>,
396}
397
398impl TempFileGuard {
399    pub fn new() -> Self {
400        Self { paths: Vec::new() }
401    }
402
403    /// Register a path to be removed when the guard drops.
404    pub fn push(&mut self, path: PathBuf) {
405        self.paths.push(path);
406    }
407}
408
409impl Drop for TempFileGuard {
410    fn drop(&mut self) {
411        for path in &self.paths {
412            remove_temp_file(path);
413        }
414    }
415}
416
417/// Ensure `file.filename` is present under `dir`, downloading it from
418/// `file.url` when missing (verified against `file.sha256` when the
419/// registry provides one).  Returns the resolved local path.
420#[cfg_attr(coverage_nightly, coverage(off))]
421pub fn ensure_file(dir: &Path, file: &ModelFile) -> Result<PathBuf> {
422    let filename = file.filename.as_str();
423    let url = file.url.as_str();
424    let local = model_cache_path(dir, filename)?;
425    if local.is_file() {
426        tracing::debug!(
427            target: TRACE_TARGET,
428            op = "ensure_file",
429            filename,
430            path = %local.display(),
431            "cached"
432        );
433        return Ok(local);
434    }
435    preflight_disk_space(dir, filename, file.approx_bytes)?;
436    download_file_verified(url, &local, file.sha256.as_deref())
437        .with_context(|| format!("downloading {filename} ({url}) -> {}", local.display()))?;
438    Ok(local)
439}
440
441/// Parse the start offset out of a `Content-Range: bytes <start>-<end>/<total>`
442/// header.  Returns `None` for anything that doesn't match that shape
443/// (the caller then falls back to a fresh full download).
444pub fn content_range_start(header: &str) -> Option<u64> {
445    header
446        .trim()
447        .strip_prefix("bytes ")?
448        .split('-')
449        .next()?
450        .parse()
451        .ok()
452}
453
454/// Stream `url` into `dest` (atomic via a `.part` rename so a killed
455/// download doesn't leave a half-written file on disk).
456///
457/// Excluded from coverage: requires real network + filesystem (and a
458/// multi-GiB download per model on the happy path).  Exercised
459/// end-to-end via the live dev loop; the pure guards
460/// ([`verify_download_len`], [`model_cache_path`]) are unit-tested.
461#[cfg_attr(coverage_nightly, coverage(off))]
462pub fn download_file(url: &str, dest: &Path) -> Result<()> {
463    download_file_verified(url, dest, None)
464}
465
466/// [`download_file`] with an optional expected sha256 — the body is
467/// hashed while it streams and a mismatch is rejected before the
468/// rename, so a bad body never lands in the cache.
469///
470/// A leftover `<dest>.part` from an interrupted run is **resumed** via
471/// an HTTP `Range` request instead of re-fetching multi-GiB models
472/// from byte zero: the existing prefix is hashed, the remainder is
473/// appended, and the final sha256 covers the assembled whole.  Servers
474/// that ignore the range (200) fall back to a fresh full download;
475/// `416` or a `Content-Range` that doesn't start where we asked drops
476/// the stale part and restarts clean.
477#[cfg_attr(coverage_nightly, coverage(off))]
478pub fn download_file_verified(url: &str, dest: &Path, expected_sha256: Option<&str>) -> Result<()> {
479    // Transport gate first: a plaintext-http model URL is a MITM away
480    // from model poisoning, so it never gets a request at all.
481    crate::net::validate_download_url(url, "model file")?;
482    if let Some(parent) = dest.parent() {
483        std::fs::create_dir_all(parent)
484            .with_context(|| format!("creating {}", parent.display()))?;
485    }
486    let part = dest.with_extension("part");
487    let client = reqwest::blocking::Client::builder()
488        .timeout(std::time::Duration::from_secs(DOWNLOAD_TIMEOUT_SECS))
489        .user_agent(concat!("studio-worker/", env!("CARGO_PKG_VERSION")))
490        .build()?;
491    let resume_from = std::fs::metadata(&part)
492        .ok()
493        .filter(|m| m.is_file())
494        .map(|m| m.len())
495        .unwrap_or(0);
496    info!(
497        target: TRACE_TARGET,
498        op = "download",
499        url,
500        dest = %dest.display(),
501        resume_from,
502        "starting"
503    );
504    let started = Instant::now();
505    let mut request = client.get(url);
506    if resume_from > 0 {
507        request = request.header("range", format!("bytes={resume_from}-"));
508    }
509    let mut response = match request.send() {
510        Ok(response) => response,
511        Err(e) => {
512            // A connection-level failure (DNS, TLS, timeout, or a
513            // connection closed before the declared body completed)
514            // must leave the same terminal breadcrumb as the other
515            // failure modes below — otherwise an operator filtering
516            // this target sees the "starting" line then silence.
517            warn!(
518                target: TRACE_TARGET,
519                op = "download",
520                url,
521                dest = %dest.display(),
522                elapsed_ms = started.elapsed().as_millis() as u64,
523                error = %e,
524                "download failed: request error"
525            );
526            return Err(e).context("GET");
527        }
528    };
529    let status = response.status();
530    if resume_from > 0 && status.as_u16() == 416 {
531        // The server can't satisfy the range (stale / already-complete
532        // part, or the remote file changed) — drop it and start clean.
533        info!(
534            target: TRACE_TARGET,
535            op = "download",
536            url,
537            dest = %dest.display(),
538            resume_from,
539            "range not satisfiable; restarting the download from scratch"
540        );
541        remove_temp_file(&part);
542        return download_file_verified(url, dest, expected_sha256);
543    }
544    if !status.is_success() {
545        warn!(
546            target: TRACE_TARGET,
547            op = "download",
548            url,
549            dest = %dest.display(),
550            status = status.as_u16(),
551            elapsed_ms = started.elapsed().as_millis() as u64,
552            "download failed: non-success status"
553        );
554        bail!("GET {url} -> {status}");
555    }
556    let resuming = resume_from > 0 && status.as_u16() == 206;
557    if resuming {
558        // A compliant 206 answers exactly the range we asked for; a
559        // Content-Range starting anywhere else would silently corrupt
560        // the assembled file, so verify before appending a byte.
561        let range_start = response
562            .headers()
563            .get("content-range")
564            .and_then(|v| v.to_str().ok())
565            .and_then(content_range_start);
566        if range_start != Some(resume_from) {
567            warn!(
568                target: TRACE_TARGET,
569                op = "download",
570                url,
571                dest = %dest.display(),
572                resume_from,
573                content_range_start = range_start,
574                "206 Content-Range does not start at our offset; restarting from scratch"
575            );
576            remove_temp_file(&part);
577            return download_file_verified(url, dest, expected_sha256);
578        }
579    }
580    // For a 206 this is the *remainder* length — exactly what we are
581    // about to stream, so the post-stream length check stays valid.
582    let expected_len = response.content_length();
583    let mut hasher = Sha256::new();
584    let file = if resuming {
585        // Fold the existing prefix into the digest so the final hash
586        // covers the assembled whole, then append.
587        let mut existing = std::fs::File::open(&part)
588            .with_context(|| format!("opening partial download {}", part.display()))?;
589        let mut buf = [0u8; 64 * 1024];
590        loop {
591            use std::io::Read as _;
592            let read = existing
593                .read(&mut buf)
594                .with_context(|| format!("hashing partial download {}", part.display()))?;
595            if read == 0 {
596                break;
597            }
598            hasher.update(&buf[..read]);
599        }
600        std::fs::OpenOptions::new()
601            .append(true)
602            .open(&part)
603            .with_context(|| format!("reopening {} for append", part.display()))?
604    } else {
605        std::fs::File::create(&part).with_context(|| format!("creating {}", part.display()))?
606    };
607    let mut writer = HashingWriter {
608        inner: file,
609        hasher,
610    };
611    let copied = std::io::copy(&mut response, &mut writer);
612    let digest = writer.hasher.finalize();
613    // Close the handle before any remove / rename so cleanup works on
614    // Windows, where an open file can't be unlinked.
615    drop(writer.inner);
616    let bytes = match copied {
617        Ok(bytes) => bytes,
618        Err(e) => {
619            remove_temp_file(&part);
620            warn!(
621                target: TRACE_TARGET,
622                op = "download",
623                url,
624                dest = %dest.display(),
625                elapsed_ms = started.elapsed().as_millis() as u64,
626                error = %e,
627                "download failed: streaming body"
628            );
629            return Err(e).context("streaming body");
630        }
631    };
632    if let Err(e) = verify_download_len(bytes, expected_len) {
633        remove_temp_file(&part);
634        warn!(
635            target: TRACE_TARGET,
636            op = "download",
637            url,
638            dest = %dest.display(),
639            bytes,
640            elapsed_ms = started.elapsed().as_millis() as u64,
641            error = %e,
642            "download failed: size mismatch"
643        );
644        return Err(e).with_context(|| format!("downloading {url}"));
645    }
646    let actual_hex: String = digest.iter().map(|b| format!("{b:02x}")).collect();
647    if let Err(e) = verify_sha256(&actual_hex, expected_sha256) {
648        remove_temp_file(&part);
649        warn!(
650            target: TRACE_TARGET,
651            op = "download",
652            url,
653            dest = %dest.display(),
654            bytes,
655            elapsed_ms = started.elapsed().as_millis() as u64,
656            error = %e,
657            "download failed: sha256 mismatch"
658        );
659        return Err(e).with_context(|| format!("downloading {url}"));
660    }
661    std::fs::rename(&part, dest)
662        .with_context(|| format!("renaming {} -> {}", part.display(), dest.display()))?;
663    let elapsed_ms = started.elapsed().as_millis() as u64;
664    info!(
665        target: TRACE_TARGET,
666        op = "download",
667        url,
668        dest = %dest.display(),
669        bytes,
670        resumed_from = if resuming { resume_from } else { 0 },
671        elapsed_ms,
672        "done"
673    );
674    Ok(())
675}
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680    use tempfile::tempdir;
681
682    // -----------------------------------------------------------------
683    // sniff_image_extension / ensure_correct_image_extension — the guard
684    // that names a downloaded base after its real content so sd-cli's
685    // extension-keyed `media_io` decoder picks the right codec.  Studio
686    // asset URLs lie (`latest.webp` is often JPEG bytes); a mismatched
687    // name was failing every img2img / edit / inpaint job with
688    // `load image from '...' failed`.
689    // -----------------------------------------------------------------
690
691    /// A tiny lossy-VP8 webp (one of the formats studio bases arrive
692    /// in) used to exercise the webp signature branch.
693    const LOSSY_WEBP: &[u8] = include_bytes!("../../tests/fixtures/lossy-vp8.webp");
694
695    #[test]
696    fn sniff_image_extension_maps_each_magic_to_an_sd_cli_extension() {
697        assert_eq!(sniff_image_extension(LOSSY_WEBP), Some("webp"));
698        assert_eq!(
699            sniff_image_extension(&[0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]),
700            Some("jpg"),
701            "JPEG (the bytes studio serves under .webp URLs)"
702        );
703        assert_eq!(
704            sniff_image_extension(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]),
705            Some("png")
706        );
707        assert_eq!(sniff_image_extension(b"GIF89a..."), Some("gif"));
708        assert_eq!(sniff_image_extension(b"BM......"), Some("bmp"));
709        assert_eq!(
710            sniff_image_extension(&[0x49, 0x49, 0x2a, 0x00]),
711            Some("tif")
712        );
713        // A RIFF container that is not WEBP (e.g. a WAV) is not an image.
714        assert_eq!(sniff_image_extension(b"RIFF\x00\x00\x00\x00WAVEfmt "), None);
715        // Unknown / too-short content yields no opinion.
716        assert_eq!(sniff_image_extension(b"\x00\x01\x02"), None);
717        assert_eq!(sniff_image_extension(b""), None);
718    }
719
720    #[test]
721    fn ensure_correct_image_extension_renames_jpeg_served_as_webp() {
722        // The exact prod failure: bytes are JPEG but the file is named
723        // `.webp` (from the lying URL).  It must be renamed to `.jpg`.
724        let dir = tempdir().unwrap();
725        let mislabelled = dir.path().join("out-init.webp");
726        std::fs::write(
727            &mislabelled,
728            [0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46],
729        )
730        .unwrap();
731
732        let corrected = ensure_correct_image_extension(&mislabelled).unwrap();
733
734        assert_eq!(corrected, dir.path().join("out-init.jpg"));
735        assert!(corrected.exists(), "renamed file carries the bytes");
736        assert!(
737            !mislabelled.exists(),
738            "the misnamed file is gone after rename"
739        );
740    }
741
742    #[test]
743    fn ensure_correct_image_extension_renames_webp_served_as_png() {
744        let dir = tempdir().unwrap();
745        let mislabelled = dir.path().join("out-init.png");
746        std::fs::write(&mislabelled, LOSSY_WEBP).unwrap();
747
748        let corrected = ensure_correct_image_extension(&mislabelled).unwrap();
749
750        assert_eq!(corrected, dir.path().join("out-init.webp"));
751        assert!(corrected.exists() && !mislabelled.exists());
752    }
753
754    #[test]
755    fn ensure_correct_image_extension_leaves_correct_or_unknown_files_in_place() {
756        let dir = tempdir().unwrap();
757        // Already-correct png: returned verbatim, not renamed.
758        let png = dir.path().join("out-mask.png");
759        std::fs::write(&png, [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]).unwrap();
760        assert_eq!(ensure_correct_image_extension(&png).unwrap(), png);
761        assert!(png.exists());
762
763        // `.jpeg` spelling for JPEG content is not churned to `.jpg`.
764        let jpeg = dir.path().join("out-ref.jpeg");
765        std::fs::write(&jpeg, [0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]).unwrap();
766        assert_eq!(ensure_correct_image_extension(&jpeg).unwrap(), jpeg);
767        assert!(jpeg.exists() && !dir.path().join("out-ref.jpg").exists());
768
769        // Unknown content (no recognised magic) passes through untouched.
770        let unknown = dir.path().join("out-init.webp");
771        std::fs::write(&unknown, [0x00, 0x01, 0x02, 0x03]).unwrap();
772        assert_eq!(ensure_correct_image_extension(&unknown).unwrap(), unknown);
773        assert!(unknown.exists());
774    }
775
776    #[test]
777    fn sanitise_model_dir_neutralises_separators_and_dot_segments() {
778        // A plain id is unchanged.
779        assert_eq!(
780            sanitise_model_dir("z-image-turbo-q4_k_m.gguf"),
781            "z-image-turbo-q4_k_m.gguf"
782        );
783        // HF-style repo ids and any other separators collapse to `_`.
784        assert_eq!(sanitise_model_dir("org/model:v2"), "org_model_v2");
785        assert_eq!(sanitise_model_dir("a\\b c"), "a_b_c");
786        // Traversal / hidden segments are neutralised at the front.
787        assert_eq!(sanitise_model_dir(".."), "_.");
788        assert_eq!(sanitise_model_dir(".git"), "_git");
789        assert_eq!(sanitise_model_dir(""), "_");
790        // The result is always a single, safe path segment.
791        for id in ["../../etc/passwd", "a/b/c", "..", "."] {
792            let dir = sanitise_model_dir(id);
793            assert!(!dir.contains('/') && !dir.contains('\\'));
794            assert!(!dir.starts_with('.'));
795        }
796    }
797
798    #[test]
799    fn model_dir_is_a_single_child_of_models_root() {
800        let root = Path::new("/models");
801        assert_eq!(
802            model_dir(root, "my-model"),
803            PathBuf::from("/models/my-model")
804        );
805        // A traversal id can't escape the root.
806        let escaped = model_dir(root, "../../etc");
807        assert!(escaped.starts_with("/models"), "got {}", escaped.display());
808        assert_eq!(escaped.components().count(), 3, "root + one segment");
809    }
810
811    #[test]
812    fn model_cache_path_accepts_plain_filenames_only() {
813        let root = Path::new("/models");
814        assert_eq!(
815            model_cache_path(root, "model.gguf").unwrap(),
816            PathBuf::from("/models/model.gguf")
817        );
818        assert!(model_cache_path(root, "../outside.gguf").is_err());
819        assert!(model_cache_path(root, "nested/model.gguf").is_err());
820        assert!(model_cache_path(root, "/tmp/model.gguf").is_err());
821        assert!(model_cache_path(root, r"nested\model.gguf").is_err());
822        assert!(model_cache_path(root, "").is_err());
823    }
824
825    // -----------------------------------------------------------------
826    // Disk-space preflight — refuses a download that cannot fit before
827    // any bytes stream, instead of dying on ENOSPC mid-body.
828    // -----------------------------------------------------------------
829
830    #[test]
831    fn check_disk_space_accepts_a_fit_with_headroom() {
832        // 100 declared + 10% headroom = 110 required.
833        assert!(check_disk_space(110, 100, "m.gguf").is_ok());
834        assert!(check_disk_space(1_000, 100, "m.gguf").is_ok());
835    }
836
837    #[test]
838    fn check_disk_space_rejects_when_it_cannot_fit() {
839        let err = check_disk_space(109, 100, "m.gguf")
840            .unwrap_err()
841            .to_string();
842        assert!(err.contains("m.gguf"), "must name the file: {err}");
843        assert!(err.contains("109"), "must name the available bytes: {err}");
844        assert!(err.contains("100"), "must name the declared size: {err}");
845        assert!(
846            err.contains("models_root"),
847            "must tell the operator what to change: {err}"
848        );
849    }
850
851    #[test]
852    fn check_disk_space_survives_huge_declared_sizes() {
853        // The +10% headroom saturates instead of overflowing near
854        // u64::MAX — an overflow would wrap `required` to a tiny number
855        // and wave an impossible download through.
856        assert!(check_disk_space(u64::MAX - 1, u64::MAX, "m.gguf").is_err());
857        assert!(check_disk_space(u64::MAX, u64::MAX, "m.gguf").is_ok());
858    }
859
860    #[test]
861    fn preflight_skips_unknown_or_zero_sizes_and_checks_known_ones() {
862        let dir = tempdir().unwrap();
863        // Unknown / zero sizes: nothing to check.
864        preflight_disk_space(dir.path(), "m.gguf", None).unwrap();
865        preflight_disk_space(dir.path(), "m.gguf", Some(0)).unwrap();
866        // A tiny known size passes on any real filesystem.
867        preflight_disk_space(dir.path(), "m.gguf", Some(1024)).unwrap();
868        // An absurd size fails against real free space.
869        assert!(preflight_disk_space(dir.path(), "m.gguf", Some(u64::MAX / 2)).is_err());
870    }
871
872    fn model_file(name: &str, bytes: Option<u64>) -> ModelFile {
873        ModelFile {
874            role: crate::types::ModelFileRole::Model,
875            url: "https://example.invalid/m".into(),
876            filename: name.into(),
877            approx_bytes: bytes,
878            sha256: None,
879        }
880    }
881
882    #[test]
883    fn existing_copy_prefers_the_cache_then_the_models_root() {
884        let root = tempdir().unwrap();
885        let cache = root.path().join("llm");
886        std::fs::create_dir_all(&cache).unwrap();
887        let file = model_file("m.gguf", Some(3));
888        assert_eq!(existing_copy(&cache, root.path(), &file).unwrap(), None);
889        std::fs::write(root.path().join("m.gguf"), b"abc").unwrap();
890        assert_eq!(
891            existing_copy(&cache, root.path(), &file).unwrap(),
892            Some(root.path().join("m.gguf"))
893        );
894        std::fs::write(cache.join("m.gguf"), b"abc").unwrap();
895        assert_eq!(
896            existing_copy(&cache, root.path(), &file).unwrap(),
897            Some(cache.join("m.gguf"))
898        );
899    }
900
901    #[test]
902    fn existing_copy_skips_a_root_file_of_the_wrong_size() {
903        let root = tempdir().unwrap();
904        std::fs::write(root.path().join("m.gguf"), b"abcd").unwrap();
905        let logs = crate::test_support::capture({
906            let root = root.path().to_path_buf();
907            move || {
908                let found = existing_copy(&root.join("llm"), &root, &model_file("m.gguf", Some(3)))
909                    .unwrap();
910                assert_eq!(found, None);
911            }
912        });
913        assert!(logs.contains("not reused"), "{logs}");
914        let unknown_size = model_file("m.gguf", None);
915        assert!(
916            existing_copy(&root.path().join("llm"), root.path(), &unknown_size)
917                .unwrap()
918                .is_some()
919        );
920    }
921
922    #[test]
923    fn existing_copy_refuses_unsafe_names() {
924        let root = tempdir().unwrap();
925        assert!(existing_copy(root.path(), root.path(), &model_file("../x", None)).is_err());
926    }
927
928    #[test]
929    fn preflight_checks_a_directory_that_does_not_exist_yet() {
930        // First use of a model: its download dir is created later, so the
931        // probe must measure the filesystem it will land on, not skip.
932        let dir = tempdir().unwrap();
933        let fresh = dir.path().join("llm").join("new-model");
934        assert!(preflight_disk_space(&fresh, "m.gguf", Some(u64::MAX / 2)).is_err());
935        preflight_disk_space(&fresh, "m.gguf", Some(1024)).unwrap();
936        assert!(!fresh.exists(), "the probe creates nothing");
937    }
938
939    // -----------------------------------------------------------------
940    // Content-Range parsing — the resume-safety check that stops a
941    // server answering the wrong range from corrupting the assembly.
942    // -----------------------------------------------------------------
943
944    #[test]
945    fn content_range_start_parses_the_standard_shape() {
946        assert_eq!(content_range_start("bytes 10-19/20"), Some(10));
947        assert_eq!(content_range_start(" bytes 0-99/1000 "), Some(0));
948        assert_eq!(content_range_start("bytes 5-9/*"), Some(5));
949    }
950
951    #[test]
952    fn content_range_start_rejects_other_shapes() {
953        assert_eq!(content_range_start("bytes */20"), None);
954        assert_eq!(content_range_start("items 10-19/20"), None);
955        assert_eq!(content_range_start("garbage"), None);
956        assert_eq!(content_range_start(""), None);
957    }
958
959    #[test]
960    fn verify_download_len_accepts_exact_match() {
961        assert!(verify_download_len(2_700_000_000, Some(2_700_000_000)).is_ok());
962    }
963
964    #[test]
965    fn verify_download_len_accepts_when_length_unknown() {
966        assert!(verify_download_len(123, None).is_ok());
967    }
968
969    #[test]
970    fn verify_download_len_rejects_truncated_download() {
971        let err = verify_download_len(40, Some(100)).unwrap_err().to_string();
972        assert!(err.contains("size mismatch"), "got: {err}");
973        assert!(err.contains("40"), "got: {err}");
974        assert!(err.contains("100"), "got: {err}");
975    }
976
977    #[test]
978    fn verify_download_len_rejects_overlong_download() {
979        assert!(verify_download_len(120, Some(100)).is_err());
980    }
981
982    fn test_file(filename: &str, url: &str) -> ModelFile {
983        ModelFile {
984            role: crate::types::ModelFileRole::Model,
985            url: url.to_string(),
986            filename: filename.to_string(),
987            approx_bytes: None,
988            sha256: None,
989        }
990    }
991
992    #[test]
993    fn ensure_file_returns_cached_path_without_network() {
994        // A file already present must be returned as-is — `ensure_file`
995        // never touches the network, so an unreachable URL is fine.
996        let dir = tempdir().unwrap();
997        std::fs::write(dir.path().join("cached.gguf"), b"already here").unwrap();
998        let path = ensure_file(
999            dir.path(),
1000            &test_file("cached.gguf", "https://example.invalid/x"),
1001        )
1002        .unwrap();
1003        assert_eq!(path, dir.path().join("cached.gguf"));
1004        assert_eq!(std::fs::read(&path).unwrap(), b"already here");
1005    }
1006
1007    #[test]
1008    fn ensure_file_rejects_path_traversal_before_any_network() {
1009        let dir = tempdir().unwrap();
1010        let err = ensure_file(
1011            dir.path(),
1012            &test_file("../escape.gguf", "https://example.invalid/x"),
1013        )
1014        .unwrap_err()
1015        .to_string();
1016        assert!(err.contains("plain file name"), "got: {err}");
1017    }
1018
1019    // -----------------------------------------------------------------
1020    // verify_sha256 — the integrity gate for registry-pinned hashes.
1021    // -----------------------------------------------------------------
1022
1023    #[test]
1024    fn verify_sha256_accepts_match_and_absence() {
1025        assert!(verify_sha256("abc123", Some("abc123")).is_ok());
1026        assert!(
1027            verify_sha256("abc123", Some("ABC123")).is_ok(),
1028            "case-insensitive"
1029        );
1030        assert!(
1031            verify_sha256("abc123", Some(" abc123 ")).is_ok(),
1032            "whitespace-tolerant"
1033        );
1034        assert!(
1035            verify_sha256("abc123", None).is_ok(),
1036            "legacy rows have no hash"
1037        );
1038    }
1039
1040    #[test]
1041    fn verify_sha256_rejects_mismatch() {
1042        let err = verify_sha256("abc123", Some("def456"))
1043            .unwrap_err()
1044            .to_string();
1045        assert!(err.contains("sha256 mismatch"), "got: {err}");
1046        assert!(
1047            err.contains("abc123") && err.contains("def456"),
1048            "must name both digests: {err}"
1049        );
1050    }
1051
1052    // -----------------------------------------------------------------
1053    // HashingWriter — streams the body into the cache file while
1054    // computing the sha256 that `verify_sha256` later checks.  The
1055    // integrity guarantee hinges on hashing *exactly* the bytes the
1056    // inner writer accepted: `write` slices `&buf[..written]`, so a
1057    // short write (inner takes only a prefix) must hash only that
1058    // prefix — the unwritten tail is re-offered by `io::copy` on the
1059    // next call.  Hashing the whole `buf` on a short write would
1060    // silently corrupt every digest and turn the integrity gate into a
1061    // false-reject.  The download integration test wraps a real `File`,
1062    // which never short-writes, so this prefix branch is only reachable
1063    // here.
1064    // -----------------------------------------------------------------
1065
1066    /// A writer that accepts at most `max_per_write` bytes per call (to
1067    /// model a short write) and counts `flush` calls.
1068    struct ProbeWriter {
1069        sink: Vec<u8>,
1070        max_per_write: usize,
1071        flushes: usize,
1072    }
1073
1074    impl Write for ProbeWriter {
1075        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1076            let take = buf.len().min(self.max_per_write);
1077            self.sink.extend_from_slice(&buf[..take]);
1078            Ok(take)
1079        }
1080        fn flush(&mut self) -> std::io::Result<()> {
1081            self.flushes += 1;
1082            Ok(())
1083        }
1084    }
1085
1086    fn hex(bytes: &[u8]) -> String {
1087        bytes.iter().map(|b| format!("{b:02x}")).collect()
1088    }
1089
1090    #[test]
1091    fn hashing_writer_hashes_only_the_bytes_the_inner_accepted() {
1092        // The inner writer takes only 3 of the 8 offered bytes, so the
1093        // hasher must absorb just "abc" — proving the `&buf[..written]`
1094        // slice.  If `write` hashed the whole `buf`, the digest would be
1095        // sha256("abcdefgh") and this assertion would fail.
1096        let mut writer = HashingWriter {
1097            inner: ProbeWriter {
1098                sink: Vec::new(),
1099                max_per_write: 3,
1100                flushes: 0,
1101            },
1102            hasher: Sha256::new(),
1103        };
1104        let written = writer.write(b"abcdefgh").unwrap();
1105        assert_eq!(written, 3, "inner accepts at most 3 bytes per write");
1106        assert_eq!(writer.inner.sink, b"abc", "only the prefix reaches inner");
1107        assert_eq!(
1108            hex(&writer.hasher.finalize()),
1109            hex(&Sha256::digest(b"abc")),
1110            "hash covers only the accepted prefix"
1111        );
1112    }
1113
1114    #[test]
1115    fn hashing_writer_digest_matches_a_short_writing_stream_end_to_end() {
1116        // Drive the writer the way `download_file_verified` does — via
1117        // `io::copy`, which re-offers the unwritten tail — through an
1118        // inner that only takes 4 bytes at a time.  The streamed bytes
1119        // and the final digest must both equal the full source, with no
1120        // double-hashing across the re-offered chunks.
1121        let source = b"the quick brown model weights".to_vec();
1122        let mut reader = source.as_slice();
1123        let mut writer = HashingWriter {
1124            inner: ProbeWriter {
1125                sink: Vec::new(),
1126                max_per_write: 4,
1127                flushes: 0,
1128            },
1129            hasher: Sha256::new(),
1130        };
1131        let copied = std::io::copy(&mut reader, &mut writer).unwrap();
1132        assert_eq!(copied as usize, source.len());
1133        assert_eq!(
1134            writer.inner.sink, source,
1135            "every byte reaches the cache file"
1136        );
1137        assert_eq!(
1138            hex(&writer.hasher.finalize()),
1139            hex(&Sha256::digest(&source)),
1140            "digest matches the full body"
1141        );
1142    }
1143
1144    #[test]
1145    fn hashing_writer_flush_delegates_to_the_inner_writer() {
1146        let mut writer = HashingWriter {
1147            inner: ProbeWriter {
1148                sink: Vec::new(),
1149                max_per_write: usize::MAX,
1150                flushes: 0,
1151            },
1152            hasher: Sha256::new(),
1153        };
1154        writer.flush().unwrap();
1155        writer.flush().unwrap();
1156        assert_eq!(writer.inner.flushes, 2, "flush is forwarded to inner");
1157    }
1158
1159    // -----------------------------------------------------------------
1160    // remove_temp_file + TempFileGuard — the shared best-effort cleanup
1161    // primitives every engine routes its per-job scratch files through.
1162    // Owned here (the shared engine-provisioning module) so the sdcpp
1163    // output guard and the onnx init/mask cleanup share one tested
1164    // implementation instead of each rolling its own silent removal.
1165    // -----------------------------------------------------------------
1166
1167    #[test]
1168    fn remove_temp_file_deletes_an_existing_file_quietly() {
1169        let dir = tempdir().unwrap();
1170        let f = dir.path().join("artefact.webp");
1171        std::fs::write(&f, b"bytes").unwrap();
1172        let out = crate::test_support::capture({
1173            let f = f.clone();
1174            move || remove_temp_file(&f)
1175        });
1176        assert!(!f.exists(), "file should be gone after cleanup");
1177        assert!(
1178            !out.contains("failed to remove temp file"),
1179            "the success path must not warn: {out:?}"
1180        );
1181    }
1182
1183    #[test]
1184    fn remove_temp_file_ignores_a_missing_file() {
1185        let dir = tempdir().unwrap();
1186        let out = crate::test_support::capture({
1187            let missing = dir.path().join("never.part");
1188            move || remove_temp_file(&missing)
1189        });
1190        assert!(
1191            !out.contains("failed to remove temp file"),
1192            "a not-found temp file is the desired end state: {out:?}"
1193        );
1194    }
1195
1196    #[test]
1197    fn remove_temp_file_surfaces_a_failed_removal() {
1198        // Pointing the helper at a directory makes `remove_file` fail on
1199        // every platform (it refuses to unlink a dir): the closest
1200        // portable stand-in for a locked / permission-denied temp file.
1201        let dir = tempdir().unwrap();
1202        let stubborn = dir.path().join("subdir");
1203        std::fs::create_dir(&stubborn).unwrap();
1204        let out = crate::test_support::capture(move || remove_temp_file(&stubborn));
1205        assert!(
1206            out.contains("failed to remove temp file"),
1207            "a failed removal must surface in the logs: {out:?}"
1208        );
1209        assert!(
1210            out.contains("subdir"),
1211            "the warning must name the offending path: {out:?}"
1212        );
1213        assert!(
1214            out.contains("cleanup"),
1215            "the warning should tag the cleanup op: {out:?}"
1216        );
1217    }
1218
1219    #[test]
1220    fn temp_file_guard_removes_every_registered_file_on_drop() {
1221        let dir = tempdir().unwrap();
1222        let out = dir.path().join("out.webp");
1223        let init = dir.path().join("out-init.png");
1224        std::fs::write(&out, b"image").unwrap();
1225        std::fs::write(&init, b"init").unwrap();
1226        {
1227            let mut guard = TempFileGuard::new();
1228            guard.push(out.clone());
1229            guard.push(init.clone());
1230            assert!(out.exists() && init.exists(), "files present before drop");
1231        }
1232        assert!(!out.exists(), "output temp must be removed on drop");
1233        assert!(!init.exists(), "init-image temp must be removed on drop");
1234    }
1235
1236    #[test]
1237    fn temp_file_guard_tolerates_a_file_that_never_materialised() {
1238        // A path registered before its download runs (so an early
1239        // failure drops a guard pointing at a file that never existed)
1240        // is the desired end state, not a cleanup warning.
1241        let dir = tempdir().unwrap();
1242        let missing = dir.path().join("never-written.webp");
1243        let out = crate::test_support::capture(move || {
1244            let mut guard = TempFileGuard::new();
1245            guard.push(missing);
1246            drop(guard);
1247        });
1248        assert!(
1249            !out.contains("failed to remove temp file"),
1250            "a never-created temp file must not warn on cleanup: {out:?}"
1251        );
1252    }
1253}