Skip to main content

orbok_models/
readiness.rs

1//! Local model file readiness check (RFC-043 §7).
2//!
3//! orbok checks local model files before starting any download. This
4//! avoids re-downloading files that are already present and valid,
5//! and prevents treating partially downloaded files as usable.
6//!
7//! User-supplied folders receive the intentionally lightweight RFC-043 check.
8//! App-managed folders additionally require the exact sizes and SHA-256 values
9//! from RFC-050 Appendix B. Provenance and local integrity remain separate.
10
11use crate::trust::{
12    DEFAULT_TRUSTED_MODEL, TrustRootBinding, TrustedModelFile, TrustedModelManifest,
13    trust_root_binding,
14};
15use sha2::{Digest, Sha256};
16use std::io::Read as _;
17use std::path::Path;
18
19// ── Per-file status ───────────────────────────────────────────────────
20
21/// Status of one required model file (RFC-043 §7.2).
22///
23/// User-facing copy must not expose these names directly:
24/// - `Ready`       → "Ready"
25/// - `Missing`     → "Needed"
26/// - `Partial`     → "Needs to finish"
27/// - `Invalid`     → "Needs to be replaced"
28/// - `CannotCheck` → "Could not check"
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum LocalFileStatus {
31    /// File exists, is non-empty, and passes available validation.
32    Ready,
33    /// File does not exist.
34    Missing,
35    /// A `.part` temporary file exists but the final file is absent or empty.
36    Partial,
37    /// File exists but failed validation (empty, wrong type, corrupted).
38    Invalid,
39    /// File access failed unexpectedly (permissions etc.).
40    CannotCheck,
41}
42
43/// Whether local bytes were authenticated against the reviewed trust root.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum LocalFileIntegrity {
46    /// Exact size and SHA-256 match reviewed source-controlled metadata.
47    TrustedDigest,
48    /// A user-supplied file passed only the lightweight usability check.
49    Unverified,
50    /// No complete usable file was available for integrity checking.
51    NotAvailable,
52    /// Complete bytes were present but did not match trusted metadata.
53    Mismatch,
54}
55
56/// Origin boundary for a readiness report.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum ModelProvenance {
59    /// Files in orbok's managed store, governed by Appendix B.
60    AppManaged,
61    /// A folder selected by the user and never mutated by trusted delivery.
62    UserSupplied,
63}
64
65impl LocalFileStatus {
66    /// User-facing copy (RFC-043 §7.2, avoiding technical terms).
67    pub fn user_label(&self) -> &'static str {
68        match self {
69            LocalFileStatus::Ready => "Ready",
70            LocalFileStatus::Missing => "Needed",
71            LocalFileStatus::Partial => "Needs to finish",
72            LocalFileStatus::Invalid => "Needs to be replaced",
73            LocalFileStatus::CannotCheck => "Could not check",
74        }
75    }
76
77    /// Whether this file needs any download or repair work.
78    pub fn needs_work(&self) -> bool {
79        !matches!(self, LocalFileStatus::Ready)
80    }
81}
82
83// ── Per-file readiness entry ──────────────────────────────────────────
84
85/// Readiness information for one required model file (RFC-043 §10.2).
86#[derive(Debug, Clone)]
87pub struct FileReadiness {
88    /// Logical name (e.g. "tokenizer", "model").
89    logical_name: &'static str,
90    /// Relative path within the model directory.
91    relative_path: &'static str,
92    /// Current status of the local file.
93    status: LocalFileStatus,
94    /// Byte-integrity claim, kept separate from file usability/provenance.
95    integrity: LocalFileIntegrity,
96}
97
98impl FileReadiness {
99    pub fn logical_name(&self) -> &'static str {
100        self.logical_name
101    }
102
103    pub fn relative_path(&self) -> &'static str {
104        self.relative_path
105    }
106
107    pub fn status(&self) -> LocalFileStatus {
108        self.status
109    }
110
111    pub fn integrity(&self) -> LocalFileIntegrity {
112        self.integrity
113    }
114}
115
116// ── Model-level readiness ─────────────────────────────────────────────
117
118/// Overall model readiness (RFC-043 §7.3).
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub enum ModelReadiness {
121    /// Every required file is present and valid.
122    Ready,
123    /// At least one file is missing.
124    NeedsDownload,
125    /// At least one file is partial or invalid (but none missing).
126    NeedsRepair,
127    /// File access failed unexpectedly.
128    CannotCheck,
129}
130
131// ── Readiness report ──────────────────────────────────────────────────
132
133/// Full readiness report for a model directory (RFC-043 §7.2–7.3).
134#[derive(Debug, Clone)]
135pub struct ModelReadinessReport {
136    provenance: ModelProvenance,
137    overall: ModelReadiness,
138    files: Vec<FileReadiness>,
139    trust_root: Option<TrustRootBinding>,
140}
141
142impl ModelReadinessReport {
143    pub fn provenance(&self) -> ModelProvenance {
144        self.provenance
145    }
146
147    pub fn overall(&self) -> ModelReadiness {
148        self.overall
149    }
150
151    pub fn files(&self) -> &[FileReadiness] {
152        &self.files
153    }
154
155    /// Files that require download or repair.
156    pub fn files_needing_work(&self) -> Vec<&FileReadiness> {
157        self.files
158            .iter()
159            .filter(|f| f.status.needs_work())
160            .collect()
161    }
162
163    /// How many files are already ready.
164    pub fn ready_count(&self) -> usize {
165        self.files
166            .iter()
167            .filter(|f| f.status == LocalFileStatus::Ready)
168            .count()
169    }
170
171    pub fn total_count(&self) -> usize {
172        self.files.len()
173    }
174
175    pub(crate) fn matches_trust_root(&self, binding: TrustRootBinding) -> bool {
176        self.trust_root == Some(binding)
177    }
178
179    #[cfg(test)]
180    pub(crate) fn set_file_state_for_test(
181        &mut self,
182        relative_path: &str,
183        status: LocalFileStatus,
184        integrity: LocalFileIntegrity,
185    ) {
186        let file = self
187            .files
188            .iter_mut()
189            .find(|file| file.relative_path == relative_path)
190            .expect("test file must exist");
191        file.status = status;
192        file.integrity = integrity;
193        self.overall = derive_overall_readiness(&self.files);
194    }
195
196    #[cfg(test)]
197    pub(crate) fn remove_file_for_test(&mut self, relative_path: &str) {
198        self.files
199            .retain(|file| file.relative_path != relative_path);
200        self.overall = derive_overall_readiness(&self.files);
201    }
202}
203
204// ── Readiness check ───────────────────────────────────────────────────
205
206/// Check local model files and return a readiness report (RFC-043 §7).
207///
208/// Called: at startup, before the wizard, before download, after
209/// download, after the user chooses an existing folder, and on retry.
210///
211/// This is a user-supplied-folder check: it performs no network access and
212/// makes no app-verified integrity claim.
213pub fn check_model_readiness(model_dir: &Path) -> ModelReadinessReport {
214    check_model_readiness_inner(
215        model_dir,
216        &DEFAULT_TRUSTED_MODEL,
217        ModelProvenance::UserSupplied,
218    )
219}
220
221/// Explicit name for the user-supplied-folder readiness boundary.
222pub fn check_user_supplied_model_readiness(model_dir: &Path) -> ModelReadinessReport {
223    check_model_readiness(model_dir)
224}
225
226/// Check orbok-managed files against the normative Appendix B trust root.
227///
228/// Alternate-manifest injection is deliberately absent from the public API:
229///
230/// ```compile_fail
231/// use orbok_models::check_app_managed_model_readiness_against;
232/// ```
233pub fn check_app_managed_model_readiness(model_dir: &Path) -> ModelReadinessReport {
234    check_model_readiness_inner(
235        model_dir,
236        &DEFAULT_TRUSTED_MODEL,
237        ModelProvenance::AppManaged,
238    )
239}
240
241/// Test-only manifest injection. It is absent from normal production builds
242/// and cannot mint trusted provenance for another crate.
243#[cfg(test)]
244pub(crate) fn check_app_managed_model_readiness_against(
245    model_dir: &Path,
246    manifest: &'static TrustedModelManifest,
247) -> ModelReadinessReport {
248    check_model_readiness_inner(model_dir, manifest, ModelProvenance::AppManaged)
249}
250
251fn check_model_readiness_inner(
252    model_dir: &Path,
253    manifest: &'static TrustedModelManifest,
254    provenance: ModelProvenance,
255) -> ModelReadinessReport {
256    let mut files = Vec::new();
257
258    for trusted_file in manifest.files {
259        let full_path = model_dir.join(trusted_file.relative_path);
260        let part_path = model_dir.join(format!("{}.part", trusted_file.relative_path));
261        let (status, integrity) =
262            check_single_file(&full_path, &part_path, trusted_file, provenance);
263        files.push(FileReadiness {
264            logical_name: trusted_file.logical_name,
265            relative_path: trusted_file.relative_path,
266            status,
267            integrity,
268        });
269    }
270
271    let overall = derive_overall_readiness(&files);
272    ModelReadinessReport {
273        provenance,
274        overall,
275        files,
276        trust_root: (provenance == ModelProvenance::AppManaged)
277            .then(|| trust_root_binding(manifest)),
278    }
279}
280
281fn check_single_file(
282    path: &Path,
283    part_path: &Path,
284    trusted_file: &TrustedModelFile,
285    provenance: ModelProvenance,
286) -> (LocalFileStatus, LocalFileIntegrity) {
287    // Check the final path first.
288    match std::fs::metadata(path) {
289        Ok(meta) => {
290            if !meta.is_file() {
291                return (LocalFileStatus::Invalid, LocalFileIntegrity::Mismatch);
292            }
293            if meta.len() == 0 {
294                return (LocalFileStatus::Invalid, LocalFileIntegrity::Mismatch);
295            }
296            if provenance == ModelProvenance::UserSupplied {
297                return (LocalFileStatus::Ready, LocalFileIntegrity::Unverified);
298            }
299            if meta.len() != trusted_file.exact_size_bytes {
300                return (LocalFileStatus::Invalid, LocalFileIntegrity::Mismatch);
301            }
302            match sha256_file(path) {
303                Ok(digest) if digest == trusted_file.sha256 => {
304                    (LocalFileStatus::Ready, LocalFileIntegrity::TrustedDigest)
305                }
306                Ok(_) => (LocalFileStatus::Invalid, LocalFileIntegrity::Mismatch),
307                Err(_) => (
308                    LocalFileStatus::CannotCheck,
309                    LocalFileIntegrity::NotAvailable,
310                ),
311            }
312        }
313        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
314            // Final file missing — check for a .part file.
315            if part_path.exists() {
316                (LocalFileStatus::Partial, LocalFileIntegrity::NotAvailable)
317            } else {
318                (LocalFileStatus::Missing, LocalFileIntegrity::NotAvailable)
319            }
320        }
321        Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => (
322            LocalFileStatus::CannotCheck,
323            LocalFileIntegrity::NotAvailable,
324        ),
325        Err(_) => (
326            LocalFileStatus::CannotCheck,
327            LocalFileIntegrity::NotAvailable,
328        ),
329    }
330}
331
332fn sha256_file(path: &Path) -> std::io::Result<String> {
333    let mut file = std::fs::File::open(path)?;
334    let mut hasher = Sha256::new();
335    let mut buffer = [0_u8; 64 * 1024];
336    loop {
337        let count = file.read(&mut buffer)?;
338        if count == 0 {
339            break;
340        }
341        hasher.update(&buffer[..count]);
342    }
343    let digest = hasher.finalize();
344    let mut encoded = String::with_capacity(64);
345    const HEX: &[u8; 16] = b"0123456789abcdef";
346    for byte in digest {
347        encoded.push(HEX[(byte >> 4) as usize] as char);
348        encoded.push(HEX[(byte & 0x0f) as usize] as char);
349    }
350    Ok(encoded)
351}
352
353fn derive_overall_readiness(files: &[FileReadiness]) -> ModelReadiness {
354    if files
355        .iter()
356        .any(|f| f.status == LocalFileStatus::CannotCheck)
357    {
358        return ModelReadiness::CannotCheck;
359    }
360    if files.iter().all(|f| f.status == LocalFileStatus::Ready) {
361        return ModelReadiness::Ready;
362    }
363    if files.iter().any(|f| f.status == LocalFileStatus::Missing) {
364        return ModelReadiness::NeedsDownload;
365    }
366    // Partial or Invalid but no Missing.
367    ModelReadiness::NeedsRepair
368}