Skip to main content

influxdb3_plugin_schemas/
identity.rs

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