use std::fmt;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(transparent)]
pub struct RelativeArtifactPath(String);
impl<'de> Deserialize<'de> for RelativeArtifactPath {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::new(&value).map_err(serde::de::Error::custom)
}
}
impl RelativeArtifactPath {
pub fn new(value: &str) -> Result<Self, PathValidationError> {
if value.is_empty() {
return Err(PathValidationError::Empty);
}
if value.starts_with('/') {
return Err(PathValidationError::Absolute);
}
if value.contains('\\') {
return Err(PathValidationError::Backslash);
}
for segment in value.split('/') {
if segment.is_empty() {
return Err(PathValidationError::EmptySegment);
}
if segment == ".." {
return Err(PathValidationError::ParentTraversal);
}
if segment == "." {
return Err(PathValidationError::CurrentDir);
}
if let Some(bad) = segment
.chars()
.find(|c| !(c.is_ascii_alphanumeric() || *c == '.' || *c == '_' || *c == '-'))
{
return Err(PathValidationError::InvalidCharacter(bad));
}
}
Ok(Self(value.to_owned()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for RelativeArtifactPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum PathValidationError {
#[error("artifact path must not be empty")]
Empty,
#[error("artifact path must be relative")]
Absolute,
#[error("artifact path must use forward slashes")]
Backslash,
#[error("artifact path must not contain empty segments")]
EmptySegment,
#[error("artifact path must not contain parent traversal")]
ParentTraversal,
#[error("artifact path must not contain current-dir segments")]
CurrentDir,
#[error("artifact path segment contains invalid character: {0:?}")]
InvalidCharacter(char),
}
impl From<PathValidationError> for crate::error::WyrdError {
fn from(error: PathValidationError) -> Self {
Self::RegistryUnresolvedPathRef {
message: error.to_string(),
details: serde_json::json!({}),
}
}
}
#[cfg(test)]
mod tests {
use super::{PathValidationError, RelativeArtifactPath};
use crate::error::WyrdError;
#[test]
fn relative_path_rejects_traversal_and_platform_separators() {
assert_eq!(
RelativeArtifactPath::new("a\\b"),
Err(PathValidationError::Backslash)
);
assert_eq!(
RelativeArtifactPath::new("a/../b"),
Err(PathValidationError::ParentTraversal)
);
assert_eq!(
RelativeArtifactPath::new("/a"),
Err(PathValidationError::Absolute)
);
assert_eq!(
RelativeArtifactPath::new("a//b"),
Err(PathValidationError::EmptySegment)
);
}
#[test]
fn relative_path_matches_server_tenant_grammar() {
assert_eq!(
RelativeArtifactPath::new("a/./b"),
Err(PathValidationError::CurrentDir),
"current-dir segments are forbidden by the server; reject them here too"
);
assert_eq!(
RelativeArtifactPath::new("weights/model file.bin"),
Err(PathValidationError::InvalidCharacter(' ')),
"the server grammar excludes spaces; reject them here too"
);
assert_eq!(
RelativeArtifactPath::new("weights/mödel.bin"),
Err(PathValidationError::InvalidCharacter('ö')),
"the server grammar is ASCII-only; reject non-ASCII here too"
);
assert_eq!(
RelativeArtifactPath::new("weights/model+v2.bin"),
Err(PathValidationError::InvalidCharacter('+'))
);
for accepted in [
"model.bin",
"weights/model.bin",
"layer_0/attn.qkv-proj.bin",
"v1.2.3/file",
] {
RelativeArtifactPath::new(accepted)
.unwrap_or_else(|err| panic!("{accepted:?} must be accepted, got {err:?}"));
}
}
#[test]
fn path_error_maps_to_registry_error_catalog() {
let error: WyrdError = PathValidationError::Absolute.into();
assert_eq!(error.code(), "WYRD_REGISTRY_400_UNRESOLVED_PATH_REF");
assert_eq!(error.status(), 400);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(transparent)]
pub struct RegistrationOperationId(Uuid);
impl RegistrationOperationId {
#[must_use]
pub const fn new(value: Uuid) -> Self {
Self(value)
}
#[must_use]
pub const fn as_uuid(self) -> Uuid {
self.0
}
}