diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index c68a8d9..4cd2d56 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -33,6 +33,8 @@ pub enum S3AddressingStyle { #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct S3Config { pub bucket: String, + /// Optional key prefix. S3 keys are looked up as `{prefix}/{key}` when set. + pub key_prefix: Option, pub endpoint: Option, pub scheme: String, pub region: String, @@ -80,6 +82,16 @@ fn parse_s3_url(raw: &str) -> Result { })? .to_string(); + // Treat the URL path as a key prefix. A path like `s3://bucket/sub` + // would otherwise behave the same as `s3://bucket`. + let key_prefix = parsed + .path() + .split('/') + .filter(|part| !part.is_empty()) + .collect::>() + .join("/"); + let key_prefix = (!key_prefix.is_empty()).then_some(key_prefix); + let mut endpoint: Option = None; let mut scheme = "https".to_string(); let mut region = "us-east-1".to_string(); @@ -110,6 +122,7 @@ fn parse_s3_url(raw: &str) -> Result { Ok(S3Config { bucket, + key_prefix, endpoint, scheme, region, @@ -411,6 +424,27 @@ mod tests { Ok(()) } + #[test] + fn s3_url_path_becomes_key_prefix() -> Result<(), ConfigError> { + let s3 = parse_s3_url("s3://my-cache/nix?endpoint=s3.example.com")?; + assert_eq!(s3.key_prefix.as_deref(), Some("nix")); + Ok(()) + } + + #[test] + fn s3_url_no_path_means_no_key_prefix() -> Result<(), ConfigError> { + let s3 = parse_s3_url("s3://my-cache?endpoint=s3.example.com")?; + assert_eq!(s3.key_prefix, None); + Ok(()) + } + + #[test] + fn s3_url_multi_segment_path_becomes_key_prefix() -> Result<(), ConfigError> { + let s3 = parse_s3_url("s3://my-cache/a/b?endpoint=s3.example.com")?; + assert_eq!(s3.key_prefix.as_deref(), Some("a/b")); + Ok(()) + } + #[test] fn s3_url_missing_bucket_is_error() { assert!(parse_s3_url("s3://").is_err()); diff --git a/crates/s3/src/lib.rs b/crates/s3/src/lib.rs index b21cde0..b621d09 100644 --- a/crates/s3/src/lib.rs +++ b/crates/s3/src/lib.rs @@ -85,6 +85,7 @@ impl S3ClientPool { ) -> Result, S3Error> { let config = self.config(upstream)?; let client = self.client(upstream, &config).await; + let key = prefixed_key(&config, key); match client .head_object() .bucket(config.bucket) @@ -141,6 +142,7 @@ impl S3ClientPool { ) -> Result, S3Error> { let config = self.config(upstream)?; let client = self.client(upstream, &config).await; + let key = prefixed_key(&config, key); let mut req = client.get_object().bucket(config.bucket).key(key); if let Some(range) = range { req = req.range(range); @@ -208,6 +210,13 @@ impl S3ClientPool { } } +fn prefixed_key(config: &S3Config, key: &str) -> String { + config + .key_prefix + .as_ref() + .map_or_else(|| key.to_string(), |prefix| format!("{prefix}/{key}")) +} + fn is_not_found(err: &E) -> bool { let text = err.to_string(); text.contains("NotFound")