1use std::fmt;
10
11#[cfg(any(test, feature = "hdf5"))]
12use crate::error::{NirError, Result};
13
14#[derive(Debug, Clone, PartialEq, Eq, Default)]
36#[non_exhaustive]
37pub enum VersionPolicy {
38 #[default]
42 Permissive,
43 RequirePresent,
46 CompatibleMajor {
65 majors: Vec<u64>,
67 },
68}
69
70impl VersionPolicy {
71 #[must_use]
77 pub fn compatible_major(majors: impl IntoIterator<Item = u64>) -> Self {
78 let mut majors: Vec<u64> = majors.into_iter().collect();
79 majors.sort_unstable();
80 majors.dedup();
81 Self::CompatibleMajor { majors }
82 }
83}
84
85impl fmt::Display for VersionPolicy {
86 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87 match self {
88 Self::Permissive => f.write_str("permissive"),
89 Self::RequirePresent => f.write_str("require-present"),
90 Self::CompatibleMajor { majors } => {
91 write!(f, "compatible-major majors=[")?;
92 for (i, major) in majors.iter().enumerate() {
93 if i > 0 {
94 write!(f, ", ")?;
95 }
96 write!(f, "{major}")?;
97 }
98 write!(f, "]")
99 }
100 }
101 }
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106#[cfg(any(test, feature = "hdf5"))]
107pub(super) struct ParsedVersion {
108 pub major: u64,
109 pub minor: u64,
110 pub patch: u64,
111}
112
113#[cfg(any(test, feature = "hdf5"))]
115pub(super) fn parse_nir_version(raw: &str) -> Option<ParsedVersion> {
116 let (core_and_pre, build) = match raw.split_once('+') {
117 Some((left, right)) => (left, Some(right)),
118 None => (raw, None),
119 };
120 if let Some(build) = build
121 && !is_valid_build_metadata(build)
122 {
123 return None;
124 }
125 let (core, pre) = match core_and_pre.split_once('-') {
126 Some((left, right)) => (left, Some(right)),
127 None => (core_and_pre, None),
128 };
129 if let Some(pre) = pre
130 && !is_valid_prerelease(pre)
131 {
132 return None;
133 }
134 let mut parts = core.split('.');
135 let major = parse_component(parts.next()?)?;
136 let minor = parse_component(parts.next()?)?;
137 let patch = parse_component(parts.next()?)?;
138 if parts.next().is_some() {
139 return None;
140 }
141 Some(ParsedVersion {
142 major,
143 minor,
144 patch,
145 })
146}
147
148#[cfg(any(test, feature = "hdf5"))]
149fn parse_component(s: &str) -> Option<u64> {
150 if s.is_empty() {
151 return None;
152 }
153 if s.len() > 1 && s.starts_with('0') {
154 return None;
155 }
156 if !s.bytes().all(|b| b.is_ascii_digit()) {
157 return None;
158 }
159 s.parse().ok()
160}
161
162#[cfg(any(test, feature = "hdf5"))]
163fn is_valid_build_metadata(s: &str) -> bool {
164 if s.is_empty() {
165 return false;
166 }
167 s.split('.').all(|ident| {
168 !ident.is_empty()
169 && ident
170 .bytes()
171 .all(|b| b.is_ascii_alphanumeric() || b == b'-')
172 })
173}
174
175#[cfg(any(test, feature = "hdf5"))]
176fn is_valid_prerelease(s: &str) -> bool {
177 if s.is_empty() {
178 return false;
179 }
180 s.split('.').all(|ident| {
181 if ident.is_empty() {
182 return false;
183 }
184 if !ident
185 .bytes()
186 .all(|b| b.is_ascii_alphanumeric() || b == b'-')
187 {
188 return false;
189 }
190 if ident.bytes().all(|b| b.is_ascii_digit()) && ident.len() > 1 && ident.starts_with('0') {
192 return false;
193 }
194 true
195 })
196}
197
198#[cfg(any(test, feature = "hdf5"))]
204pub(super) fn enforce_version_policy(observed: Option<&str>, policy: &VersionPolicy) -> Result<()> {
205 match policy {
206 VersionPolicy::Permissive => Ok(()),
207 VersionPolicy::RequirePresent => {
208 if observed.is_none() {
209 Err(incompatible(None, policy))
210 } else {
211 Ok(())
212 }
213 }
214 VersionPolicy::CompatibleMajor { majors } => {
215 let Some(raw) = observed else {
216 return Err(incompatible(None, policy));
217 };
218 match parse_nir_version(raw) {
219 Some(parsed) if majors.contains(&parsed.major) => Ok(()),
220 _ => Err(incompatible(Some(raw.to_owned()), policy)),
221 }
222 }
223 }
224}
225
226#[cfg(any(test, feature = "hdf5"))]
227fn incompatible(observed: Option<String>, policy: &VersionPolicy) -> NirError {
228 NirError::IncompatibleVersion {
229 observed,
230 policy: policy.to_string(),
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 #[test]
239 fn compatible_major_canonicalizes_the_allow_list() {
240 let policy = VersionPolicy::compatible_major([1, 0, 1, 0]);
241 assert_eq!(
242 policy,
243 VersionPolicy::CompatibleMajor { majors: vec![0, 1] }
244 );
245 assert_eq!(policy.to_string(), "compatible-major majors=[0, 1]");
246 }
247
248 #[test]
249 fn parse_accepts_core_and_suffixes() {
250 assert_eq!(
251 parse_nir_version("0.1.1"),
252 Some(ParsedVersion {
253 major: 0,
254 minor: 1,
255 patch: 1
256 })
257 );
258 assert_eq!(
259 parse_nir_version("1.0.8"),
260 Some(ParsedVersion {
261 major: 1,
262 minor: 0,
263 patch: 8
264 })
265 );
266 let pre = parse_nir_version("1.0.0-rc.1").unwrap();
267 assert_eq!(pre.major, 1);
268 let build = parse_nir_version("1.0.0+exp.sha.5114f85").unwrap();
269 assert_eq!(build.major, 1);
270 let both = parse_nir_version("1.2.3-alpha.1+build.9").unwrap();
271 assert_eq!(
272 both,
273 ParsedVersion {
274 major: 1,
275 minor: 2,
276 patch: 3
277 }
278 );
279 assert!(parse_nir_version("0.2.0-beta-1").is_some());
282 }
283
284 #[test]
285 fn parse_rejects_malformed_strings() {
286 for raw in [
287 "",
288 "latest",
289 "1",
290 "1.0",
291 "v1.0.0",
292 "01.0.0",
293 "1.0.0.0",
294 "1.0.0-",
295 "1.0.0-01",
296 "1.0.0-rc.01",
297 "1.0.0+",
298 "1.0.0-+build",
299 "1.0.0-rc.",
300 "-1.0.0",
301 ] {
302 assert!(
303 parse_nir_version(raw).is_none(),
304 "{raw:?} should be malformed"
305 );
306 }
307 }
308
309 #[test]
310 fn permissive_never_rejects() {
311 let policy = VersionPolicy::Permissive;
312 enforce_version_policy(None, &policy).unwrap();
313 enforce_version_policy(Some("not-a-semver"), &policy).unwrap();
314 }
315
316 #[test]
317 fn require_present_rejects_only_absence() {
318 let policy = VersionPolicy::RequirePresent;
319 match enforce_version_policy(None, &policy).unwrap_err() {
320 NirError::IncompatibleVersion { observed, policy } => {
321 assert_eq!(observed, None);
322 assert_eq!(policy, "require-present");
323 }
324 other => panic!("unexpected {other:?}"),
325 }
326 enforce_version_policy(Some("not-a-semver"), &policy).unwrap();
327 }
328
329 #[test]
330 fn compatible_major_covers_missing_malformed_and_majors() {
331 let policy = VersionPolicy::compatible_major([0, 1]);
332 assert!(enforce_version_policy(Some("0.1.1"), &policy).is_ok());
333 assert!(enforce_version_policy(Some("0.2.0"), &policy).is_ok());
334 assert!(enforce_version_policy(Some("1.0.8"), &policy).is_ok());
335 assert!(enforce_version_policy(Some("1.0.0-rc.1"), &policy).is_ok());
336 assert!(enforce_version_policy(Some("1.0.0+build"), &policy).is_ok());
337
338 for (raw, expect_observed) in [
339 (None, None),
340 (Some("2.0.0"), Some("2.0.0")),
341 (Some("not-a-semver"), Some("not-a-semver")),
342 (Some("v1.0.0"), Some("v1.0.0")),
343 ] {
344 match enforce_version_policy(raw, &policy).unwrap_err() {
345 NirError::IncompatibleVersion { observed, policy } => {
346 assert_eq!(observed.as_deref(), expect_observed);
347 assert_eq!(policy, "compatible-major majors=[0, 1]");
348 }
349 other => panic!("unexpected {other:?} for {raw:?}"),
350 }
351 }
352 }
353
354 #[test]
355 fn empty_major_list_is_fail_closed() {
356 let policy = VersionPolicy::compatible_major([]);
357 match enforce_version_policy(Some("1.0.8"), &policy).unwrap_err() {
358 NirError::IncompatibleVersion { observed, policy } => {
359 assert_eq!(observed.as_deref(), Some("1.0.8"));
360 assert_eq!(policy, "compatible-major majors=[]");
361 }
362 other => panic!("unexpected {other:?}"),
363 }
364 }
365}