Skip to main content

nir_rs/io/
version.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! Opt-in `/version` compatibility policy for HDF5 reads.
4//!
5//! Default [`super::read`] stays permissive — missing or arbitrary version
6//! strings are stored verbatim, matching Python `nir.read`. Callers that need
7//! a fail-closed envelope check set [`VersionPolicy`] on [`super::ReadOptions`].
8
9use std::fmt;
10
11#[cfg(any(test, feature = "hdf5"))]
12use crate::error::{NirError, Result};
13
14/// How [`super::read_with`] treats the root `/version` dataset.
15///
16/// The default is [`Self::Permissive`]. Majors accepted by
17/// [`Self::CompatibleMajor`] are supplied by the caller; this crate does not
18/// keep a hidden compatibility matrix. Paper fixtures vendored under
19/// `tests/fixtures/` embed `0.1.1` / `0.2.0`, and [`super::DEFAULT_NIR_VERSION`]
20/// is `1.0.8` — a typical importer therefore passes `[0, 1]`.
21///
22/// ```
23/// use nir_rs::io::{ReadOptions, VersionPolicy};
24///
25/// let tool = ReadOptions::default();
26/// assert_eq!(tool.version_policy, VersionPolicy::Permissive);
27///
28/// let importer = ReadOptions::default()
29///     .with_version_policy(VersionPolicy::compatible_major([0, 1]));
30/// assert!(matches!(
31///     importer.version_policy,
32///     VersionPolicy::CompatibleMajor { .. }
33/// ));
34/// ```
35#[derive(Debug, Clone, PartialEq, Eq, Default)]
36#[non_exhaustive]
37pub enum VersionPolicy {
38    /// Accept a missing `/version` or any present string and store it verbatim.
39    ///
40    /// Matches Python `nir.read` and the default [`super::read`] entry point.
41    #[default]
42    Permissive,
43    /// Reject a missing `/version`. Any present string is stored verbatim
44    /// without SemVer parsing.
45    RequirePresent,
46    /// Parse `/version` as `MAJOR.MINOR.PATCH` with optional SemVer prerelease
47    /// (`-…`) and build (`+…`) suffixes, and accept only the listed majors.
48    ///
49    /// Construct with [`Self::compatible_major`] so the allow-list is explicit.
50    /// A missing `/version` is a policy failure.
51    ///
52    /// # Parsing
53    ///
54    /// The core must be exactly three decimal components. `1.0`, `v1.0.0`, and
55    /// `01.0.0` are malformed. Numeric components must not have leading zeros
56    /// (`0` itself is allowed).
57    ///
58    /// Prerelease and build suffixes are **parsed, then ignored for the major
59    /// check**: `1.0.0-rc.1+exp.sha` has major `1` and is accepted when `1` is
60    /// listed. They are not stripped from [`crate::NirGraph::version`] — the
61    /// original wire string is stored. An empty suffix (`1.0.0-` / `1.0.0+`)
62    /// is malformed. Identifiers may contain ASCII alphanumerics and `-`,
63    /// separated by `.`.
64    CompatibleMajor {
65        /// Accepted major numbers, for example `[0, 1]`.
66        majors: Vec<u64>,
67    },
68}
69
70impl VersionPolicy {
71    /// Accept `/version` strings whose SemVer major is one of `majors`.
72    ///
73    /// Duplicates are dropped and the list is sorted so [`std::fmt::Display`]
74    /// is stable.
75    /// An empty list rejects every well-formed version (fail-closed).
76    #[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/// Core `MAJOR.MINOR.PATCH` extracted from a SemVer-compatible `/version`.
105#[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/// Parse the NIR `/version` subset used by [`VersionPolicy::CompatibleMajor`].
114#[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        // Numeric identifiers MUST NOT include leading zeroes
191        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/// Apply `policy` to an already-decoded `/version` string (or its absence).
199///
200/// Shared by the full graph reader and [`super::read_version_with`] so the two
201/// cannot disagree about which strings a policy accepts. Dataset-shape errors
202/// (missing vs group vs string) are resolved before this is called.
203#[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        // Hyphens inside identifiers are allowed; the first `-` / `+` split
280        // still isolates the core.
281        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}