Skip to main content

osdk_core/model/
mod.rs

1//! Model references, immutable snapshot manifests, and local materialization.
2
3use std::path::{Component, Path, PathBuf};
4use std::sync::Arc;
5
6use serde::{Deserialize, Serialize};
7
8use crate::dirs::{create_dir_all, sanitize_tool_id, Dirs};
9use crate::error::{Error, Result};
10use crate::lock::FileLock;
11use crate::pipeline::verify::{hash_file, HashAlgo};
12use crate::store::link::LinkMode;
13use crate::store::manifest::{FileEntry, Manifest};
14use crate::store::Cas;
15
16pub mod env;
17pub mod provider;
18pub mod pull;
19pub mod source;
20
21const MODEL_MANIFEST_FILE: &str = ".osdk-model.json";
22const CURRENT_FILE: &str = "current.json";
23const COMPLETE_MARKER: &str = ".osdk-complete";
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26pub enum ProviderId {
27    #[serde(rename = "huggingface", alias = "hugging-face", alias = "hf")]
28    HuggingFace,
29    #[serde(rename = "modelscope", alias = "model-scope", alias = "ms")]
30    ModelScope,
31}
32
33impl ProviderId {
34    pub fn as_str(self) -> &'static str {
35        match self {
36            Self::HuggingFace => "huggingface",
37            Self::ModelScope => "modelscope",
38        }
39    }
40}
41
42impl std::fmt::Display for ProviderId {
43    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        formatter.write_str(self.as_str())
45    }
46}
47
48impl std::str::FromStr for ProviderId {
49    type Err = Error;
50
51    fn from_str(value: &str) -> Result<Self> {
52        match value.trim().to_ascii_lowercase().as_str() {
53            "hf" | "huggingface" | "hugging-face" => Ok(Self::HuggingFace),
54            "ms" | "modelscope" | "model-scope" => Ok(Self::ModelScope),
55            other => Err(Error::config(format!(
56                "unknown model provider `{other}` (expected huggingface|modelscope)"
57            ))),
58        }
59    }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct ModelRef {
64    pub provider: ProviderId,
65    pub repository: String,
66    pub revision: String,
67}
68
69impl ModelRef {
70    pub fn parse(value: &str) -> Result<Self> {
71        let (provider, rest) = value.split_once(':').ok_or_else(|| {
72            Error::config(format!(
73                "invalid model reference `{value}` (expected hf:owner/repo@revision)"
74            ))
75        })?;
76        let provider = provider.parse()?;
77        let (repository, revision) = rest
78            .rsplit_once('@')
79            .unwrap_or((rest, default_revision(provider)));
80        validate_repository(repository)?;
81        if revision.trim().is_empty() {
82            return Err(Error::config("model revision cannot be empty"));
83        }
84        Ok(Self {
85            provider,
86            repository: repository.to_string(),
87            revision: revision.to_string(),
88        })
89    }
90}
91
92impl std::fmt::Display for ModelRef {
93    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        write!(
95            formatter,
96            "{}:{}@{}",
97            self.provider, self.repository, self.revision
98        )
99    }
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
103pub struct ModelFile {
104    pub path: String,
105    pub size: u64,
106    pub cas_hash: String,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub sha256: Option<String>,
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub etag: Option<String>,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
114pub struct SnapshotManifest {
115    pub schema: u32,
116    pub name: String,
117    pub provider: ProviderId,
118    pub repository: String,
119    pub requested_revision: String,
120    pub revision: String,
121    pub endpoint: String,
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub variant: Option<String>,
124    pub files: Vec<ModelFile>,
125    pub created_at: u64,
126}
127
128#[derive(Debug, Clone)]
129pub struct SnapshotIdentity {
130    pub name: String,
131    pub provider: ProviderId,
132    pub repository: String,
133    pub requested_revision: String,
134    pub revision: String,
135    pub endpoint: String,
136    pub variant: Option<String>,
137}
138
139#[derive(Debug, Clone)]
140pub struct DownloadedModelFile {
141    pub path: String,
142    pub source: PathBuf,
143    pub size: u64,
144    pub sha256: Option<String>,
145    pub etag: Option<String>,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
149struct CurrentSnapshot {
150    snapshot: String,
151}
152
153#[derive(Debug, Clone)]
154pub struct InstalledModel {
155    pub manifest: SnapshotManifest,
156    pub path: PathBuf,
157}
158
159pub struct ModelStore {
160    dirs: Dirs,
161    cas: Arc<Cas>,
162    link_mode: LinkMode,
163}
164
165impl ModelStore {
166    pub fn new(dirs: Dirs, cas: Arc<Cas>, link_mode: LinkMode) -> Self {
167        Self {
168            dirs,
169            cas,
170            link_mode,
171        }
172    }
173
174    pub fn publish(
175        &self,
176        identity: SnapshotIdentity,
177        mut files: Vec<DownloadedModelFile>,
178    ) -> Result<InstalledModel> {
179        validate_model_name(&identity.name)?;
180        validate_repository(&identity.repository)?;
181        if files.is_empty() {
182            return Err(Error::other("model snapshot contains no files"));
183        }
184        files.sort_by(|left, right| left.path.cmp(&right.path));
185        if files.windows(2).any(|pair| pair[0].path == pair[1].path) {
186            return Err(Error::other("model snapshot contains duplicate file paths"));
187        }
188
189        let model_root = self.model_root(&identity.name);
190        let snapshots = model_root.join("snapshots");
191        create_dir_all(&snapshots)?;
192        let snapshot = snapshot_key(&identity, &files);
193        let destination = snapshots.join(&snapshot);
194        let lock_path = model_root.join(".locks").join(format!("{snapshot}.lock"));
195        let _lock = FileLock::acquire(&lock_path)?;
196
197        if destination.join(COMPLETE_MARKER).is_file() {
198            self.write_current(&model_root, &snapshot)?;
199            return self.load_path(&destination);
200        }
201
202        let temporary = snapshots.join(format!(".{snapshot}.tmp-{}", std::process::id()));
203        if temporary.exists() {
204            std::fs::remove_dir_all(&temporary).map_err(|error| Error::io(&temporary, error))?;
205        }
206        create_dir_all(&temporary)?;
207
208        let mut model_files = Vec::with_capacity(files.len());
209        let mut cas_manifest = Manifest::new(
210            format!("model:{}", identity.name),
211            snapshot.clone(),
212            self.link_mode.to_string(),
213        );
214        let result = (|| {
215            for file in files {
216                let relative = safe_relative_path(&file.path)?;
217                let actual_size = std::fs::metadata(&file.source)
218                    .map_err(|error| Error::io(&file.source, error))?
219                    .len();
220                if actual_size != file.size {
221                    return Err(Error::other(format!(
222                        "model file size mismatch for {}: expected {}, got {}",
223                        file.path, file.size, actual_size
224                    )));
225                }
226                if let Some(expected) = file.sha256.as_deref() {
227                    crate::pipeline::verify::verify_file(
228                        &file.source,
229                        expected,
230                        HashAlgo::Sha256,
231                        &file.path,
232                    )?;
233                }
234                let (cas_hash, _, _) = self.cas.ingest_preserve(&file.source)?;
235                let destination_file = temporary.join(&relative);
236                self.cas
237                    .materialize_object(&cas_hash, &destination_file, self.link_mode)?;
238                model_files.push(ModelFile {
239                    path: file.path.clone(),
240                    size: file.size,
241                    cas_hash: cas_hash.clone(),
242                    sha256: file.sha256,
243                    etag: file.etag,
244                });
245                cas_manifest.files.push(FileEntry {
246                    path: file.path,
247                    hash: Some(cas_hash),
248                    mode: 0o644,
249                    symlink: None,
250                });
251            }
252
253            model_files.sort_by(|left, right| left.path.cmp(&right.path));
254            cas_manifest
255                .files
256                .sort_by(|left, right| left.path.cmp(&right.path));
257            cas_manifest.save(&temporary)?;
258            let manifest = SnapshotManifest {
259                schema: 1,
260                name: identity.name,
261                provider: identity.provider,
262                repository: identity.repository,
263                requested_revision: identity.requested_revision,
264                revision: identity.revision,
265                endpoint: identity.endpoint,
266                variant: identity.variant,
267                files: model_files,
268                created_at: crate::source::now_secs(),
269            };
270            write_json_atomic(&temporary.join(MODEL_MANIFEST_FILE), &manifest)?;
271            std::fs::write(temporary.join(COMPLETE_MARKER), b"")
272                .map_err(|error| Error::io(temporary.join(COMPLETE_MARKER), error))?;
273            if destination.exists() {
274                std::fs::remove_dir_all(&destination)
275                    .map_err(|error| Error::io(&destination, error))?;
276            }
277            std::fs::rename(&temporary, &destination)
278                .map_err(|error| Error::io(&destination, error))?;
279            self.write_current(&model_root, &snapshot)?;
280            Ok(InstalledModel {
281                manifest,
282                path: destination.clone(),
283            })
284        })();
285
286        if result.is_err() {
287            let _ = std::fs::remove_dir_all(&temporary);
288        }
289        result
290    }
291
292    pub fn current(&self, name: &str) -> Result<InstalledModel> {
293        validate_model_name(name)?;
294        let model_root = self.model_root(name);
295        let marker_path = model_root.join(CURRENT_FILE);
296        let bytes = std::fs::read(&marker_path).map_err(|error| Error::io(&marker_path, error))?;
297        let current: CurrentSnapshot = serde_json::from_slice(&bytes)?;
298        self.load_path(&model_root.join("snapshots").join(current.snapshot))
299    }
300
301    pub fn list(&self) -> Result<Vec<InstalledModel>> {
302        let root = self.dirs.models();
303        if !root.is_dir() {
304            return Ok(Vec::new());
305        }
306        let mut installed = Vec::new();
307        for entry in std::fs::read_dir(&root).map_err(|error| Error::io(&root, error))? {
308            let entry = entry.map_err(|error| Error::io(&root, error))?;
309            if !entry.path().is_dir() {
310                continue;
311            }
312            let name = entry.file_name().to_string_lossy().to_string();
313            if let Ok(model) = self.current(&name) {
314                installed.push(model);
315            }
316        }
317        installed.sort_by(|left, right| left.manifest.name.cmp(&right.manifest.name));
318        Ok(installed)
319    }
320
321    pub fn verify(&self, name: &str) -> Result<SnapshotManifest> {
322        let installed = self.current(name)?;
323        for file in &installed.manifest.files {
324            let path = installed.path.join(safe_relative_path(&file.path)?);
325            let actual = crate::store::hash_file(&path)?;
326            if actual != file.cas_hash {
327                return Err(Error::ChecksumMismatch {
328                    name: file.path.clone(),
329                    expected: file.cas_hash.clone(),
330                    actual,
331                });
332            }
333            if let Some(expected) = file.sha256.as_deref() {
334                let actual = hash_file(&path, HashAlgo::Sha256)?;
335                if !actual.eq_ignore_ascii_case(expected) {
336                    return Err(Error::ChecksumMismatch {
337                        name: file.path.clone(),
338                        expected: expected.to_string(),
339                        actual,
340                    });
341                }
342            }
343        }
344        Ok(installed.manifest)
345    }
346
347    pub fn remove(&self, name: &str) -> Result<bool> {
348        validate_model_name(name)?;
349        let root = self.model_root(name);
350        if !root.exists() {
351            return Ok(false);
352        }
353        std::fs::remove_dir_all(&root).map_err(|error| Error::io(&root, error))?;
354        Ok(true)
355    }
356
357    fn model_root(&self, name: &str) -> PathBuf {
358        self.dirs.models().join(sanitize_tool_id(name))
359    }
360
361    fn load_path(&self, path: &Path) -> Result<InstalledModel> {
362        if !path.join(COMPLETE_MARKER).is_file() {
363            return Err(Error::other(format!(
364                "model snapshot is incomplete: {}",
365                path.display()
366            )));
367        }
368        let manifest_path = path.join(MODEL_MANIFEST_FILE);
369        let bytes =
370            std::fs::read(&manifest_path).map_err(|error| Error::io(&manifest_path, error))?;
371        Ok(InstalledModel {
372            manifest: serde_json::from_slice(&bytes)?,
373            path: path.to_path_buf(),
374        })
375    }
376
377    fn write_current(&self, model_root: &Path, snapshot: &str) -> Result<()> {
378        write_json_atomic(
379            &model_root.join(CURRENT_FILE),
380            &CurrentSnapshot {
381                snapshot: snapshot.to_string(),
382            },
383        )
384    }
385}
386
387fn default_revision(provider: ProviderId) -> &'static str {
388    match provider {
389        ProviderId::HuggingFace => "main",
390        ProviderId::ModelScope => "master",
391    }
392}
393
394fn validate_repository(repository: &str) -> Result<()> {
395    let mut parts = repository.split('/');
396    let owner = parts.next().unwrap_or_default();
397    let repo = parts.next().unwrap_or_default();
398    if owner.is_empty()
399        || repo.is_empty()
400        || parts.next().is_some()
401        || [owner, repo].iter().any(|part| {
402            matches!(*part, "." | "..")
403                || !part
404                    .chars()
405                    .all(|character| character.is_ascii_alphanumeric() || "._-".contains(character))
406        })
407    {
408        return Err(Error::config(format!(
409            "invalid model repository `{repository}` (expected owner/name)"
410        )));
411    }
412    Ok(())
413}
414
415pub fn validate_model_name(name: &str) -> Result<()> {
416    if name.is_empty()
417        || !name
418            .chars()
419            .all(|character| character.is_ascii_alphanumeric() || "._-".contains(character))
420    {
421        return Err(Error::config(format!(
422            "invalid model name `{name}` (use letters, digits, dot, dash, or underscore)"
423        )));
424    }
425    Ok(())
426}
427
428pub fn safe_relative_path(value: &str) -> Result<PathBuf> {
429    let path = Path::new(value);
430    if path.as_os_str().is_empty()
431        || path.is_absolute()
432        || path
433            .components()
434            .any(|component| !matches!(component, Component::Normal(_)))
435    {
436        return Err(Error::config(format!("unsafe model file path `{value}`")));
437    }
438    Ok(path.to_path_buf())
439}
440
441fn snapshot_key(identity: &SnapshotIdentity, files: &[DownloadedModelFile]) -> String {
442    let mut hasher = blake3::Hasher::new();
443    hasher.update(
444        format!(
445            "{}\0{}\0{}\0{}",
446            identity.provider,
447            identity.repository,
448            identity.revision,
449            identity.variant.as_deref().unwrap_or_default()
450        )
451        .as_bytes(),
452    );
453    for file in files {
454        hasher.update(file.path.as_bytes());
455        hasher.update(b"\0");
456        hasher.update(file.size.to_string().as_bytes());
457        hasher.update(b"\0");
458        hasher.update(file.sha256.as_deref().unwrap_or_default().as_bytes());
459        hasher.update(b"\0");
460        hasher.update(file.etag.as_deref().unwrap_or_default().as_bytes());
461        hasher.update(b"\0");
462    }
463    hasher.finalize().to_hex()[..24].to_string()
464}
465
466fn write_json_atomic<T: Serialize>(path: &Path, value: &T) -> Result<()> {
467    if let Some(parent) = path.parent() {
468        create_dir_all(parent)?;
469    }
470    let bytes = serde_json::to_vec_pretty(value)?;
471    let temporary = path.with_extension(format!("tmp-{}", std::process::id()));
472    std::fs::write(&temporary, bytes).map_err(|error| Error::io(&temporary, error))?;
473    std::fs::rename(&temporary, path).map_err(|error| Error::io(path, error))
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    fn store(root: &Path) -> ModelStore {
481        let dirs = Dirs::resolve_from(|key| match key {
482            "OSDK_DATA_DIR" => Some(root.join("data").display().to_string()),
483            "OSDK_CACHE_DIR" => Some(root.join("cache").display().to_string()),
484            "OSDK_CONFIG_DIR" => Some(root.join("config").display().to_string()),
485            _ => None,
486        })
487        .unwrap();
488        dirs.ensure().unwrap();
489        ModelStore::new(
490            dirs.clone(),
491            Arc::new(Cas::new(dirs.store.clone())),
492            LinkMode::Copy,
493        )
494    }
495
496    #[test]
497    fn parses_provider_references_and_defaults() {
498        let hf = ModelRef::parse("hf:Qwen/Qwen2.5-7B-Instruct@abc123").unwrap();
499        assert_eq!(hf.provider, ProviderId::HuggingFace);
500        assert_eq!(hf.revision, "abc123");
501        let modelscope = ModelRef::parse("modelscope:Qwen/Qwen2.5-7B-Instruct").unwrap();
502        assert_eq!(modelscope.revision, "master");
503        assert!(ModelRef::parse("hf:../secret").is_err());
504    }
505
506    #[test]
507    fn publishes_lists_and_verifies_immutable_snapshot() {
508        let temporary = tempfile::tempdir().unwrap();
509        let source = temporary.path().join("config.json");
510        std::fs::write(&source, br#"{"model":"fixture"}"#).unwrap();
511        let size = std::fs::metadata(&source).unwrap().len();
512        let sha256 = hash_file(&source, HashAlgo::Sha256).unwrap();
513        let store = store(temporary.path());
514        let installed = store
515            .publish(
516                SnapshotIdentity {
517                    name: "fixture".into(),
518                    provider: ProviderId::HuggingFace,
519                    repository: "owner/repo".into(),
520                    requested_revision: "main".into(),
521                    revision: "abc123".into(),
522                    endpoint: "https://example.test".into(),
523                    variant: None,
524                },
525                vec![DownloadedModelFile {
526                    path: "config.json".into(),
527                    source,
528                    size,
529                    sha256: Some(sha256),
530                    etag: Some("etag".into()),
531                }],
532            )
533            .unwrap();
534
535        assert_eq!(
536            std::fs::read(installed.path.join("config.json")).unwrap(),
537            br#"{"model":"fixture"}"#
538        );
539        assert_eq!(store.list().unwrap().len(), 1);
540        assert_eq!(store.verify("fixture").unwrap().revision, "abc123");
541        std::fs::write(installed.path.join("config.json"), b"tampered").unwrap();
542        assert!(store.verify("fixture").is_err());
543    }
544
545    #[test]
546    fn model_manifests_keep_cas_objects_live() {
547        let temporary = tempfile::tempdir().unwrap();
548        let source = temporary.path().join("weights.bin");
549        std::fs::write(&source, b"same weights").unwrap();
550        let size = std::fs::metadata(&source).unwrap().len();
551        let store = store(temporary.path());
552        let installed = store
553            .publish(
554                SnapshotIdentity {
555                    name: "fixture".into(),
556                    provider: ProviderId::ModelScope,
557                    repository: "owner/repo".into(),
558                    requested_revision: "master".into(),
559                    revision: "v1".into(),
560                    endpoint: "https://example.test".into(),
561                    variant: None,
562                },
563                vec![DownloadedModelFile {
564                    path: "weights.bin".into(),
565                    source,
566                    size,
567                    sha256: None,
568                    etag: None,
569                }],
570            )
571            .unwrap();
572        let hash = installed.manifest.files[0].cas_hash.clone();
573        let (removed, _) = store
574            .cas
575            .gc_roots(&[&store.dirs.installs, &store.dirs.models()])
576            .unwrap();
577        assert_eq!(removed, 0);
578        assert!(store.cas.object_path(&hash).is_file());
579        store.remove("fixture").unwrap();
580        let (removed, _) = store
581            .cas
582            .gc_roots(&[&store.dirs.installs, &store.dirs.models()])
583            .unwrap();
584        assert_eq!(removed, 1);
585    }
586
587    #[test]
588    fn file_selection_is_part_of_snapshot_identity() {
589        let temporary = tempfile::tempdir().unwrap();
590        let first = temporary.path().join("first.bin");
591        let second = temporary.path().join("second.bin");
592        std::fs::write(&first, b"first").unwrap();
593        std::fs::write(&second, b"second").unwrap();
594        let store = store(temporary.path());
595        let identity = SnapshotIdentity {
596            name: "fixture".into(),
597            provider: ProviderId::HuggingFace,
598            repository: "owner/repo".into(),
599            requested_revision: "main".into(),
600            revision: "abc123".into(),
601            endpoint: "https://example.test".into(),
602            variant: None,
603        };
604        let first_snapshot = store
605            .publish(
606                identity.clone(),
607                vec![DownloadedModelFile {
608                    path: "first.bin".into(),
609                    source: first,
610                    size: 5,
611                    sha256: None,
612                    etag: None,
613                }],
614            )
615            .unwrap();
616        let second_snapshot = store
617            .publish(
618                identity,
619                vec![DownloadedModelFile {
620                    path: "second.bin".into(),
621                    source: second,
622                    size: 6,
623                    sha256: None,
624                    etag: None,
625                }],
626            )
627            .unwrap();
628        assert_ne!(first_snapshot.path, second_snapshot.path);
629        assert!(second_snapshot.path.join("second.bin").is_file());
630        assert!(!second_snapshot.path.join("first.bin").exists());
631    }
632}