1use 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
30const TRACE_TARGET: &str = "studio_worker::engine::download";
33
34const DOWNLOAD_TIMEOUT_SECS: u64 = 30 * 60;
37
38pub 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 if out.starts_with('.') {
61 out.replace_range(0..1, "_");
62 }
63 out
64}
65
66pub fn model_dir(models_root: &Path, model_id: &str) -> PathBuf {
68 models_root.join(sanitise_model_dir(model_id))
69}
70
71pub 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#[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#[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 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
171pub 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
187pub 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
206pub 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
234pub 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
250pub 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
265struct 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
285pub 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
314pub 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 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
367pub 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#[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 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#[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
441pub 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#[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#[cfg_attr(coverage_nightly, coverage(off))]
478pub fn download_file_verified(url: &str, dest: &Path, expected_sha256: Option<&str>) -> Result<()> {
479 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 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 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 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 let expected_len = response.content_length();
583 let mut hasher = Sha256::new();
584 let file = if resuming {
585 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 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 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 assert_eq!(sniff_image_extension(b"RIFF\x00\x00\x00\x00WAVEfmt "), None);
715 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 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 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 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 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 assert_eq!(
780 sanitise_model_dir("z-image-turbo-q4_k_m.gguf"),
781 "z-image-turbo-q4_k_m.gguf"
782 );
783 assert_eq!(sanitise_model_dir("org/model:v2"), "org_model_v2");
785 assert_eq!(sanitise_model_dir("a\\b c"), "a_b_c");
786 assert_eq!(sanitise_model_dir(".."), "_.");
788 assert_eq!(sanitise_model_dir(".git"), "_git");
789 assert_eq!(sanitise_model_dir(""), "_");
790 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 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 #[test]
831 fn check_disk_space_accepts_a_fit_with_headroom() {
832 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 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 preflight_disk_space(dir.path(), "m.gguf", None).unwrap();
865 preflight_disk_space(dir.path(), "m.gguf", Some(0)).unwrap();
866 preflight_disk_space(dir.path(), "m.gguf", Some(1024)).unwrap();
868 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 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 #[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 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 #[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 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 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 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 #[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 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 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}