use std::path::{Path, PathBuf};
use aws_credential_types::Credentials;
use aws_sdk_s3::Client;
use aws_sdk_s3::config::{BehaviorVersion, Region};
use aws_sdk_s3::primitives::ByteStream;
use serde::{Deserialize, Serialize};
use crate::config::MediaStorageConfig;
use crate::error::MediaError;
fn guard_egress(method: &str) -> Result<(), autumn_web::capsule::UnrecordedEgress> {
autumn_web::capsule::guard_egress("media storage", method, "s3 object")
}
pub const DEFAULT_CONTENT_TYPE: &str = "video/mp4";
const DEFAULT_LOCAL_PUBLIC_BASE: &str = "/media";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct StoredObject {
pub backend: String,
pub key: String,
pub uri: String,
}
#[derive(Clone)]
pub struct S3MediaStorage {
pub bucket: String,
pub endpoint_url: Option<String>,
pub region: Option<String>,
pub access_key_id: String,
pub secret_access_key: String,
pub session_token: Option<String>,
pub public_base_url: String,
pub key_prefix: String,
pub force_path_style: bool,
client: std::sync::Arc<tokio::sync::OnceCell<Client>>,
}
impl std::fmt::Debug for S3MediaStorage {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("S3MediaStorage")
.field("bucket", &self.bucket)
.field("endpoint_url", &self.endpoint_url)
.field("region", &self.region)
.field("access_key_id", &self.access_key_id)
.field("secret_access_key", &"** redacted **")
.field(
"session_token",
&self.session_token.as_ref().map(|_| "** redacted **"),
)
.field("public_base_url", &self.public_base_url)
.field("key_prefix", &self.key_prefix)
.field("force_path_style", &self.force_path_style)
.finish_non_exhaustive()
}
}
#[derive(Clone, Debug)]
pub enum MediaStorage {
Local {
root: PathBuf,
public_base_url: String,
},
S3(S3MediaStorage),
}
impl MediaStorage {
pub fn from_config(cfg: &MediaStorageConfig) -> Result<Self, MediaError> {
use crate::config::MediaStorageBackend;
match cfg.backend {
MediaStorageBackend::Local => {
let public_base_url = non_empty(cfg.public_base_url.as_deref())
.unwrap_or(DEFAULT_LOCAL_PUBLIC_BASE)
.to_owned();
Ok(Self::Local {
root: PathBuf::from(&cfg.root),
public_base_url,
})
}
MediaStorageBackend::S3 => {
let bucket = non_empty(cfg.bucket.as_deref())
.ok_or(MediaError::MissingBucket)?
.to_owned();
let access_key_id = non_empty(cfg.access_key_id.as_deref());
let secret_access_key = non_empty(cfg.secret_access_key.as_deref());
if access_key_id.is_some() != secret_access_key.is_some() {
return Err(MediaError::PartialS3Credentials);
}
let region = non_empty(cfg.region.as_deref()).map(ToOwned::to_owned);
let key_prefix = cfg.key_prefix.trim_matches('/').to_owned();
let endpoint_url = non_empty(cfg.endpoint_url.as_deref()).map(ToOwned::to_owned);
let public_base_url = match non_empty(cfg.public_base_url.as_deref()) {
Some(explicit) => explicit.to_owned(),
None if is_tigris_endpoint(endpoint_url.as_deref()) => {
default_tigris_public_base(&bucket, &key_prefix)
}
None => return Err(MediaError::MissingPublicBaseUrl { bucket }),
};
Ok(Self::S3(S3MediaStorage {
bucket,
endpoint_url,
region,
access_key_id: access_key_id.unwrap_or_default().to_owned(),
secret_access_key: secret_access_key.unwrap_or_default().to_owned(),
session_token: non_empty(cfg.session_token.as_deref()).map(ToOwned::to_owned),
public_base_url,
key_prefix,
force_path_style: cfg.force_path_style,
client: std::sync::Arc::new(tokio::sync::OnceCell::new()),
}))
}
}
}
#[must_use]
pub const fn backend_name(&self) -> &'static str {
match self {
Self::Local { .. } => "local",
Self::S3(_) => "s3",
}
}
fn resolved_key(&self, key: &str) -> String {
match self {
Self::Local { .. } => key.trim_matches('/').to_owned(),
Self::S3(config) => join_key_prefix(&config.key_prefix, key),
}
}
#[must_use]
pub fn object_key(&self, key: &str) -> String {
self.resolved_key(key)
}
#[must_use]
pub fn public_url(&self, key: &str) -> String {
let encoded = encode_key_for_url(key.trim_matches('/'));
match self {
Self::Local {
public_base_url, ..
} => join_url(public_base_url, &encoded),
Self::S3(config) => join_url(&config.public_base_url, &encoded),
}
}
#[must_use]
pub fn storage_uri(&self, key: &str, local_path: &Path) -> String {
match self {
Self::Local { .. } => local_path.display().to_string(),
Self::S3(config) => format!("s3://{}/{}", config.bucket, self.resolved_key(key)),
}
}
#[must_use]
pub fn parse_s3_uri_key(&self, uri: &str) -> Option<String> {
let rest = uri.strip_prefix("s3://")?;
let (bucket, stored_key) = rest.split_once('/')?;
if stored_key.is_empty() {
return None;
}
let relative = match self {
Self::Local { .. } => stored_key.trim_matches('/').to_owned(),
Self::S3(config) => {
if bucket != config.bucket {
return None;
}
strip_key_prefix(&config.key_prefix, stored_key)
}
};
if relative.is_empty() {
return None;
}
Some(relative)
}
pub async fn persist_file(
&self,
key: &str,
local_path: &Path,
) -> Result<StoredObject, MediaError> {
self.persist_file_with_content_type(key, local_path, DEFAULT_CONTENT_TYPE)
.await
}
pub async fn persist_file_with_content_type(
&self,
key: &str,
local_path: &Path,
content_type: &str,
) -> Result<StoredObject, MediaError> {
reject_dot_only_segments(key)?;
match self {
Self::Local { .. } => Ok(StoredObject {
backend: self.backend_name().to_owned(),
key: key.to_owned(),
uri: self.storage_uri(key, local_path),
}),
Self::S3(config) => {
let stored_key = self.resolved_key(key);
let body = ByteStream::from_path(local_path).await.map_err(|error| {
MediaError::LocalRead {
path: local_path.display().to_string(),
source: std::io::Error::other(error),
}
})?;
guard_egress("PUT").map_err(|refused| MediaError::ReplayRefused {
message: refused.to_string(),
})?;
let client = self.build_client().await;
client
.put_object()
.bucket(&config.bucket)
.key(&stored_key)
.content_type(content_type)
.body(body)
.send()
.await
.map_err(|error| MediaError::S3Upload {
bucket: config.bucket.clone(),
key: stored_key.clone(),
message: error.to_string(),
})?;
Ok(StoredObject {
backend: self.backend_name().to_owned(),
key: key.to_owned(),
uri: self.storage_uri(key, local_path),
})
}
}
}
pub async fn remove_object_if_present(&self, key: &str) -> Result<(), MediaError> {
match self {
Self::Local { .. } => Ok(()),
Self::S3(config) => {
let stored_key = self.resolved_key(key);
guard_egress("DELETE").map_err(|refused| MediaError::ReplayRefused {
message: refused.to_string(),
})?;
let client = self.build_client().await;
client
.delete_object()
.bucket(&config.bucket)
.key(&stored_key)
.send()
.await
.map_err(|error| MediaError::S3Delete {
bucket: config.bucket.clone(),
key: stored_key.clone(),
message: error.to_string(),
})?;
Ok(())
}
}
}
async fn build_client(&self) -> Client {
let Self::S3(config) = self else {
unreachable!("build_client is only called for the S3 backend");
};
config
.client
.get_or_init(|| async {
let explicit_region = non_empty(config.region.as_deref())
.map(|region| Region::new(region.to_owned()));
if !config.access_key_id.is_empty() && !config.secret_access_key.is_empty() {
let credentials = Credentials::new(
config.access_key_id.clone(),
config.secret_access_key.clone(),
config.session_token.clone(),
None,
"autumn-media-plugin",
);
let region = match explicit_region {
Some(region) => Some(region),
None => aws_config::defaults(BehaviorVersion::latest())
.load()
.await
.region()
.cloned(),
};
let mut builder = aws_sdk_s3::Config::builder()
.behavior_version(BehaviorVersion::latest())
.credentials_provider(credentials)
.force_path_style(config.force_path_style);
if let Some(region) = region {
builder = builder.region(region);
}
if let Some(endpoint) = &config.endpoint_url {
builder = builder.endpoint_url(endpoint);
}
Client::from_conf(builder.build())
} else {
let mut loader = aws_config::defaults(BehaviorVersion::latest());
if let Some(region) = explicit_region {
loader = loader.region(region);
}
let shared = loader.load().await;
let mut builder = aws_sdk_s3::config::Builder::from(&shared)
.force_path_style(config.force_path_style);
if let Some(endpoint) = &config.endpoint_url {
builder = builder.endpoint_url(endpoint);
}
Client::from_conf(builder.build())
}
})
.await
.clone()
}
}
fn non_empty(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
#[must_use]
pub fn default_tigris_public_base(bucket: &str, key_prefix: &str) -> String {
join_url(
&format!("https://{bucket}.t3.tigrisfiles.io"),
key_prefix.trim_matches('/'),
)
}
pub(crate) fn is_tigris_endpoint(endpoint_url: Option<&str>) -> bool {
let Some(endpoint) = endpoint_url else {
return false;
};
let host = endpoint_host(endpoint);
host == "t3.storage.dev"
|| host.ends_with(".t3.storage.dev")
|| host == "tigrisfiles.io"
|| host.ends_with(".tigrisfiles.io")
}
fn endpoint_host(endpoint: &str) -> String {
let after_scheme = endpoint
.split_once("://")
.map_or(endpoint, |(_, rest)| rest);
let authority = after_scheme
.split(['/', '?', '#'])
.next()
.unwrap_or_default();
let host_port = authority
.rsplit_once('@')
.map_or(authority, |(_, host)| host);
host_port
.split(':')
.next()
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
}
#[must_use]
pub fn join_key_prefix(prefix: &str, key: &str) -> String {
let prefix = prefix.trim_matches('/');
let key = key.trim_matches('/');
if prefix.is_empty() {
key.to_owned()
} else {
format!("{prefix}/{key}")
}
}
pub(crate) fn reject_dot_only_segments(key: &str) -> Result<(), MediaError> {
for segment in key.trim_matches('/').split('/') {
let segment = segment.trim();
if segment.is_empty() || segment == "." || segment == ".." {
return Err(MediaError::InvalidKeySegment {
key: key.to_owned(),
});
}
}
Ok(())
}
#[must_use]
pub fn strip_key_prefix(prefix: &str, stored_key: &str) -> String {
let prefix = prefix.trim_matches('/');
let stored_key = stored_key.trim_matches('/');
if prefix.is_empty() {
return stored_key.to_owned();
}
if let Some(rest) = stored_key.strip_prefix(prefix) {
if rest.is_empty() {
return String::new();
}
if let Some(after) = rest.strip_prefix('/') {
return after.trim_matches('/').to_owned();
}
}
stored_key.to_owned()
}
fn encode_key_for_url(key: &str) -> String {
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
const SEGMENT: &AsciiSet = &NON_ALPHANUMERIC
.remove(b'-')
.remove(b'.')
.remove(b'_')
.remove(b'~');
key.split('/')
.map(|seg| {
if seg == "." || seg == ".." {
seg.replace('.', "%2E")
} else {
utf8_percent_encode(seg, SEGMENT).to_string()
}
})
.collect::<Vec<_>>()
.join("/")
}
#[must_use]
pub fn join_url(base: &str, path: &str) -> String {
let base = base.trim_end_matches('/');
let path = path.trim_start_matches('/');
if path.is_empty() {
base.to_owned()
} else {
format!("{base}/{path}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{MediaStorageBackend, MediaStorageConfig};
fn s3_config(bucket: Option<&str>) -> MediaStorageConfig {
MediaStorageConfig {
backend: MediaStorageBackend::S3,
bucket: bucket.map(ToOwned::to_owned),
access_key_id: Some("AKIA".to_owned()),
secret_access_key: Some("secret".to_owned()),
public_base_url: Some("https://cdn.example.com".to_owned()),
..MediaStorageConfig::default()
}
}
#[tokio::test]
async fn a_replay_refusal_names_no_object_key() {
let storage = MediaStorage::from_config(&s3_config(Some("media"))).expect("s3 storage");
let staged = std::env::temp_dir().join("autumn-media-replay-refusal.bin");
std::fs::write(&staged, b"x").expect("staged file");
let tape = std::sync::Arc::new(autumn_web::capsule::ReplayEffects::new(
autumn_web::capsule::CapsuleEffects::default(),
));
let (upload, delete) = autumn_web::capsule::with_effect_tape(
tape,
Box::pin(async {
(
storage
.persist_file("users/alice@example.com/token-s3cr3t.mp4", &staged)
.await,
storage
.remove_object_if_present("users/alice@example.com/token-s3cr3t.mp4")
.await,
)
}),
)
.await;
let _ = std::fs::remove_file(&staged);
for error in [
upload.expect_err("a replay refuses the upload"),
delete.expect_err("a replay refuses the delete"),
] {
assert!(matches!(error, MediaError::ReplayRefused { .. }), "{error}");
assert!(!error.to_string().contains("alice"), "{error}");
assert!(!error.to_string().contains("s3cr3t"), "{error}");
}
}
#[test]
fn join_url_joins_base_and_path() {
assert_eq!(
join_url("https://cdn.example.com", "a/b.mp4"),
"https://cdn.example.com/a/b.mp4"
);
}
#[test]
fn join_url_collapses_extra_slashes() {
assert_eq!(
join_url("https://cdn.example.com/", "/a/b.mp4"),
"https://cdn.example.com/a/b.mp4"
);
}
#[test]
fn join_url_empty_path_returns_trimmed_base() {
assert_eq!(
join_url("https://cdn.example.com/", ""),
"https://cdn.example.com"
);
}
#[test]
fn join_key_prefix_prepends_prefix() {
assert_eq!(join_key_prefix("media", "clips/1.mp4"), "media/clips/1.mp4");
}
#[test]
fn join_key_prefix_empty_prefix_trims_key() {
assert_eq!(join_key_prefix("", "/clips/1.mp4/"), "clips/1.mp4");
}
#[test]
fn join_key_prefix_trims_surrounding_slashes() {
assert_eq!(
join_key_prefix("/media/", "/clips/1.mp4"),
"media/clips/1.mp4"
);
}
#[test]
fn tigris_public_base_uses_bucket_and_prefix() {
assert_eq!(
default_tigris_public_base("my-bucket", "highlights"),
"https://my-bucket.t3.tigrisfiles.io/highlights"
);
}
#[test]
fn tigris_public_base_empty_prefix_omits_trailing_slash() {
assert_eq!(
default_tigris_public_base("my-bucket", ""),
"https://my-bucket.t3.tigrisfiles.io"
);
}
#[test]
fn is_tigris_endpoint_recognizes_tigris_hosts() {
assert!(is_tigris_endpoint(Some("https://t3.storage.dev")));
assert!(is_tigris_endpoint(Some("HTTPS://T3.Storage.Dev:443/")));
assert!(is_tigris_endpoint(Some(
"https://my-bucket.t3.tigrisfiles.io"
)));
assert!(is_tigris_endpoint(Some("https://tigrisfiles.io")));
}
#[test]
fn is_tigris_endpoint_rejects_generic_and_absent() {
assert!(!is_tigris_endpoint(None));
assert!(!is_tigris_endpoint(Some(
"https://s3.us-east-1.amazonaws.com"
)));
assert!(!is_tigris_endpoint(Some(
"https://abc123.r2.cloudflarestorage.com"
)));
assert!(!is_tigris_endpoint(Some("http://127.0.0.1:9000")));
assert!(!is_tigris_endpoint(Some(
"https://tigrisfiles.io.evil.example.com"
)));
assert!(!is_tigris_endpoint(Some("https://nottigrisfiles.io")));
}
#[test]
fn endpoint_host_strips_scheme_userinfo_port_and_path() {
assert_eq!(endpoint_host("https://t3.storage.dev"), "t3.storage.dev");
assert_eq!(
endpoint_host("https://user@t3.storage.dev:443/path?q=1#f"),
"t3.storage.dev"
);
assert_eq!(endpoint_host("T3.STORAGE.DEV"), "t3.storage.dev");
}
#[test]
fn object_key_local_passthrough() {
let storage = MediaStorage::Local {
root: PathBuf::from("media"),
public_base_url: "/media".to_owned(),
};
assert_eq!(storage.object_key("clips/1.mp4"), "clips/1.mp4");
}
#[test]
fn object_key_s3_prefix_join() {
let storage = MediaStorage::from_config(&s3_config(Some("b"))).unwrap();
assert_eq!(storage.object_key("clips/1.mp4"), "media/clips/1.mp4");
}
#[test]
fn object_key_s3_empty_prefix() {
let mut cfg = s3_config(Some("b"));
cfg.key_prefix = String::new();
let storage = MediaStorage::from_config(&cfg).unwrap();
assert_eq!(storage.object_key("/clips/1.mp4/"), "clips/1.mp4");
}
#[test]
fn public_url_local_joins_base() {
let storage = MediaStorage::Local {
root: PathBuf::from("media"),
public_base_url: "/media".to_owned(),
};
assert_eq!(storage.public_url("clips/1.mp4"), "/media/clips/1.mp4");
}
#[test]
fn public_url_s3_joins_public_base() {
let mut cfg = s3_config(Some("b"));
cfg.public_base_url = Some("https://cdn.example.com/media/".to_owned());
let storage = MediaStorage::from_config(&cfg).unwrap();
assert_eq!(
storage.public_url("/clips/1.mp4"),
"https://cdn.example.com/media/clips/1.mp4"
);
}
#[test]
fn public_url_percent_encodes_reserved_char() {
let storage = MediaStorage::Local {
root: PathBuf::from("media"),
public_base_url: "/media".to_owned(),
};
let url = storage.public_url("clips/a#b.mp4");
assert_eq!(url, "/media/clips/a%23b.mp4");
assert!(!url.contains("a#b"), "reserved `#` must be encoded: {url}");
assert!(url.contains("/clips/"), "path separators preserved: {url}");
}
#[test]
fn public_url_passes_through_unreserved_chars() {
let storage = MediaStorage::Local {
root: PathBuf::from("media"),
public_base_url: "/media".to_owned(),
};
let url = storage.public_url("highlights/my-clip_1.mp4");
assert_eq!(url, "/media/highlights/my-clip_1.mp4");
assert!(
!url.contains('%'),
"no percent-encoding for slug key: {url}"
);
}
#[test]
fn public_url_encodes_space_question_and_percent() {
let storage = MediaStorage::Local {
root: PathBuf::from("media"),
public_base_url: "/media".to_owned(),
};
assert_eq!(
storage.public_url("clips/a b?c%d.mp4"),
"/media/clips/a%20b%3Fc%25d.mp4"
);
}
#[test]
fn public_url_encodes_dotdot_segment() {
let storage = MediaStorage::Local {
root: PathBuf::from("media"),
public_base_url: "/media".to_owned(),
};
let url = storage.public_url("clips/../other.mp4");
assert_eq!(url, "/media/clips/%2E%2E/other.mp4");
assert!(
!url.contains("/../"),
"literal `..` traversal must not survive: {url}"
);
}
#[test]
fn public_url_encodes_single_dot_segment() {
let storage = MediaStorage::Local {
root: PathBuf::from("media"),
public_base_url: "/media".to_owned(),
};
let url = storage.public_url("clips/./a.mp4");
assert_eq!(url, "/media/clips/%2E/a.mp4");
assert!(
!url.contains("/./"),
"literal `.` current-dir segment must not survive: {url}"
);
}
#[test]
fn encode_key_for_url_preserves_ordinary_filename_dot() {
assert_eq!(encode_key_for_url("clips/a.mp4"), "clips/a.mp4");
assert!(!encode_key_for_url("clips/a.mp4").contains("%2E"));
assert_eq!(
encode_key_for_url("clips/../other.mp4"),
"clips/%2E%2E/other.mp4"
);
assert_eq!(
encode_key_for_url("clips/./other.mp4"),
"clips/%2E/other.mp4"
);
}
#[test]
fn storage_uri_local_is_path() {
let storage = MediaStorage::Local {
root: PathBuf::from("media"),
public_base_url: "/media".to_owned(),
};
assert_eq!(
storage.storage_uri("clips/1.mp4", Path::new("/tmp/staged/1.mp4")),
"/tmp/staged/1.mp4"
);
}
#[test]
fn storage_uri_s3_is_s3_uri_with_prefix() {
let storage = MediaStorage::from_config(&s3_config(Some("my-bucket"))).unwrap();
assert_eq!(
storage.storage_uri("clips/1.mp4", Path::new("/tmp/staged/1.mp4")),
"s3://my-bucket/media/clips/1.mp4"
);
}
#[test]
fn parse_s3_uri_key_strips_prefix_to_caller_relative() {
let storage = MediaStorage::from_config(&s3_config(Some("bucket"))).unwrap();
assert_eq!(
storage.parse_s3_uri_key("s3://bucket/media/clips/1.mp4"),
Some("clips/1.mp4".to_owned())
);
}
#[test]
fn parse_s3_uri_key_empty_prefix_returns_full_key() {
let mut cfg = s3_config(Some("bucket"));
cfg.key_prefix = String::new();
let storage = MediaStorage::from_config(&cfg).unwrap();
assert_eq!(
storage.parse_s3_uri_key("s3://bucket/a/b/c.jpg"),
Some("a/b/c.jpg".to_owned())
);
}
#[test]
fn parse_s3_uri_key_foreign_prefix_left_intact() {
let storage = MediaStorage::from_config(&s3_config(Some("bucket"))).unwrap();
assert_eq!(
storage.parse_s3_uri_key("s3://bucket/media-extra/x.jpg"),
Some("media-extra/x.jpg".to_owned())
);
}
#[test]
fn parse_s3_uri_key_foreign_bucket_is_none() {
let storage = MediaStorage::from_config(&s3_config(Some("bucket"))).unwrap();
assert_eq!(
storage.parse_s3_uri_key("s3://other-bucket/media/clips/1.mp4"),
None
);
assert_eq!(
storage.parse_s3_uri_key("s3://bucket/media/clips/1.mp4"),
Some("clips/1.mp4".to_owned())
);
}
#[test]
fn parse_s3_uri_key_prefix_only_is_none() {
let storage = MediaStorage::from_config(&s3_config(Some("bucket"))).unwrap();
assert_eq!(storage.parse_s3_uri_key("s3://bucket/media"), None);
}
#[test]
fn parse_s3_uri_key_empty_key_is_none() {
let storage = MediaStorage::from_config(&s3_config(Some("bucket"))).unwrap();
assert_eq!(storage.parse_s3_uri_key("s3://bucket/"), None);
}
#[test]
fn parse_s3_uri_key_non_s3_is_none() {
let storage = MediaStorage::from_config(&s3_config(Some("bucket"))).unwrap();
assert_eq!(storage.parse_s3_uri_key("/local/path/1.mp4"), None);
assert_eq!(storage.parse_s3_uri_key("https://x/y"), None);
}
#[test]
fn key_resolution_is_consistent_across_object_key_storage_uri_and_parse() {
let storage = MediaStorage::from_config(&s3_config(Some("bucket"))).unwrap();
let caller_key = "clips/a.mp4";
assert_eq!(storage.object_key(caller_key), "media/clips/a.mp4");
let uri = storage.storage_uri(caller_key, Path::new("/tmp/staged/a.mp4"));
assert_eq!(uri, "s3://bucket/media/clips/a.mp4");
let parsed = storage.parse_s3_uri_key(&uri).unwrap();
assert_eq!(parsed, caller_key);
assert_eq!(storage.object_key(&parsed), "media/clips/a.mp4");
assert_ne!(storage.object_key(&parsed), "media/media/clips/a.mp4");
}
#[test]
fn key_resolution_round_trips_with_empty_prefix() {
let mut cfg = s3_config(Some("bucket"));
cfg.key_prefix = String::new();
let storage = MediaStorage::from_config(&cfg).unwrap();
let caller_key = "clips/a.mp4";
let uri = storage.storage_uri(caller_key, Path::new("/tmp/a.mp4"));
assert_eq!(uri, "s3://bucket/clips/a.mp4");
assert_eq!(storage.parse_s3_uri_key(&uri).as_deref(), Some(caller_key));
}
#[test]
fn public_url_and_stored_key_agree_on_trailing_slash() {
let mut cfg = s3_config(Some("bucket"));
cfg.public_base_url = Some("https://cdn.example.com/media/".to_owned());
let storage = MediaStorage::from_config(&cfg).unwrap();
assert_eq!(storage.object_key("clips/a.mp4/"), "media/clips/a.mp4");
assert_eq!(
storage.public_url("clips/a.mp4/"),
"https://cdn.example.com/media/clips/a.mp4"
);
assert_eq!(
storage.public_url("/clips/a.mp4/"),
"https://cdn.example.com/media/clips/a.mp4"
);
}
#[test]
fn public_url_local_trailing_slash_matches_object_key() {
let storage = MediaStorage::Local {
root: PathBuf::from("media"),
public_base_url: "/media".to_owned(),
};
assert_eq!(storage.object_key("clips/a.mp4/"), "clips/a.mp4");
assert_eq!(storage.public_url("clips/a.mp4/"), "/media/clips/a.mp4");
}
#[test]
fn strip_key_prefix_removes_boundary_prefix() {
assert_eq!(
strip_key_prefix("media", "media/clips/1.mp4"),
"clips/1.mp4"
);
assert_eq!(
strip_key_prefix("/media/", "media/clips/1.mp4"),
"clips/1.mp4"
);
}
#[test]
fn strip_key_prefix_leaves_non_boundary_and_foreign_keys() {
assert_eq!(strip_key_prefix("media", "media-extra/x"), "media-extra/x");
assert_eq!(strip_key_prefix("media", "other/x"), "other/x");
}
#[test]
fn strip_key_prefix_empty_prefix_trims_only() {
assert_eq!(strip_key_prefix("", "/clips/1.mp4/"), "clips/1.mp4");
}
#[test]
fn strip_key_prefix_key_equal_to_prefix_is_empty() {
assert_eq!(strip_key_prefix("media", "media"), "");
}
#[test]
fn reject_dot_only_segments_rejects_dotdot() {
assert!(matches!(
reject_dot_only_segments("clips/../other.mp4"),
Err(MediaError::InvalidKeySegment { .. })
));
}
#[test]
fn reject_dot_only_segments_rejects_single_dot() {
assert!(matches!(
reject_dot_only_segments("clips/./a.mp4"),
Err(MediaError::InvalidKeySegment { .. })
));
}
#[test]
fn reject_dot_only_segments_accepts_filename_with_dots() {
assert!(reject_dot_only_segments("clips/my.file.mp4").is_ok());
assert!(reject_dot_only_segments("highlights/chan/a-b_c.mp4").is_ok());
}
#[test]
fn reject_dot_only_segments_trims_outer_slashes_but_rejects_inner_empties() {
assert!(reject_dot_only_segments("/clips/a.mp4/").is_ok());
assert!(reject_dot_only_segments("clips//a.mp4").is_err());
assert!(reject_dot_only_segments("clips/ .. /a.mp4").is_err());
assert!(reject_dot_only_segments("").is_err());
}
#[tokio::test]
async fn persist_file_rejects_dot_only_segment_key() {
let storage = MediaStorage::Local {
root: PathBuf::from("media"),
public_base_url: "/media".to_owned(),
};
let err = storage
.persist_file("clips/../secret.mp4", Path::new("/tmp/staged/x.mp4"))
.await
.expect_err("dot-only-segment key must be rejected at the boundary");
assert!(matches!(err, MediaError::InvalidKeySegment { .. }));
}
#[tokio::test]
async fn persist_file_accepts_ordinary_dotted_filename() {
let storage = MediaStorage::Local {
root: PathBuf::from("media"),
public_base_url: "/media".to_owned(),
};
let stored = storage
.persist_file("clips/my.file.mp4", Path::new("/tmp/staged/my.file.mp4"))
.await
.expect("ordinary dotted filename must be accepted");
assert_eq!(stored.key, "clips/my.file.mp4");
}
#[test]
fn from_config_local_default() {
let storage = MediaStorage::from_config(&MediaStorageConfig::default()).unwrap();
match storage {
MediaStorage::Local {
root,
public_base_url,
} => {
assert_eq!(root, PathBuf::from("media"));
assert_eq!(public_base_url, "/media");
}
MediaStorage::S3(_) => panic!("expected local backend"),
}
assert_eq!(
MediaStorage::from_config(&MediaStorageConfig::default())
.unwrap()
.backend_name(),
"local"
);
}
#[test]
fn from_config_s3_with_bucket_no_region_and_tigris_base() {
let mut cfg = s3_config(Some("my-bucket"));
cfg.public_base_url = None;
cfg.endpoint_url = Some("https://t3.storage.dev".to_owned());
let storage = MediaStorage::from_config(&cfg).unwrap();
match storage {
MediaStorage::S3(config) => {
assert_eq!(config.bucket, "my-bucket");
assert_eq!(config.region, None);
assert_eq!(
config.endpoint_url.as_deref(),
Some("https://t3.storage.dev")
);
assert_eq!(
config.public_base_url,
"https://my-bucket.t3.tigrisfiles.io/media"
);
}
MediaStorage::Local { .. } => panic!("expected s3 backend"),
}
}
#[test]
fn from_config_s3_tigris_public_host_endpoint_also_defaults() {
let mut cfg = s3_config(Some("my-bucket"));
cfg.public_base_url = None;
cfg.endpoint_url = Some("https://my-bucket.t3.tigrisfiles.io".to_owned());
let MediaStorage::S3(config) = MediaStorage::from_config(&cfg).unwrap() else {
panic!("expected s3 backend");
};
assert_eq!(
config.public_base_url,
"https://my-bucket.t3.tigrisfiles.io/media"
);
}
#[test]
fn from_config_s3_generic_endpoint_no_public_base_errors() {
let mut cfg = s3_config(Some("my-bucket"));
cfg.public_base_url = None;
cfg.endpoint_url = Some("https://s3.us-east-1.amazonaws.com".to_owned());
let error = MediaStorage::from_config(&cfg).unwrap_err();
assert!(
matches!(error, MediaError::MissingPublicBaseUrl { .. }),
"expected MissingPublicBaseUrl, got {error:?}"
);
assert!(
error.to_string().contains("public_base_url"),
"error must name public_base_url: {error}"
);
}
#[test]
fn from_config_s3_no_endpoint_no_public_base_errors() {
let mut cfg = s3_config(Some("my-bucket"));
cfg.public_base_url = None;
cfg.endpoint_url = None;
let error = MediaStorage::from_config(&cfg).unwrap_err();
assert!(
matches!(error, MediaError::MissingPublicBaseUrl { .. }),
"expected MissingPublicBaseUrl, got {error:?}"
);
assert!(
error.to_string().contains("public_base_url"),
"error must name public_base_url: {error}"
);
}
#[test]
fn from_config_s3_generic_endpoint_with_public_base_uses_it_verbatim() {
let mut cfg = s3_config(Some("my-bucket"));
cfg.endpoint_url = Some("https://s3.us-east-1.amazonaws.com".to_owned());
cfg.public_base_url = Some("https://cdn.example.com/assets".to_owned());
let MediaStorage::S3(config) = MediaStorage::from_config(&cfg).unwrap() else {
panic!("expected s3 backend");
};
assert_eq!(config.public_base_url, "https://cdn.example.com/assets");
assert!(
!config.public_base_url.contains("tigrisfiles.io"),
"generic backend must not derive a tigris URL: {}",
config.public_base_url
);
}
#[test]
fn from_config_s3_respects_explicit_region_endpoint_public_base() {
let mut cfg = s3_config(Some("b"));
cfg.region = Some("us-east-1".to_owned());
cfg.endpoint_url = Some("https://example.r2.cloudflarestorage.com".to_owned());
cfg.public_base_url = Some("https://cdn.example.com".to_owned());
let MediaStorage::S3(config) = MediaStorage::from_config(&cfg).unwrap() else {
panic!("expected s3 backend");
};
assert_eq!(config.region.as_deref(), Some("us-east-1"));
assert_eq!(
config.endpoint_url.as_deref(),
Some("https://example.r2.cloudflarestorage.com")
);
assert_eq!(config.public_base_url, "https://cdn.example.com");
}
#[test]
fn from_config_s3_no_region_yields_none() {
let MediaStorage::S3(config) = MediaStorage::from_config(&s3_config(Some("b"))).unwrap()
else {
panic!("expected s3 backend");
};
assert_eq!(config.region, None);
}
#[test]
fn from_config_s3_threads_explicit_auto_region() {
let mut cfg = s3_config(Some("b"));
cfg.region = Some("auto".to_owned());
let MediaStorage::S3(config) = MediaStorage::from_config(&cfg).unwrap() else {
panic!("expected s3 backend");
};
assert_eq!(config.region.as_deref(), Some("auto"));
}
#[test]
fn from_config_s3_missing_bucket_errors() {
assert!(matches!(
MediaStorage::from_config(&s3_config(None)),
Err(MediaError::MissingBucket)
));
assert!(matches!(
MediaStorage::from_config(&s3_config(Some(" "))),
Err(MediaError::MissingBucket)
));
}
#[test]
fn from_config_s3_partial_credentials_errors() {
let mut cfg = s3_config(Some("b"));
cfg.secret_access_key = None;
assert!(matches!(
MediaStorage::from_config(&cfg),
Err(MediaError::PartialS3Credentials)
));
let mut cfg = s3_config(Some("b"));
cfg.access_key_id = None;
assert!(matches!(
MediaStorage::from_config(&cfg),
Err(MediaError::PartialS3Credentials)
));
}
#[test]
fn from_config_s3_without_credentials_is_ok() {
let mut cfg = s3_config(Some("b"));
cfg.access_key_id = None;
cfg.secret_access_key = None;
let MediaStorage::S3(config) = MediaStorage::from_config(&cfg).unwrap() else {
panic!("expected s3 backend");
};
assert!(config.access_key_id.is_empty());
assert!(config.secret_access_key.is_empty());
}
#[test]
fn secret_access_key_is_redacted_in_debug() {
let mut cfg = s3_config(Some("b"));
cfg.secret_access_key = Some("s3kr3t-value-xyz".to_owned());
cfg.session_token = Some("session-token-value-abc".to_owned());
let storage = MediaStorage::from_config(&cfg).unwrap();
let rendered = format!("{storage:?}");
assert!(rendered.contains("** redacted **"));
assert!(
!rendered.contains("s3kr3t-value-xyz"),
"secret value must not appear: {rendered}"
);
assert!(
!rendered.contains("session-token-value-abc"),
"session token value must not appear: {rendered}"
);
assert!(
rendered.contains("session_token: Some(\"** redacted **\")"),
"session token must render redacted when present: {rendered}"
);
}
#[test]
fn session_token_is_none_in_debug_when_absent() {
let storage = MediaStorage::from_config(&s3_config(Some("b"))).unwrap();
let rendered = format!("{storage:?}");
assert!(
rendered.contains("session_token: None"),
"absent session token must render None: {rendered}"
);
}
#[test]
fn from_config_threads_session_token() {
let mut cfg = s3_config(Some("b"));
cfg.session_token = Some("temp-creds-token".to_owned());
let MediaStorage::S3(config) = MediaStorage::from_config(&cfg).unwrap() else {
panic!("expected s3 backend");
};
assert_eq!(config.session_token.as_deref(), Some("temp-creds-token"));
}
#[test]
fn from_config_blank_session_token_is_none() {
let mut cfg = s3_config(Some("b"));
cfg.session_token = Some(" ".to_owned());
let MediaStorage::S3(config) = MediaStorage::from_config(&cfg).unwrap() else {
panic!("expected s3 backend");
};
assert_eq!(config.session_token, None);
}
#[test]
fn from_config_absent_session_token_is_none() {
let MediaStorage::S3(config) = MediaStorage::from_config(&s3_config(Some("b"))).unwrap()
else {
panic!("expected s3 backend");
};
assert_eq!(config.session_token, None);
}
#[tokio::test]
async fn build_client_constructs_against_fake_endpoint_without_network() {
let mut cfg = s3_config(Some("test-bucket"));
cfg.region = Some("us-east-1".to_owned());
cfg.endpoint_url = Some("http://127.0.0.1:9".to_owned());
cfg.force_path_style = true;
let storage = MediaStorage::from_config(&cfg).unwrap();
let _client = storage.build_client().await;
}
#[tokio::test]
async fn build_client_threads_explicit_region_into_client() {
let mut cfg = s3_config(Some("test-bucket"));
cfg.region = Some("eu-west-2".to_owned());
cfg.endpoint_url = Some("http://127.0.0.1:9".to_owned());
cfg.force_path_style = true;
let storage = MediaStorage::from_config(&cfg).unwrap();
let client = storage.build_client().await;
assert_eq!(
client.config().region().map(Region::as_ref),
Some("eu-west-2")
);
}
}