Skip to main content

warpgate_api/
locator.rs

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