use std::collections::HashMap;
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("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 trait ArtifactStore: Send + Sync {
fn save(
&self,
session_id: &str,
name: &str,
data: ArtifactData,
) -> impl std::future::Future<Output = Result<ArtifactVersion, ArtifactError>> + Send;
fn load(
&self,
session_id: &str,
name: &str,
) -> impl std::future::Future<
Output = Result<Option<(ArtifactData, ArtifactVersion)>, ArtifactError>,
> + Send;
fn load_version(
&self,
session_id: &str,
name: &str,
version: u32,
) -> impl std::future::Future<
Output = Result<Option<(ArtifactData, ArtifactVersion)>, ArtifactError>,
> + Send;
fn list(
&self,
session_id: &str,
) -> impl std::future::Future<Output = Result<Vec<ArtifactMeta>, ArtifactError>> + Send;
fn delete(
&self,
session_id: &str,
name: &str,
) -> impl std::future::Future<Output = Result<(), ArtifactError>> + Send;
}
pub type ArtifactByteStream = Pin<Box<dyn Stream<Item = Result<Bytes, ArtifactError>> + Send>>;
pub trait StreamingArtifactStore: ArtifactStore {
fn save_stream(
&self,
session_id: &str,
name: &str,
content_type: String,
metadata: HashMap<String, String>,
stream: ArtifactByteStream,
) -> impl std::future::Future<Output = Result<ArtifactVersion, ArtifactError>> + Send;
fn load_stream(
&self,
session_id: &str,
name: &str,
version: Option<u32>,
) -> impl std::future::Future<Output = Result<Option<ArtifactByteStream>, ArtifactError>> + Send;
}
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(())
}