1use std::collections::HashMap;
4use std::fs::{self, File};
5use std::ops::ControlFlow;
6use std::path::{Path, PathBuf};
7use std::sync::mpsc;
8use std::sync::{Mutex, PoisonError};
9
10use sha2::{Digest, Sha256};
11
12use crate::{Config, Error, ModelSpec};
13
14const PROGRESS_INTERVAL: u64 = 1 << 20;
16
17const VERIFY_PROGRESS_INTERVAL: u64 = 64 << 20;
21
22type Reporter<'a> = dyn FnMut(Progress) -> ControlFlow<()> + 'a;
24
25fn announce(on_progress: &mut Reporter<'_>, progress: Progress) -> Result<(), Error> {
28 match on_progress(progress) {
29 ControlFlow::Continue(()) => Ok(()),
30 ControlFlow::Break(()) => Err(Error::Cancelled),
31 }
32}
33
34#[derive(Debug, Clone)]
36pub enum Progress {
37 Verifying { file: String },
39 Downloading { file: String, downloaded: u64, total: u64 },
41 Ready { file: String },
43}
44
45pub fn prepare(
76 cfg: &Config,
77 mut on_progress: impl FnMut(Progress) -> ControlFlow<()>,
78) -> Result<(), Error> {
79 let dir = models_dir(cfg)?;
80 fs::create_dir_all(&dir).map_err(|source| Error::Io { path: dir.clone(), source })?;
81
82 let specs: Vec<&ModelSpec> =
83 std::iter::once(&cfg.asr_model).chain(cfg.normalizer.as_ref()).collect();
84 let cache = DigestCache::load(cfg.digest_cache_dir.as_deref());
85 let cache = &cache;
88 let (report_tx, reports) = mpsc::channel::<Report>();
89
90 let outcomes: Vec<Result<(), Error>> = std::thread::scope(|scope| {
91 let workers: Vec<_> = specs
92 .iter()
93 .map(|spec| {
94 let report_tx = report_tx.clone();
95 let dir = dir.as_path();
96 scope.spawn(move || {
97 let mut announce_to_driver = |progress| {
101 let (verdict_tx, verdict) = mpsc::channel();
109 let report = Report { progress, verdict: verdict_tx };
110 if report_tx.send(report).is_err() {
111 return ControlFlow::Break(());
112 }
113 verdict.recv().unwrap_or(ControlFlow::Break(()))
114 };
115 ensure(dir, spec, cache, &mut announce_to_driver)
116 })
117 })
118 .collect();
119 drop(report_tx);
122
123 for report in reports {
124 let _ = report.verdict.send(on_progress(report.progress));
125 }
126
127 workers
128 .into_iter()
129 .map(|worker| worker.join().unwrap_or_else(|panic| std::panic::resume_unwind(panic)))
130 .collect()
131 });
132
133 cache.save();
137
138 outcomes.into_iter().find(Result::is_err).unwrap_or(Ok(()))
141}
142
143struct Report {
146 progress: Progress,
147 verdict: mpsc::Sender<ControlFlow<()>>,
148}
149
150pub(crate) fn models_dir(cfg: &Config) -> Result<PathBuf, Error> {
154 match &cfg.models_dir {
155 Some(dir) => Ok(dir.clone()),
156 None => dirs::cache_dir().map(|d| d.join("forge-dictate")).ok_or(Error::NoCacheDir),
157 }
158}
159
160const DIGEST_CACHE_FILE: &str = "verified.json";
163
164const DIGEST_CACHE_VERSION: u32 = 1;
167
168#[derive(Clone, serde::Serialize, serde::Deserialize)]
170struct DigestRecord {
171 size: u64,
172 mtime_ns: u64,
176 sha256: String,
177}
178
179#[derive(serde::Serialize, serde::Deserialize)]
180struct DigestFile {
181 version: u32,
182 records: HashMap<PathBuf, DigestRecord>,
183}
184
185struct DigestCache {
199 file: Option<PathBuf>,
202 inner: Mutex<CacheInner>,
203}
204
205#[derive(Default)]
206struct CacheInner {
207 records: HashMap<PathBuf, DigestRecord>,
208 changed: bool,
211}
212
213impl DigestCache {
214 fn load(dir: Option<&Path>) -> Self {
216 let Some(file) = dir.map(|dir| dir.join(DIGEST_CACHE_FILE)) else {
217 return Self { file: None, inner: Mutex::new(CacheInner::default()) };
218 };
219 let records = match fs::read_to_string(&file) {
220 Ok(raw) => match serde_json::from_str::<DigestFile>(&raw) {
221 Ok(parsed) if parsed.version == DIGEST_CACHE_VERSION => parsed.records,
222 Ok(parsed) => {
223 tracing::warn!(
224 event_name = "dictate_digest_record_layout_unknown",
225 path = %file.display(),
226 version = parsed.version,
227 "digest record is a layout this build does not read; every model is hashed this run"
228 );
229 HashMap::new()
230 }
231 Err(error) => {
232 tracing::warn!(
233 event_name = "dictate_digest_record_unreadable",
234 path = %file.display(),
235 %error,
236 "digest record is unreadable; every model is hashed this run"
237 );
238 HashMap::new()
239 }
240 },
241 Err(error) if error.kind() == std::io::ErrorKind::NotFound => HashMap::new(),
243 Err(error) => {
244 tracing::warn!(
245 event_name = "dictate_digest_record_read_failed",
246 path = %file.display(),
247 %error,
248 "digest record could not be read; every model is hashed this run"
249 );
250 HashMap::new()
251 }
252 };
253 Self { file: Some(file), inner: Mutex::new(CacheInner { records, changed: false }) }
254 }
255
256 fn hit(&self, file: &Path, spec: &ModelSpec) -> bool {
261 let Some(expected) = spec.sha256.as_deref() else { return false };
264 let Some((size, mtime_ns)) = state_of(file) else { return false };
265 let inner = self.inner.lock().unwrap_or_else(PoisonError::into_inner);
266 inner.records.get(file).is_some_and(|record| {
267 record.size == size
268 && record.mtime_ns == mtime_ns
269 && record.sha256.eq_ignore_ascii_case(expected)
270 })
271 }
272
273 fn record(&self, file: &Path, state: Option<(u64, u64)>, digest: &str) {
279 if self.file.is_none() {
280 return;
281 }
282 let Some((size, mtime_ns)) = state else { return };
283 let mut inner = self.inner.lock().unwrap_or_else(PoisonError::into_inner);
284 inner
285 .records
286 .insert(file.to_path_buf(), DigestRecord { size, mtime_ns, sha256: digest.to_owned() });
287 inner.changed = true;
288 }
289
290 fn save(&self) {
292 let Some(path) = self.file.as_deref() else { return };
293 let records = {
294 let inner = self.inner.lock().unwrap_or_else(PoisonError::into_inner);
295 if !inner.changed {
296 return;
297 }
298 inner.records.clone()
299 };
300 let body = match serde_json::to_string_pretty(&DigestFile {
301 version: DIGEST_CACHE_VERSION,
302 records,
303 }) {
304 Ok(body) => body,
305 Err(error) => {
306 tracing::warn!(
307 event_name = "dictate_digest_record_serialize_failed",
308 %error,
309 "digest record could not be serialised; not written"
310 );
311 return;
312 }
313 };
314 let written =
315 path.parent().map_or(Ok(()), fs::create_dir_all).and_then(|()| fs::write(path, body));
316 if let Err(error) = written {
317 tracing::warn!(
318 event_name = "dictate_digest_record_write_failed",
319 path = %path.display(),
320 %error,
321 "digest record could not be written; the next run hashes what this one did"
322 );
323 }
324 }
325}
326
327fn state_of(path: &Path) -> Option<(u64, u64)> {
331 let metadata = fs::metadata(path).ok()?;
332 let since_epoch = metadata.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?;
333 Some((metadata.len(), u64::try_from(since_epoch.as_nanos()).ok()?))
334}
335
336fn ensure(
337 dir: &Path,
338 spec: &ModelSpec,
339 cache: &DigestCache,
340 on_progress: &mut Reporter<'_>,
341) -> Result<(), Error> {
342 let target = dir.join(&spec.file);
343 if target.try_exists().map_err(|source| Error::Io { path: target.clone(), source })? {
344 match check_present(&target, spec, cache, on_progress) {
345 Ok(()) => {
346 announce(on_progress, Progress::Ready { file: spec.file.clone() })?;
347 return Ok(());
348 }
349 Err(failure) if !is_bytes_verdict(&failure) => return Err(failure),
350 Err(failure) => {
354 tracing::debug!(
355 event_name = "dictate_model_mismatch_refetching",
356 file = %spec.file,
357 error = %failure,
358 "model on disk does not match its spec; fetching a replacement"
359 );
360 }
361 }
362 }
363
364 let partial = dir.join(format!("{}.part", spec.file));
365 download(spec, &partial, on_progress)?;
366
367 if spec.sha256.is_some() {
371 announce(on_progress, Progress::Verifying { file: spec.file.clone() })?;
372 }
373
374 let state = state_of(&partial);
375 let digest = match verify(&partial, spec, on_progress) {
376 Ok(digest) => digest,
377 Err(failure) => return Err(discard_unusable_partial(&partial, failure)),
378 };
379 fs::rename(&partial, &target).map_err(|source| Error::Io { path: target.clone(), source })?;
380 if let Some(digest) = &digest {
383 cache.record(&target, state, digest);
384 }
385 announce(on_progress, Progress::Ready { file: spec.file.clone() })?;
386 Ok(())
387}
388
389fn check_present(
392 target: &Path,
393 spec: &ModelSpec,
394 cache: &DigestCache,
395 on_progress: &mut Reporter<'_>,
396) -> Result<(), Error> {
397 if cache.hit(target, spec) {
400 return Ok(());
401 }
402 if spec.sha256.is_some() {
406 announce(on_progress, Progress::Verifying { file: spec.file.clone() })?;
407 }
408 let state = state_of(target);
409 let digest = verify(target, spec, on_progress)?;
410 if let Some(digest) = &digest {
411 cache.record(target, state, digest);
412 }
413 Ok(())
414}
415
416fn is_bytes_verdict(failure: &Error) -> bool {
420 matches!(failure, Error::SizeMismatch { .. } | Error::HashMismatch { .. })
421}
422
423fn discard_unusable_partial(partial: &Path, failure: Error) -> Error {
429 if !is_bytes_verdict(&failure) {
430 tracing::warn!(
431 event_name = "dictate_partial_unchecked",
432 path = %partial.display(),
433 error = %failure,
434 "could not check the partial; leaving it in place"
435 );
436 return failure;
437 }
438 match fs::remove_file(partial) {
439 Ok(()) => {
440 tracing::warn!(
441 event_name = "dictate_partial_removed",
442 path = %partial.display(),
443 error = %failure,
444 "partial does not match its spec; removed"
445 );
446 failure
447 }
448 Err(source) => {
449 tracing::error!(
450 event_name = "dictate_partial_removal_failed",
451 path = %partial.display(),
452 error = %failure,
453 remove_error = %source,
454 "partial does not match its spec and could not be removed"
455 );
456 Error::StalePartial { path: partial.into(), source }
457 }
458 }
459}
460
461fn verify(
475 path: &Path,
476 spec: &ModelSpec,
477 on_progress: &mut Reporter<'_>,
478) -> Result<Option<String>, Error> {
479 let actual =
480 fs::metadata(path).map_err(|source| Error::Io { path: path.into(), source })?.len();
481 if actual != spec.size {
482 return Err(Error::SizeMismatch { path: path.into(), expected: spec.size, actual });
483 }
484
485 let Some(expected) = spec.sha256.as_deref() else {
486 return Ok(None);
487 };
488 let digest = sha256(path, spec, on_progress)?;
489 if !digest.eq_ignore_ascii_case(expected) {
490 return Err(Error::HashMismatch {
491 path: path.into(),
492 expected: expected.to_owned(),
493 actual: digest,
494 });
495 }
496 Ok(Some(digest))
497}
498
499fn sha256(path: &Path, spec: &ModelSpec, on_progress: &mut Reporter<'_>) -> Result<String, Error> {
500 let mut file = File::open(path).map_err(|source| Error::Io { path: path.into(), source })?;
501 let mut sink = HashingWriter {
502 hasher: Sha256::new(),
503 name: &spec.file,
504 hashed: 0,
505 reported: 0,
506 cancelled: false,
507 interval: VERIFY_PROGRESS_INTERVAL,
508 on_progress,
509 };
510 let copied = std::io::copy(&mut file, &mut sink);
511 if sink.cancelled {
513 return Err(Error::Cancelled);
514 }
515 copied.map_err(|source| Error::Io { path: path.into(), source })?;
516 Ok(hex::encode(sink.hasher.finalize()))
517}
518
519struct HashingWriter<'a, 'r> {
523 hasher: Sha256,
524 name: &'a str,
525 hashed: u64,
526 reported: u64,
527 cancelled: bool,
528 interval: u64,
529 on_progress: &'a mut Reporter<'r>,
530}
531
532impl std::io::Write for HashingWriter<'_, '_> {
533 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
534 self.hasher.update(buf);
535 self.hashed += buf.len() as u64;
536 if self.hashed - self.reported >= self.interval && self.report().is_break() {
537 self.cancelled = true;
538 return Err(std::io::Error::other("cancelled by the progress callback"));
541 }
542 Ok(buf.len())
543 }
544
545 fn flush(&mut self) -> std::io::Result<()> {
546 Ok(())
547 }
548}
549
550impl HashingWriter<'_, '_> {
551 fn report(&mut self) -> ControlFlow<()> {
552 self.reported = self.hashed;
553 (self.on_progress)(Progress::Verifying { file: self.name.to_owned() })
554 }
555}
556
557fn download(spec: &ModelSpec, partial: &Path, on_progress: &mut Reporter<'_>) -> Result<(), Error> {
558 let mut have = match fs::metadata(partial) {
559 Ok(meta) => meta.len(),
560 Err(e) if e.kind() == std::io::ErrorKind::NotFound => 0,
561 Err(source) => return Err(Error::Io { path: partial.into(), source }),
562 };
563 if have == spec.size {
568 return Ok(());
569 }
570 if have > spec.size {
571 have = 0;
572 }
573
574 let http = |source| Error::Http { url: spec.url.clone(), source };
575 let client = reqwest::blocking::Client::builder()
576 .connect_timeout(std::time::Duration::from_secs(30))
577 .build()
578 .map_err(http)?;
579
580 let mut request = client.get(&spec.url);
581 if have > 0 {
582 tracing::debug!(
583 event_name = "dictate_download_resumed",
584 file = %spec.file,
585 have,
586 "resuming interrupted download"
587 );
588 request = request.header(reqwest::header::RANGE, format!("bytes={have}-"));
589 }
590 let mut response = request.send().map_err(http)?;
591
592 let status = response.status();
593 if !status.is_success() {
594 return Err(Error::HttpStatus { url: spec.url.clone(), status: status.as_u16() });
595 }
596 let resuming = have > 0
601 && status == reqwest::StatusCode::PARTIAL_CONTENT
602 && content_range_starts_at(response.headers(), have);
603 if !resuming {
604 have = 0;
605 }
606
607 let mut options = fs::OpenOptions::new();
608 options.create(true).write(true);
609 if resuming {
610 options.append(true);
611 } else {
612 options.truncate(true);
613 }
614 let file =
615 options.open(partial).map_err(|source| Error::Io { path: partial.into(), source })?;
616
617 let mut sink = ProgressWriter {
618 file,
619 name: &spec.file,
620 written: have,
621 reported: have,
622 total: spec.size,
623 cancelled: false,
624 on_progress,
625 };
626 let copied = std::io::copy(&mut response, &mut sink);
627 if sink.cancelled {
630 return Err(Error::Cancelled);
631 }
632 copied.map_err(|source| Error::Io { path: partial.into(), source })?;
633 sink.report_now()
634}
635
636fn content_range_starts_at(headers: &reqwest::header::HeaderMap, want: u64) -> bool {
640 headers
641 .get(reqwest::header::CONTENT_RANGE)
642 .and_then(|value| value.to_str().ok())
643 .and_then(|value| value.split_whitespace().nth(1))
644 .and_then(|range| range.split('-').next())
645 .and_then(|start| start.parse::<u64>().ok())
646 .is_some_and(|start| start == want)
647}
648
649struct ProgressWriter<'a, 'r> {
653 file: File,
654 name: &'a str,
655 written: u64,
656 reported: u64,
657 total: u64,
658 cancelled: bool,
659 on_progress: &'a mut Reporter<'r>,
660}
661
662impl ProgressWriter<'_, '_> {
663 fn report(&mut self) -> ControlFlow<()> {
664 self.reported = self.written;
665 (self.on_progress)(Progress::Downloading {
666 file: self.name.to_owned(),
667 downloaded: self.written,
668 total: self.total,
669 })
670 }
671
672 fn report_now(&mut self) -> Result<(), Error> {
673 match self.report() {
674 ControlFlow::Continue(()) => Ok(()),
675 ControlFlow::Break(()) => Err(Error::Cancelled),
676 }
677 }
678}
679
680impl std::io::Write for ProgressWriter<'_, '_> {
681 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
682 let n = self.file.write(buf)?;
683 self.written += n as u64;
684 if self.written - self.reported >= PROGRESS_INTERVAL && self.report().is_break() {
685 self.cancelled = true;
686 return Err(std::io::Error::other("cancelled by the progress callback"));
692 }
693 Ok(n)
694 }
695
696 fn flush(&mut self) -> std::io::Result<()> {
697 self.file.flush()
698 }
699}
700
701#[cfg(test)]
702mod tests_cached_verification {
703 use super::*;
704 use crate::{ConfigBuilder, ModelFacts, ModelSpec, spec_for_download};
705 use sha2::{Digest, Sha256};
706 use std::fs;
707 use std::io::Write as _;
708
709 fn offline_spec(file: &str, body: &[u8]) -> ModelSpec {
713 ModelSpec {
714 file: file.into(),
715 url: "http://127.0.0.1:1/unreachable".into(),
716 size: body.len() as u64,
717 sha256: Some(hex::encode(Sha256::digest(body))),
718 facts: ModelFacts::default(),
719 }
720 }
721
722 #[test]
726 fn a_spec_without_a_digest_is_accepted_on_its_size_alone() {
727 let dir = tempfile::tempdir().unwrap();
728 let body = b"five!";
729 fs::write(dir.path().join("candidate.gguf"), body).unwrap();
730
731 let cfg = ConfigBuilder::new()
732 .models_dir(dir.path())
733 .asr_model(spec_for_download(
734 "candidate.gguf",
735 "http://127.0.0.1:1/unreachable",
736 body.len() as u64,
737 None,
738 ModelFacts::default(),
739 ))
740 .normalizer(None)
741 .build();
742 let announced = std::cell::RefCell::new(Vec::new());
743
744 prepare(&cfg, |progress| {
745 announced.borrow_mut().push(progress);
746 std::ops::ControlFlow::Continue(())
747 })
748 .expect("a file of the right length is all a digestless spec asks for");
749
750 assert!(
751 matches!(
752 announced.borrow().last(),
753 Some(Progress::Ready { file }) if file == "candidate.gguf"
754 ),
755 "the file was not announced ready: {:?}",
756 announced.borrow()
757 );
758 assert!(
759 !announced.borrow().iter().any(|p| matches!(p, Progress::Verifying { .. })),
760 "a digestless spec has nothing to hash, so nothing may say it is verifying"
761 );
762 }
763
764 #[test]
765 fn a_cached_model_matching_its_spec_is_used_as_is() {
766 let dir = tempfile::tempdir().unwrap();
767 let body = b"the complete model weights";
768 fs::write(dir.path().join("asr.gguf"), body).unwrap();
769
770 let cfg = ConfigBuilder::new()
771 .models_dir(dir.path())
772 .asr_model(offline_spec("asr.gguf", body))
773 .normalizer(None)
774 .build();
775
776 let mut reported = Vec::new();
777 prepare(&cfg, |p| {
778 reported.push(p);
779 ControlFlow::Continue(())
780 })
781 .expect("a cached model that matches its spec must be accepted");
782
783 let sequence: Vec<_> = reported
784 .iter()
785 .map(|p| match p {
786 Progress::Verifying { .. } => "verifying",
787 Progress::Downloading { .. } => "downloading",
788 Progress::Ready { .. } => "ready",
789 })
790 .collect();
791 assert_eq!(
792 sequence,
793 ["verifying", "ready"],
794 "a good cached model must be verified and announced without being fetched again"
795 );
796 }
797
798 #[test]
799 fn a_complete_partial_is_verified_rather_than_fetched_again() {
800 let dir = tempfile::tempdir().unwrap();
801 let body = b"the complete model weights";
802 fs::write(dir.path().join("asr.gguf.part"), body).unwrap();
804
805 let cfg = ConfigBuilder::new()
806 .models_dir(dir.path())
807 .asr_model(offline_spec("asr.gguf", body))
808 .normalizer(None)
809 .build();
810
811 prepare(&cfg, |_| ControlFlow::Continue(()))
812 .expect("a complete partial must be adopted, not re-downloaded");
813 assert_eq!(
814 fs::read(dir.path().join("asr.gguf")).unwrap(),
815 body,
816 "the finished bytes must be promoted in place rather than thrown away"
817 );
818 }
819
820 #[cfg(unix)]
823 #[test]
824 fn an_unreadable_partial_is_reported_not_destroyed() {
825 use std::os::unix::fs::PermissionsExt;
826
827 let dir = tempfile::tempdir().unwrap();
828 let body = b"the complete model weights";
829 let partial = dir.path().join("asr.gguf.part");
830 fs::write(&partial, body).unwrap();
832 fs::set_permissions(&partial, fs::Permissions::from_mode(0o000)).unwrap();
833
834 let cfg = ConfigBuilder::new()
835 .models_dir(dir.path())
836 .asr_model(offline_spec("asr.gguf", body))
837 .normalizer(None)
838 .build();
839
840 let err = prepare(&cfg, |_| ControlFlow::Continue(()))
841 .expect_err("an unreadable partial cannot be verified");
842 assert!(
843 matches!(err, Error::Io { .. }),
844 "a filesystem fault must stay an io error, not become a verdict on the bytes, got: {err:?}"
845 );
846 assert!(
847 partial.exists(),
848 "an io error says nothing about the bytes, so the file must survive it"
849 );
850
851 fs::set_permissions(&partial, fs::Permissions::from_mode(0o600)).unwrap();
852 }
853
854 #[cfg(unix)]
857 #[test]
858 fn a_partial_that_cannot_be_removed_is_a_different_error() {
859 use std::os::unix::fs::PermissionsExt;
860
861 let dir = tempfile::tempdir().unwrap();
862 let partial = dir.path().join("asr.gguf.part");
863 fs::write(&partial, b"the corrupted model weight").unwrap();
864 fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o500)).unwrap();
865
866 let cfg = ConfigBuilder::new()
867 .models_dir(dir.path())
868 .asr_model(offline_spec("asr.gguf", b"the complete model weights"))
869 .normalizer(None)
870 .build();
871
872 let err = prepare(&cfg, |_| ControlFlow::Continue(()))
873 .expect_err("a corrupt partial must not be accepted");
874 assert!(
875 matches!(err, Error::StalePartial { .. }),
876 "a corruption that will repeat forever must be distinguishable from one that clears on retry, got: {err:?}"
877 );
878
879 fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o700)).unwrap();
880 }
881
882 #[cfg(unix)]
887 #[test]
888 fn an_unreadable_target_is_reported_not_replaced() {
889 use std::os::unix::fs::PermissionsExt;
890
891 let dir = tempfile::tempdir().unwrap();
892 let body = b"the complete model weights";
893 let target = dir.path().join("asr.gguf");
894 fs::write(&target, body).unwrap();
895 fs::set_permissions(&target, fs::Permissions::from_mode(0o000)).unwrap();
896
897 let cfg = ConfigBuilder::new()
898 .models_dir(dir.path())
899 .asr_model(offline_spec("asr.gguf", body))
900 .normalizer(None)
901 .build();
902
903 let err = prepare(&cfg, |_| ControlFlow::Continue(()))
904 .expect_err("a model that cannot be read cannot be verified");
905 assert!(
906 matches!(err, Error::Io { .. }),
907 "an unreadable file must stay an io error rather than be sent to the network, got: {err:?}"
908 );
909 assert!(
910 target.exists(),
911 "an io error says nothing about the bytes, so the file must survive it"
912 );
913
914 fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).unwrap();
915 }
916
917 fn hashing_sink<'a, 'r>(
918 interval: u64,
919 on_progress: &'a mut Reporter<'r>,
920 ) -> HashingWriter<'a, 'r> {
921 HashingWriter {
922 hasher: Sha256::new(),
923 name: "asr.gguf",
924 hashed: 0,
925 reported: 0,
926 cancelled: false,
927 interval,
928 on_progress,
929 }
930 }
931
932 #[test]
935 fn the_hashing_sink_breaks_at_its_interval() {
936 let body = b"0123456789abcdefghij";
937 let events = std::cell::RefCell::new(Vec::new());
938 let mut on_progress = |p: Progress| {
939 events.borrow_mut().push(p);
940 ControlFlow::Break(())
941 };
942 let mut sink = hashing_sink(8, &mut on_progress);
943
944 sink.write_all(&body[..5]).unwrap();
946 assert!(events.borrow().is_empty(), "no checkpoint under the interval");
947
948 assert!(sink.write_all(&body[5..15]).is_err(), "a break must fail the write");
951 assert!(sink.cancelled, "the caller must be able to tell a break from a disk fault");
952 assert_eq!(sink.hashed, 15, "every fed byte reaches the count");
953 assert_eq!(sink.reported, 15, "the checkpoint reported what it had hashed");
954 assert_eq!(events.borrow().len(), 1, "exactly one checkpoint fired before the break");
955 let first = events.borrow()[0].clone();
956 assert!(
957 matches!(first, Progress::Verifying { ref file } if file == "asr.gguf"),
958 "the checkpoint names the file being verified, got: {first:?}"
959 );
960 }
961
962 #[test]
965 fn the_hashing_sink_accumulates_the_fed_digest() {
966 let body = b"pretend these are recognition weights";
967 let mut on_progress = |_| ControlFlow::Continue(());
968 let mut sink = hashing_sink(u64::MAX, &mut on_progress);
969
970 sink.write_all(body).unwrap();
971 assert_eq!(sink.hashed, body.len() as u64, "every fed byte reaches the count");
972 assert_eq!(sink.reported, 0, "an interval nothing crosses reports nothing");
973 assert!(!sink.cancelled, "a continuing callback never cancels");
974 assert_eq!(
975 hex::encode(sink.hasher.finalize()),
976 hex::encode(Sha256::digest(body)),
977 "the sink must hash exactly the bytes it is fed"
978 );
979 }
980}
981
982#[cfg(test)]
983mod tests_download {
984 use super::*;
985 use crate::{ConfigBuilder, ModelFacts, ModelSpec, spec_for_download};
986 use sha2::{Digest, Sha256};
987 use std::io::{BufRead, BufReader, Write};
988 use std::net::TcpListener;
989 use std::sync::mpsc;
990 use std::thread;
991
992 struct Server {
998 base: String,
999 seen: mpsc::Receiver<String>,
1000 }
1001
1002 #[derive(Clone, Copy)]
1005 enum Ranges {
1006 Honour,
1008 Ignore,
1010 Clamp,
1012 }
1013
1014 fn serve(files: Vec<(&'static str, Vec<u8>)>) -> Server {
1015 serve_inner(files, Ranges::Honour)
1016 }
1017
1018 fn serve_ignoring_range(files: Vec<(&'static str, Vec<u8>)>) -> Server {
1019 serve_inner(files, Ranges::Ignore)
1020 }
1021
1022 fn serve_clamping_range(files: Vec<(&'static str, Vec<u8>)>) -> Server {
1023 serve_inner(files, Ranges::Clamp)
1024 }
1025
1026 fn serve_inner(files: Vec<(&'static str, Vec<u8>)>, ranges: Ranges) -> Server {
1027 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1028 let base = format!("http://{}", listener.local_addr().unwrap());
1029 let (tx, seen) = mpsc::channel();
1030
1031 thread::spawn(move || {
1034 for stream in listener.incoming() {
1035 let Ok(mut stream) = stream else { break };
1036 let mut reader = BufReader::new(stream.try_clone().unwrap());
1037 let mut head = String::new();
1038 loop {
1039 let mut line = String::new();
1040 if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" {
1041 break;
1042 }
1043 head.push_str(&line);
1044 }
1045
1046 let path = head.split_whitespace().nth(1).unwrap_or("/").to_string();
1047 let asked: Option<usize> = head
1048 .lines()
1049 .find(|l| l.to_ascii_lowercase().starts_with("range:"))
1050 .and_then(|l| l.split("bytes=").nth(1))
1051 .and_then(|r| r.trim().trim_end_matches('-').parse().ok());
1052 let (partial, start) = match (ranges, asked) {
1055 (Ranges::Honour, Some(n)) => (true, n),
1056 (Ranges::Clamp, Some(_)) => (true, 0),
1057 (Ranges::Ignore, Some(_)) | (_, None) => (false, 0),
1059 };
1060 tx.send(head).unwrap();
1061
1062 let body = files.iter().find(|(p, _)| *p == path).map(|(_, b)| b);
1063 let response = match body {
1064 None => {
1065 b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
1066 .to_vec()
1067 }
1068 Some(full) => {
1069 let slice = &full[start.min(full.len())..];
1070 let mut head = if partial {
1071 format!(
1072 "HTTP/1.1 206 Partial Content\r\nContent-Length: {}\r\nContent-Range: bytes {}-{}/{}\r\n",
1073 slice.len(),
1074 start,
1075 full.len() - 1,
1076 full.len()
1077 )
1078 } else {
1079 format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n", slice.len())
1080 };
1081 head.push_str("Connection: close\r\n\r\n");
1082 let mut out = head.into_bytes();
1083 out.extend_from_slice(slice);
1084 out
1085 }
1086 };
1087 let _ = stream.write_all(&response);
1088 let _ = stream.flush();
1089 }
1090 });
1091
1092 Server { base, seen }
1093 }
1094
1095 fn spec_for(server: &Server, path: &str, body: &[u8]) -> ModelSpec {
1096 ModelSpec {
1097 file: path.trim_start_matches('/').into(),
1098 url: format!("{}{path}", server.base),
1099 size: body.len() as u64,
1100 sha256: Some(hex::encode(Sha256::digest(body))),
1101 facts: ModelFacts::default(),
1102 }
1103 }
1104
1105 #[test]
1106 fn every_configured_model_is_downloaded_and_verified() {
1107 let asr = b"pretend these are recognition weights".to_vec();
1108 let norm = b"and these rewrite the words".to_vec();
1109 let server = serve(vec![("/asr.gguf", asr.clone()), ("/norm.gguf", norm.clone())]);
1110 let dir = tempfile::tempdir().unwrap();
1111
1112 let cfg = ConfigBuilder::new()
1113 .models_dir(dir.path())
1114 .asr_model(spec_for(&server, "/asr.gguf", &asr))
1115 .normalizer(spec_for(&server, "/norm.gguf", &norm))
1116 .build();
1117
1118 let mut reported = Vec::new();
1119 prepare(&cfg, |p| {
1120 reported.push(p);
1121 ControlFlow::Continue(())
1122 })
1123 .expect("both models must download and verify");
1124
1125 let landed_asr = fs::read(dir.path().join("asr.gguf"))
1126 .expect("the asr model must be on disk under its spec's file name");
1127 let landed_norm = fs::read(dir.path().join("norm.gguf"))
1128 .expect("the normalizer must be fetched too, not only the asr model");
1129 assert_eq!(landed_asr, asr, "the asr model must be the bytes the server served");
1130 assert_eq!(landed_norm, norm, "the normalizer must be the bytes the server served");
1131
1132 let mut ready: Vec<_> = reported
1135 .iter()
1136 .filter_map(|p| match p {
1137 Progress::Ready { file } => Some(file.as_str()),
1138 _ => None,
1139 })
1140 .collect();
1141 ready.sort_unstable();
1142 assert_eq!(ready, ["asr.gguf", "norm.gguf"], "each model must be reported ready");
1143 }
1144
1145 #[test]
1155 fn a_panicking_callback_unwinds_rather_than_parking_the_workers() {
1156 let asr = b"pretend these are recognition weights".to_vec();
1157 let norm = b"and these rewrite the words".to_vec();
1158 let server = serve(vec![("/asr.gguf", asr.clone()), ("/norm.gguf", norm.clone())]);
1159 let dir = tempfile::tempdir().unwrap();
1160 let cfg = ConfigBuilder::new()
1161 .models_dir(dir.path())
1162 .asr_model(spec_for(&server, "/asr.gguf", &asr))
1163 .normalizer(spec_for(&server, "/norm.gguf", &norm))
1164 .build();
1165
1166 let running = std::thread::spawn(move || prepare(&cfg, |_| panic!("callback blew up")));
1167
1168 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
1169 while !running.is_finished() && std::time::Instant::now() < deadline {
1170 std::thread::sleep(std::time::Duration::from_millis(25));
1171 }
1172 assert!(
1173 running.is_finished(),
1174 "prepare never returned: the workers are parked on a verdict that cannot arrive"
1175 );
1176 assert!(
1177 running.join().is_err(),
1178 "the callback's panic must surface to the caller, not be swallowed"
1179 );
1180 }
1181
1182 #[test]
1183 fn a_failing_model_does_not_stop_the_other_from_being_fetched() {
1184 let norm = b"and these rewrite the words".to_vec();
1185 let server = serve(vec![("/norm.gguf", norm.clone())]);
1186 let dir = tempfile::tempdir().unwrap();
1187
1188 let cfg = ConfigBuilder::new()
1189 .models_dir(dir.path())
1190 .asr_model(spec_for(&server, "/absent.gguf", b"never served"))
1191 .normalizer(spec_for(&server, "/norm.gguf", &norm))
1192 .build();
1193
1194 let err = prepare(&cfg, |_| ControlFlow::Continue(()))
1195 .expect_err("the missing asr model must still be reported");
1196 assert!(
1197 matches!(err, Error::HttpStatus { status: 404, .. }),
1198 "the asr model's 404 must survive the other model succeeding, got: {err:?}"
1199 );
1200 let landed = fs::read(dir.path().join("norm.gguf")).expect(
1201 "the models run concurrently, so one failing must not leave the other unattempted",
1202 );
1203 assert_eq!(landed, norm, "the normalizer must be the bytes the server served");
1204 }
1205
1206 #[test]
1207 fn a_download_reports_its_last_byte_before_it_reports_verifying() {
1208 let body = b"pretend these are recognition weights".to_vec();
1209 let server = serve(vec![("/asr.gguf", body.clone())]);
1210 let dir = tempfile::tempdir().unwrap();
1211
1212 let cfg = ConfigBuilder::new()
1213 .models_dir(dir.path())
1214 .asr_model(spec_for(&server, "/asr.gguf", &body))
1215 .normalizer(None)
1216 .build();
1217
1218 let mut reported = Vec::new();
1219 prepare(&cfg, |p| {
1220 reported.push(p);
1221 ControlFlow::Continue(())
1222 })
1223 .expect("the model must download");
1224
1225 let total = body.len() as u64;
1226 let sequence: Vec<_> = reported
1227 .iter()
1228 .map(|p| match p {
1229 Progress::Downloading { downloaded, .. } if *downloaded == total => {
1230 "downloaded-all"
1231 }
1232 Progress::Downloading { .. } => "downloading",
1233 Progress::Verifying { .. } => "verifying",
1234 Progress::Ready { .. } => "ready",
1235 })
1236 .collect();
1237 assert_eq!(
1238 sequence,
1239 ["downloaded-all", "verifying", "ready"],
1240 "a caller must be told hashing has started, or a full progress bar looks like a hang"
1241 );
1242 }
1243
1244 #[test]
1245 fn interrupted_download_resumes_from_what_is_already_on_disk() {
1246 let body = b"pretend these are recognition weights".to_vec();
1247 let server = serve(vec![("/asr.gguf", body.clone())]);
1248 let dir = tempfile::tempdir().unwrap();
1249 fs::write(dir.path().join("asr.gguf.part"), &body[..10]).unwrap();
1251
1252 let cfg = ConfigBuilder::new()
1253 .models_dir(dir.path())
1254 .asr_model(spec_for(&server, "/asr.gguf", &body))
1255 .normalizer(None)
1256 .build();
1257
1258 prepare(&cfg, |_| ControlFlow::Continue(()))
1259 .expect("a partial download must resume, not fail");
1260
1261 let head = server.seen.recv().unwrap();
1262 assert!(
1263 head.to_ascii_lowercase().contains("range: bytes=10-"),
1264 "resume must ask for the bytes it lacks, request was: {head}"
1265 );
1266 assert_eq!(
1267 fs::read(dir.path().join("asr.gguf")).unwrap(),
1268 body,
1269 "the resumed file must be the whole model, not the tail alone"
1270 );
1271 }
1272
1273 #[test]
1274 fn a_server_that_ignores_range_does_not_produce_a_spliced_file() {
1275 let body = b"pretend these are recognition weights".to_vec();
1276 let server = serve_ignoring_range(vec![("/asr.gguf", body.clone())]);
1277 let dir = tempfile::tempdir().unwrap();
1278 fs::write(dir.path().join("asr.gguf.part"), b"XXXXXXXXXX").unwrap();
1279
1280 let cfg = ConfigBuilder::new()
1281 .models_dir(dir.path())
1282 .asr_model(spec_for(&server, "/asr.gguf", &body))
1283 .normalizer(None)
1284 .build();
1285
1286 prepare(&cfg, |_| ControlFlow::Continue(()))
1287 .expect("a whole-file 200 must replace the partial, not be appended to it");
1288 assert_eq!(
1289 fs::read(dir.path().join("asr.gguf")).unwrap(),
1290 body,
1291 "a 200 answer to a ranged request must overwrite what was already on disk"
1292 );
1293 }
1294
1295 #[test]
1296 fn breaking_mid_transfer_leaves_a_resumable_prefix() {
1297 let body: Vec<u8> = (0..1_572_864u32).map(|i| (i % 251) as u8).collect();
1301 let server = serve(vec![("/asr.gguf", body.clone())]);
1302 let dir = tempfile::tempdir().unwrap();
1303
1304 let cfg = ConfigBuilder::new()
1305 .models_dir(dir.path())
1306 .asr_model(spec_for(&server, "/asr.gguf", &body))
1307 .normalizer(None)
1308 .build();
1309
1310 let err = prepare(&cfg, |_| ControlFlow::Break(()))
1311 .expect_err("a callback that breaks must stop the transfer");
1312 assert!(matches!(err, Error::Cancelled), "a break must surface as Cancelled, got: {err:?}");
1313
1314 let partial = fs::read(dir.path().join("asr.gguf.part")).expect("the partial must remain");
1315 assert!(
1316 !partial.is_empty() && partial.len() < body.len(),
1317 "cancelling must stop short: kept {} of {} bytes",
1318 partial.len(),
1319 body.len()
1320 );
1321 assert_eq!(
1322 partial,
1323 body[..partial.len()],
1324 "what is kept must be a prefix of the file, or a later call resumes onto wrong bytes"
1325 );
1326 }
1327
1328 #[test]
1329 fn breaking_after_the_last_byte_still_aborts() {
1330 let body = b"pretend these are recognition weights".to_vec();
1331 let server = serve(vec![("/asr.gguf", body.clone())]);
1332 let dir = tempfile::tempdir().unwrap();
1333
1334 let cfg = ConfigBuilder::new()
1335 .models_dir(dir.path())
1336 .asr_model(spec_for(&server, "/asr.gguf", &body))
1337 .normalizer(None)
1338 .build();
1339
1340 let err = prepare(&cfg, |_| ControlFlow::Break(()))
1343 .expect_err("a break at the closing report must still abort");
1344 assert!(matches!(err, Error::Cancelled), "a break must surface as Cancelled, got: {err:?}");
1345 assert!(
1346 !dir.path().join("asr.gguf").exists(),
1347 "a cancelled transfer must not be promoted to the real file"
1348 );
1349 }
1350
1351 #[test]
1352 fn a_refusing_server_is_reported_as_its_status() {
1353 let server = serve(vec![("/asr.gguf", b"present".to_vec())]);
1354 let dir = tempfile::tempdir().unwrap();
1355
1356 let cfg = ConfigBuilder::new()
1357 .models_dir(dir.path())
1358 .asr_model(spec_for(&server, "/absent.gguf", b"never served"))
1359 .normalizer(None)
1360 .build();
1361
1362 let err =
1363 prepare(&cfg, |_| ControlFlow::Continue(())).expect_err("a 404 is not a download");
1364 assert!(
1365 matches!(err, Error::HttpStatus { status: 404, .. }),
1366 "a refusal must be reported as the status it was, not as a corrupt file, got: {err:?}"
1367 );
1368 }
1369
1370 #[test]
1371 fn a_206_from_an_offset_we_did_not_ask_for_does_not_splice() {
1372 let body = b"pretend these are recognition weights".to_vec();
1373 let server = serve_clamping_range(vec![("/asr.gguf", body.clone())]);
1374 let dir = tempfile::tempdir().unwrap();
1375 fs::write(dir.path().join("asr.gguf.part"), &body[..10]).unwrap();
1376
1377 let cfg = ConfigBuilder::new()
1378 .models_dir(dir.path())
1379 .asr_model(spec_for(&server, "/asr.gguf", &body))
1380 .normalizer(None)
1381 .build();
1382
1383 prepare(&cfg, |_| ControlFlow::Continue(()))
1384 .expect("a 206 starting at zero must replace the partial");
1385 assert_eq!(
1386 fs::read(dir.path().join("asr.gguf")).unwrap(),
1387 body,
1388 "only the requested offset makes a 206 a resume; any other start must overwrite"
1389 );
1390 }
1391
1392 #[test]
1393 fn partial_that_fails_verification_is_discarded() {
1394 let body = b"pretend these are recognition weights".to_vec();
1395 let server = serve(vec![("/asr.gguf", body.clone())]);
1396 let dir = tempfile::tempdir().unwrap();
1397 let partial = dir.path().join("asr.gguf.part");
1398 fs::write(&partial, b"CORRUPTED!").unwrap();
1401
1402 let cfg = ConfigBuilder::new()
1403 .models_dir(dir.path())
1404 .asr_model(spec_for(&server, "/asr.gguf", &body))
1405 .normalizer(None)
1406 .build();
1407
1408 let err = prepare(&cfg, |_| ControlFlow::Continue(()))
1409 .expect_err("a corrupt resume must not be accepted");
1410 assert!(
1411 matches!(err, Error::HashMismatch { .. }),
1412 "expected a hash rejection, got: {err:?}"
1413 );
1414 assert!(
1415 !partial.exists(),
1416 "a partial that cannot ever verify must be discarded, or every later resume inherits it"
1417 );
1418 }
1419
1420 #[test]
1424 fn a_model_of_the_wrong_bytes_is_replaced_by_a_fetch() {
1425 let body = b"pretend these are recognition weights".to_vec();
1426 let server = serve(vec![("/asr.gguf", body.clone())]);
1427 let dir = tempfile::tempdir().unwrap();
1428 fs::write(dir.path().join("asr.gguf"), vec![b'x'; body.len()]).unwrap();
1431
1432 let cfg = ConfigBuilder::new()
1433 .models_dir(dir.path())
1434 .asr_model(spec_for(&server, "/asr.gguf", &body))
1435 .normalizer(None)
1436 .build();
1437
1438 prepare(&cfg, |_| ControlFlow::Continue(()))
1439 .expect("a file whose bytes are wrong must be replaced, not refused");
1440 assert_eq!(
1441 fs::read(dir.path().join("asr.gguf")).unwrap(),
1442 body,
1443 "the name must end up holding the served bytes, not the ones it had"
1444 );
1445 }
1446
1447 #[test]
1448 fn a_truncated_model_is_replaced_by_a_fetch() {
1449 let body = b"pretend these are recognition weights".to_vec();
1450 let server = serve(vec![("/asr.gguf", body.clone())]);
1451 let dir = tempfile::tempdir().unwrap();
1452 fs::write(dir.path().join("asr.gguf"), &body[..body.len() - 1]).unwrap();
1453
1454 let cfg = ConfigBuilder::new()
1455 .models_dir(dir.path())
1456 .asr_model(spec_for(&server, "/asr.gguf", &body))
1457 .normalizer(None)
1458 .build();
1459
1460 prepare(&cfg, |_| ControlFlow::Continue(()))
1461 .expect("a short file must be replaced, not refused");
1462 assert_eq!(
1463 fs::read(dir.path().join("asr.gguf")).unwrap(),
1464 body,
1465 "the name must end up holding the served bytes, not the truncated ones"
1466 );
1467 }
1468
1469 #[test]
1473 fn a_digestless_model_of_the_wrong_size_is_replaced_by_a_fetch() {
1474 let body = b"pretend these are recognition weights".to_vec();
1475 let server = serve(vec![("/asr.gguf", body.clone())]);
1476 let dir = tempfile::tempdir().unwrap();
1477 fs::write(dir.path().join("asr.gguf"), &body[..10]).unwrap();
1478
1479 let cfg = ConfigBuilder::new()
1480 .models_dir(dir.path())
1481 .asr_model(spec_for_download(
1482 "asr.gguf",
1483 &format!("{}/asr.gguf", server.base),
1484 body.len() as u64,
1485 None,
1486 ModelFacts::default(),
1487 ))
1488 .normalizer(None)
1489 .build();
1490
1491 prepare(&cfg, |_| ControlFlow::Continue(()))
1492 .expect("a digestless file of the wrong size must be replaced, not refused");
1493 assert_eq!(
1494 fs::read(dir.path().join("asr.gguf")).unwrap(),
1495 body,
1496 "the name must end up holding the served bytes, not the short ones"
1497 );
1498 }
1499
1500 #[test]
1505 fn a_mismatch_a_fetch_cannot_settle_is_still_refused() {
1506 let other = b"the copy the server holds is wrong too".to_vec();
1507 let wanted = b"pretend these are recognition weights".to_vec();
1508 let server = serve(vec![("/asr.gguf", other.clone())]);
1509 let dir = tempfile::tempdir().unwrap();
1510 let target = dir.path().join("asr.gguf");
1511 let wrong = vec![b'x'; wanted.len()];
1512 fs::write(&target, &wrong).unwrap();
1513
1514 let cfg = ConfigBuilder::new()
1515 .models_dir(dir.path())
1516 .asr_model(spec_for(&server, "/asr.gguf", &wanted))
1517 .normalizer(None)
1518 .build();
1519
1520 let err = prepare(&cfg, |_| ControlFlow::Continue(()))
1521 .expect_err("bytes that fail verification twice must not be accepted");
1522 assert!(
1523 matches!(
1524 err,
1525 Error::SizeMismatch { expected, actual, .. }
1526 if (expected, actual) == (wanted.len() as u64, other.len() as u64)
1527 ),
1528 "the second mismatch must be reported as the mismatch it is, got: {err:?}"
1529 );
1530 assert_eq!(
1531 server.seen.try_iter().count(),
1532 1,
1533 "the repair is a single fetch, not a retry loop"
1534 );
1535 assert_eq!(
1536 fs::read(&target).unwrap(),
1537 wrong,
1538 "a replacement that never verified must not cost the file it was replacing"
1539 );
1540 }
1541}
1542
1543#[cfg(test)]
1544mod tests_digest_cache {
1545 use super::*;
1546 use crate::{ConfigBuilder, ModelFacts, ModelSpec};
1547 use std::fs;
1548 use std::io::Write as _;
1549 use std::os::unix::fs::PermissionsExt;
1550 use std::time::{Duration, SystemTime};
1551
1552 const BODY: &[u8] = b"the complete model weights";
1553
1554 fn offline_spec(file: &str, body: &[u8]) -> ModelSpec {
1558 ModelSpec {
1559 file: file.into(),
1560 url: "http://127.0.0.1:1/unreachable".into(),
1561 size: body.len() as u64,
1562 sha256: Some(hex::encode(Sha256::digest(body))),
1563 facts: ModelFacts::default(),
1564 }
1565 }
1566
1567 fn config_with(models: &Path, digests: &Path, asr: ModelSpec) -> Config {
1568 ConfigBuilder::new()
1569 .models_dir(models)
1570 .digest_cache_dir(digests)
1571 .asr_model(asr)
1572 .normalizer(None)
1573 .build()
1574 }
1575
1576 fn config(models: &Path, digests: &Path) -> Config {
1577 config_with(models, digests, offline_spec("asr.gguf", BODY))
1578 }
1579
1580 fn tag(progress: &Progress) -> &'static str {
1581 match progress {
1582 Progress::Verifying { .. } => "verifying",
1583 Progress::Downloading { .. } => "downloading",
1584 Progress::Ready { .. } => "ready",
1585 }
1586 }
1587
1588 fn prepare_ok(cfg: &Config) -> Vec<&'static str> {
1589 let mut reported = Vec::new();
1590 prepare(cfg, |progress| {
1591 reported.push(tag(&progress));
1592 ControlFlow::Continue(())
1593 })
1594 .expect("prepare must succeed");
1595 reported
1596 }
1597
1598 fn refuse_after_repair(cfg: &Config, target: &Path) {
1604 let before = fs::read(target).unwrap();
1605 let mut reported = Vec::new();
1606 let err = prepare(cfg, |progress| {
1607 reported.push(tag(&progress));
1608 ControlFlow::Continue(())
1609 })
1610 .expect_err("a file this run cannot accept must not be reported ready");
1611 assert!(
1612 reported.contains(&"verifying"),
1613 "the file must be read again rather than taken on trust, reported: {reported:?}"
1614 );
1615 assert!(
1616 matches!(err, Error::Http { .. }),
1617 "the refusal must be the repair it could not make, got: {err:?}"
1618 );
1619 assert_eq!(
1620 fs::read(target).unwrap(),
1621 before,
1622 "a fetch that died before it ever verified must not cost the file it was replacing"
1623 );
1624 }
1625
1626 fn deny_reads(path: &Path) {
1630 fs::set_permissions(path, fs::Permissions::from_mode(0o000)).unwrap();
1631 }
1632
1633 fn allow_reads(path: &Path) {
1634 fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap();
1635 }
1636
1637 fn touch_mtime(path: &Path, when: SystemTime) {
1639 fs::File::options().write(true).open(path).unwrap().set_modified(when).unwrap();
1640 }
1641
1642 fn mtime(path: &Path) -> SystemTime {
1643 fs::metadata(path).unwrap().modified().unwrap()
1644 }
1645
1646 #[test]
1647 fn an_unchanged_verified_file_is_not_read_again() {
1648 let models = tempfile::tempdir().unwrap();
1649 let digests = tempfile::tempdir().unwrap();
1650 let file = models.path().join("asr.gguf");
1651 fs::write(&file, BODY).unwrap();
1652 let cfg = config(models.path(), digests.path());
1653
1654 assert_eq!(
1655 prepare_ok(&cfg),
1656 ["verifying", "ready"],
1657 "a file with no record yet must be hashed this run"
1658 );
1659
1660 deny_reads(&file);
1661 assert_eq!(
1662 prepare_ok(&cfg),
1663 ["ready"],
1664 "a file whose size and mtime match its record must not be read again, and the run \
1665 must still report it ready"
1666 );
1667 allow_reads(&file);
1668 }
1669
1670 #[test]
1671 fn a_moved_mtime_is_hashed_again() {
1672 let models = tempfile::tempdir().unwrap();
1673 let digests = tempfile::tempdir().unwrap();
1674 let file = models.path().join("asr.gguf");
1675 fs::write(&file, BODY).unwrap();
1676 let cfg = config(models.path(), digests.path());
1677 prepare_ok(&cfg);
1678
1679 touch_mtime(&file, SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000));
1682 deny_reads(&file);
1683 let err = prepare(&cfg, |_| ControlFlow::Continue(()))
1684 .expect_err("a file whose mtime moved must be read again");
1685 assert!(
1686 matches!(err, Error::Io { .. }),
1687 "the re-read must actually open the file, got: {err:?}"
1688 );
1689
1690 allow_reads(&file);
1691 assert_eq!(
1692 prepare_ok(&cfg),
1693 ["verifying", "ready"],
1694 "and once re-verified it is usable again"
1695 );
1696 }
1697
1698 #[test]
1699 fn a_size_change_is_not_taken_on_trust_with_the_mtime_restored() {
1700 let models = tempfile::tempdir().unwrap();
1701 let digests = tempfile::tempdir().unwrap();
1702 let file = models.path().join("asr.gguf");
1703 fs::write(&file, BODY).unwrap();
1704 let cfg = config(models.path(), digests.path());
1705 prepare_ok(&cfg);
1706
1707 let recorded = mtime(&file);
1710 fs::OpenOptions::new().append(true).open(&file).unwrap().write_all(b"!").unwrap();
1711 touch_mtime(&file, recorded);
1712 assert_eq!(
1713 mtime(&file),
1714 recorded,
1715 "this filesystem cannot restore an mtime exactly, so this test cannot isolate the size"
1716 );
1717
1718 refuse_after_repair(&cfg, &file);
1719 }
1720
1721 #[test]
1726 fn a_moved_spec_digest_is_not_a_hit() {
1727 let models = tempfile::tempdir().unwrap();
1728 let digests = tempfile::tempdir().unwrap();
1729 let file = models.path().join("asr.gguf");
1730 fs::write(&file, BODY).unwrap();
1731 prepare_ok(&config(models.path(), digests.path()));
1732
1733 let mut moved = offline_spec("asr.gguf", BODY);
1734 moved.sha256 = Some(hex::encode(Sha256::digest(b"what the re-release weights are")));
1735 assert_eq!(
1736 moved.size,
1737 BODY.len() as u64,
1738 "only the digest may differ, or this test is about the size instead"
1739 );
1740
1741 refuse_after_repair(&config_with(models.path(), digests.path(), moved), &file);
1742 }
1743
1744 #[test]
1748 fn a_corrupted_file_whose_mtime_moved_is_still_caught() {
1749 let models = tempfile::tempdir().unwrap();
1750 let digests = tempfile::tempdir().unwrap();
1751 let file = models.path().join("asr.gguf");
1752 fs::write(&file, BODY).unwrap();
1753 let cfg = config(models.path(), digests.path());
1754 prepare_ok(&cfg);
1755
1756 fs::write(&file, vec![b'x'; BODY.len()]).unwrap();
1757 touch_mtime(&file, SystemTime::UNIX_EPOCH + Duration::from_secs(2_000_000));
1758
1759 refuse_after_repair(&cfg, &file);
1760 }
1761
1762 #[test]
1768 fn same_size_and_mtime_is_taken_on_trust() {
1769 let models = tempfile::tempdir().unwrap();
1770 let digests = tempfile::tempdir().unwrap();
1771 let file = models.path().join("asr.gguf");
1772 fs::write(&file, BODY).unwrap();
1773 let cfg = config(models.path(), digests.path());
1774 prepare_ok(&cfg);
1775
1776 let recorded = mtime(&file);
1777 fs::write(&file, vec![b'x'; BODY.len()]).unwrap();
1778 touch_mtime(&file, recorded);
1779
1780 assert_eq!(
1781 prepare_ok(&cfg),
1782 ["ready"],
1783 "a state that matches the record is not re-read, whatever the bytes are"
1784 );
1785 }
1786
1787 #[test]
1791 fn an_unusable_cache_record_costs_a_hash_not_the_run() {
1792 let models = tempfile::tempdir().unwrap();
1793 let digests = tempfile::tempdir().unwrap();
1794 let file = models.path().join("asr.gguf");
1795 fs::write(&file, BODY).unwrap();
1796
1797 fs::write(digests.path().join("verified.json"), b"{ not json").unwrap();
1798 assert_eq!(
1799 prepare_ok(&config(models.path(), digests.path())),
1800 ["verifying", "ready"],
1801 "an unreadable record must fall back to hashing, not fail"
1802 );
1803
1804 let absent = digests.path().join("nothing-here-yet");
1805 assert_eq!(
1806 prepare_ok(&config(models.path(), &absent)),
1807 ["verifying", "ready"],
1808 "a record directory that does not exist yet must be created, not refused"
1809 );
1810
1811 let blocked = tempfile::tempdir().unwrap();
1812 let not_a_directory = blocked.path().join("a-file");
1813 fs::write(¬_a_directory, b"").unwrap();
1814 assert_eq!(
1815 prepare_ok(&config(models.path(), ¬_a_directory)),
1816 ["verifying", "ready"],
1817 "a record path that cannot be written must not fail the run"
1818 );
1819 }
1820}