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}