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