OXYGENHUB

Strata: S3, Google Cloud Storage and Azure Blob Storage stores 8d80356371c5

A new crate, oxy-strata, with the stores that need an HTTP client and
the providers' signing: s3://BUCKET/PREFIX, gs://BUCKET/PREFIX and
az://ACCOUNT/CONTAINER/PREFIX, each a Store over the object_store
crate (signing with ring, as the rest of oxy does, not aws-lc-rs). A
URL may carry settings as query parameters (profile, region, endpoint,
credentials), which is where oxy remote add will keep them.

Credentials come from each provider's standard places. AWS: the
environment, the profile's keys in ~/.aws/credentials, then what the
AWS CLI can produce for the profile (SSO, login sessions, roles), then
the instance's role; the region is taken from the URL, the environment
or the profile, else asked of S3. Google: a key file named by
GOOGLE_APPLICATION_CREDENTIALS or the URL, the application default
credentials, then the instance. Azure: a service principal, an account
key or a SAS token, then az login, then the managed identity. Errors
say what to do next.

oxy registers the stores at start. A version carries both an ETag and
a generation, so one compare-and-swap serves all three providers. The
tests run the conformance check and a repository round trip against
each service that the environment names, and delete what they wrote;
checked on the real S3, GCS and Azure.

stored_url now keeps any URL with a scheme as written: it turned a
bucket URL into a path.

refs #63
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-03 22:04 UTC
Signature
✔ signed by Andy McCall
Hash
8d80356371c5573ae038724d4adec6f3835e1d265c75ac1d9c7a0c82c49f2724
Depends on
135212de5370 2b5ee371060e 37c299cec358 5b9c6ad45f6e 750251b21a96 827281eed2ed 982864068a59 9f9e72b93e4e a70dce536476 c6aa5bd9d1c8 d02e545dc265 e993a086c13f fce44ae9ad29 ff0af1ee299d
Depended on by
835ceb9b3ea7 135bdcadec4a ed931d1d1102 642da2636de2 4275ecb110f5 e02edccce98c b260109d27c5 82202a4d0025 6f06d9f912b4 ef7d4ea6f33b 767ac6cd0545 7e0e8e5ad882 30644f1ecb7b 87c948720343 e5f8eb2a2006 68dcf21fe055 f05b84d1ad2d efad40f9ac8e 8b53b935dd03 1e0fbb47ab90 2ddd471651cd e2a5edbf1395 d0bf2da15094 579afd107b9b c9369ad8b1b1 7026bd7dc5b8 d069701a4997 c71a5777509a 648189388f1a 2cd40ea1c04c 6cc9dea93092 f874dc8659c2
In bookmarks
agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.10.0 v0.11.0 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
82 atoms, 10 tree operations, 13 files

Browse the tree at this change

added crates/oxy-strata/Cargo.toml +23 −0
1 +
[package]
2 +
name = "oxy-strata"
3 +
version.workspace = true
4 +
edition.workspace = true
5 +
license.workspace = true
6 +
description = "Oxygen Strata: repositories kept on S3, Google Cloud Storage and Azure Blob Storage"
7 +
8 +
[dependencies]
9 +
oxy-core = { path = "../oxy-core" }
10 +
# The cloud stores, signing with ring (the crypto stack oxy already
11 +
# links), not aws-lc-rs, which needs a C toolchain and cmake to build.
12 +
object_store = { version = "0.14", default-features = false, features = ["aws-base", "gcp-base", "azure-base", "reqwest", "ring"] }
13 +
# object_store's reqwest needs a TLS stack: rustls, with its provider
14 +
# installed at run time (`rustls::crypto::ring`). A dependency's feature
15 +
# list cannot name another crate's feature, so reqwest is named here.
16 +
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider"] }
17 +
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
18 +
tokio = { version = "1", default-features = false, features = ["rt"] }
19 +
futures = "0.3"
20 +
serde_json = "1"
21 +
22 +
[dev-dependencies]
23 +
tempfile = "3"
added crates/oxy-strata/src/aws.rs +211 −0
1 +
//! Amazon S3: `s3://BUCKET/PREFIX`.
2 +
//!
3 +
//! Credentials, in the order the AWS tools use them:
4 +
//! 1. `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` (and
5 +
//!    `AWS_SESSION_TOKEN`) in the environment, unless the URL names a
6 +
//!    profile;
7 +
//! 2. the profile (`?profile=NAME`, else `AWS_PROFILE`, else `default`):
8 +
//!    its keys from `~/.aws/credentials`;
9 +
//! 3. what the AWS CLI can produce for that profile
10 +
//!    (`aws configure export-credentials`): SSO, login sessions, assumed
11 +
//!    roles and credential processes, so anything that works for `aws`
12 +
//!    works for `oxy`;
13 +
//! 4. the instance's or container's role, through the provider's own
14 +
//!    lookup.
15 +
//!
16 +
//! The region comes from `?region=`, the environment, the profile, or is
17 +
//! asked of S3 itself; the endpoint (`?endpoint=`, for MinIO and other
18 +
//! S3-alikes) from the URL, the environment or the profile.
19 +
20 +
use crate::ini;
21 +
use crate::StoreUrl;
22 +
use object_store::aws::{AmazonS3, AmazonS3Builder, S3ConditionalPut};
23 +
use object_store::ClientOptions;
24 +
use oxy_core::strata::{Error, Result};
25 +
use std::path::PathBuf;
26 +
27 +
pub const HINT: &str = "set AWS_PROFILE (or add ?profile=NAME to the remote URL) to a profile in ~/.aws/credentials, run `aws sso login` or `aws login` for it, or set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY";
28 +
29 +
struct Found {
30 +
    key_id: String,
31 +
    secret: String,
32 +
    token: Option<String>,
33 +
}
34 +
35 +
fn env(name: &str) -> Option<String> {
36 +
    std::env::var(name).ok().filter(|v| !v.is_empty())
37 +
}
38 +
39 +
fn home() -> Option<PathBuf> {
40 +
    env("HOME")
41 +
        .or_else(|| env("USERPROFILE"))
42 +
        .map(PathBuf::from)
43 +
}
44 +
45 +
fn read_ini(env_override: &str, default_rel: &str) -> ini::Sections {
46 +
    let path = env(env_override)
47 +
        .map(PathBuf::from)
48 +
        .or_else(|| home().map(|h| h.join(default_rel)));
49 +
    path.and_then(|p| std::fs::read_to_string(p).ok())
50 +
        .map(|t| ini::parse(&t))
51 +
        .unwrap_or_default()
52 +
}
53 +
54 +
/// A profile's section in `~/.aws/config`: `[profile NAME]`, or
55 +
/// `[default]`; `~/.aws/credentials` uses `[NAME]` for both.
56 +
fn config_section<'a>(
57 +
    config: &'a ini::Sections,
58 +
    profile: &str,
59 +
) -> Option<&'a std::collections::BTreeMap<String, String>> {
60 +
    if profile == "default" {
61 +
        config.get("default")
62 +
    } else {
63 +
        config
64 +
            .get(&format!("profile {profile}"))
65 +
            .or_else(|| config.get(profile))
66 +
    }
67 +
}
68 +
69 +
/// Ask the AWS CLI for the profile's credentials, if it is installed and
70 +
/// knows the profile.
71 +
fn from_cli(profile: &str) -> Option<Found> {
72 +
    let out = std::process::Command::new("aws")
73 +
        .args([
74 +
            "configure",
75 +
            "export-credentials",
76 +
            "--profile",
77 +
            profile,
78 +
            "--format",
79 +
            "process",
80 +
        ])
81 +
        .stdin(std::process::Stdio::null())
82 +
        .stderr(std::process::Stdio::null())
83 +
        .output()
84 +
        .ok()?;
85 +
    if !out.status.success() {
86 +
        return None;
87 +
    }
88 +
    let v: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
89 +
    Some(Found {
90 +
        key_id: v.get("AccessKeyId")?.as_str()?.to_string(),
91 +
        secret: v.get("SecretAccessKey")?.as_str()?.to_string(),
92 +
        token: v
93 +
            .get("SessionToken")
94 +
            .and_then(|t| t.as_str())
95 +
            .map(str::to_string),
96 +
    })
97 +
}
98 +
99 +
pub fn open(url: &StoreUrl, runtime: &tokio::runtime::Runtime) -> Result<AmazonS3> {
100 +
    let bucket = url.host.clone();
101 +
    let explicit_profile = url.param("profile").map(str::to_string);
102 +
    let profile = explicit_profile
103 +
        .clone()
104 +
        .or_else(|| env("AWS_PROFILE"))
105 +
        .unwrap_or_else(|| "default".into());
106 +
    let config = read_ini("AWS_CONFIG_FILE", ".aws/config");
107 +
    let creds = read_ini("AWS_SHARED_CREDENTIALS_FILE", ".aws/credentials");
108 +
    let section = config_section(&config, &profile);
109 +
    let known_profile = section.is_some() || creds.contains_key(&profile);
110 +
111 +
    // from_env picks up the environment's keys, region, endpoint, and the
112 +
    // container and instance roles; what follows fills in or overrides.
113 +
    let mut b = AmazonS3Builder::from_env()
114 +
        .with_bucket_name(&bucket)
115 +
        .with_conditional_put(S3ConditionalPut::ETagMatch);
116 +
117 +
    let env_keys = match (env("AWS_ACCESS_KEY_ID"), env("AWS_SECRET_ACCESS_KEY")) {
118 +
        (Some(k), Some(s)) if explicit_profile.is_none() => Some(Found {
119 +
            key_id: k,
120 +
            secret: s,
121 +
            token: env("AWS_SESSION_TOKEN"),
122 +
        }),
123 +
        _ => None,
124 +
    };
125 +
    let found = env_keys
126 +
        .or_else(|| {
127 +
            let c = creds.get(&profile)?;
128 +
            Some(Found {
129 +
                key_id: c.get("aws_access_key_id")?.clone(),
130 +
                secret: c.get("aws_secret_access_key")?.clone(),
131 +
                token: c.get("aws_session_token").cloned(),
132 +
            })
133 +
        })
134 +
        .or_else(|| {
135 +
            if known_profile {
136 +
                from_cli(&profile)
137 +
            } else {
138 +
                None
139 +
            }
140 +
        });
141 +
    match found {
142 +
        Some(f) => {
143 +
            b = b
144 +
                .with_access_key_id(f.key_id)
145 +
                .with_secret_access_key(f.secret);
146 +
            if let Some(t) = f.token {
147 +
                b = b.with_token(t);
148 +
            }
149 +
        }
150 +
        None if explicit_profile.is_some() && !known_profile => {
151 +
            return Err(Error::Backend(format!(
152 +
                "{}: profile {profile:?} is not in ~/.aws/config or ~/.aws/credentials: {HINT}",
153 +
                url.plain()
154 +
            )));
155 +
        }
156 +
        None => {}
157 +
    }
158 +
159 +
    let endpoint = url
160 +
        .param("endpoint")
161 +
        .map(str::to_string)
162 +
        .or_else(|| env("AWS_ENDPOINT_URL_S3"))
163 +
        .or_else(|| env("AWS_ENDPOINT_URL"))
164 +
        .or_else(|| env("AWS_ENDPOINT"))
165 +
        .or_else(|| section.and_then(|s| s.get("endpoint_url").cloned()));
166 +
    if let Some(e) = &endpoint {
167 +
        b = b
168 +
            .with_endpoint(e.clone())
169 +
            .with_allow_http(e.starts_with("http://"));
170 +
        // S3-alikes serve buckets by path, not by host name.
171 +
        b = b.with_virtual_hosted_style_request(false);
172 +
    }
173 +
174 +
    let region = url
175 +
        .param("region")
176 +
        .map(str::to_string)
177 +
        .or_else(|| env("AWS_REGION"))
178 +
        .or_else(|| env("AWS_DEFAULT_REGION"))
179 +
        .or_else(|| section.and_then(|s| s.get("region").cloned()));
180 +
    let region = match region {
181 +
        Some(r) => r,
182 +
        None if endpoint.is_some() => "us-east-1".into(),
183 +
        None => runtime
184 +
            .block_on(object_store::aws::resolve_bucket_region(&bucket, &ClientOptions::new()))
185 +
            .map_err(|e| {
186 +
                Error::Backend(format!(
187 +
                    "{}: no region was given and S3 did not say which region holds the bucket ({e}): add ?region=NAME to the remote URL, set AWS_REGION, or put region in the profile",
188 +
                    url.plain()
189 +
                ))
190 +
            })?,
191 +
    };
192 +
    b = b.with_region(region);
193 +
194 +
    b.build()
195 +
        .map_err(|e| Error::Backend(format!("{}: {e}: {HINT}", url.plain())))
196 +
}
197 +
198 +
#[cfg(test)]
199 +
mod tests {
200 +
    use super::*;
201 +
202 +
    #[test]
203 +
    fn profile_sections_are_found_under_either_heading() {
204 +
        let config =
205 +
            ini::parse("[default]\nregion = a\n[profile work]\nregion = b\n[plain]\nregion = c\n");
206 +
        assert_eq!(config_section(&config, "default").unwrap()["region"], "a");
207 +
        assert_eq!(config_section(&config, "work").unwrap()["region"], "b");
208 +
        assert_eq!(config_section(&config, "plain").unwrap()["region"], "c");
209 +
        assert!(config_section(&config, "none").is_none());
210 +
    }
211 +
}
added crates/oxy-strata/src/azure.rs +65 −0
1 +
//! Azure Blob Storage: `az://ACCOUNT/CONTAINER/PREFIX`.
2 +
//!
3 +
//! Credentials: a service principal (`AZURE_CLIENT_ID`,
4 +
//! `AZURE_CLIENT_SECRET`, `AZURE_TENANT_ID`, as the Azure SDKs name them,
5 +
//! or the `AZURE_STORAGE_*` spellings), an account key
6 +
//! (`AZURE_STORAGE_ACCOUNT_KEY`), a SAS token (`AZURE_STORAGE_SAS_TOKEN`),
7 +
//! else what `az login` left behind, else the machine's managed
8 +
//! identity. `?endpoint=URL` points at an emulator.
9 +
10 +
use crate::StoreUrl;
11 +
use object_store::azure::{MicrosoftAzure, MicrosoftAzureBuilder};
12 +
use oxy_core::strata::{Error, Result};
13 +
14 +
pub const HINT: &str = "run `az login`, or set AZURE_CLIENT_ID, AZURE_CLIENT_SECRET and AZURE_TENANT_ID for a service principal, or AZURE_STORAGE_ACCOUNT_KEY";
15 +
16 +
fn env(name: &str) -> Option<String> {
17 +
    std::env::var(name).ok().filter(|v| !v.is_empty())
18 +
}
19 +
20 +
/// The store and the prefix under the container.
21 +
pub fn open(url: &StoreUrl) -> Result<(MicrosoftAzure, String)> {
22 +
    let (container, prefix) = match url.path.split_once('/') {
23 +
        Some((c, p)) => (c.to_string(), p.to_string()),
24 +
        None if !url.path.is_empty() => (url.path.clone(), String::new()),
25 +
        None => {
26 +
            return Err(Error::Backend(format!(
27 +
                "{}: an Azure store URL names the account and the container: az://ACCOUNT/CONTAINER/PREFIX",
28 +
                url.plain()
29 +
            )))
30 +
        }
31 +
    };
32 +
    let mut b = MicrosoftAzureBuilder::from_env()
33 +
        .with_account(&url.host)
34 +
        .with_container_name(&container);
35 +
    let principal = (
36 +
        env("AZURE_CLIENT_ID").or_else(|| env("AZURE_STORAGE_CLIENT_ID")),
37 +
        env("AZURE_CLIENT_SECRET").or_else(|| env("AZURE_STORAGE_CLIENT_SECRET")),
38 +
        env("AZURE_TENANT_ID").or_else(|| env("AZURE_STORAGE_TENANT_ID")),
39 +
    );
40 +
    let mut explicit = false;
41 +
    if let (Some(id), Some(secret), Some(tenant)) = principal {
42 +
        b = b.with_client_secret_authorization(id, secret, tenant);
43 +
        explicit = true;
44 +
    }
45 +
    if env("AZURE_STORAGE_ACCOUNT_KEY").is_some()
46 +
        || env("AZURE_STORAGE_ACCESS_KEY").is_some()
47 +
        || env("AZURE_STORAGE_SAS_TOKEN").is_some()
48 +
        || env("AZURE_STORAGE_SAS_KEY").is_some()
49 +
    {
50 +
        explicit = true;
51 +
    }
52 +
    if let Some(endpoint) = url.param("endpoint") {
53 +
        b = b
54 +
            .with_endpoint(endpoint.to_string())
55 +
            .with_allow_http(endpoint.starts_with("http://"));
56 +
    }
57 +
    if !explicit {
58 +
        // What `az login` left behind; the managed identity comes after.
59 +
        b = b.with_use_azure_cli(true);
60 +
    }
61 +
    let store = b
62 +
        .build()
63 +
        .map_err(|e| Error::Backend(format!("{}: {e}: {HINT}", url.plain())))?;
64 +
    Ok((store, prefix))
65 +
}
added crates/oxy-strata/src/gcp.rs +61 −0
1 +
//! Google Cloud Storage: `gs://BUCKET/PREFIX`.
2 +
//!
3 +
//! Credentials, in Google's own order: a service account key file named
4 +
//! by `?credentials=PATH` or `GOOGLE_APPLICATION_CREDENTIALS` (which may
5 +
//! also hold application default credentials), the key or path in
6 +
//! `GOOGLE_SERVICE_ACCOUNT*`, then the application default credentials
7 +
//! `gcloud auth application-default login` wrote, then the instance's
8 +
//! service account. `?endpoint=URL` points at an emulator, and
9 +
//! `?anonymous=1` skips signing for one.
10 +
11 +
use crate::StoreUrl;
12 +
use object_store::gcp::{GoogleCloudStorage, GoogleCloudStorageBuilder};
13 +
use oxy_core::strata::{Error, Result};
14 +
15 +
pub const HINT: &str = "run `gcloud auth application-default login`, or set GOOGLE_APPLICATION_CREDENTIALS (or add ?credentials=PATH to the remote URL) to a service account key file";
16 +
17 +
/// Whether a credentials file is a service account key, as opposed to
18 +
/// application default credentials.
19 +
fn is_service_account_key(path: &str) -> bool {
20 +
    std::fs::read_to_string(path)
21 +
        .ok()
22 +
        .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
23 +
        .and_then(|v| {
24 +
            v.get("type")
25 +
                .and_then(|t| t.as_str())
26 +
                .map(|t| t == "service_account")
27 +
        })
28 +
        .unwrap_or(false)
29 +
}
30 +
31 +
pub fn open(url: &StoreUrl) -> Result<GoogleCloudStorage> {
32 +
    let mut b = GoogleCloudStorageBuilder::from_env().with_bucket_name(&url.host);
33 +
    let file = url.param("credentials").map(str::to_string).or_else(|| {
34 +
        std::env::var("GOOGLE_APPLICATION_CREDENTIALS")
35 +
            .ok()
36 +
            .filter(|v| !v.is_empty())
37 +
    });
38 +
    if let Some(path) = file {
39 +
        if !std::path::Path::new(&path).is_file() {
40 +
            return Err(Error::Backend(format!(
41 +
                "{}: the credentials file {path} does not exist: {HINT}",
42 +
                url.plain()
43 +
            )));
44 +
        }
45 +
        b = if is_service_account_key(&path) {
46 +
            b.with_service_account_path(path)
47 +
        } else {
48 +
            b.with_application_credentials(path)
49 +
        };
50 +
    }
51 +
    if let Some(endpoint) = url.param("endpoint") {
52 +
        b = b.with_base_url(endpoint);
53 +
    }
54 +
    if url.param("anonymous").is_some_and(|v| v != "0") {
55 +
        // An emulator checks no token, but the client would still ask the
56 +
        // metadata server for one: give it a token to send.
57 +
        b = b.with_bearer_token("anonymous").with_skip_signature(true);
58 +
    }
59 +
    b.build()
60 +
        .map_err(|e| Error::Backend(format!("{}: {e}: {HINT}", url.plain())))
61 +
}
added crates/oxy-strata/src/ini.rs +42 −0
1 +
//! The INI files the AWS tools keep (`~/.aws/credentials`, `~/.aws/config`):
2 +
//! `[section]` headings and `key = value` lines, nothing more.
3 +
4 +
use std::collections::BTreeMap;
5 +
6 +
pub type Sections = BTreeMap<String, BTreeMap<String, String>>;
7 +
8 +
pub fn parse(text: &str) -> Sections {
9 +
    let mut out = Sections::new();
10 +
    let mut current: Option<String> = None;
11 +
    for raw in text.lines() {
12 +
        let line = raw.trim();
13 +
        if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
14 +
            continue;
15 +
        }
16 +
        if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
17 +
            current = Some(name.trim().to_string());
18 +
            out.entry(name.trim().to_string()).or_default();
19 +
            continue;
20 +
        }
21 +
        if let (Some(section), Some((k, v))) = (&current, line.split_once('=')) {
22 +
            out.entry(section.clone())
23 +
                .or_default()
24 +
                .insert(k.trim().to_string(), v.trim().to_string());
25 +
        }
26 +
    }
27 +
    out
28 +
}
29 +
30 +
#[cfg(test)]
31 +
mod tests {
32 +
    use super::parse;
33 +
34 +
    #[test]
35 +
    fn sections_and_keys() {
36 +
        let s = parse("[default]\nregion = eu-west-2\n\n# a comment\n[profile work]\nregion=us-east-1\nsso_start_url = https://x\n");
37 +
        assert_eq!(s["default"]["region"], "eu-west-2");
38 +
        assert_eq!(s["profile work"]["region"], "us-east-1");
39 +
        assert_eq!(s["profile work"]["sso_start_url"], "https://x");
40 +
        assert!(parse("").is_empty());
41 +
    }
42 +
}
added crates/oxy-strata/src/lib.rs +436 −0
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 ini;
19 +
20 +
use futures::TryStreamExt;
21 +
use object_store::path::Path as ObjectPath;
22 +
use object_store::{
23 +
    GetOptions, ObjectStore, ObjectStoreExt, PutMode, PutOptions, PutPayload, UpdateVersion,
24 +
};
25 +
use oxy_core::strata::{check_key, Entry, Error, Result, Store, Version};
26 +
use std::collections::BTreeMap;
27 +
use std::future::Future;
28 +
use std::ops::Range;
29 +
use std::sync::{Arc, Mutex};
30 +
31 +
/// Make the cloud stores available to `oxy_core::protocol::open_remote`.
32 +
/// Safe to call more than once.
33 +
pub fn register() {
34 +
    // object_store's reqwest is built with rustls and no bundled crypto
35 +
    // provider; oxy links ring already, so that is the one to install.
36 +
    let _ = rustls::crypto::ring::default_provider().install_default();
37 +
    oxy_core::protocol::register_store_opener(open_url);
38 +
}
39 +
40 +
fn open_url(
41 +
    url: &str,
42 +
    _options: &oxy_core::protocol::RemoteOptions,
43 +
) -> Option<Result<Box<dyn Store>>> {
44 +
    let scheme = url.split_once("://")?.0;
45 +
    if !matches!(scheme, "s3" | "gs" | "az") {
46 +
        return None;
47 +
    }
48 +
    Some(CloudStore::open(url).map(|s| Box::new(s) as Box<dyn Store>))
49 +
}
50 +
51 +
/// A parsed store URL: `SCHEME://HOST/PATH?PARAMS`.
52 +
#[derive(Clone, Debug, PartialEq, Eq)]
53 +
pub struct StoreUrl {
54 +
    pub scheme: String,
55 +
    /// The bucket (S3, GCS) or the storage account (Azure).
56 +
    pub host: String,
57 +
    /// The path under the host, without leading or trailing slashes; for
58 +
    /// Azure the container is its first segment.
59 +
    pub path: String,
60 +
    pub params: BTreeMap<String, String>,
61 +
}
62 +
63 +
impl StoreUrl {
64 +
    pub fn parse(url: &str) -> Result<StoreUrl> {
65 +
        let bad = |why: &str| Error::Backend(format!("{url}: {why}"));
66 +
        let (scheme, rest) = url.split_once("://").ok_or_else(|| bad("not a URL"))?;
67 +
        let (rest, query) = match rest.split_once('?') {
68 +
            Some((r, q)) => (r, Some(q)),
69 +
            None => (rest, None),
70 +
        };
71 +
        let (host, path) = match rest.split_once('/') {
72 +
            Some((h, p)) => (h, p.trim_matches('/')),
73 +
            None => (rest, ""),
74 +
        };
75 +
        if host.is_empty() {
76 +
            return Err(bad("names no bucket"));
77 +
        }
78 +
        let mut params = BTreeMap::new();
79 +
        for pair in query.unwrap_or("").split('&').filter(|p| !p.is_empty()) {
80 +
            let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
81 +
            params.insert(k.to_string(), percent_decode(v));
82 +
        }
83 +
        Ok(StoreUrl {
84 +
            scheme: scheme.to_string(),
85 +
            host: host.to_string(),
86 +
            path: path.to_string(),
87 +
            params,
88 +
        })
89 +
    }
90 +
91 +
    /// The URL without its parameters, for messages.
92 +
    pub fn plain(&self) -> String {
93 +
        if self.path.is_empty() {
94 +
            format!("{}://{}", self.scheme, self.host)
95 +
        } else {
96 +
            format!("{}://{}/{}", self.scheme, self.host, self.path)
97 +
        }
98 +
    }
99 +
100 +
    pub fn param(&self, name: &str) -> Option<&str> {
101 +
        self.params.get(name).map(String::as_str)
102 +
    }
103 +
}
104 +
105 +
fn percent_decode(s: &str) -> String {
106 +
    let bytes = s.as_bytes();
107 +
    let mut out = Vec::with_capacity(bytes.len());
108 +
    let mut i = 0;
109 +
    while i < bytes.len() {
110 +
        if bytes[i] == b'%' && i + 2 < bytes.len() {
111 +
            if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
112 +
                out.push(v);
113 +
                i += 3;
114 +
                continue;
115 +
            }
116 +
        }
117 +
        out.push(bytes[i]);
118 +
        i += 1;
119 +
    }
120 +
    String::from_utf8_lossy(&out).into_owned()
121 +
}
122 +
123 +
/// A store on a cloud provider: the provider's client, the prefix every
124 +
/// key goes under, and a runtime to run the client on.
125 +
pub struct CloudStore {
126 +
    inner: Arc<dyn ObjectStore>,
127 +
    prefix: String,
128 +
    name: String,
129 +
    hint: &'static str,
130 +
    runtime: tokio::runtime::Runtime,
131 +
    /// One call at a time: the runtime runs on the calling thread.
132 +
    turn: Mutex<()>,
133 +
}
134 +
135 +
impl CloudStore {
136 +
    /// Open the store a URL names. Nothing is sent yet: credentials are
137 +
    /// resolved here, but the bucket is first spoken to on the first call.
138 +
    pub fn open(url: &str) -> Result<CloudStore> {
139 +
        let parsed = StoreUrl::parse(url)?;
140 +
        let runtime = tokio::runtime::Builder::new_current_thread()
141 +
            .enable_all()
142 +
            .build()
143 +
            .map_err(|e| Error::Backend(format!("starting the HTTP runtime: {e}")))?;
144 +
        let (inner, prefix, hint): (Arc<dyn ObjectStore>, String, &'static str) =
145 +
            match parsed.scheme.as_str() {
146 +
                "s3" => (
147 +
                    Arc::new(aws::open(&parsed, &runtime)?),
148 +
                    parsed.path.clone(),
149 +
                    aws::HINT,
150 +
                ),
151 +
                "gs" => (
152 +
                    Arc::new(gcp::open(&parsed)?),
153 +
                    parsed.path.clone(),
154 +
                    gcp::HINT,
155 +
                ),
156 +
                "az" => {
157 +
                    let (store, prefix) = azure::open(&parsed)?;
158 +
                    (Arc::new(store), prefix, azure::HINT)
159 +
                }
160 +
                other => {
161 +
                    return Err(Error::Backend(format!(
162 +
                        "{url}: {other}:// is not a store oxy knows (s3://, gs://, az://, dir://)"
163 +
                    )))
164 +
                }
165 +
            };
166 +
        Ok(CloudStore {
167 +
            inner,
168 +
            prefix,
169 +
            name: parsed.plain(),
170 +
            hint,
171 +
            runtime,
172 +
            turn: Mutex::new(()),
173 +
        })
174 +
    }
175 +
176 +
    fn block<F: Future>(&self, f: F) -> F::Output {
177 +
        let _turn = self.turn.lock().unwrap_or_else(|p| p.into_inner());
178 +
        self.runtime.block_on(f)
179 +
    }
180 +
181 +
    fn path(&self, key: &str) -> Result<ObjectPath> {
182 +
        check_key(key)?;
183 +
        let full = if self.prefix.is_empty() {
184 +
            key.to_string()
185 +
        } else {
186 +
            format!("{}/{key}", self.prefix)
187 +
        };
188 +
        ObjectPath::parse(&full).map_err(|e| Error::Backend(format!("{full}: {e}")))
189 +
    }
190 +
191 +
    /// Our error for the provider's, with what to do next where it helps.
192 +
    fn map(&self, e: object_store::Error, key: &str) -> Error {
193 +
        use object_store::Error as E;
194 +
        match e {
195 +
            E::NotFound { .. } => Error::NotFound(key.to_string()),
196 +
            E::AlreadyExists { .. } => Error::Exists(key.to_string()),
197 +
            E::Precondition { .. } | E::NotModified { .. } => Error::Conflict(key.to_string()),
198 +
            other => {
199 +
                let text = other.to_string();
200 +
                let lower = text.to_ascii_lowercase();
201 +
                let advice = if lower.contains("403")
202 +
                    || lower.contains("accessdenied")
203 +
                    || lower.contains("forbidden")
204 +
                    || lower.contains("authorizationpermissionmismatch")
205 +
                {
206 +
                    format!(
207 +
                        ": access denied; the credentials in use may not do this on {}: {}",
208 +
                        self.name, self.hint
209 +
                    )
210 +
                } else if lower.contains("credential") || lower.contains("no valid credentials") {
211 +
                    format!(": no usable credentials: {}", self.hint)
212 +
                } else if lower.contains("nosuchbucket")
213 +
                    || lower.contains("bucketnotfound")
214 +
                    || lower.contains("containernotfound")
215 +
                {
216 +
                    format!(
217 +
                        ": {} does not exist, or the credentials cannot see it",
218 +
                        self.name
219 +
                    )
220 +
                } else if lower.contains("dns")
221 +
                    || lower.contains("connect")
222 +
                    || lower.contains("timed out")
223 +
                {
224 +
                    ": cannot reach the service; check the network, the endpoint and the region"
225 +
                        .to_string()
226 +
                } else {
227 +
                    String::new()
228 +
                };
229 +
                Error::Backend(format!("{}: {text}{advice}", self.name))
230 +
            }
231 +
        }
232 +
    }
233 +
234 +
    fn version_of(e_tag: Option<String>, version: Option<String>) -> Version {
235 +
        Version(format!(
236 +
            "{}\t{}",
237 +
            e_tag.unwrap_or_default(),
238 +
            version.unwrap_or_default()
239 +
        ))
240 +
    }
241 +
242 +
    fn update_version(v: &Version) -> UpdateVersion {
243 +
        let (e_tag, version) = v.0.split_once('\t').unwrap_or((v.0.as_str(), ""));
244 +
        UpdateVersion {
245 +
            e_tag: (!e_tag.is_empty()).then(|| e_tag.to_string()),
246 +
            version: (!version.is_empty()).then(|| version.to_string()),
247 +
        }
248 +
    }
249 +
}
250 +
251 +
impl Store for CloudStore {
252 +
    fn describe(&self) -> String {
253 +
        self.name.clone()
254 +
    }
255 +
256 +
    fn has(&self, key: &str) -> Result<bool> {
257 +
        let path = self.path(key)?;
258 +
        match self.block(self.inner.head(&path)) {
259 +
            Ok(_) => Ok(true),
260 +
            Err(object_store::Error::NotFound { .. }) => Ok(false),
261 +
            Err(e) => Err(self.map(e, key)),
262 +
        }
263 +
    }
264 +
265 +
    fn get(&self, key: &str) -> Result<Vec<u8>> {
266 +
        let path = self.path(key)?;
267 +
        self.block(async {
268 +
            let r = self.inner.get(&path).await?;
269 +
            r.bytes().await
270 +
        })
271 +
        .map(|b| b.to_vec())
272 +
        .map_err(|e| self.map(e, key))
273 +
    }
274 +
275 +
    fn get_range(&self, key: &str, range: Range<u64>) -> Result<Vec<u8>> {
276 +
        let path = self.path(key)?;
277 +
        if range.start > range.end {
278 +
            return Err(Error::Backend(format!(
279 +
                "{key}: bytes {}..{} is not a range",
280 +
                range.start, range.end
281 +
            )));
282 +
        }
283 +
        if range.is_empty() {
284 +
            return Ok(Vec::new());
285 +
        }
286 +
        let want = (range.end - range.start) as usize;
287 +
        let bytes = self
288 +
            .block(self.inner.get_range(&path, range.clone()))
289 +
            .map_err(|e| self.map(e, key))?;
290 +
        if bytes.len() != want {
291 +
            return Err(Error::Backend(format!(
292 +
                "{key}: bytes {}..{} asked, {} returned: the object is shorter",
293 +
                range.start,
294 +
                range.end,
295 +
                bytes.len()
296 +
            )));
297 +
        }
298 +
        Ok(bytes.to_vec())
299 +
    }
300 +
301 +
    fn put_new(&self, key: &str, bytes: &[u8]) -> Result<()> {
302 +
        let path = self.path(key)?;
303 +
        let opts = PutOptions {
304 +
            mode: PutMode::Create,
305 +
            ..Default::default()
306 +
        };
307 +
        self.block(
308 +
            self.inner
309 +
                .put_opts(&path, PutPayload::from(bytes.to_vec()), opts),
310 +
        )
311 +
        .map(|_| ())
312 +
        .map_err(|e| self.map(e, key))
313 +
    }
314 +
315 +
    fn list(&self, prefix: &str) -> Result<Vec<Entry>> {
316 +
        // Providers list by whole path segments; the asked prefix may end
317 +
        // mid-segment, so list its directory and filter here.
318 +
        let dir = prefix.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
319 +
        let full_dir = match (self.prefix.as_str(), dir) {
320 +
            ("", "") => None,
321 +
            ("", d) => Some(d.to_string()),
322 +
            (p, "") => Some(p.to_string()),
323 +
            (p, d) => Some(format!("{p}/{d}")),
324 +
        };
325 +
        let full_dir = full_dir
326 +
            .map(|d| ObjectPath::parse(&d).map_err(|e| Error::Backend(format!("{d}: {e}"))))
327 +
            .transpose()?;
328 +
        let metas: Vec<object_store::ObjectMeta> = self
329 +
            .block(self.inner.list(full_dir.as_ref()).try_collect())
330 +
            .map_err(|e| self.map(e, prefix))?;
331 +
        let strip = if self.prefix.is_empty() {
332 +
            String::new()
333 +
        } else {
334 +
            format!("{}/", self.prefix)
335 +
        };
336 +
        let mut out: Vec<Entry> = metas
337 +
            .into_iter()
338 +
            .filter_map(|m| {
339 +
                let loc = m.location.to_string();
340 +
                let key = loc.strip_prefix(&strip)?;
341 +
                key.starts_with(prefix).then(|| Entry {
342 +
                    key: key.to_string(),
343 +
                    size: m.size,
344 +
                })
345 +
            })
346 +
            .collect();
347 +
        out.sort_by(|a, b| a.key.cmp(&b.key));
348 +
        Ok(out)
349 +
    }
350 +
351 +
    fn delete(&self, key: &str) -> Result<()> {
352 +
        let path = self.path(key)?;
353 +
        match self.block(self.inner.delete(&path)) {
354 +
            Ok(()) | Err(object_store::Error::NotFound { .. }) => Ok(()),
355 +
            Err(e) => Err(self.map(e, key)),
356 +
        }
357 +
    }
358 +
359 +
    fn read_ref(&self, key: &str) -> Result<Option<(Vec<u8>, Version)>> {
360 +
        let path = self.path(key)?;
361 +
        let got = self.block(async {
362 +
            let r = self.inner.get_opts(&path, GetOptions::default()).await?;
363 +
            let meta = r.meta.clone();
364 +
            let bytes = r.bytes().await?;
365 +
            Ok::<_, object_store::Error>((bytes, meta))
366 +
        });
367 +
        match got {
368 +
            Ok((bytes, meta)) => Ok(Some((
369 +
                bytes.to_vec(),
370 +
                Self::version_of(meta.e_tag, meta.version),
371 +
            ))),
372 +
            Err(object_store::Error::NotFound { .. }) => Ok(None),
373 +
            Err(e) => Err(self.map(e, key)),
374 +
        }
375 +
    }
376 +
377 +
    fn write_ref(&self, key: &str, bytes: &[u8], expected: Option<&Version>) -> Result<Version> {
378 +
        let path = self.path(key)?;
379 +
        let mode = match expected {
380 +
            None => PutMode::Create,
381 +
            Some(v) => PutMode::Update(Self::update_version(v)),
382 +
        };
383 +
        let opts = PutOptions {
384 +
            mode,
385 +
            ..Default::default()
386 +
        };
387 +
        let put = self
388 +
            .block(
389 +
                self.inner
390 +
                    .put_opts(&path, PutPayload::from(bytes.to_vec()), opts),
391 +
            )
392 +
            .map_err(|e| match self.map(e, key) {
393 +
                // A record that exists when none was expected is a lost
394 +
                // race too, not a misuse of put_new.
395 +
                Error::Exists(k) => Error::Conflict(k),
396 +
                other => other,
397 +
            })?;
398 +
        Ok(Self::version_of(put.e_tag, put.version))
399 +
    }
400 +
}
401 +
402 +
#[cfg(test)]
403 +
mod tests {
404 +
    use super::*;
405 +
406 +
    #[test]
407 +
    fn urls_parse_with_their_parameters() {
408 +
        let u =
409 +
            StoreUrl::parse("s3://my-bucket/repos/oxygen?profile=work&region=eu-west-2").unwrap();
410 +
        assert_eq!(u.scheme, "s3");
411 +
        assert_eq!(u.host, "my-bucket");
412 +
        assert_eq!(u.path, "repos/oxygen");
413 +
        assert_eq!(u.param("profile"), Some("work"));
414 +
        assert_eq!(u.param("region"), Some("eu-west-2"));
415 +
        assert_eq!(u.plain(), "s3://my-bucket/repos/oxygen");
416 +
        let u = StoreUrl::parse("gs://bucket").unwrap();
417 +
        assert_eq!((u.host.as_str(), u.path.as_str()), ("bucket", ""));
418 +
        assert_eq!(u.plain(), "gs://bucket");
419 +
        let u = StoreUrl::parse("az://acct/container/a/b/?endpoint=http%3A%2F%2F127.0.0.1%3A10000")
420 +
            .unwrap();
421 +
        assert_eq!(u.path, "container/a/b");
422 +
        assert_eq!(u.param("endpoint"), Some("http://127.0.0.1:10000"));
423 +
        assert!(StoreUrl::parse("s3://").is_err());
424 +
        assert!(StoreUrl::parse("nonsense").is_err());
425 +
    }
426 +
427 +
    #[test]
428 +
    fn versions_carry_both_an_etag_and_a_generation() {
429 +
        let v = CloudStore::version_of(Some("\"abc\"".into()), Some("17".into()));
430 +
        let u = CloudStore::update_version(&v);
431 +
        assert_eq!(u.e_tag.as_deref(), Some("\"abc\""));
432 +
        assert_eq!(u.version.as_deref(), Some("17"));
433 +
        let u = CloudStore::update_version(&CloudStore::version_of(None, Some("3".into())));
434 +
        assert_eq!((u.e_tag, u.version.as_deref()), (None, Some("3")));
435 +
    }
436 +
}
added crates/oxy-strata/tests/cloud.rs +149 −0
1 +
//! The cloud stores against real services or emulators, when the
2 +
//! environment names them; otherwise each test says so and passes.
3 +
//!
4 +
//! - `OXY_TEST_S3=s3://BUCKET/PREFIX[?params]`, with `OXY_TEST_S3_PROFILE`
5 +
//!   added as `?profile=`;
6 +
//! - `OXY_TEST_GCS=gs://BUCKET/PREFIX[?params]`, with
7 +
//!   `OXY_TEST_GCS_CREDENTIALS` added as `?credentials=`;
8 +
//! - `OXY_TEST_AZURE=az://ACCOUNT/CONTAINER/PREFIX[?params]`, with the
9 +
//!   `AZURE_*` variables read from the file `OXY_TEST_AZURE_CREDENTIALS`.
10 +
//!
11 +
//! Every test works under its own prefix below the one given, and deletes
12 +
//! what it wrote.
13 +
14 +
use oxy_core::repo::Repo;
15 +
use oxy_core::strata::{conformance, Store};
16 +
use oxy_strata::CloudStore;
17 +
use std::path::Path;
18 +
19 +
fn configured(scheme: &str) -> Option<String> {
20 +
    let (var, extra, key) = match scheme {
21 +
        "s3" => ("OXY_TEST_S3", "OXY_TEST_S3_PROFILE", "profile"),
22 +
        "gs" => ("OXY_TEST_GCS", "OXY_TEST_GCS_CREDENTIALS", "credentials"),
23 +
        "az" => ("OXY_TEST_AZURE", "OXY_TEST_AZURE_CREDENTIALS", ""),
24 +
        _ => unreachable!(),
25 +
    };
26 +
    let url = std::env::var(var).ok().filter(|u| !u.is_empty())?;
27 +
    let extra_value = std::env::var(extra).ok().filter(|v| !v.is_empty());
28 +
    if scheme == "az" {
29 +
        if let Some(file) = &extra_value {
30 +
            for line in std::fs::read_to_string(file).unwrap_or_default().lines() {
31 +
                if let Some((k, v)) = line.split_once('=') {
32 +
                    std::env::set_var(k.trim(), v.trim());
33 +
                }
34 +
            }
35 +
        }
36 +
        return Some(url);
37 +
    }
38 +
    Some(match extra_value {
39 +
        Some(v) if !url.contains(&format!("{key}=")) => {
40 +
            let sep = if url.contains('?') { '&' } else { '?' };
41 +
            format!("{url}{sep}{key}={v}")
42 +
        }
43 +
        _ => url,
44 +
    })
45 +
}
46 +
47 +
fn each_store() -> Vec<(String, CloudStore)> {
48 +
    oxy_strata::register();
49 +
    ["s3", "gs", "az"]
50 +
        .into_iter()
51 +
        .filter_map(|s| {
52 +
            let Some(url) = configured(s) else {
53 +
                eprintln!("{s}: not configured, skipped");
54 +
                return None;
55 +
            };
56 +
            let store = CloudStore::open(&url).unwrap_or_else(|e| panic!("{url}: {e}"));
57 +
            Some((url, store))
58 +
        })
59 +
        .collect()
60 +
}
61 +
62 +
fn remove_all(store: &dyn Store, prefix: &str) {
63 +
    for e in store.list(prefix).unwrap() {
64 +
        store.delete(&e.key).unwrap();
65 +
    }
66 +
}
67 +
68 +
/// What earlier runs that failed may have left under the test prefix:
69 +
/// other processes' `probe-*` and `repo-*` keys.
70 +
fn remove_stale(store: &dyn Store) {
71 +
    let mine = format!("-{}", std::process::id());
72 +
    for e in store.list("").unwrap() {
73 +
        let first = e.key.split('/').next().unwrap_or("");
74 +
        if (first.starts_with("probe-") || first.starts_with("repo-")) && !first.ends_with(&mine) {
75 +
            store.delete(&e.key).unwrap();
76 +
        }
77 +
    }
78 +
}
79 +
80 +
#[test]
81 +
fn every_configured_store_conforms() {
82 +
    for (url, store) in each_store() {
83 +
        remove_stale(&store);
84 +
        let prefix = format!("probe-{}", std::process::id());
85 +
        let passed = conformance(&store, &prefix).unwrap_or_else(|e| {
86 +
            remove_all(&store, &prefix);
87 +
            panic!("{url}: {e}")
88 +
        });
89 +
        eprintln!("{url}: {} checks passed", passed.len());
90 +
        assert_eq!(passed.len(), 8);
91 +
    }
92 +
}
93 +
94 +
/// A small repository pushed to the store, cloned back, and pulled, by
95 +
/// URL: what a user does. Then every key the test wrote is removed.
96 +
#[test]
97 +
fn a_repository_round_trips_through_every_configured_store() {
98 +
    for (url, base_store) in each_store() {
99 +
        remove_stale(&base_store);
100 +
        let dir = tempfile::tempdir().unwrap();
101 +
        let root = dir.path();
102 +
        std::fs::create_dir_all(root.join("home")).unwrap();
103 +
        std::env::set_var("OXY_HOME", root.join("home"));
104 +
        let (base, query) = match url.split_once('?') {
105 +
            Some((b, q)) => (b.to_string(), format!("?{q}")),
106 +
            None => (url.clone(), String::new()),
107 +
        };
108 +
        let remote_url = format!("{base}/repo-{}{query}", std::process::id());
109 +
        let plain = format!("{base}/repo-{}", std::process::id());
110 +
        let store = CloudStore::open(&remote_url).unwrap();
111 +
112 +
        let mut a = Repo::init(&root.join("a")).unwrap();
113 +
        a.config.set("remote.cloud.url", &remote_url).unwrap();
114 +
        a.save_config().unwrap();
115 +
        write(root, "a/f.txt", b"hello from a\n");
116 +
        let h1 = a.commit("first", &[]).unwrap();
117 +
        let r = a.push("cloud", false).unwrap();
118 +
        assert!(r.iter().all(|x| x.ok), "{plain}: {r:?}");
119 +
        assert!(store.has("oxygen.toml").unwrap());
120 +
        assert_eq!(store.list("bundles/").unwrap().len(), 1, "{plain}");
121 +
122 +
        let b = Repo::clone(&remote_url, &root.join("b"))
123 +
            .unwrap_or_else(|e| panic!("{plain}: clone: {e}"));
124 +
        assert_eq!(
125 +
            std::fs::read(root.join("b/f.txt")).unwrap(),
126 +
            b"hello from a\n"
127 +
        );
128 +
        assert_eq!(b.bookmark_heads().unwrap(), vec![h1]);
129 +
        write(root, "b/f.txt", b"hello from a\nand b\n");
130 +
        b.commit("second", &[]).unwrap();
131 +
        assert!(b.push("origin", false).unwrap()[0].ok);
132 +
        let pr = a.pull("cloud").unwrap();
133 +
        assert!(pr.changed, "{plain}");
134 +
        assert_eq!(
135 +
            std::fs::read(root.join("a/f.txt")).unwrap(),
136 +
            b"hello from a\nand b\n"
137 +
        );
138 +
        let total: u64 = store.list("").unwrap().iter().map(|e| e.size).sum();
139 +
        eprintln!("{plain}: round trip ok, {total} bytes in the store before cleanup");
140 +
        remove_all(&store, "");
141 +
        assert!(store.list("").unwrap().is_empty(), "{plain}: cleanup");
142 +
    }
143 +
}
144 +
145 +
fn write(root: &Path, rel: &str, content: &[u8]) {
146 +
    let p = root.join(rel);
147 +
    std::fs::create_dir_all(p.parent().unwrap()).unwrap();
148 +
    std::fs::write(p, content).unwrap();
149 +
}
modified Cargo.lock +702 −6
…
17 unchanged lines
18 18
]
19 19
20 20
[[package]]
21 +
name = "android_system_properties"
22 +
version = "0.1.6"
23 +
source = "registry+https://github.com/rust-lang/crates.io-index"
24 +
checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
25 +
dependencies = [
26 +
 "libc",
27 +
]
28 +
29 +
[[package]]
21 30
name = "anstream"
22 31
version = "1.0.0"
23 32
source = "registry+https://github.com/rust-lang/crates.io-index"
…
101 unchanged lines
125 134
 "proc-macro2",
126 135
 "quote",
127 136
 "syn 2.0.119",
137 +
]
138 +
139 +
[[package]]
140 +
name = "async-trait"
141 +
version = "0.1.92"
142 +
source = "registry+https://github.com/rust-lang/crates.io-index"
143 +
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
144 +
dependencies = [
145 +
 "proc-macro2",
146 +
 "quote",
147 +
 "syn 3.0.6",
128 148
]
129 149
130 150
[[package]]
…
194 unchanged lines
325 345
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
326 346
327 347
[[package]]
348 +
name = "chacha20"
349 +
version = "0.10.2"
350 +
source = "registry+https://github.com/rust-lang/crates.io-index"
351 +
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
352 +
dependencies = [
353 +
 "cfg-if",
354 +
 "cpufeatures",
355 +
 "rand_core 0.10.1",
356 +
]
357 +
358 +
[[package]]
359 +
name = "chrono"
360 +
version = "0.4.45"
361 +
source = "registry+https://github.com/rust-lang/crates.io-index"
362 +
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
363 +
dependencies = [
364 +
 "iana-time-zone",
365 +
 "num-traits",
366 +
 "serde",
367 +
 "windows-link",
368 +
]
369 +
370 +
[[package]]
328 371
name = "clap"
329 372
version = "4.6.7"
330 373
source = "registry+https://github.com/rust-lang/crates.io-index"
…
65 unchanged lines
396 439
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
397 440
398 441
[[package]]
442 +
name = "combine"
443 +
version = "4.6.8"
444 +
source = "registry+https://github.com/rust-lang/crates.io-index"
445 +
checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
446 +
dependencies = [
447 +
 "bytes",
448 +
 "memchr",
449 +
]
450 +
451 +
[[package]]
399 452
name = "console"
400 453
version = "0.15.11"
401 454
source = "registry+https://github.com/rust-lang/crates.io-index"
…
19 unchanged lines
421 474
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
422 475
423 476
[[package]]
477 +
name = "core-foundation"
478 +
version = "0.10.1"
479 +
source = "registry+https://github.com/rust-lang/crates.io-index"
480 +
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
481 +
dependencies = [
482 +
 "core-foundation-sys",
483 +
 "libc",
484 +
]
485 +
486 +
[[package]]
487 +
name = "core-foundation-sys"
488 +
version = "0.8.7"
489 +
source = "registry+https://github.com/rust-lang/crates.io-index"
490 +
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
491 +
492 +
[[package]]
424 493
name = "cpufeatures"
425 494
version = "0.3.1"
426 495
source = "registry+https://github.com/rust-lang/crates.io-index"
427 496
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
428 497
dependencies = [
429 498
 "libc",
499 +
]
500 +
501 +
[[package]]
502 +
name = "crc-fast"
503 +
version = "1.10.0"
504 +
source = "registry+https://github.com/rust-lang/crates.io-index"
505 +
checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5"
506 +
dependencies = [
507 +
 "digest 0.10.7",
508 +
 "spin",
430 509
]
431 510
432 511
[[package]]
…
29 unchanged lines
462 541
version = "0.8.23"
463 542
source = "registry+https://github.com/rust-lang/crates.io-index"
464 543
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
544 +
545 +
[[package]]
546 +
name = "crypto-common"
547 +
version = "0.1.7"
548 +
source = "registry+https://github.com/rust-lang/crates.io-index"
549 +
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
550 +
dependencies = [
551 +
 "generic-array",
552 +
 "typenum",
553 +
]
465 554
466 555
[[package]]
467 556
name = "crypto-common"
…
24 unchanged lines
492 581
 "cfg-if",
493 582
 "cpufeatures",
494 583
 "curve25519-dalek-derive",
495 -
 "digest",
584 +
 "digest 0.11.3",
496 585
 "fiat-crypto",
497 586
 "rustc_version",
498 587
 "subtle",
…
46 unchanged lines
545 634
version = "0.5.8"
546 635
source = "registry+https://github.com/rust-lang/crates.io-index"
547 636
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
637 +
638 +
[[package]]
639 +
name = "digest"
640 +
version = "0.10.7"
641 +
source = "registry+https://github.com/rust-lang/crates.io-index"
642 +
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
643 +
dependencies = [
644 +
 "crypto-common 0.1.7",
645 +
]
548 646
549 647
[[package]]
550 648
name = "digest"
…
2 unchanged lines
553 651
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
554 652
dependencies = [
555 653
 "block-buffer",
556 -
 "crypto-common",
654 +
 "crypto-common 0.2.2",
557 655
]
558 656
559 657
[[package]]
…
109 unchanged lines
669 767
dependencies = [
670 768
 "autocfg",
671 769
 "tokio",
770 +
]
771 +
772 +
[[package]]
773 +
name = "futures"
774 +
version = "0.3.34"
775 +
source = "registry+https://github.com/rust-lang/crates.io-index"
776 +
checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
777 +
dependencies = [
778 +
 "futures-channel",
779 +
 "futures-core",
780 +
 "futures-executor",
781 +
 "futures-io",
782 +
 "futures-sink",
783 +
 "futures-task",
784 +
 "futures-util",
672 785
]
673 786
674 787
[[package]]
…
3 unchanged lines
678 791
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
679 792
dependencies = [
680 793
 "futures-core",
794 +
 "futures-sink",
681 795
]
682 796
683 797
[[package]]
…
3 unchanged lines
687 801
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
688 802
689 803
[[package]]
804 +
name = "futures-executor"
805 +
version = "0.3.34"
806 +
source = "registry+https://github.com/rust-lang/crates.io-index"
807 +
checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
808 +
dependencies = [
809 +
 "futures-core",
810 +
 "futures-task",
811 +
 "futures-util",
812 +
]
813 +
814 +
[[package]]
815 +
name = "futures-io"
816 +
version = "0.3.34"
817 +
source = "registry+https://github.com/rust-lang/crates.io-index"
818 +
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
819 +
820 +
[[package]]
821 +
name = "futures-macro"
822 +
version = "0.3.34"
823 +
source = "registry+https://github.com/rust-lang/crates.io-index"
824 +
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
825 +
dependencies = [
826 +
 "proc-macro2",
827 +
 "quote",
828 +
 "syn 3.0.6",
829 +
]
830 +
831 +
[[package]]
690 832
name = "futures-sink"
691 833
version = "0.3.34"
692 834
source = "registry+https://github.com/rust-lang/crates.io-index"
…
11 unchanged lines
704 846
source = "registry+https://github.com/rust-lang/crates.io-index"
705 847
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
706 848
dependencies = [
849 +
 "futures-channel",
707 850
 "futures-core",
851 +
 "futures-io",
852 +
 "futures-macro",
853 +
 "futures-sink",
708 854
 "futures-task",
855 +
 "memchr",
709 856
 "pin-project-lite",
710 857
 "slab",
711 858
]
712 859
713 860
[[package]]
861 +
name = "generic-array"
862 +
version = "0.14.7"
863 +
source = "registry+https://github.com/rust-lang/crates.io-index"
864 +
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
865 +
dependencies = [
866 +
 "typenum",
867 +
 "version_check",
868 +
]
869 +
870 +
[[package]]
714 871
name = "getrandom"
715 872
version = "0.2.17"
716 873
source = "registry+https://github.com/rust-lang/crates.io-index"
…
122 unchanged lines
839 996
version = "1.0.3"
840 997
source = "registry+https://github.com/rust-lang/crates.io-index"
841 998
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
999 +
1000 +
[[package]]
1001 +
name = "humantime"
1002 +
version = "2.4.0"
1003 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1004 +
checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15"
842 1005
843 1006
[[package]]
844 1007
name = "hybrid-array"
…
24 unchanged lines
869 1032
 "pin-project-lite",
870 1033
 "smallvec",
871 1034
 "tokio",
1035 +
 "want",
872 1036
]
873 1037
874 1038
[[package]]
1039 +
name = "hyper-rustls"
1040 +
version = "0.27.10"
1041 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1042 +
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
1043 +
dependencies = [
1044 +
 "http",
1045 +
 "hyper",
1046 +
 "hyper-util",
1047 +
 "rustls",
1048 +
 "tokio",
1049 +
 "tokio-rustls",
1050 +
 "tower-service",
1051 +
]
1052 +
1053 +
[[package]]
875 1054
name = "hyper-util"
876 1055
version = "0.1.21"
877 1056
source = "registry+https://github.com/rust-lang/crates.io-index"
878 1057
checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
879 1058
dependencies = [
1059 +
 "base64 0.23.1",
880 1060
 "bytes",
1061 +
 "futures-channel",
1062 +
 "futures-util",
881 1063
 "http",
882 1064
 "http-body",
1065 +
 "httparse",
883 1066
 "hyper",
1067 +
 "ipnet",
1068 +
 "libc",
1069 +
 "percent-encoding",
884 1070
 "pin-project-lite",
1071 +
 "socket2",
885 1072
 "tokio",
886 1073
 "tower-service",
1074 +
 "tracing",
887 1075
]
888 1076
889 1077
[[package]]
1078 +
name = "iana-time-zone"
1079 +
version = "0.1.65"
1080 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1081 +
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
1082 +
dependencies = [
1083 +
 "android_system_properties",
1084 +
 "core-foundation-sys",
1085 +
 "iana-time-zone-haiku",
1086 +
 "js-sys",
1087 +
 "log",
1088 +
 "wasm-bindgen",
1089 +
 "windows-core",
1090 +
]
1091 +
1092 +
[[package]]
1093 +
name = "iana-time-zone-haiku"
1094 +
version = "0.1.2"
1095 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1096 +
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
1097 +
dependencies = [
1098 +
 "cc",
1099 +
]
1100 +
1101 +
[[package]]
890 1102
name = "icu_collections"
891 1103
version = "2.3.0"
892 1104
source = "registry+https://github.com/rust-lang/crates.io-index"
…
137 unchanged lines
1030 1242
]
1031 1243
1032 1244
[[package]]
1245 +
name = "ipnet"
1246 +
version = "2.12.2"
1247 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1248 +
checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
1249 +
1250 +
[[package]]
1033 1251
name = "is_terminal_polyfill"
1034 1252
version = "1.70.2"
1035 1253
source = "registry+https://github.com/rust-lang/crates.io-index"
1036 1254
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
1255 +
1256 +
[[package]]
1257 +
name = "itertools"
1258 +
version = "0.15.0"
1259 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1260 +
checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc"
1261 +
dependencies = [
1262 +
 "either",
1263 +
]
1037 1264
1038 1265
[[package]]
1039 1266
name = "itoa"
…
2 unchanged lines
1042 1269
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
1043 1270
1044 1271
[[package]]
1272 +
name = "jni"
1273 +
version = "0.22.4"
1274 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1275 +
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
1276 +
dependencies = [
1277 +
 "cfg-if",
1278 +
 "combine",
1279 +
 "jni-macros",
1280 +
 "jni-sys",
1281 +
 "log",
1282 +
 "simd_cesu8",
1283 +
 "thiserror 2.0.21",
1284 +
 "walkdir",
1285 +
 "windows-link",
1286 +
]
1287 +
1288 +
[[package]]
1289 +
name = "jni-macros"
1290 +
version = "0.22.4"
1291 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1292 +
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
1293 +
dependencies = [
1294 +
 "proc-macro2",
1295 +
 "quote",
1296 +
 "rustc_version",
1297 +
 "simd_cesu8",
1298 +
 "syn 2.0.119",
1299 +
]
1300 +
1301 +
[[package]]
1302 +
name = "jni-sys"
1303 +
version = "0.4.1"
1304 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1305 +
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
1306 +
dependencies = [
1307 +
 "jni-sys-macros",
1308 +
]
1309 +
1310 +
[[package]]
1311 +
name = "jni-sys-macros"
1312 +
version = "0.4.1"
1313 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1314 +
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
1315 +
dependencies = [
1316 +
 "quote",
1317 +
 "syn 2.0.119",
1318 +
]
1319 +
1320 +
[[package]]
1045 1321
name = "jobserver"
1046 1322
version = "0.1.35"
1047 1323
source = "registry+https://github.com/rust-lang/crates.io-index"
…
49 unchanged lines
1097 1373
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
1098 1374
1099 1375
[[package]]
1376 +
name = "lock_api"
1377 +
version = "0.4.14"
1378 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1379 +
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
1380 +
dependencies = [
1381 +
 "scopeguard",
1382 +
]
1383 +
1384 +
[[package]]
1100 1385
name = "log"
1101 1386
version = "0.4.34"
1102 1387
source = "registry+https://github.com/rust-lang/crates.io-index"
…
4 unchanged lines
1107 1392
version = "0.8.4"
1108 1393
source = "registry+https://github.com/rust-lang/crates.io-index"
1109 1394
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
1395 +
1396 +
[[package]]
1397 +
name = "md-5"
1398 +
version = "0.11.0"
1399 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1400 +
checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
1401 +
dependencies = [
1402 +
 "cfg-if",
1403 +
 "digest 0.11.3",
1404 +
]
1110 1405
1111 1406
[[package]]
1112 1407
name = "memchr"
…
41 unchanged lines
1154 1449
checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d"
1155 1450
dependencies = [
1156 1451
 "const-oid",
1157 -
 "crypto-common",
1452 +
 "crypto-common 0.2.2",
1158 1453
 "ctutils",
1159 1454
 "hybrid-array",
1160 1455
 "module-lattice",
…
62 unchanged lines
1223 1518
version = "0.4.0"
1224 1519
source = "registry+https://github.com/rust-lang/crates.io-index"
1225 1520
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
1521 +
1522 +
[[package]]
1523 +
name = "object_store"
1524 +
version = "0.14.2"
1525 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1526 +
checksum = "f1796bc93603f78c5760a69f2d58badc9618d22adade0a95385bb2adbae4eb94"
1527 +
dependencies = [
1528 +
 "async-trait",
1529 +
 "base64 0.23.1",
1530 +
 "bytes",
1531 +
 "chrono",
1532 +
 "crc-fast",
1533 +
 "form_urlencoded",
1534 +
 "futures-channel",
1535 +
 "futures-core",
1536 +
 "futures-util",
1537 +
 "http",
1538 +
 "http-body-util",
1539 +
 "httparse",
1540 +
 "humantime",
1541 +
 "hyper",
1542 +
 "itertools",
1543 +
 "md-5",
1544 +
 "parking_lot",
1545 +
 "percent-encoding",
1546 +
 "quick-xml",
1547 +
 "rand 0.10.3",
1548 +
 "reqwest",
1549 +
 "ring",
1550 +
 "rustls-pki-types",
1551 +
 "serde",
1552 +
 "serde_json",
1553 +
 "serde_urlencoded",
1554 +
 "thiserror 2.0.21",
1555 +
 "tokio",
1556 +
 "tracing",
1557 +
 "url",
1558 +
 "wasm-bindgen-futures",
1559 +
 "web-time",
1560 +
]
1226 1561
1227 1562
[[package]]
1228 1563
name = "oid-registry"
…
15 unchanged lines
1244 1579
version = "1.70.2"
1245 1580
source = "registry+https://github.com/rust-lang/crates.io-index"
1246 1581
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
1582 +
1583 +
[[package]]
1584 +
name = "openssl-probe"
1585 +
version = "0.2.1"
1586 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1587 +
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
1247 1588
1248 1589
[[package]]
1249 1590
name = "oxy"
…
8 unchanged lines
1258 1599
 "libc",
1259 1600
 "oxy-core",
1260 1601
 "oxy-server",
1602 +
 "oxy-strata",
1261 1603
 "serde_json",
1262 1604
 "tempfile",
1263 1605
]
…
36 unchanged lines
1300 1642
]
1301 1643
1302 1644
[[package]]
1645 +
name = "oxy-strata"
1646 +
version = "0.3.1"
1647 +
dependencies = [
1648 +
 "futures",
1649 +
 "object_store",
1650 +
 "oxy-core",
1651 +
 "reqwest",
1652 +
 "rustls",
1653 +
 "serde_json",
1654 +
 "tempfile",
1655 +
 "tokio",
1656 +
]
1657 +
1658 +
[[package]]
1659 +
name = "parking_lot"
1660 +
version = "0.12.5"
1661 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1662 +
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
1663 +
dependencies = [
1664 +
 "lock_api",
1665 +
 "parking_lot_core",
1666 +
]
1667 +
1668 +
[[package]]
1669 +
name = "parking_lot_core"
1670 +
version = "0.9.12"
1671 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1672 +
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
1673 +
dependencies = [
1674 +
 "cfg-if",
1675 +
 "libc",
1676 +
 "redox_syscall",
1677 +
 "smallvec",
1678 +
 "windows-link",
1679 +
]
1680 +
1681 +
[[package]]
1303 1682
name = "pem"
1304 1683
version = "4.0.0"
1305 1684
source = "registry+https://github.com/rust-lang/crates.io-index"
…
96 unchanged lines
1402 1781
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
1403 1782
1404 1783
[[package]]
1784 +
name = "quick-xml"
1785 +
version = "0.41.0"
1786 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1787 +
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
1788 +
dependencies = [
1789 +
 "memchr",
1790 +
 "serde",
1791 +
]
1792 +
1793 +
[[package]]
1405 1794
name = "quote"
1406 1795
version = "1.0.47"
1407 1796
source = "registry+https://github.com/rust-lang/crates.io-index"
…
36 unchanged lines
1444 1833
]
1445 1834
1446 1835
[[package]]
1836 +
name = "rand"
1837 +
version = "0.10.3"
1838 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1839 +
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
1840 +
dependencies = [
1841 +
 "chacha20",
1842 +
 "getrandom 0.4.3",
1843 +
 "rand_core 0.10.1",
1844 +
]
1845 +
1846 +
[[package]]
1447 1847
name = "rand_chacha"
1448 1848
version = "0.3.1"
1449 1849
source = "registry+https://github.com/rust-lang/crates.io-index"
…
81 unchanged lines
1531 1931
]
1532 1932
1533 1933
[[package]]
1934 +
name = "redox_syscall"
1935 +
version = "0.5.18"
1936 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1937 +
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
1938 +
dependencies = [
1939 +
 "bitflags",
1940 +
]
1941 +
1942 +
[[package]]
1534 1943
name = "regex-automata"
1535 1944
version = "0.4.18"
1536 1945
source = "registry+https://github.com/rust-lang/crates.io-index"
…
11 unchanged lines
1548 1957
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
1549 1958
1550 1959
[[package]]
1960 +
name = "reqwest"
1961 +
version = "0.13.5"
1962 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1963 +
checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"
1964 +
dependencies = [
1965 +
 "base64 0.23.1",
1966 +
 "bytes",
1967 +
 "futures-core",
1968 +
 "futures-util",
1969 +
 "h2",
1970 +
 "http",
1971 +
 "http-body",
1972 +
 "http-body-util",
1973 +
 "hyper",
1974 +
 "hyper-rustls",
1975 +
 "hyper-util",
1976 +
 "js-sys",
1977 +
 "log",
1978 +
 "percent-encoding",
1979 +
 "pin-project-lite",
1980 +
 "rustls",
1981 +
 "rustls-pki-types",
1982 +
 "rustls-platform-verifier",
1983 +
 "sync_wrapper",
1984 +
 "tokio",
1985 +
 "tokio-rustls",
1986 +
 "tokio-util",
1987 +
 "tower",
1988 +
 "tower-http",
1989 +
 "tower-service",
1990 +
 "url",
1991 +
 "wasm-bindgen",
1992 +
 "wasm-bindgen-futures",
1993 +
 "wasm-streams",
1994 +
 "web-sys",
1995 +
]
1996 +
1997 +
[[package]]
1551 1998
name = "ring"
1552 1999
version = "0.17.14"
1553 2000
source = "registry+https://github.com/rust-lang/crates.io-index"
…
60 unchanged lines
1614 2061
]
1615 2062
1616 2063
[[package]]
2064 +
name = "rustls-native-certs"
2065 +
version = "0.8.4"
2066 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2067 +
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
2068 +
dependencies = [
2069 +
 "openssl-probe",
2070 +
 "rustls-pki-types",
2071 +
 "schannel",
2072 +
 "security-framework",
2073 +
]
2074 +
2075 +
[[package]]
1617 2076
name = "rustls-pemfile"
1618 2077
version = "2.2.0"
1619 2078
source = "registry+https://github.com/rust-lang/crates.io-index"
…
9 unchanged lines
1629 2088
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
1630 2089
dependencies = [
1631 2090
 "zeroize",
2091 +
]
2092 +
2093 +
[[package]]
2094 +
name = "rustls-platform-verifier"
2095 +
version = "0.7.0"
2096 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2097 +
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
2098 +
dependencies = [
2099 +
 "core-foundation",
2100 +
 "core-foundation-sys",
2101 +
 "jni",
2102 +
 "log",
2103 +
 "once_cell",
2104 +
 "rustls",
2105 +
 "rustls-native-certs",
2106 +
 "rustls-platform-verifier-android",
2107 +
 "rustls-webpki",
2108 +
 "security-framework",
2109 +
 "security-framework-sys",
2110 +
 "webpki-root-certs",
2111 +
 "windows-sys 0.52.0",
1632 2112
]
2113 +
2114 +
[[package]]
2115 +
name = "rustls-platform-verifier-android"
2116 +
version = "0.1.1"
2117 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2118 +
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
1633 2119
1634 2120
[[package]]
1635 2121
name = "rustls-webpki"
…
40 unchanged lines
1676 2162
]
1677 2163
1678 2164
[[package]]
2165 +
name = "schannel"
2166 +
version = "0.1.29"
2167 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2168 +
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
2169 +
dependencies = [
2170 +
 "windows-sys 0.61.2",
2171 +
]
2172 +
2173 +
[[package]]
2174 +
name = "scopeguard"
2175 +
version = "1.2.0"
2176 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2177 +
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
2178 +
2179 +
[[package]]
2180 +
name = "security-framework"
2181 +
version = "3.7.0"
2182 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2183 +
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
2184 +
dependencies = [
2185 +
 "bitflags",
2186 +
 "core-foundation",
2187 +
 "core-foundation-sys",
2188 +
 "libc",
2189 +
 "security-framework-sys",
2190 +
]
2191 +
2192 +
[[package]]
2193 +
name = "security-framework-sys"
2194 +
version = "2.17.0"
2195 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2196 +
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
2197 +
dependencies = [
2198 +
 "core-foundation-sys",
2199 +
 "libc",
2200 +
]
2201 +
2202 +
[[package]]
1679 2203
name = "semver"
1680 2204
version = "1.0.28"
1681 2205
source = "registry+https://github.com/rust-lang/crates.io-index"
…
73 unchanged lines
1755 2279
dependencies = [
1756 2280
 "cfg-if",
1757 2281
 "cpufeatures",
1758 -
 "digest",
2282 +
 "digest 0.11.3",
1759 2283
]
1760 2284
1761 2285
[[package]]
…
2 unchanged lines
1764 2288
source = "registry+https://github.com/rust-lang/crates.io-index"
1765 2289
checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a"
1766 2290
dependencies = [
1767 -
 "digest",
2291 +
 "digest 0.11.3",
1768 2292
 "keccak",
1769 2293
 "sponge-cursor",
1770 2294
]
…
10 unchanged lines
1781 2305
source = "registry+https://github.com/rust-lang/crates.io-index"
1782 2306
checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5"
1783 2307
dependencies = [
1784 -
 "digest",
2308 +
 "digest 0.11.3",
1785 2309
 "rand_core 0.10.1",
1786 2310
]
1787 2311
…
2 unchanged lines
1790 2314
version = "0.3.10"
1791 2315
source = "registry+https://github.com/rust-lang/crates.io-index"
1792 2316
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
2317 +
2318 +
[[package]]
2319 +
name = "simd_cesu8"
2320 +
version = "1.2.0"
2321 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2322 +
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
2323 +
dependencies = [
2324 +
 "rustc_version",
2325 +
 "simdutf8",
2326 +
]
2327 +
2328 +
[[package]]
2329 +
name = "simdutf8"
2330 +
version = "0.1.5"
2331 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2332 +
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
1793 2333
1794 2334
[[package]]
1795 2335
name = "similar"
…
24 unchanged lines
1820 2360
]
1821 2361
1822 2362
[[package]]
2363 +
name = "spin"
2364 +
version = "0.10.1"
2365 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2366 +
checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3"
2367 +
2368 +
[[package]]
1823 2369
name = "spki"
1824 2370
version = "0.8.0"
1825 2371
source = "registry+https://github.com/rust-lang/crates.io-index"
…
54 unchanged lines
1880 2426
version = "1.0.2"
1881 2427
source = "registry+https://github.com/rust-lang/crates.io-index"
1882 2428
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
2429 +
dependencies = [
2430 +
 "futures-core",
2431 +
]
1883 2432
1884 2433
[[package]]
1885 2434
name = "synstructure"
…
174 unchanged lines
2060 2609
 "tower-layer",
2061 2610
 "tower-service",
2062 2611
 "tracing",
2612 +
]
2613 +
2614 +
[[package]]
2615 +
name = "tower-http"
2616 +
version = "0.6.11"
2617 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2618 +
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
2619 +
dependencies = [
2620 +
 "bitflags",
2621 +
 "bytes",
2622 +
 "futures-util",
2623 +
 "http",
2624 +
 "http-body",
2625 +
 "pin-project-lite",
2626 +
 "tower",
2627 +
 "tower-layer",
2628 +
 "tower-service",
2629 +
 "url",
2063 2630
]
2064 2631
2065 2632
[[package]]
…
16 unchanged lines
2082 2649
dependencies = [
2083 2650
 "log",
2084 2651
 "pin-project-lite",
2652 +
 "tracing-attributes",
2085 2653
 "tracing-core",
2086 2654
]
2087 2655
2088 2656
[[package]]
2657 +
name = "tracing-attributes"
2658 +
version = "0.1.31"
2659 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2660 +
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
2661 +
dependencies = [
2662 +
 "proc-macro2",
2663 +
 "quote",
2664 +
 "syn 2.0.119",
2665 +
]
2666 +
2667 +
[[package]]
2089 2668
name = "tracing-core"
2090 2669
version = "0.1.36"
2091 2670
source = "registry+https://github.com/rust-lang/crates.io-index"
…
3 unchanged lines
2095 2674
]
2096 2675
2097 2676
[[package]]
2677 +
name = "try-lock"
2678 +
version = "0.2.5"
2679 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2680 +
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
2681 +
2682 +
[[package]]
2098 2683
name = "typenum"
2099 2684
version = "1.20.1"
2100 2685
source = "registry+https://github.com/rust-lang/crates.io-index"
…
64 unchanged lines
2165 2750
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
2166 2751
2167 2752
[[package]]
2753 +
name = "version_check"
2754 +
version = "0.9.5"
2755 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2756 +
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
2757 +
2758 +
[[package]]
2168 2759
name = "wait-timeout"
2169 2760
version = "0.2.1"
2170 2761
source = "registry+https://github.com/rust-lang/crates.io-index"
…
13 unchanged lines
2184 2775
]
2185 2776
2186 2777
[[package]]
2778 +
name = "want"
2779 +
version = "0.3.1"
2780 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2781 +
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
2782 +
dependencies = [
2783 +
 "try-lock",
2784 +
]
2785 +
2786 +
[[package]]
2187 2787
name = "wasi"
2188 2788
version = "0.11.1+wasi-snapshot-preview1"
2189 2789
source = "registry+https://github.com/rust-lang/crates.io-index"
…
19 unchanged lines
2209 2809
 "rustversion",
2210 2810
 "wasm-bindgen-macro",
2211 2811
 "wasm-bindgen-shared",
2812 +
]
2813 +
2814 +
[[package]]
2815 +
name = "wasm-bindgen-futures"
2816 +
version = "0.4.79"
2817 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2818 +
checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e"
2819 +
dependencies = [
2820 +
 "js-sys",
2821 +
 "tokio",
2822 +
 "wasm-bindgen",
2212 2823
]
2213 2824
2214 2825
[[package]]
…
29 unchanged lines
2244 2855
]
2245 2856
2246 2857
[[package]]
2858 +
name = "wasm-streams"
2859 +
version = "0.5.0"
2860 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2861 +
checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
2862 +
dependencies = [
2863 +
 "futures-util",
2864 +
 "js-sys",
2865 +
 "wasm-bindgen",
2866 +
 "wasm-bindgen-futures",
2867 +
 "web-sys",
2868 +
]
2869 +
2870 +
[[package]]
2871 +
name = "web-sys"
2872 +
version = "0.3.106"
2873 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2874 +
checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
2875 +
dependencies = [
2876 +
 "js-sys",
2877 +
 "wasm-bindgen",
2878 +
]
2879 +
2880 +
[[package]]
2247 2881
name = "web-time"
2248 2882
version = "1.1.0"
2249 2883
source = "registry+https://github.com/rust-lang/crates.io-index"
…
1 unchanged lines
2251 2885
dependencies = [
2252 2886
 "js-sys",
2253 2887
 "wasm-bindgen",
2888 +
]
2889 +
2890 +
[[package]]
2891 +
name = "webpki-root-certs"
2892 +
version = "1.0.9"
2893 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2894 +
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
2895 +
dependencies = [
2896 +
 "rustls-pki-types",
2254 2897
]
2255 2898
2256 2899
[[package]]
…
21 unchanged lines
2278 2921
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
2279 2922
dependencies = [
2280 2923
 "windows-sys 0.61.2",
2924 +
]
2925 +
2926 +
[[package]]
2927 +
name = "windows-core"
2928 +
version = "0.62.2"
2929 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2930 +
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
2931 +
dependencies = [
2932 +
 "windows-implement",
2933 +
 "windows-interface",
2934 +
 "windows-link",
2935 +
 "windows-result",
2936 +
 "windows-strings",
2281 2937
]
2282 2938
2283 2939
[[package]]
2940 +
name = "windows-implement"
2941 +
version = "0.60.2"
2942 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2943 +
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
2944 +
dependencies = [
2945 +
 "proc-macro2",
2946 +
 "quote",
2947 +
 "syn 2.0.119",
2948 +
]
2949 +
2950 +
[[package]]
2951 +
name = "windows-interface"
2952 +
version = "0.59.3"
2953 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2954 +
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
2955 +
dependencies = [
2956 +
 "proc-macro2",
2957 +
 "quote",
2958 +
 "syn 2.0.119",
2959 +
]
2960 +
2961 +
[[package]]
2284 2962
name = "windows-link"
2285 2963
version = "0.2.1"
2286 2964
source = "registry+https://github.com/rust-lang/crates.io-index"
2287 2965
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
2966 +
2967 +
[[package]]
2968 +
name = "windows-result"
2969 +
version = "0.4.1"
2970 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2971 +
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
2972 +
dependencies = [
2973 +
 "windows-link",
2974 +
]
2975 +
2976 +
[[package]]
2977 +
name = "windows-strings"
2978 +
version = "0.5.1"
2979 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2980 +
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
2981 +
dependencies = [
2982 +
 "windows-link",
2983 +
]
2288 2984
2289 2985
[[package]]
2290 2986
name = "windows-sys"
…
271 unchanged lines
modified Cargo.toml +2 −1
1 1
[workspace]
2 -
members = ["crates/oxy-core", "crates/oxy-server", "."]
2 +
members = ["crates/oxy-core", "crates/oxy-server", "crates/oxy-strata", "."]
3 3
resolver = "2"
4 4
5 5
[workspace.package]
…
16 unchanged lines
22 22
[dependencies]
23 23
oxy-core = { path = "crates/oxy-core" }
24 24
oxy-server = { path = "crates/oxy-server" }
25 +
oxy-strata = { path = "crates/oxy-strata" }
25 26
clap = { version = "4", features = ["derive"] }
26 27
anyhow = "1"
27 28
libc = "0.2"
…
8 unchanged lines
modified crates/oxy-core/src/protocol.rs +3 −3
…
531 unchanged lines
532 532
/// path made absolute (with its `file://` kept), so it still names the same
533 533
/// repository from inside the clone (oxygen#57).
534 534
pub fn stored_url(url: &str) -> String {
535 -
    if url.starts_with("http://") || url.starts_with("https://") {
536 -
        return url.to_string();
537 -
    }
535 +
    // Only a path is made absolute: a plain one, or one behind file:// or
536 +
    // dir://. Every other URL (a hub, a bucket) is kept as written.
538 537
    let (prefix, path) = match (url.strip_prefix("file://"), url.strip_prefix("dir://")) {
539 538
        (Some(p), _) => ("file://", p),
540 539
        (_, Some(p)) => ("dir://", p),
540 +
        _ if url.contains("://") => return url.to_string(),
541 541
        _ => ("", url),
542 542
    };
543 543
    match std::path::absolute(path) {
…
701 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +6 −0
…
6933 unchanged lines
6934 6934
        "https://hub.example/o/n"
6935 6935
    );
6936 6936
    assert!(crate::protocol::stored_url("file://x").starts_with("file:///"));
6937 +
    assert!(crate::protocol::stored_url("dir://x").starts_with("dir:///"));
6938 +
    // A bucket is kept as written: it is not a path.
6939 +
    assert_eq!(
6940 +
        crate::protocol::stored_url("s3://bucket/prefix?profile=p"),
6941 +
        "s3://bucket/prefix?profile=p"
6942 +
    );
6937 6943
    let url = root.join("origin").to_string_lossy().to_string();
6938 6944
    let b = Repo::clone(&url, &root.join("b")).unwrap();
6939 6945
    assert_eq!(b.config.remotes["origin"], url);
…
2 unchanged lines
modified docs/stores.md +21 −10
…
140 unchanged lines
141 141
It then retries the write. Uploading packs needs no locking: two writers
142 142
uploading the same objects is harmless.
143 143
144 -
Before building on it, check each provider's current documentation for
145 -
conditional writes: S3 added `If-Match` on `PutObject` in 2024, and this
146 -
design depends on it.
144 +
**As built (2026-10-04):** the `object_store` crate 0.14.2 carries both
145 +
writes as `PutMode::Create` and `PutMode::Update(UpdateVersion { e_tag,
146 +
version })`; S3 needs `S3ConditionalPut::ETagMatch` switched on, which
147 +
sends `If-None-Match: *` and `If-Match: <ETag>`; GCS uses the generation
148 +
(`ifGenerationMatch`); Azure the ETag. A store's `Version` carries both
149 +
the ETag and the generation, so the same code serves all three.
150 +
Checked against the real services on 2026-10-03/04: every conditional
151 +
write refuses as required on S3, GCS and Azure.
147 152
148 153
## 6. Safety
149 154
…
22 unchanged lines
172 177
  create-only refusals, compare-and-swap conflicts, concurrent pushers
173 178
  merging, an interrupted push leaving the remote valid, gc with a
174 179
  concurrent reader, and a repository-id mismatch being refused.
175 -
- **Emulators in CI** (Flow jobs have no network):
176 -
  - MinIO for S3, if its conditional writes match S3's, otherwise
177 -
    LocalStack;
178 -
  - `fake-gcs-server` for GCS.
179 -
- **Real buckets** (Andy's AWS and GCP accounts) for a nightly or
180 -
  manual test run. It needs network access, so it runs outside the
181 -
  isolated tier, with credentials scoped to a test prefix.
180 +
- **In CI** (Flow jobs have no network): the in-memory and directory
181 +
  stores run the conformance check and the whole push/clone/pull flow.
182 +
- **Real buckets** for the cloud stores, run by hand from a machine that
183 +
  holds restricted credentials: `crates/oxy-strata/tests/cloud.rs` reads
184 +
  `OXY_TEST_S3`, `OXY_TEST_GCS` and `OXY_TEST_AZURE` (each a store URL
185 +
  under a test prefix, with `OXY_TEST_S3_PROFILE`,
186 +
  `OXY_TEST_GCS_CREDENTIALS` and `OXY_TEST_AZURE_CREDENTIALS` naming the
187 +
  identity), runs the conformance check and a repository round trip under
188 +
  a prefix of its own, and deletes what it wrote. Unset, the tests say so
189 +
  and pass.
190 +
- **Emulators** were tried and dropped: MinIO's images are no longer
191 +
  published publicly, and `fake-gcs-server` rejects the client's XML
192 +
  uploads. The real services are the test.
182 193
183 194
## 8. Order of work
184 195
…
31 unchanged lines
modified src/cli/mod.rs +2 −0
…
997 unchanged lines
998 998
999 999
/// Parse arguments and run; returns the process exit code.
1000 1000
pub fn run() -> Result<i32> {
1001 +
    // The cloud stores (s3://, gs://, az://) for remotes kept on them.
1002 +
    oxy_strata::register();
1001 1003
    let cli = Cli::parse();
1002 1004
    ui::init(cli.plain);
1003 1005
    AT_OP.set(cli.at_op.clone()).ok();
…
314 unchanged lines