sentry_release_parser/
parser.rs

1use std::{cmp::Ordering, fmt};
2
3use lazy_static::lazy_static;
4use regex::Regex;
5
6#[cfg(feature = "serde")]
7use serde::{
8    ser::{SerializeStruct, Serializer},
9    Serialize,
10};
11
12lazy_static! {
13    static ref RELEASE_REGEX: Regex = Regex::new(r#"^(@?[^@]+)@(.+?)$"#).unwrap();
14    static ref VERSION_REGEX: Regex = Regex::new(
15        r"(?x)
16        ^
17            (?P<major>[0-9][0-9]*)
18            (?:\.(?P<minor>[0-9][0-9]*))?
19            (?:\.(?P<patch>[0-9][0-9]*))?
20            (?:\.(?P<revision>[0-9][0-9]*))?
21            (?:
22                (?P<prerelease>
23                    (?:-|[a-z])
24                    (?:0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)?
25                    (?:\.(?:0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*))*)
26                )?
27            (?:\+(?P<build_code>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?
28        $
29        "
30    )
31    .unwrap();
32    static ref HEX_REGEX: Regex = Regex::new(r#"^[a-fA-F0-9]+$"#).unwrap();
33    // what can or cannot go through the API which is a limiting factor for
34    // releases and environments.
35    static ref VALID_API_ATTRIBUTE_REGEX: Regex = Regex::new(r"^[^\\/\r\n\t\x7f\x00-\x1f]*\z").unwrap();
36}
37
38/// An error indicating invalid versions.
39#[derive(Debug, Clone, PartialEq)]
40#[cfg_attr(feature = "serde", derive(Serialize))]
41pub struct InvalidVersion;
42
43impl std::error::Error for InvalidVersion {}
44
45impl fmt::Display for InvalidVersion {
46    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
47        write!(f, "invalid version")
48    }
49}
50
51/// An error indicating invalid releases.
52#[derive(Debug, Clone, PartialEq)]
53pub enum InvalidRelease {
54    /// The release name was too long
55    TooLong,
56    /// Release name is restricted
57    RestrictedName,
58    /// The release contained invalid characters
59    BadCharacters,
60}
61
62/// An error indicating invalid environment.
63#[derive(Debug, Clone, PartialEq)]
64pub enum InvalidEnvironment {
65    /// The environment name was too long
66    TooLong,
67    /// Environment name is restricted
68    RestrictedName,
69    /// The environment contained invalid characters
70    BadCharacters,
71}
72
73impl std::error::Error for InvalidRelease {}
74
75impl fmt::Display for InvalidRelease {
76    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
77        write!(
78            f,
79            "invalid release: {}",
80            match *self {
81                InvalidRelease::BadCharacters => "bad characters in release name",
82                InvalidRelease::RestrictedName => "restricted release name",
83                InvalidRelease::TooLong => "release name too long",
84            }
85        )
86    }
87}
88
89impl std::error::Error for InvalidEnvironment {}
90
91impl fmt::Display for InvalidEnvironment {
92    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
93        write!(
94            f,
95            "invalid environment: {}",
96            match *self {
97                InvalidEnvironment::BadCharacters => "bad characters in environment name",
98                InvalidEnvironment::RestrictedName => "restricted environment name",
99                InvalidEnvironment::TooLong => "environment name too long",
100            }
101        )
102    }
103}
104
105/// Represents a parsed version.
106#[derive(Debug, Clone)]
107pub struct Version<'a> {
108    raw: &'a str,
109    major: &'a str,
110    minor: &'a str,
111    patch: &'a str,
112    revision: &'a str,
113    pre: &'a str,
114    before_code: &'a str,
115    build_code: &'a str,
116    components: u8,
117}
118
119#[cfg(feature = "serde")]
120impl<'a> Serialize for Version<'a> {
121    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
122    where
123        S: Serializer,
124    {
125        let mut state = serializer.serialize_struct("Version", 5)?;
126        state.serialize_field("major", &self.major())?;
127        state.serialize_field("minor", &self.minor())?;
128        state.serialize_field("patch", &self.patch())?;
129        state.serialize_field("revision", &self.revision())?;
130        state.serialize_field("pre", &self.pre())?;
131        state.serialize_field("build_code", &self.build_code())?;
132        state.serialize_field("raw_short", &self.raw_short())?;
133        state.serialize_field("components", &self.components())?;
134        state.serialize_field("raw_quad", &self.raw_quad())?;
135        state.end()
136    }
137}
138
139fn is_build_hash(s: &str) -> bool {
140    match s.len() {
141        12 | 16 | 20 | 32 | 40 | 64 => HEX_REGEX.is_match(s),
142        _ => false,
143    }
144}
145
146impl<'a> Version<'a> {
147    /// Parses a version from a string.
148    pub fn parse(version: &'a str) -> Result<Version<'a>, InvalidVersion> {
149        let caps = if let Some(caps) = VERSION_REGEX.captures(version) {
150            caps
151        } else {
152            return Err(InvalidVersion);
153        };
154
155        let components = 1
156            + caps.get(2).map_or(0, |_| 1)
157            + caps.get(3).map_or(0, |_| 1)
158            + caps.get(4).map_or(0, |_| 1);
159
160        // this is a special case we don't want to capture with a regex.  If there is only one
161        // single version component and the pre-release marker does not start with a dash, we
162        // consider it.  This means 1.0a1 is okay, 1-a1 is as well, but 1a1 is not.
163        if components == 1 && caps.get(5).map_or(false, |x| !x.as_str().starts_with('-')) {
164            return Err(InvalidVersion);
165        }
166
167        let before_code = match caps.get(6) {
168            Some(cap) => &version[..cap.start() - 1],
169            None => version,
170        };
171
172        Ok(Version {
173            raw: version,
174            major: caps.get(1).map(|x| x.as_str()).unwrap_or_default(),
175            minor: caps.get(2).map(|x| x.as_str()).unwrap_or_default(),
176            patch: caps.get(3).map(|x| x.as_str()).unwrap_or_default(),
177            revision: caps.get(4).map(|x| x.as_str()).unwrap_or_default(),
178            pre: caps
179                .get(5)
180                .map(|x| {
181                    let mut pre = x.as_str();
182                    if pre.starts_with('-') {
183                        pre = &pre[1..];
184                    }
185                    pre
186                })
187                .unwrap_or(""),
188            before_code,
189            build_code: caps.get(6).map(|x| x.as_str()).unwrap_or(""),
190            components,
191        })
192    }
193
194    /// Converts the version into a semver.
195    ///
196    /// Requires the `semver` feature.
197    #[cfg(feature = "semver")]
198    pub fn as_semver(&self) -> semver::Version {
199        fn split(s: &str) -> Vec<semver::Identifier> {
200            s.split('.')
201                .map(|item| {
202                    if let Ok(val) = item.parse::<u64>() {
203                        semver::Identifier::Numeric(val)
204                    } else {
205                        semver::Identifier::AlphaNumeric(item.into())
206                    }
207                })
208                .collect()
209        }
210
211        semver::Version {
212            major: self.major(),
213            minor: self.minor(),
214            patch: self.patch(),
215            pre: split(self.pre),
216            build: split(self.build_code),
217        }
218    }
219
220    /// Returns the major version component.
221    pub fn major(&self) -> u64 {
222        self.major.parse().unwrap_or_default()
223    }
224
225    /// Returns the minor version component.
226    pub fn minor(&self) -> u64 {
227        self.minor.parse().unwrap_or_default()
228    }
229
230    /// Returns the patch level version component.
231    pub fn patch(&self) -> u64 {
232        self.patch.parse().unwrap_or_default()
233    }
234
235    /// Returns the revision level version component.
236    pub fn revision(&self) -> u64 {
237        self.revision.parse().unwrap_or_default()
238    }
239
240    /// If a pre-release identifier is included returns that.
241    pub fn pre(&self) -> Option<&'a str> {
242        if self.pre.is_empty() {
243            None
244        } else {
245            Some(self.pre)
246        }
247    }
248
249    /// If a build code is included returns that.
250    pub fn build_code(&self) -> Option<&'a str> {
251        if self.build_code.is_empty() {
252            None
253        } else {
254            Some(self.build_code)
255        }
256    }
257
258    /// Returns the build code as build number.
259    pub fn build_number(&self) -> Option<u64> {
260        self.build_code().and_then(|val| val.parse().ok())
261    }
262
263    /// Returns the number of components.
264    pub fn components(&self) -> u8 {
265        self.components
266    }
267
268    /// Returns the raw version as string.
269    ///
270    /// It's generally better to use `to_string` which normalizes.
271    pub fn raw(&self) -> &'a str {
272        self.raw
273    }
274
275    /// Returns the part of the version raw before the build code.
276    ///
277    /// This is useful as the system can mis-parse some versions and
278    /// instead of formatting out the version from the parts, this can
279    /// be used to format out the version part as it was input by the
280    /// user but still abbreviate the build code.
281    pub fn raw_short(&self) -> &'a str {
282        self.before_code
283    }
284
285    /// Returns the version triple (major, minor, patch)
286    pub fn triple(&self) -> (u64, u64, u64) {
287        (self.major(), self.minor(), self.patch())
288    }
289
290    /// Returns the version quadruple.
291    pub fn quad(&self) -> (u64, u64, u64, u64) {
292        (self.major(), self.minor(), self.patch(), self.revision())
293    }
294
295    /// Returns the version quadruple as raw strings.
296    pub fn raw_quad(&self) -> (&'a str, Option<&'a str>, Option<&'a str>, Option<&'a str>) {
297        (
298            self.major,
299            (self.components > 1).then_some(self.minor),
300            (self.components > 2).then_some(self.patch),
301            (self.components > 3).then_some(self.revision),
302        )
303    }
304}
305
306impl<'a> Ord for Version<'a> {
307    fn cmp(&self, other: &Self) -> Ordering {
308        match self.quad().cmp(&other.quad()) {
309            Ordering::Equal => {
310                // handle pre-releases first
311                match (self.pre(), other.pre()) {
312                    (None, Some(_)) => return Ordering::Greater,
313                    (Some(_), None) => return Ordering::Less,
314                    (Some(self_pre), Some(other_pre)) => {
315                        match self_pre.cmp(other_pre) {
316                            Ordering::Equal => {}
317                            other => return other,
318                        };
319                    }
320                    (None, None) => {}
321                }
322
323                // if we have build numbers, compare them
324                if let (Some(self_num), Some(other_num)) =
325                    (self.build_number(), other.build_number())
326                {
327                    return self_num.cmp(&other_num);
328                }
329
330                // lastly compare build code lexicographically
331                self.build_code().cmp(&other.build_code())
332            }
333            other => other,
334        }
335    }
336}
337
338impl<'a> PartialOrd for Version<'a> {
339    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
340        Some(self.cmp(other))
341    }
342}
343
344impl<'a> PartialEq for Version<'a> {
345    fn eq(&self, other: &Self) -> bool {
346        self.quad() == other.quad()
347            && self.pre() == other.pre()
348            && self.build_code() == other.build_code()
349    }
350}
351
352impl<'a> Eq for Version<'a> {}
353
354impl<'a> fmt::Display for Version<'a> {
355    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
356        write!(f, "{}", self.raw)?;
357        Ok(())
358    }
359}
360
361/// Represents a parsed release.
362#[derive(Debug, Clone, PartialEq)]
363pub struct Release<'a> {
364    raw: &'a str,
365    package: &'a str,
366    version_raw: &'a str,
367    version: Option<Version<'a>>,
368}
369
370#[cfg(feature = "serde")]
371impl<'a> Serialize for Release<'a> {
372    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
373    where
374        S: Serializer,
375    {
376        let mut state = serializer.serialize_struct("Release", 6)?;
377        state.serialize_field("package", &self.package())?;
378        state.serialize_field("version_raw", &self.version_raw())?;
379        state.serialize_field("version_parsed", &self.version())?;
380        state.serialize_field("build_hash", &self.build_hash())?;
381        state.serialize_field("description", &self.describe().to_string())?;
382        state.end()
383    }
384}
385
386/// Given a string checks if the release is generally valid.
387pub fn validate_release(release: &str) -> Result<(), InvalidRelease> {
388    if release.len() > 200 {
389        Err(InvalidRelease::TooLong)
390    } else if release == "." || release == ".." || release.eq_ignore_ascii_case("latest") {
391        Err(InvalidRelease::RestrictedName)
392    } else if !VALID_API_ATTRIBUTE_REGEX.is_match(release) {
393        Err(InvalidRelease::BadCharacters)
394    } else {
395        Ok(())
396    }
397}
398
399/// Given a string checks if the environment name is generally valid.
400pub fn validate_environment(environment: &str) -> Result<(), InvalidEnvironment> {
401    if environment.len() > 64 {
402        Err(InvalidEnvironment::TooLong)
403    } else if environment == "." || environment == ".." || environment.eq_ignore_ascii_case("none")
404    {
405        Err(InvalidEnvironment::RestrictedName)
406    } else if !VALID_API_ATTRIBUTE_REGEX.is_match(environment) {
407        Err(InvalidEnvironment::BadCharacters)
408    } else {
409        Ok(())
410    }
411}
412
413impl<'a> Release<'a> {
414    /// Parses a release from a string.
415    pub fn parse(release: &'a str) -> Result<Release<'a>, InvalidRelease> {
416        let release = release.trim();
417        validate_release(release)?;
418        if let Some(caps) = RELEASE_REGEX.captures(release) {
419            let package = caps.get(1).unwrap().as_str();
420            let version_raw = caps.get(2).unwrap().as_str();
421            if !is_build_hash(version_raw) {
422                let version = Version::parse(version_raw).ok();
423                return Ok(Release {
424                    raw: release,
425                    package,
426                    version_raw,
427                    version,
428                });
429            } else {
430                return Ok(Release {
431                    raw: release,
432                    package,
433                    version_raw,
434                    version: None,
435                });
436            }
437        }
438        Ok(Release {
439            raw: release,
440            package: "",
441            version_raw: release,
442            version: None,
443        })
444    }
445
446    /// Returns the raw version.
447    ///
448    /// It's generally better to use `to_string` which normalizes.
449    pub fn raw(&self) -> &'a str {
450        self.raw
451    }
452
453    /// Returns the contained package information.
454    pub fn package(&self) -> Option<&'a str> {
455        if self.package.is_empty() {
456            None
457        } else {
458            Some(self.package)
459        }
460    }
461
462    /// The raw version part of the release.
463    ///
464    /// This is set even if the version part is not a valid version
465    /// (for instance because it's a hash).
466    pub fn version_raw(&self) -> &'a str {
467        self.version_raw
468    }
469
470    /// If a parsed version if available returns it.
471    pub fn version(&self) -> Option<&Version<'a>> {
472        self.version.as_ref()
473    }
474
475    /// Returns the build hash if available.
476    pub fn build_hash(&self) -> Option<&'a str> {
477        self.version
478            .as_ref()
479            .and_then(|x| x.build_code())
480            .filter(|x| is_build_hash(x))
481            .or_else(|| {
482                if is_build_hash(self.version_raw()) {
483                    Some(self.version_raw())
484                } else {
485                    None
486                }
487            })
488    }
489
490    /// Returns a short description.
491    ///
492    /// This returns a human readable format that includes an abbreviated
493    /// name of the release.  Typically it will remove the package and it
494    /// will try to abbreviate build hashes etc.
495    pub fn describe(&self) -> ReleaseDescription<'_> {
496        ReleaseDescription(self)
497    }
498}
499
500/// Helper object to format a release into a description.
501#[derive(Debug)]
502pub struct ReleaseDescription<'a>(&'a Release<'a>);
503
504impl<'a> fmt::Display for ReleaseDescription<'a> {
505    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
506        let short_hash = self
507            .0
508            .build_hash()
509            .map(|hash| hash.get(..12).unwrap_or(hash));
510
511        if let Some(ver) = self.0.version() {
512            write!(f, "{}", ver.raw_short())?;
513            if let Some(short_hash) = short_hash {
514                write!(f, " ({})", short_hash)?;
515            } else if let Some(build_code) = ver.build_code() {
516                write!(f, " ({})", build_code)?;
517            }
518        } else if let Some(short_hash) = short_hash {
519            write!(f, "{}", short_hash)?;
520        } else {
521            write!(f, "{}", self.0)?;
522        }
523        Ok(())
524    }
525}
526
527impl<'a> fmt::Display for Release<'a> {
528    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
529        let mut have_package = false;
530        if let Some(package) = self.package() {
531            write!(f, "{}", package)?;
532            have_package = true;
533        }
534        if let Some(version) = self.version() {
535            if have_package {
536                write!(f, "@")?;
537            }
538            write!(f, "{}", version)?;
539        } else {
540            if have_package {
541                write!(f, "@")?;
542            }
543            write!(f, "{}", self.version_raw)?;
544        }
545        Ok(())
546    }
547}
548
549#[test]
550fn test_release_validation() {
551    assert_eq!(
552        validate_release("latest"),
553        Err(InvalidRelease::RestrictedName)
554    );
555    assert_eq!(validate_release("."), Err(InvalidRelease::RestrictedName));
556    assert_eq!(validate_release(".."), Err(InvalidRelease::RestrictedName));
557    assert_eq!(
558        validate_release("foo\nbar"),
559        Err(InvalidRelease::BadCharacters)
560    );
561    assert_eq!(validate_release("good"), Ok(()));
562}
563
564#[test]
565fn test_environment_validation() {
566    assert_eq!(
567        validate_environment("none"),
568        Err(InvalidEnvironment::RestrictedName)
569    );
570    assert_eq!(
571        validate_environment("."),
572        Err(InvalidEnvironment::RestrictedName)
573    );
574    assert_eq!(
575        validate_environment(".."),
576        Err(InvalidEnvironment::RestrictedName)
577    );
578    assert_eq!(
579        validate_environment("f4f3db928593f258e1d850997be07b577f0779cc5549f9968bae625ea001175bX"),
580        Err(InvalidEnvironment::TooLong)
581    );
582    assert_eq!(
583        validate_environment("foo\nbar"),
584        Err(InvalidEnvironment::BadCharacters)
585    );
586    assert_eq!(validate_environment("good"), Ok(()));
587}
588
589#[test]
590fn test_version_ordering() {
591    macro_rules! ver {
592        ($v:expr) => {
593            Version::parse($v).unwrap()
594        };
595    }
596
597    assert_eq!(ver!("1.0.0"), ver!("1.0.0"));
598    assert_ne!(ver!("1.1.0"), ver!("1.0.0"));
599    assert_eq!(ver!("1.0"), ver!("1.0.0"));
600    assert_eq!(ver!("1.0dev"), ver!("1.0.0-dev"));
601    assert_eq!(ver!("1.0dev"), ver!("1.0.0.0-dev"));
602    assert_ne!(ver!("1.0dev"), ver!("1.0.0.0-dev1"));
603
604    assert!(ver!("1.0dev") >= ver!("1.0.0.0-dev"));
605    assert!(ver!("1.0dev") < ver!("1.0.0.0"));
606
607    assert!(ver!("1.0.0") > ver!("1.0.0-rc1"));
608    assert!(ver!("1.0.0") >= ver!("1.0.0-rc1"));
609    assert!(ver!("1.0.0-rc1") > ver!("0.9"));
610    assert!(ver!("1.0.0+10") < ver!("1.0.0+20"));
611    assert!(ver!("1.0.0+a") < ver!("1.0.0+b"));
612
613    assert!(ver!("1.0") < ver!("2.0"));
614    assert!(ver!("1.1.0") < ver!("10.0"));
615    assert!(ver!("1.1.0") < ver!("1.1.1"));
616    assert!(ver!("1.1.0.1") < ver!("1.1.0.2"));
617    assert!(ver!("1.1.0.1") > ver!("1.1.0.0"));
618    assert!(ver!("1.1.0.1") > ver!("1.0.0.0"));
619    assert!(ver!("1.1.0.1") > ver!("1.0.42.0"));
620
621    assert!(ver!("1.0+abcd") < ver!("1.0+abcde"));
622}