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