OXYGENHUB

Coverage / crates/oxy-strata/src/lib.rs 55.0%

280 of 509 lines run, 38 of 168 functions · run #284 on main · run missed

1
//! Oxygen Strata: repositories kept on cloud object storage (docs/stores.md).
2
//!
3
//! This crate gives `oxy` the stores that need an HTTP client and the
4
//! providers' signing: Amazon S3 (`s3://BUCKET/PREFIX`), Google Cloud
5
//! Storage (`gs://BUCKET/PREFIX`) and Azure Blob Storage
6
//! (`az://ACCOUNT/CONTAINER/PREFIX`), each as a [`Store`] over the
7
//! `object_store` crate. Credentials come from each provider's standard
8
//! places, with nothing oxy-specific to set up; a URL may carry settings
9
//! as query parameters (`?profile=work&region=eu-west-2`), which is where
10
//! `oxy remote add --profile` keeps them.
11
//!
12
//! [`register`] hands these stores to oxy-core's remote opener; a program
13
//! that links this crate calls it once at start.
14
15
mod aws;
16
mod azure;
17
mod gcp;
18
mod http;
19
mod ini;
20
21
use futures::TryStreamExt;
22
use object_store::path::Path as ObjectPath;
23
use object_store::{
24
    GetOptions, ObjectStore, ObjectStoreExt, PutMode, PutOptions, PutPayload, UpdateVersion,
25
};
26
use oxy_core::strata::{check_key, Entry, Error, Result, Store, Version};
27
use std::collections::BTreeMap;
28
use std::future::Future;
29
use std::ops::Range;
30
use std::sync::{Arc, Mutex};
31
32
/// Make the cloud stores available to `oxy_core::protocol::open_remote`.
33
/// Safe to call more than once.
34148×
pub fn register() {
35
    // object_store's reqwest is built with rustls and no bundled crypto
36
    // provider; oxy links ring already, so that is the one to install.
37148×
    let _ = rustls::crypto::ring::default_provider().install_default();
38148×
    oxy_core::protocol::register_store_opener(open_url);
39148×
}
40
4117×
fn open_url(
4217×
    url: &str,
4317×
    _options: &oxy_core::protocol::RemoteOptions,
4417×
) -> Option<Result<Box<dyn Store>>> {
4517×
    let scheme = url.split_once("://")?.0;
460×
    if !matches!(scheme, "s3" | "gs" | "az") {
470×
        return None;
480×
    }
490×
    let lookup = *CREDENTIALS.lock().unwrap_or_else(|p| p.into_inner());
500×
    let given = lookup.and_then(|f| f(url));
510×
    Some(CloudStore::open_with(url, given.as_ref()).map(|s| Box::new(s) as Box<dyn Store>))
5217×
}
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.
783×
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
793×
        match self {
801×
            Credentials::Aws { access_key_id, .. } => {
811×
                write!(f, "Aws({access_key_id}, secret hidden)")
82
            }
831×
            Credentials::Gcp { .. } => write!(f, "Gcp(service account key hidden)"),
841×
            Credentials::Azure { client_id, .. } => write!(f, "Azure({client_id}, secret hidden)"),
85
        }
863×
    }
87
}
88
89
impl Credentials {
90
    /// The scheme of the stores these are for.
912×
    pub fn scheme(&self) -> &'static str {
922×
        match self {
932×
            Credentials::Aws { .. } => "s3",
940×
            Credentials::Gcp { .. } => "gs",
950×
            Credentials::Azure { .. } => "az",
96
        }
972×
    }
98
99
    /// As JSON, for keeping (sealed) and reading back with [`Credentials::from_json`].
1003×
    pub fn to_json(&self) -> String {
1013×
        let v = match self {
102
            Credentials::Aws {
1031×
                access_key_id,
1041×
                secret_access_key,
1051×
                session_token,
1061×
            } => serde_json::json!({
1071×
                "kind": "aws", "access_key_id": access_key_id, "secret_access_key": secret_access_key,
1081×
                "session_token": session_token,
109
            }),
110
            Credentials::Gcp {
1111×
                service_account_key,
112
            } => {
1131×
                serde_json::json!({ "kind": "gcp", "service_account_key": service_account_key })
114
            }
115
            Credentials::Azure {
1161×
                tenant_id,
1171×
                client_id,
1181×
                client_secret,
1191×
            } => serde_json::json!({
1201×
                "kind": "azure", "tenant_id": tenant_id, "client_id": client_id, "client_secret": client_secret,
121
            }),
122
        };
1233×
        v.to_string()
1243×
    }
125
1267×
    pub fn from_json(text: &str) -> Result<Credentials> {
1277×
        let v: serde_json::Value = serde_json::from_str(text)
1287×
            .map_err(|e| Error::Backend(format!("credentials are not JSON: {e}")))?;
12910×
        let field = |k: &str| -> Result<String> {
13010×
            v.get(k)
13110×
                .and_then(|x| x.as_str())
13210×
                .filter(|x| !x.trim().is_empty())
13310×
                .map(|x| x.trim().to_string())
13410×
                .ok_or_else(|| Error::Backend(format!("credentials need {k}")))
13510×
        };
1366×
        match v.get("kind").and_then(|k| k.as_str()) {
1376×
            Some("aws") => Ok(Credentials::Aws {
1382×
                access_key_id: field("access_key_id")?,
1392×
                secret_access_key: field("secret_access_key")?,
1401×
                session_token: field("session_token").ok(),
141
            }),
1424×
            Some("gcp") => {
1432×
                let key = field("service_account_key")?;
1442×
                let parsed: serde_json::Value = serde_json::from_str(&key).map_err(|_| {
1450×
                    Error::Backend("the service account key is not the JSON Google issues".into())
1460×
                })?;
1472×
                if parsed.get("type").and_then(|t| t.as_str()) != Some("service_account") {
1481×
                    return Err(Error::Backend(
1491×
                        "the key is not a service account key (its type is not service_account)"
1501×
                            .into(),
1511×
                    ));
1521×
                }
1531×
                Ok(Credentials::Gcp {
1541×
                    service_account_key: key,
1551×
                })
156
            }
1572×
            Some("azure") => Ok(Credentials::Azure {
1581×
                tenant_id: field("tenant_id")?,
1591×
                client_id: field("client_id")?,
1601×
                client_secret: field("client_secret")?,
161
            }),
1621×
            _ => Err(Error::Backend(
1631×
                "credentials need a kind: aws, gcp or azure".into(),
1641×
            )),
165
        }
1667×
    }
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.
1760×
pub fn set_credentials_lookup(lookup: CredentialsLookup) {
1770×
    *CREDENTIALS.lock().unwrap_or_else(|p| p.into_inner()) = Some(lookup);
1780×
}
179
180
/// A parsed store URL: `SCHEME://HOST/PATH?PARAMS`.
181
#[derive(Clone, Debug, PartialEq, Eq)]
182
pub struct StoreUrl {
183
    pub scheme: String,
184
    /// The bucket (S3, GCS) or the storage account (Azure).
185
    pub host: String,
186
    /// The path under the host, without leading or trailing slashes; for
187
    /// Azure the container is its first segment.
188
    pub path: String,
189
    pub params: BTreeMap<String, String>,
190
}
191
192
impl StoreUrl {
1937×
    pub fn parse(url: &str) -> Result<StoreUrl> {
1947×
        let bad = |why: &str| Error::Backend(format!("{url}: {why}"));
1957×
        let (scheme, rest) = url.split_once("://").ok_or_else(|| bad("not a URL"))?;
1966×
        let (rest, query) = match rest.split_once('?') {
1973×
            Some((r, q)) => (r, Some(q)),
1983×
            None => (rest, None),
199
        };
2006×
        let (host, path) = match rest.split_once('/') {
2014×
            Some((h, p)) => (h, p.trim_matches('/')),
2022×
            None => (rest, ""),
203
        };
2046×
        if host.is_empty() {
2051×
            return Err(bad("names no bucket"));
2065×
        }
2075×
        let mut params = BTreeMap::new();
2087×
        for pair in query.unwrap_or("").split('&').filter(|p| !p.is_empty()) {
2095×
            let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
2105×
            params.insert(k.to_string(), percent_decode(v));
2115×
        }
2125×
        Ok(StoreUrl {
2135×
            scheme: scheme.to_string(),
2145×
            host: host.to_string(),
2155×
            path: path.to_string(),
2165×
            params,
2175×
        })
2187×
    }
219
220
    /// The URL without its parameters, for messages.
2214×
    pub fn plain(&self) -> String {
2224×
        if self.path.is_empty() {
2231×
            format!("{}://{}", self.scheme, self.host)
224
        } else {
2253×
            format!("{}://{}/{}", self.scheme, self.host, self.path)
226
        }
2274×
    }
228
2296×
    pub fn param(&self, name: &str) -> Option<&str> {
2306×
        self.params.get(name).map(String::as_str)
2316×
    }
232
}
233
2345×
fn percent_decode(s: &str) -> String {
2355×
    let bytes = s.as_bytes();
2365×
    let mut out = Vec::with_capacity(bytes.len());
2375×
    let mut i = 0;
23867×
    while i < bytes.len() {
23962×
        if bytes[i] == b'%' && i + 2 < bytes.len() {
2404×
            if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
2414×
                out.push(v);
2424×
                i += 3;
2434×
                continue;
2440×
            }
24558×
        }
24658×
        out.push(bytes[i]);
24758×
        i += 1;
248
    }
2495×
    String::from_utf8_lossy(&out).into_owned()
2505×
}
251
252
/// A store on a cloud provider: the provider's client, the prefix every
253
/// key goes under, and a runtime to run the client on.
254
pub struct CloudStore {
255
    inner: Arc<dyn ObjectStore>,
256
    prefix: String,
257
    name: String,
258
    hint: &'static str,
259
    /// Always there until the store is dropped.
260
    runtime: Option<tokio::runtime::Runtime>,
261
    /// One call at a time: the runtime runs on the calling thread.
262
    turn: Mutex<()>,
263
}
264
265
impl Drop for CloudStore {
266
    /// Without waiting, so a store can be dropped inside a runtime too.
2671×
    fn drop(&mut self) {
2681×
        if let Some(rt) = self.runtime.take() {
2691×
            rt.shutdown_background();
2701×
        }
2711×
    }
272
}
273
274
impl CloudStore {
275
    /// Open the store a URL names. Nothing is sent yet: credentials are
276
    /// resolved here, but the bucket is first spoken to on the first call.
2771×
    pub fn open(url: &str) -> Result<CloudStore> {
2781×
        Self::open_with(url, None)
2791×
    }
280
281
    /// [`CloudStore::open`] with credentials given, which must be for
282
    /// the URL's kind of store; then only they are used.
2832×
    pub fn open_with(url: &str, given: Option<&Credentials>) -> Result<CloudStore> {
2842×
        let parsed = StoreUrl::parse(url)?;
2852×
        if let Some(c) = given.filter(|c| c.scheme() != parsed.scheme) {
2861×
            return Err(Error::Backend(format!(
2871×
                "{}: the credentials given are for {}:// stores",
2881×
                parsed.plain(),
2891×
                c.scheme()
2901×
            )));
2911×
        }
2921×
        let aws_keys = match given {
293
            Some(Credentials::Aws {
2940×
                access_key_id,
2950×
                secret_access_key,
2960×
                session_token,
2970×
            }) => Some((
2980×
                access_key_id.as_str(),
2990×
                secret_access_key.as_str(),
3000×
                session_token.as_deref(),
3010×
            )),
3021×
            _ => None,
303
        };
3041×
        let gcp_key = match given {
305
            Some(Credentials::Gcp {
3060×
                service_account_key,
3070×
            }) => Some(service_account_key.as_str()),
3081×
            _ => None,
309
        };
3101×
        let principal = match given {
311
            Some(Credentials::Azure {
3120×
                tenant_id,
3130×
                client_id,
3140×
                client_secret,
3150×
            }) => Some((
3160×
                tenant_id.as_str(),
3170×
                client_id.as_str(),
3180×
                client_secret.as_str(),
3190×
            )),
3201×
            _ => None,
321
        };
3221×
        let runtime = tokio::runtime::Builder::new_current_thread()
3231×
            .enable_all()
3241×
            .build()
3251×
            .map_err(|e| Error::Backend(format!("starting the HTTP runtime: {e}")))?;
3261×
        let (inner, prefix, hint): (Arc<dyn ObjectStore>, String, &'static str) =
3271×
            match parsed.scheme.as_str() {
3281×
                "s3" => (
3291×
                    Arc::new(aws::open(&parsed, aws_keys)?),
3301×
                    parsed.path.clone(),
331
                    aws::HINT,
332
                ),
3330×
                "gs" => (
3340×
                    Arc::new(gcp::open(&parsed, gcp_key)?),
3350×
                    parsed.path.clone(),
336
                    gcp::HINT,
337
                ),
3380×
                "az" => {
3390×
                    let (store, prefix) = azure::open(&parsed, principal)?;
3400×
                    (Arc::new(store), prefix, azure::HINT)
341
                }
3420×
                other => {
3430×
                    return Err(Error::Backend(format!(
3440×
                        "{url}: {other}:// is not a store oxy knows (s3://, gs://, az://, dir://)"
3450×
                    )))
346
                }
347
            };
3481×
        Ok(CloudStore {
3491×
            inner,
3501×
            prefix,
3511×
            name: parsed.plain(),
3521×
            hint,
3531×
            runtime: Some(runtime),
3541×
            turn: Mutex::new(()),
3551×
        })
3562×
    }
357
358
    /// Run a request to completion. Called from a thread that is itself
359
    /// driving async tasks (a server's handler), where blocking on another
360
    /// runtime is not allowed, the request runs on a thread of its own.
3611×
    fn block<F>(&self, f: F) -> F::Output
3621×
    where
3631×
        F: Future + Send,
3641×
        F::Output: Send,
365
    {
3661×
        let _turn = self.turn.lock().unwrap_or_else(|p| p.into_inner());
3671×
        let runtime = self
3681×
            .runtime
3691×
            .as_ref()
3701×
            .expect("the runtime lives as long as the store");
3711×
        if tokio::runtime::Handle::try_current().is_ok() {
3721×
            return std::thread::scope(|s| {
3731×
                s.spawn(|| runtime.block_on(f))
3741×
                    .join()
3751×
                    .unwrap_or_else(|p| std::panic::resume_unwind(p))
3761×
            });
3770×
        }
3780×
        runtime.block_on(f)
3791×
    }
380
3810×
    fn path(&self, key: &str) -> Result<ObjectPath> {
3820×
        check_key(key)?;
3830×
        let full = if self.prefix.is_empty() {
3840×
            key.to_string()
385
        } else {
3860×
            format!("{}/{key}", self.prefix)
387
        };
3880×
        ObjectPath::parse(&full).map_err(|e| Error::Backend(format!("{full}: {e}")))
3890×
    }
390
391
    /// Our error for the provider's, with what to do next where it helps.
3920×
    fn map(&self, e: object_store::Error, key: &str) -> Error {
393
        use object_store::Error as E;
3940×
        match e {
3950×
            E::NotFound { .. } => Error::NotFound(key.to_string()),
3960×
            E::AlreadyExists { .. } => Error::Exists(key.to_string()),
3970×
            E::Precondition { .. } | E::NotModified { .. } => Error::Conflict(key.to_string()),
3980×
            other => {
3990×
                let text = other.to_string();
4000×
                let lower = text.to_ascii_lowercase();
4010×
                let advice = if lower.contains("403")
4020×
                    || lower.contains("accessdenied")
4030×
                    || lower.contains("forbidden")
4040×
                    || lower.contains("authorizationpermissionmismatch")
405
                {
4060×
                    format!(
407
                        ": access denied; the credentials in use may not do this on {}: {}",
408
                        self.name, self.hint
409
                    )
4100×
                } else if lower.contains("credential") || lower.contains("no valid credentials") {
4110×
                    format!(": no usable credentials: {}", self.hint)
4120×
                } else if lower.contains("nosuchbucket")
4130×
                    || lower.contains("bucketnotfound")
4140×
                    || lower.contains("containernotfound")
415
                {
4160×
                    format!(
417
                        ": {} does not exist, or the credentials cannot see it",
418
                        self.name
419
                    )
4200×
                } else if lower.contains("dns")
4210×
                    || lower.contains("connect")
4220×
                    || lower.contains("timed out")
423
                {
4240×
                    ": cannot reach the service; check the network, the endpoint and the region"
4250×
                        .to_string()
426
                } else {
4270×
                    String::new()
428
                };
4290×
                Error::Backend(format!("{}: {text}{advice}", self.name))
430
            }
431
        }
4320×
    }
433
4342×
    fn version_of(e_tag: Option<String>, version: Option<String>) -> Version {
4352×
        Version(format!(
4362×
            "{}\t{}",
4372×
            e_tag.unwrap_or_default(),
4382×
            version.unwrap_or_default()
4392×
        ))
4402×
    }
441
4422×
    fn update_version(v: &Version) -> UpdateVersion {
4432×
        let (e_tag, version) = v.0.split_once('\t').unwrap_or((v.0.as_str(), ""));
444
        UpdateVersion {
4452×
            e_tag: (!e_tag.is_empty()).then(|| e_tag.to_string()),
4462×
            version: (!version.is_empty()).then(|| version.to_string()),
447
        }
4482×
    }
449
}
450
451
impl Store for CloudStore {
4520×
    fn describe(&self) -> String {
4530×
        self.name.clone()
4540×
    }
455
4560×
    fn has(&self, key: &str) -> Result<bool> {
4570×
        let path = self.path(key)?;
4580×
        match self.block(self.inner.head(&path)) {
4590×
            Ok(_) => Ok(true),
4600×
            Err(object_store::Error::NotFound { .. }) => Ok(false),
4610×
            Err(e) => Err(self.map(e, key)),
462
        }
4630×
    }
464
4650×
    fn get(&self, key: &str) -> Result<Vec<u8>> {
4660×
        let path = self.path(key)?;
4670×
        self.block(async {
4680×
            let r = self.inner.get(&path).await?;
4690×
            r.bytes().await
4700×
        })
4710×
        .map(|b| b.to_vec())
4720×
        .map_err(|e| self.map(e, key))
4730×
    }
474
4750×
    fn get_range(&self, key: &str, range: Range<u64>) -> Result<Vec<u8>> {
4760×
        let path = self.path(key)?;
4770×
        if range.start > range.end {
4780×
            return Err(Error::Backend(format!(
4790×
                "{key}: bytes {}..{} is not a range",
4800×
                range.start, range.end
4810×
            )));
4820×
        }
4830×
        if range.is_empty() {
4840×
            return Ok(Vec::new());
4850×
        }
4860×
        let want = (range.end - range.start) as usize;
4870×
        let bytes = self
4880×
            .block(self.inner.get_range(&path, range.clone()))
4890×
            .map_err(|e| self.map(e, key))?;
4900×
        if bytes.len() != want {
4910×
            return Err(Error::Backend(format!(
4920×
                "{key}: bytes {}..{} asked, {} returned: the object is shorter",
4930×
                range.start,
4940×
                range.end,
4950×
                bytes.len()
4960×
            )));
4970×
        }
4980×
        Ok(bytes.to_vec())
4990×
    }
500
5010×
    fn put_new(&self, key: &str, bytes: &[u8]) -> Result<()> {
5020×
        let path = self.path(key)?;
5030×
        let opts = PutOptions {
5040×
            mode: PutMode::Create,
5050×
            ..Default::default()
5060×
        };
5070×
        self.block(
5080×
            self.inner
5090×
                .put_opts(&path, PutPayload::from(bytes.to_vec()), opts),
510
        )
5110×
        .map(|_| ())
5120×
        .map_err(|e| self.map(e, key))
5130×
    }
514
5150×
    fn list(&self, prefix: &str) -> Result<Vec<Entry>> {
516
        // Providers list by whole path segments; the asked prefix may end
517
        // mid-segment, so list its directory and filter here.
5180×
        let dir = prefix.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
5190×
        let full_dir = match (self.prefix.as_str(), dir) {
5200×
            ("", "") => None,
5210×
            ("", d) => Some(d.to_string()),
5220×
            (p, "") => Some(p.to_string()),
5230×
            (p, d) => Some(format!("{p}/{d}")),
524
        };
5250×
        let full_dir = full_dir
5260×
            .map(|d| ObjectPath::parse(&d).map_err(|e| Error::Backend(format!("{d}: {e}"))))
5270×
            .transpose()?;
5280×
        let metas: Vec<object_store::ObjectMeta> = self
5290×
            .block(self.inner.list(full_dir.as_ref()).try_collect())
5300×
            .map_err(|e| self.map(e, prefix))?;
5310×
        let strip = if self.prefix.is_empty() {
5320×
            String::new()
533
        } else {
5340×
            format!("{}/", self.prefix)
535
        };
5360×
        let mut out: Vec<Entry> = metas
5370×
            .into_iter()
5380×
            .filter_map(|m| {
5390×
                let loc = m.location.to_string();
5400×
                let key = loc.strip_prefix(&strip)?;
5410×
                key.starts_with(prefix).then(|| Entry {
5420×
                    key: key.to_string(),
5430×
                    size: m.size,
5440×
                    modified_ms: u64::try_from(m.last_modified.timestamp_millis()).ok(),
5450×
                })
5460×
            })
5470×
            .collect();
5480×
        out.sort_by(|a, b| a.key.cmp(&b.key));
5490×
        Ok(out)
5500×
    }
551
5520×
    fn delete(&self, key: &str) -> Result<()> {
5530×
        let path = self.path(key)?;
5540×
        match self.block(self.inner.delete(&path)) {
5550×
            Ok(()) | Err(object_store::Error::NotFound { .. }) => Ok(()),
5560×
            Err(e) => Err(self.map(e, key)),
557
        }
5580×
    }
559
5600×
    fn read_ref(&self, key: &str) -> Result<Option<(Vec<u8>, Version)>> {
5610×
        let path = self.path(key)?;
5620×
        let got = self.block(async {
5630×
            let r = self.inner.get_opts(&path, GetOptions::default()).await?;
5640×
            let meta = r.meta.clone();
5650×
            let bytes = r.bytes().await?;
5660×
            Ok::<_, object_store::Error>((bytes, meta))
5670×
        });
5680×
        match got {
5690×
            Ok((bytes, meta)) => Ok(Some((
5700×
                bytes.to_vec(),
5710×
                Self::version_of(meta.e_tag, meta.version),
5720×
            ))),
5730×
            Err(object_store::Error::NotFound { .. }) => Ok(None),
5740×
            Err(e) => Err(self.map(e, key)),
575
        }
5760×
    }
577
5780×
    fn write_ref(&self, key: &str, bytes: &[u8], expected: Option<&Version>) -> Result<Version> {
5790×
        let path = self.path(key)?;
5800×
        let mode = match expected {
5810×
            None => PutMode::Create,
5820×
            Some(v) => PutMode::Update(Self::update_version(v)),
583
        };
5840×
        let opts = PutOptions {
5850×
            mode,
5860×
            ..Default::default()
5870×
        };
5880×
        let put = self
5890×
            .block(
5900×
                self.inner
5910×
                    .put_opts(&path, PutPayload::from(bytes.to_vec()), opts),
592
            )
5930×
            .map_err(|e| match self.map(e, key) {
594
                // A record that exists when none was expected is a lost
595
                // race too, not a misuse of put_new.
5960×
                Error::Exists(k) => Error::Conflict(k),
5970×
                other => other,
5980×
            })?;
5990×
        Ok(Self::version_of(put.e_tag, put.version))
6000×
    }
601
}
602
603
#[cfg(test)]
604
mod tests {
605
    #[tokio::test]
6061×
    async fn a_store_can_be_used_from_inside_a_runtime() {
607
        // As a server's handler would: blocking on the store's own runtime
608
        // from here must neither panic nor deadlock, and nor must dropping it.
6091×
        let store = CloudStore::open(
6101×
            "s3://oxy-no-such-bucket/p?region=eu-west-2&endpoint=http://127.0.0.1:9",
611
        )
6121×
        .unwrap();
6131×
        assert_eq!(store.block(async { 7 }), 7);
6141×
        drop(store);
6151×
    }
616
617
    #[test]
6181×
    fn credentials_round_trip_and_never_show_their_secret() {
6191×
        let aws = Credentials::Aws {
6201×
            access_key_id: "AKIDEXAMPLE".into(),
6211×
            secret_access_key: "wJalrXUtnFEMI".into(),
6221×
            session_token: None,
6231×
        };
6241×
        assert_eq!(Credentials::from_json(&aws.to_json()).unwrap(), aws);
6251×
        assert_eq!(format!("{aws:?}"), "Aws(AKIDEXAMPLE, secret hidden)");
6261×
        let az = Credentials::Azure {
6271×
            tenant_id: "t".into(),
6281×
            client_id: "c".into(),
6291×
            client_secret: "s3cr3t".into(),
6301×
        };
6311×
        assert_eq!(Credentials::from_json(&az.to_json()).unwrap(), az);
6321×
        assert!(!format!("{az:?}").contains("s3cr3t"));
6331×
        let key = r#"{"type":"service_account","client_email":"x@y","private_key":"k"}"#;
6341×
        let gcp = Credentials::Gcp {
6351×
            service_account_key: key.into(),
6361×
        };
6371×
        assert_eq!(Credentials::from_json(&gcp.to_json()).unwrap(), gcp);
6381×
        assert!(!format!("{gcp:?}").contains("private_key"));
6394×
        for (bad, why) in [
6401×
            (r#"{"kind":"aws","access_key_id":"a"}"#, "secret_access_key"),
6411×
            (
6421×
                r#"{"kind":"gcp","service_account_key":"{\"type\":\"authorized_user\"}"}"#,
6431×
                "not a service account key",
6441×
            ),
6451×
            (r#"{"kind":"ftp"}"#, "aws, gcp or azure"),
6461×
            ("nope", "not JSON"),
6471×
        ] {
6484×
            let e = Credentials::from_json(bad).unwrap_err().to_string();
6494×
            assert!(e.contains(why), "{bad}: {e}");
650
        }
6511×
        let e = CloudStore::open_with("gs://bucket/p", Some(&aws))
6521×
            .err()
6531×
            .unwrap()
6541×
            .to_string();
6551×
        assert!(e.contains("are for s3:// stores"), "{e}");
6561×
    }
657
658
    use super::*;
659
660
    #[test]
6611×
    fn urls_parse_with_their_parameters() {
6621×
        let u =
6631×
            StoreUrl::parse("s3://my-bucket/repos/oxygen?profile=work&region=eu-west-2").unwrap();
6641×
        assert_eq!(u.scheme, "s3");
6651×
        assert_eq!(u.host, "my-bucket");
6661×
        assert_eq!(u.path, "repos/oxygen");
6671×
        assert_eq!(u.param("profile"), Some("work"));
6681×
        assert_eq!(u.param("region"), Some("eu-west-2"));
6691×
        assert_eq!(u.plain(), "s3://my-bucket/repos/oxygen");
6701×
        let u = StoreUrl::parse("gs://bucket").unwrap();
6711×
        assert_eq!((u.host.as_str(), u.path.as_str()), ("bucket", ""));
6721×
        assert_eq!(u.plain(), "gs://bucket");
6731×
        let u = StoreUrl::parse("az://acct/container/a/b/?endpoint=http%3A%2F%2F127.0.0.1%3A10000")
6741×
            .unwrap();
6751×
        assert_eq!(u.path, "container/a/b");
6761×
        assert_eq!(u.param("endpoint"), Some("http://127.0.0.1:10000"));
6771×
        assert!(StoreUrl::parse("s3://").is_err());
6781×
        assert!(StoreUrl::parse("nonsense").is_err());
6791×
    }
680
681
    #[test]
6821×
    fn versions_carry_both_an_etag_and_a_generation() {
6831×
        let v = CloudStore::version_of(Some("\"abc\"".into()), Some("17".into()));
6841×
        let u = CloudStore::update_version(&v);
6851×
        assert_eq!(u.e_tag.as_deref(), Some("\"abc\""));
6861×
        assert_eq!(u.version.as_deref(), Some("17"));
6871×
        let u = CloudStore::update_version(&CloudStore::version_of(None, Some("3".into())));
6881×
        assert_eq!((u.e_tag, u.version.as_deref()), (None, Some("3")));
6891×
    }
690
}