use std::{fmt, str::FromStr};
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PackageName(String);
impl PackageName {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl FromStr for PackageName {
type Err = DomainError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
if value.is_empty()
|| value.len() > 214
|| value.starts_with('.')
|| value.starts_with('_')
|| value.ends_with('.')
|| value.ends_with('_')
|| value.chars().any(char::is_whitespace)
{
return Err(DomainError::InvalidPackageName(value.to_owned()));
}
if value.starts_with('@') {
let mut parts = value.split('/');
let scope = parts.next().unwrap_or_default();
let name = parts.next().unwrap_or_default();
if parts.next().is_some()
|| scope.len() < 2
|| name.is_empty()
|| scope[1..].chars().any(|c| !is_name_character(c))
|| name.chars().any(|c| !is_name_character(c))
{
return Err(DomainError::InvalidPackageName(value.to_owned()));
}
} else if value.chars().any(|c| !is_name_character(c)) {
return Err(DomainError::InvalidPackageName(value.to_owned()));
}
Ok(Self(value.to_owned()))
}
}
impl fmt::Display for PackageName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PackageVersion {
pub major: u64,
pub minor: u64,
pub patch: u64,
}
impl FromStr for PackageVersion {
type Err = DomainError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let mut parts = value.split('.');
let numbers = [parts.next(), parts.next(), parts.next()];
if parts.next().is_some() || numbers.iter().any(Option::is_none) {
return Err(DomainError::InvalidPackageVersion(value.to_owned()));
}
let [Some(major), Some(minor), Some(patch)] = numbers else {
unreachable!("checked above");
};
let parse = |part: &str| {
if part.is_empty() || (part.len() > 1 && part.starts_with('0')) {
return Err(DomainError::InvalidPackageVersion(value.to_owned()));
}
part.parse::<u64>()
.map_err(|_| DomainError::InvalidPackageVersion(value.to_owned()))
};
Ok(Self {
major: parse(major)?,
minor: parse(minor)?,
patch: parse(patch)?,
})
}
}
impl fmt::Display for PackageVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
}
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct ArtifactDigest(String);
impl ArtifactDigest {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl FromStr for ArtifactDigest {
type Err = DomainError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let Some(hex) = value.strip_prefix("sha256-") else {
return Err(DomainError::InvalidArtifactDigest(value.to_owned()));
};
if hex.len() != 64 || hex.chars().any(|c| !c.is_ascii_hexdigit()) {
return Err(DomainError::InvalidArtifactDigest(value.to_owned()));
}
Ok(Self(value.to_ascii_lowercase()))
}
}
impl fmt::Display for ArtifactDigest {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct RegistryOrigin(String);
impl RegistryOrigin {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl FromStr for RegistryOrigin {
type Err = DomainError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let trimmed = value.trim_end_matches('/');
let valid = trimmed.starts_with("https://")
&& trimmed.len() > "https://".len()
&& !trimmed.contains(['@', '?', '#'])
&& trimmed[8..]
.split('/')
.next()
.is_some_and(|host| !host.is_empty());
if !valid {
return Err(DomainError::InvalidRegistryOrigin(value.to_owned()));
}
Ok(Self(trimmed.to_owned()))
}
}
impl fmt::Display for RegistryOrigin {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PackageInstanceId {
pub registry: RegistryOrigin,
pub name: PackageName,
pub version: PackageVersion,
}
impl PackageInstanceId {
pub fn new(registry: RegistryOrigin, name: PackageName, version: PackageVersion) -> Self {
Self {
registry,
name,
version,
}
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PackageIntegrity(String);
impl PackageIntegrity {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl FromStr for PackageIntegrity {
type Err = DomainError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let Some(encoded) = value.strip_prefix("sha512-") else {
return Err(DomainError::InvalidPackageIntegrity(value.to_owned()));
};
let valid_length = encoded.len() == 86 || encoded.len() == 88;
let valid_characters = encoded
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '='));
if !valid_length || !valid_characters || (encoded.contains('=') && !encoded.ends_with("=="))
{
return Err(DomainError::InvalidPackageIntegrity(value.to_owned()));
}
Ok(Self(value.to_owned()))
}
}
impl fmt::Display for PackageIntegrity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PeerContext(std::collections::BTreeMap<PackageName, PackageVersion>);
impl PeerContext {
pub fn with(mut self, name: PackageName, version: PackageVersion) -> Self {
self.0.insert(name, version);
self
}
pub fn entries(&self) -> &std::collections::BTreeMap<PackageName, PackageVersion> {
&self.0
}
}
impl fmt::Display for PeerContext {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut first = true;
for (name, version) in &self.0 {
if !first {
f.write_str(",")?;
}
first = false;
write!(f, "{name}@{version}")?;
}
Ok(())
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PlatformContext {
pub os: Option<String>,
pub cpu: Option<String>,
pub libc: Option<String>,
}
impl PlatformContext {
pub fn new(
os: Option<&str>,
cpu: Option<&str>,
libc: Option<&str>,
) -> Result<Self, DomainError> {
let context = Self {
os: os.map(str::to_owned),
cpu: cpu.map(str::to_owned),
libc: libc.map(str::to_owned),
};
if [&context.os, &context.cpu, &context.libc]
.into_iter()
.flatten()
.any(|v| v.is_empty() || v.chars().any(char::is_whitespace))
{
return Err(DomainError::InvalidPlatformContext);
}
Ok(context)
}
}
impl fmt::Display for PlatformContext {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let values = [&self.os, &self.cpu, &self.libc];
let mut first = true;
for value in values.into_iter().flatten() {
if !first {
f.write_str("-")?;
}
first = false;
f.write_str(value)?;
}
Ok(())
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DomainError {
InvalidPackageName(String),
InvalidPackageVersion(String),
InvalidArtifactDigest(String),
InvalidRegistryOrigin(String),
InvalidPackageIntegrity(String),
InvalidPlatformContext,
}
impl fmt::Display for DomainError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidPackageName(value) => write!(f, "invalid package name: {value}"),
Self::InvalidPackageVersion(value) => write!(f, "invalid package version: {value}"),
Self::InvalidArtifactDigest(value) => write!(f, "invalid artifact digest: {value}"),
Self::InvalidRegistryOrigin(value) => write!(f, "invalid registry origin: {value}"),
Self::InvalidPackageIntegrity(value) => write!(f, "invalid package integrity: {value}"),
Self::InvalidPlatformContext => f.write_str("invalid platform context"),
}
}
}
impl std::error::Error for DomainError {}
fn is_name_character(character: char) -> bool {
character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_scoped_and_unscoped_package_names() {
assert!("tapid".parse::<PackageName>().is_ok());
assert!("@tapid/core".parse::<PackageName>().is_ok());
}
#[test]
fn rejects_unsafe_package_names() {
for value in [
"",
"../tapid",
"@tapid",
"@tapid/core/extra",
"tap id",
"tapid/core",
] {
assert!(value.parse::<PackageName>().is_err(), "accepted {value}");
}
}
#[test]
fn parses_canonical_versions() {
let version = "1.2.3".parse::<PackageVersion>().unwrap();
assert_eq!(version.to_string(), "1.2.3");
assert!("01.2.3".parse::<PackageVersion>().is_err());
}
#[test]
fn accepts_only_sha256_digests() {
let digest = format!("sha256-{}", "A".repeat(64))
.parse::<ArtifactDigest>()
.unwrap();
assert_eq!(digest.to_string(), format!("sha256-{}", "a".repeat(64)));
assert!("sha512-deadbeef".parse::<ArtifactDigest>().is_err());
}
#[test]
fn registry_origin_is_typed_and_canonical_without_secrets() {
let origin = "https://REGISTRY.example.test/"
.parse::<RegistryOrigin>()
.unwrap();
assert_eq!(origin.as_str(), "https://REGISTRY.example.test");
assert!(
"http://registry.example.test"
.parse::<RegistryOrigin>()
.is_err()
);
assert!(
"https://user:pass@registry.example.test"
.parse::<RegistryOrigin>()
.is_err()
);
}
#[test]
fn integrity_preserves_mixed_case_wire_encoding() {
let value = format!("sha512-{}", "AbCdEfGh".repeat(11));
let integrity = value.parse::<PackageIntegrity>().unwrap();
assert_eq!(integrity.to_string(), value);
}
#[test]
fn package_instance_identity_includes_registry() {
let name: PackageName = "tapid".parse().unwrap();
let version: PackageVersion = "1.0.0".parse().unwrap();
let first = PackageInstanceId::new(
"https://one.example".parse().unwrap(),
name.clone(),
version,
);
let second = PackageInstanceId::new("https://two.example".parse().unwrap(), name, version);
assert_ne!(first, second);
}
#[test]
fn contexts_have_deterministic_empty_and_nonempty_forms() {
let peer = PeerContext::default().with("react".parse().unwrap(), "18.2.0".parse().unwrap());
assert_eq!(peer.to_string(), "react@18.2.0");
let platform = PlatformContext::new(Some("linux"), Some("x86_64"), Some("gnu")).unwrap();
assert_eq!(platform.to_string(), "linux-x86_64-gnu");
}
}