Skip to main content

nginx_lint_common/
nginx_version.rs

1//! nginx version parsing and comparison.
2//!
3//! Supports the canonical `major.minor.patch` form (e.g. `"1.30.1"`).
4//! Used by the linter to filter rules whose declared
5//! [`min_nginx_version`](crate::linter::LintRule::min_nginx_version) /
6//! [`max_nginx_version`](crate::linter::LintRule::max_nginx_version) range
7//! does not include the user-configured
8//! [`target_nginx_version`](crate::config::LintConfig).
9
10use std::cmp::Ordering;
11use std::fmt;
12use std::str::FromStr;
13
14/// A parsed nginx version triple (`major.minor.patch`).
15///
16/// nginx releases follow `major.minor.patch` (e.g. `1.30.1`); this struct
17/// stores those three components and orders them lexicographically.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
19pub struct NginxVersion {
20    pub major: u32,
21    pub minor: u32,
22    pub patch: u32,
23}
24
25impl NginxVersion {
26    /// Construct a version from its three components.
27    pub const fn new(major: u32, minor: u32, patch: u32) -> Self {
28        Self {
29            major,
30            minor,
31            patch,
32        }
33    }
34
35    /// Parse a version string like `"1.30.1"`.
36    pub fn parse(s: &str) -> Result<Self, NginxVersionParseError> {
37        let parts: Vec<&str> = s.split('.').collect();
38        if parts.len() != 3 {
39            return Err(NginxVersionParseError::InvalidFormat(s.to_string()));
40        }
41        let major = parts[0]
42            .parse::<u32>()
43            .map_err(|_| NginxVersionParseError::InvalidComponent(s.to_string()))?;
44        let minor = parts[1]
45            .parse::<u32>()
46            .map_err(|_| NginxVersionParseError::InvalidComponent(s.to_string()))?;
47        let patch = parts[2]
48            .parse::<u32>()
49            .map_err(|_| NginxVersionParseError::InvalidComponent(s.to_string()))?;
50        Ok(Self {
51            major,
52            minor,
53            patch,
54        })
55    }
56}
57
58impl Ord for NginxVersion {
59    fn cmp(&self, other: &Self) -> Ordering {
60        (self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch))
61    }
62}
63
64impl PartialOrd for NginxVersion {
65    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
66        Some(self.cmp(other))
67    }
68}
69
70impl fmt::Display for NginxVersion {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
73    }
74}
75
76impl FromStr for NginxVersion {
77    type Err = NginxVersionParseError;
78
79    fn from_str(s: &str) -> Result<Self, Self::Err> {
80        Self::parse(s)
81    }
82}
83
84/// Returns true when `version` falls within the optional inclusive bounds.
85///
86/// A `None` bound means "unbounded" on that side.
87///
88/// # Panics
89///
90/// In debug builds, panics if both bounds are supplied and `min > max`
91/// (i.e. the range is empty). A reversed range is almost always a plugin
92/// author mistake; failing fast in tests catches the bug at the source
93/// rather than silently filtering every rule out at runtime.
94pub fn is_in_range(
95    version: &NginxVersion,
96    min: Option<&NginxVersion>,
97    max: Option<&NginxVersion>,
98) -> bool {
99    if let (Some(min), Some(max)) = (min, max) {
100        debug_assert!(
101            min <= max,
102            "is_in_range called with reversed bounds: min={} > max={}",
103            min,
104            max
105        );
106    }
107    if let Some(min) = min
108        && version < min
109    {
110        return false;
111    }
112    if let Some(max) = max
113        && version > max
114    {
115        return false;
116    }
117    true
118}
119
120/// Format an optional `(min, max)` nginx version pair as a human-readable
121/// range. Returns `None` when both bounds are unset.
122///
123/// Uses `>=` / `<=` comparison-operator notation rather than Rust's `..=`
124/// inclusive-range syntax — most nginx-lint users are not Rust developers
125/// and `>=`/`<=` is the same notation npm, pip, and similar tools use for
126/// version constraints.
127///
128/// # Examples
129///
130/// ```
131/// use nginx_lint_common::nginx_version::format_range;
132///
133/// assert_eq!(
134///     format_range(Some("0.6.27"), Some("1.30.0")),
135///     Some("nginx >=0.6.27, <=1.30.0".to_string())
136/// );
137/// assert_eq!(format_range(Some("1.0.0"), None), Some("nginx >=1.0.0".to_string()));
138/// assert_eq!(format_range(None, Some("1.30.0")), Some("nginx <=1.30.0".to_string()));
139/// assert_eq!(format_range(None, None), None);
140/// ```
141pub fn format_range(min: Option<&str>, max: Option<&str>) -> Option<String> {
142    match (min, max) {
143        (Some(min), Some(max)) => Some(format!("nginx >={}, <={}", min, max)),
144        (Some(min), None) => Some(format!("nginx >={}", min)),
145        (None, Some(max)) => Some(format!("nginx <={}", max)),
146        (None, None) => None,
147    }
148}
149
150/// Error returned by [`NginxVersion::parse`].
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub enum NginxVersionParseError {
153    /// The string did not have the expected three dot-separated components.
154    InvalidFormat(String),
155    /// One of the three components was not a valid `u32`.
156    InvalidComponent(String),
157}
158
159impl fmt::Display for NginxVersionParseError {
160    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161        match self {
162            Self::InvalidFormat(s) => write!(
163                f,
164                "invalid nginx version '{}': expected major.minor.patch format",
165                s
166            ),
167            Self::InvalidComponent(s) => write!(
168                f,
169                "invalid nginx version '{}': components must be non-negative integers",
170                s
171            ),
172        }
173    }
174}
175
176impl std::error::Error for NginxVersionParseError {}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn parse_valid_versions() {
184        assert_eq!(
185            NginxVersion::parse("1.30.0").unwrap(),
186            NginxVersion::new(1, 30, 0)
187        );
188        assert_eq!(
189            NginxVersion::parse("0.0.1").unwrap(),
190            NginxVersion::new(0, 0, 1)
191        );
192        assert_eq!(
193            NginxVersion::parse("10.20.30").unwrap(),
194            NginxVersion::new(10, 20, 30)
195        );
196    }
197
198    #[test]
199    fn parse_rejects_two_components() {
200        assert!(matches!(
201            NginxVersion::parse("1.30"),
202            Err(NginxVersionParseError::InvalidFormat(_))
203        ));
204    }
205
206    #[test]
207    fn parse_rejects_four_components() {
208        assert!(matches!(
209            NginxVersion::parse("1.30.0.1"),
210            Err(NginxVersionParseError::InvalidFormat(_))
211        ));
212    }
213
214    #[test]
215    fn parse_rejects_v_prefix() {
216        assert!(matches!(
217            NginxVersion::parse("v1.30.0"),
218            Err(NginxVersionParseError::InvalidComponent(_))
219        ));
220    }
221
222    #[test]
223    fn parse_rejects_non_numeric_component() {
224        assert!(matches!(
225            NginxVersion::parse("1.30.x"),
226            Err(NginxVersionParseError::InvalidComponent(_))
227        ));
228        assert!(matches!(
229            NginxVersion::parse("1.a.0"),
230            Err(NginxVersionParseError::InvalidComponent(_))
231        ));
232    }
233
234    #[test]
235    fn parse_rejects_empty() {
236        assert!(NginxVersion::parse("").is_err());
237    }
238
239    #[test]
240    fn ordering() {
241        let v_1_30_0 = NginxVersion::new(1, 30, 0);
242        let v_1_30_1 = NginxVersion::new(1, 30, 1);
243        let v_1_31_0 = NginxVersion::new(1, 31, 0);
244        let v_2_0_0 = NginxVersion::new(2, 0, 0);
245        assert!(v_1_30_0 < v_1_30_1);
246        assert!(v_1_30_1 < v_1_31_0);
247        assert!(v_1_31_0 < v_2_0_0);
248        assert_eq!(v_1_30_0, NginxVersion::new(1, 30, 0));
249    }
250
251    #[test]
252    fn display() {
253        assert_eq!(NginxVersion::new(1, 30, 1).to_string(), "1.30.1");
254    }
255
256    #[test]
257    fn range_unbounded() {
258        let v = NginxVersion::new(1, 30, 0);
259        assert!(is_in_range(&v, None, None));
260    }
261
262    #[test]
263    fn range_min_only() {
264        let min = NginxVersion::new(1, 0, 0);
265        assert!(is_in_range(&NginxVersion::new(1, 0, 0), Some(&min), None));
266        assert!(is_in_range(&NginxVersion::new(2, 0, 0), Some(&min), None));
267        assert!(!is_in_range(&NginxVersion::new(0, 9, 0), Some(&min), None));
268    }
269
270    #[test]
271    fn range_max_only() {
272        let max = NginxVersion::new(1, 30, 0);
273        assert!(is_in_range(&NginxVersion::new(1, 30, 0), None, Some(&max)));
274        assert!(is_in_range(&NginxVersion::new(1, 0, 0), None, Some(&max)));
275        assert!(!is_in_range(&NginxVersion::new(1, 30, 1), None, Some(&max)));
276        assert!(!is_in_range(&NginxVersion::new(1, 31, 0), None, Some(&max)));
277    }
278
279    #[test]
280    fn range_both_bounds_inclusive() {
281        let min = NginxVersion::new(0, 6, 27);
282        let max = NginxVersion::new(1, 30, 0);
283        assert!(is_in_range(
284            &NginxVersion::new(0, 6, 27),
285            Some(&min),
286            Some(&max)
287        ));
288        assert!(is_in_range(
289            &NginxVersion::new(1, 30, 0),
290            Some(&min),
291            Some(&max)
292        ));
293        assert!(is_in_range(
294            &NginxVersion::new(1, 0, 0),
295            Some(&min),
296            Some(&max)
297        ));
298        assert!(!is_in_range(
299            &NginxVersion::new(0, 6, 26),
300            Some(&min),
301            Some(&max)
302        ));
303        assert!(!is_in_range(
304            &NginxVersion::new(1, 30, 1),
305            Some(&min),
306            Some(&max)
307        ));
308    }
309
310    #[test]
311    fn from_str_works() {
312        let v: NginxVersion = "1.30.1".parse().unwrap();
313        assert_eq!(v, NginxVersion::new(1, 30, 1));
314    }
315
316    #[test]
317    #[should_panic(expected = "reversed bounds")]
318    fn range_panics_on_reversed_bounds_in_debug() {
319        // Plugin author mistakes (e.g. swapping min/max) would silently
320        // filter every version out at runtime; debug_assert! catches it
321        // in tests instead. Only fires in debug builds.
322        let min = NginxVersion::new(2, 0, 0);
323        let max = NginxVersion::new(1, 0, 0);
324        let v = NginxVersion::new(1, 5, 0);
325        is_in_range(&v, Some(&min), Some(&max));
326    }
327
328    #[test]
329    fn range_equal_bounds_is_allowed() {
330        // min == max is a valid single-point range, not a mistake.
331        let exact = NginxVersion::new(1, 30, 0);
332        assert!(is_in_range(
333            &NginxVersion::new(1, 30, 0),
334            Some(&exact),
335            Some(&exact)
336        ));
337        assert!(!is_in_range(
338            &NginxVersion::new(1, 30, 1),
339            Some(&exact),
340            Some(&exact)
341        ));
342    }
343
344    #[test]
345    fn format_range_both_bounds() {
346        assert_eq!(
347            format_range(Some("0.6.27"), Some("1.30.0")),
348            Some("nginx >=0.6.27, <=1.30.0".to_string())
349        );
350    }
351
352    #[test]
353    fn format_range_min_only() {
354        assert_eq!(
355            format_range(Some("1.0.0"), None),
356            Some("nginx >=1.0.0".to_string())
357        );
358    }
359
360    #[test]
361    fn format_range_max_only() {
362        assert_eq!(
363            format_range(None, Some("1.29.6")),
364            Some("nginx <=1.29.6".to_string())
365        );
366    }
367
368    #[test]
369    fn format_range_none() {
370        // Rules with no declared range produce no string at all so callers
371        // can use `if let Some(range) = ...` to skip the display section.
372        assert_eq!(format_range(None, None), None);
373    }
374}