OXYGENHUB

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

472 of 778 lines run, 69 of 257 functions · run #372 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.
34155×
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.
37155×
    let _ = rustls::crypto::ring::default_provider().install_default();
38155×
    oxy_core::protocol::register_store_opener(open_url);
39155×
}
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 stat(&self, key: &str) -> Result<Option<Entry>> {
960×
        self.0.stat(key)
970×
    }
980×
    fn delete(&self, key: &str) -> Result<()> {
990×
        self.0.delete(key)
1000×
    }
1010×
    fn read_ref(&self, key: &str) -> Result<Option<(Vec<u8>, Version)>> {
1020×
        self.0.read_ref(key)
1030×
    }
1040×
    fn write_ref(&self, key: &str, bytes: &[u8], expected: Option<&Version>) -> Result<Version> {
1050×
        self.0.write_ref(key, bytes, expected)
1060×
    }
107
}
108
109
static OPENS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
110
static CALLS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
111
static MICROS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
112
static BY_OP: [std::sync::atomic::AtomicU64; Op::ALL.len()] =
113
    [const { std::sync::atomic::AtomicU64::new(0) }; Op::ALL.len()];
114
115
/// The kinds of request a cloud store makes, counted apart so a server can
116
/// tell which of them its time goes to.
117
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
118
pub enum Op {
119
    /// `has` that found the object.
120
    Has,
121
    /// `has` that did not: an absent object is never cached.
122
    HasMissing,
123
    Get,
124
    Range,
125
    Put,
126
    List,
127
    /// A listing of a repository's packs, sent.
128
    ListPacks,
129
    /// A listing answered from [`Listings`], not sent.
130
    ListKept,
131
    Delete,
132
    ReadRef,
133
    /// A read of a repository's journal of signatures and index entries
134
    /// (`pack-entries`, spec §13.7): one per open of a folded store, in
135
    /// place of a listing per lookup.
136
    ReadJournal,
137
    WriteRef,
138
}
139
140
impl Op {
141
    pub const ALL: [Op; 12] = [
142
        Op::Has,
143
        Op::HasMissing,
144
        Op::Get,
145
        Op::Range,
146
        Op::Put,
147
        Op::List,
148
        Op::ListPacks,
149
        Op::ListKept,
150
        Op::Delete,
151
        Op::ReadRef,
152
        Op::ReadJournal,
153
        Op::WriteRef,
154
    ];
155
15612×
    pub fn name(self) -> &'static str {
15712×
        match self {
1581×
            Op::Has => "has",
1591×
            Op::HasMissing => "has_missing",
1601×
            Op::Get => "get",
1611×
            Op::Range => "range",
1621×
            Op::Put => "put",
1631×
            Op::List => "list",
1641×
            Op::ListPacks => "list_packs",
1651×
            Op::ListKept => "list_kept",
1661×
            Op::Delete => "delete",
1671×
            Op::ReadRef => "read_ref",
1681×
            Op::ReadJournal => "read_journal",
1691×
            Op::WriteRef => "write_ref",
170
        }
17112×
    }
172
}
173
1740×
fn counted(op: Op) {
1750×
    BY_OP[op as usize].fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1760×
}
177
178
/// What the cloud stores have done since the process started: clients
179
/// opened, requests made and the time spent in them, for a server to show
180
/// (a hub's /health).
181
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
182
pub struct Stats {
183
    pub opens: u64,
184
    pub calls: u64,
185
    pub millis: u64,
186
    /// Requests by kind, in [`Op::ALL`]'s order.
187
    pub by_op: [u64; Op::ALL.len()],
188
}
189
190
impl Stats {
191
    /// Each kind of request with how many were made.
1921×
    pub fn ops(&self) -> impl Iterator<Item = (&'static str, u64)> + '_ {
1931×
        Op::ALL
1941×
            .iter()
19512×
            .map(|op| (op.name(), self.by_op[*op as usize]))
1961×
    }
197
}
198
1993×
pub fn stats() -> Stats {
200
    use std::sync::atomic::Ordering::Relaxed;
201
    Stats {
2023×
        opens: OPENS.load(Relaxed),
2033×
        calls: CALLS.load(Relaxed),
2043×
        millis: MICROS.load(Relaxed) / 1000,
20536×
        by_op: std::array::from_fn(|i| BY_OP[i].load(Relaxed)),
206
    }
2073×
}
208
209
/// Credentials a program keeps itself and hands over, instead of the
210
/// provider's usual places: a hub keeps each repository's sealed. When a
211
/// store is opened with them, nothing from the environment or the
212
/// provider's files is used.
213
#[derive(Clone, PartialEq, Eq, Hash)]
214
pub enum Credentials {
215
    /// An S3 access key.
216
    Aws {
217
        access_key_id: String,
218
        secret_access_key: String,
219
        session_token: Option<String>,
220
    },
221
    /// A Google Cloud service account key, as the JSON Google issues.
222
    Gcp { service_account_key: String },
223
    /// An Azure service principal.
224
    Azure {
225
        tenant_id: String,
226
        client_id: String,
227
        client_secret: String,
228
    },
229
}
230
231
impl std::fmt::Debug for Credentials {
232
    /// Names the kind and the public part only: never the secret.
2333×
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2343×
        match self {
2351×
            Credentials::Aws { access_key_id, .. } => {
2361×
                write!(f, "Aws({access_key_id}, secret hidden)")
237
            }
2381×
            Credentials::Gcp { .. } => write!(f, "Gcp(service account key hidden)"),
2391×
            Credentials::Azure { client_id, .. } => write!(f, "Azure({client_id}, secret hidden)"),
240
        }
2413×
    }
242
}
243
244
impl Credentials {
245
    /// The scheme of the stores these are for.
2464×
    pub fn scheme(&self) -> &'static str {
2474×
        match self {
2484×
            Credentials::Aws { .. } => "s3",
2490×
            Credentials::Gcp { .. } => "gs",
2500×
            Credentials::Azure { .. } => "az",
251
        }
2524×
    }
253
254
    /// As JSON, for keeping (sealed) and reading back with [`Credentials::from_json`].
2553×
    pub fn to_json(&self) -> String {
2563×
        let v = match self {
257
            Credentials::Aws {
2581×
                access_key_id,
2591×
                secret_access_key,
2601×
                session_token,
2611×
            } => serde_json::json!({
2621×
                "kind": "aws", "access_key_id": access_key_id, "secret_access_key": secret_access_key,
2631×
                "session_token": session_token,
264
            }),
265
            Credentials::Gcp {
2661×
                service_account_key,
267
            } => {
2681×
                serde_json::json!({ "kind": "gcp", "service_account_key": service_account_key })
269
            }
270
            Credentials::Azure {
2711×
                tenant_id,
2721×
                client_id,
2731×
                client_secret,
2741×
            } => serde_json::json!({
2751×
                "kind": "azure", "tenant_id": tenant_id, "client_id": client_id, "client_secret": client_secret,
276
            }),
277
        };
2783×
        v.to_string()
2793×
    }
280
2817×
    pub fn from_json(text: &str) -> Result<Credentials> {
2827×
        let v: serde_json::Value = serde_json::from_str(text)
2837×
            .map_err(|e| Error::Backend(format!("credentials are not JSON: {e}")))?;
28410×
        let field = |k: &str| -> Result<String> {
28510×
            v.get(k)
28610×
                .and_then(|x| x.as_str())
28710×
                .filter(|x| !x.trim().is_empty())
28810×
                .map(|x| x.trim().to_string())
28910×
                .ok_or_else(|| Error::Backend(format!("credentials need {k}")))
29010×
        };
2916×
        match v.get("kind").and_then(|k| k.as_str()) {
2926×
            Some("aws") => Ok(Credentials::Aws {
2932×
                access_key_id: field("access_key_id")?,
2942×
                secret_access_key: field("secret_access_key")?,
2951×
                session_token: field("session_token").ok(),
296
            }),
2974×
            Some("gcp") => {
2982×
                let key = field("service_account_key")?;
2992×
                let parsed: serde_json::Value = serde_json::from_str(&key).map_err(|_| {
3000×
                    Error::Backend("the service account key is not the JSON Google issues".into())
3010×
                })?;
3022×
                if parsed.get("type").and_then(|t| t.as_str()) != Some("service_account") {
3031×
                    return Err(Error::Backend(
3041×
                        "the key is not a service account key (its type is not service_account)"
3051×
                            .into(),
3061×
                    ));
3071×
                }
3081×
                Ok(Credentials::Gcp {
3091×
                    service_account_key: key,
3101×
                })
311
            }
3122×
            Some("azure") => Ok(Credentials::Azure {
3131×
                tenant_id: field("tenant_id")?,
3141×
                client_id: field("client_id")?,
3151×
                client_secret: field("client_secret")?,
316
            }),
3171×
            _ => Err(Error::Backend(
3181×
                "credentials need a kind: aws, gcp or azure".into(),
3191×
            )),
320
        }
3217×
    }
322
}
323
324
/// Finds the credentials a program keeps for a store URL.
325
pub type CredentialsLookup = fn(&str) -> Option<Credentials>;
326
327
static CREDENTIALS: Mutex<Option<CredentialsLookup>> = Mutex::new(None);
328
329
/// Have stores opened by URL (as a repository's `storage.url` is) use the
330
/// credentials `lookup` finds for it, where it finds any.
3310×
pub fn set_credentials_lookup(lookup: CredentialsLookup) {
3320×
    *CREDENTIALS.lock().unwrap_or_else(|p| p.into_inner()) = Some(lookup);
3330×
}
334
335
/// A parsed store URL: `SCHEME://HOST/PATH?PARAMS`.
336
#[derive(Clone, Debug, PartialEq, Eq)]
337
pub struct StoreUrl {
338
    pub scheme: String,
339
    /// The bucket (S3, GCS) or the storage account (Azure).
340
    pub host: String,
341
    /// The path under the host, without leading or trailing slashes; for
342
    /// Azure the container is its first segment.
343
    pub path: String,
344
    pub params: BTreeMap<String, String>,
345
}
346
347
impl StoreUrl {
3489×
    pub fn parse(url: &str) -> Result<StoreUrl> {
3499×
        let bad = |why: &str| Error::Backend(format!("{url}: {why}"));
3509×
        let (scheme, rest) = url.split_once("://").ok_or_else(|| bad("not a URL"))?;
3518×
        let (rest, query) = match rest.split_once('?') {
3525×
            Some((r, q)) => (r, Some(q)),
3533×
            None => (rest, None),
354
        };
3558×
        let (host, path) = match rest.split_once('/') {
3566×
            Some((h, p)) => (h, p.trim_matches('/')),
3572×
            None => (rest, ""),
358
        };
3598×
        if host.is_empty() {
3601×
            return Err(bad("names no bucket"));
3617×
        }
3627×
        let mut params = BTreeMap::new();
3639×
        for pair in query.unwrap_or("").split('&').filter(|p| !p.is_empty()) {
3647×
            let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
3657×
            params.insert(k.to_string(), percent_decode(v));
3667×
        }
3677×
        Ok(StoreUrl {
3687×
            scheme: scheme.to_string(),
3697×
            host: host.to_string(),
3707×
            path: path.to_string(),
3717×
            params,
3727×
        })
3739×
    }
374
375
    /// The URL without its parameters, for messages.
3766×
    pub fn plain(&self) -> String {
3776×
        if self.path.is_empty() {
3781×
            format!("{}://{}", self.scheme, self.host)
379
        } else {
3805×
            format!("{}://{}/{}", self.scheme, self.host, self.path)
381
        }
3826×
    }
383
38410×
    pub fn param(&self, name: &str) -> Option<&str> {
38510×
        self.params.get(name).map(String::as_str)
38610×
    }
387
}
388
3897×
fn percent_decode(s: &str) -> String {
3907×
    let bytes = s.as_bytes();
3917×
    let mut out = Vec::with_capacity(bytes.len());
3927×
    let mut i = 0;
39387×
    while i < bytes.len() {
39480×
        if bytes[i] == b'%' && i + 2 < bytes.len() {
3954×
            if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
3964×
                out.push(v);
3974×
                i += 3;
3984×
                continue;
3990×
            }
40076×
        }
40176×
        out.push(bytes[i]);
40276×
        i += 1;
403
    }
4047×
    String::from_utf8_lossy(&out).into_owned()
4057×
}
406
407
/// A store on a cloud provider: the provider's client, the prefix every
408
/// key goes under, and a runtime to run the client on.
409
pub struct CloudStore {
410
    inner: Arc<dyn ObjectStore>,
411
    prefix: String,
412
    name: String,
413
    hint: &'static str,
414
    /// Always there until the store is dropped: two workers, so the
415
    /// requests of a shared store run side by side.
416
    runtime: Option<tokio::runtime::Runtime>,
417
    /// Keys `has` found missing lately (see [`Misses`]).
418
    misses: Misses,
419
    /// Pack listings, kept a moment (see [`Listings`]).
420
    listings: Listings,
421
}
422
423
/// How long a key found missing is taken as missing without asking again.
424
const MISS_FOR: std::time::Duration = std::time::Duration::from_secs(30);
425
426
/// Keys found missing in the last [`MISS_FOR`], so a server answering the
427
/// same question again and again does not send a request each time
428
/// (oxygenhub#228: a client's negotiate asked about ~190 objects the hub
429
/// lacked every 8 s, 45 ms a request). This process forgets a key the
430
/// moment it writes it, so its own writes are never hidden; another writer
431
/// to the same keys is seen within `MISS_FOR`.
432
#[derive(Default)]
433
struct Misses(Mutex<std::collections::HashMap<String, std::time::Instant>>);
434
435
impl Misses {
436
    /// At most this many kept; past it the oldest half is let go.
437
    const MAX: usize = 100_000;
438
4396×
    fn known(&self, key: &str) -> bool {
4406×
        let map = self.0.lock().unwrap_or_else(|p| p.into_inner());
4416×
        map.get(key).is_some_and(|t| t.elapsed() < MISS_FOR)
4426×
    }
443
4443×
    fn missed(&self, key: &str) {
4453×
        let mut map = self.0.lock().unwrap_or_else(|p| p.into_inner());
4463×
        if map.len() >= Self::MAX {
4470×
            map.retain(|_, t| t.elapsed() < MISS_FOR / 2);
4480×
            if map.len() >= Self::MAX {
4490×
                map.clear();
4500×
            }
4513×
        }
4523×
        map.insert(key.to_string(), std::time::Instant::now());
4533×
    }
454
4557×
    fn written(&self, key: &str) {
4567×
        self.0.lock().unwrap_or_else(|p| p.into_inner()).remove(key);
4577×
    }
458
}
459
460
/// Listings kept for [`MISS_FOR`] (oxygen#116): the `pack-` keys, which
461
/// every open of a repository's store lists, so a forge serving a
462
/// repository from a bucket lists them once a while rather than once a
463
/// request. Like [`Misses`], this process forgets a listing the moment it
464
/// writes or deletes a key under it; another writer's pack is seen within
465
/// `MISS_FOR`, as a missing key is.
466
#[derive(Default)]
467
struct Listings(Mutex<std::collections::HashMap<String, (std::time::Instant, Vec<Entry>)>>);
468
469
impl Listings {
470
    /// The prefixes kept: those every open lists.
47117×
    fn kept(prefix: &str) -> bool {
47217×
        prefix == "pack-"
47317×
    }
474
4759×
    fn get(&self, prefix: &str) -> Option<Vec<Entry>> {
4769×
        if !Self::kept(prefix) {
4771×
            return None;
4788×
        }
4798×
        let map = self.0.lock().unwrap_or_else(|p| p.into_inner());
4808×
        map.get(prefix)
4818×
            .filter(|(t, _)| t.elapsed() < MISS_FOR)
4828×
            .map(|(_, e)| e.clone())
4839×
    }
484
4858×
    fn put(&self, prefix: &str, entries: &[Entry]) {
4868×
        if Self::kept(prefix) {
4877×
            self.0.lock().unwrap_or_else(|p| p.into_inner()).insert(
4887×
                prefix.to_string(),
4897×
                (std::time::Instant::now(), entries.to_vec()),
490
            );
4911×
        }
4928×
    }
493
4948×
    fn written(&self, key: &str) {
4958×
        self.0
4968×
            .lock()
4978×
            .unwrap_or_else(|p| p.into_inner())
4988×
            .retain(|prefix, _| !key.starts_with(prefix.as_str()));
4998×
    }
500
}
501
502
/// Run a request that writes or deletes `key`, forgetting what the caches
503
/// hold for it both before it is sent and after it completes, whatever it
504
/// returns: a listing or a miss taken by another thread while the request
505
/// was in flight may predate it, and would otherwise be kept for
506
/// `MISS_FOR` (oxygenhub#249).
5073×
fn writing<T>(misses: &Misses, listings: &Listings, key: &str, request: impl FnOnce() -> T) -> T {
5083×
    misses.written(key);
5093×
    listings.written(key);
5103×
    let out = request();
5113×
    misses.written(key);
5123×
    listings.written(key);
5133×
    out
5143×
}
515
516
impl Drop for CloudStore {
517
    /// Without waiting, so a store can be dropped inside a runtime too.
5181×
    fn drop(&mut self) {
5191×
        if let Some(rt) = self.runtime.take() {
5201×
            rt.shutdown_background();
5211×
        }
5221×
    }
523
}
524
525
impl CloudStore {
526
    /// Open the store a URL names. Nothing is sent yet: credentials are
527
    /// resolved here, but the bucket is first spoken to on the first call.
5281×
    pub fn open(url: &str) -> Result<CloudStore> {
5291×
        Self::open_with(url, None)
5301×
    }
531
532
    /// [`CloudStore::open`] with credentials given, which must be for
533
    /// the URL's kind of store; then only they are used.
5344×
    pub fn open_with(url: &str, given: Option<&Credentials>) -> Result<CloudStore> {
5354×
        let parsed = StoreUrl::parse(url)?;
5364×
        if let Some(c) = given.filter(|c| c.scheme() != parsed.scheme) {
5371×
            return Err(Error::Backend(format!(
5381×
                "{}: the credentials given are for {}:// stores",
5391×
                parsed.plain(),
5401×
                c.scheme()
5411×
            )));
5423×
        }
5433×
        let aws_keys = match given {
544
            Some(Credentials::Aws {
5452×
                access_key_id,
5462×
                secret_access_key,
5472×
                session_token,
5482×
            }) => Some((
5492×
                access_key_id.as_str(),
5502×
                secret_access_key.as_str(),
5512×
                session_token.as_deref(),
5522×
            )),
5531×
            _ => None,
554
        };
5553×
        let gcp_key = match given {
556
            Some(Credentials::Gcp {
5570×
                service_account_key,
5580×
            }) => Some(service_account_key.as_str()),
5593×
            _ => None,
560
        };
5613×
        let principal = match given {
562
            Some(Credentials::Azure {
5630×
                tenant_id,
5640×
                client_id,
5650×
                client_secret,
5660×
            }) => Some((
5670×
                tenant_id.as_str(),
5680×
                client_id.as_str(),
5690×
                client_secret.as_str(),
5700×
            )),
5713×
            _ => None,
572
        };
5733×
        OPENS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5743×
        let runtime = tokio::runtime::Builder::new_multi_thread()
5753×
            .worker_threads(2)
5763×
            .thread_name("oxy-strata")
5773×
            .enable_all()
5783×
            .build()
5793×
            .map_err(|e| Error::Backend(format!("starting the HTTP runtime: {e}")))?;
5803×
        let (inner, prefix, hint): (Arc<dyn ObjectStore>, String, &'static str) =
5813×
            match parsed.scheme.as_str() {
5823×
                "s3" => (
5833×
                    Arc::new(aws::open(&parsed, aws_keys)?),
5843×
                    parsed.path.clone(),
585
                    aws::HINT,
586
                ),
5870×
                "gs" => (
5880×
                    Arc::new(gcp::open(&parsed, gcp_key)?),
5890×
                    parsed.path.clone(),
590
                    gcp::HINT,
591
                ),
5920×
                "az" => {
5930×
                    let (store, prefix) = azure::open(&parsed, principal)?;
5940×
                    (Arc::new(store), prefix, azure::HINT)
595
                }
5960×
                other => {
5970×
                    return Err(Error::Backend(format!(
5980×
                        "{url}: {other}:// is not a store oxy knows (s3://, gs://, az://, dir://)"
5990×
                    )))
600
                }
601
            };
6023×
        Ok(CloudStore {
6033×
            inner,
6043×
            prefix,
6053×
            name: parsed.plain(),
6063×
            hint,
6073×
            runtime: Some(runtime),
6083×
            misses: Misses::default(),
6093×
            listings: Listings::default(),
6103×
        })
6114×
    }
612
613
    /// Run a request to completion. Called from a thread that is itself
614
    /// driving async tasks (a server's handler), where blocking on another
615
    /// runtime is not allowed, the request runs on a thread of its own.
6161×
    fn block<F>(&self, f: F) -> F::Output
6171×
    where
6181×
        F: Future + Send,
6191×
        F::Output: Send,
620
    {
6211×
        let runtime = self
6221×
            .runtime
6231×
            .as_ref()
6241×
            .expect("the runtime lives as long as the store");
6251×
        let started = std::time::Instant::now();
6261×
        let out = if tokio::runtime::Handle::try_current().is_ok() {
6271×
            std::thread::scope(|s| {
6281×
                s.spawn(|| runtime.block_on(f))
6291×
                    .join()
6301×
                    .unwrap_or_else(|p| std::panic::resume_unwind(p))
6311×
            })
632
        } else {
6330×
            runtime.block_on(f)
634
        };
6351×
        CALLS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6361×
        MICROS.fetch_add(
6371×
            started.elapsed().as_micros() as u64,
6381×
            std::sync::atomic::Ordering::Relaxed,
639
        );
6401×
        out
6411×
    }
642
6430×
    fn path(&self, key: &str) -> Result<ObjectPath> {
6440×
        check_key(key)?;
6450×
        let full = if self.prefix.is_empty() {
6460×
            key.to_string()
647
        } else {
6480×
            format!("{}/{key}", self.prefix)
649
        };
6500×
        ObjectPath::parse(&full).map_err(|e| Error::Backend(format!("{full}: {e}")))
6510×
    }
652
653
    /// Our error for the provider's, with what to do next where it helps.
6540×
    fn map(&self, e: object_store::Error, key: &str) -> Error {
655
        use object_store::Error as E;
6560×
        match e {
6570×
            E::NotFound { .. } => Error::NotFound(key.to_string()),
6580×
            E::AlreadyExists { .. } => Error::Exists(key.to_string()),
6590×
            E::Precondition { .. } | E::NotModified { .. } => Error::Conflict(key.to_string()),
6600×
            other => {
6610×
                let text = other.to_string();
6620×
                let lower = text.to_ascii_lowercase();
6630×
                let advice = if lower.contains("403")
6640×
                    || lower.contains("accessdenied")
6650×
                    || lower.contains("forbidden")
6660×
                    || lower.contains("authorizationpermissionmismatch")
667
                {
6680×
                    format!(
669
                        ": access denied; the credentials in use may not do this on {}: {}",
670
                        self.name, self.hint
671
                    )
6720×
                } else if lower.contains("credential") || lower.contains("no valid credentials") {
6730×
                    format!(": no usable credentials: {}", self.hint)
6740×
                } else if lower.contains("nosuchbucket")
6750×
                    || lower.contains("bucketnotfound")
6760×
                    || lower.contains("containernotfound")
677
                {
6780×
                    format!(
679
                        ": {} does not exist, or the credentials cannot see it",
680
                        self.name
681
                    )
6820×
                } else if lower.contains("dns")
6830×
                    || lower.contains("connect")
6840×
                    || lower.contains("timed out")
685
                {
6860×
                    ": cannot reach the service; check the network, the endpoint and the region"
6870×
                        .to_string()
688
                } else {
6890×
                    String::new()
690
                };
6910×
                Error::Backend(format!("{}: {text}{advice}", self.name))
692
            }
693
        }
6940×
    }
695
6962×
    fn version_of(e_tag: Option<String>, version: Option<String>) -> Version {
6972×
        Version(format!(
6982×
            "{}\t{}",
6992×
            e_tag.unwrap_or_default(),
7002×
            version.unwrap_or_default()
7012×
        ))
7022×
    }
703
7042×
    fn update_version(v: &Version) -> UpdateVersion {
7052×
        let (e_tag, version) = v.0.split_once('\t').unwrap_or((v.0.as_str(), ""));
706
        UpdateVersion {
7072×
            e_tag: (!e_tag.is_empty()).then(|| e_tag.to_string()),
7082×
            version: (!version.is_empty()).then(|| version.to_string()),
709
        }
7102×
    }
711
}
712
713
impl Store for CloudStore {
7140×
    fn describe(&self) -> String {
7150×
        self.name.clone()
7160×
    }
717
7180×
    fn has(&self, key: &str) -> Result<bool> {
7190×
        let path = self.path(key)?;
7200×
        if self.misses.known(key) {
7210×
            return Ok(false);
7220×
        }
7230×
        match self.block(self.inner.head(&path)) {
724
            Ok(_) => {
7250×
                counted(Op::Has);
7260×
                Ok(true)
727
            }
728
            Err(object_store::Error::NotFound { .. }) => {
7290×
                counted(Op::HasMissing);
7300×
                self.misses.missed(key);
7310×
                Ok(false)
732
            }
7330×
            Err(e) => Err(self.map(e, key)),
734
        }
7350×
    }
736
7370×
    fn get(&self, key: &str) -> Result<Vec<u8>> {
7380×
        let path = self.path(key)?;
7390×
        counted(Op::Get);
7400×
        self.block(async {
7410×
            let r = self.inner.get(&path).await?;
7420×
            r.bytes().await
7430×
        })
7440×
        .map(|b| b.to_vec())
7450×
        .map_err(|e| self.map(e, key))
7460×
    }
747
7480×
    fn get_range(&self, key: &str, range: Range<u64>) -> Result<Vec<u8>> {
7490×
        let path = self.path(key)?;
7500×
        if range.start > range.end {
7510×
            return Err(Error::Backend(format!(
7520×
                "{key}: bytes {}..{} is not a range",
7530×
                range.start, range.end
7540×
            )));
7550×
        }
7560×
        if range.is_empty() {
7570×
            return Ok(Vec::new());
7580×
        }
7590×
        let want = (range.end - range.start) as usize;
7600×
        counted(Op::Range);
7610×
        let bytes = self
7620×
            .block(self.inner.get_range(&path, range.clone()))
7630×
            .map_err(|e| self.map(e, key))?;
7640×
        if bytes.len() != want {
7650×
            return Err(Error::Backend(format!(
7660×
                "{key}: bytes {}..{} asked, {} returned: the object is shorter",
7670×
                range.start,
7680×
                range.end,
7690×
                bytes.len()
7700×
            )));
7710×
        }
7720×
        Ok(bytes.to_vec())
7730×
    }
774
7750×
    fn put_new(&self, key: &str, bytes: &[u8]) -> Result<()> {
7760×
        let path = self.path(key)?;
7770×
        let opts = PutOptions {
7780×
            mode: PutMode::Create,
7790×
            ..Default::default()
7800×
        };
7810×
        counted(Op::Put);
7820×
        writing(&self.misses, &self.listings, key, || {
7830×
            self.block(
7840×
                self.inner
7850×
                    .put_opts(&path, PutPayload::from(bytes.to_vec()), opts),
786
            )
7870×
        })
7880×
        .map(|_| ())
7890×
        .map_err(|e| self.map(e, key))
7900×
    }
791
7920×
    fn list(&self, prefix: &str) -> Result<Vec<Entry>> {
7930×
        if let Some(kept) = self.listings.get(prefix) {
7940×
            counted(Op::ListKept);
7950×
            return Ok(kept);
7960×
        }
797
        // Providers list by whole path segments; the asked prefix may end
798
        // mid-segment, so list its directory and filter here.
7990×
        let dir = prefix.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
8000×
        let full_dir = match (self.prefix.as_str(), dir) {
8010×
            ("", "") => None,
8020×
            ("", d) => Some(d.to_string()),
8030×
            (p, "") => Some(p.to_string()),
8040×
            (p, d) => Some(format!("{p}/{d}")),
805
        };
8060×
        let full_dir = full_dir
8070×
            .map(|d| ObjectPath::parse(&d).map_err(|e| Error::Backend(format!("{d}: {e}"))))
8080×
            .transpose()?;
8090×
        counted(if Listings::kept(prefix) {
8100×
            Op::ListPacks
811
        } else {
8120×
            Op::List
813
        });
8140×
        let metas: Vec<object_store::ObjectMeta> = self
8150×
            .block(self.inner.list(full_dir.as_ref()).try_collect())
8160×
            .map_err(|e| self.map(e, prefix))?;
8170×
        let strip = if self.prefix.is_empty() {
8180×
            String::new()
819
        } else {
8200×
            format!("{}/", self.prefix)
821
        };
8220×
        let mut out: Vec<Entry> = metas
8230×
            .into_iter()
8240×
            .filter_map(|m| {
8250×
                let loc = m.location.to_string();
8260×
                let key = loc.strip_prefix(&strip)?;
8270×
                key.starts_with(prefix).then(|| Entry {
8280×
                    key: key.to_string(),
8290×
                    size: m.size,
8300×
                    modified_ms: u64::try_from(m.last_modified.timestamp_millis()).ok(),
8310×
                })
8320×
            })
8330×
            .collect();
8340×
        out.sort_by(|a, b| a.key.cmp(&b.key));
8350×
        self.listings.put(prefix, &out);
8360×
        Ok(out)
8370×
    }
838
839
    /// A HEAD of the key, not a listing of its directory (oxygen#116).
8400×
    fn stat(&self, key: &str) -> Result<Option<Entry>> {
8410×
        let path = self.path(key)?;
8420×
        counted(Op::Has);
8430×
        match self.block(self.inner.head(&path)) {
8440×
            Ok(m) => Ok(Some(Entry {
8450×
                key: key.to_string(),
8460×
                size: m.size,
8470×
                modified_ms: u64::try_from(m.last_modified.timestamp_millis()).ok(),
8480×
            })),
8490×
            Err(object_store::Error::NotFound { .. }) => Ok(None),
8500×
            Err(e) => Err(self.map(e, key)),
851
        }
8520×
    }
853
8540×
    fn delete(&self, key: &str) -> Result<()> {
8550×
        let path = self.path(key)?;
8560×
        counted(Op::Delete);
8570×
        match writing(&self.misses, &self.listings, key, || {
8580×
            self.block(self.inner.delete(&path))
8590×
        }) {
8600×
            Ok(()) | Err(object_store::Error::NotFound { .. }) => Ok(()),
8610×
            Err(e) => Err(self.map(e, key)),
862
        }
8630×
    }
864
8650×
    fn read_ref(&self, key: &str) -> Result<Option<(Vec<u8>, Version)>> {
8660×
        let path = self.path(key)?;
8670×
        counted(if key == oxy_core::store::JOURNAL_KEY {
8680×
            Op::ReadJournal
869
        } else {
8700×
            Op::ReadRef
871
        });
8720×
        let got = self.block(async {
8730×
            let r = self.inner.get_opts(&path, GetOptions::default()).await?;
8740×
            let meta = r.meta.clone();
8750×
            let bytes = r.bytes().await?;
8760×
            Ok::<_, object_store::Error>((bytes, meta))
8770×
        });
8780×
        match got {
8790×
            Ok((bytes, meta)) => Ok(Some((
8800×
                bytes.to_vec(),
8810×
                Self::version_of(meta.e_tag, meta.version),
8820×
            ))),
8830×
            Err(object_store::Error::NotFound { .. }) => Ok(None),
8840×
            Err(e) => Err(self.map(e, key)),
885
        }
8860×
    }
887
8880×
    fn write_ref(&self, key: &str, bytes: &[u8], expected: Option<&Version>) -> Result<Version> {
8890×
        let path = self.path(key)?;
8900×
        let mode = match expected {
8910×
            None => PutMode::Create,
8920×
            Some(v) => PutMode::Update(Self::update_version(v)),
893
        };
8940×
        let opts = PutOptions {
8950×
            mode,
8960×
            ..Default::default()
8970×
        };
8980×
        counted(Op::WriteRef);
8990×
        let put = writing(&self.misses, &self.listings, key, || {
9000×
            self.block(
9010×
                self.inner
9020×
                    .put_opts(&path, PutPayload::from(bytes.to_vec()), opts),
903
            )
9040×
        })
9050×
        .map_err(|e| match self.map(e, key) {
906
            // A record that exists when none was expected is a lost
907
            // race too, not a misuse of put_new.
9080×
            Error::Exists(k) => Error::Conflict(k),
9090×
            other => other,
9100×
        })?;
9110×
        Ok(Self::version_of(put.e_tag, put.version))
9120×
    }
913
}
914
915
#[cfg(test)]
916
mod tests {
917
918
    /// A miss is remembered until the key is written, or for MISS_FOR.
919
    #[test]
9201×
    fn a_miss_is_remembered_until_written() {
9211×
        let m = super::Misses::default();
9221×
        assert!(!m.known("loose/ab/cd"));
9231×
        m.missed("loose/ab/cd");
9241×
        assert!(m.known("loose/ab/cd") && !m.known("loose/ab/ce"));
9251×
        m.written("loose/ab/cd");
9261×
        assert!(!m.known("loose/ab/cd"), "a write forgets the miss");
9271×
        m.missed("old");
9281×
        m.0.lock()
9291×
            .unwrap()
9301×
            .insert("old".into(), std::time::Instant::now() - super::MISS_FOR);
9311×
        assert!(!m.known("old"), "a miss expires");
9321×
    }
933
934
    /// Every kind of request is reported, once and by its own name.
935
    /// The pack listing is kept until a pack key is written or deleted
936
    /// here, or for MISS_FOR; other prefixes are never kept (oxygen#116).
937
    #[test]
9381×
    fn the_pack_listing_is_kept_until_a_pack_is_written() {
9391×
        let l = super::Listings::default();
9401×
        let e = |k: &str| oxy_core::strata::Entry {
9413×
            key: k.into(),
942
            size: 1,
9433×
            modified_ms: None,
9443×
        };
9451×
        l.put("pack-", &[e("pack-1.idx")]);
9461×
        assert_eq!(l.get("pack-").map(|v| v.len()), Some(1));
9471×
        l.put("loose/", &[e("loose/ab")]);
9481×
        assert!(l.get("loose/").is_none(), "only the pack listing is kept");
9491×
        l.written("loose/cd");
9501×
        assert!(l.get("pack-").is_some(), "a loose object leaves it");
9511×
        l.written("pack-2.pack");
9521×
        assert!(l.get("pack-").is_none(), "a new pack forgets it");
9531×
        l.put("pack-", &[e("pack-1.idx")]);
9541×
        l.0.lock().unwrap().get_mut("pack-").unwrap().0 -= super::MISS_FOR;
9551×
        assert!(l.get("pack-").is_none(), "and it ages out");
9561×
    }
957
958
    /// A listing or miss taken by another thread while a write is in
959
    /// flight is forgotten when the write completes, not kept stale for
960
    /// MISS_FOR (oxygenhub#249), whether the write succeeded or not.
961
    #[test]
9621×
    fn a_listing_taken_during_a_write_is_forgotten_after_it() {
9631×
        let (m, l) = (super::Misses::default(), super::Listings::default());
9641×
        let e = |k: &str| oxy_core::strata::Entry {
9655×
            key: k.into(),
966
            size: 1,
9675×
            modified_ms: None,
9685×
        };
9691×
        l.put("pack-", &[e("pack-1.idx")]);
9701×
        let got = super::writing(&m, &l, "pack-2.idx", || {
9711×
            assert!(l.get("pack-").is_none(), "forgotten before it is sent");
972
            // Another thread lists, and misses, mid-request.
9731×
            l.put("pack-", &[e("pack-1.idx")]);
9741×
            m.missed("pack-2.idx");
9751×
            7
9761×
        });
9771×
        assert_eq!(got, 7);
9781×
        assert!(l.get("pack-").is_none(), "the mid-request listing is gone");
9791×
        assert!(!m.known("pack-2.idx"), "and the miss");
980
        // A delete that fails is treated the same.
9811×
        l.put("pack-", &[e("pack-1.idx")]);
9821×
        let r: std::result::Result<(), &str> = super::writing(&m, &l, "pack-1.idx", || {
9831×
            l.put("pack-", &[e("pack-1.idx")]);
9841×
            Err("network")
9851×
        });
9861×
        assert!(r.is_err() && l.get("pack-").is_none());
987
        // Other prefixes' writes leave the pack listing.
9881×
        l.put("pack-", &[e("pack-1.idx")]);
9891×
        super::writing(&m, &l, "loose/ab/cd", || ());
9901×
        assert!(l.get("pack-").is_some());
9911×
    }
992
993
    #[test]
9941×
    fn stats_name_every_kind_of_request() {
9951×
        let names: Vec<&str> = super::stats().ops().map(|(n, _)| n).collect();
9961×
        assert_eq!(names.len(), super::Op::ALL.len());
9971×
        let mut unique = names.clone();
9981×
        unique.sort();
9991×
        unique.dedup();
10001×
        assert_eq!(unique.len(), names.len());
10011×
        assert!(names.contains(&"has_missing") && names.contains(&"range"));
10021×
    }
1003
1004
    /// One client per store and credentials (oxygenhub#234): opening the
1005
    /// same again shares it, and only a new one counts as opened.
1006
    #[test]
10071×
    fn a_store_opened_again_shares_its_client() {
10081×
        let creds = |k: &str| Credentials::Aws {
10093×
            access_key_id: k.into(),
10103×
            secret_access_key: "s".into(),
10113×
            session_token: None,
10123×
        };
10131×
        let url = "s3://oxy-shared-test/repos/a?region=eu-west-2";
10141×
        let before = super::stats().opens;
10151×
        let a = super::shared(url, Some(creds("one"))).unwrap();
10161×
        let b = super::shared(url, Some(creds("one"))).unwrap();
10171×
        assert!(std::sync::Arc::ptr_eq(&a, &b));
10181×
        let other_keys = super::shared(url, Some(creds("two"))).unwrap();
10191×
        assert!(
10201×
            !std::sync::Arc::ptr_eq(&a, &other_keys),
1021
            "other credentials, another client"
1022
        );
10231×
        assert!(
10241×
            super::stats().opens - before >= 2,
1025
            "two clients opened (other tests may open more)"
1026
        );
10271×
    }
1028
1029
    #[tokio::test]
10301×
    async fn a_store_can_be_used_from_inside_a_runtime() {
1031
        // As a server's handler would: blocking on the store's own runtime
1032
        // from here must neither panic nor deadlock, and nor must dropping it.
10331×
        let store = CloudStore::open(
10341×
            "s3://oxy-no-such-bucket/p?region=eu-west-2&endpoint=http://127.0.0.1:9",
1035
        )
10361×
        .unwrap();
10371×
        assert_eq!(store.block(async { 7 }), 7);
10381×
        drop(store);
10391×
    }
1040
1041
    #[test]
10421×
    fn credentials_round_trip_and_never_show_their_secret() {
10431×
        let aws = Credentials::Aws {
10441×
            access_key_id: "AKIDEXAMPLE".into(),
10451×
            secret_access_key: "wJalrXUtnFEMI".into(),
10461×
            session_token: None,
10471×
        };
10481×
        assert_eq!(Credentials::from_json(&aws.to_json()).unwrap(), aws);
10491×
        assert_eq!(format!("{aws:?}"), "Aws(AKIDEXAMPLE, secret hidden)");
10501×
        let az = Credentials::Azure {
10511×
            tenant_id: "t".into(),
10521×
            client_id: "c".into(),
10531×
            client_secret: "s3cr3t".into(),
10541×
        };
10551×
        assert_eq!(Credentials::from_json(&az.to_json()).unwrap(), az);
10561×
        assert!(!format!("{az:?}").contains("s3cr3t"));
10571×
        let key = r#"{"type":"service_account","client_email":"x@y","private_key":"k"}"#;
10581×
        let gcp = Credentials::Gcp {
10591×
            service_account_key: key.into(),
10601×
        };
10611×
        assert_eq!(Credentials::from_json(&gcp.to_json()).unwrap(), gcp);
10621×
        assert!(!format!("{gcp:?}").contains("private_key"));
10634×
        for (bad, why) in [
10641×
            (r#"{"kind":"aws","access_key_id":"a"}"#, "secret_access_key"),
10651×
            (
10661×
                r#"{"kind":"gcp","service_account_key":"{\"type\":\"authorized_user\"}"}"#,
10671×
                "not a service account key",
10681×
            ),
10691×
            (r#"{"kind":"ftp"}"#, "aws, gcp or azure"),
10701×
            ("nope", "not JSON"),
10711×
        ] {
10724×
            let e = Credentials::from_json(bad).unwrap_err().to_string();
10734×
            assert!(e.contains(why), "{bad}: {e}");
1074
        }
10751×
        let e = CloudStore::open_with("gs://bucket/p", Some(&aws))
10761×
            .err()
10771×
            .unwrap()
10781×
            .to_string();
10791×
        assert!(e.contains("are for s3:// stores"), "{e}");
10801×
    }
1081
1082
    use super::*;
1083
1084
    #[test]
10851×
    fn urls_parse_with_their_parameters() {
10861×
        let u =
10871×
            StoreUrl::parse("s3://my-bucket/repos/oxygen?profile=work&region=eu-west-2").unwrap();
10881×
        assert_eq!(u.scheme, "s3");
10891×
        assert_eq!(u.host, "my-bucket");
10901×
        assert_eq!(u.path, "repos/oxygen");
10911×
        assert_eq!(u.param("profile"), Some("work"));
10921×
        assert_eq!(u.param("region"), Some("eu-west-2"));
10931×
        assert_eq!(u.plain(), "s3://my-bucket/repos/oxygen");
10941×
        let u = StoreUrl::parse("gs://bucket").unwrap();
10951×
        assert_eq!((u.host.as_str(), u.path.as_str()), ("bucket", ""));
10961×
        assert_eq!(u.plain(), "gs://bucket");
10971×
        let u = StoreUrl::parse("az://acct/container/a/b/?endpoint=http%3A%2F%2F127.0.0.1%3A10000")
10981×
            .unwrap();
10991×
        assert_eq!(u.path, "container/a/b");
11001×
        assert_eq!(u.param("endpoint"), Some("http://127.0.0.1:10000"));
11011×
        assert!(StoreUrl::parse("s3://").is_err());
11021×
        assert!(StoreUrl::parse("nonsense").is_err());
11031×
    }
1104
1105
    #[test]
11061×
    fn versions_carry_both_an_etag_and_a_generation() {
11071×
        let v = CloudStore::version_of(Some("\"abc\"".into()), Some("17".into()));
11081×
        let u = CloudStore::update_version(&v);
11091×
        assert_eq!(u.e_tag.as_deref(), Some("\"abc\""));
11101×
        assert_eq!(u.version.as_deref(), Some("17"));
11111×
        let u = CloudStore::update_version(&CloudStore::version_of(None, Some("3".into())));
11121×
        assert_eq!((u.e_tag, u.version.as_deref()), (None, Some("3")));
11131×
    }
1114
}