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, 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}