use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use bytes::Bytes;
use chrono::{DateTime, Utc};
use futures::Stream;
#[derive(Debug, thiserror::Error)]
pub enum ArtifactError {
#[error("invalid artifact name '{name}': {reason}")]
InvalidName { name: String, reason: String },
#[error("invalid session id '{session_id}': {reason}")]
InvalidSessionId { session_id: String, reason: String },
#[error("resolved artifact path escapes the artifact root")]
PathOutsideRoot,
#[error("artifact storage error: {0}")]
Storage(#[from] Box<dyn std::error::Error + Send + Sync>),
#[error("artifact store not configured")]
NotConfigured,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ArtifactData {
pub content: Vec<u8>,
pub content_type: String,
#[serde(default)]
pub metadata: HashMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ArtifactVersion {
pub name: String,
pub version: u32,
pub created_at: DateTime<Utc>,
pub size: usize,
pub content_type: String,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ArtifactMeta {
pub name: String,
pub latest_version: u32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub content_type: String,
}
pub type ArtifactFuture<'a, T> =
Pin<Box<dyn Future<Output = Result<T, ArtifactError>> + Send + 'a>>;
pub trait ArtifactStore: Send + Sync {
fn save<'a>(
&'a self,
session_id: &'a str,
name: &'a str,
data: ArtifactData,
) -> ArtifactFuture<'a, ArtifactVersion>;
fn load<'a>(
&'a self,
session_id: &'a str,
name: &'a str,
) -> ArtifactFuture<'a, Option<(ArtifactData, ArtifactVersion)>>;
fn load_version<'a>(
&'a self,
session_id: &'a str,
name: &'a str,
version: u32,
) -> ArtifactFuture<'a, Option<(ArtifactData, ArtifactVersion)>>;
fn list<'a>(&'a self, session_id: &'a str) -> ArtifactFuture<'a, Vec<ArtifactMeta>>;
fn delete<'a>(&'a self, session_id: &'a str, name: &'a str) -> ArtifactFuture<'a, ()>;
}
pub type ArtifactByteStream = Pin<Box<dyn Stream<Item = Result<Bytes, ArtifactError>> + Send>>;
pub trait StreamingArtifactStore: ArtifactStore {
fn save_stream<'a>(
&'a self,
session_id: &'a str,
name: &'a str,
content_type: String,
metadata: HashMap<String, String>,
stream: ArtifactByteStream,
) -> ArtifactFuture<'a, ArtifactVersion>;
fn load_stream<'a>(
&'a self,
session_id: &'a str,
name: &'a str,
version: Option<u32>,
) -> ArtifactFuture<'a, Option<ArtifactByteStream>>;
}
pub fn validate_artifact_name(name: &str) -> Result<(), ArtifactError> {
if name.is_empty() {
return Err(ArtifactError::InvalidName {
name: name.to_string(),
reason: "name must not be empty".to_string(),
});
}
if name.starts_with('/') {
return Err(ArtifactError::InvalidName {
name: name.to_string(),
reason: "name must not start with '/'".to_string(),
});
}
if name.ends_with('/') {
return Err(ArtifactError::InvalidName {
name: name.to_string(),
reason: "name must not end with '/'".to_string(),
});
}
if name.contains("//") {
return Err(ArtifactError::InvalidName {
name: name.to_string(),
reason: "name must not contain consecutive slashes".to_string(),
});
}
if name.contains("../") || name.contains("/..") || name == ".." {
return Err(ArtifactError::InvalidName {
name: name.to_string(),
reason: "name must not contain path traversal".to_string(),
});
}
let valid = name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '/');
if !valid {
return Err(ArtifactError::InvalidName {
name: name.to_string(),
reason: "name contains invalid characters (allowed: alphanumeric, -, _, ., /)"
.to_string(),
});
}
Ok(())
}
pub fn validate_session_id(session_id: &str) -> Result<(), ArtifactError> {
if session_id.is_empty() {
return Err(ArtifactError::InvalidSessionId {
session_id: session_id.to_string(),
reason: "session id must not be empty".to_string(),
});
}
if session_id.trim() != session_id {
return Err(ArtifactError::InvalidSessionId {
session_id: session_id.to_string(),
reason: "session id must not contain leading or trailing whitespace".to_string(),
});
}
if session_id.contains('/') || session_id.contains('\\') {
return Err(ArtifactError::InvalidSessionId {
session_id: session_id.to_string(),
reason: "session id must not contain path separators".to_string(),
});
}
if session_id.contains("..") {
return Err(ArtifactError::InvalidSessionId {
session_id: session_id.to_string(),
reason: "session id must not contain path traversal".to_string(),
});
}
if session_id
.chars()
.any(|c| c == '\0' || c.is_ascii_control())
{
return Err(ArtifactError::InvalidSessionId {
session_id: session_id.to_string(),
reason: "session id must not contain control characters".to_string(),
});
}
if session_id.contains(':') {
return Err(ArtifactError::InvalidSessionId {
session_id: session_id.to_string(),
reason: "session id must not contain ':'".to_string(),
});
}
Ok(())
}