Skip to main content

lenso_app_authoring/
identity.rs

1//! Canonical identities shared by Plugin authoring and catalog consumers.
2
3use anyhow::bail;
4
5/// Versioned shape accepted for a Plugin ID already present in a project.
6#[derive(Clone, Copy, Debug, Eq, PartialEq)]
7pub enum PluginIdVersion {
8    /// Namespaced Plugin identity used by new authoring and catalogs.
9    V1,
10    /// Pre-v1 unnamespaced identity retained only for opening existing projects.
11    Legacy,
12}
13
14/// Validates the canonical namespaced Plugin ID v1 grammar.
15///
16/// A v1 ID contains at least two dot-separated labels. Every label starts with
17/// a lowercase ASCII letter, ends with a lowercase letter or digit, and may
18/// contain lowercase letters, digits, or hyphens between them. Labels contain
19/// at most 63 bytes and the complete ID contains at most 253 bytes.
20pub fn validate_plugin_id_v1(plugin_id: &str) -> anyhow::Result<()> {
21    if plugin_id.len() > 253 {
22        bail!("Plugin id v1 must not exceed 253 bytes");
23    }
24    let labels = plugin_id.split('.').collect::<Vec<_>>();
25    if labels.len() < 2 || !labels.iter().all(valid_plugin_label) {
26        bail!(
27            "Plugin id v1 must contain at least two lowercase dot-separated labels; labels start with a letter, end with a letter or digit, and may contain hyphens"
28        );
29    }
30    Ok(())
31}
32
33/// Classifies an existing Plugin ID without silently breaking pre-v1 projects.
34pub fn classify_existing_plugin_id(plugin_id: &str) -> anyhow::Result<PluginIdVersion> {
35    if validate_plugin_id_v1(plugin_id).is_ok() {
36        return Ok(PluginIdVersion::V1);
37    }
38    if plugin_id.is_empty()
39        || plugin_id.contains('.')
40        || !plugin_id
41            .bytes()
42            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || b".-".contains(&byte))
43        || !plugin_id
44            .bytes()
45            .next()
46            .is_some_and(|byte| byte.is_ascii_lowercase())
47    {
48        bail!("Plugin id is neither a canonical v1 identity nor a supported legacy identity");
49    }
50    Ok(PluginIdVersion::Legacy)
51}
52
53/// Validates one exact Semantic Version rather than a range or tag.
54pub fn validate_release_version(version: &str) -> anyhow::Result<()> {
55    semver::Version::parse(version)
56        .map(|_| ())
57        .map_err(|error| {
58            anyhow::anyhow!("Release version must be an exact Semantic Version: {error}")
59        })
60}
61
62fn valid_plugin_label(label: &&str) -> bool {
63    let bytes = label.as_bytes();
64    (1..=63).contains(&bytes.len())
65        && bytes[0].is_ascii_lowercase()
66        && bytes[bytes.len() - 1].is_ascii_alphanumeric()
67        && bytes
68            .iter()
69            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-')
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75
76    fn vectors() -> serde_json::Value {
77        serde_json::from_str(include_str!(
78            "../contracts/plugin-identity-v1.conformance.json"
79        ))
80        .unwrap()
81    }
82
83    #[test]
84    fn rust_validator_obeys_published_plugin_id_vectors() {
85        let vectors = vectors();
86        for value in vectors["pluginId"]["valid"].as_array().unwrap() {
87            let value = value.as_str().unwrap();
88            assert!(
89                validate_plugin_id_v1(value).is_ok(),
90                "expected valid: {value}"
91            );
92        }
93        for value in vectors["pluginId"]["invalid"].as_array().unwrap() {
94            let value = value.as_str().unwrap();
95            assert!(
96                validate_plugin_id_v1(value).is_err(),
97                "expected invalid: {value}"
98            );
99        }
100    }
101
102    #[test]
103    fn rust_validator_obeys_published_semver_vectors() {
104        let vectors = vectors();
105        for value in vectors["version"]["valid"].as_array().unwrap() {
106            let value = value.as_str().unwrap();
107            assert!(
108                validate_release_version(value).is_ok(),
109                "expected valid: {value}"
110            );
111        }
112        for value in vectors["version"]["invalid"].as_array().unwrap() {
113            let value = value.as_str().unwrap();
114            assert!(
115                validate_release_version(value).is_err(),
116                "expected invalid: {value}"
117            );
118        }
119    }
120
121    #[test]
122    fn existing_projects_can_be_opened_with_an_explicit_legacy_classification() {
123        assert_eq!(
124            classify_existing_plugin_id("uppercase").unwrap(),
125            PluginIdVersion::Legacy
126        );
127        assert_eq!(
128            classify_existing_plugin_id("company.uppercase").unwrap(),
129            PluginIdVersion::V1
130        );
131        assert_eq!(
132            classify_existing_plugin_id("uppercase-v2").unwrap(),
133            PluginIdVersion::Legacy
134        );
135        assert!(classify_existing_plugin_id("company..uppercase").is_err());
136    }
137}