Skip to main content

cherenkov/model/index/
disk.rs

1//! Registered filesystem stores preserve physical layouts and external ownership.
2
3use super::{Catalog, ModelIndex};
4use anyhow::{Context, Result, ensure};
5use serde::{Deserialize, Serialize};
6use std::path::{Path, PathBuf};
7
8/// Physical layout used to find an owner/repository within a disk store.
9#[derive(Clone, Copy, Default, Serialize, Deserialize, clap::ValueEnum)]
10#[serde(rename_all = "snake_case")]
11pub enum DiskLayout {
12    /// Directories at ROOT/owner/repo, as used by ordinary and LM Studio stores.
13    #[default]
14    Directory,
15    /// Hugging Face's models--owner--repo/snapshots/commit cache layout.
16    HfCache,
17}
18
19/// A named, externally owned filesystem store registered in this index.
20#[derive(Clone, Serialize, Deserialize)]
21pub struct DiskStore {
22    /// Stable identity; removing and re-registering a name creates a new identity.
23    pub id: String,
24    /// URI authority used in disk://NAME/owner/repo.
25    pub name: String,
26    /// Canonical filesystem root. Registration never takes ownership of its files.
27    pub path: PathBuf,
28    /// Model-location convention beneath the root.
29    pub layout: DiskLayout,
30    /// Whether source URI resolution is enabled; existing index entries are retained.
31    pub enabled: bool,
32}
33
34impl ModelIndex {
35    /// Register an existing filesystem root without crawling or importing models.
36    pub fn add_disk_store(&self, name: &str, path: &Path, layout: DiskLayout) -> Result<DiskStore> {
37        ensure!(
38            crate::storage::safe_component(name),
39            "invalid disk store name"
40        );
41
42        let path = path.canonicalize().context("opening disk store")?;
43
44        ensure!(path.is_dir(), "disk store root must be a directory");
45
46        let mut catalog = self.lock()?;
47
48        ensure!(
49            !catalog.value.stores.contains_key(name),
50            "store {name:?} is already registered"
51        );
52
53        let store = DiskStore {
54            id: super::id(),
55            name: name.to_owned(),
56            path,
57            layout,
58            enabled: true,
59        };
60
61        catalog.value.stores.insert(name.to_owned(), store.clone());
62        catalog.save()?;
63
64        Ok(store)
65    }
66
67    /// List store registrations without opening their roots or contacting providers.
68    pub fn disk_stores(&self) -> Result<Vec<DiskStore>> {
69        if !self.paths.data.join("index.json").exists() {
70            return Ok(Vec::new());
71        }
72
73        Ok(self.lock()?.value.stores.values().cloned().collect())
74    }
75
76    /// Enable or disable resolution through a store's URI without changing its ID.
77    pub fn set_disk_store_enabled(&self, name: &str, enabled: bool) -> Result<()> {
78        let mut catalog = self.lock()?;
79        catalog
80            .value
81            .stores
82            .get_mut(name)
83            .context("store is not registered")?
84            .enabled = enabled;
85
86        catalog.save()
87    }
88
89    /// Forget a store registration without deleting files or registered model entries.
90    pub fn remove_disk_store(&self, name: &str) -> Result<()> {
91        let mut catalog = self.lock()?;
92
93        ensure!(
94            catalog.value.stores.remove(name).is_some(),
95            "store is not registered"
96        );
97
98        catalog.save()
99    }
100}
101
102/// Find an enabled store before resolving a source or inspecting a registered URI.
103pub(super) fn registered<'a>(catalog: &'a Catalog, name: &str) -> Result<&'a DiskStore> {
104    let store = catalog
105        .stores
106        .get(name)
107        .context("disk store is not registered")?;
108
109    ensure!(store.enabled, "disk store {name:?} is disabled");
110
111    Ok(store)
112}
113
114impl DiskStore {
115    /// Resolve a source inside this root, rejecting directory traversal and symlink escapes.
116    pub(super) fn locate(&self, repo: &str, revision: Option<&str>) -> Result<PathBuf> {
117        let candidate = match self.layout {
118            DiskLayout::Directory => self.path.join(repo),
119            DiskLayout::HfCache => self.snapshot(repo, revision)?,
120        };
121        let path = candidate
122            .canonicalize()
123            .context("model is absent from the disk store")?;
124
125        ensure!(
126            path.starts_with(&self.path),
127            "model path escapes the disk store"
128        );
129
130        Ok(path)
131    }
132
133    /// Select a cached revision without downloading or advancing a cached ref.
134    fn snapshot(&self, repo: &str, revision: Option<&str>) -> Result<PathBuf> {
135        let root = self
136            .path
137            .join(format!("models--{}", repo.replace('/', "--")));
138        let snapshots = root.join("snapshots");
139        let requested = revision.unwrap_or("main");
140
141        ensure!(
142            !Path::new(requested).is_absolute()
143                && requested.split('/').all(crate::storage::safe_component),
144            "invalid cache revision"
145        );
146
147        let cached_ref = match std::fs::read_to_string(root.join("refs").join(requested)) {
148            Ok(commit) => Some(commit),
149            Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
150            Err(error) => return Err(error).context("reading cached revision"),
151        };
152
153        if let Some(commit) = cached_ref {
154            let commit = commit.trim();
155
156            ensure!(
157                super::reference::is_revision(commit),
158                "invalid cached commit"
159            );
160
161            return Ok(snapshots.join(commit));
162        }
163
164        ensure!(
165            revision.is_none_or(super::reference::is_revision),
166            "cached ref not found; commit prefixes require at least eight hexadecimal characters"
167        );
168
169        let mut matches = std::fs::read_dir(&snapshots)?
170            .collect::<std::io::Result<Vec<_>>>()?
171            .into_iter()
172            .filter(|entry| {
173                entry.file_name().to_str().is_some_and(|name| {
174                    super::reference::is_revision(name)
175                        && revision.is_none_or(|rev| name.starts_with(rev))
176                })
177            });
178        let first = matches.next().context("cached revision not found")?;
179
180        ensure!(
181            matches.next().is_none(),
182            "cached revision is ambiguous; append @commit"
183        );
184
185        Ok(first.path())
186    }
187
188    /// Map a known local path to the same selector type produced by URL parsing.
189    pub(super) fn selector(&self, path: &Path) -> Option<super::selector::Selector> {
190        let relative = path.strip_prefix(&self.path).ok()?;
191        let parts: Vec<_> = relative
192            .iter()
193            .map(|part| part.to_str())
194            .collect::<Option<_>>()?;
195        let (repo, revision) = match (self.layout, parts.as_slice()) {
196            (DiskLayout::Directory, [owner, repo]) => (format!("{owner}/{repo}"), None),
197            (DiskLayout::HfCache, [repo, "snapshots", commit]) => {
198                let parts: Vec<_> = repo.split("--").collect();
199                let ["models", owner, repo] = parts.as_slice() else {
200                    return None;
201                };
202
203                (format!("{owner}/{repo}"), Some((*commit).to_owned()))
204            }
205            _ => return None,
206        };
207
208        Some(super::selector::Selector::Disk {
209            store: self.name.clone(),
210            repo,
211            revision,
212        })
213    }
214}