Cloud stores take credentials handed over 7026bd7dc5b8
oxy_strata::Credentials (an S3 access key, a Google service account key, an Azure service principal), CloudStore::open_with, and set_credentials_lookup for stores opened by URL. Given credentials are the only ones used: nothing from the environment or the providers' files. Kept as JSON for sealing; Debug never shows a secret. Checked by conformance against the real S3, GCS and Azure test stores with the credentials handed over. refs #68
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-05 01:17 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
7026bd7dc5b89e7162a2d846476d1370c902d5b9067f3355470110e3cec205d5- Depends on
- 2cd40ea1c04c 8d80356371c5 8fd78eb52ad2 c07406bcc237 d069701a4997
- Depended on by
- 2ddd471651cd 579afd107b9b c9369ad8b1b1
- In bookmarks
- bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
- In tags
- v0.10.0 v0.11.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
- Edits
- 19 atoms, 0 tree operations, 6 files
Browse the tree at this change
modified CHANGELOG.md +2 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **Cloud stores take credentials handed over (#68).** `oxy_strata::Credentials` (an S3 access key, a Google service account key, an Azure service principal) can be given when a store is opened, and a program can register a lookup for the stores it opens by URL. With them, nothing from the environment or the providers' files is used, which lets a hub keep each repository's credentials itself. Their debug form never shows a secret. |
|
| 6 | + | ||
| 5 | 7 | - **A repository kept on a store (#68).** `oxy init --store URL` keeps a repository's objects and operation log on `dir://`, S3, GCS or Azure, behind a local cache of `storage.cache` bytes (2 GiB by default) that can be deleted at any time. Checked against the real services: a commit takes 0.5 to 0.9 seconds, and reopening with no cache about half a second. |
|
| 6 | 8 | ||
| 7 | 9 | - **A caching store, and the operation head on a store (#68).** `strata::CachingStore` puts a local directory in front of any store: reads are kept, writes go to the remote first, the cache stays under a size limit by removing what was used least recently, and references always come from the remote. A repository kept on a store other than this disk's directory now holds its operation head there, moved by compare-and-swap with merge and retry. |
|
| … | 110 unchanged lines |
modified crates/oxy-strata/src/aws.rs +33 −7
| … | 94 unchanged lines |
||
| 95 | 95 | }) |
|
| 96 | 96 | } |
|
| 97 | 97 | ||
| 98 | - | pub fn open(url: &StoreUrl) -> Result<AmazonS3> { |
|
| 98 | + | /// With `keys` given, only they are used: nothing from the environment |
|
| 99 | + | /// or a profile, and the region and endpoint come from the URL alone. |
|
| 100 | + | pub fn open(url: &StoreUrl, keys: Option<(&str, &str, Option<&str>)>) -> Result<AmazonS3> { |
|
| 101 | + | if let Some((id, secret, token)) = keys { |
|
| 102 | + | let mut b = AmazonS3Builder::new() |
|
| 103 | + | .with_bucket_name(&url.host) |
|
| 104 | + | .with_http_connector(crate::http::UreqConnector) |
|
| 105 | + | .with_conditional_put(S3ConditionalPut::ETagMatch) |
|
| 106 | + | .with_access_key_id(id) |
|
| 107 | + | .with_secret_access_key(secret); |
|
| 108 | + | if let Some(t) = token { |
|
| 109 | + | b = b.with_token(t); |
|
| 110 | + | } |
|
| 111 | + | let endpoint = url.param("endpoint").map(str::to_string); |
|
| 112 | + | return finish(b, url, endpoint, url.param("region").map(str::to_string)); |
|
| 113 | + | } |
|
| 99 | 114 | let bucket = url.host.clone(); |
|
| 100 | 115 | let explicit_profile = url.param("profile").map(str::to_string); |
|
| 101 | 116 | let profile = explicit_profile |
|
| … | 61 unchanged lines |
||
| 163 | 178 | .or_else(|| env("AWS_ENDPOINT_URL")) |
|
| 164 | 179 | .or_else(|| env("AWS_ENDPOINT")) |
|
| 165 | 180 | .or_else(|| section.and_then(|s| s.get("endpoint_url").cloned())); |
|
| 181 | + | let region = url |
|
| 182 | + | .param("region") |
|
| 183 | + | .map(str::to_string) |
|
| 184 | + | .or_else(|| env("AWS_REGION")) |
|
| 185 | + | .or_else(|| env("AWS_DEFAULT_REGION")) |
|
| 186 | + | .or_else(|| section.and_then(|s| s.get("region").cloned())); |
|
| 187 | + | finish(b, url, endpoint, region) |
|
| 188 | + | } |
|
| 189 | + | ||
| 190 | + | /// The endpoint and region, then the store. |
|
| 191 | + | fn finish( |
|
| 192 | + | mut b: AmazonS3Builder, |
|
| 193 | + | url: &StoreUrl, |
|
| 194 | + | endpoint: Option<String>, |
|
| 195 | + | region: Option<String>, |
|
| 196 | + | ) -> Result<AmazonS3> { |
|
| 197 | + | let bucket = url.host.clone(); |
|
| 166 | 198 | if let Some(e) = &endpoint { |
|
| 167 | 199 | b = b |
|
| 168 | 200 | .with_endpoint(e.clone()) |
|
| … | 2 unchanged lines |
||
| 171 | 203 | b = b.with_virtual_hosted_style_request(false); |
|
| 172 | 204 | } |
|
| 173 | 205 | ||
| 174 | - | let region = url |
|
| 175 | - | .param("region") |
|
| 176 | - | .map(str::to_string) |
|
| 177 | - | .or_else(|| env("AWS_REGION")) |
|
| 178 | - | .or_else(|| env("AWS_DEFAULT_REGION")) |
|
| 179 | - | .or_else(|| section.and_then(|s| s.get("region").cloned())); |
|
| 180 | 206 | let region = match region { |
|
| 181 | 207 | Some(r) => r, |
|
| 182 | 208 | None if endpoint.is_some() => "us-east-1".into(), |
|
| … | 27 unchanged lines |
modified crates/oxy-strata/src/azure.rs +22 −1
| … | 17 unchanged lines |
||
| 18 | 18 | } |
|
| 19 | 19 | ||
| 20 | 20 | /// The store and the prefix under the container. |
|
| 21 | - | pub fn open(url: &StoreUrl) -> Result<(MicrosoftAzure, String)> { |
|
| 21 | + | /// With `principal` (tenant, client id, client secret) given, only it |
|
| 22 | + | /// is used. |
|
| 23 | + | pub fn open( |
|
| 24 | + | url: &StoreUrl, |
|
| 25 | + | principal: Option<(&str, &str, &str)>, |
|
| 26 | + | ) -> Result<(MicrosoftAzure, String)> { |
|
| 22 | 27 | let (container, prefix) = match url.path.split_once('/') { |
|
| 23 | 28 | Some((c, p)) => (c.to_string(), p.to_string()), |
|
| 24 | 29 | None if !url.path.is_empty() => (url.path.clone(), String::new()), |
|
| … | 4 unchanged lines |
||
| 29 | 34 | ))) |
|
| 30 | 35 | } |
|
| 31 | 36 | }; |
|
| 37 | + | if let Some((tenant, id, secret)) = principal { |
|
| 38 | + | let mut b = MicrosoftAzureBuilder::new() |
|
| 39 | + | .with_account(&url.host) |
|
| 40 | + | .with_container_name(&container) |
|
| 41 | + | .with_http_connector(crate::http::UreqConnector) |
|
| 42 | + | .with_client_secret_authorization(id, secret, tenant); |
|
| 43 | + | if let Some(endpoint) = url.param("endpoint") { |
|
| 44 | + | b = b |
|
| 45 | + | .with_endpoint(endpoint.to_string()) |
|
| 46 | + | .with_allow_http(endpoint.starts_with("http://")); |
|
| 47 | + | } |
|
| 48 | + | let store = b |
|
| 49 | + | .build() |
|
| 50 | + | .map_err(|e| Error::Backend(format!("{}: {e}", url.plain())))?; |
|
| 51 | + | return Ok((store, prefix)); |
|
| 52 | + | } |
|
| 32 | 53 | let mut b = MicrosoftAzureBuilder::from_env() |
|
| 33 | 54 | .with_account(&url.host) |
|
| 34 | 55 | .with_container_name(&container) |
|
| … | 32 unchanged lines |
modified crates/oxy-strata/src/gcp.rs +14 −1
| … | 27 unchanged lines |
||
| 28 | 28 | .unwrap_or(false) |
|
| 29 | 29 | } |
|
| 30 | 30 | ||
| 31 | - | pub fn open(url: &StoreUrl) -> Result<GoogleCloudStorage> { |
|
| 31 | + | /// With `key` (a service account key's JSON) given, only it is used. |
|
| 32 | + | pub fn open(url: &StoreUrl, key: Option<&str>) -> Result<GoogleCloudStorage> { |
|
| 33 | + | if let Some(key) = key { |
|
| 34 | + | let mut b = GoogleCloudStorageBuilder::new() |
|
| 35 | + | .with_bucket_name(&url.host) |
|
| 36 | + | .with_http_connector(crate::http::UreqConnector) |
|
| 37 | + | .with_service_account_key(key); |
|
| 38 | + | if let Some(endpoint) = url.param("endpoint") { |
|
| 39 | + | b = b.with_base_url(endpoint); |
|
| 40 | + | } |
|
| 41 | + | return b |
|
| 42 | + | .build() |
|
| 43 | + | .map_err(|e| Error::Backend(format!("{}: {e}", url.plain()))); |
|
| 44 | + | } |
|
| 32 | 45 | let mut b = GoogleCloudStorageBuilder::from_env() |
|
| 33 | 46 | .with_bucket_name(&url.host) |
|
| 34 | 47 | .with_http_connector(crate::http::UreqConnector); |
|
| … | 24 unchanged lines |
modified crates/oxy-strata/src/lib.rs +216 −4
| … | 45 unchanged lines |
||
| 46 | 46 | if !matches!(scheme, "s3" | "gs" | "az") { |
|
| 47 | 47 | return None; |
|
| 48 | 48 | } |
|
| 49 | - | Some(CloudStore::open(url).map(|s| Box::new(s) as Box<dyn Store>)) |
|
| 49 | + | let lookup = *CREDENTIALS.lock().unwrap_or_else(|p| p.into_inner()); |
|
| 50 | + | let given = lookup.and_then(|f| f(url)); |
|
| 51 | + | Some(CloudStore::open_with(url, given.as_ref()).map(|s| Box::new(s) as Box<dyn Store>)) |
|
| 50 | 52 | } |
|
| 51 | 53 | ||
| 54 | + | /// Credentials a program keeps itself and hands over, instead of the |
|
| 55 | + | /// provider's usual places: a hub keeps each repository's sealed. When a |
|
| 56 | + | /// store is opened with them, nothing from the environment or the |
|
| 57 | + | /// provider's files is used. |
|
| 58 | + | #[derive(Clone, PartialEq, Eq)] |
|
| 59 | + | pub enum Credentials { |
|
| 60 | + | /// An S3 access key. |
|
| 61 | + | Aws { |
|
| 62 | + | access_key_id: String, |
|
| 63 | + | secret_access_key: String, |
|
| 64 | + | session_token: Option<String>, |
|
| 65 | + | }, |
|
| 66 | + | /// A Google Cloud service account key, as the JSON Google issues. |
|
| 67 | + | Gcp { service_account_key: String }, |
|
| 68 | + | /// An Azure service principal. |
|
| 69 | + | Azure { |
|
| 70 | + | tenant_id: String, |
|
| 71 | + | client_id: String, |
|
| 72 | + | client_secret: String, |
|
| 73 | + | }, |
|
| 74 | + | } |
|
| 75 | + | ||
| 76 | + | impl std::fmt::Debug for Credentials { |
|
| 77 | + | /// Names the kind and the public part only: never the secret. |
|
| 78 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
|
| 79 | + | match self { |
|
| 80 | + | Credentials::Aws { access_key_id, .. } => { |
|
| 81 | + | write!(f, "Aws({access_key_id}, secret hidden)") |
|
| 82 | + | } |
|
| 83 | + | Credentials::Gcp { .. } => write!(f, "Gcp(service account key hidden)"), |
|
| 84 | + | Credentials::Azure { client_id, .. } => write!(f, "Azure({client_id}, secret hidden)"), |
|
| 85 | + | } |
|
| 86 | + | } |
|
| 87 | + | } |
|
| 88 | + | ||
| 89 | + | impl Credentials { |
|
| 90 | + | /// The scheme of the stores these are for. |
|
| 91 | + | pub fn scheme(&self) -> &'static str { |
|
| 92 | + | match self { |
|
| 93 | + | Credentials::Aws { .. } => "s3", |
|
| 94 | + | Credentials::Gcp { .. } => "gs", |
|
| 95 | + | Credentials::Azure { .. } => "az", |
|
| 96 | + | } |
|
| 97 | + | } |
|
| 98 | + | ||
| 99 | + | /// As JSON, for keeping (sealed) and reading back with [`Credentials::from_json`]. |
|
| 100 | + | pub fn to_json(&self) -> String { |
|
| 101 | + | let v = match self { |
|
| 102 | + | Credentials::Aws { |
|
| 103 | + | access_key_id, |
|
| 104 | + | secret_access_key, |
|
| 105 | + | session_token, |
|
| 106 | + | } => serde_json::json!({ |
|
| 107 | + | "kind": "aws", "access_key_id": access_key_id, "secret_access_key": secret_access_key, |
|
| 108 | + | "session_token": session_token, |
|
| 109 | + | }), |
|
| 110 | + | Credentials::Gcp { |
|
| 111 | + | service_account_key, |
|
| 112 | + | } => { |
|
| 113 | + | serde_json::json!({ "kind": "gcp", "service_account_key": service_account_key }) |
|
| 114 | + | } |
|
| 115 | + | Credentials::Azure { |
|
| 116 | + | tenant_id, |
|
| 117 | + | client_id, |
|
| 118 | + | client_secret, |
|
| 119 | + | } => serde_json::json!({ |
|
| 120 | + | "kind": "azure", "tenant_id": tenant_id, "client_id": client_id, "client_secret": client_secret, |
|
| 121 | + | }), |
|
| 122 | + | }; |
|
| 123 | + | v.to_string() |
|
| 124 | + | } |
|
| 125 | + | ||
| 126 | + | pub fn from_json(text: &str) -> Result<Credentials> { |
|
| 127 | + | let v: serde_json::Value = serde_json::from_str(text) |
|
| 128 | + | .map_err(|e| Error::Backend(format!("credentials are not JSON: {e}")))?; |
|
| 129 | + | let field = |k: &str| -> Result<String> { |
|
| 130 | + | v.get(k) |
|
| 131 | + | .and_then(|x| x.as_str()) |
|
| 132 | + | .filter(|x| !x.trim().is_empty()) |
|
| 133 | + | .map(|x| x.trim().to_string()) |
|
| 134 | + | .ok_or_else(|| Error::Backend(format!("credentials need {k}"))) |
|
| 135 | + | }; |
|
| 136 | + | match v.get("kind").and_then(|k| k.as_str()) { |
|
| 137 | + | Some("aws") => Ok(Credentials::Aws { |
|
| 138 | + | access_key_id: field("access_key_id")?, |
|
| 139 | + | secret_access_key: field("secret_access_key")?, |
|
| 140 | + | session_token: field("session_token").ok(), |
|
| 141 | + | }), |
|
| 142 | + | Some("gcp") => { |
|
| 143 | + | let key = field("service_account_key")?; |
|
| 144 | + | let parsed: serde_json::Value = serde_json::from_str(&key).map_err(|_| { |
|
| 145 | + | Error::Backend("the service account key is not the JSON Google issues".into()) |
|
| 146 | + | })?; |
|
| 147 | + | if parsed.get("type").and_then(|t| t.as_str()) != Some("service_account") { |
|
| 148 | + | return Err(Error::Backend( |
|
| 149 | + | "the key is not a service account key (its type is not service_account)" |
|
| 150 | + | .into(), |
|
| 151 | + | )); |
|
| 152 | + | } |
|
| 153 | + | Ok(Credentials::Gcp { |
|
| 154 | + | service_account_key: key, |
|
| 155 | + | }) |
|
| 156 | + | } |
|
| 157 | + | Some("azure") => Ok(Credentials::Azure { |
|
| 158 | + | tenant_id: field("tenant_id")?, |
|
| 159 | + | client_id: field("client_id")?, |
|
| 160 | + | client_secret: field("client_secret")?, |
|
| 161 | + | }), |
|
| 162 | + | _ => Err(Error::Backend( |
|
| 163 | + | "credentials need a kind: aws, gcp or azure".into(), |
|
| 164 | + | )), |
|
| 165 | + | } |
|
| 166 | + | } |
|
| 167 | + | } |
|
| 168 | + | ||
| 169 | + | /// Finds the credentials a program keeps for a store URL. |
|
| 170 | + | pub type CredentialsLookup = fn(&str) -> Option<Credentials>; |
|
| 171 | + | ||
| 172 | + | static CREDENTIALS: Mutex<Option<CredentialsLookup>> = Mutex::new(None); |
|
| 173 | + | ||
| 174 | + | /// Have stores opened by URL (as a repository's `storage.url` is) use the |
|
| 175 | + | /// credentials `lookup` finds for it, where it finds any. |
|
| 176 | + | pub fn set_credentials_lookup(lookup: CredentialsLookup) { |
|
| 177 | + | *CREDENTIALS.lock().unwrap_or_else(|p| p.into_inner()) = Some(lookup); |
|
| 178 | + | } |
|
| 179 | + | ||
| 52 | 180 | /// A parsed store URL: `SCHEME://HOST/PATH?PARAMS`. |
|
| 53 | 181 | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 54 | 182 | pub struct StoreUrl { |
|
| … | 82 unchanged lines |
||
| 137 | 265 | /// Open the store a URL names. Nothing is sent yet: credentials are |
|
| 138 | 266 | /// resolved here, but the bucket is first spoken to on the first call. |
|
| 139 | 267 | pub fn open(url: &str) -> Result<CloudStore> { |
|
| 268 | + | Self::open_with(url, None) |
|
| 269 | + | } |
|
| 270 | + | ||
| 271 | + | /// [`CloudStore::open`] with credentials given, which must be for |
|
| 272 | + | /// the URL's kind of store; then only they are used. |
|
| 273 | + | pub fn open_with(url: &str, given: Option<&Credentials>) -> Result<CloudStore> { |
|
| 140 | 274 | let parsed = StoreUrl::parse(url)?; |
|
| 275 | + | if let Some(c) = given.filter(|c| c.scheme() != parsed.scheme) { |
|
| 276 | + | return Err(Error::Backend(format!( |
|
| 277 | + | "{}: the credentials given are for {}:// stores", |
|
| 278 | + | parsed.plain(), |
|
| 279 | + | c.scheme() |
|
| 280 | + | ))); |
|
| 281 | + | } |
|
| 282 | + | let aws_keys = match given { |
|
| 283 | + | Some(Credentials::Aws { |
|
| 284 | + | access_key_id, |
|
| 285 | + | secret_access_key, |
|
| 286 | + | session_token, |
|
| 287 | + | }) => Some(( |
|
| 288 | + | access_key_id.as_str(), |
|
| 289 | + | secret_access_key.as_str(), |
|
| 290 | + | session_token.as_deref(), |
|
| 291 | + | )), |
|
| 292 | + | _ => None, |
|
| 293 | + | }; |
|
| 294 | + | let gcp_key = match given { |
|
| 295 | + | Some(Credentials::Gcp { |
|
| 296 | + | service_account_key, |
|
| 297 | + | }) => Some(service_account_key.as_str()), |
|
| 298 | + | _ => None, |
|
| 299 | + | }; |
|
| 300 | + | let principal = match given { |
|
| 301 | + | Some(Credentials::Azure { |
|
| 302 | + | tenant_id, |
|
| 303 | + | client_id, |
|
| 304 | + | client_secret, |
|
| 305 | + | }) => Some(( |
|
| 306 | + | tenant_id.as_str(), |
|
| 307 | + | client_id.as_str(), |
|
| 308 | + | client_secret.as_str(), |
|
| 309 | + | )), |
|
| 310 | + | _ => None, |
|
| 311 | + | }; |
|
| 141 | 312 | let runtime = tokio::runtime::Builder::new_current_thread() |
|
| 142 | 313 | .enable_all() |
|
| 143 | 314 | .build() |
|
| … | 1 unchanged lines |
||
| 145 | 316 | let (inner, prefix, hint): (Arc<dyn ObjectStore>, String, &'static str) = |
|
| 146 | 317 | match parsed.scheme.as_str() { |
|
| 147 | 318 | "s3" => ( |
|
| 148 | - | Arc::new(aws::open(&parsed)?), |
|
| 319 | + | Arc::new(aws::open(&parsed, aws_keys)?), |
|
| 149 | 320 | parsed.path.clone(), |
|
| 150 | 321 | aws::HINT, |
|
| 151 | 322 | ), |
|
| 152 | 323 | "gs" => ( |
|
| 153 | - | Arc::new(gcp::open(&parsed)?), |
|
| 324 | + | Arc::new(gcp::open(&parsed, gcp_key)?), |
|
| 154 | 325 | parsed.path.clone(), |
|
| 155 | 326 | gcp::HINT, |
|
| 156 | 327 | ), |
|
| 157 | 328 | "az" => { |
|
| 158 | - | let (store, prefix) = azure::open(&parsed)?; |
|
| 329 | + | let (store, prefix) = azure::open(&parsed, principal)?; |
|
| 159 | 330 | (Arc::new(store), prefix, azure::HINT) |
|
| 160 | 331 | } |
|
| 161 | 332 | other => { |
|
| … | 241 unchanged lines |
||
| 403 | 574 | ||
| 404 | 575 | #[cfg(test)] |
|
| 405 | 576 | mod tests { |
|
| 577 | + | #[test] |
|
| 578 | + | fn credentials_round_trip_and_never_show_their_secret() { |
|
| 579 | + | let aws = Credentials::Aws { |
|
| 580 | + | access_key_id: "AKIDEXAMPLE".into(), |
|
| 581 | + | secret_access_key: "wJalrXUtnFEMI".into(), |
|
| 582 | + | session_token: None, |
|
| 583 | + | }; |
|
| 584 | + | assert_eq!(Credentials::from_json(&aws.to_json()).unwrap(), aws); |
|
| 585 | + | assert_eq!(format!("{aws:?}"), "Aws(AKIDEXAMPLE, secret hidden)"); |
|
| 586 | + | let az = Credentials::Azure { |
|
| 587 | + | tenant_id: "t".into(), |
|
| 588 | + | client_id: "c".into(), |
|
| 589 | + | client_secret: "s3cr3t".into(), |
|
| 590 | + | }; |
|
| 591 | + | assert_eq!(Credentials::from_json(&az.to_json()).unwrap(), az); |
|
| 592 | + | assert!(!format!("{az:?}").contains("s3cr3t")); |
|
| 593 | + | let key = r#"{"type":"service_account","client_email":"x@y","private_key":"k"}"#; |
|
| 594 | + | let gcp = Credentials::Gcp { |
|
| 595 | + | service_account_key: key.into(), |
|
| 596 | + | }; |
|
| 597 | + | assert_eq!(Credentials::from_json(&gcp.to_json()).unwrap(), gcp); |
|
| 598 | + | assert!(!format!("{gcp:?}").contains("private_key")); |
|
| 599 | + | for (bad, why) in [ |
|
| 600 | + | (r#"{"kind":"aws","access_key_id":"a"}"#, "secret_access_key"), |
|
| 601 | + | ( |
|
| 602 | + | r#"{"kind":"gcp","service_account_key":"{\"type\":\"authorized_user\"}"}"#, |
|
| 603 | + | "not a service account key", |
|
| 604 | + | ), |
|
| 605 | + | (r#"{"kind":"ftp"}"#, "aws, gcp or azure"), |
|
| 606 | + | ("nope", "not JSON"), |
|
| 607 | + | ] { |
|
| 608 | + | let e = Credentials::from_json(bad).unwrap_err().to_string(); |
|
| 609 | + | assert!(e.contains(why), "{bad}: {e}"); |
|
| 610 | + | } |
|
| 611 | + | let e = CloudStore::open_with("gs://bucket/p", Some(&aws)) |
|
| 612 | + | .err() |
|
| 613 | + | .unwrap() |
|
| 614 | + | .to_string(); |
|
| 615 | + | assert!(e.contains("are for s3:// stores"), "{e}"); |
|
| 616 | + | } |
|
| 617 | + | ||
| 406 | 618 | use super::*; |
|
| 407 | 619 | ||
| 408 | 620 | #[test] |
|
| … | 30 unchanged lines |
modified crates/oxy-strata/tests/cloud.rs +72 −0
| … | 198 unchanged lines |
||
| 199 | 199 | } |
|
| 200 | 200 | } |
|
| 201 | 201 | ||
| 202 | + | /// Each store opened with credentials handed over (`CloudStore::open_with`, |
|
| 203 | + | /// as a hub does with the ones it keeps sealed), the profile and key file |
|
| 204 | + | /// parameters left off the URL, passes the conformance check. |
|
| 205 | + | #[test] |
|
| 206 | + | fn every_configured_store_conforms_with_credentials_handed_over() { |
|
| 207 | + | use oxy_strata::Credentials; |
|
| 208 | + | let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty()); |
|
| 209 | + | let mut tried = 0; |
|
| 210 | + | if let (Some(url), Some(profile)) = (env("OXY_TEST_S3"), env("OXY_TEST_S3_PROFILE")) { |
|
| 211 | + | let home = std::path::PathBuf::from(env("HOME").unwrap()); |
|
| 212 | + | let text = std::fs::read_to_string(home.join(".aws/credentials")).unwrap_or_default(); |
|
| 213 | + | let mut section = String::new(); |
|
| 214 | + | let mut keys = std::collections::BTreeMap::new(); |
|
| 215 | + | for line in text.lines().map(str::trim) { |
|
| 216 | + | if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) { |
|
| 217 | + | section = name.to_string(); |
|
| 218 | + | } else if let Some((k, v)) = line.split_once('=') { |
|
| 219 | + | if section == profile { |
|
| 220 | + | keys.insert(k.trim().to_string(), v.trim().to_string()); |
|
| 221 | + | } |
|
| 222 | + | } |
|
| 223 | + | } |
|
| 224 | + | if let (Some(id), Some(secret)) = ( |
|
| 225 | + | keys.get("aws_access_key_id"), |
|
| 226 | + | keys.get("aws_secret_access_key"), |
|
| 227 | + | ) { |
|
| 228 | + | let c = Credentials::Aws { |
|
| 229 | + | access_key_id: id.clone(), |
|
| 230 | + | secret_access_key: secret.clone(), |
|
| 231 | + | session_token: keys.get("aws_session_token").cloned(), |
|
| 232 | + | }; |
|
| 233 | + | check_handed_over(&url, &c); |
|
| 234 | + | tried += 1; |
|
| 235 | + | } |
|
| 236 | + | } |
|
| 237 | + | if let (Some(url), Some(file)) = (env("OXY_TEST_GCS"), env("OXY_TEST_GCS_CREDENTIALS")) { |
|
| 238 | + | let c = Credentials::Gcp { |
|
| 239 | + | service_account_key: std::fs::read_to_string(file).unwrap(), |
|
| 240 | + | }; |
|
| 241 | + | check_handed_over(&url, &c); |
|
| 242 | + | tried += 1; |
|
| 243 | + | } |
|
| 244 | + | if let (Some(url), Some(file)) = (env("OXY_TEST_AZURE"), env("OXY_TEST_AZURE_CREDENTIALS")) { |
|
| 245 | + | let mut vars = std::collections::BTreeMap::new(); |
|
| 246 | + | for line in std::fs::read_to_string(file).unwrap().lines() { |
|
| 247 | + | if let Some((k, v)) = line.split_once('=') { |
|
| 248 | + | vars.insert(k.trim().to_string(), v.trim().to_string()); |
|
| 249 | + | } |
|
| 250 | + | } |
|
| 251 | + | let c = Credentials::Azure { |
|
| 252 | + | tenant_id: vars["AZURE_TENANT_ID"].clone(), |
|
| 253 | + | client_id: vars["AZURE_CLIENT_ID"].clone(), |
|
| 254 | + | client_secret: vars["AZURE_CLIENT_SECRET"].clone(), |
|
| 255 | + | }; |
|
| 256 | + | check_handed_over(&url, &c); |
|
| 257 | + | tried += 1; |
|
| 258 | + | } |
|
| 259 | + | eprintln!("{tried} stores checked with credentials handed over"); |
|
| 260 | + | } |
|
| 261 | + | ||
| 262 | + | fn check_handed_over(url: &str, c: &oxy_strata::Credentials) { |
|
| 263 | + | let store = |
|
| 264 | + | oxy_strata::CloudStore::open_with(url, Some(c)).unwrap_or_else(|e| panic!("{url}: {e}")); |
|
| 265 | + | let prefix = format!("probe-{}", std::process::id()); |
|
| 266 | + | let passed = conformance(&store, &prefix).unwrap_or_else(|e| { |
|
| 267 | + | remove_all(&store, &prefix); |
|
| 268 | + | panic!("{url} with {c:?}: {e}") |
|
| 269 | + | }); |
|
| 270 | + | eprintln!("{url} with {c:?}: {} checks passed", passed.len()); |
|
| 271 | + | assert_eq!(passed.len(), 8); |
|
| 272 | + | } |
|
| 273 | + | ||
| 202 | 274 | fn write(root: &Path, rel: &str, content: &[u8]) { |
|
| 203 | 275 | let p = root.join(rel); |
|
| 204 | 276 | std::fs::create_dir_all(p.parent().unwrap()).unwrap(); |
|
| … | 2 unchanged lines |