lenso_app_authoring/
identity.rs1use anyhow::bail;
4
5#[derive(Clone, Copy, Debug, Eq, PartialEq)]
7pub enum PluginIdVersion {
8 V1,
10 Legacy,
12}
13
14pub 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
33pub 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
53pub 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}