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, Hash, Ord, PartialEq, PartialOrd)]
125pub struct RegistryOrigin(String);
126
127impl RegistryOrigin {
128 pub fn as_str(&self) -> &str {
129 &self.0
130 }
131}
132
133impl FromStr for RegistryOrigin {
134 type Err = DomainError;
135
136 fn from_str(value: &str) -> Result<Self, Self::Err> {
137 let trimmed = value.trim_end_matches('/');
138 let valid = trimmed.starts_with("https://")
139 && trimmed.len() > "https://".len()
140 && !trimmed.contains(['@', '?', '#'])
141 && trimmed[8..]
142 .split('/')
143 .next()
144 .is_some_and(|host| !host.is_empty());
145 if !valid {
146 return Err(DomainError::InvalidRegistryOrigin(value.to_owned()));
147 }
148 Ok(Self(trimmed.to_owned()))
149 }
150}
151
152impl fmt::Display for RegistryOrigin {
153 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
154 self.0.fmt(f)
155 }
156}
157
158#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
159pub struct PackageInstanceId {
160 pub registry: RegistryOrigin,
161 pub name: PackageName,
162 pub version: PackageVersion,
163}
164
165impl PackageInstanceId {
166 pub fn new(registry: RegistryOrigin, name: PackageName, version: PackageVersion) -> Self {
167 Self {
168 registry,
169 name,
170 version,
171 }
172 }
173}
174
175#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
176pub struct PackageIntegrity(String);
177
178impl PackageIntegrity {
179 pub fn as_str(&self) -> &str {
180 &self.0
181 }
182}
183
184impl FromStr for PackageIntegrity {
185 type Err = DomainError;
186
187 fn from_str(value: &str) -> Result<Self, Self::Err> {
188 let Some(encoded) = value.strip_prefix("sha512-") else {
189 return Err(DomainError::InvalidPackageIntegrity(value.to_owned()));
190 };
191 let valid_length = encoded.len() == 86 || encoded.len() == 88;
192 let valid_characters = encoded
193 .chars()
194 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '='));
195 if !valid_length || !valid_characters || (encoded.contains('=') && !encoded.ends_with("=="))
196 {
197 return Err(DomainError::InvalidPackageIntegrity(value.to_owned()));
198 }
199 Ok(Self(value.to_owned()))
200 }
201}
202
203impl fmt::Display for PackageIntegrity {
204 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
205 self.0.fmt(f)
206 }
207}
208
209#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
210pub struct PeerContext(std::collections::BTreeMap<PackageName, PackageVersion>);
211
212impl PeerContext {
213 pub fn with(mut self, name: PackageName, version: PackageVersion) -> Self {
214 self.0.insert(name, version);
215 self
216 }
217 pub fn entries(&self) -> &std::collections::BTreeMap<PackageName, PackageVersion> {
218 &self.0
219 }
220}
221
222impl fmt::Display for PeerContext {
223 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
224 let mut first = true;
225 for (name, version) in &self.0 {
226 if !first {
227 f.write_str(",")?;
228 }
229 first = false;
230 write!(f, "{name}@{version}")?;
231 }
232 Ok(())
233 }
234}
235
236#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
237pub struct PlatformContext {
238 pub os: Option<String>,
239 pub cpu: Option<String>,
240 pub libc: Option<String>,
241}
242
243impl PlatformContext {
244 pub fn new(
245 os: Option<&str>,
246 cpu: Option<&str>,
247 libc: Option<&str>,
248 ) -> Result<Self, DomainError> {
249 let context = Self {
250 os: os.map(str::to_owned),
251 cpu: cpu.map(str::to_owned),
252 libc: libc.map(str::to_owned),
253 };
254 if [&context.os, &context.cpu, &context.libc]
255 .into_iter()
256 .flatten()
257 .any(|v| v.is_empty() || v.chars().any(char::is_whitespace))
258 {
259 return Err(DomainError::InvalidPlatformContext);
260 }
261 Ok(context)
262 }
263}
264
265impl fmt::Display for PlatformContext {
266 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
267 let values = [&self.os, &self.cpu, &self.libc];
268 let mut first = true;
269 for value in values.into_iter().flatten() {
270 if !first {
271 f.write_str("-")?;
272 }
273 first = false;
274 f.write_str(value)?;
275 }
276 Ok(())
277 }
278}
279
280#[derive(Clone, Debug, Eq, PartialEq)]
281pub enum DomainError {
282 InvalidPackageName(String),
283 InvalidPackageVersion(String),
284 InvalidArtifactDigest(String),
285 InvalidRegistryOrigin(String),
286 InvalidPackageIntegrity(String),
287 InvalidPlatformContext,
288}
289
290impl fmt::Display for DomainError {
291 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
292 match self {
293 Self::InvalidPackageName(value) => write!(f, "invalid package name: {value}"),
294 Self::InvalidPackageVersion(value) => write!(f, "invalid package version: {value}"),
295 Self::InvalidArtifactDigest(value) => write!(f, "invalid artifact digest: {value}"),
296 Self::InvalidRegistryOrigin(value) => write!(f, "invalid registry origin: {value}"),
297 Self::InvalidPackageIntegrity(value) => write!(f, "invalid package integrity: {value}"),
298 Self::InvalidPlatformContext => f.write_str("invalid platform context"),
299 }
300 }
301}
302
303impl std::error::Error for DomainError {}
304
305fn is_name_character(character: char) -> bool {
306 character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312
313 #[test]
314 fn accepts_scoped_and_unscoped_package_names() {
315 assert!("tapid".parse::<PackageName>().is_ok());
316 assert!("@tapid/core".parse::<PackageName>().is_ok());
317 }
318
319 #[test]
320 fn rejects_unsafe_package_names() {
321 for value in [
322 "",
323 "../tapid",
324 "@tapid",
325 "@tapid/core/extra",
326 "tap id",
327 "tapid/core",
328 ] {
329 assert!(value.parse::<PackageName>().is_err(), "accepted {value}");
330 }
331 }
332
333 #[test]
334 fn parses_canonical_versions() {
335 let version = "1.2.3".parse::<PackageVersion>().unwrap();
336 assert_eq!(version.to_string(), "1.2.3");
337 assert!("01.2.3".parse::<PackageVersion>().is_err());
338 }
339
340 #[test]
341 fn accepts_only_sha256_digests() {
342 let digest = format!("sha256-{}", "A".repeat(64))
343 .parse::<ArtifactDigest>()
344 .unwrap();
345 assert_eq!(digest.to_string(), format!("sha256-{}", "a".repeat(64)));
346 assert!("sha512-deadbeef".parse::<ArtifactDigest>().is_err());
347 }
348
349 #[test]
350 fn registry_origin_is_typed_and_canonical_without_secrets() {
351 let origin = "https://REGISTRY.example.test/"
352 .parse::<RegistryOrigin>()
353 .unwrap();
354 assert_eq!(origin.as_str(), "https://REGISTRY.example.test");
355 assert!(
356 "http://registry.example.test"
357 .parse::<RegistryOrigin>()
358 .is_err()
359 );
360 assert!(
361 "https://user:pass@registry.example.test"
362 .parse::<RegistryOrigin>()
363 .is_err()
364 );
365 }
366
367 #[test]
368 fn integrity_preserves_mixed_case_wire_encoding() {
369 let value = format!("sha512-{}", "AbCdEfGh".repeat(11));
370 let integrity = value.parse::<PackageIntegrity>().unwrap();
371 assert_eq!(integrity.to_string(), value);
372 }
373
374 #[test]
375 fn package_instance_identity_includes_registry() {
376 let name: PackageName = "tapid".parse().unwrap();
377 let version: PackageVersion = "1.0.0".parse().unwrap();
378 let first = PackageInstanceId::new(
379 "https://one.example".parse().unwrap(),
380 name.clone(),
381 version,
382 );
383 let second = PackageInstanceId::new("https://two.example".parse().unwrap(), name, version);
384 assert_ne!(first, second);
385 }
386
387 #[test]
388 fn contexts_have_deterministic_empty_and_nonempty_forms() {
389 let peer = PeerContext::default().with("react".parse().unwrap(), "18.2.0".parse().unwrap());
390 assert_eq!(peer.to_string(), "react@18.2.0");
391 let platform = PlatformContext::new(Some("linux"), Some("x86_64"), Some("gnu")).unwrap();
392 assert_eq!(platform.to_string(), "linux-x86_64-gnu");
393 }
394}