use std::str::FromStr;
use serde::Deserialize;
use crate::{Config, validate_required_trimmed};
pub const ENV_AGENTICS_STORAGE_BACKEND: &str = "AGENTICS_STORAGE_BACKEND";
pub const ENV_AGENTICS_STORAGE_ROOT: &str = "AGENTICS_STORAGE_ROOT";
pub const ENV_AGENTICS_STORAGE_WORK_ROOT: &str = "AGENTICS_STORAGE_WORK_ROOT";
pub const ENV_AGENTICS_S3_BUCKET: &str = "AGENTICS_S3_BUCKET";
pub const ENV_AGENTICS_S3_PREFIX: &str = "AGENTICS_S3_PREFIX";
pub const ENV_AGENTICS_S3_REGION: &str = "AGENTICS_S3_REGION";
pub const ENV_AGENTICS_S3_ENDPOINT_URL: &str = "AGENTICS_S3_ENDPOINT_URL";
pub const ENV_AGENTICS_S3_FORCE_PATH_STYLE: &str = "AGENTICS_S3_FORCE_PATH_STYLE";
pub const DEFAULT_STORAGE_BACKEND: StorageBackend = StorageBackend::S3;
pub const DEFAULT_STORAGE_ROOT: &str = "storage";
pub const DEFAULT_S3_BUCKET: &str = "agentics";
pub const DEFAULT_S3_REGION: &str = "us-east-1";
pub const DEFAULT_S3_ENDPOINT_URL: &str = "http://127.0.0.1:9000";
pub const DEFAULT_S3_FORCE_PATH_STYLE: bool = true;
pub(crate) const DEFAULT_STORAGE_MAX_BUNDLE_ARCHIVE_BYTES: u64 = 1024 * 1024 * 1024;
pub(crate) const DEFAULT_STORAGE_MAX_STATEMENT_BYTES: u64 = 1024 * 1024;
pub(crate) const DEFAULT_STORAGE_MAX_JSON_ARTIFACT_BYTES: u64 = 1024 * 1024;
pub(crate) const DEFAULT_STORAGE_TMP_OBJECT_GRACE_HOURS: u64 = 24;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StorageBackend {
Local,
S3,
}
impl StorageBackend {
pub fn as_str(self) -> &'static str {
match self {
Self::Local => "local",
Self::S3 => "s3",
}
}
}
impl FromStr for StorageBackend {
type Err = anyhow::Error;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.trim() {
"local" => Ok(Self::Local),
"s3" => Ok(Self::S3),
other => anyhow::bail!(
"{ENV_AGENTICS_STORAGE_BACKEND} must be either `local` or `s3`; got `{other}`"
),
}
}
}
pub(crate) fn validate_object_storage_config(config: &Config) -> anyhow::Result<()> {
crate::validation::validate_report(&config.storage)?;
match config.storage.backend {
StorageBackend::Local => Ok(()),
StorageBackend::S3 => {
validate_required_trimmed(config.storage.s3_bucket.as_deref(), "AGENTICS_S3_BUCKET")?;
validate_s3_prefix(config.storage.s3_prefix.as_deref())?;
validate_required_trimmed(Some(&config.storage.s3_region), "AGENTICS_S3_REGION")?;
if let Some(endpoint_url) = &config.storage.s3_endpoint_url
&& !matches!(endpoint_url.scheme(), "http" | "https")
{
anyhow::bail!("AGENTICS_S3_ENDPOINT_URL must start with http:// or https://");
}
Ok(())
}
}
}
pub(crate) fn validate_s3_prefix(value: Option<&str>) -> anyhow::Result<()> {
let Some(prefix) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(());
};
if prefix.starts_with('/') || prefix.ends_with('/') || prefix.contains('\\') {
anyhow::bail!(
"AGENTICS_S3_PREFIX must be a relative slash-separated storage prefix without leading or trailing slash"
);
}
for component in prefix.split('/') {
if component.is_empty()
|| component == "."
|| component == ".."
|| component.bytes().any(|byte| {
byte.is_ascii_whitespace()
|| byte.is_ascii_control()
|| !(byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
})
{
anyhow::bail!("AGENTICS_S3_PREFIX contains an unsafe path component");
}
}
Ok(())
}