Skip to main content

ferrum_models/vnext/
source.rs

1use std::fmt;
2use std::fs::File;
3use std::io::{BufReader, Read};
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6
7use ferrum_interfaces::vnext::{
8    FileFingerprint, ModelArtifactSourceRole, ModelSourceKind, OriginalModelSource,
9    OriginalModelSources, ProductModelArtifactBinding, ProductModelSourceIdentity,
10    ResolvedModelSource, ResolvedModelSources,
11};
12use ferrum_types::{FerrumError, Result};
13use sha2::{Digest, Sha256};
14
15const TOKENIZER_REQUIRED_FILES: &[&str] = &["tokenizer.json"];
16const TOKENIZER_OPTIONAL_FILES: &[&str] = &[
17    "tokenizer_config.json",
18    "generation_config.json",
19    "special_tokens_map.json",
20    "chat_template.json",
21    "chat_template.jinja",
22];
23
24/// Exact physical weight artifact selected by product source resolution.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum ProductionWeightArtifact {
27    SafetensorsDirectory(PathBuf),
28    GgufFile(PathBuf),
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct HuggingFaceSnapshotIdentity {
33    pub repository_id: String,
34    pub revision: String,
35}
36
37impl ProductionWeightArtifact {
38    pub fn safetensors_directory(path: impl Into<PathBuf>) -> Self {
39        Self::SafetensorsDirectory(path.into())
40    }
41
42    pub fn gguf_file(path: impl Into<PathBuf>) -> Self {
43        Self::GgufFile(path.into())
44    }
45
46    pub fn path(&self) -> &Path {
47        match self {
48            Self::SafetensorsDirectory(path) | Self::GgufFile(path) => path,
49        }
50    }
51
52    pub fn is_gguf(&self) -> bool {
53        matches!(self, Self::GgufFile(_))
54    }
55}
56
57/// Non-serialized product source lease shared by tokenizer, model preparation,
58/// plan resolution, and the executor. Source roles remain distinct even when
59/// all three happen to resolve to one Hugging Face snapshot.
60#[derive(Clone)]
61pub struct ProductionModelSourceBundle {
62    semantic_root: PathBuf,
63    tokenizer_root: PathBuf,
64    weights: ProductionWeightArtifact,
65    original_sources: OriginalModelSources,
66    resolved_sources: ResolvedModelSources,
67    config_json: Arc<[u8]>,
68    weight_config_json: Option<Arc<[u8]>>,
69    tokenizer_json: Arc<[u8]>,
70    tokenizer_config_json: Option<Arc<[u8]>>,
71    generation_config_json: Option<Arc<[u8]>>,
72    chat_template_json: Option<Arc<[u8]>>,
73    chat_template_jinja: Option<Arc<[u8]>>,
74}
75
76impl fmt::Debug for ProductionModelSourceBundle {
77    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
78        formatter
79            .debug_struct("ProductionModelSourceBundle")
80            .field("semantic_root", &self.semantic_root)
81            .field("tokenizer_root", &self.tokenizer_root)
82            .field("weights", &self.weights)
83            .field("original_sources", &self.original_sources)
84            .field("resolved_sources", &self.resolved_sources)
85            .field("config_json_bytes", &self.config_json.len())
86            .field(
87                "weight_config_json_bytes",
88                &self.weight_config_json.as_ref().map(|bytes| bytes.len()),
89            )
90            .field("tokenizer_json_bytes", &self.tokenizer_json.len())
91            .field(
92                "tokenizer_config_json_bytes",
93                &self.tokenizer_config_json.as_ref().map(|bytes| bytes.len()),
94            )
95            .field(
96                "generation_config_json_bytes",
97                &self
98                    .generation_config_json
99                    .as_ref()
100                    .map(|bytes| bytes.len()),
101            )
102            .field(
103                "chat_template_json_bytes",
104                &self.chat_template_json.as_ref().map(|bytes| bytes.len()),
105            )
106            .field(
107                "chat_template_jinja_bytes",
108                &self.chat_template_jinja.as_ref().map(|bytes| bytes.len()),
109            )
110            .finish()
111    }
112}
113
114impl ProductionModelSourceBundle {
115    pub fn open(
116        semantic_root: impl AsRef<Path>,
117        tokenizer_root: impl AsRef<Path>,
118        weights: ProductionWeightArtifact,
119        original_sources: OriginalModelSources,
120    ) -> Result<Self> {
121        Self::open_with_semantic_preflight(
122            semantic_root,
123            tokenizer_root,
124            weights,
125            original_sources,
126            |_| Ok(()),
127        )
128    }
129
130    /// Opens one immutable role-specific bundle after validating the exact
131    /// semantic bytes retained by the bundle. The preflight runs before the
132    /// weight path is normalized, listed, fingerprinted, or opened.
133    pub(super) fn open_with_semantic_preflight(
134        semantic_root: impl AsRef<Path>,
135        tokenizer_root: impl AsRef<Path>,
136        weights: ProductionWeightArtifact,
137        original_sources: OriginalModelSources,
138        semantic_preflight: impl FnOnce(&[u8]) -> Result<()>,
139    ) -> Result<Self> {
140        let semantic_root = canonical_directory(semantic_root.as_ref(), "semantic source")?;
141        let config_json = read_required_file(&semantic_root.join("config.json"))?;
142        semantic_preflight(&config_json)?;
143
144        let tokenizer_root = canonical_directory(tokenizer_root.as_ref(), "tokenizer source")?;
145        let weights = normalize_weight_artifact(weights)?;
146
147        let semantic_files = vec![fingerprint_loaded_file("config.json", &config_json)?];
148        let tokenizer_files = fingerprint_named_files(
149            &tokenizer_root,
150            TOKENIZER_REQUIRED_FILES,
151            TOKENIZER_OPTIONAL_FILES,
152        )?;
153        let weight_files = fingerprint_weight_artifact(&weights)?;
154
155        let weight_config_json = match &weights {
156            ProductionWeightArtifact::SafetensorsDirectory(root) => {
157                read_optional_file(&root.join("config.json"))?
158            }
159            ProductionWeightArtifact::GgufFile(_) => None,
160        };
161        let tokenizer_json = read_required_file(&tokenizer_root.join("tokenizer.json"))?;
162        let tokenizer_config_json =
163            read_optional_file(&tokenizer_root.join("tokenizer_config.json"))?;
164        let generation_config_json =
165            read_optional_file(&tokenizer_root.join("generation_config.json"))?;
166        let chat_template_json = read_optional_file(&tokenizer_root.join("chat_template.json"))?;
167        let chat_template_jinja = read_optional_file(&tokenizer_root.join("chat_template.jinja"))?;
168
169        let resolved_sources = ResolvedModelSources {
170            semantic: resolved_source(&original_sources.semantic, &semantic_root, semantic_files)?,
171            tokenizer: resolved_source(
172                &original_sources.tokenizer,
173                &tokenizer_root,
174                tokenizer_files,
175            )?,
176            weights: resolved_source(&original_sources.weights, weights.path(), weight_files)?,
177        };
178
179        Ok(Self {
180            semantic_root,
181            tokenizer_root,
182            weights,
183            original_sources,
184            resolved_sources,
185            config_json,
186            weight_config_json,
187            tokenizer_json,
188            tokenizer_config_json,
189            generation_config_json,
190            chat_template_json,
191            chat_template_jinja,
192        })
193    }
194
195    /// Compatibility constructor for existing co-located safetensors callers.
196    /// Product entrypoints should construct explicit role-specific sources.
197    pub fn open_colocated_safetensors(model_dir: impl AsRef<Path>) -> Result<Self> {
198        let model_dir = model_dir.as_ref();
199        let location = model_dir.display().to_string();
200        let original = OriginalModelSource {
201            kind: ModelSourceKind::LocalDirectory,
202            location,
203            requested_revision: None,
204        };
205        Self::open(
206            model_dir,
207            model_dir,
208            ProductionWeightArtifact::safetensors_directory(model_dir),
209            OriginalModelSources {
210                semantic: original.clone(),
211                tokenizer: original.clone(),
212                weights: original,
213            },
214        )
215    }
216
217    pub fn semantic_root(&self) -> &Path {
218        &self.semantic_root
219    }
220
221    pub fn tokenizer_root(&self) -> &Path {
222        &self.tokenizer_root
223    }
224
225    pub fn tokenizer_file(&self) -> PathBuf {
226        self.tokenizer_root.join("tokenizer.json")
227    }
228
229    pub fn weights(&self) -> &ProductionWeightArtifact {
230        &self.weights
231    }
232
233    pub fn original_sources(&self) -> &OriginalModelSources {
234        &self.original_sources
235    }
236
237    pub fn resolved_sources(&self) -> &ResolvedModelSources {
238        &self.resolved_sources
239    }
240
241    pub fn config_json(&self) -> &[u8] {
242        &self.config_json
243    }
244
245    pub fn tokenizer_json(&self) -> &[u8] {
246        &self.tokenizer_json
247    }
248
249    /// Physical checkpoint metadata retained independently from the semantic
250    /// model config. Quantized safetensors commonly repeat semantic fields in
251    /// this file, but only physical format metadata may be selected from it.
252    pub fn weight_config_json(&self) -> Option<&[u8]> {
253        self.weight_config_json.as_deref()
254    }
255
256    pub fn tokenizer_config_json(&self) -> Option<&[u8]> {
257        self.tokenizer_config_json.as_deref()
258    }
259
260    pub fn generation_config_json(&self) -> Option<&[u8]> {
261        self.generation_config_json.as_deref()
262    }
263
264    pub fn chat_template_json(&self) -> Option<&[u8]> {
265        self.chat_template_json.as_deref()
266    }
267
268    pub fn chat_template_jinja(&self) -> Option<&[u8]> {
269        self.chat_template_jinja.as_deref()
270    }
271
272    pub fn fingerprint(
273        &self,
274        role: ModelArtifactSourceRole,
275        relative_path: &str,
276    ) -> Option<&FileFingerprint> {
277        self.resolved_sources
278            .for_role(role)
279            .files
280            .iter()
281            .find(|file| file.relative_path == relative_path)
282    }
283
284    pub fn weight_payload_bytes(&self) -> Result<u64> {
285        self.resolved_sources
286            .weights
287            .files
288            .iter()
289            .filter(|file| {
290                file.relative_path.ends_with(".safetensors")
291                    || self.weights.is_gguf()
292                        && self
293                            .weights
294                            .path()
295                            .file_name()
296                            .and_then(|name| name.to_str())
297                            == Some(file.relative_path.as_str())
298            })
299            .try_fold(0_u64, |total, file| {
300                total.checked_add(file.size_bytes).ok_or_else(|| {
301                    FerrumError::model("resolved weight payload byte size overflows u64")
302                })
303            })
304    }
305
306    pub fn product_source_identity(
307        &self,
308        requested_model: impl Into<String>,
309        resolved_model: impl Into<String>,
310        template_source_file: &str,
311        template_content: &str,
312    ) -> Result<ProductModelSourceIdentity> {
313        let binding = |role, source_file: &str, content_sha256: Option<String>| {
314            let fingerprint = self.fingerprint(role, source_file).ok_or_else(|| {
315                FerrumError::model(format!(
316                    "selected {role:?} source file is absent: {source_file}"
317                ))
318            })?;
319            ProductModelArtifactBinding::new(
320                role,
321                source_file,
322                fingerprint.sha256.clone(),
323                content_sha256,
324            )
325            .map_err(|error| FerrumError::model(error.to_string()))
326        };
327        let semantic_config = binding(ModelArtifactSourceRole::Semantic, "config.json", None)?;
328        let tokenizer = binding(ModelArtifactSourceRole::Tokenizer, "tokenizer.json", None)?;
329        let template = binding(
330            ModelArtifactSourceRole::Tokenizer,
331            template_source_file,
332            Some(format!("{:x}", Sha256::digest(template_content.as_bytes()))),
333        )?;
334        let weight_config = self
335            .weight_config_json
336            .as_ref()
337            .map(|_| binding(ModelArtifactSourceRole::Weights, "config.json", None))
338            .transpose()?;
339        ProductModelSourceIdentity::new(
340            requested_model,
341            resolved_model,
342            self.original_sources.clone(),
343            self.resolved_sources.clone(),
344            semantic_config,
345            tokenizer,
346            template,
347            weight_config,
348        )
349        .map_err(|error| FerrumError::model(error.to_string()))
350    }
351}
352
353fn canonical_directory(path: &Path, kind: &str) -> Result<PathBuf> {
354    if !path.is_dir() {
355        return Err(FerrumError::model(format!(
356            "{kind} is not a directory: {}",
357            path.display()
358        )));
359    }
360    path.canonicalize()
361        .map_err(|error| FerrumError::model(format!("canonicalize {}: {error}", path.display())))
362}
363
364fn normalize_weight_artifact(
365    artifact: ProductionWeightArtifact,
366) -> Result<ProductionWeightArtifact> {
367    match artifact {
368        ProductionWeightArtifact::SafetensorsDirectory(path) => {
369            Ok(ProductionWeightArtifact::SafetensorsDirectory(
370                canonical_directory(&path, "safetensors weight source")?,
371            ))
372        }
373        ProductionWeightArtifact::GgufFile(path) => {
374            if !path.is_file() {
375                return Err(FerrumError::model(format!(
376                    "GGUF weight source is not a file: {}",
377                    path.display()
378                )));
379            }
380            let file_name = path.file_name().ok_or_else(|| {
381                FerrumError::model(format!("GGUF source has no file name: {}", path.display()))
382            })?;
383            let parent = path.parent().ok_or_else(|| {
384                FerrumError::model(format!("GGUF source has no parent: {}", path.display()))
385            })?;
386            let parent = parent.canonicalize().map_err(|error| {
387                FerrumError::model(format!("canonicalize {}: {error}", parent.display()))
388            })?;
389            Ok(ProductionWeightArtifact::GgufFile(parent.join(file_name)))
390        }
391    }
392}
393
394fn fingerprint_named_files(
395    root: &Path,
396    required: &[&str],
397    optional: &[&str],
398) -> Result<Vec<FileFingerprint>> {
399    let mut files = Vec::with_capacity(required.len() + optional.len());
400    for relative_path in required {
401        files.push(fingerprint_file(root, relative_path)?);
402    }
403    for relative_path in optional {
404        if root.join(relative_path).is_file() {
405            files.push(fingerprint_file(root, relative_path)?);
406        }
407    }
408    files.sort_by(|left, right| left.relative_path.cmp(&right.relative_path));
409    Ok(files)
410}
411
412fn fingerprint_weight_artifact(
413    artifact: &ProductionWeightArtifact,
414) -> Result<Vec<FileFingerprint>> {
415    match artifact {
416        ProductionWeightArtifact::GgufFile(path) => {
417            let file_name = path
418                .file_name()
419                .and_then(|name| name.to_str())
420                .ok_or_else(|| FerrumError::model("GGUF source file name is not UTF-8"))?;
421            fingerprint_file(
422                path.parent()
423                    .ok_or_else(|| FerrumError::model("GGUF source has no parent"))?,
424                file_name,
425            )
426            .map(|file| vec![file])
427        }
428        ProductionWeightArtifact::SafetensorsDirectory(root) => {
429            let mut relative_paths = std::fs::read_dir(root)
430                .map_err(|error| {
431                    FerrumError::model(format!("read weight directory {}: {error}", root.display()))
432                })?
433                .filter_map(|entry| entry.ok())
434                .filter_map(|entry| {
435                    let path = entry.path();
436                    let name = path.file_name()?.to_str()?.to_owned();
437                    (path.is_file()
438                        && (name.ends_with(".safetensors")
439                            || name == "model.safetensors.index.json"
440                            || name == "config.json"))
441                        .then_some(name)
442                })
443                .collect::<Vec<_>>();
444            relative_paths.sort();
445            if !relative_paths
446                .iter()
447                .any(|path| path.ends_with(".safetensors"))
448            {
449                return Err(FerrumError::model(format!(
450                    "safetensors source contains no shard: {}",
451                    root.display()
452                )));
453            }
454            relative_paths
455                .iter()
456                .map(|path| fingerprint_file(root, path))
457                .collect()
458        }
459    }
460}
461
462fn fingerprint_file(root: &Path, relative_path: &str) -> Result<FileFingerprint> {
463    if !portable_relative_path(relative_path) {
464        return Err(FerrumError::model(format!(
465            "source manifest path is not portable: {relative_path:?}"
466        )));
467    }
468    let path = root.join(relative_path);
469    let metadata = std::fs::metadata(&path)
470        .map_err(|error| FerrumError::model(format!("stat {}: {error}", path.display())))?;
471    if !metadata.is_file() || metadata.len() == 0 {
472        return Err(FerrumError::model(format!(
473            "source manifest file is missing or empty: {}",
474            path.display()
475        )));
476    }
477    let sha256 = match trusted_hf_blob_sha256(&path) {
478        Some(sha256) => sha256,
479        None => hash_file(&path)?,
480    };
481    Ok(FileFingerprint {
482        relative_path: relative_path.to_owned(),
483        size_bytes: metadata.len(),
484        sha256,
485    })
486}
487
488fn fingerprint_loaded_file(relative_path: &str, bytes: &[u8]) -> Result<FileFingerprint> {
489    if !portable_relative_path(relative_path) {
490        return Err(FerrumError::model(format!(
491            "source manifest path is not portable: {relative_path:?}"
492        )));
493    }
494    if bytes.is_empty() {
495        return Err(FerrumError::model(format!(
496            "source manifest file is empty: {relative_path}"
497        )));
498    }
499    let size_bytes = u64::try_from(bytes.len())
500        .map_err(|_| FerrumError::internal("source file size exceeds u64"))?;
501    Ok(FileFingerprint {
502        relative_path: relative_path.to_owned(),
503        size_bytes,
504        sha256: format!("{:x}", Sha256::digest(bytes)),
505    })
506}
507
508fn trusted_hf_blob_sha256(path: &Path) -> Option<String> {
509    let mut entry = path.to_path_buf();
510    for _ in 0..8 {
511        if let Some(sha256) = trusted_hf_snapshot_entry_sha256(&entry) {
512            return Some(sha256);
513        }
514        let target = std::fs::read_link(&entry).ok()?;
515        entry = if target.is_absolute() {
516            target
517        } else {
518            entry.parent()?.join(target)
519        };
520    }
521    None
522}
523
524fn trusted_hf_snapshot_entry_sha256(entry: &Path) -> Option<String> {
525    let revision = entry.parent()?;
526    let snapshots = revision.parent()?;
527    (snapshots.file_name()?.to_str()? == "snapshots").then_some(())?;
528    let repository = snapshots.parent()?;
529    repository
530        .file_name()?
531        .to_str()?
532        .starts_with("models--")
533        .then_some(())?;
534
535    let target = std::fs::read_link(entry).ok()?;
536    let target = if target.is_absolute() {
537        target
538    } else {
539        revision.join(target)
540    };
541    let blob = target.canonicalize().ok()?;
542    let blobs = blob.parent()?;
543    (blobs.file_name()?.to_str()? == "blobs").then_some(())?;
544    (blobs.parent()?.canonicalize().ok()? == repository.canonicalize().ok()?).then_some(())?;
545    sha256_file_name(&blob)
546}
547
548fn sha256_file_name(path: &Path) -> Option<String> {
549    let digest = path.file_name()?.to_str()?;
550    (digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()))
551        .then(|| digest.to_ascii_lowercase())
552}
553
554fn hash_file(path: &Path) -> Result<String> {
555    let file = File::open(path)
556        .map_err(|error| FerrumError::model(format!("open {}: {error}", path.display())))?;
557    let mut reader = BufReader::with_capacity(4 * 1024 * 1024, file);
558    let mut hasher = Sha256::new();
559    let mut buffer = vec![0_u8; 4 * 1024 * 1024];
560    loop {
561        let count = reader
562            .read(&mut buffer)
563            .map_err(|error| FerrumError::model(format!("read {}: {error}", path.display())))?;
564        if count == 0 {
565            break;
566        }
567        hasher.update(&buffer[..count]);
568    }
569    Ok(format!("{:x}", hasher.finalize()))
570}
571
572fn resolved_source(
573    original: &OriginalModelSource,
574    path: &Path,
575    files: Vec<FileFingerprint>,
576) -> Result<ResolvedModelSource> {
577    let manifest_revision = manifest_revision(&files)?;
578    let snapshot_identity = huggingface_snapshot_identity(path);
579    let (canonical_location, resolved_revision) = if let Some(identity) = snapshot_identity {
580        (identity.repository_id, identity.revision)
581    } else {
582        match original.kind {
583            ModelSourceKind::Repository => (
584                original.location.clone(),
585                huggingface_snapshot_revision(path).ok_or_else(|| {
586                    FerrumError::model(format!(
587                        "repository source {} did not resolve below snapshots/<revision>: {}",
588                        original.location,
589                        path.display()
590                    ))
591                })?,
592            ),
593            ModelSourceKind::LocalDirectory | ModelSourceKind::LocalFile => {
594                (path.display().to_string(), manifest_revision)
595            }
596            ModelSourceKind::ReleaseArtifact => {
597                return Err(FerrumError::unsupported(
598                    "release artifact source bundles are not implemented",
599                ))
600            }
601        }
602    };
603    Ok(ResolvedModelSource {
604        canonical_location,
605        resolved_revision,
606        files,
607    })
608}
609
610/// Recover a stable repository/revision identity only from an exact Hugging
611/// Face cache snapshot root or one direct artifact below it. The path is
612/// inspected structurally without canonicalizing an LFS symlink into `blobs/`.
613pub fn huggingface_snapshot_identity(path: &Path) -> Option<HuggingFaceSnapshotIdentity> {
614    huggingface_snapshot_root_identity(path)
615        .or_else(|| path.parent().and_then(huggingface_snapshot_root_identity))
616}
617
618fn huggingface_snapshot_root_identity(root: &Path) -> Option<HuggingFaceSnapshotIdentity> {
619    let revision = root.file_name()?.to_str()?;
620    if revision.len() != 40
621        || !revision
622            .bytes()
623            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
624    {
625        return None;
626    }
627    let snapshots = root.parent()?;
628    if snapshots.file_name()?.to_str()? != "snapshots" {
629        return None;
630    }
631    let repository_dir = snapshots.parent()?.file_name()?.to_str()?;
632    let encoded = repository_dir.strip_prefix("models--")?;
633    let components = encoded.split("--").collect::<Vec<_>>();
634    if components.len() != 2 || components.iter().any(|component| component.is_empty()) {
635        return None;
636    }
637    let repository_id = components.join("/");
638    if format!("models--{}", repository_id.replace('/', "--")) != repository_dir {
639        return None;
640    }
641    Some(HuggingFaceSnapshotIdentity {
642        repository_id,
643        revision: revision.to_owned(),
644    })
645}
646
647fn huggingface_snapshot_revision(path: &Path) -> Option<String> {
648    let root = if path.is_file() { path.parent()? } else { path };
649    if root.parent()?.file_name()?.to_str()? != "snapshots" {
650        return None;
651    }
652    root.file_name()?.to_str().map(str::to_owned)
653}
654
655fn manifest_revision(files: &[FileFingerprint]) -> Result<String> {
656    let bytes = serde_json::to_vec(files)
657        .map_err(|error| FerrumError::internal(format!("serialize source manifest: {error}")))?;
658    Ok(format!("{:x}", Sha256::digest(bytes)))
659}
660
661fn read_required_file(path: &Path) -> Result<Arc<[u8]>> {
662    let bytes = std::fs::read(path)
663        .map_err(|error| FerrumError::model(format!("read {}: {error}", path.display())))?;
664    if bytes.is_empty() {
665        return Err(FerrumError::model(format!(
666            "required source file is empty: {}",
667            path.display()
668        )));
669    }
670    Ok(bytes.into())
671}
672
673fn read_optional_file(path: &Path) -> Result<Option<Arc<[u8]>>> {
674    if !path.is_file() {
675        return Ok(None);
676    }
677    read_required_file(path).map(Some)
678}
679
680fn portable_relative_path(path: &str) -> bool {
681    !path.is_empty()
682        && !path.starts_with('/')
683        && !path.ends_with('/')
684        && !path.contains('\\')
685        && path
686            .split('/')
687            .all(|component| !matches!(component, "" | "." | ".."))
688}
689
690#[cfg(test)]
691mod tests {
692    use super::*;
693
694    fn original(kind: ModelSourceKind, location: &str) -> OriginalModelSource {
695        OriginalModelSource {
696            kind,
697            location: location.to_owned(),
698            requested_revision: None,
699        }
700    }
701
702    #[test]
703    fn preserves_three_distinct_roots_and_same_named_files() {
704        let root = tempfile::tempdir().unwrap();
705        let semantic = root.path().join("semantic");
706        let tokenizer = root.path().join("tokenizer");
707        let weights = root.path().join("weights");
708        std::fs::create_dir_all(&semantic).unwrap();
709        std::fs::create_dir_all(&tokenizer).unwrap();
710        std::fs::create_dir_all(&weights).unwrap();
711        std::fs::write(
712            semantic.join("config.json"),
713            br#"{"architectures":["Fixture"]}"#,
714        )
715        .unwrap();
716        std::fs::write(tokenizer.join("tokenizer.json"), br#"{"version":"1.0"}"#).unwrap();
717        std::fs::write(
718            tokenizer.join("tokenizer_config.json"),
719            br#"{"chat_template":"fixture"}"#,
720        )
721        .unwrap();
722        std::fs::write(weights.join("model.safetensors"), b"fixture-weights").unwrap();
723
724        let bundle = ProductionModelSourceBundle::open(
725            &semantic,
726            &tokenizer,
727            ProductionWeightArtifact::safetensors_directory(&weights),
728            OriginalModelSources {
729                semantic: original(ModelSourceKind::LocalDirectory, "semantic-input"),
730                tokenizer: original(ModelSourceKind::LocalDirectory, "tokenizer-input"),
731                weights: original(ModelSourceKind::LocalDirectory, "weights-input"),
732            },
733        )
734        .unwrap();
735
736        assert_ne!(bundle.semantic_root(), bundle.tokenizer_root());
737        assert_ne!(bundle.tokenizer_root(), bundle.weights().path());
738        assert_eq!(
739            bundle
740                .fingerprint(ModelArtifactSourceRole::Semantic, "config.json")
741                .unwrap()
742                .size_bytes,
743            29
744        );
745        assert!(bundle
746            .fingerprint(ModelArtifactSourceRole::Tokenizer, "tokenizer.json")
747            .is_some());
748        assert!(bundle
749            .fingerprint(ModelArtifactSourceRole::Weights, "model.safetensors")
750            .is_some());
751        assert_eq!(bundle.weight_payload_bytes().unwrap(), 15);
752    }
753
754    #[test]
755    fn semantic_preflight_fingerprint_and_retained_bytes_are_atomic() {
756        let root = tempfile::tempdir().unwrap();
757        let semantic = root.path().join("semantic");
758        let tokenizer = root.path().join("tokenizer");
759        let weights = root.path().join("weights");
760        std::fs::create_dir_all(&semantic).unwrap();
761        std::fs::create_dir_all(&tokenizer).unwrap();
762        std::fs::create_dir_all(&weights).unwrap();
763        let original_config = br#"{"architectures":["Original"]}"#;
764        std::fs::write(semantic.join("config.json"), original_config).unwrap();
765        std::fs::write(tokenizer.join("tokenizer.json"), br#"{"version":"1.0"}"#).unwrap();
766        std::fs::write(weights.join("model.safetensors"), b"fixture-weights").unwrap();
767
768        let bundle = ProductionModelSourceBundle::open_with_semantic_preflight(
769            &semantic,
770            &tokenizer,
771            ProductionWeightArtifact::safetensors_directory(&weights),
772            OriginalModelSources {
773                semantic: original(ModelSourceKind::LocalDirectory, "semantic-input"),
774                tokenizer: original(ModelSourceKind::LocalDirectory, "tokenizer-input"),
775                weights: original(ModelSourceKind::LocalDirectory, "weights-input"),
776            },
777            |raw| {
778                assert_eq!(raw, original_config);
779                std::fs::write(
780                    semantic.join("config.json"),
781                    br#"{"architectures":["Mutated"]}"#,
782                )
783                .unwrap();
784                Ok(())
785            },
786        )
787        .unwrap();
788
789        assert_eq!(bundle.config_json(), original_config);
790        assert_eq!(
791            bundle
792                .fingerprint(ModelArtifactSourceRole::Semantic, "config.json")
793                .unwrap()
794                .sha256,
795            format!("{:x}", Sha256::digest(original_config))
796        );
797    }
798
799    #[test]
800    fn local_huggingface_snapshot_paths_recover_stable_role_identities() {
801        let root = tempfile::tempdir().unwrap();
802        let semantic_revision = "a".repeat(40);
803        let weight_revision = "b".repeat(40);
804        let semantic = root
805            .path()
806            .join("models--Qwen--Qwen3.5-35B-A3B")
807            .join("snapshots")
808            .join(&semantic_revision);
809        let weights = root
810            .path()
811            .join("models--Qwen--Qwen3.5-35B-A3B-GPTQ-Int4")
812            .join("snapshots")
813            .join(&weight_revision);
814        std::fs::create_dir_all(&semantic).unwrap();
815        std::fs::create_dir_all(&weights).unwrap();
816        std::fs::write(
817            semantic.join("config.json"),
818            br#"{"architectures":["Fixture"]}"#,
819        )
820        .unwrap();
821        std::fs::write(semantic.join("tokenizer.json"), br#"{"version":"1.0"}"#).unwrap();
822        std::fs::write(
823            semantic.join("tokenizer_config.json"),
824            br#"{"chat_template":"fixture"}"#,
825        )
826        .unwrap();
827        std::fs::write(
828            weights.join("config.json"),
829            br#"{"quantization_config":{"quant_method":"gptq"}}"#,
830        )
831        .unwrap();
832        std::fs::write(weights.join("model.safetensors"), b"fixture-weights").unwrap();
833        let local = |path: &Path| OriginalModelSource {
834            kind: ModelSourceKind::LocalDirectory,
835            location: path.display().to_string(),
836            requested_revision: None,
837        };
838        let bundle = ProductionModelSourceBundle::open(
839            &semantic,
840            &semantic,
841            ProductionWeightArtifact::safetensors_directory(&weights),
842            OriginalModelSources {
843                semantic: local(&semantic),
844                tokenizer: local(&semantic),
845                weights: local(&weights),
846            },
847        )
848        .unwrap();
849
850        assert_eq!(
851            bundle.resolved_sources().semantic.canonical_location,
852            "Qwen/Qwen3.5-35B-A3B"
853        );
854        assert_eq!(
855            bundle.resolved_sources().semantic.resolved_revision,
856            semantic_revision
857        );
858        assert_eq!(
859            bundle.resolved_sources().weights.canonical_location,
860            "Qwen/Qwen3.5-35B-A3B-GPTQ-Int4"
861        );
862        assert_eq!(
863            bundle.resolved_sources().weights.resolved_revision,
864            weight_revision
865        );
866        let identity = bundle
867            .product_source_identity(
868                weights.display().to_string(),
869                "Qwen/Qwen3.5-35B-A3B-GPTQ-Int4",
870                "tokenizer_config.json",
871                "fixture",
872            )
873            .unwrap();
874        let template_sha256 = format!("{:x}", Sha256::digest(b"fixture"));
875        assert_eq!(identity.template.content_sha256, Some(template_sha256));
876        assert!(identity.weight_config.is_some());
877    }
878
879    #[test]
880    fn huggingface_snapshot_identity_rejects_ambiguous_cache_paths() {
881        let revision = "a".repeat(40);
882        assert!(huggingface_snapshot_identity(Path::new(&format!(
883            "/cache/models--Qwen--Model/snapshots/{revision}"
884        )))
885        .is_some());
886        assert!(huggingface_snapshot_identity(Path::new(
887            "/cache/models--Qwen--Model/snapshots/main"
888        ))
889        .is_none());
890        assert!(huggingface_snapshot_identity(Path::new(&format!(
891            "/cache/models--Qwen--Nested--Model/snapshots/{revision}"
892        )))
893        .is_none());
894    }
895
896    #[cfg(unix)]
897    #[test]
898    fn trusts_huggingface_lfs_blob_identity_without_rehashing_name() {
899        use std::os::unix::fs::symlink;
900
901        let root = tempfile::tempdir().unwrap();
902        let repository = root.path().join("models--fixture--weights");
903        let blobs = repository.join("blobs");
904        let snapshot = repository.join("snapshots").join("revision");
905        std::fs::create_dir_all(&blobs).unwrap();
906        std::fs::create_dir_all(&snapshot).unwrap();
907        let digest = "a".repeat(64);
908        std::fs::write(blobs.join(&digest), b"weight-bytes").unwrap();
909        symlink(
910            Path::new("../../blobs").join(&digest),
911            snapshot.join("model.safetensors"),
912        )
913        .unwrap();
914
915        let fingerprint = fingerprint_file(&snapshot, "model.safetensors").unwrap();
916        assert_eq!(fingerprint.sha256, digest);
917        assert_eq!(fingerprint.size_bytes, 12);
918    }
919
920    #[cfg(unix)]
921    #[test]
922    fn trusts_huggingface_blob_identity_through_product_package_symlink() {
923        use std::os::unix::fs::symlink;
924
925        let root = tempfile::tempdir().unwrap();
926        let repository = root.path().join("models--fixture--Qwen3.5-4B-GGUF");
927        let blobs = repository.join("blobs");
928        let snapshot = repository.join("snapshots").join("revision");
929        let package = root.path().join("product-package");
930        std::fs::create_dir_all(&blobs).unwrap();
931        std::fs::create_dir_all(&snapshot).unwrap();
932        std::fs::create_dir_all(&package).unwrap();
933        let digest = "b".repeat(64);
934        std::fs::write(blobs.join(&digest), b"weight-bytes").unwrap();
935        let snapshot_weight = snapshot.join("model.gguf");
936        symlink(Path::new("../../blobs").join(&digest), &snapshot_weight).unwrap();
937        symlink(&snapshot_weight, package.join("model.gguf")).unwrap();
938
939        let fingerprint = fingerprint_file(&package, "model.gguf").unwrap();
940        assert_eq!(fingerprint.sha256, digest);
941        assert_eq!(fingerprint.size_bytes, 12);
942
943        let direct_blob_link = package.join("direct-blob.gguf");
944        symlink(blobs.join(&digest), &direct_blob_link).unwrap();
945        let direct_blob_fingerprint = fingerprint_file(&package, "direct-blob.gguf").unwrap();
946        assert_eq!(
947            direct_blob_fingerprint.sha256,
948            format!("{:x}", Sha256::digest(b"weight-bytes"))
949        );
950        assert_ne!(direct_blob_fingerprint.sha256, digest);
951    }
952}