1use 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}