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
71#[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 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
122pub 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
138pub 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
157pub 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
185pub 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
201pub 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
216struct 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
236pub 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
265pub 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 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
318pub 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#[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 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#[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
392pub 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#[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#[cfg_attr(coverage_nightly, coverage(off))]
429pub fn download_file_verified(url: &str, dest: &Path, expected_sha256: Option<&str>) -> Result<()> {
430 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 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 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 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 let expected_len = response.content_length();
534 let mut hasher = Sha256::new();
535 let file = if resuming {
536 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 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 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 assert_eq!(sniff_image_extension(b"RIFF\x00\x00\x00\x00WAVEfmt "), None);
666 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 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 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 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 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 assert_eq!(
731 sanitise_model_dir("z-image-turbo-q4_k_m.gguf"),
732 "z-image-turbo-q4_k_m.gguf"
733 );
734 assert_eq!(sanitise_model_dir("org/model:v2"), "org_model_v2");
736 assert_eq!(sanitise_model_dir("a\\b c"), "a_b_c");
737 assert_eq!(sanitise_model_dir(".."), "_.");
739 assert_eq!(sanitise_model_dir(".git"), "_git");
740 assert_eq!(sanitise_model_dir(""), "_");
741 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 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 #[test]
782 fn check_disk_space_accepts_a_fit_with_headroom() {
783 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 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 preflight_disk_space(dir.path(), "m.gguf", None).unwrap();
816 preflight_disk_space(dir.path(), "m.gguf", Some(0)).unwrap();
817 preflight_disk_space(dir.path(), "m.gguf", Some(1024)).unwrap();
819 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 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 #[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 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 #[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 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 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 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 #[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 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 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}