OXYGENHUB
# 767ac6cd0545 oxygen / crates / oxy-strata / src / lib.rs
39.1 KiB · Rust · last changed Readers never wait: one head per handle, retry on a miss, retire before deleting 17 days ago source blame history raw
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.
34
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.
37
    let _ = rustls::crypto::ring::default_provider().install_default();
38
    oxy_core::protocol::register_store_opener(open_url);
39
}
40
41
fn open_url(
42
    url: &str,
43
    _options: &oxy_core::protocol::RemoteOptions,
44
) -> Option<Result<Box<dyn Store>>> {
45
    let scheme = url.split_once("://")?.0;
46
    if !matches!(scheme, "s3" | "gs" | "az") {
47
        return None;
48
    }
49
    let lookup = *CREDENTIALS.lock().unwrap_or_else(|p| p.into_inner());
50
    let given = lookup.and_then(|f| f(url));
51
    Some(shared(url, given).map(|s| Box::new(Shared(s)) as Box<dyn Store>))
52
}
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
60
fn shared(url: &str, given: Option<Credentials>) -> Result<Arc<CloudStore>> {
61
    static OPEN: Mutex<Option<OpenStores>> = Mutex::new(None);
62
    let key = (url.to_string(), given);
63
    let mut open = OPEN.lock().unwrap_or_else(|p| p.into_inner());
64
    let map = open.get_or_insert_with(Default::default);
65
    if let Some(s) = map.get(&key) {
66
        return Ok(s.clone());
67
    }
68
    let store = Arc::new(CloudStore::open_with(url, key.1.as_ref())?);
69
    map.insert(key, store.clone());
70
    Ok(store)
71
}
72
73
/// A shared [`CloudStore`] as a [`Store`].
74
struct Shared(Arc<CloudStore>);
75
76
impl Store for Shared {
77
    fn describe(&self) -> String {
78
        self.0.describe()
79
    }
80
    fn has(&self, key: &str) -> Result<bool> {
81
        self.0.has(key)
82
    }
83
    fn get(&self, key: &str) -> Result<Vec<u8>> {
84
        self.0.get(key)
85
    }
86
    fn get_range(&self, key: &str, range: Range<u64>) -> Result<Vec<u8>> {
87
        self.0.get_range(key, range)
88
    }
89
    fn put_new(&self, key: &str, bytes: &[u8]) -> Result<()> {
90
        self.0.put_new(key, bytes)
91
    }
92
    fn list(&self, prefix: &str) -> Result<Vec<Entry>> {
93
        self.0.list(prefix)
94
    }
95
    fn stat(&self, key: &str) -> Result<Option<Entry>> {
96
        self.0.stat(key)
97
    }
98
    fn delete(&self, key: &str) -> Result<()> {
99
        self.0.delete(key)
100
    }
101
    fn read_ref(&self, key: &str) -> Result<Option<(Vec<u8>, Version)>> {
102
        self.0.read_ref(key)
103
    }
104
    fn write_ref(&self, key: &str, bytes: &[u8], expected: Option<&Version>) -> Result<Version> {
105
        self.0.write_ref(key, bytes, expected)
106
    }
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
    Delete,
128
    ReadRef,
129
    WriteRef,
130
}
131
132
impl Op {
133
    pub const ALL: [Op; 9] = [
134
        Op::Has,
135
        Op::HasMissing,
136
        Op::Get,
137
        Op::Range,
138
        Op::Put,
139
        Op::List,
140
        Op::Delete,
141
        Op::ReadRef,
142
        Op::WriteRef,
143
    ];
144
145
    pub fn name(self) -> &'static str {
146
        match self {
147
            Op::Has => "has",
148
            Op::HasMissing => "has_missing",
149
            Op::Get => "get",
150
            Op::Range => "range",
151
            Op::Put => "put",
152
            Op::List => "list",
153
            Op::Delete => "delete",
154
            Op::ReadRef => "read_ref",
155
            Op::WriteRef => "write_ref",
156
        }
157
    }
158
}
159
160
fn counted(op: Op) {
161
    BY_OP[op as usize].fetch_add(1, std::sync::atomic::Ordering::Relaxed);
162
}
163
164
/// What the cloud stores have done since the process started: clients
165
/// opened, requests made and the time spent in them, for a server to show
166
/// (a hub's /health).
167
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
168
pub struct Stats {
169
    pub opens: u64,
170
    pub calls: u64,
171
    pub millis: u64,
172
    /// Requests by kind, in [`Op::ALL`]'s order.
173
    pub by_op: [u64; Op::ALL.len()],
174
}
175
176
impl Stats {
177
    /// Each kind of request with how many were made.
178
    pub fn ops(&self) -> impl Iterator<Item = (&'static str, u64)> + '_ {
179
        Op::ALL
180
            .iter()
181
            .map(|op| (op.name(), self.by_op[*op as usize]))
182
    }
183
}
184
185
pub fn stats() -> Stats {
186
    use std::sync::atomic::Ordering::Relaxed;
187
    Stats {
188
        opens: OPENS.load(Relaxed),
189
        calls: CALLS.load(Relaxed),
190
        millis: MICROS.load(Relaxed) / 1000,
191
        by_op: std::array::from_fn(|i| BY_OP[i].load(Relaxed)),
192
    }
193
}
194
195
/// Credentials a program keeps itself and hands over, instead of the
196
/// provider's usual places: a hub keeps each repository's sealed. When a
197
/// store is opened with them, nothing from the environment or the
198
/// provider's files is used.
199
#[derive(Clone, PartialEq, Eq, Hash)]
200
pub enum Credentials {
201
    /// An S3 access key.
202
    Aws {
203
        access_key_id: String,
204
        secret_access_key: String,
205
        session_token: Option<String>,
206
    },
207
    /// A Google Cloud service account key, as the JSON Google issues.
208
    Gcp { service_account_key: String },
209
    /// An Azure service principal.
210
    Azure {
211
        tenant_id: String,
212
        client_id: String,
213
        client_secret: String,
214
    },
215
}
216
217
impl std::fmt::Debug for Credentials {
218
    /// Names the kind and the public part only: never the secret.
219
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220
        match self {
221
            Credentials::Aws { access_key_id, .. } => {
222
                write!(f, "Aws({access_key_id}, secret hidden)")
223
            }
224
            Credentials::Gcp { .. } => write!(f, "Gcp(service account key hidden)"),
225
            Credentials::Azure { client_id, .. } => write!(f, "Azure({client_id}, secret hidden)"),
226
        }
227
    }
228
}
229
230
impl Credentials {
231
    /// The scheme of the stores these are for.
232
    pub fn scheme(&self) -> &'static str {
233
        match self {
234
            Credentials::Aws { .. } => "s3",
235
            Credentials::Gcp { .. } => "gs",
236
            Credentials::Azure { .. } => "az",
237
        }
238
    }
239
240
    /// As JSON, for keeping (sealed) and reading back with [`Credentials::from_json`].
241
    pub fn to_json(&self) -> String {
242
        let v = match self {
243
            Credentials::Aws {
244
                access_key_id,
245
                secret_access_key,
246
                session_token,
247
            } => serde_json::json!({
248
                "kind": "aws", "access_key_id": access_key_id, "secret_access_key": secret_access_key,
249
                "session_token": session_token,
250
            }),
251
            Credentials::Gcp {
252
                service_account_key,
253
            } => {
254
                serde_json::json!({ "kind": "gcp", "service_account_key": service_account_key })
255
            }
256
            Credentials::Azure {
257
                tenant_id,
258
                client_id,
259
                client_secret,
260
            } => serde_json::json!({
261
                "kind": "azure", "tenant_id": tenant_id, "client_id": client_id, "client_secret": client_secret,
262
            }),
263
        };
264
        v.to_string()
265
    }
266
267
    pub fn from_json(text: &str) -> Result<Credentials> {
268
        let v: serde_json::Value = serde_json::from_str(text)
269
            .map_err(|e| Error::Backend(format!("credentials are not JSON: {e}")))?;
270
        let field = |k: &str| -> Result<String> {
271
            v.get(k)
272
                .and_then(|x| x.as_str())
273
                .filter(|x| !x.trim().is_empty())
274
                .map(|x| x.trim().to_string())
275
                .ok_or_else(|| Error::Backend(format!("credentials need {k}")))
276
        };
277
        match v.get("kind").and_then(|k| k.as_str()) {
278
            Some("aws") => Ok(Credentials::Aws {
279
                access_key_id: field("access_key_id")?,
280
                secret_access_key: field("secret_access_key")?,
281
                session_token: field("session_token").ok(),
282
            }),
283
            Some("gcp") => {
284
                let key = field("service_account_key")?;
285
                let parsed: serde_json::Value = serde_json::from_str(&key).map_err(|_| {
286
                    Error::Backend("the service account key is not the JSON Google issues".into())
287
                })?;
288
                if parsed.get("type").and_then(|t| t.as_str()) != Some("service_account") {
289
                    return Err(Error::Backend(
290
                        "the key is not a service account key (its type is not service_account)"
291
                            .into(),
292
                    ));
293
                }
294
                Ok(Credentials::Gcp {
295
                    service_account_key: key,
296
                })
297
            }
298
            Some("azure") => Ok(Credentials::Azure {
299
                tenant_id: field("tenant_id")?,
300
                client_id: field("client_id")?,
301
                client_secret: field("client_secret")?,
302
            }),
303
            _ => Err(Error::Backend(
304
                "credentials need a kind: aws, gcp or azure".into(),
305
            )),
306
        }
307
    }
308
}
309
310
/// Finds the credentials a program keeps for a store URL.
311
pub type CredentialsLookup = fn(&str) -> Option<Credentials>;
312
313
static CREDENTIALS: Mutex<Option<CredentialsLookup>> = Mutex::new(None);
314
315
/// Have stores opened by URL (as a repository's `storage.url` is) use the
316
/// credentials `lookup` finds for it, where it finds any.
317
pub fn set_credentials_lookup(lookup: CredentialsLookup) {
318
    *CREDENTIALS.lock().unwrap_or_else(|p| p.into_inner()) = Some(lookup);
319
}
320
321
/// A parsed store URL: `SCHEME://HOST/PATH?PARAMS`.
322
#[derive(Clone, Debug, PartialEq, Eq)]
323
pub struct StoreUrl {
324
    pub scheme: String,
325
    /// The bucket (S3, GCS) or the storage account (Azure).
326
    pub host: String,
327
    /// The path under the host, without leading or trailing slashes; for
328
    /// Azure the container is its first segment.
329
    pub path: String,
330
    pub params: BTreeMap<String, String>,
331
}
332
333
impl StoreUrl {
334
    pub fn parse(url: &str) -> Result<StoreUrl> {
335
        let bad = |why: &str| Error::Backend(format!("{url}: {why}"));
336
        let (scheme, rest) = url.split_once("://").ok_or_else(|| bad("not a URL"))?;
337
        let (rest, query) = match rest.split_once('?') {
338
            Some((r, q)) => (r, Some(q)),
339
            None => (rest, None),
340
        };
341
        let (host, path) = match rest.split_once('/') {
342
            Some((h, p)) => (h, p.trim_matches('/')),
343
            None => (rest, ""),
344
        };
345
        if host.is_empty() {
346
            return Err(bad("names no bucket"));
347
        }
348
        let mut params = BTreeMap::new();
349
        for pair in query.unwrap_or("").split('&').filter(|p| !p.is_empty()) {
350
            let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
351
            params.insert(k.to_string(), percent_decode(v));
352
        }
353
        Ok(StoreUrl {
354
            scheme: scheme.to_string(),
355
            host: host.to_string(),
356
            path: path.to_string(),
357
            params,
358
        })
359
    }
360
361
    /// The URL without its parameters, for messages.
362
    pub fn plain(&self) -> String {
363
        if self.path.is_empty() {
364
            format!("{}://{}", self.scheme, self.host)
365
        } else {
366
            format!("{}://{}/{}", self.scheme, self.host, self.path)
367
        }
368
    }
369
370
    pub fn param(&self, name: &str) -> Option<&str> {
371
        self.params.get(name).map(String::as_str)
372
    }
373
}
374
375
fn percent_decode(s: &str) -> String {
376
    let bytes = s.as_bytes();
377
    let mut out = Vec::with_capacity(bytes.len());
378
    let mut i = 0;
379
    while i < bytes.len() {
380
        if bytes[i] == b'%' && i + 2 < bytes.len() {
381
            if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
382
                out.push(v);
383
                i += 3;
384
                continue;
385
            }
386
        }
387
        out.push(bytes[i]);
388
        i += 1;
389
    }
390
    String::from_utf8_lossy(&out).into_owned()
391
}
392
393
/// A store on a cloud provider: the provider's client, the prefix every
394
/// key goes under, and a runtime to run the client on.
395
pub struct CloudStore {
396
    inner: Arc<dyn ObjectStore>,
397
    prefix: String,
398
    name: String,
399
    hint: &'static str,
400
    /// Always there until the store is dropped: two workers, so the
401
    /// requests of a shared store run side by side.
402
    runtime: Option<tokio::runtime::Runtime>,
403
    /// Keys `has` found missing lately (see [`Misses`]).
404
    misses: Misses,
405
    /// Pack listings, kept a moment (see [`Listings`]).
406
    listings: Listings,
407
}
408
409
/// How long a key found missing is taken as missing without asking again.
410
const MISS_FOR: std::time::Duration = std::time::Duration::from_secs(30);
411
412
/// Keys found missing in the last [`MISS_FOR`], so a server answering the
413
/// same question again and again does not send a request each time
414
/// (oxygenhub#228: a client's negotiate asked about ~190 objects the hub
415
/// lacked every 8 s, 45 ms a request). This process forgets a key the
416
/// moment it writes it, so its own writes are never hidden; another writer
417
/// to the same keys is seen within `MISS_FOR`.
418
#[derive(Default)]
419
struct Misses(Mutex<std::collections::HashMap<String, std::time::Instant>>);
420
421
impl Misses {
422
    /// At most this many kept; past it the oldest half is let go.
423
    const MAX: usize = 100_000;
424
425
    fn known(&self, key: &str) -> bool {
426
        let map = self.0.lock().unwrap_or_else(|p| p.into_inner());
427
        map.get(key).is_some_and(|t| t.elapsed() < MISS_FOR)
428
    }
429
430
    fn missed(&self, key: &str) {
431
        let mut map = self.0.lock().unwrap_or_else(|p| p.into_inner());
432
        if map.len() >= Self::MAX {
433
            map.retain(|_, t| t.elapsed() < MISS_FOR / 2);
434
            if map.len() >= Self::MAX {
435
                map.clear();
436
            }
437
        }
438
        map.insert(key.to_string(), std::time::Instant::now());
439
    }
440
441
    fn written(&self, key: &str) {
442
        self.0.lock().unwrap_or_else(|p| p.into_inner()).remove(key);
443
    }
444
}
445
446
/// Listings kept for [`MISS_FOR`] (oxygen#116): the `pack-` keys, which
447
/// every open of a repository's store lists, so a forge serving a
448
/// repository from a bucket lists them once a while rather than once a
449
/// request. Like [`Misses`], this process forgets a listing the moment it
450
/// writes or deletes a key under it; another writer's pack is seen within
451
/// `MISS_FOR`, as a missing key is.
452
#[derive(Default)]
453
struct Listings(Mutex<std::collections::HashMap<String, (std::time::Instant, Vec<Entry>)>>);
454
455
impl Listings {
456
    /// The prefixes kept: those every open lists.
457
    fn kept(prefix: &str) -> bool {
458
        prefix == "pack-"
459
    }
460
461
    fn get(&self, prefix: &str) -> Option<Vec<Entry>> {
462
        if !Self::kept(prefix) {
463
            return None;
464
        }
465
        let map = self.0.lock().unwrap_or_else(|p| p.into_inner());
466
        map.get(prefix)
467
            .filter(|(t, _)| t.elapsed() < MISS_FOR)
468
            .map(|(_, e)| e.clone())
469
    }
470
471
    fn put(&self, prefix: &str, entries: &[Entry]) {
472
        if Self::kept(prefix) {
473
            self.0.lock().unwrap_or_else(|p| p.into_inner()).insert(
474
                prefix.to_string(),
475
                (std::time::Instant::now(), entries.to_vec()),
476
            );
477
        }
478
    }
479
480
    fn written(&self, key: &str) {
481
        self.0
482
            .lock()
483
            .unwrap_or_else(|p| p.into_inner())
484
            .retain(|prefix, _| !key.starts_with(prefix.as_str()));
485
    }
486
}
487
488
/// Run a request that writes or deletes `key`, forgetting what the caches
489
/// hold for it both before it is sent and after it completes, whatever it
490
/// returns: a listing or a miss taken by another thread while the request
491
/// was in flight may predate it, and would otherwise be kept for
492
/// `MISS_FOR` (oxygenhub#249).
493
fn writing<T>(misses: &Misses, listings: &Listings, key: &str, request: impl FnOnce() -> T) -> T {
494
    misses.written(key);
495
    listings.written(key);
496
    let out = request();
497
    misses.written(key);
498
    listings.written(key);
499
    out
500
}
501
502
impl Drop for CloudStore {
503
    /// Without waiting, so a store can be dropped inside a runtime too.
504
    fn drop(&mut self) {
505
        if let Some(rt) = self.runtime.take() {
506
            rt.shutdown_background();
507
        }
508
    }
509
}
510
511
impl CloudStore {
512
    /// Open the store a URL names. Nothing is sent yet: credentials are
513
    /// resolved here, but the bucket is first spoken to on the first call.
514
    pub fn open(url: &str) -> Result<CloudStore> {
515
        Self::open_with(url, None)
516
    }
517
518
    /// [`CloudStore::open`] with credentials given, which must be for
519
    /// the URL's kind of store; then only they are used.
520
    pub fn open_with(url: &str, given: Option<&Credentials>) -> Result<CloudStore> {
521
        let parsed = StoreUrl::parse(url)?;
522
        if let Some(c) = given.filter(|c| c.scheme() != parsed.scheme) {
523
            return Err(Error::Backend(format!(
524
                "{}: the credentials given are for {}:// stores",
525
                parsed.plain(),
526
                c.scheme()
527
            )));
528
        }
529
        let aws_keys = match given {
530
            Some(Credentials::Aws {
531
                access_key_id,
532
                secret_access_key,
533
                session_token,
534
            }) => Some((
535
                access_key_id.as_str(),
536
                secret_access_key.as_str(),
537
                session_token.as_deref(),
538
            )),
539
            _ => None,
540
        };
541
        let gcp_key = match given {
542
            Some(Credentials::Gcp {
543
                service_account_key,
544
            }) => Some(service_account_key.as_str()),
545
            _ => None,
546
        };
547
        let principal = match given {
548
            Some(Credentials::Azure {
549
                tenant_id,
550
                client_id,
551
                client_secret,
552
            }) => Some((
553
                tenant_id.as_str(),
554
                client_id.as_str(),
555
                client_secret.as_str(),
556
            )),
557
            _ => None,
558
        };
559
        OPENS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
560
        let runtime = tokio::runtime::Builder::new_multi_thread()
561
            .worker_threads(2)
562
            .thread_name("oxy-strata")
563
            .enable_all()
564
            .build()
565
            .map_err(|e| Error::Backend(format!("starting the HTTP runtime: {e}")))?;
566
        let (inner, prefix, hint): (Arc<dyn ObjectStore>, String, &'static str) =
567
            match parsed.scheme.as_str() {
568
                "s3" => (
569
                    Arc::new(aws::open(&parsed, aws_keys)?),
570
                    parsed.path.clone(),
571
                    aws::HINT,
572
                ),
573
                "gs" => (
574
                    Arc::new(gcp::open(&parsed, gcp_key)?),
575
                    parsed.path.clone(),
576
                    gcp::HINT,
577
                ),
578
                "az" => {
579
                    let (store, prefix) = azure::open(&parsed, principal)?;
580
                    (Arc::new(store), prefix, azure::HINT)
581
                }
582
                other => {
583
                    return Err(Error::Backend(format!(
584
                        "{url}: {other}:// is not a store oxy knows (s3://, gs://, az://, dir://)"
585
                    )))
586
                }
587
            };
588
        Ok(CloudStore {
589
            inner,
590
            prefix,
591
            name: parsed.plain(),
592
            hint,
593
            runtime: Some(runtime),
594
            misses: Misses::default(),
595
            listings: Listings::default(),
596
        })
597
    }
598
599
    /// Run a request to completion. Called from a thread that is itself
600
    /// driving async tasks (a server's handler), where blocking on another
601
    /// runtime is not allowed, the request runs on a thread of its own.
602
    fn block<F>(&self, f: F) -> F::Output
603
    where
604
        F: Future + Send,
605
        F::Output: Send,
606
    {
607
        let runtime = self
608
            .runtime
609
            .as_ref()
610
            .expect("the runtime lives as long as the store");
611
        let started = std::time::Instant::now();
612
        let out = if tokio::runtime::Handle::try_current().is_ok() {
613
            std::thread::scope(|s| {
614
                s.spawn(|| runtime.block_on(f))
615
                    .join()
616
                    .unwrap_or_else(|p| std::panic::resume_unwind(p))
617
            })
618
        } else {
619
            runtime.block_on(f)
620
        };
621
        CALLS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
622
        MICROS.fetch_add(
623
            started.elapsed().as_micros() as u64,
624
            std::sync::atomic::Ordering::Relaxed,
625
        );
626
        out
627
    }
628
629
    fn path(&self, key: &str) -> Result<ObjectPath> {
630
        check_key(key)?;
631
        let full = if self.prefix.is_empty() {
632
            key.to_string()
633
        } else {
634
            format!("{}/{key}", self.prefix)
635
        };
636
        ObjectPath::parse(&full).map_err(|e| Error::Backend(format!("{full}: {e}")))
637
    }
638
639
    /// Our error for the provider's, with what to do next where it helps.
640
    fn map(&self, e: object_store::Error, key: &str) -> Error {
641
        use object_store::Error as E;
642
        match e {
643
            E::NotFound { .. } => Error::NotFound(key.to_string()),
644
            E::AlreadyExists { .. } => Error::Exists(key.to_string()),
645
            E::Precondition { .. } | E::NotModified { .. } => Error::Conflict(key.to_string()),
646
            other => {
647
                let text = other.to_string();
648
                let lower = text.to_ascii_lowercase();
649
                let advice = if lower.contains("403")
650
                    || lower.contains("accessdenied")
651
                    || lower.contains("forbidden")
652
                    || lower.contains("authorizationpermissionmismatch")
653
                {
654
                    format!(
655
                        ": access denied; the credentials in use may not do this on {}: {}",
656
                        self.name, self.hint
657
                    )
658
                } else if lower.contains("credential") || lower.contains("no valid credentials") {
659
                    format!(": no usable credentials: {}", self.hint)
660
                } else if lower.contains("nosuchbucket")
661
                    || lower.contains("bucketnotfound")
662
                    || lower.contains("containernotfound")
663
                {
664
                    format!(
665
                        ": {} does not exist, or the credentials cannot see it",
666
                        self.name
667
                    )
668
                } else if lower.contains("dns")
669
                    || lower.contains("connect")
670
                    || lower.contains("timed out")
671
                {
672
                    ": cannot reach the service; check the network, the endpoint and the region"
673
                        .to_string()
674
                } else {
675
                    String::new()
676
                };
677
                Error::Backend(format!("{}: {text}{advice}", self.name))
678
            }
679
        }
680
    }
681
682
    fn version_of(e_tag: Option<String>, version: Option<String>) -> Version {
683
        Version(format!(
684
            "{}\t{}",
685
            e_tag.unwrap_or_default(),
686
            version.unwrap_or_default()
687
        ))
688
    }
689
690
    fn update_version(v: &Version) -> UpdateVersion {
691
        let (e_tag, version) = v.0.split_once('\t').unwrap_or((v.0.as_str(), ""));
692
        UpdateVersion {
693
            e_tag: (!e_tag.is_empty()).then(|| e_tag.to_string()),
694
            version: (!version.is_empty()).then(|| version.to_string()),
695
        }
696
    }
697
}
698
699
impl Store for CloudStore {
700
    fn describe(&self) -> String {
701
        self.name.clone()
702
    }
703
704
    fn has(&self, key: &str) -> Result<bool> {
705
        let path = self.path(key)?;
706
        if self.misses.known(key) {
707
            return Ok(false);
708
        }
709
        match self.block(self.inner.head(&path)) {
710
            Ok(_) => {
711
                counted(Op::Has);
712
                Ok(true)
713
            }
714
            Err(object_store::Error::NotFound { .. }) => {
715
                counted(Op::HasMissing);
716
                self.misses.missed(key);
717
                Ok(false)
718
            }
719
            Err(e) => Err(self.map(e, key)),
720
        }
721
    }
722
723
    fn get(&self, key: &str) -> Result<Vec<u8>> {
724
        let path = self.path(key)?;
725
        counted(Op::Get);
726
        self.block(async {
727
            let r = self.inner.get(&path).await?;
728
            r.bytes().await
729
        })
730
        .map(|b| b.to_vec())
731
        .map_err(|e| self.map(e, key))
732
    }
733
734
    fn get_range(&self, key: &str, range: Range<u64>) -> Result<Vec<u8>> {
735
        let path = self.path(key)?;
736
        if range.start > range.end {
737
            return Err(Error::Backend(format!(
738
                "{key}: bytes {}..{} is not a range",
739
                range.start, range.end
740
            )));
741
        }
742
        if range.is_empty() {
743
            return Ok(Vec::new());
744
        }
745
        let want = (range.end - range.start) as usize;
746
        counted(Op::Range);
747
        let bytes = self
748
            .block(self.inner.get_range(&path, range.clone()))
749
            .map_err(|e| self.map(e, key))?;
750
        if bytes.len() != want {
751
            return Err(Error::Backend(format!(
752
                "{key}: bytes {}..{} asked, {} returned: the object is shorter",
753
                range.start,
754
                range.end,
755
                bytes.len()
756
            )));
757
        }
758
        Ok(bytes.to_vec())
759
    }
760
761
    fn put_new(&self, key: &str, bytes: &[u8]) -> Result<()> {
762
        let path = self.path(key)?;
763
        let opts = PutOptions {
764
            mode: PutMode::Create,
765
            ..Default::default()
766
        };
767
        counted(Op::Put);
768
        writing(&self.misses, &self.listings, key, || {
769
            self.block(
770
                self.inner
771
                    .put_opts(&path, PutPayload::from(bytes.to_vec()), opts),
772
            )
773
        })
774
        .map(|_| ())
775
        .map_err(|e| self.map(e, key))
776
    }
777
778
    fn list(&self, prefix: &str) -> Result<Vec<Entry>> {
779
        if let Some(kept) = self.listings.get(prefix) {
780
            return Ok(kept);
781
        }
782
        // Providers list by whole path segments; the asked prefix may end
783
        // mid-segment, so list its directory and filter here.
784
        let dir = prefix.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
785
        let full_dir = match (self.prefix.as_str(), dir) {
786
            ("", "") => None,
787
            ("", d) => Some(d.to_string()),
788
            (p, "") => Some(p.to_string()),
789
            (p, d) => Some(format!("{p}/{d}")),
790
        };
791
        let full_dir = full_dir
792
            .map(|d| ObjectPath::parse(&d).map_err(|e| Error::Backend(format!("{d}: {e}"))))
793
            .transpose()?;
794
        counted(Op::List);
795
        let metas: Vec<object_store::ObjectMeta> = self
796
            .block(self.inner.list(full_dir.as_ref()).try_collect())
797
            .map_err(|e| self.map(e, prefix))?;
798
        let strip = if self.prefix.is_empty() {
799
            String::new()
800
        } else {
801
            format!("{}/", self.prefix)
802
        };
803
        let mut out: Vec<Entry> = metas
804
            .into_iter()
805
            .filter_map(|m| {
806
                let loc = m.location.to_string();
807
                let key = loc.strip_prefix(&strip)?;
808
                key.starts_with(prefix).then(|| Entry {
809
                    key: key.to_string(),
810
                    size: m.size,
811
                    modified_ms: u64::try_from(m.last_modified.timestamp_millis()).ok(),
812
                })
813
            })
814
            .collect();
815
        out.sort_by(|a, b| a.key.cmp(&b.key));
816
        self.listings.put(prefix, &out);
817
        Ok(out)
818
    }
819
820
    /// A HEAD of the key, not a listing of its directory (oxygen#116).
821
    fn stat(&self, key: &str) -> Result<Option<Entry>> {
822
        let path = self.path(key)?;
823
        counted(Op::Has);
824
        match self.block(self.inner.head(&path)) {
825
            Ok(m) => Ok(Some(Entry {
826
                key: key.to_string(),
827
                size: m.size,
828
                modified_ms: u64::try_from(m.last_modified.timestamp_millis()).ok(),
829
            })),
830
            Err(object_store::Error::NotFound { .. }) => Ok(None),
831
            Err(e) => Err(self.map(e, key)),
832
        }
833
    }
834
835
    fn delete(&self, key: &str) -> Result<()> {
836
        let path = self.path(key)?;
837
        counted(Op::Delete);
838
        match writing(&self.misses, &self.listings, key, || {
839
            self.block(self.inner.delete(&path))
840
        }) {
841
            Ok(()) | Err(object_store::Error::NotFound { .. }) => Ok(()),
842
            Err(e) => Err(self.map(e, key)),
843
        }
844
    }
845
846
    fn read_ref(&self, key: &str) -> Result<Option<(Vec<u8>, Version)>> {
847
        let path = self.path(key)?;
848
        counted(Op::ReadRef);
849
        let got = self.block(async {
850
            let r = self.inner.get_opts(&path, GetOptions::default()).await?;
851
            let meta = r.meta.clone();
852
            let bytes = r.bytes().await?;
853
            Ok::<_, object_store::Error>((bytes, meta))
854
        });
855
        match got {
856
            Ok((bytes, meta)) => Ok(Some((
857
                bytes.to_vec(),
858
                Self::version_of(meta.e_tag, meta.version),
859
            ))),
860
            Err(object_store::Error::NotFound { .. }) => Ok(None),
861
            Err(e) => Err(self.map(e, key)),
862
        }
863
    }
864
865
    fn write_ref(&self, key: &str, bytes: &[u8], expected: Option<&Version>) -> Result<Version> {
866
        let path = self.path(key)?;
867
        let mode = match expected {
868
            None => PutMode::Create,
869
            Some(v) => PutMode::Update(Self::update_version(v)),
870
        };
871
        let opts = PutOptions {
872
            mode,
873
            ..Default::default()
874
        };
875
        counted(Op::WriteRef);
876
        let put = writing(&self.misses, &self.listings, key, || {
877
            self.block(
878
                self.inner
879
                    .put_opts(&path, PutPayload::from(bytes.to_vec()), opts),
880
            )
881
        })
882
        .map_err(|e| match self.map(e, key) {
883
            // A record that exists when none was expected is a lost
884
            // race too, not a misuse of put_new.
885
            Error::Exists(k) => Error::Conflict(k),
886
            other => other,
887
        })?;
888
        Ok(Self::version_of(put.e_tag, put.version))
889
    }
890
}
891
892
#[cfg(test)]
893
mod tests {
894
895
    /// A miss is remembered until the key is written, or for MISS_FOR.
896
    #[test]
897
    fn a_miss_is_remembered_until_written() {
898
        let m = super::Misses::default();
899
        assert!(!m.known("loose/ab/cd"));
900
        m.missed("loose/ab/cd");
901
        assert!(m.known("loose/ab/cd") && !m.known("loose/ab/ce"));
902
        m.written("loose/ab/cd");
903
        assert!(!m.known("loose/ab/cd"), "a write forgets the miss");
904
        m.missed("old");
905
        m.0.lock()
906
            .unwrap()
907
            .insert("old".into(), std::time::Instant::now() - super::MISS_FOR);
908
        assert!(!m.known("old"), "a miss expires");
909
    }
910
911
    /// Every kind of request is reported, once and by its own name.
912
    /// The pack listing is kept until a pack key is written or deleted
913
    /// here, or for MISS_FOR; other prefixes are never kept (oxygen#116).
914
    #[test]
915
    fn the_pack_listing_is_kept_until_a_pack_is_written() {
916
        let l = super::Listings::default();
917
        let e = |k: &str| oxy_core::strata::Entry {
918
            key: k.into(),
919
            size: 1,
920
            modified_ms: None,
921
        };
922
        l.put("pack-", &[e("pack-1.idx")]);
923
        assert_eq!(l.get("pack-").map(|v| v.len()), Some(1));
924
        l.put("loose/", &[e("loose/ab")]);
925
        assert!(l.get("loose/").is_none(), "only the pack listing is kept");
926
        l.written("loose/cd");
927
        assert!(l.get("pack-").is_some(), "a loose object leaves it");
928
        l.written("pack-2.pack");
929
        assert!(l.get("pack-").is_none(), "a new pack forgets it");
930
        l.put("pack-", &[e("pack-1.idx")]);
931
        l.0.lock().unwrap().get_mut("pack-").unwrap().0 -= super::MISS_FOR;
932
        assert!(l.get("pack-").is_none(), "and it ages out");
933
    }
934
935
    /// A listing or miss taken by another thread while a write is in
936
    /// flight is forgotten when the write completes, not kept stale for
937
    /// MISS_FOR (oxygenhub#249), whether the write succeeded or not.
938
    #[test]
939
    fn a_listing_taken_during_a_write_is_forgotten_after_it() {
940
        let (m, l) = (super::Misses::default(), super::Listings::default());
941
        let e = |k: &str| oxy_core::strata::Entry {
942
            key: k.into(),
943
            size: 1,
944
            modified_ms: None,
945
        };
946
        l.put("pack-", &[e("pack-1.idx")]);
947
        let got = super::writing(&m, &l, "pack-2.idx", || {
948
            assert!(l.get("pack-").is_none(), "forgotten before it is sent");
949
            // Another thread lists, and misses, mid-request.
950
            l.put("pack-", &[e("pack-1.idx")]);
951
            m.missed("pack-2.idx");
952
            7
953
        });
954
        assert_eq!(got, 7);
955
        assert!(l.get("pack-").is_none(), "the mid-request listing is gone");
956
        assert!(!m.known("pack-2.idx"), "and the miss");
957
        // A delete that fails is treated the same.
958
        l.put("pack-", &[e("pack-1.idx")]);
959
        let r: std::result::Result<(), &str> = super::writing(&m, &l, "pack-1.idx", || {
960
            l.put("pack-", &[e("pack-1.idx")]);
961
            Err("network")
962
        });
963
        assert!(r.is_err() && l.get("pack-").is_none());
964
        // Other prefixes' writes leave the pack listing.
965
        l.put("pack-", &[e("pack-1.idx")]);
966
        super::writing(&m, &l, "loose/ab/cd", || ());
967
        assert!(l.get("pack-").is_some());
968
    }
969
970
    #[test]
971
    fn stats_name_every_kind_of_request() {
972
        let names: Vec<&str> = super::stats().ops().map(|(n, _)| n).collect();
973
        assert_eq!(names.len(), super::Op::ALL.len());
974
        let mut unique = names.clone();
975
        unique.sort();
976
        unique.dedup();
977
        assert_eq!(unique.len(), names.len());
978
        assert!(names.contains(&"has_missing") && names.contains(&"range"));
979
    }
980
981
    /// One client per store and credentials (oxygenhub#234): opening the
982
    /// same again shares it, and only a new one counts as opened.
983
    #[test]
984
    fn a_store_opened_again_shares_its_client() {
985
        let creds = |k: &str| Credentials::Aws {
986
            access_key_id: k.into(),
987
            secret_access_key: "s".into(),
988
            session_token: None,
989
        };
990
        let url = "s3://oxy-shared-test/repos/a?region=eu-west-2";
991
        let before = super::stats().opens;
992
        let a = super::shared(url, Some(creds("one"))).unwrap();
993
        let b = super::shared(url, Some(creds("one"))).unwrap();
994
        assert!(std::sync::Arc::ptr_eq(&a, &b));
995
        let other_keys = super::shared(url, Some(creds("two"))).unwrap();
996
        assert!(
997
            !std::sync::Arc::ptr_eq(&a, &other_keys),
998
            "other credentials, another client"
999
        );
1000
        assert!(
1001
            super::stats().opens - before >= 2,
1002
            "two clients opened (other tests may open more)"
1003
        );
1004
    }
1005
1006
    #[tokio::test]
1007
    async fn a_store_can_be_used_from_inside_a_runtime() {
1008
        // As a server's handler would: blocking on the store's own runtime
1009
        // from here must neither panic nor deadlock, and nor must dropping it.
1010
        let store = CloudStore::open(
1011
            "s3://oxy-no-such-bucket/p?region=eu-west-2&endpoint=http://127.0.0.1:9",
1012
        )
1013
        .unwrap();
1014
        assert_eq!(store.block(async { 7 }), 7);
1015
        drop(store);
1016
    }
1017
1018
    #[test]
1019
    fn credentials_round_trip_and_never_show_their_secret() {
1020
        let aws = Credentials::Aws {
1021
            access_key_id: "AKIDEXAMPLE".into(),
1022
            secret_access_key: "wJalrXUtnFEMI".into(),
1023
            session_token: None,
1024
        };
1025
        assert_eq!(Credentials::from_json(&aws.to_json()).unwrap(), aws);
1026
        assert_eq!(format!("{aws:?}"), "Aws(AKIDEXAMPLE, secret hidden)");
1027
        let az = Credentials::Azure {
1028
            tenant_id: "t".into(),
1029
            client_id: "c".into(),
1030
            client_secret: "s3cr3t".into(),
1031
        };
1032
        assert_eq!(Credentials::from_json(&az.to_json()).unwrap(), az);
1033
        assert!(!format!("{az:?}").contains("s3cr3t"));
1034
        let key = r#"{"type":"service_account","client_email":"x@y","private_key":"k"}"#;
1035
        let gcp = Credentials::Gcp {
1036
            service_account_key: key.into(),
1037
        };
1038
        assert_eq!(Credentials::from_json(&gcp.to_json()).unwrap(), gcp);
1039
        assert!(!format!("{gcp:?}").contains("private_key"));
1040
        for (bad, why) in [
1041
            (r#"{"kind":"aws","access_key_id":"a"}"#, "secret_access_key"),
1042
            (
1043
                r#"{"kind":"gcp","service_account_key":"{\"type\":\"authorized_user\"}"}"#,
1044
                "not a service account key",
1045
            ),
1046
            (r#"{"kind":"ftp"}"#, "aws, gcp or azure"),
1047
            ("nope", "not JSON"),
1048
        ] {
1049
            let e = Credentials::from_json(bad).unwrap_err().to_string();
1050
            assert!(e.contains(why), "{bad}: {e}");
1051
        }
1052
        let e = CloudStore::open_with("gs://bucket/p", Some(&aws))
1053
            .err()
1054
            .unwrap()
1055
            .to_string();
1056
        assert!(e.contains("are for s3:// stores"), "{e}");
1057
    }
1058
1059
    use super::*;
1060
1061
    #[test]
1062
    fn urls_parse_with_their_parameters() {
1063
        let u =
1064
            StoreUrl::parse("s3://my-bucket/repos/oxygen?profile=work&region=eu-west-2").unwrap();
1065
        assert_eq!(u.scheme, "s3");
1066
        assert_eq!(u.host, "my-bucket");
1067
        assert_eq!(u.path, "repos/oxygen");
1068
        assert_eq!(u.param("profile"), Some("work"));
1069
        assert_eq!(u.param("region"), Some("eu-west-2"));
1070
        assert_eq!(u.plain(), "s3://my-bucket/repos/oxygen");
1071
        let u = StoreUrl::parse("gs://bucket").unwrap();
1072
        assert_eq!((u.host.as_str(), u.path.as_str()), ("bucket", ""));
1073
        assert_eq!(u.plain(), "gs://bucket");
1074
        let u = StoreUrl::parse("az://acct/container/a/b/?endpoint=http%3A%2F%2F127.0.0.1%3A10000")
1075
            .unwrap();
1076
        assert_eq!(u.path, "container/a/b");
1077
        assert_eq!(u.param("endpoint"), Some("http://127.0.0.1:10000"));
1078
        assert!(StoreUrl::parse("s3://").is_err());
1079
        assert!(StoreUrl::parse("nonsense").is_err());
1080
    }
1081
1082
    #[test]
1083
    fn versions_carry_both_an_etag_and_a_generation() {
1084
        let v = CloudStore::version_of(Some("\"abc\"".into()), Some("17".into()));
1085
        let u = CloudStore::update_version(&v);
1086
        assert_eq!(u.e_tag.as_deref(), Some("\"abc\""));
1087
        assert_eq!(u.version.as_deref(), Some("17"));
1088
        let u = CloudStore::update_version(&CloudStore::version_of(None, Some("3".into())));
1089
        assert_eq!((u.e_tag, u.version.as_deref()), (None, Some("3")));
1090
    }
1091
}