diff --git a/core/Cargo.lock b/core/Cargo.lock index b4359c518160..dbe9d492602f 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -7046,6 +7046,7 @@ dependencies = [ "bytes", "http 1.4.0", "log", + "mea 0.6.0", "opendal-core", "quick-xml", "serde", diff --git a/core/services/swift/Cargo.toml b/core/services/swift/Cargo.toml index eb2914e0b2cb..839c7713f17e 100644 --- a/core/services/swift/Cargo.toml +++ b/core/services/swift/Cargo.toml @@ -34,6 +34,7 @@ all-features = true bytes = { workspace = true } http = { workspace = true } log = { workspace = true } +mea = { workspace = true } opendal-core = { path = "../../core", version = "0.55.0", default-features = false } quick-xml = { workspace = true, features = ["serialize", "overlapped-lists"] } serde = { workspace = true, features = ["derive"] } diff --git a/core/services/swift/src/backend.rs b/core/services/swift/src/backend.rs index a9bf8440fc20..360606071502 100644 --- a/core/services/swift/src/backend.rs +++ b/core/services/swift/src/backend.rs @@ -21,6 +21,7 @@ use std::sync::Arc; use http::Response; use http::StatusCode; use log::debug; +use mea::mutex::Mutex; use super::SWIFT_SCHEME; use super::SwiftConfig; @@ -52,6 +53,9 @@ impl SwiftBuilder { /// /// If user inputs endpoint without scheme, we will /// prepend `https://` to it. + /// + /// When using Keystone v3 authentication, the endpoint can be omitted + /// and will be discovered from the service catalog. pub fn endpoint(mut self, endpoint: &str) -> Self { self.config.endpoint = if endpoint.is_empty() { None @@ -95,6 +99,60 @@ impl SwiftBuilder { } self } + + /// Set the Keystone v3 authentication URL. + /// + /// e.g. `https://keystone.example.com/v3` + pub fn auth_url(mut self, auth_url: &str) -> Self { + if !auth_url.is_empty() { + self.config.auth_url = Some(auth_url.to_string()); + } + self + } + + /// Set the username for Keystone v3 authentication. + pub fn username(mut self, username: &str) -> Self { + if !username.is_empty() { + self.config.username = Some(username.to_string()); + } + self + } + + /// Set the password for Keystone v3 authentication. + pub fn password(mut self, password: &str) -> Self { + if !password.is_empty() { + self.config.password = Some(password.to_string()); + } + self + } + + /// Set the user domain name for Keystone v3 authentication. + /// + /// Defaults to "Default" if not specified. + pub fn user_domain_name(mut self, name: &str) -> Self { + if !name.is_empty() { + self.config.user_domain_name = Some(name.to_string()); + } + self + } + + /// Set the project (tenant) name for Keystone v3 authentication. + pub fn project_name(mut self, name: &str) -> Self { + if !name.is_empty() { + self.config.project_name = Some(name.to_string()); + } + self + } + + /// Set the project domain name for Keystone v3 authentication. + /// + /// Defaults to "Default" if not specified. + pub fn project_domain_name(mut self, name: &str) -> Self { + if !name.is_empty() { + self.config.project_domain_name = Some(name.to_string()); + } + self + } } impl Builder for SwiftBuilder { @@ -107,23 +165,6 @@ impl Builder for SwiftBuilder { let root = normalize_root(&self.config.root.unwrap_or_default()); debug!("backend use root {root}"); - let endpoint = match self.config.endpoint { - Some(endpoint) => { - if endpoint.starts_with("http") { - endpoint - } else { - format!("https://{endpoint}") - } - } - None => { - return Err(Error::new( - ErrorKind::ConfigInvalid, - "missing endpoint for Swift", - )); - } - }; - debug!("backend use endpoint: {}", &endpoint); - let container = match self.config.container { Some(container) => container, None => { @@ -134,39 +175,139 @@ impl Builder for SwiftBuilder { } }; - let token = self.config.token.unwrap_or_default(); - - Ok(SwiftBackend { - core: Arc::new(SwiftCore { - info: { - let am = AccessorInfo::default(); - am.set_scheme(SWIFT_SCHEME) - .set_root(&root) - .set_native_capability(Capability { - stat: true, - read: true, - - write: true, - write_can_empty: true, - write_with_user_metadata: true, - - delete: true, - - list: true, - list_with_recursive: true, - - shared: true, - - ..Default::default() - }); - am.into() - }, - root, - endpoint, - container, - token, - }), - }) + // Determine authentication mode and endpoint. + let has_keystone = self.config.auth_url.is_some(); + let has_token = self.config.token.is_some(); + + let info: Arc = { + let am = AccessorInfo::default(); + am.set_scheme(SWIFT_SCHEME) + .set_root(&root) + .set_native_capability(Capability { + stat: true, + read: true, + + write: true, + write_can_empty: true, + write_with_user_metadata: true, + + delete: true, + + list: true, + list_with_recursive: true, + + shared: true, + + ..Default::default() + }); + am.into() + }; + + if has_keystone { + // Keystone v3 authentication mode. + let auth_url = self.config.auth_url.unwrap(); + let username = self.config.username.ok_or_else(|| { + Error::new( + ErrorKind::ConfigInvalid, + "username is required for Keystone v3 authentication", + ) + })?; + let password = self.config.password.ok_or_else(|| { + Error::new( + ErrorKind::ConfigInvalid, + "password is required for Keystone v3 authentication", + ) + })?; + let project_name = self.config.project_name.ok_or_else(|| { + Error::new( + ErrorKind::ConfigInvalid, + "project_name is required for Keystone v3 authentication", + ) + })?; + let user_domain_name = self + .config + .user_domain_name + .unwrap_or_else(|| "Default".to_string()); + let project_domain_name = self + .config + .project_domain_name + .unwrap_or_else(|| "Default".to_string()); + + let creds = KeystoneCredentials { + auth_url, + username, + password, + user_domain_name, + project_name, + project_domain_name, + }; + + let endpoint_lock = std::sync::OnceLock::new(); + + // If an explicit endpoint was provided, set it now. + if let Some(ep) = self.config.endpoint { + let ep = if ep.starts_with("http") { + ep + } else { + format!("https://{ep}") + }; + let _ = endpoint_lock.set(ep); + } + // Otherwise it will be discovered from the Keystone catalog. + + let signer = SwiftSigner::new_keystone(info.clone(), creds); + + Ok(SwiftBackend { + core: Arc::new(SwiftCore { + info, + root, + + endpoint: endpoint_lock, + container, + signer: Arc::new(Mutex::new(signer)), + }), + }) + } else if has_token { + // Static token mode (existing behavior). + let endpoint = match self.config.endpoint { + Some(endpoint) => { + if endpoint.starts_with("http") { + endpoint + } else { + format!("https://{endpoint}") + } + } + None => { + return Err(Error::new( + ErrorKind::ConfigInvalid, + "missing endpoint for Swift", + )); + } + }; + debug!("backend use endpoint: {}", &endpoint); + + let token = self.config.token.unwrap_or_default(); + let signer = SwiftSigner::new_static(info.clone(), token); + + let endpoint_lock = std::sync::OnceLock::new(); + let _ = endpoint_lock.set(endpoint); + + Ok(SwiftBackend { + core: Arc::new(SwiftCore { + info, + root, + + endpoint: endpoint_lock, + container, + signer: Arc::new(Mutex::new(signer)), + }), + }) + } else { + Err(Error::new( + ErrorKind::ConfigInvalid, + "either token or auth_url (with credentials) must be provided for Swift", + )) + } } } @@ -187,6 +328,9 @@ impl Access for SwiftBackend { } async fn stat(&self, path: &str, _args: OpStat) -> Result { + // Ensure endpoint is resolved (Keystone mode may need first auth). + self.core.ensure_endpoint().await?; + let resp = self.core.swift_get_metadata(path).await?; match resp.status() { @@ -205,6 +349,8 @@ impl Access for SwiftBackend { } async fn read(&self, path: &str, args: OpRead) -> Result<(RpRead, Self::Reader)> { + self.core.ensure_endpoint().await?; + let resp = self.core.swift_read(path, args.range(), &args).await?; let status = resp.status(); @@ -220,6 +366,8 @@ impl Access for SwiftBackend { } async fn write(&self, path: &str, args: OpWrite) -> Result<(RpWrite, Self::Writer)> { + self.core.ensure_endpoint().await?; + let writer = SwiftWriter::new(self.core.clone(), args.clone(), path.to_string()); let w = oio::OneShotWriter::new(writer); @@ -228,6 +376,8 @@ impl Access for SwiftBackend { } async fn delete(&self) -> Result<(RpDelete, Self::Deleter)> { + self.core.ensure_endpoint().await?; + Ok(( RpDelete::default(), oio::OneShotDeleter::new(SwiftDeleter::new(self.core.clone())), @@ -235,6 +385,8 @@ impl Access for SwiftBackend { } async fn list(&self, path: &str, args: OpList) -> Result<(RpList, Self::Lister)> { + self.core.ensure_endpoint().await?; + let l = SwiftLister::new( self.core.clone(), path.to_string(), @@ -246,6 +398,8 @@ impl Access for SwiftBackend { } async fn copy(&self, from: &str, to: &str, _args: OpCopy) -> Result { + self.core.ensure_endpoint().await?; + // cannot copy objects larger than 5 GB. // Reference: https://docs.openstack.org/api-ref/object-store/#copy-object let resp = self.core.swift_copy(from, to).await?; diff --git a/core/services/swift/src/config.rs b/core/services/swift/src/config.rs index 63e928aac7d2..b4b6c3e5c2f4 100644 --- a/core/services/swift/src/config.rs +++ b/core/services/swift/src/config.rs @@ -29,13 +29,37 @@ use super::backend::SwiftBuilder; #[non_exhaustive] pub struct SwiftConfig { /// The endpoint for Swift. + /// + /// When using Keystone v3 authentication, this can be omitted and will be + /// discovered from the service catalog. pub endpoint: Option, /// The container for Swift. pub container: Option, /// The root for Swift. pub root: Option, /// The token for Swift. + /// + /// When using Keystone v3 authentication, this is not needed — a token + /// will be acquired and refreshed automatically. pub token: Option, + /// The Keystone v3 authentication URL. + /// + /// e.g. `https://keystone.example.com/v3` + pub auth_url: Option, + /// The username for Keystone v3 authentication. + pub username: Option, + /// The password for Keystone v3 authentication. + pub password: Option, + /// The user domain name for Keystone v3 authentication. + /// + /// Defaults to "Default" if not specified. + pub user_domain_name: Option, + /// The project (tenant) name for Keystone v3 authentication. + pub project_name: Option, + /// The project domain name for Keystone v3 authentication. + /// + /// Defaults to "Default" if not specified. + pub project_domain_name: Option, } impl Debug for SwiftConfig { @@ -57,10 +81,10 @@ impl opendal_core::Configurator for SwiftConfig { if let Some(authority) = uri.authority() { map.entry("endpoint".to_string()) .or_insert_with(|| format!("https://{authority}")); - } else if !map.contains_key("endpoint") { + } else if !map.contains_key("endpoint") && !map.contains_key("auth_url") { return Err(opendal_core::Error::new( opendal_core::ErrorKind::ConfigInvalid, - "endpoint is required", + "endpoint or auth_url is required", ) .with_context("service", SWIFT_SCHEME)); } diff --git a/core/services/swift/src/core.rs b/core/services/swift/src/core.rs index df521fcf29cd..2b314b20d91b 100644 --- a/core/services/swift/src/core.rs +++ b/core/services/swift/src/core.rs @@ -18,20 +18,220 @@ use std::fmt::Debug; use std::sync::Arc; +use bytes::Buf; +use bytes::Bytes; use http::Request; use http::Response; +use http::StatusCode; use http::header; +use http::header::CONTENT_TYPE; +use mea::mutex::Mutex; use serde::Deserialize; use opendal_core::raw::*; use opendal_core::*; +use std::sync::OnceLock; + +/// Authentication mode for Swift. +pub enum SwiftAuth { + /// Static token provided directly. + Token, + /// Keystone v3 credentials for automatic token acquisition and refresh. + Keystone(KeystoneCredentials), +} + +/// Keystone v3 credentials. +pub struct KeystoneCredentials { + pub auth_url: String, + pub username: String, + pub password: String, + pub user_domain_name: String, + pub project_name: String, + pub project_domain_name: String, +} + +/// Signer that manages token lifecycle for Swift. +/// +/// In static-token mode, the token is used as-is with no refresh. +/// In Keystone mode, the token is acquired on first use and refreshed +/// automatically when it approaches expiry (2-minute grace period). +pub struct SwiftSigner { + pub info: Arc, + + /// Authentication mode. + pub auth: SwiftAuth, + + /// Cached token. + pub token: String, + /// Token expiry. Set to `Timestamp::MAX` for static tokens (never expire). + pub expires_in: Timestamp, + /// Storage URL discovered from Keystone catalog. Empty for static-token mode + /// (endpoint is on SwiftCore directly). + pub storage_url: String, +} + +impl SwiftSigner { + /// Create a signer for static-token authentication. + pub fn new_static(info: Arc, token: String) -> Self { + SwiftSigner { + info, + auth: SwiftAuth::Token, + token, + expires_in: Timestamp::MAX, + storage_url: String::new(), + } + } + + /// Create a signer for Keystone v3 authentication. + pub fn new_keystone(info: Arc, creds: KeystoneCredentials) -> Self { + SwiftSigner { + info, + auth: SwiftAuth::Keystone(creds), + token: String::new(), + expires_in: Timestamp::MIN, // forces first-call refresh + storage_url: String::new(), + } + } + + /// Insert the `X-Auth-Token` header, refreshing if needed. + pub async fn sign(&mut self, req: &mut Request) -> Result<()> { + if !self.token.is_empty() && self.expires_in > Timestamp::now() { + let value = self + .token + .parse() + .expect("token must be valid header value"); + req.headers_mut().insert("X-Auth-Token", value); + return Ok(()); + } + + self.acquire_token().await?; + + let value = self + .token + .parse() + .expect("token must be valid header value"); + req.headers_mut().insert("X-Auth-Token", value); + Ok(()) + } + + /// Acquire a Keystone v3 token. + async fn acquire_token(&mut self) -> Result<()> { + let creds = match &self.auth { + SwiftAuth::Token => { + return Err(Error::new( + ErrorKind::Unexpected, + "static token expired or missing — cannot refresh", + )); + } + SwiftAuth::Keystone(creds) => creds, + }; + + let url = format!("{}/auth/tokens", creds.auth_url.trim_end_matches('/')); + + let body = serde_json::json!({ + "auth": { + "identity": { + "methods": ["password"], + "password": { + "user": { + "name": creds.username, + "domain": { "name": creds.user_domain_name }, + "password": creds.password, + } + } + }, + "scope": { + "project": { + "name": creds.project_name, + "domain": { "name": creds.project_domain_name }, + } + } + } + }); + + let body_bytes = Bytes::from(serde_json::to_vec(&body).map_err(new_json_serialize_error)?); + + let request = Request::post(&url) + .header(CONTENT_TYPE, "application/json") + .header(header::CONTENT_LENGTH, body_bytes.len()) + .body(Buffer::from(body_bytes)) + .map_err(new_request_build_error)?; + + let resp = self.info.http_client().send(request).await?; + + match resp.status() { + StatusCode::CREATED => { + // Token is in the response header. + let token = resp + .headers() + .get("X-Subject-Token") + .ok_or_else(|| { + Error::new( + ErrorKind::Unexpected, + "Keystone response missing X-Subject-Token header", + ) + })? + .to_str() + .map_err(|_| { + Error::new( + ErrorKind::Unexpected, + "X-Subject-Token header contains non-ASCII characters", + ) + })? + .to_string(); + + let body = resp.into_body(); + let data: KeystoneTokenResponse = + serde_json::from_reader(body.reader()).map_err(new_json_deserialize_error)?; + + // Parse expiry with 2-minute grace period. + let expires_at: Timestamp = data.token.expires_at.parse()?; + self.expires_in = expires_at - Duration::from_secs(120); + + self.token = token; + + // Extract Swift storage URL from the service catalog. + if let Some(url) = extract_swift_endpoint(&data.token.catalog) { + self.storage_url = url; + } + + Ok(()) + } + _ => { + let status = resp.status(); + let body = resp.into_body(); + let body_str = String::from_utf8_lossy(&body.to_bytes()).to_string(); + Err(Error::new( + ErrorKind::Unexpected, + format!("Keystone authentication failed with status {status}: {body_str}"), + )) + } + } + } +} + +/// Extract the public Swift storage URL from the Keystone service catalog. +fn extract_swift_endpoint(catalog: &[KeystoneCatalogEntry]) -> Option { + catalog + .iter() + .find(|entry| entry.service_type == "object-store") + .and_then(|entry| { + entry + .endpoints + .iter() + .find(|ep| ep.interface == "public") + .map(|ep| ep.url.trim_end_matches('/').to_string()) + }) +} + pub struct SwiftCore { pub info: Arc, pub root: String, - pub endpoint: String, + /// Lazily resolved endpoint. Set from config or discovered via Keystone catalog. + pub endpoint: OnceLock, pub container: String, - pub token: String, + pub signer: Arc>, } impl Debug for SwiftCore { @@ -45,27 +245,74 @@ impl Debug for SwiftCore { } impl SwiftCore { + /// Sign a request with the current token. + pub async fn sign(&self, req: &mut Request) -> Result<()> { + let mut signer = self.signer.lock().await; + signer.sign(req).await + } + + /// Get the resolved endpoint, or return an error if not yet available. + fn get_endpoint(&self) -> Result<&str> { + self.endpoint.get().map(|s| s.as_str()).ok_or_else(|| { + Error::new( + ErrorKind::ConfigInvalid, + "Swift endpoint not yet resolved — call ensure_endpoint() first", + ) + }) + } + + /// Ensure the endpoint is resolved. + /// + /// For static-token mode, this is a no-op (set at build time). + /// For Keystone mode without explicit endpoint, this triggers a token + /// acquisition to discover the storage URL from the service catalog. + pub async fn ensure_endpoint(&self) -> Result<()> { + if self.endpoint.get().is_some() { + return Ok(()); + } + + // Need to discover from Keystone. + let mut signer = self.signer.lock().await; + + // Double-check after acquiring lock. + if self.endpoint.get().is_some() { + return Ok(()); + } + + // Trigger token acquisition if needed. + if signer.token.is_empty() || signer.expires_in <= Timestamp::now() { + signer.acquire_token().await?; + } + + if signer.storage_url.is_empty() { + return Err(Error::new( + ErrorKind::ConfigInvalid, + "Keystone service catalog does not contain an object-store endpoint", + )); + } + + // This may race but OnceLock handles that safely. + let _ = self.endpoint.set(signer.storage_url.clone()); + Ok(()) + } + pub async fn swift_delete(&self, path: &str) -> Result> { let p = build_abs_path(&self.root, path); let url = format!( "{}/{}/{}", - &self.endpoint, + self.get_endpoint()?, &self.container, percent_encode_path(&p) ); - let mut req = Request::delete(&url); - - req = req.header("X-Auth-Token", &self.token); - - let body = Buffer::new(); - - let req = req + let mut req = Request::delete(&url) .extension(Operation::Delete) - .body(body) + .body(Buffer::new()) .map_err(new_request_build_error)?; + self.sign(&mut req).await?; + self.info.http_client().send(req).await } @@ -80,10 +327,11 @@ impl SwiftCore { // The delimiter is used to disable recursive listing. // Swift returns a 200 status code when there is no such pseudo directory in prefix. - let mut url = QueryPairsWriter::new(&format!("{}/{}/", &self.endpoint, &self.container,)) - .push("prefix", &percent_encode_path(&p)) - .push("delimiter", delimiter) - .push("format", "json"); + let mut url = + QueryPairsWriter::new(&format!("{}/{}/", self.get_endpoint()?, &self.container)) + .push("prefix", &percent_encode_path(&p)) + .push("delimiter", delimiter) + .push("format", "json"); if let Some(limit) = limit { url = url.push("limit", &limit.to_string()); @@ -92,15 +340,13 @@ impl SwiftCore { url = url.push("marker", marker); } - let mut req = Request::get(url.finish()); - - req = req.header("X-Auth-Token", &self.token); - - let req = req + let mut req = Request::get(url.finish()) .extension(Operation::List) .body(Buffer::new()) .map_err(new_request_build_error)?; + self.sign(&mut req).await?; + self.info.http_client().send(req).await } @@ -114,28 +360,29 @@ impl SwiftCore { let p = build_abs_path(&self.root, path); let url = format!( "{}/{}/{}", - &self.endpoint, + self.get_endpoint()?, &self.container, percent_encode_path(&p) ); - let mut req = Request::put(&url); + let mut builder = Request::put(&url); // Set user metadata headers. if let Some(user_metadata) = args.user_metadata() { for (k, v) in user_metadata { - req = req.header(format!("X-Object-Meta-{k}"), v); + builder = builder.header(format!("X-Object-Meta-{k}"), v); } } - req = req.header("X-Auth-Token", &self.token); - req = req.header(header::CONTENT_LENGTH, length); + builder = builder.header(header::CONTENT_LENGTH, length); - let req = req + let mut req = builder .extension(Operation::Write) .body(body) .map_err(new_request_build_error)?; + self.sign(&mut req).await?; + self.info.http_client().send(req).await } @@ -151,24 +398,24 @@ impl SwiftCore { let url = format!( "{}/{}/{}", - &self.endpoint, + self.get_endpoint()?, &self.container, percent_encode_path(&p) ); - let mut req = Request::get(&url); - - req = req.header("X-Auth-Token", &self.token); + let mut builder = Request::get(&url); if !range.is_full() { - req = req.header(header::RANGE, range.to_header()); + builder = builder.header(header::RANGE, range.to_header()); } - let req = req + let mut req = builder .extension(Operation::Read) .body(Buffer::new()) .map_err(new_request_build_error)?; + self.sign(&mut req).await?; + self.info.http_client().fetch(req).await } @@ -187,28 +434,22 @@ impl SwiftCore { let url = format!( "{}/{}/{}", - &self.endpoint, + self.get_endpoint()?, &self.container, percent_encode_path(&dst_p) ); // Request method doesn't support for COPY, we use PUT instead. // Reference: https://docs.openstack.org/api-ref/object-store/#copy-object - let mut req = Request::put(&url); - - req = req.header("X-Auth-Token", &self.token); - req = req.header("X-Copy-From", percent_encode_path(&src_p)); - - // if use PUT method, we need to set the content-length to 0. - req = req.header("Content-Length", "0"); - - let body = Buffer::new(); - - let req = req + let mut req = Request::put(&url) + .header("X-Copy-From", percent_encode_path(&src_p)) + .header("Content-Length", "0") .extension(Operation::Copy) - .body(body) + .body(Buffer::new()) .map_err(new_request_build_error)?; + self.sign(&mut req).await?; + self.info.http_client().send(req).await } @@ -217,24 +458,51 @@ impl SwiftCore { let url = format!( "{}/{}/{}", - &self.endpoint, + self.get_endpoint()?, &self.container, percent_encode_path(&p) ); - let mut req = Request::head(&url); - - req = req.header("X-Auth-Token", &self.token); - - let req = req + let mut req = Request::head(&url) .extension(Operation::Stat) .body(Buffer::new()) .map_err(new_request_build_error)?; + self.sign(&mut req).await?; + self.info.http_client().send(req).await } } +// --- Keystone v3 deserialization types --- + +#[derive(Debug, Deserialize)] +struct KeystoneTokenResponse { + token: KeystoneToken, +} + +#[derive(Debug, Deserialize)] +struct KeystoneToken { + expires_at: String, + #[serde(default)] + catalog: Vec, +} + +#[derive(Debug, Deserialize)] +struct KeystoneCatalogEntry { + #[serde(rename = "type")] + service_type: String, + endpoints: Vec, +} + +#[derive(Debug, Deserialize)] +struct KeystoneEndpoint { + interface: String, + url: String, +} + +// --- List response types --- + #[derive(Debug, Eq, PartialEq, Deserialize)] #[serde(untagged)] pub enum ListOpResponse { @@ -307,4 +575,194 @@ mod tests { Ok(()) } + + #[test] + fn extract_swift_endpoint_from_catalog() { + let catalog = vec![ + KeystoneCatalogEntry { + service_type: "identity".to_string(), + endpoints: vec![KeystoneEndpoint { + interface: "public".to_string(), + url: "https://keystone.example.com/v3".to_string(), + }], + }, + KeystoneCatalogEntry { + service_type: "object-store".to_string(), + endpoints: vec![ + KeystoneEndpoint { + interface: "admin".to_string(), + url: "https://admin.swift.example.com".to_string(), + }, + KeystoneEndpoint { + interface: "public".to_string(), + url: "https://swift.example.com/v1/AUTH_abc".to_string(), + }, + ], + }, + ]; + + assert_eq!( + extract_swift_endpoint(&catalog), + Some("https://swift.example.com/v1/AUTH_abc".to_string()) + ); + } + + #[test] + fn extract_swift_endpoint_missing() { + let catalog = vec![KeystoneCatalogEntry { + service_type: "identity".to_string(), + endpoints: vec![KeystoneEndpoint { + interface: "public".to_string(), + url: "https://keystone.example.com/v3".to_string(), + }], + }]; + + assert_eq!(extract_swift_endpoint(&catalog), None); + } + + #[test] + fn extract_swift_endpoint_strips_trailing_slash() { + let catalog = vec![KeystoneCatalogEntry { + service_type: "object-store".to_string(), + endpoints: vec![KeystoneEndpoint { + interface: "public".to_string(), + url: "https://swift.example.com/v1/AUTH_abc/".to_string(), + }], + }]; + + assert_eq!( + extract_swift_endpoint(&catalog), + Some("https://swift.example.com/v1/AUTH_abc".to_string()) + ); + } + + #[test] + fn extract_swift_endpoint_prefers_public() { + let catalog = vec![KeystoneCatalogEntry { + service_type: "object-store".to_string(), + endpoints: vec![ + KeystoneEndpoint { + interface: "internal".to_string(), + url: "https://internal.swift.example.com/v1/AUTH_abc".to_string(), + }, + KeystoneEndpoint { + interface: "public".to_string(), + url: "https://public.swift.example.com/v1/AUTH_abc".to_string(), + }, + KeystoneEndpoint { + interface: "admin".to_string(), + url: "https://admin.swift.example.com".to_string(), + }, + ], + }]; + + assert_eq!( + extract_swift_endpoint(&catalog), + Some("https://public.swift.example.com/v1/AUTH_abc".to_string()) + ); + } + + #[test] + fn parse_keystone_token_response() { + let json = r#"{ + "token": { + "methods": ["password"], + "expires_at": "2026-02-22T17:20:42.000000Z", + "catalog": [ + { + "type": "object-store", + "endpoints": [ + { + "interface": "public", + "url": "https://swift.example.com/v1/AUTH_abc", + "region_id": "RegionOne", + "region": "RegionOne" + } + ], + "id": "abc123", + "name": "swift" + }, + { + "type": "identity", + "endpoints": [ + { + "interface": "public", + "url": "https://keystone.example.com/v3", + "region_id": "RegionOne", + "region": "RegionOne" + } + ], + "id": "def456", + "name": "keystone" + } + ], + "user": {"name": "testuser", "id": "uid123"}, + "project": {"name": "testproject", "id": "pid456"} + } + }"#; + + let resp: KeystoneTokenResponse = + serde_json::from_str(json).expect("should parse Keystone response"); + assert_eq!(resp.token.expires_at, "2026-02-22T17:20:42.000000Z"); + assert_eq!(resp.token.catalog.len(), 2); + + let swift_url = extract_swift_endpoint(&resp.token.catalog); + assert_eq!( + swift_url, + Some("https://swift.example.com/v1/AUTH_abc".to_string()) + ); + } + + #[test] + fn parse_keystone_token_response_minimal() { + // Some Keystone responses may have extra fields we don't use. + // Verify we deserialize correctly with only the fields we need. + let json = r#"{ + "token": { + "expires_at": "2025-01-01T00:00:00Z" + } + }"#; + + let resp: KeystoneTokenResponse = + serde_json::from_str(json).expect("should parse minimal response"); + assert_eq!(resp.token.expires_at, "2025-01-01T00:00:00Z"); + assert!(resp.token.catalog.is_empty()); + } + + #[test] + fn parse_keystone_expiry_as_timestamp() { + // Verify the expires_at string can be parsed as our Timestamp type + let expires_at = "2026-02-22T17:20:42.000000Z"; + let ts: Timestamp = expires_at.parse().expect("should parse as Timestamp"); + // Verify it's in the future (or at least parseable and comparable) + assert!(ts > Timestamp::MIN); + } + + #[test] + fn static_signer_never_expires() { + let info: Arc = AccessorInfo::default().into(); + let signer = SwiftSigner::new_static(info, "test-token".to_string()); + assert_eq!(signer.token, "test-token"); + assert_eq!(signer.expires_in, Timestamp::MAX); + // Static signer's token should always be considered valid + assert!(signer.expires_in > Timestamp::now()); + } + + #[test] + fn keystone_signer_starts_expired() { + let info: Arc = AccessorInfo::default().into(); + let creds = KeystoneCredentials { + auth_url: "https://keystone.example.com/v3".to_string(), + username: "user".to_string(), + password: "pass".to_string(), + user_domain_name: "Default".to_string(), + project_name: "project".to_string(), + project_domain_name: "Default".to_string(), + }; + let signer = SwiftSigner::new_keystone(info, creds); + assert!(signer.token.is_empty()); + assert_eq!(signer.expires_in, Timestamp::MIN); + // Should be considered expired, forcing token acquisition on first use + assert!(signer.expires_in <= Timestamp::now()); + } }