warpgate_api/
locator.rs

1use crate::locator_error::PluginLocatorError;
2use serde::{Deserialize, Serialize};
3use std::fmt::Display;
4use std::path::PathBuf;
5use std::str::FromStr;
6
7/// A file system locator.
8#[derive(Clone, Debug, Default, Eq, PartialEq)]
9pub struct FileLocator {
10    /// Path explicitly configured by a user (with file://).
11    pub file: String,
12
13    /// The file (above) resolved to an absolute path.
14    /// This must be done manually on the host side.
15    pub path: Option<PathBuf>,
16}
17
18#[cfg(not(target_arch = "wasm32"))]
19impl FileLocator {
20    pub fn get_unresolved_path(&self) -> PathBuf {
21        PathBuf::from(self.file.strip_prefix("file://").unwrap_or(&self.file))
22    }
23
24    pub fn get_resolved_path(&self) -> PathBuf {
25        let mut path = self
26            .path
27            .clone()
28            .unwrap_or_else(|| self.get_unresolved_path());
29
30        if !path.is_absolute() {
31            path = std::env::current_dir()
32                .expect("Could not determine working directory!")
33                .join(path);
34        }
35
36        path
37    }
38}
39
40/// A GitHub release locator.
41#[derive(Clone, Debug, Default, Eq, PartialEq)]
42pub struct GitHubLocator {
43    /// Owner/org and repository name: `owner/repo`.
44    pub repo_slug: String,
45
46    /// Explicit release tag to use. Defaults to `latest`.
47    pub tag: Option<String>,
48
49    /// Project name to match tags against. Primarily used in monorepos.
50    pub project_name: Option<String>,
51}
52
53/// A HTTPS URL locator.
54#[derive(Clone, Debug, Default, Eq, PartialEq)]
55pub struct UrlLocator {
56    /// URL explicitly configured by a user (with https://).
57    pub url: String,
58}
59
60/// An OCI registry locator.
61#[derive(Clone, Debug, Default, Eq, PartialEq)]
62pub struct RegistryLocator {
63    /// Registry host: `ghcr.io`.
64    pub registry: Option<String>,
65
66    /// Namespace or organization: `org/namespace`.
67    pub namespace: Option<String>,
68
69    /// The image name (plugin identifier): `plugin`
70    pub image: String,
71
72    /// Explicit release tag to use. Defaults to `latest`.
73    pub tag: Option<String>,
74}
75
76/// Strategies and protocols for locating plugins.
77#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
78#[serde(untagged, into = "String", try_from = "String")]
79pub enum PluginLocator {
80    /// file:///abs/path/to/file.wasm
81    /// file://../rel/path/to/file.wasm
82    File(Box<FileLocator>),
83
84    /// github://owner/repo
85    /// github://owner/repo@tag
86    /// github://owner/repo/project
87    GitHub(Box<GitHubLocator>),
88
89    /// https://url/to/file.wasm
90    Url(Box<UrlLocator>),
91
92    /// registry://plugins/python
93    /// registry://plugins/python:tag
94    Registry(Box<RegistryLocator>),
95}
96
97#[cfg(feature = "schematic")]
98impl schematic::Schematic for PluginLocator {
99    fn schema_name() -> Option<String> {
100        Some("PluginLocator".into())
101    }
102
103    fn build_schema(mut schema: schematic::SchemaBuilder) -> schematic::Schema {
104        schema.set_description("Strategies and protocols for locating plugins.");
105        schema.string_default()
106    }
107}
108
109impl Display for PluginLocator {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        match self {
112            PluginLocator::File(file) => {
113                if file.file.starts_with("file://") {
114                    write!(f, "{}", file.file)
115                } else {
116                    write!(f, "file://{}", file.file)
117                }
118            }
119            PluginLocator::Url(url) => write!(f, "{}", url.url),
120            PluginLocator::GitHub(github) => write!(
121                f,
122                "github://{}{}{}",
123                github.repo_slug,
124                github
125                    .project_name
126                    .as_deref()
127                    .map(|n| format!("/{n}"))
128                    .unwrap_or_default(),
129                github
130                    .tag
131                    .as_deref()
132                    .map(|t| format!("@{t}"))
133                    .unwrap_or_default()
134            ),
135            PluginLocator::Registry(registry) => write!(
136                f,
137                "registry://{}:{}",
138                vec![
139                    registry.registry.clone(),
140                    registry.namespace.clone(),
141                    Some(registry.image.clone())
142                ]
143                .into_iter()
144                .flatten()
145                .collect::<Vec<_>>()
146                .join("/"),
147                registry.tag.as_deref().unwrap_or("latest")
148            ),
149        }
150    }
151}
152
153impl FromStr for PluginLocator {
154    type Err = PluginLocatorError;
155
156    fn from_str(value: &str) -> Result<Self, Self::Err> {
157        PluginLocator::try_from(value.to_owned())
158    }
159}
160
161impl TryFrom<String> for PluginLocator {
162    type Error = PluginLocatorError;
163
164    fn try_from(value: String) -> Result<Self, Self::Error> {
165        // Legacy support
166        if let Some(source) = value.strip_prefix("source:") {
167            if source.starts_with("http") {
168                return Self::try_from(source.to_owned());
169            } else {
170                return Self::try_from(format!("file://{source}"));
171            }
172        } else if value.starts_with("github:") && !value.contains("//") {
173            return Self::try_from(format!("github://{}", &value[7..]));
174        }
175
176        if !value.contains("://") {
177            return Err(PluginLocatorError::MissingProtocol);
178        }
179
180        let mut parts = value.splitn(2, "://");
181
182        let Some(protocol) = parts.next() else {
183            return Err(PluginLocatorError::MissingProtocol);
184        };
185
186        let Some(location) = parts.next() else {
187            return Err(PluginLocatorError::MissingLocation);
188        };
189
190        if location.is_empty() {
191            return Err(PluginLocatorError::MissingLocation);
192        }
193
194        match protocol {
195            "file" => Ok(PluginLocator::File(Box::new(FileLocator {
196                file: value,
197                path: None,
198            }))),
199            "github" => {
200                if !location.contains('/') {
201                    return Err(PluginLocatorError::MissingGitHubOrg);
202                }
203
204                let mut github = GitHubLocator::default();
205                let mut query = location;
206
207                if let Some(index) = query.find('@') {
208                    github.tag = Some(query[index + 1..].into());
209                    query = &query[0..index];
210                }
211
212                let mut parts = query.split('/');
213                let org = parts.next().unwrap_or_default().to_owned();
214                let repo = parts.next().unwrap_or_default().to_owned();
215                let prefix = parts.next().map(|f| f.to_owned());
216
217                github.project_name = prefix;
218                github.repo_slug = format!("{org}/{repo}");
219
220                Ok(PluginLocator::GitHub(Box::new(github)))
221            }
222            "http" => Err(PluginLocatorError::SecureUrlsOnly),
223            "https" => Ok(PluginLocator::Url(Box::new(UrlLocator { url: value }))),
224            "registry" => {
225                let mut registry = RegistryLocator::default();
226                let mut query = location;
227
228                if let Some(index) = query.find(":") {
229                    registry.tag = Some(query[index + 1..].into());
230                    query = &query[0..index];
231                }
232
233                if let Some(index) = query.find("/") {
234                    let inner = &query[0..index];
235
236                    // Domains contain a period for the TLD
237                    if inner.contains('.') {
238                        registry.registry = Some(inner.into());
239                        query = &query[index + 1..];
240                    }
241                }
242
243                if let Some(index) = query.rfind('/') {
244                    registry.image = query[index + 1..].into();
245                    query = &query[0..index];
246                } else {
247                    registry.image = query.into();
248                    query = &query[0..0];
249                }
250
251                if !query.is_empty() {
252                    registry.namespace = Some(query.into());
253                }
254
255                if registry.image.is_empty() {
256                    return Err(PluginLocatorError::MissingRegistryImage);
257                }
258
259                Ok(PluginLocator::Registry(Box::new(registry)))
260            }
261            unknown => Err(PluginLocatorError::UnknownProtocol(unknown.to_owned())),
262        }
263    }
264}
265
266impl From<PluginLocator> for String {
267    fn from(locator: PluginLocator) -> Self {
268        locator.to_string()
269    }
270}
271
272impl AsRef<PluginLocator> for PluginLocator {
273    fn as_ref(&self) -> &Self {
274        self
275    }
276}