Skip to main content

influxdb3_plugin_schemas/
identity.rs

1//! Plugin identity: `PluginId` tuple, `PluginName` and `IndexUrl` newtypes.
2
3use crate::SchemaError;
4use std::fmt;
5use std::str::FromStr;
6
7/// URL of a registry index: the first component of registry plugin identity,
8/// used both in [`PluginId::Registry`] and in `dependencies.plugins` entries
9/// ([`crate::PluginDependency`]), so references and identities compare under
10/// one normalization and validation rule.
11///
12/// Scheme is restricted to `https`, `http`, or `file` — the same set as
13/// [`crate::ArtifactsUrl`], via the same shared validator. The set may widen
14/// in a future minor version; widening is non-breaking.
15#[derive(Debug, Clone, PartialEq, Eq, Hash)]
16pub struct IndexUrl(url::Url);
17
18impl IndexUrl {
19    pub fn try_new(raw: &str) -> Result<Self, SchemaError> {
20        let url = parse_registry_scheme_url(raw, |url, scheme| {
21            SchemaError::UnsupportedIndexUrlScheme { url, scheme }
22        })?;
23        Ok(Self(url))
24    }
25
26    pub fn as_url(&self) -> &url::Url {
27        &self.0
28    }
29}
30
31impl fmt::Display for IndexUrl {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        self.0.fmt(f)
34    }
35}
36
37impl<'de> serde::Deserialize<'de> for IndexUrl {
38    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
39    where
40        D: serde::Deserializer<'de>,
41    {
42        let raw = String::deserialize(deserializer)?;
43        Self::try_new(&raw).map_err(serde::de::Error::custom)
44    }
45}
46
47impl serde::Serialize for IndexUrl {
48    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
49    where
50        S: serde::Serializer,
51    {
52        serializer.collect_str(&self.0)
53    }
54}
55
56/// Parses `raw` as an absolute URL restricted to the registry scheme set
57/// (`https`, `http`, `file`). Shared by [`IndexUrl`] and
58/// [`crate::ArtifactsUrl`] so the scheme rule is single-sourced;
59/// `scheme_error` builds the caller's field-specific error variant. Parse
60/// failures map to `SchemaError::InvalidUrl` for both callers.
61pub(crate) fn parse_registry_scheme_url(
62    raw: &str,
63    scheme_error: impl FnOnce(String, String) -> SchemaError,
64) -> Result<url::Url, SchemaError> {
65    let url = url::Url::parse(raw).map_err(|source| SchemaError::InvalidUrl {
66        url: raw.to_owned(),
67        source,
68    })?;
69    match url.scheme() {
70        "https" | "http" | "file" => Ok(url),
71        other => Err(scheme_error(raw.to_owned(), other.to_owned())),
72    }
73}
74
75/// Validated plugin name matching `[a-zA-Z][a-zA-Z0-9_-]*` (1-64 ASCII
76/// characters, starting with an ASCII letter). Case-preserving in storage.
77/// Windows reserved device names (`con`, `prn`, `aux`, `nul`, `com0-9`,
78/// `lpt0-9`) are rejected case-insensitively. Collisions inside a single
79/// index use the [canonical form](Self::canonical).
80#[derive(Debug, Clone, PartialEq, Eq, Hash)]
81pub struct PluginName(String);
82
83impl PluginName {
84    /// Windows reserved device names. Rejected case-insensitively because
85    /// plugins extract to `plugin_dir/<name>/<version>/` and these names
86    /// cannot be created as filesystem entries on Windows regardless of
87    /// extension.
88    const WINDOWS_RESERVED: &'static [&'static str] = &[
89        "con", "prn", "aux", "nul", "com0", "com1", "com2", "com3", "com4", "com5", "com6", "com7",
90        "com8", "com9", "lpt0", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8",
91        "lpt9",
92    ];
93
94    pub fn as_str(&self) -> &str {
95        &self.0
96    }
97
98    /// Canonical form for collision detection. Never surface to users;
99    /// use `as_str()`/`Display` for presentation.
100    ///
101    /// Returns owned `String` rather than `Cow<str>`: the result differs
102    /// from `as_str()` whenever the name contains any uppercase character
103    /// or hyphen (common). Collision checks run O(n) in index size
104    /// (v1 cap: ~200 plugins), so allocation cost is not load-bearing
105    /// and the simpler type wins.
106    pub fn canonical(&self) -> String {
107        canonical_name(&self.0)
108    }
109
110    pub fn into_inner(self) -> String {
111        self.0
112    }
113
114    fn validate(name: &str) -> Result<(), SchemaError> {
115        let bytes = name.as_bytes();
116        if bytes.is_empty() || bytes.len() > 64 {
117            return Err(SchemaError::InvalidPluginName {
118                name: name.to_owned(),
119            });
120        }
121        let first = bytes[0];
122        let is_alpha = |b: u8| b.is_ascii_uppercase() || b.is_ascii_lowercase();
123        let is_alnum = |b: u8| b.is_ascii_digit() || is_alpha(b);
124        if !is_alpha(first) {
125            return Err(SchemaError::InvalidPluginName {
126                name: name.to_owned(),
127            });
128        }
129        for &b in &bytes[1..] {
130            if !(is_alnum(b) || b == b'-' || b == b'_') {
131                return Err(SchemaError::InvalidPluginName {
132                    name: name.to_owned(),
133                });
134            }
135        }
136        let lower = name.to_ascii_lowercase();
137        if Self::WINDOWS_RESERVED.iter().any(|&r| r == lower) {
138            return Err(SchemaError::ReservedPluginName {
139                name: name.to_owned(),
140            });
141        }
142        Ok(())
143    }
144}
145
146/// Canonical form used for `(name, version)` collision detection inside
147/// a single index. Lives alongside `PluginName::canonical()` so the rule
148/// is single-sourced; callers with a raw (un-validated) `&str` — notably
149/// [`Index::from_raw_json`] — can dedupe without routing through the
150/// validator.
151pub(crate) fn canonical_name(raw: &str) -> String {
152    raw.to_ascii_lowercase().replace('-', "_")
153}
154
155impl FromStr for PluginName {
156    type Err = SchemaError;
157
158    fn from_str(s: &str) -> Result<Self, Self::Err> {
159        Self::validate(s)?;
160        Ok(Self(s.to_owned()))
161    }
162}
163
164impl fmt::Display for PluginName {
165    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166        f.write_str(&self.0)
167    }
168}
169
170impl<'de> serde::Deserialize<'de> for PluginName {
171    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
172    where
173        D: serde::Deserializer<'de>,
174    {
175        let raw = String::deserialize(deserializer)?;
176        Self::from_str(&raw).map_err(serde::de::Error::custom)
177    }
178}
179
180impl serde::Serialize for PluginName {
181    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
182    where
183        S: serde::Serializer,
184    {
185        serializer.serialize_str(&self.0)
186    }
187}
188
189/// Global plugin identity: the tuple `(source, name, version)` where `source`
190/// is either a registry URL or a local directory. Two `PluginId`s are equal
191/// when all three components match.
192///
193/// No serde impls: the SDK itself doesn't need them (manifests and indexes
194/// use their own types). Add later with a snapshot test pinning the JSON shape
195/// if a downstream consumer needs to persist `PluginId`.
196#[derive(Debug, Clone, PartialEq, Eq, Hash)]
197pub enum PluginId {
198    Registry {
199        index_url: IndexUrl,
200        name: PluginName,
201        version: semver::Version,
202    },
203    Local {
204        path: std::path::PathBuf,
205        name: PluginName,
206        version: semver::Version,
207    },
208}
209
210impl PluginId {
211    pub fn registry(index_url: IndexUrl, name: PluginName, version: semver::Version) -> Self {
212        Self::Registry {
213            index_url,
214            name,
215            version,
216        }
217    }
218
219    pub fn local(path: std::path::PathBuf, name: PluginName, version: semver::Version) -> Self {
220        Self::Local {
221            path,
222            name,
223            version,
224        }
225    }
226
227    pub fn name(&self) -> &PluginName {
228        match self {
229            Self::Registry { name, .. } | Self::Local { name, .. } => name,
230        }
231    }
232
233    pub fn version(&self) -> &semver::Version {
234        match self {
235            Self::Registry { version, .. } | Self::Local { version, .. } => version,
236        }
237    }
238}
239
240impl fmt::Display for PluginId {
241    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
242        match self {
243            Self::Registry {
244                index_url,
245                name,
246                version,
247            } => write!(f, "{name}@{version} ({index_url})"),
248            Self::Local {
249                path,
250                name,
251                version,
252            } => write!(f, "{name}@{version} (local: {})", path.display()),
253        }
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use assert_matches::assert_matches;
261    use rstest::rstest;
262
263    #[rstest]
264    #[case("a")]
265    #[case("aa")]
266    #[case("plugin")]
267    #[case("my-plugin")]
268    #[case("a1b2c3")]
269    #[case("a-really-long-but-still-valid-name-that-is-under-64-chars")]
270    #[case("Z")]
271    #[case("MyPlugin")]
272    #[case("MYPLUGIN")]
273    #[case("Test-1_v2")]
274    #[case("Foo")]
275    #[case("foo_bar")]
276    fn valid_names_accepted(#[case] input: &str) {
277        let name = PluginName::from_str(input).expect("should accept valid name");
278        assert_eq!(name.as_str(), input);
279    }
280
281    #[rstest]
282    #[case("")] // empty
283    #[case("-foo")] // leading hyphen
284    #[case("foo bar")] // space
285    #[case("123")] // digit-leading
286    #[case("7plugin")] // digit-leading
287    #[case("café")] // non-ASCII
288    #[case("foo.bar")] // dot
289    fn invalid_names_rejected(#[case] input: &str) {
290        let err = PluginName::from_str(input).expect_err("should reject");
291        assert_matches!(err, SchemaError::InvalidPluginName { .. });
292    }
293
294    #[test]
295    fn plugin_name_length_boundaries() {
296        assert!(PluginName::from_str("a").is_ok());
297        assert!(PluginName::from_str(&"a".repeat(64)).is_ok());
298        assert!(matches!(
299            PluginName::from_str(&"a".repeat(65)),
300            Err(SchemaError::InvalidPluginName { .. })
301        ));
302        assert!(matches!(
303            PluginName::from_str(""),
304            Err(SchemaError::InvalidPluginName { .. })
305        ));
306    }
307
308    #[rstest]
309    #[case("con")]
310    #[case("prn")]
311    #[case("aux")]
312    #[case("nul")]
313    #[case("com0")]
314    #[case("com9")]
315    #[case("lpt0")]
316    #[case("lpt9")]
317    #[case("CON")]
318    #[case("Com1")]
319    fn reserved_names_rejected(#[case] input: &str) {
320        let err = PluginName::from_str(input).expect_err("should reject reserved name");
321        assert!(
322            matches!(err, SchemaError::ReservedPluginName { ref name } if name == input),
323            "expected ReservedPluginName with preserved input spelling, got: {err:?}"
324        );
325    }
326
327    #[rstest]
328    #[case("console")]
329    #[case("com10")]
330    #[case("conin")]
331    #[case("com")]
332    fn near_reserved_names_accepted(#[case] input: &str) {
333        assert!(PluginName::from_str(input).is_ok());
334    }
335
336    #[test]
337    fn plugin_name_display_matches_as_str() {
338        let name = PluginName::from_str("downsampler").unwrap();
339        assert_eq!(format!("{name}"), "downsampler");
340    }
341
342    #[test]
343    fn plugin_name_round_trips_through_serde_json() {
344        let name = PluginName::from_str("my-plugin").unwrap();
345        let json = serde_json::to_string(&name).unwrap();
346        assert_eq!(json, "\"my-plugin\"");
347        let back: PluginName = serde_json::from_str(&json).unwrap();
348        assert_eq!(back, name);
349    }
350
351    #[test]
352    fn plugin_name_deserialize_rejects_invalid() {
353        let result: Result<PluginName, _> = serde_json::from_str("\"Bad Name\"");
354        let err = result.expect_err("should reject invalid name");
355        // serde flattens through `Deserialize`'s custom impl; the error message
356        // must contain the normalization hint so consumers can understand what
357        // went wrong. The exact prefix ("invalid plugin name") is pinned by
358        // the SchemaError::InvalidPluginName Display snapshot in src/error.rs.
359        assert!(
360            err.to_string().contains("plugin name"),
361            "expected error mentioning plugin name, got: {err}"
362        );
363    }
364
365    #[rstest]
366    #[case("a", "a")]
367    #[case("MyPlugin", "myplugin")]
368    #[case("foo-bar", "foo_bar")]
369    #[case("foo_bar", "foo_bar")]
370    #[case("Foo-Bar_Baz", "foo_bar_baz")]
371    #[case("Test-1_v2", "test_1_v2")]
372    fn canonical_form_matches_table(#[case] input: &str, #[case] expected: &str) {
373        let name = PluginName::from_str(input).expect("valid input");
374        assert_eq!(name.canonical(), expected);
375        // Non-mutation invariant: canonical() does not change stored form
376        assert_eq!(name.as_str(), input);
377    }
378}
379
380#[cfg(test)]
381mod index_url_tests {
382    use super::*;
383    use assert_matches::assert_matches;
384    use rstest::rstest;
385
386    #[rstest]
387    #[case("https://plugins.example.com/index.json")]
388    #[case("http://localhost:8080/index.json")]
389    #[case("file:///srv/registry/index.json")]
390    fn valid_schemes_accepted(#[case] input: &str) {
391        assert!(IndexUrl::try_new(input).is_ok());
392    }
393
394    #[rstest]
395    #[case("s3://bucket/index.json")]
396    #[case("oci://registry.example")]
397    #[case("ftp://registry.example/index.json")]
398    fn invalid_schemes_rejected(#[case] input: &str) {
399        assert_matches!(
400            IndexUrl::try_new(input),
401            Err(SchemaError::UnsupportedIndexUrlScheme { .. })
402        );
403    }
404
405    #[test]
406    fn malformed_rejected() {
407        assert_matches!(
408            IndexUrl::try_new("not a url"),
409            Err(SchemaError::InvalidUrl { .. })
410        );
411    }
412}
413
414#[cfg(test)]
415mod plugin_id_tests {
416    use super::*;
417    use pretty_assertions::assert_eq;
418    use semver::Version;
419    use std::path::PathBuf;
420
421    #[test]
422    fn registry_variant_constructs_from_parts() {
423        let id = PluginId::registry(
424            IndexUrl::try_new("https://plugins.example.com/index.json").unwrap(),
425            PluginName::from_str("downsampler").unwrap(),
426            Version::new(1, 2, 0),
427        );
428        match &id {
429            PluginId::Registry { name, version, .. } => {
430                assert_eq!(name.as_str(), "downsampler");
431                assert_eq!(*version, Version::new(1, 2, 0));
432            }
433            PluginId::Local { .. } => panic!("expected Registry variant"),
434        }
435        assert_eq!(id.name().as_str(), "downsampler");
436        assert_eq!(*id.version(), Version::new(1, 2, 0));
437    }
438
439    #[test]
440    fn local_variant_constructs_from_parts() {
441        let id = PluginId::local(
442            PathBuf::from("/srv/plugins/my-plugin"),
443            PluginName::from_str("my-plugin").unwrap(),
444            Version::new(0, 3, 1),
445        );
446        match &id {
447            PluginId::Local {
448                path,
449                name,
450                version,
451            } => {
452                assert_eq!(*path, PathBuf::from("/srv/plugins/my-plugin"));
453                assert_eq!(name.as_str(), "my-plugin");
454                assert_eq!(*version, Version::new(0, 3, 1));
455            }
456            PluginId::Registry { .. } => panic!("expected Local variant"),
457        }
458        assert_eq!(id.name().as_str(), "my-plugin");
459        assert_eq!(*id.version(), Version::new(0, 3, 1));
460    }
461
462    #[test]
463    fn display_shape_pinned() {
464        let registry = PluginId::registry(
465            IndexUrl::try_new("https://r.example/index.json").unwrap(),
466            PluginName::from_str("downsampler").unwrap(),
467            Version::new(1, 2, 0),
468        );
469        let local = PluginId::local(
470            PathBuf::from("/srv/plugins/my-plugin"),
471            PluginName::from_str("my-plugin").unwrap(),
472            Version::new(0, 3, 1),
473        );
474        insta::assert_yaml_snapshot!(
475            "plugin_id_display",
476            vec![registry.to_string(), local.to_string()]
477        );
478    }
479}