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#[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#[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 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 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 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
610pub 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}