Skip to main content

tapid_core/
lib.rs

1use std::{fmt, str::FromStr};
2
3#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
4pub struct PackageName(String);
5
6impl PackageName {
7    pub fn as_str(&self) -> &str {
8        &self.0
9    }
10}
11
12impl FromStr for PackageName {
13    type Err = DomainError;
14
15    fn from_str(value: &str) -> Result<Self, Self::Err> {
16        if value.is_empty()
17            || value.len() > 214
18            || value.starts_with('.')
19            || value.starts_with('_')
20            || value.ends_with('.')
21            || value.ends_with('_')
22            || value.chars().any(char::is_whitespace)
23        {
24            return Err(DomainError::InvalidPackageName(value.to_owned()));
25        }
26
27        if value.starts_with('@') {
28            let mut parts = value.split('/');
29            let scope = parts.next().unwrap_or_default();
30            let name = parts.next().unwrap_or_default();
31            if parts.next().is_some()
32                || scope.len() < 2
33                || name.is_empty()
34                || scope[1..].chars().any(|c| !is_name_character(c))
35                || name.chars().any(|c| !is_name_character(c))
36            {
37                return Err(DomainError::InvalidPackageName(value.to_owned()));
38            }
39        } else if value.chars().any(|c| !is_name_character(c)) {
40            return Err(DomainError::InvalidPackageName(value.to_owned()));
41        }
42
43        Ok(Self(value.to_owned()))
44    }
45}
46
47impl fmt::Display for PackageName {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        self.0.fmt(f)
50    }
51}
52
53#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
54pub struct PackageVersion {
55    pub major: u64,
56    pub minor: u64,
57    pub patch: u64,
58}
59
60impl FromStr for PackageVersion {
61    type Err = DomainError;
62
63    fn from_str(value: &str) -> Result<Self, Self::Err> {
64        let mut parts = value.split('.');
65        let numbers = [parts.next(), parts.next(), parts.next()];
66        if parts.next().is_some() || numbers.iter().any(Option::is_none) {
67            return Err(DomainError::InvalidPackageVersion(value.to_owned()));
68        }
69
70        let [Some(major), Some(minor), Some(patch)] = numbers else {
71            unreachable!("checked above");
72        };
73        let parse = |part: &str| {
74            if part.is_empty() || (part.len() > 1 && part.starts_with('0')) {
75                return Err(DomainError::InvalidPackageVersion(value.to_owned()));
76            }
77            part.parse::<u64>()
78                .map_err(|_| DomainError::InvalidPackageVersion(value.to_owned()))
79        };
80
81        Ok(Self {
82            major: parse(major)?,
83            minor: parse(minor)?,
84            patch: parse(patch)?,
85        })
86    }
87}
88
89impl fmt::Display for PackageVersion {
90    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
92    }
93}
94
95#[derive(Clone, Debug, Eq, Hash, PartialEq)]
96pub struct ArtifactDigest(String);
97
98impl ArtifactDigest {
99    pub fn as_str(&self) -> &str {
100        &self.0
101    }
102}
103
104impl FromStr for ArtifactDigest {
105    type Err = DomainError;
106
107    fn from_str(value: &str) -> Result<Self, Self::Err> {
108        let Some(hex) = value.strip_prefix("sha256-") else {
109            return Err(DomainError::InvalidArtifactDigest(value.to_owned()));
110        };
111        if hex.len() != 64 || hex.chars().any(|c| !c.is_ascii_hexdigit()) {
112            return Err(DomainError::InvalidArtifactDigest(value.to_owned()));
113        }
114        Ok(Self(value.to_ascii_lowercase()))
115    }
116}
117
118impl fmt::Display for ArtifactDigest {
119    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120        self.0.fmt(f)
121    }
122}
123
124#[derive(Clone, Debug, Eq, PartialEq)]
125pub enum DomainError {
126    InvalidPackageName(String),
127    InvalidPackageVersion(String),
128    InvalidArtifactDigest(String),
129}
130
131impl fmt::Display for DomainError {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        match self {
134            Self::InvalidPackageName(value) => write!(f, "invalid package name: {value}"),
135            Self::InvalidPackageVersion(value) => write!(f, "invalid package version: {value}"),
136            Self::InvalidArtifactDigest(value) => write!(f, "invalid artifact digest: {value}"),
137        }
138    }
139}
140
141impl std::error::Error for DomainError {}
142
143fn is_name_character(character: char) -> bool {
144    character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn accepts_scoped_and_unscoped_package_names() {
153        assert!("tapid".parse::<PackageName>().is_ok());
154        assert!("@tapid/core".parse::<PackageName>().is_ok());
155    }
156
157    #[test]
158    fn rejects_unsafe_package_names() {
159        for value in [
160            "",
161            "../tapid",
162            "@tapid",
163            "@tapid/core/extra",
164            "tap id",
165            "tapid/core",
166        ] {
167            assert!(value.parse::<PackageName>().is_err(), "accepted {value}");
168        }
169    }
170
171    #[test]
172    fn parses_canonical_versions() {
173        let version = "1.2.3".parse::<PackageVersion>().unwrap();
174        assert_eq!(version.to_string(), "1.2.3");
175        assert!("01.2.3".parse::<PackageVersion>().is_err());
176    }
177
178    #[test]
179    fn accepts_only_sha256_digests() {
180        let digest = format!("sha256-{}", "A".repeat(64))
181            .parse::<ArtifactDigest>()
182            .unwrap();
183        assert_eq!(digest.to_string(), format!("sha256-{}", "a".repeat(64)));
184        assert!("sha512-deadbeef".parse::<ArtifactDigest>().is_err());
185    }
186}