OXYGENHUB
◆ v0.4.0 oxygen / crates / oxy-strata / src / lib.rs
15.6 KiB · Rust · last changed The object store runs on any Store: a directory today, a bucket next 1 month 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
    Some(CloudStore::open(url).map(|s| Box::new(s) as Box<dyn Store>))
50
}
51
52
/// A parsed store URL: `SCHEME://HOST/PATH?PARAMS`.
53
#[derive(Clone, Debug, PartialEq, Eq)]
54
pub struct StoreUrl {
55
    pub scheme: String,
56
    /// The bucket (S3, GCS) or the storage account (Azure).
57
    pub host: String,
58
    /// The path under the host, without leading or trailing slashes; for
59
    /// Azure the container is its first segment.
60
    pub path: String,
61
    pub params: BTreeMap<String, String>,
62
}
63
64
impl StoreUrl {
65
    pub fn parse(url: &str) -> Result<StoreUrl> {
66
        let bad = |why: &str| Error::Backend(format!("{url}: {why}"));
67
        let (scheme, rest) = url.split_once("://").ok_or_else(|| bad("not a URL"))?;
68
        let (rest, query) = match rest.split_once('?') {
69
            Some((r, q)) => (r, Some(q)),
70
            None => (rest, None),
71
        };
72
        let (host, path) = match rest.split_once('/') {
73
            Some((h, p)) => (h, p.trim_matches('/')),
74
            None => (rest, ""),
75
        };
76
        if host.is_empty() {
77
            return Err(bad("names no bucket"));
78
        }
79
        let mut params = BTreeMap::new();
80
        for pair in query.unwrap_or("").split('&').filter(|p| !p.is_empty()) {
81
            let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
82
            params.insert(k.to_string(), percent_decode(v));
83
        }
84
        Ok(StoreUrl {
85
            scheme: scheme.to_string(),
86
            host: host.to_string(),
87
            path: path.to_string(),
88
            params,
89
        })
90
    }
91
92
    /// The URL without its parameters, for messages.
93
    pub fn plain(&self) -> String {
94
        if self.path.is_empty() {
95
            format!("{}://{}", self.scheme, self.host)
96
        } else {
97
            format!("{}://{}/{}", self.scheme, self.host, self.path)
98
        }
99
    }
100
101
    pub fn param(&self, name: &str) -> Option<&str> {
102
        self.params.get(name).map(String::as_str)
103
    }
104
}
105
106
fn percent_decode(s: &str) -> String {
107
    let bytes = s.as_bytes();
108
    let mut out = Vec::with_capacity(bytes.len());
109
    let mut i = 0;
110
    while i < bytes.len() {
111
        if bytes[i] == b'%' && i + 2 < bytes.len() {
112
            if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
113
                out.push(v);
114
                i += 3;
115
                continue;
116
            }
117
        }
118
        out.push(bytes[i]);
119
        i += 1;
120
    }
121
    String::from_utf8_lossy(&out).into_owned()
122
}
123
124
/// A store on a cloud provider: the provider's client, the prefix every
125
/// key goes under, and a runtime to run the client on.
126
pub struct CloudStore {
127
    inner: Arc<dyn ObjectStore>,
128
    prefix: String,
129
    name: String,
130
    hint: &'static str,
131
    runtime: tokio::runtime::Runtime,
132
    /// One call at a time: the runtime runs on the calling thread.
133
    turn: Mutex<()>,
134
}
135
136
impl CloudStore {
137
    /// Open the store a URL names. Nothing is sent yet: credentials are
138
    /// resolved here, but the bucket is first spoken to on the first call.
139
    pub fn open(url: &str) -> Result<CloudStore> {
140
        let parsed = StoreUrl::parse(url)?;
141
        let runtime = tokio::runtime::Builder::new_current_thread()
142
            .enable_all()
143
            .build()
144
            .map_err(|e| Error::Backend(format!("starting the HTTP runtime: {e}")))?;
145
        let (inner, prefix, hint): (Arc<dyn ObjectStore>, String, &'static str) =
146
            match parsed.scheme.as_str() {
147
                "s3" => (
148
                    Arc::new(aws::open(&parsed)?),
149
                    parsed.path.clone(),
150
                    aws::HINT,
151
                ),
152
                "gs" => (
153
                    Arc::new(gcp::open(&parsed)?),
154
                    parsed.path.clone(),
155
                    gcp::HINT,
156
                ),
157
                "az" => {
158
                    let (store, prefix) = azure::open(&parsed)?;
159
                    (Arc::new(store), prefix, azure::HINT)
160
                }
161
                other => {
162
                    return Err(Error::Backend(format!(
163
                        "{url}: {other}:// is not a store oxy knows (s3://, gs://, az://, dir://)"
164
                    )))
165
                }
166
            };
167
        Ok(CloudStore {
168
            inner,
169
            prefix,
170
            name: parsed.plain(),
171
            hint,
172
            runtime,
173
            turn: Mutex::new(()),
174
        })
175
    }
176
177
    fn block<F: Future>(&self, f: F) -> F::Output {
178
        let _turn = self.turn.lock().unwrap_or_else(|p| p.into_inner());
179
        self.runtime.block_on(f)
180
    }
181
182
    fn path(&self, key: &str) -> Result<ObjectPath> {
183
        check_key(key)?;
184
        let full = if self.prefix.is_empty() {
185
            key.to_string()
186
        } else {
187
            format!("{}/{key}", self.prefix)
188
        };
189
        ObjectPath::parse(&full).map_err(|e| Error::Backend(format!("{full}: {e}")))
190
    }
191
192
    /// Our error for the provider's, with what to do next where it helps.
193
    fn map(&self, e: object_store::Error, key: &str) -> Error {
194
        use object_store::Error as E;
195
        match e {
196
            E::NotFound { .. } => Error::NotFound(key.to_string()),
197
            E::AlreadyExists { .. } => Error::Exists(key.to_string()),
198
            E::Precondition { .. } | E::NotModified { .. } => Error::Conflict(key.to_string()),
199
            other => {
200
                let text = other.to_string();
201
                let lower = text.to_ascii_lowercase();
202
                let advice = if lower.contains("403")
203
                    || lower.contains("accessdenied")
204
                    || lower.contains("forbidden")
205
                    || lower.contains("authorizationpermissionmismatch")
206
                {
207
                    format!(
208
                        ": access denied; the credentials in use may not do this on {}: {}",
209
                        self.name, self.hint
210
                    )
211
                } else if lower.contains("credential") || lower.contains("no valid credentials") {
212
                    format!(": no usable credentials: {}", self.hint)
213
                } else if lower.contains("nosuchbucket")
214
                    || lower.contains("bucketnotfound")
215
                    || lower.contains("containernotfound")
216
                {
217
                    format!(
218
                        ": {} does not exist, or the credentials cannot see it",
219
                        self.name
220
                    )
221
                } else if lower.contains("dns")
222
                    || lower.contains("connect")
223
                    || lower.contains("timed out")
224
                {
225
                    ": cannot reach the service; check the network, the endpoint and the region"
226
                        .to_string()
227
                } else {
228
                    String::new()
229
                };
230
                Error::Backend(format!("{}: {text}{advice}", self.name))
231
            }
232
        }
233
    }
234
235
    fn version_of(e_tag: Option<String>, version: Option<String>) -> Version {
236
        Version(format!(
237
            "{}\t{}",
238
            e_tag.unwrap_or_default(),
239
            version.unwrap_or_default()
240
        ))
241
    }
242
243
    fn update_version(v: &Version) -> UpdateVersion {
244
        let (e_tag, version) = v.0.split_once('\t').unwrap_or((v.0.as_str(), ""));
245
        UpdateVersion {
246
            e_tag: (!e_tag.is_empty()).then(|| e_tag.to_string()),
247
            version: (!version.is_empty()).then(|| version.to_string()),
248
        }
249
    }
250
}
251
252
impl Store for CloudStore {
253
    fn describe(&self) -> String {
254
        self.name.clone()
255
    }
256
257
    fn has(&self, key: &str) -> Result<bool> {
258
        let path = self.path(key)?;
259
        match self.block(self.inner.head(&path)) {
260
            Ok(_) => Ok(true),
261
            Err(object_store::Error::NotFound { .. }) => Ok(false),
262
            Err(e) => Err(self.map(e, key)),
263
        }
264
    }
265
266
    fn get(&self, key: &str) -> Result<Vec<u8>> {
267
        let path = self.path(key)?;
268
        self.block(async {
269
            let r = self.inner.get(&path).await?;
270
            r.bytes().await
271
        })
272
        .map(|b| b.to_vec())
273
        .map_err(|e| self.map(e, key))
274
    }
275
276
    fn get_range(&self, key: &str, range: Range<u64>) -> Result<Vec<u8>> {
277
        let path = self.path(key)?;
278
        if range.start > range.end {
279
            return Err(Error::Backend(format!(
280
                "{key}: bytes {}..{} is not a range",
281
                range.start, range.end
282
            )));
283
        }
284
        if range.is_empty() {
285
            return Ok(Vec::new());
286
        }
287
        let want = (range.end - range.start) as usize;
288
        let bytes = self
289
            .block(self.inner.get_range(&path, range.clone()))
290
            .map_err(|e| self.map(e, key))?;
291
        if bytes.len() != want {
292
            return Err(Error::Backend(format!(
293
                "{key}: bytes {}..{} asked, {} returned: the object is shorter",
294
                range.start,
295
                range.end,
296
                bytes.len()
297
            )));
298
        }
299
        Ok(bytes.to_vec())
300
    }
301
302
    fn put_new(&self, key: &str, bytes: &[u8]) -> Result<()> {
303
        let path = self.path(key)?;
304
        let opts = PutOptions {
305
            mode: PutMode::Create,
306
            ..Default::default()
307
        };
308
        self.block(
309
            self.inner
310
                .put_opts(&path, PutPayload::from(bytes.to_vec()), opts),
311
        )
312
        .map(|_| ())
313
        .map_err(|e| self.map(e, key))
314
    }
315
316
    fn list(&self, prefix: &str) -> Result<Vec<Entry>> {
317
        // Providers list by whole path segments; the asked prefix may end
318
        // mid-segment, so list its directory and filter here.
319
        let dir = prefix.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
320
        let full_dir = match (self.prefix.as_str(), dir) {
321
            ("", "") => None,
322
            ("", d) => Some(d.to_string()),
323
            (p, "") => Some(p.to_string()),
324
            (p, d) => Some(format!("{p}/{d}")),
325
        };
326
        let full_dir = full_dir
327
            .map(|d| ObjectPath::parse(&d).map_err(|e| Error::Backend(format!("{d}: {e}"))))
328
            .transpose()?;
329
        let metas: Vec<object_store::ObjectMeta> = self
330
            .block(self.inner.list(full_dir.as_ref()).try_collect())
331
            .map_err(|e| self.map(e, prefix))?;
332
        let strip = if self.prefix.is_empty() {
333
            String::new()
334
        } else {
335
            format!("{}/", self.prefix)
336
        };
337
        let mut out: Vec<Entry> = metas
338
            .into_iter()
339
            .filter_map(|m| {
340
                let loc = m.location.to_string();
341
                let key = loc.strip_prefix(&strip)?;
342
                key.starts_with(prefix).then(|| Entry {
343
                    key: key.to_string(),
344
                    size: m.size,
345
                    modified_ms: u64::try_from(m.last_modified.timestamp_millis()).ok(),
346
                })
347
            })
348
            .collect();
349
        out.sort_by(|a, b| a.key.cmp(&b.key));
350
        Ok(out)
351
    }
352
353
    fn delete(&self, key: &str) -> Result<()> {
354
        let path = self.path(key)?;
355
        match self.block(self.inner.delete(&path)) {
356
            Ok(()) | Err(object_store::Error::NotFound { .. }) => Ok(()),
357
            Err(e) => Err(self.map(e, key)),
358
        }
359
    }
360
361
    fn read_ref(&self, key: &str) -> Result<Option<(Vec<u8>, Version)>> {
362
        let path = self.path(key)?;
363
        let got = self.block(async {
364
            let r = self.inner.get_opts(&path, GetOptions::default()).await?;
365
            let meta = r.meta.clone();
366
            let bytes = r.bytes().await?;
367
            Ok::<_, object_store::Error>((bytes, meta))
368
        });
369
        match got {
370
            Ok((bytes, meta)) => Ok(Some((
371
                bytes.to_vec(),
372
                Self::version_of(meta.e_tag, meta.version),
373
            ))),
374
            Err(object_store::Error::NotFound { .. }) => Ok(None),
375
            Err(e) => Err(self.map(e, key)),
376
        }
377
    }
378
379
    fn write_ref(&self, key: &str, bytes: &[u8], expected: Option<&Version>) -> Result<Version> {
380
        let path = self.path(key)?;
381
        let mode = match expected {
382
            None => PutMode::Create,
383
            Some(v) => PutMode::Update(Self::update_version(v)),
384
        };
385
        let opts = PutOptions {
386
            mode,
387
            ..Default::default()
388
        };
389
        let put = self
390
            .block(
391
                self.inner
392
                    .put_opts(&path, PutPayload::from(bytes.to_vec()), opts),
393
            )
394
            .map_err(|e| match self.map(e, key) {
395
                // A record that exists when none was expected is a lost
396
                // race too, not a misuse of put_new.
397
                Error::Exists(k) => Error::Conflict(k),
398
                other => other,
399
            })?;
400
        Ok(Self::version_of(put.e_tag, put.version))
401
    }
402
}
403
404
#[cfg(test)]
405
mod tests {
406
    use super::*;
407
408
    #[test]
409
    fn urls_parse_with_their_parameters() {
410
        let u =
411
            StoreUrl::parse("s3://my-bucket/repos/oxygen?profile=work&region=eu-west-2").unwrap();
412
        assert_eq!(u.scheme, "s3");
413
        assert_eq!(u.host, "my-bucket");
414
        assert_eq!(u.path, "repos/oxygen");
415
        assert_eq!(u.param("profile"), Some("work"));
416
        assert_eq!(u.param("region"), Some("eu-west-2"));
417
        assert_eq!(u.plain(), "s3://my-bucket/repos/oxygen");
418
        let u = StoreUrl::parse("gs://bucket").unwrap();
419
        assert_eq!((u.host.as_str(), u.path.as_str()), ("bucket", ""));
420
        assert_eq!(u.plain(), "gs://bucket");
421
        let u = StoreUrl::parse("az://acct/container/a/b/?endpoint=http%3A%2F%2F127.0.0.1%3A10000")
422
            .unwrap();
423
        assert_eq!(u.path, "container/a/b");
424
        assert_eq!(u.param("endpoint"), Some("http://127.0.0.1:10000"));
425
        assert!(StoreUrl::parse("s3://").is_err());
426
        assert!(StoreUrl::parse("nonsense").is_err());
427
    }
428
429
    #[test]
430
    fn versions_carry_both_an_etag_and_a_generation() {
431
        let v = CloudStore::version_of(Some("\"abc\"".into()), Some("17".into()));
432
        let u = CloudStore::update_version(&v);
433
        assert_eq!(u.e_tag.as_deref(), Some("\"abc\""));
434
        assert_eq!(u.version.as_deref(), Some("17"));
435
        let u = CloudStore::update_version(&CloudStore::version_of(None, Some("3".into())));
436
        assert_eq!((u.e_tag, u.version.as_deref()), (None, Some("3")));
437
    }
438
}