cherenkov/model/index/
selector.rs1use anyhow::{Context, Result, ensure};
4use reqwest::Url;
5use std::path::{Path, PathBuf};
6
7#[derive(Debug, PartialEq, Eq)]
9pub(super) enum Selector {
10 Registered(String),
11 Hub {
12 repo: String,
13 revision: Option<String>,
14 },
15 Disk {
16 store: String,
17 repo: String,
18 revision: Option<String>,
19 },
20 Path(PathBuf),
21 LocalRevision {
22 path: PathBuf,
23 revision: String,
24 },
25}
26
27pub(super) fn parse(input: &Path) -> Result<Selector> {
29 let Some(text) = input.to_str() else {
30 return Ok(Selector::Path(input.to_owned()));
31 };
32
33 ensure!(!text.is_empty(), "empty model reference");
34
35 if input.is_absolute()
36 || matches!(text, "." | "..")
37 || text.starts_with("./")
38 || text.starts_with("../")
39 {
40 return Ok(Selector::Path(input.to_owned()));
41 }
42
43 let mut components = input.components();
44
45 if components
46 .next()
47 .is_some_and(|part| part.as_os_str() == "~")
48 {
49 return Ok(Selector::Path(
50 dirs::home_dir()
51 .context("home directory unavailable")?
52 .join(components.as_path()),
53 ));
54 }
55
56 let uri = match Url::parse(text) {
57 Ok(uri) => uri,
58 Err(_) if !text.contains(':') => {
59 return Ok(if text.contains('/') {
60 Selector::Path(input.to_owned())
61 } else {
62 Selector::Registered(text.to_owned())
63 });
64 }
65 Err(error) => return Err(error).context("invalid model locator"),
66 };
67
68 ensure!(
69 uri.query().is_none() && uri.fragment().is_none(),
70 "model locators do not accept queries or fragments"
71 );
72
73 match uri.scheme() {
74 "model" => {
75 ensure!(
76 uri.cannot_be_a_base(),
77 "use model:alias for legacy references"
78 );
79
80 legacy(uri.path())
81 }
82 "hf" | "disk" => source(&uri, text),
83 _ => anyhow::bail!("unsupported model source scheme {:?}", uri.scheme()),
84 }
85}
86
87fn source(uri: &Url, original: &str) -> Result<Selector> {
89 ensure!(
90 uri.username().is_empty() && uri.password().is_none() && uri.port().is_none(),
91 "model source authority cannot contain credentials or a port"
92 );
93 ensure!(
94 !original.contains('%')
95 && !uri.as_str().contains('%')
96 && !original.split('/').any(|part| matches!(part, "." | "..")),
97 "model source paths must use literal names without dot segments"
98 );
99
100 let authority = uri
101 .host_str()
102 .context("model source requires an authority")?;
103
104 ensure!(
105 crate::storage::safe_component(authority),
106 "invalid source authority"
107 );
108
109 let segments: Vec<_> = uri
110 .path_segments()
111 .context("model source requires a repository path")?
112 .collect();
113 let location = segments.join("/");
114 let (repository, revision) = revision(&location)?;
115
116 match uri.scheme() {
117 "hf" => {
118 ensure!(
119 crate::storage::safe_component(repository),
120 "use hf://owner/repo[@revision]"
121 );
122
123 Ok(Selector::Hub {
124 repo: format!("{authority}/{repository}"),
125 revision,
126 })
127 }
128 "disk" => {
129 let (owner, name) = repository
130 .split_once('/')
131 .context("use disk://store/owner/repo[@revision]")?;
132
133 ensure!(
134 crate::storage::safe_component(owner) && crate::storage::safe_component(name),
135 "use disk://store/owner/repo[@revision]"
136 );
137
138 Ok(Selector::Disk {
139 store: authority.to_owned(),
140 repo: repository.to_owned(),
141 revision,
142 })
143 }
144 _ => unreachable!(),
145 }
146}
147
148fn revision(location: &str) -> Result<(&str, Option<String>)> {
150 let Some((repository, revision)) = location.split_once('@') else {
151 return Ok((location, None));
152 };
153
154 validate_revision(revision)?;
155
156 Ok((repository, Some(revision.to_owned())))
157}
158
159pub(super) fn validate_revision(revision: &str) -> Result<()> {
161 ensure!(
162 revision.split('/').all(crate::storage::safe_component),
163 "invalid model revision"
164 );
165
166 Ok(())
167}
168
169fn legacy(key: &str) -> Result<Selector> {
171 ensure!(!key.is_empty(), "empty model reference");
172
173 let Some((kind, location)) = key.split_once(':') else {
174 return Ok(Selector::Registered(key.to_owned()));
175 };
176 let (location, revision) = location
177 .rsplit_once(':')
178 .context("legacy source reference needs a revision")?;
179
180 ensure!(
181 super::reference::is_revision(revision),
182 "invalid legacy revision"
183 );
184
185 match kind {
186 "hf" => {
187 let (owner, repo) = location
188 .split_once('/')
189 .context("HF source requires owner/repo")?;
190
191 ensure!(
192 crate::storage::safe_component(owner) && crate::storage::safe_component(repo),
193 "invalid HF repository"
194 );
195
196 Ok(Selector::Hub {
197 repo: location.to_owned(),
198 revision: Some(revision.to_owned()),
199 })
200 }
201 "local" => {
202 ensure!(
203 Path::new(location).is_absolute(),
204 "legacy local reference requires an absolute path"
205 );
206
207 Ok(Selector::LocalRevision {
208 path: PathBuf::from(location),
209 revision: revision.to_owned(),
210 })
211 }
212 _ => anyhow::bail!("unknown legacy source kind"),
213 }
214}
215
216#[cfg(test)]
217#[path = "../../../tests/unit/model/index/selector.rs"]
218mod tests;