OXYGENHUB

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

330 of 577 lines run, 42 of 186 functions · run #295 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(shared(url, given).map(|s| Box::new(Shared(s)) as Box<dyn Store>))
5217×
}
53
54
/// One client per store and credentials, kept for the life of the process
55
/// (oxygenhub#234): a server opens a repository for every request, and a
56
/// new client each time meant a new runtime, new connections and signing
57
/// in to the provider again before the first byte.
58
type OpenStores = std::collections::HashMap<(String, Option<Credentials>), Arc<CloudStore>>;
59
603×
fn shared(url: &str, given: Option<Credentials>) -> Result<Arc<CloudStore>> {
61
    static OPEN: Mutex<Option<OpenStores>> = Mutex::new(None);
623×
    let key = (url.to_string(), given);
633×
    let mut open = OPEN.lock().unwrap_or_else(|p| p.into_inner());
643×
    let map = open.get_or_insert_with(Default::default);
653×
    if let Some(s) = map.get(&key) {
661×
        return Ok(s.clone());
672×
    }
682×
    let store = Arc::new(CloudStore::open_with(url, key.1.as_ref())?);
692×
    map.insert(key, store.clone());
702×
    Ok(store)
713×
}
72
73
/// A shared [`CloudStore`] as a [`Store`].
74
struct Shared(Arc<CloudStore>);
75
76
impl Store for Shared {
770×
    fn describe(&self) -> String {
780×
        self.0.describe()
790×
    }
800×
    fn has(&self, key: &str) -> Result<bool> {
810×
        self.0.has(key)
820×
    }
830×
    fn get(&self, key: &str) -> Result<Vec<u8>> {
840×
        self.0.get(key)
850×
    }
860×
    fn get_range(&self, key: &str, range: Range<u64>) -> Result<Vec<u8>> {
870×
        self.0.get_range(key, range)
880×
    }
890×
    fn put_new(&self, key: &str, bytes: &[u8]) -> Result<()> {
900×
        self.0.put_new(key, bytes)
910×
    }
920×
    fn list(&self, prefix: &str) -> Result<Vec<Entry>> {
930×
        self.0.list(prefix)
940×
    }
950×
    fn delete(&self, key: &str) -> Result<()> {
960×
        self.0.delete(key)
970×
    }
980×
    fn read_ref(&self, key: &str) -> Result<Option<(Vec<u8>, Version)>> {
990×
        self.0.read_ref(key)
1000×
    }
1010×
    fn write_ref(&self, key: &str, bytes: &[u8], expected: Option<&Version>) -> Result<Version> {
1020×
        self.0.write_ref(key, bytes, expected)
1030×
    }
104
}
105
106
static OPENS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
107
static CALLS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
108
static MICROS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
109
110
/// What the cloud stores have done since the process started: clients
111
/// opened, requests made and the time spent in them, for a server to show
112
/// (a hub's /health).
113
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
114
pub struct Stats {
115
    pub opens: u64,
116
    pub calls: u64,
117
    pub millis: u64,
118
}
119
1202×
pub fn stats() -> Stats {
121
    use std::sync::atomic::Ordering::Relaxed;
1222×
    Stats {
1232×
        opens: OPENS.load(Relaxed),
1242×
        calls: CALLS.load(Relaxed),
1252×
        millis: MICROS.load(Relaxed) / 1000,
1262×
    }
1272×
}
128
129
/// Credentials a program keeps itself and hands over, instead of the
130
/// provider's usual places: a hub keeps each repository's sealed. When a
131
/// store is opened with them, nothing from the environment or the
132
/// provider's files is used.
133
#[derive(Clone, PartialEq, Eq, Hash)]
134
pub enum Credentials {
135
    /// An S3 access key.
136
    Aws {
137
        access_key_id: String,
138
        secret_access_key: String,
139
        session_token: Option<String>,
140
    },
141
    /// A Google Cloud service account key, as the JSON Google issues.
142
    Gcp { service_account_key: String },
143
    /// An Azure service principal.
144
    Azure {
145
        tenant_id: String,
146
        client_id: String,
147
        client_secret: String,
148
    },
149
}
150
151
impl std::fmt::Debug for Credentials {
152
    /// Names the kind and the public part only: never the secret.
1533×
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1543×
        match self {
1551×
            Credentials::Aws { access_key_id, .. } => {
1561×
                write!(f, "Aws({access_key_id}, secret hidden)")
157
            }
1581×
            Credentials::Gcp { .. } => write!(f, "Gcp(service account key hidden)"),
1591×
            Credentials::Azure { client_id, .. } => write!(f, "Azure({client_id}, secret hidden)"),
160
        }
1613×
    }
162
}
163
164
impl Credentials {
165
    /// The scheme of the stores these are for.
1664×
    pub fn scheme(&self) -> &'static str {
1674×
        match self {
1684×
            Credentials::Aws { .. } => "s3",
1690×
            Credentials::Gcp { .. } => "gs",
1700×
            Credentials::Azure { .. } => "az",
171
        }
1724×
    }
173
174
    /// As JSON, for keeping (sealed) and reading back with [`Credentials::from_json`].
1753×
    pub fn to_json(&self) -> String {
1763×
        let v = match self {
177
            Credentials::Aws {
1781×
                access_key_id,
1791×
                secret_access_key,
1801×
                session_token,
1811×
            } => serde_json::json!({
1821×
                "kind": "aws", "access_key_id": access_key_id, "secret_access_key": secret_access_key,
1831×
                "session_token": session_token,
184
            }),
185
            Credentials::Gcp {
1861×
                service_account_key,
187
            } => {
1881×
                serde_json::json!({ "kind": "gcp", "service_account_key": service_account_key })
189
            }
190
            Credentials::Azure {
1911×
                tenant_id,
1921×
                client_id,
1931×
                client_secret,
1941×
            } => serde_json::json!({
1951×
                "kind": "azure", "tenant_id": tenant_id, "client_id": client_id, "client_secret": client_secret,
196
            }),
197
        };
1983×
        v.to_string()
1993×
    }
200
2017×
    pub fn from_json(text: &str) -> Result<Credentials> {
2027×
        let v: serde_json::Value = serde_json::from_str(text)
2037×
            .map_err(|e| Error::Backend(format!("credentials are not JSON: {e}")))?;
20410×
        let field = |k: &str| -> Result<String> {
20510×
            v.get(k)
20610×
                .and_then(|x| x.as_str())
20710×
                .filter(|x| !x.trim().is_empty())
20810×
                .map(|x| x.trim().to_string())
20910×
                .ok_or_else(|| Error::Backend(format!("credentials need {k}")))
21010×
        };
2116×
        match v.get("kind").and_then(|k| k.as_str()) {
2126×
            Some("aws") => Ok(Credentials::Aws {
2132×
                access_key_id: field("access_key_id")?,
2142×
                secret_access_key: field("secret_access_key")?,
2151×
                session_token: field("session_token").ok(),
216
            }),
2174×
            Some("gcp") => {
2182×
                let key = field("service_account_key")?;
2192×
                let parsed: serde_json::Value = serde_json::from_str(&key).map_err(|_| {
2200×
                    Error::Backend("the service account key is not the JSON Google issues".into())
2210×
                })?;
2222×
                if parsed.get("type").and_then(|t| t.as_str()) != Some("service_account") {
2231×
                    return Err(Error::Backend(
2241×
                        "the key is not a service account key (its type is not service_account)"
2251×
                            .into(),
2261×
                    ));
2271×
                }
2281×
                Ok(Credentials::Gcp {
2291×
                    service_account_key: key,
2301×
                })
231
            }
2322×
            Some("azure") => Ok(Credentials::Azure {
2331×
                tenant_id: field("tenant_id")?,
2341×
                client_id: field("client_id")?,
2351×
                client_secret: field("client_secret")?,
236
            }),
2371×
            _ => Err(Error::Backend(
2381×
                "credentials need a kind: aws, gcp or azure".into(),
2391×
            )),
240
        }
2417×
    }
242
}
243
244
/// Finds the credentials a program keeps for a store URL.
245
pub type CredentialsLookup = fn(&str) -> Option<Credentials>;
246
247
static CREDENTIALS: Mutex<Option<CredentialsLookup>> = Mutex::new(None);
248
249
/// Have stores opened by URL (as a repository's `storage.url` is) use the
250
/// credentials `lookup` finds for it, where it finds any.
2510×
pub fn set_credentials_lookup(lookup: CredentialsLookup) {
2520×
    *CREDENTIALS.lock().unwrap_or_else(|p| p.into_inner()) = Some(lookup);
2530×
}
254
255
/// A parsed store URL: `SCHEME://HOST/PATH?PARAMS`.
256
#[derive(Clone, Debug, PartialEq, Eq)]
257
pub struct StoreUrl {
258
    pub scheme: String,
259
    /// The bucket (S3, GCS) or the storage account (Azure).
260
    pub host: String,
261
    /// The path under the host, without leading or trailing slashes; for
262
    /// Azure the container is its first segment.
263
    pub path: String,
264
    pub params: BTreeMap<String, String>,
265
}
266
267
impl StoreUrl {
2689×
    pub fn parse(url: &str) -> Result<StoreUrl> {
2699×
        let bad = |why: &str| Error::Backend(format!("{url}: {why}"));
2709×
        let (scheme, rest) = url.split_once("://").ok_or_else(|| bad("not a URL"))?;
2718×
        let (rest, query) = match rest.split_once('?') {
2725×
            Some((r, q)) => (r, Some(q)),
2733×
            None => (rest, None),
274
        };
2758×
        let (host, path) = match rest.split_once('/') {
2766×
            Some((h, p)) => (h, p.trim_matches('/')),
2772×
            None => (rest, ""),
278
        };
2798×
        if host.is_empty() {
2801×
            return Err(bad("names no bucket"));
2817×
        }
2827×
        let mut params = BTreeMap::new();
2839×
        for pair in query.unwrap_or("").split('&').filter(|p| !p.is_empty()) {
2847×
            let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
2857×
            params.insert(k.to_string(), percent_decode(v));
2867×
        }
2877×
        Ok(StoreUrl {
2887×
            scheme: scheme.to_string(),
2897×
            host: host.to_string(),
2907×
            path: path.to_string(),
2917×
            params,
2927×
        })
2939×
    }
294
295
    /// The URL without its parameters, for messages.
2966×
    pub fn plain(&self) -> String {
2976×
        if self.path.is_empty() {
2981×
            format!("{}://{}", self.scheme, self.host)
299
        } else {
3005×
            format!("{}://{}/{}", self.scheme, self.host, self.path)
301
        }
3026×
    }
303
30410×
    pub fn param(&self, name: &str) -> Option<&str> {
30510×
        self.params.get(name).map(String::as_str)
30610×
    }
307
}
308
3097×
fn percent_decode(s: &str) -> String {
3107×
    let bytes = s.as_bytes();
3117×
    let mut out = Vec::with_capacity(bytes.len());
3127×
    let mut i = 0;
31387×
    while i < bytes.len() {
31480×
        if bytes[i] == b'%' && i + 2 < bytes.len() {
3154×
            if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
3164×
                out.push(v);
3174×
                i += 3;
3184×
                continue;
3190×
            }
32076×
        }
32176×
        out.push(bytes[i]);
32276×
        i += 1;
323
    }
3247×
    String::from_utf8_lossy(&out).into_owned()
3257×
}
326
327
/// A store on a cloud provider: the provider's client, the prefix every
328
/// key goes under, and a runtime to run the client on.
329
pub struct CloudStore {
330
    inner: Arc<dyn ObjectStore>,
331
    prefix: String,
332
    name: String,
333
    hint: &'static str,
334
    /// Always there until the store is dropped: two workers, so the
335
    /// requests of a shared store run side by side.
336
    runtime: Option<tokio::runtime::Runtime>,
337
}
338
339
impl Drop for CloudStore {
340
    /// Without waiting, so a store can be dropped inside a runtime too.
3411×
    fn drop(&mut self) {
3421×
        if let Some(rt) = self.runtime.take() {
3431×
            rt.shutdown_background();
3441×
        }
3451×
    }
346
}
347
348
impl CloudStore {
349
    /// Open the store a URL names. Nothing is sent yet: credentials are
350
    /// resolved here, but the bucket is first spoken to on the first call.
3511×
    pub fn open(url: &str) -> Result<CloudStore> {
3521×
        Self::open_with(url, None)
3531×
    }
354
355
    /// [`CloudStore::open`] with credentials given, which must be for
356
    /// the URL's kind of store; then only they are used.
3574×
    pub fn open_with(url: &str, given: Option<&Credentials>) -> Result<CloudStore> {
3584×
        let parsed = StoreUrl::parse(url)?;
3594×
        if let Some(c) = given.filter(|c| c.scheme() != parsed.scheme) {
3601×
            return Err(Error::Backend(format!(
3611×
                "{}: the credentials given are for {}:// stores",
3621×
                parsed.plain(),
3631×
                c.scheme()
3641×
            )));
3653×
        }
3663×
        let aws_keys = match given {
367
            Some(Credentials::Aws {
3682×
                access_key_id,
3692×
                secret_access_key,
3702×
                session_token,
3712×
            }) => Some((
3722×
                access_key_id.as_str(),
3732×
                secret_access_key.as_str(),
3742×
                session_token.as_deref(),
3752×
            )),
3761×
            _ => None,
377
        };
3783×
        let gcp_key = match given {
379
            Some(Credentials::Gcp {
3800×
                service_account_key,
3810×
            }) => Some(service_account_key.as_str()),
3823×
            _ => None,
383
        };
3843×
        let principal = match given {
385
            Some(Credentials::Azure {
3860×
                tenant_id,
3870×
                client_id,
3880×
                client_secret,
3890×
            }) => Some((
3900×
                tenant_id.as_str(),
3910×
                client_id.as_str(),
3920×
                client_secret.as_str(),
3930×
            )),
3943×
            _ => None,
395
        };
3963×
        OPENS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3973×
        let runtime = tokio::runtime::Builder::new_multi_thread()
3983×
            .worker_threads(2)
3993×
            .thread_name("oxy-strata")
4003×
            .enable_all()
4013×
            .build()
4023×
            .map_err(|e| Error::Backend(format!("starting the HTTP runtime: {e}")))?;
4033×
        let (inner, prefix, hint): (Arc<dyn ObjectStore>, String, &'static str) =
4043×
            match parsed.scheme.as_str() {
4053×
                "s3" => (
4063×
                    Arc::new(aws::open(&parsed, aws_keys)?),
4073×
                    parsed.path.clone(),
408
                    aws::HINT,
409
                ),
4100×
                "gs" => (
4110×
                    Arc::new(gcp::open(&parsed, gcp_key)?),
4120×
                    parsed.path.clone(),
413
                    gcp::HINT,
414
                ),
4150×
                "az" => {
4160×
                    let (store, prefix) = azure::open(&parsed, principal)?;
4170×
                    (Arc::new(store), prefix, azure::HINT)
418
                }
4190×
                other => {
4200×
                    return Err(Error::Backend(format!(
4210×
                        "{url}: {other}:// is not a store oxy knows (s3://, gs://, az://, dir://)"
4220×
                    )))
423
                }
424
            };
4253×
        Ok(CloudStore {
4263×
            inner,
4273×
            prefix,
4283×
            name: parsed.plain(),
4293×
            hint,
4303×
            runtime: Some(runtime),
4313×
        })
4324×
    }
433
434
    /// Run a request to completion. Called from a thread that is itself
435
    /// driving async tasks (a server's handler), where blocking on another
436
    /// runtime is not allowed, the request runs on a thread of its own.
4371×
    fn block<F>(&self, f: F) -> F::Output
4381×
    where
4391×
        F: Future + Send,
4401×
        F::Output: Send,
441
    {
4421×
        let runtime = self
4431×
            .runtime
4441×
            .as_ref()
4451×
            .expect("the runtime lives as long as the store");
4461×
        let started = std::time::Instant::now();
4471×
        let out = if tokio::runtime::Handle::try_current().is_ok() {
4481×
            std::thread::scope(|s| {
4491×
                s.spawn(|| runtime.block_on(f))
4501×
                    .join()
4511×
                    .unwrap_or_else(|p| std::panic::resume_unwind(p))
4521×
            })
453
        } else {
4540×
            runtime.block_on(f)
455
        };
4561×
        CALLS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4571×
        MICROS.fetch_add(
4581×
            started.elapsed().as_micros() as u64,
4591×
            std::sync::atomic::Ordering::Relaxed,
460
        );
4611×
        out
4621×
    }
463
4640×
    fn path(&self, key: &str) -> Result<ObjectPath> {
4650×
        check_key(key)?;
4660×
        let full = if self.prefix.is_empty() {
4670×
            key.to_string()
468
        } else {
4690×
            format!("{}/{key}", self.prefix)
470
        };
4710×
        ObjectPath::parse(&full).map_err(|e| Error::Backend(format!("{full}: {e}")))
4720×
    }
473
474
    /// Our error for the provider's, with what to do next where it helps.
4750×
    fn map(&self, e: object_store::Error, key: &str) -> Error {
476
        use object_store::Error as E;
4770×
        match e {
4780×
            E::NotFound { .. } => Error::NotFound(key.to_string()),
4790×
            E::AlreadyExists { .. } => Error::Exists(key.to_string()),
4800×
            E::Precondition { .. } | E::NotModified { .. } => Error::Conflict(key.to_string()),
4810×
            other => {
4820×
                let text = other.to_string();
4830×
                let lower = text.to_ascii_lowercase();
4840×
                let advice = if lower.contains("403")
4850×
                    || lower.contains("accessdenied")
4860×
                    || lower.contains("forbidden")
4870×
                    || lower.contains("authorizationpermissionmismatch")
488
                {
4890×
                    format!(
490
                        ": access denied; the credentials in use may not do this on {}: {}",
491
                        self.name, self.hint
492
                    )
4930×
                } else if lower.contains("credential") || lower.contains("no valid credentials") {
4940×
                    format!(": no usable credentials: {}", self.hint)
4950×
                } else if lower.contains("nosuchbucket")
4960×
                    || lower.contains("bucketnotfound")
4970×
                    || lower.contains("containernotfound")
498
                {
4990×
                    format!(
500
                        ": {} does not exist, or the credentials cannot see it",
501
                        self.name
502
                    )
5030×
                } else if lower.contains("dns")
5040×
                    || lower.contains("connect")
5050×
                    || lower.contains("timed out")
506
                {
5070×
                    ": cannot reach the service; check the network, the endpoint and the region"
5080×
                        .to_string()
509
                } else {
5100×
                    String::new()
511
                };
5120×
                Error::Backend(format!("{}: {text}{advice}", self.name))
513
            }
514
        }
5150×
    }
516
5172×
    fn version_of(e_tag: Option<String>, version: Option<String>) -> Version {
5182×
        Version(format!(
5192×
            "{}\t{}",
5202×
            e_tag.unwrap_or_default(),
5212×
            version.unwrap_or_default()
5222×
        ))
5232×
    }
524
5252×
    fn update_version(v: &Version) -> UpdateVersion {
5262×
        let (e_tag, version) = v.0.split_once('\t').unwrap_or((v.0.as_str(), ""));
527
        UpdateVersion {
5282×
            e_tag: (!e_tag.is_empty()).then(|| e_tag.to_string()),
5292×
            version: (!version.is_empty()).then(|| version.to_string()),
530
        }
5312×
    }
532
}
533
534
impl Store for CloudStore {
5350×
    fn describe(&self) -> String {
5360×
        self.name.clone()
5370×
    }
538
5390×
    fn has(&self, key: &str) -> Result<bool> {
5400×
        let path = self.path(key)?;
5410×
        match self.block(self.inner.head(&path)) {
5420×
            Ok(_) => Ok(true),
5430×
            Err(object_store::Error::NotFound { .. }) => Ok(false),
5440×
            Err(e) => Err(self.map(e, key)),
545
        }
5460×
    }
547
5480×
    fn get(&self, key: &str) -> Result<Vec<u8>> {
5490×
        let path = self.path(key)?;
5500×
        self.block(async {
5510×
            let r = self.inner.get(&path).await?;
5520×
            r.bytes().await
5530×
        })
5540×
        .map(|b| b.to_vec())
5550×
        .map_err(|e| self.map(e, key))
5560×
    }
557
5580×
    fn get_range(&self, key: &str, range: Range<u64>) -> Result<Vec<u8>> {
5590×
        let path = self.path(key)?;
5600×
        if range.start > range.end {
5610×
            return Err(Error::Backend(format!(
5620×
                "{key}: bytes {}..{} is not a range",
5630×
                range.start, range.end
5640×
            )));
5650×
        }
5660×
        if range.is_empty() {
5670×
            return Ok(Vec::new());
5680×
        }
5690×
        let want = (range.end - range.start) as usize;
5700×
        let bytes = self
5710×
            .block(self.inner.get_range(&path, range.clone()))
5720×
            .map_err(|e| self.map(e, key))?;
5730×
        if bytes.len() != want {
5740×
            return Err(Error::Backend(format!(
5750×
                "{key}: bytes {}..{} asked, {} returned: the object is shorter",
5760×
                range.start,
5770×
                range.end,
5780×
                bytes.len()
5790×
            )));
5800×
        }
5810×
        Ok(bytes.to_vec())
5820×
    }
583
5840×
    fn put_new(&self, key: &str, bytes: &[u8]) -> Result<()> {
5850×
        let path = self.path(key)?;
5860×
        let opts = PutOptions {
5870×
            mode: PutMode::Create,
5880×
            ..Default::default()
5890×
        };
5900×
        self.block(
5910×
            self.inner
5920×
                .put_opts(&path, PutPayload::from(bytes.to_vec()), opts),
593
        )
5940×
        .map(|_| ())
5950×
        .map_err(|e| self.map(e, key))
5960×
    }
597
5980×
    fn list(&self, prefix: &str) -> Result<Vec<Entry>> {
599
        // Providers list by whole path segments; the asked prefix may end
600
        // mid-segment, so list its directory and filter here.
6010×
        let dir = prefix.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
6020×
        let full_dir = match (self.prefix.as_str(), dir) {
6030×
            ("", "") => None,
6040×
            ("", d) => Some(d.to_string()),
6050×
            (p, "") => Some(p.to_string()),
6060×
            (p, d) => Some(format!("{p}/{d}")),
607
        };
6080×
        let full_dir = full_dir
6090×
            .map(|d| ObjectPath::parse(&d).map_err(|e| Error::Backend(format!("{d}: {e}"))))
6100×
            .transpose()?;
6110×
        let metas: Vec<object_store::ObjectMeta> = self
6120×
            .block(self.inner.list(full_dir.as_ref()).try_collect())
6130×
            .map_err(|e| self.map(e, prefix))?;
6140×
        let strip = if self.prefix.is_empty() {
6150×
            String::new()
616
        } else {
6170×
            format!("{}/", self.prefix)
618
        };
6190×
        let mut out: Vec<Entry> = metas
6200×
            .into_iter()
6210×
            .filter_map(|m| {
6220×
                let loc = m.location.to_string();
6230×
                let key = loc.strip_prefix(&strip)?;
6240×
                key.starts_with(prefix).then(|| Entry {
6250×
                    key: key.to_string(),
6260×
                    size: m.size,
6270×
                    modified_ms: u64::try_from(m.last_modified.timestamp_millis()).ok(),
6280×
                })
6290×
            })
6300×
            .collect();
6310×
        out.sort_by(|a, b| a.key.cmp(&b.key));
6320×
        Ok(out)
6330×
    }
634
6350×
    fn delete(&self, key: &str) -> Result<()> {
6360×
        let path = self.path(key)?;
6370×
        match self.block(self.inner.delete(&path)) {
6380×
            Ok(()) | Err(object_store::Error::NotFound { .. }) => Ok(()),
6390×
            Err(e) => Err(self.map(e, key)),
640
        }
6410×
    }
642
6430×
    fn read_ref(&self, key: &str) -> Result<Option<(Vec<u8>, Version)>> {
6440×
        let path = self.path(key)?;
6450×
        let got = self.block(async {
6460×
            let r = self.inner.get_opts(&path, GetOptions::default()).await?;
6470×
            let meta = r.meta.clone();
6480×
            let bytes = r.bytes().await?;
6490×
            Ok::<_, object_store::Error>((bytes, meta))
6500×
        });
6510×
        match got {
6520×
            Ok((bytes, meta)) => Ok(Some((
6530×
                bytes.to_vec(),
6540×
                Self::version_of(meta.e_tag, meta.version),
6550×
            ))),
6560×
            Err(object_store::Error::NotFound { .. }) => Ok(None),
6570×
            Err(e) => Err(self.map(e, key)),
658
        }
6590×
    }
660
6610×
    fn write_ref(&self, key: &str, bytes: &[u8], expected: Option<&Version>) -> Result<Version> {
6620×
        let path = self.path(key)?;
6630×
        let mode = match expected {
6640×
            None => PutMode::Create,
6650×
            Some(v) => PutMode::Update(Self::update_version(v)),
666
        };
6670×
        let opts = PutOptions {
6680×
            mode,
6690×
            ..Default::default()
6700×
        };
6710×
        let put = self
6720×
            .block(
6730×
                self.inner
6740×
                    .put_opts(&path, PutPayload::from(bytes.to_vec()), opts),
675
            )
6760×
            .map_err(|e| match self.map(e, key) {
677
                // A record that exists when none was expected is a lost
678
                // race too, not a misuse of put_new.
6790×
                Error::Exists(k) => Error::Conflict(k),
6800×
                other => other,
6810×
            })?;
6820×
        Ok(Self::version_of(put.e_tag, put.version))
6830×
    }
684
}
685
686
#[cfg(test)]
687
mod tests {
688
689
    /// One client per store and credentials (oxygenhub#234): opening the
690
    /// same again shares it, and only a new one counts as opened.
691
    #[test]
6921×
    fn a_store_opened_again_shares_its_client() {
6931×
        let creds = |k: &str| Credentials::Aws {
6943×
            access_key_id: k.into(),
6953×
            secret_access_key: "s".into(),
6963×
            session_token: None,
6973×
        };
6981×
        let url = "s3://oxy-shared-test/repos/a?region=eu-west-2";
6991×
        let before = super::stats().opens;
7001×
        let a = super::shared(url, Some(creds("one"))).unwrap();
7011×
        let b = super::shared(url, Some(creds("one"))).unwrap();
7021×
        assert!(std::sync::Arc::ptr_eq(&a, &b));
7031×
        let other_keys = super::shared(url, Some(creds("two"))).unwrap();
7041×
        assert!(
7051×
            !std::sync::Arc::ptr_eq(&a, &other_keys),
706
            "other credentials, another client"
707
        );
7081×
        assert!(
7091×
            super::stats().opens - before >= 2,
710
            "two clients opened (other tests may open more)"
711
        );
7121×
    }
713
714
    #[tokio::test]
7151×
    async fn a_store_can_be_used_from_inside_a_runtime() {
716
        // As a server's handler would: blocking on the store's own runtime
717
        // from here must neither panic nor deadlock, and nor must dropping it.
7181×
        let store = CloudStore::open(
7191×
            "s3://oxy-no-such-bucket/p?region=eu-west-2&endpoint=http://127.0.0.1:9",
720
        )
7211×
        .unwrap();
7221×
        assert_eq!(store.block(async { 7 }), 7);
7231×
        drop(store);
7241×
    }
725
726
    #[test]
7271×
    fn credentials_round_trip_and_never_show_their_secret() {
7281×
        let aws = Credentials::Aws {
7291×
            access_key_id: "AKIDEXAMPLE".into(),
7301×
            secret_access_key: "wJalrXUtnFEMI".into(),
7311×
            session_token: None,
7321×
        };
7331×
        assert_eq!(Credentials::from_json(&aws.to_json()).unwrap(), aws);
7341×
        assert_eq!(format!("{aws:?}"), "Aws(AKIDEXAMPLE, secret hidden)");
7351×
        let az = Credentials::Azure {
7361×
            tenant_id: "t".into(),
7371×
            client_id: "c".into(),
7381×
            client_secret: "s3cr3t".into(),
7391×
        };
7401×
        assert_eq!(Credentials::from_json(&az.to_json()).unwrap(), az);
7411×
        assert!(!format!("{az:?}").contains("s3cr3t"));
7421×
        let key = r#"{"type":"service_account","client_email":"x@y","private_key":"k"}"#;
7431×
        let gcp = Credentials::Gcp {
7441×
            service_account_key: key.into(),
7451×
        };
7461×
        assert_eq!(Credentials::from_json(&gcp.to_json()).unwrap(), gcp);
7471×
        assert!(!format!("{gcp:?}").contains("private_key"));
7484×
        for (bad, why) in [
7491×
            (r#"{"kind":"aws","access_key_id":"a"}"#, "secret_access_key"),
7501×
            (
7511×
                r#"{"kind":"gcp","service_account_key":"{\"type\":\"authorized_user\"}"}"#,
7521×
                "not a service account key",
7531×
            ),
7541×
            (r#"{"kind":"ftp"}"#, "aws, gcp or azure"),
7551×
            ("nope", "not JSON"),
7561×
        ] {
7574×
            let e = Credentials::from_json(bad).unwrap_err().to_string();
7584×
            assert!(e.contains(why), "{bad}: {e}");
759
        }
7601×
        let e = CloudStore::open_with("gs://bucket/p", Some(&aws))
7611×
            .err()
7621×
            .unwrap()
7631×
            .to_string();
7641×
        assert!(e.contains("are for s3:// stores"), "{e}");
7651×
    }
766
767
    use super::*;
768
769
    #[test]
7701×
    fn urls_parse_with_their_parameters() {
7711×
        let u =
7721×
            StoreUrl::parse("s3://my-bucket/repos/oxygen?profile=work&region=eu-west-2").unwrap();
7731×
        assert_eq!(u.scheme, "s3");
7741×
        assert_eq!(u.host, "my-bucket");
7751×
        assert_eq!(u.path, "repos/oxygen");
7761×
        assert_eq!(u.param("profile"), Some("work"));
7771×
        assert_eq!(u.param("region"), Some("eu-west-2"));
7781×
        assert_eq!(u.plain(), "s3://my-bucket/repos/oxygen");
7791×
        let u = StoreUrl::parse("gs://bucket").unwrap();
7801×
        assert_eq!((u.host.as_str(), u.path.as_str()), ("bucket", ""));
7811×
        assert_eq!(u.plain(), "gs://bucket");
7821×
        let u = StoreUrl::parse("az://acct/container/a/b/?endpoint=http%3A%2F%2F127.0.0.1%3A10000")
7831×
            .unwrap();
7841×
        assert_eq!(u.path, "container/a/b");
7851×
        assert_eq!(u.param("endpoint"), Some("http://127.0.0.1:10000"));
7861×
        assert!(StoreUrl::parse("s3://").is_err());
7871×
        assert!(StoreUrl::parse("nonsense").is_err());
7881×
    }
789
790
    #[test]
7911×
    fn versions_carry_both_an_etag_and_a_generation() {
7921×
        let v = CloudStore::version_of(Some("\"abc\"".into()), Some("17".into()));
7931×
        let u = CloudStore::update_version(&v);
7941×
        assert_eq!(u.e_tag.as_deref(), Some("\"abc\""));
7951×
        assert_eq!(u.version.as_deref(), Some("17"));
7961×
        let u = CloudStore::update_version(&CloudStore::version_of(None, Some("3".into())));
7971×
        assert_eq!((u.e_tag, u.version.as_deref()), (None, Some("3")));
7981×
    }
799
}