OXYGENHUB
# 7e0e8e5ad882 oxygen / crates / oxy-strata / src / lib.rs
29.5 KiB · Rust · last changed Opening a bucket store sends no request per pack (refs #116) 19 days ago source blame history raw
8d803563 Andy McCall ↶ 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;
2cd40ea1 Andy McCall ↶ 18
mod http;
8d803563 Andy McCall ↶ 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
    }
7026bd7d Andy McCall ↶ 49
    let lookup = *CREDENTIALS.lock().unwrap_or_else(|p| p.into_inner());
50
    let given = lookup.and_then(|f| f(url));
2ddd4716 Andy McCall ↶ 51
    Some(shared(url, given).map(|s| Box::new(Shared(s)) as Box<dyn Store>))
8d803563 Andy McCall ↶ 52
}
53
2ddd4716 Andy McCall ↶ 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
    }
7e0e8e5a Andy McCall ↶ 95
    fn stat(&self, key: &str) -> Result<Option<Entry>> {
96
        self.0.stat(key)
97
    }
2ddd4716 Andy McCall ↶ 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
113
/// What the cloud stores have done since the process started: clients
114
/// opened, requests made and the time spent in them, for a server to show
115
/// (a hub's /health).
116
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
117
pub struct Stats {
118
    pub opens: u64,
119
    pub calls: u64,
120
    pub millis: u64,
121
}
122
123
pub fn stats() -> Stats {
124
    use std::sync::atomic::Ordering::Relaxed;
125
    Stats {
126
        opens: OPENS.load(Relaxed),
127
        calls: CALLS.load(Relaxed),
128
        millis: MICROS.load(Relaxed) / 1000,
129
    }
130
}
131
7026bd7d Andy McCall ↶ 132
/// Credentials a program keeps itself and hands over, instead of the
133
/// provider's usual places: a hub keeps each repository's sealed. When a
134
/// store is opened with them, nothing from the environment or the
135
/// provider's files is used.
2ddd4716 Andy McCall ↶ 136
#[derive(Clone, PartialEq, Eq, Hash)]
7026bd7d Andy McCall ↶ 137
pub enum Credentials {
138
    /// An S3 access key.
139
    Aws {
140
        access_key_id: String,
141
        secret_access_key: String,
142
        session_token: Option<String>,
143
    },
144
    /// A Google Cloud service account key, as the JSON Google issues.
145
    Gcp { service_account_key: String },
146
    /// An Azure service principal.
147
    Azure {
148
        tenant_id: String,
149
        client_id: String,
150
        client_secret: String,
151
    },
152
}
153
154
impl std::fmt::Debug for Credentials {
155
    /// Names the kind and the public part only: never the secret.
156
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157
        match self {
158
            Credentials::Aws { access_key_id, .. } => {
159
                write!(f, "Aws({access_key_id}, secret hidden)")
160
            }
161
            Credentials::Gcp { .. } => write!(f, "Gcp(service account key hidden)"),
162
            Credentials::Azure { client_id, .. } => write!(f, "Azure({client_id}, secret hidden)"),
163
        }
164
    }
165
}
166
167
impl Credentials {
168
    /// The scheme of the stores these are for.
169
    pub fn scheme(&self) -> &'static str {
170
        match self {
171
            Credentials::Aws { .. } => "s3",
172
            Credentials::Gcp { .. } => "gs",
173
            Credentials::Azure { .. } => "az",
174
        }
175
    }
176
177
    /// As JSON, for keeping (sealed) and reading back with [`Credentials::from_json`].
178
    pub fn to_json(&self) -> String {
179
        let v = match self {
180
            Credentials::Aws {
181
                access_key_id,
182
                secret_access_key,
183
                session_token,
184
            } => serde_json::json!({
185
                "kind": "aws", "access_key_id": access_key_id, "secret_access_key": secret_access_key,
186
                "session_token": session_token,
187
            }),
188
            Credentials::Gcp {
189
                service_account_key,
190
            } => {
191
                serde_json::json!({ "kind": "gcp", "service_account_key": service_account_key })
192
            }
193
            Credentials::Azure {
194
                tenant_id,
195
                client_id,
196
                client_secret,
197
            } => serde_json::json!({
198
                "kind": "azure", "tenant_id": tenant_id, "client_id": client_id, "client_secret": client_secret,
199
            }),
200
        };
201
        v.to_string()
202
    }
203
204
    pub fn from_json(text: &str) -> Result<Credentials> {
205
        let v: serde_json::Value = serde_json::from_str(text)
206
            .map_err(|e| Error::Backend(format!("credentials are not JSON: {e}")))?;
207
        let field = |k: &str| -> Result<String> {
208
            v.get(k)
209
                .and_then(|x| x.as_str())
210
                .filter(|x| !x.trim().is_empty())
211
                .map(|x| x.trim().to_string())
212
                .ok_or_else(|| Error::Backend(format!("credentials need {k}")))
213
        };
214
        match v.get("kind").and_then(|k| k.as_str()) {
215
            Some("aws") => Ok(Credentials::Aws {
216
                access_key_id: field("access_key_id")?,
217
                secret_access_key: field("secret_access_key")?,
218
                session_token: field("session_token").ok(),
219
            }),
220
            Some("gcp") => {
221
                let key = field("service_account_key")?;
222
                let parsed: serde_json::Value = serde_json::from_str(&key).map_err(|_| {
223
                    Error::Backend("the service account key is not the JSON Google issues".into())
224
                })?;
225
                if parsed.get("type").and_then(|t| t.as_str()) != Some("service_account") {
226
                    return Err(Error::Backend(
227
                        "the key is not a service account key (its type is not service_account)"
228
                            .into(),
229
                    ));
230
                }
231
                Ok(Credentials::Gcp {
232
                    service_account_key: key,
233
                })
234
            }
235
            Some("azure") => Ok(Credentials::Azure {
236
                tenant_id: field("tenant_id")?,
237
                client_id: field("client_id")?,
238
                client_secret: field("client_secret")?,
239
            }),
240
            _ => Err(Error::Backend(
241
                "credentials need a kind: aws, gcp or azure".into(),
242
            )),
243
        }
244
    }
245
}
246
247
/// Finds the credentials a program keeps for a store URL.
248
pub type CredentialsLookup = fn(&str) -> Option<Credentials>;
249
250
static CREDENTIALS: Mutex<Option<CredentialsLookup>> = Mutex::new(None);
251
252
/// Have stores opened by URL (as a repository's `storage.url` is) use the
253
/// credentials `lookup` finds for it, where it finds any.
254
pub fn set_credentials_lookup(lookup: CredentialsLookup) {
255
    *CREDENTIALS.lock().unwrap_or_else(|p| p.into_inner()) = Some(lookup);
256
}
257
8d803563 Andy McCall ↶ 258
/// A parsed store URL: `SCHEME://HOST/PATH?PARAMS`.
259
#[derive(Clone, Debug, PartialEq, Eq)]
260
pub struct StoreUrl {
261
    pub scheme: String,
262
    /// The bucket (S3, GCS) or the storage account (Azure).
263
    pub host: String,
264
    /// The path under the host, without leading or trailing slashes; for
265
    /// Azure the container is its first segment.
266
    pub path: String,
267
    pub params: BTreeMap<String, String>,
268
}
269
270
impl StoreUrl {
271
    pub fn parse(url: &str) -> Result<StoreUrl> {
272
        let bad = |why: &str| Error::Backend(format!("{url}: {why}"));
273
        let (scheme, rest) = url.split_once("://").ok_or_else(|| bad("not a URL"))?;
274
        let (rest, query) = match rest.split_once('?') {
275
            Some((r, q)) => (r, Some(q)),
276
            None => (rest, None),
277
        };
278
        let (host, path) = match rest.split_once('/') {
279
            Some((h, p)) => (h, p.trim_matches('/')),
280
            None => (rest, ""),
281
        };
282
        if host.is_empty() {
283
            return Err(bad("names no bucket"));
284
        }
285
        let mut params = BTreeMap::new();
286
        for pair in query.unwrap_or("").split('&').filter(|p| !p.is_empty()) {
287
            let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
288
            params.insert(k.to_string(), percent_decode(v));
289
        }
290
        Ok(StoreUrl {
291
            scheme: scheme.to_string(),
292
            host: host.to_string(),
293
            path: path.to_string(),
294
            params,
295
        })
296
    }
297
298
    /// The URL without its parameters, for messages.
299
    pub fn plain(&self) -> String {
300
        if self.path.is_empty() {
301
            format!("{}://{}", self.scheme, self.host)
302
        } else {
303
            format!("{}://{}/{}", self.scheme, self.host, self.path)
304
        }
305
    }
306
307
    pub fn param(&self, name: &str) -> Option<&str> {
308
        self.params.get(name).map(String::as_str)
309
    }
310
}
311
312
fn percent_decode(s: &str) -> String {
313
    let bytes = s.as_bytes();
314
    let mut out = Vec::with_capacity(bytes.len());
315
    let mut i = 0;
316
    while i < bytes.len() {
317
        if bytes[i] == b'%' && i + 2 < bytes.len() {
318
            if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
319
                out.push(v);
320
                i += 3;
321
                continue;
322
            }
323
        }
324
        out.push(bytes[i]);
325
        i += 1;
326
    }
327
    String::from_utf8_lossy(&out).into_owned()
328
}
329
330
/// A store on a cloud provider: the provider's client, the prefix every
331
/// key goes under, and a runtime to run the client on.
332
pub struct CloudStore {
333
    inner: Arc<dyn ObjectStore>,
334
    prefix: String,
335
    name: String,
336
    hint: &'static str,
2ddd4716 Andy McCall ↶ 337
    /// Always there until the store is dropped: two workers, so the
338
    /// requests of a shared store run side by side.
c9369ad8 Andy McCall ↶ 339
    runtime: Option<tokio::runtime::Runtime>,
8d803563 Andy McCall ↶ 340
}
341
c9369ad8 Andy McCall ↶ 342
impl Drop for CloudStore {
343
    /// Without waiting, so a store can be dropped inside a runtime too.
344
    fn drop(&mut self) {
345
        if let Some(rt) = self.runtime.take() {
346
            rt.shutdown_background();
347
        }
348
    }
349
}
350
8d803563 Andy McCall ↶ 351
impl CloudStore {
352
    /// Open the store a URL names. Nothing is sent yet: credentials are
353
    /// resolved here, but the bucket is first spoken to on the first call.
354
    pub fn open(url: &str) -> Result<CloudStore> {
7026bd7d Andy McCall ↶ 355
        Self::open_with(url, None)
356
    }
357
358
    /// [`CloudStore::open`] with credentials given, which must be for
359
    /// the URL's kind of store; then only they are used.
360
    pub fn open_with(url: &str, given: Option<&Credentials>) -> Result<CloudStore> {
8d803563 Andy McCall ↶ 361
        let parsed = StoreUrl::parse(url)?;
7026bd7d Andy McCall ↶ 362
        if let Some(c) = given.filter(|c| c.scheme() != parsed.scheme) {
363
            return Err(Error::Backend(format!(
364
                "{}: the credentials given are for {}:// stores",
365
                parsed.plain(),
366
                c.scheme()
367
            )));
368
        }
369
        let aws_keys = match given {
370
            Some(Credentials::Aws {
371
                access_key_id,
372
                secret_access_key,
373
                session_token,
374
            }) => Some((
375
                access_key_id.as_str(),
376
                secret_access_key.as_str(),
377
                session_token.as_deref(),
378
            )),
379
            _ => None,
380
        };
381
        let gcp_key = match given {
382
            Some(Credentials::Gcp {
383
                service_account_key,
384
            }) => Some(service_account_key.as_str()),
385
            _ => None,
386
        };
387
        let principal = match given {
388
            Some(Credentials::Azure {
389
                tenant_id,
390
                client_id,
391
                client_secret,
392
            }) => Some((
393
                tenant_id.as_str(),
394
                client_id.as_str(),
395
                client_secret.as_str(),
396
            )),
397
            _ => None,
398
        };
2ddd4716 Andy McCall ↶ 399
        OPENS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
400
        let runtime = tokio::runtime::Builder::new_multi_thread()
401
            .worker_threads(2)
402
            .thread_name("oxy-strata")
8d803563 Andy McCall ↶ 403
            .enable_all()
404
            .build()
405
            .map_err(|e| Error::Backend(format!("starting the HTTP runtime: {e}")))?;
406
        let (inner, prefix, hint): (Arc<dyn ObjectStore>, String, &'static str) =
407
            match parsed.scheme.as_str() {
408
                "s3" => (
7026bd7d Andy McCall ↶ 409
                    Arc::new(aws::open(&parsed, aws_keys)?),
8d803563 Andy McCall ↶ 410
                    parsed.path.clone(),
411
                    aws::HINT,
412
                ),
413
                "gs" => (
7026bd7d Andy McCall ↶ 414
                    Arc::new(gcp::open(&parsed, gcp_key)?),
8d803563 Andy McCall ↶ 415
                    parsed.path.clone(),
416
                    gcp::HINT,
417
                ),
418
                "az" => {
7026bd7d Andy McCall ↶ 419
                    let (store, prefix) = azure::open(&parsed, principal)?;
8d803563 Andy McCall ↶ 420
                    (Arc::new(store), prefix, azure::HINT)
421
                }
422
                other => {
423
                    return Err(Error::Backend(format!(
424
                        "{url}: {other}:// is not a store oxy knows (s3://, gs://, az://, dir://)"
425
                    )))
426
                }
427
            };
428
        Ok(CloudStore {
429
            inner,
430
            prefix,
431
            name: parsed.plain(),
432
            hint,
c9369ad8 Andy McCall ↶ 433
            runtime: Some(runtime),
8d803563 Andy McCall ↶ 434
        })
435
    }
436
c9369ad8 Andy McCall ↶ 437
    /// Run a request to completion. Called from a thread that is itself
438
    /// driving async tasks (a server's handler), where blocking on another
439
    /// runtime is not allowed, the request runs on a thread of its own.
440
    fn block<F>(&self, f: F) -> F::Output
441
    where
442
        F: Future + Send,
443
        F::Output: Send,
444
    {
445
        let runtime = self
446
            .runtime
447
            .as_ref()
448
            .expect("the runtime lives as long as the store");
2ddd4716 Andy McCall ↶ 449
        let started = std::time::Instant::now();
450
        let out = if tokio::runtime::Handle::try_current().is_ok() {
451
            std::thread::scope(|s| {
c9369ad8 Andy McCall ↶ 452
                s.spawn(|| runtime.block_on(f))
453
                    .join()
454
                    .unwrap_or_else(|p| std::panic::resume_unwind(p))
2ddd4716 Andy McCall ↶ 455
            })
456
        } else {
457
            runtime.block_on(f)
458
        };
459
        CALLS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
460
        MICROS.fetch_add(
461
            started.elapsed().as_micros() as u64,
462
            std::sync::atomic::Ordering::Relaxed,
463
        );
464
        out
8d803563 Andy McCall ↶ 465
    }
466
467
    fn path(&self, key: &str) -> Result<ObjectPath> {
468
        check_key(key)?;
469
        let full = if self.prefix.is_empty() {
470
            key.to_string()
471
        } else {
472
            format!("{}/{key}", self.prefix)
473
        };
474
        ObjectPath::parse(&full).map_err(|e| Error::Backend(format!("{full}: {e}")))
475
    }
476
477
    /// Our error for the provider's, with what to do next where it helps.
478
    fn map(&self, e: object_store::Error, key: &str) -> Error {
479
        use object_store::Error as E;
480
        match e {
481
            E::NotFound { .. } => Error::NotFound(key.to_string()),
482
            E::AlreadyExists { .. } => Error::Exists(key.to_string()),
483
            E::Precondition { .. } | E::NotModified { .. } => Error::Conflict(key.to_string()),
484
            other => {
485
                let text = other.to_string();
486
                let lower = text.to_ascii_lowercase();
487
                let advice = if lower.contains("403")
488
                    || lower.contains("accessdenied")
489
                    || lower.contains("forbidden")
490
                    || lower.contains("authorizationpermissionmismatch")
491
                {
492
                    format!(
493
                        ": access denied; the credentials in use may not do this on {}: {}",
494
                        self.name, self.hint
495
                    )
496
                } else if lower.contains("credential") || lower.contains("no valid credentials") {
497
                    format!(": no usable credentials: {}", self.hint)
498
                } else if lower.contains("nosuchbucket")
499
                    || lower.contains("bucketnotfound")
500
                    || lower.contains("containernotfound")
501
                {
502
                    format!(
503
                        ": {} does not exist, or the credentials cannot see it",
504
                        self.name
505
                    )
506
                } else if lower.contains("dns")
507
                    || lower.contains("connect")
508
                    || lower.contains("timed out")
509
                {
510
                    ": cannot reach the service; check the network, the endpoint and the region"
511
                        .to_string()
512
                } else {
513
                    String::new()
514
                };
515
                Error::Backend(format!("{}: {text}{advice}", self.name))
516
            }
517
        }
518
    }
519
520
    fn version_of(e_tag: Option<String>, version: Option<String>) -> Version {
521
        Version(format!(
522
            "{}\t{}",
523
            e_tag.unwrap_or_default(),
524
            version.unwrap_or_default()
525
        ))
526
    }
527
528
    fn update_version(v: &Version) -> UpdateVersion {
529
        let (e_tag, version) = v.0.split_once('\t').unwrap_or((v.0.as_str(), ""));
530
        UpdateVersion {
531
            e_tag: (!e_tag.is_empty()).then(|| e_tag.to_string()),
532
            version: (!version.is_empty()).then(|| version.to_string()),
533
        }
534
    }
535
}
536
537
impl Store for CloudStore {
538
    fn describe(&self) -> String {
539
        self.name.clone()
540
    }
541
542
    fn has(&self, key: &str) -> Result<bool> {
543
        let path = self.path(key)?;
544
        match self.block(self.inner.head(&path)) {
545
            Ok(_) => Ok(true),
546
            Err(object_store::Error::NotFound { .. }) => Ok(false),
547
            Err(e) => Err(self.map(e, key)),
548
        }
549
    }
550
551
    fn get(&self, key: &str) -> Result<Vec<u8>> {
552
        let path = self.path(key)?;
553
        self.block(async {
554
            let r = self.inner.get(&path).await?;
555
            r.bytes().await
556
        })
557
        .map(|b| b.to_vec())
558
        .map_err(|e| self.map(e, key))
559
    }
560
561
    fn get_range(&self, key: &str, range: Range<u64>) -> Result<Vec<u8>> {
562
        let path = self.path(key)?;
563
        if range.start > range.end {
564
            return Err(Error::Backend(format!(
565
                "{key}: bytes {}..{} is not a range",
566
                range.start, range.end
567
            )));
568
        }
569
        if range.is_empty() {
570
            return Ok(Vec::new());
571
        }
572
        let want = (range.end - range.start) as usize;
573
        let bytes = self
574
            .block(self.inner.get_range(&path, range.clone()))
575
            .map_err(|e| self.map(e, key))?;
576
        if bytes.len() != want {
577
            return Err(Error::Backend(format!(
578
                "{key}: bytes {}..{} asked, {} returned: the object is shorter",
579
                range.start,
580
                range.end,
581
                bytes.len()
582
            )));
583
        }
584
        Ok(bytes.to_vec())
585
    }
586
587
    fn put_new(&self, key: &str, bytes: &[u8]) -> Result<()> {
588
        let path = self.path(key)?;
589
        let opts = PutOptions {
590
            mode: PutMode::Create,
591
            ..Default::default()
592
        };
593
        self.block(
594
            self.inner
595
                .put_opts(&path, PutPayload::from(bytes.to_vec()), opts),
596
        )
597
        .map(|_| ())
598
        .map_err(|e| self.map(e, key))
599
    }
600
601
    fn list(&self, prefix: &str) -> Result<Vec<Entry>> {
602
        // Providers list by whole path segments; the asked prefix may end
603
        // mid-segment, so list its directory and filter here.
604
        let dir = prefix.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
605
        let full_dir = match (self.prefix.as_str(), dir) {
606
            ("", "") => None,
607
            ("", d) => Some(d.to_string()),
608
            (p, "") => Some(p.to_string()),
609
            (p, d) => Some(format!("{p}/{d}")),
610
        };
611
        let full_dir = full_dir
612
            .map(|d| ObjectPath::parse(&d).map_err(|e| Error::Backend(format!("{d}: {e}"))))
613
            .transpose()?;
614
        let metas: Vec<object_store::ObjectMeta> = self
615
            .block(self.inner.list(full_dir.as_ref()).try_collect())
616
            .map_err(|e| self.map(e, prefix))?;
617
        let strip = if self.prefix.is_empty() {
618
            String::new()
619
        } else {
620
            format!("{}/", self.prefix)
621
        };
622
        let mut out: Vec<Entry> = metas
623
            .into_iter()
624
            .filter_map(|m| {
625
                let loc = m.location.to_string();
626
                let key = loc.strip_prefix(&strip)?;
627
                key.starts_with(prefix).then(|| Entry {
628
                    key: key.to_string(),
629
                    size: m.size,
64818938 Andy McCall ↶ 630
                    modified_ms: u64::try_from(m.last_modified.timestamp_millis()).ok(),
8d803563 Andy McCall ↶ 631
                })
632
            })
633
            .collect();
634
        out.sort_by(|a, b| a.key.cmp(&b.key));
635
        Ok(out)
7e0e8e5a Andy McCall ↶ 636
    }
637
638
    /// A HEAD of the key, not a listing of its directory (oxygen#116).
639
    fn stat(&self, key: &str) -> Result<Option<Entry>> {
640
        let path = self.path(key)?;
641
        counted(Op::Has);
642
        match self.block(self.inner.head(&path)) {
643
            Ok(m) => Ok(Some(Entry {
644
                key: key.to_string(),
645
                size: m.size,
646
                modified_ms: u64::try_from(m.last_modified.timestamp_millis()).ok(),
647
            })),
648
            Err(object_store::Error::NotFound { .. }) => Ok(None),
649
            Err(e) => Err(self.map(e, key)),
650
        }
8d803563 Andy McCall ↶ 651
    }
652
653
    fn delete(&self, key: &str) -> Result<()> {
654
        let path = self.path(key)?;
655
        match self.block(self.inner.delete(&path)) {
656
            Ok(()) | Err(object_store::Error::NotFound { .. }) => Ok(()),
657
            Err(e) => Err(self.map(e, key)),
658
        }
659
    }
660
661
    fn read_ref(&self, key: &str) -> Result<Option<(Vec<u8>, Version)>> {
662
        let path = self.path(key)?;
663
        let got = self.block(async {
664
            let r = self.inner.get_opts(&path, GetOptions::default()).await?;
665
            let meta = r.meta.clone();
666
            let bytes = r.bytes().await?;
667
            Ok::<_, object_store::Error>((bytes, meta))
668
        });
669
        match got {
670
            Ok((bytes, meta)) => Ok(Some((
671
                bytes.to_vec(),
672
                Self::version_of(meta.e_tag, meta.version),
673
            ))),
674
            Err(object_store::Error::NotFound { .. }) => Ok(None),
675
            Err(e) => Err(self.map(e, key)),
676
        }
677
    }
678
679
    fn write_ref(&self, key: &str, bytes: &[u8], expected: Option<&Version>) -> Result<Version> {
680
        let path = self.path(key)?;
681
        let mode = match expected {
682
            None => PutMode::Create,
683
            Some(v) => PutMode::Update(Self::update_version(v)),
684
        };
685
        let opts = PutOptions {
686
            mode,
687
            ..Default::default()
688
        };
689
        let put = self
690
            .block(
691
                self.inner
692
                    .put_opts(&path, PutPayload::from(bytes.to_vec()), opts),
693
            )
694
            .map_err(|e| match self.map(e, key) {
695
                // A record that exists when none was expected is a lost
696
                // race too, not a misuse of put_new.
697
                Error::Exists(k) => Error::Conflict(k),
698
                other => other,
699
            })?;
700
        Ok(Self::version_of(put.e_tag, put.version))
701
    }
702
}
703
704
#[cfg(test)]
705
mod tests {
2ddd4716 Andy McCall ↶ 706
707
    /// One client per store and credentials (oxygenhub#234): opening the
708
    /// same again shares it, and only a new one counts as opened.
709
    #[test]
710
    fn a_store_opened_again_shares_its_client() {
711
        let creds = |k: &str| Credentials::Aws {
712
            access_key_id: k.into(),
713
            secret_access_key: "s".into(),
714
            session_token: None,
715
        };
716
        let url = "s3://oxy-shared-test/repos/a?region=eu-west-2";
717
        let before = super::stats().opens;
718
        let a = super::shared(url, Some(creds("one"))).unwrap();
719
        let b = super::shared(url, Some(creds("one"))).unwrap();
720
        assert!(std::sync::Arc::ptr_eq(&a, &b));
721
        let other_keys = super::shared(url, Some(creds("two"))).unwrap();
722
        assert!(
723
            !std::sync::Arc::ptr_eq(&a, &other_keys),
724
            "other credentials, another client"
725
        );
726
        assert!(
727
            super::stats().opens - before >= 2,
728
            "two clients opened (other tests may open more)"
729
        );
730
    }
731
c9369ad8 Andy McCall ↶ 732
    #[tokio::test]
733
    async fn a_store_can_be_used_from_inside_a_runtime() {
734
        // As a server's handler would: blocking on the store's own runtime
735
        // from here must neither panic nor deadlock, and nor must dropping it.
736
        let store = CloudStore::open(
737
            "s3://oxy-no-such-bucket/p?region=eu-west-2&endpoint=http://127.0.0.1:9",
738
        )
739
        .unwrap();
740
        assert_eq!(store.block(async { 7 }), 7);
741
        drop(store);
742
    }
743
7026bd7d Andy McCall ↶ 744
    #[test]
745
    fn credentials_round_trip_and_never_show_their_secret() {
746
        let aws = Credentials::Aws {
747
            access_key_id: "AKIDEXAMPLE".into(),
748
            secret_access_key: "wJalrXUtnFEMI".into(),
749
            session_token: None,
750
        };
751
        assert_eq!(Credentials::from_json(&aws.to_json()).unwrap(), aws);
752
        assert_eq!(format!("{aws:?}"), "Aws(AKIDEXAMPLE, secret hidden)");
753
        let az = Credentials::Azure {
754
            tenant_id: "t".into(),
755
            client_id: "c".into(),
756
            client_secret: "s3cr3t".into(),
757
        };
758
        assert_eq!(Credentials::from_json(&az.to_json()).unwrap(), az);
759
        assert!(!format!("{az:?}").contains("s3cr3t"));
760
        let key = r#"{"type":"service_account","client_email":"x@y","private_key":"k"}"#;
761
        let gcp = Credentials::Gcp {
762
            service_account_key: key.into(),
763
        };
764
        assert_eq!(Credentials::from_json(&gcp.to_json()).unwrap(), gcp);
765
        assert!(!format!("{gcp:?}").contains("private_key"));
766
        for (bad, why) in [
767
            (r#"{"kind":"aws","access_key_id":"a"}"#, "secret_access_key"),
768
            (
769
                r#"{"kind":"gcp","service_account_key":"{\"type\":\"authorized_user\"}"}"#,
770
                "not a service account key",
771
            ),
772
            (r#"{"kind":"ftp"}"#, "aws, gcp or azure"),
773
            ("nope", "not JSON"),
774
        ] {
775
            let e = Credentials::from_json(bad).unwrap_err().to_string();
776
            assert!(e.contains(why), "{bad}: {e}");
777
        }
778
        let e = CloudStore::open_with("gs://bucket/p", Some(&aws))
779
            .err()
780
            .unwrap()
781
            .to_string();
782
        assert!(e.contains("are for s3:// stores"), "{e}");
783
    }
784
8d803563 Andy McCall ↶ 785
    use super::*;
786
787
    #[test]
788
    fn urls_parse_with_their_parameters() {
789
        let u =
790
            StoreUrl::parse("s3://my-bucket/repos/oxygen?profile=work&region=eu-west-2").unwrap();
791
        assert_eq!(u.scheme, "s3");
792
        assert_eq!(u.host, "my-bucket");
793
        assert_eq!(u.path, "repos/oxygen");
794
        assert_eq!(u.param("profile"), Some("work"));
795
        assert_eq!(u.param("region"), Some("eu-west-2"));
796
        assert_eq!(u.plain(), "s3://my-bucket/repos/oxygen");
797
        let u = StoreUrl::parse("gs://bucket").unwrap();
798
        assert_eq!((u.host.as_str(), u.path.as_str()), ("bucket", ""));
799
        assert_eq!(u.plain(), "gs://bucket");
800
        let u = StoreUrl::parse("az://acct/container/a/b/?endpoint=http%3A%2F%2F127.0.0.1%3A10000")
801
            .unwrap();
802
        assert_eq!(u.path, "container/a/b");
803
        assert_eq!(u.param("endpoint"), Some("http://127.0.0.1:10000"));
804
        assert!(StoreUrl::parse("s3://").is_err());
805
        assert!(StoreUrl::parse("nonsense").is_err());
806
    }
807
808
    #[test]
809
    fn versions_carry_both_an_etag_and_a_generation() {
810
        let v = CloudStore::version_of(Some("\"abc\"".into()), Some("17".into()));
811
        let u = CloudStore::update_version(&v);
812
        assert_eq!(u.e_tag.as_deref(), Some("\"abc\""));
813
        assert_eq!(u.version.as_deref(), Some("17"));
814
        let u = CloudStore::update_version(&CloudStore::version_of(None, Some("3".into())));
815
        assert_eq!((u.e_tag, u.version.as_deref()), (None, Some("3")));
816
    }
817
}