use std::sync::Arc;
use object_store::path::Path as ObjectPath;
use object_store::prefix::PrefixStore;
use object_store::ObjectStore;
use url::Url;
use zarrs::storage::storage_adapter::async_to_sync::{
AsyncToSyncBlockOn, AsyncToSyncStorageAdapter,
};
use zarrs_object_store::AsyncObjectStore;
use super::zarr_store::SanitizedV2;
use super::{AxisSelect, NativeDataset, OutputSchema, Selection};
use crate::error::{Error, Result};
use crate::s3_config::{
has_any, options_for_url, S3_CREDENTIAL_KEYS, S3_ENDPOINT_KEYS, S3_SIGNING_KEYS,
S3_STATIC_KEY_KEYS,
};
const ENV_PREFIXES: [&str; 3] = ["AWS_", "GOOGLE_", "AZURE_"];
fn os_err(detail: impl Into<String>) -> Error {
Error::Format {
format: "zarr".to_string(),
detail: detail.into(),
}
}
#[must_use]
pub fn store_options_from_env() -> Vec<(String, String)> {
std::env::vars()
.filter(|(k, _)| ENV_PREFIXES.iter().any(|p| k.starts_with(p)))
.map(|(k, v)| (k.to_ascii_lowercase(), v))
.collect()
}
fn is_s3_url(url_str: &str) -> bool {
url_str.starts_with("s3://") || url_str.starts_with("s3a://")
}
#[must_use]
pub fn read_store_options(url: &str, explicit: &[(String, String)]) -> Vec<(String, String)> {
let per_bucket = options_for_url(url);
let mut merged = store_options_from_env();
for (k, v) in per_bucket.iter().chain(explicit.iter()) {
merged.retain(|(mk, _)| mk != k);
merged.push((k.clone(), v.clone()));
}
if !is_s3_url(url) || has_any(&merged, &S3_SIGNING_KEYS) {
return merged;
}
let stated_for_this_read =
has_any(explicit, &S3_CREDENTIAL_KEYS) || has_any(&per_bucket, &S3_CREDENTIAL_KEYS);
let configured_s3_compatible_endpoint =
has_any(&merged, &S3_ENDPOINT_KEYS) && has_any(&merged, &S3_STATIC_KEY_KEYS);
if !stated_for_this_read && !configured_s3_compatible_endpoint {
merged.push(("aws_skip_signature".to_string(), "true".to_string()));
}
merged
}
fn apply_s3_defaults(url_str: &str, options: &mut Vec<(String, String)>) {
if !is_s3_url(url_str) {
return;
}
if !has_any(options, &S3_SIGNING_KEYS) && !has_any(options, &S3_CREDENTIAL_KEYS) {
options.push(("aws_skip_signature".to_string(), "true".to_string()));
}
if !has_any(options, &["region", "aws_region"]) {
options.push((
"aws_region".to_string(),
crate::transport::resolve_region(None),
));
}
}
struct TokioBlockOn(tokio::runtime::Handle);
impl AsyncToSyncBlockOn for TokioBlockOn {
fn block_on<F: core::future::Future>(&self, future: F) -> F::Output {
self.0.block_on(future)
}
}
type SyncObjectStore =
AsyncToSyncStorageAdapter<AsyncObjectStore<PrefixStore<Box<dyn ObjectStore>>>, TokioBlockOn>;
pub(crate) fn runtime() -> Result<tokio::runtime::Runtime> {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.map_err(|e| os_err(format!("build tokio runtime for object_store: {e}")))
}
pub(crate) fn resolve_backend(
url_str: &str,
options: &[(String, String)],
) -> Result<(Box<dyn ObjectStore>, ObjectPath)> {
let url =
Url::parse(url_str).map_err(|e| os_err(format!("invalid store URL '{url_str}': {e}")))?;
object_store::parse_url_opts(&url, options.iter().map(|(k, v)| (k.as_str(), v.as_str())))
.map_err(|e| os_err(format!("no object_store backend for '{url_str}': {e}")))
}
fn build_sync_store(
url_str: &str,
options: &[(String, String)],
handle: tokio::runtime::Handle,
) -> Result<Arc<SyncObjectStore>> {
let (store, prefix) = resolve_backend(url_str, options)?;
let prefixed = PrefixStore::new(store, prefix);
let async_store = Arc::new(AsyncObjectStore::new(prefixed));
Ok(Arc::new(AsyncToSyncStorageAdapter::new(
async_store,
TokioBlockOn(handle),
)))
}
fn build_read_store(
url_str: &str,
options: &[(String, String)],
handle: tokio::runtime::Handle,
) -> Result<Arc<SanitizedV2<SyncObjectStore>>> {
let mut opts = options.to_vec();
apply_s3_defaults(url_str, &mut opts);
let inner = build_sync_store(url_str, &opts, handle)?;
Ok(Arc::new(SanitizedV2::new(inner)))
}
pub fn array_shape_object_store(
url: &str,
var: &str,
options: &[(String, String)],
) -> Result<Vec<usize>> {
let rt = runtime()?;
let store = build_read_store(url, options, rt.handle().clone())?;
let array = super::zarr::open_array(store, var)?;
Ok(array.shape().iter().map(|&s| s as usize).collect())
}
pub fn read_zarr_object_store(
url: &str,
variables: &[String],
select: &Selection,
) -> Result<NativeDataset> {
read_zarr_object_store_with_options(url, variables, select, &read_store_options(url, &[]))
}
pub fn read_zarr_object_store_with_options(
url: &str,
variables: &[String],
select: &Selection,
options: &[(String, String)],
) -> Result<NativeDataset> {
if variables.is_empty() {
return Err(os_err(
"object-store zarr read requires an explicit list of variables",
));
}
let rt = runtime()?;
let store = build_read_store(url, options, rt.handle().clone())?;
let axes: Option<&[AxisSelect]> = match select {
Selection::Orthogonal(a) => Some(a.as_slice()),
_ => None,
};
super::zarr::read_arrays(store, variables, axes)
}
pub fn write_zarr_object_store(url: &str, schema: &OutputSchema) -> Result<()> {
write_zarr_object_store_with_options(url, schema, &store_options_from_env())
}
pub fn write_zarr_object_store_with_options(
url: &str,
schema: &OutputSchema,
options: &[(String, String)],
) -> Result<()> {
let rt = runtime()?;
let store = build_sync_store(url, options, rt.handle().clone())?;
super::zarr_write::write_all_to_store(store, url, schema)
}
#[cfg(test)]
mod tests {
use super::*;
use object_store::ObjectStoreExt;
use std::io::{Read, Write};
use std::net::TcpListener;
fn hermetic_opts() -> Vec<(String, String)> {
[
("aws_skip_signature", "true"),
("aws_region", "auto"),
("google_skip_signature", "true"),
("azure_skip_signature", "true"),
("azure_storage_account_name", "dispatchtest"),
]
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect()
}
#[test]
fn every_claimed_scheme_dispatches_to_a_backend() {
let opts = hermetic_opts();
let cases: &[(&str, &str)] = &[
("file:///tmp/some/store.zarr", "tmp/some/store.zarr"),
("memory:///datasets/run-1.zarr", "datasets/run-1.zarr"),
("s3://bucket/datasets/run-1.zarr", "datasets/run-1.zarr"),
("s3a://bucket/datasets/run-1.zarr", "datasets/run-1.zarr"),
("gs://bucket/datasets/run-1.zarr", "datasets/run-1.zarr"),
("az://container/datasets/run-1.zarr", "datasets/run-1.zarr"),
(
"abfs://container/datasets/run-1.zarr",
"datasets/run-1.zarr",
),
(
"abfss://container/datasets/run-1.zarr",
"datasets/run-1.zarr",
),
("adl://container/datasets/run-1.zarr", "datasets/run-1.zarr"),
(
"azure://container/datasets/run-1.zarr",
"datasets/run-1.zarr",
),
(
"http://example.org/datasets/run-1.zarr",
"datasets/run-1.zarr",
),
(
"https://example.org/datasets/run-1.zarr",
"datasets/run-1.zarr",
),
(
"https://bucket.s3.us-east-1.amazonaws.com/datasets/run-1.zarr",
"datasets/run-1.zarr",
),
(
"https://acct.r2.cloudflarestorage.com/bucket/datasets/run-1.zarr",
"datasets/run-1.zarr",
),
(
"https://acct.blob.core.windows.net/container/datasets/run-1.zarr",
"datasets/run-1.zarr",
),
];
for (url, want_prefix) in cases {
let (_store, prefix) = resolve_backend(url, &opts)
.unwrap_or_else(|e| panic!("scheme dispatch failed for {url}: {e}"));
assert_eq!(prefix.as_ref(), *want_prefix, "path prefix for {url}");
}
}
#[test]
fn an_unsupported_scheme_is_a_named_error() {
for url in ["ftp://host/store.zarr", "opfs:///datasets/run-1.zarr"] {
let err = resolve_backend(url, &[]).expect_err("should have no backend");
let msg = err.to_string();
assert!(msg.contains(url), "error should name the URL: {msg}");
}
}
#[test]
fn an_s3_endpoint_override_redirects_the_request() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback");
let port = listener.local_addr().unwrap().port();
let server = std::thread::spawn(move || {
let (mut sock, _) = listener.accept().expect("accept");
let mut buf = [0u8; 4096];
let n = sock.read(&mut buf).expect("read request");
let req = String::from_utf8_lossy(&buf[..n]).to_string();
sock.write_all(
b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\nETag: \"x\"\r\n\
Last-Modified: Thu, 01 Jan 1970 00:00:00 GMT\r\n\r\nhello",
)
.expect("write response");
let _ = sock.flush();
req
});
let endpoint = format!("http://127.0.0.1:{port}");
let opts: Vec<(String, String)> = [
("endpoint", endpoint.as_str()),
("region", "auto"),
("aws_allow_http", "true"),
("aws_skip_signature", "true"),
]
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect();
let (store, prefix) =
resolve_backend("s3://my-bucket/datasets/run-1.zarr", &opts).expect("s3 dispatch");
assert_eq!(prefix.as_ref(), "datasets/run-1.zarr");
let rt = runtime().expect("runtime");
let body = rt
.block_on(async move {
let key = prefix.join("zarr.json");
store.get(&key).await?.bytes().await
})
.expect("get through the overridden endpoint");
assert_eq!(&body[..], b"hello");
let req = server.join().expect("server thread");
let first_line = req.lines().next().unwrap_or_default().to_string();
assert!(
first_line.starts_with("GET /my-bucket/datasets/run-1.zarr/zarr.json "),
"request should be path-style against the custom endpoint, got: {first_line}"
);
assert!(
req.to_ascii_lowercase()
.contains(&format!("host: 127.0.0.1:{port}")),
"request should be addressed to the override host"
);
}
#[test]
fn an_s3_read_defaults_to_unsigned_and_regional() {
let mut opts = Vec::new();
apply_s3_defaults("s3://inmap-model/isrm_v1.2.1.zarr/", &mut opts);
assert_eq!(
opts.iter().find(|(k, _)| k == "aws_skip_signature"),
Some(&("aws_skip_signature".to_string(), "true".to_string())),
"an unauthenticated s3:// read must not try to sign"
);
assert!(
opts.iter().any(|(k, v)| k == "aws_region" && !v.is_empty()),
"object_store has no region fallback; the cached transport's chain supplies one"
);
}
#[test]
fn stated_credentials_and_other_schemes_are_left_alone() {
for stated in [
("aws_access_key_id", "AKIAEXAMPLE"),
("aws_skip_signature", "false"),
] {
let mut opts = vec![(stated.0.to_string(), stated.1.to_string())];
apply_s3_defaults("s3://private-bucket/store.zarr", &mut opts);
assert_eq!(
opts.iter()
.filter(|(k, _)| k == "aws_skip_signature")
.count(),
usize::from(stated.0 == "aws_skip_signature"),
"stating {} must not add a signing default",
stated.0
);
}
let mut opts = Vec::new();
apply_s3_defaults("https://example.org/store.zarr", &mut opts);
assert!(opts.is_empty(), "non-s3 schemes get no S3 defaults");
}
#[test]
fn env_options_are_prefix_scoped_and_lowercased() {
let _env = EnvScope::new(&[
("AWS_ENDPOINT_URL", "http://127.0.0.1:9000"),
("EARTHSCIIO_NOT_A_STORE_OPTION", "ignored"),
]);
let opts = store_options_from_env();
assert!(opts.iter().any(|(k, _)| k == "aws_endpoint_url"));
assert!(
!opts.iter().any(|(k, _)| k.contains("earthsciio")),
"only AWS_/GOOGLE_/AZURE_ variables are harvested"
);
}
use crate::test_env::EnvScope;
fn value<'a>(options: &'a [(String, String)], key: &str) -> Option<&'a str> {
options
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_str())
}
fn resolved_read_options(url: &str, explicit: &[(String, String)]) -> Vec<(String, String)> {
let mut options = read_store_options(url, explicit);
apply_s3_defaults(url, &mut options);
options
}
const PUBLIC_STORE: &str = "s3://inmap-model/isrm_v1.2.1.zarr/";
const ECS_ROLE_URI: &str = "/v2/credentials/00000000-0000-0000-0000-000000000000";
#[test]
fn an_injected_container_role_does_not_sign_a_public_read() {
let _env = EnvScope::new(&[("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", ECS_ROLE_URI)]);
let opts = resolved_read_options(PUBLIC_STORE, &[]);
assert_eq!(
value(&opts, "aws_skip_signature"),
Some("true"),
"a role the platform injected must not sign a public read"
);
assert!(
value(&opts, "aws_container_credentials_relative_uri").is_some(),
"the pointer is still passed through, so a caller who asks for \
signing authenticates with the ambient role"
);
}
#[test]
fn an_injected_web_identity_role_does_not_sign_a_public_read_either() {
let _env = EnvScope::new(&[
(
"AWS_ROLE_ARN",
"arn:aws:iam::000000000000:role/not-a-real-role",
),
(
"AWS_WEB_IDENTITY_TOKEN_FILE",
"/var/run/secrets/eks.amazonaws.com/serviceaccount/token",
),
]);
let opts = resolved_read_options(PUBLIC_STORE, &[]);
assert_eq!(
value(&opts, "aws_skip_signature"),
Some("true"),
"a service-account role the platform injected is not a stated intent"
);
}
#[test]
fn every_credential_and_endpoint_spelling_is_one_object_store_parses() {
use std::str::FromStr;
for key in S3_CREDENTIAL_KEYS
.iter()
.chain(S3_ENDPOINT_KEYS.iter())
.chain(S3_SIGNING_KEYS.iter())
.chain(S3_STATIC_KEY_KEYS.iter())
{
assert!(
object_store::aws::AmazonS3ConfigKey::from_str(key).is_ok(),
"'{key}' is not an option key object_store understands, so \
naming it here can only ever be a no-op"
);
}
for stated in ["container_credentials_full_uri", "token", "role_arn"] {
assert!(
S3_CREDENTIAL_KEYS.contains(&stated),
"'{stated}' is a credential object_store accepts and this list must see it"
);
}
}
#[test]
fn the_processs_own_write_credentials_do_not_sign_a_public_read() {
let _env = EnvScope::new(&[
("AWS_ACCESS_KEY_ID", "AKIAEXAMPLENOTREAL"),
("AWS_SECRET_ACCESS_KEY", "not-a-real-secret-for-tests"),
]);
let opts = resolved_read_options(PUBLIC_STORE, &[]);
assert_eq!(
value(&opts, "aws_skip_signature"),
Some("true"),
"ambient write credentials must not sign a public read"
);
}
#[test]
fn a_caller_that_asks_for_signed_access_gets_it() {
let _env = EnvScope::new(&[("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", ECS_ROLE_URI)]);
const PRIVATE_STORE: &str = "s3://private-bucket/store.zarr";
let stated = [("aws_skip_signature".to_string(), "false".to_string())];
let opts = resolved_read_options(PRIVATE_STORE, &stated);
assert_eq!(
value(&opts, "aws_skip_signature"),
Some("false"),
"no default may overturn a stated intent"
);
assert!(
value(&opts, "aws_container_credentials_relative_uri").is_some(),
"asking for signing must leave something to sign with"
);
let stated = [
(
"aws_access_key_id".to_string(),
"AKIAEXAMPLENOTREAL".to_string(),
),
(
"aws_secret_access_key".to_string(),
"not-a-real-secret-for-tests".to_string(),
),
];
let opts = resolved_read_options(PRIVATE_STORE, &stated);
assert_eq!(
value(&opts, "aws_skip_signature"),
None,
"credentials the caller stated ARE the caller stating intent"
);
drop(_env);
let _env = EnvScope::new(&[("AWS_SKIP_SIGNATURE", "false")]);
let opts = resolved_read_options(PUBLIC_STORE, &[]);
assert_eq!(
value(&opts, "aws_skip_signature"),
Some("false"),
"AWS_SKIP_SIGNATURE is not injected by anything: it is a statement"
);
}
#[test]
fn an_env_configured_s3_compatible_endpoint_still_signs() {
{
let _env = EnvScope::new(&[
(
"AWS_ENDPOINT_URL",
"https://account.r2.cloudflarestorage.com",
),
("AWS_ACCESS_KEY_ID", "AKIAEXAMPLENOTREAL"),
("AWS_SECRET_ACCESS_KEY", "not-a-real-secret-for-tests"),
]);
let opts = resolved_read_options("s3://my-bucket/store.zarr", &[]);
assert_eq!(
value(&opts, "aws_skip_signature"),
None,
"an operator-configured endpoint plus keys is a configured provider"
);
}
let _env = EnvScope::new(&[("AWS_ENDPOINT_URL", "http://127.0.0.1:9000")]);
let opts = resolved_read_options("s3://my-bucket/store.zarr", &[]);
assert_eq!(
value(&opts, "aws_skip_signature"),
Some("true"),
"an endpoint alone is still an anonymous read"
);
}
const REQUESTER_PAYS: &str = r#"{"inmap-model":{"aws_skip_signature":"false","aws_request_payer":"true","aws_region":"us-east-2"}}"#;
#[test]
fn a_named_bucket_outranks_the_environment() {
let _env = EnvScope::new(&[
("AWS_REGION", "eu-west-1"),
(crate::BUCKET_OPTIONS_ENV, REQUESTER_PAYS),
]);
let opts = resolved_read_options(PUBLIC_STORE, &[]);
assert_eq!(value(&opts, "aws_region"), Some("us-east-2"));
assert_eq!(value(&opts, "aws_request_payer"), Some("true"));
assert_eq!(
value(&opts, "aws_skip_signature"),
Some("false"),
"naming the bucket with a signing statement is stating intent"
);
}
#[test]
fn a_caller_still_outranks_a_named_bucket() {
let _env = EnvScope::new(&[(crate::BUCKET_OPTIONS_ENV, REQUESTER_PAYS)]);
let stated = [("aws_region".to_string(), "us-west-2".to_string())];
let opts = resolved_read_options(PUBLIC_STORE, &stated);
assert_eq!(value(&opts, "aws_region"), Some("us-west-2"));
assert_eq!(
value(&opts, "aws_request_payer"),
Some("true"),
"overriding one key must not discard the rest of the bucket's options"
);
}
#[test]
fn naming_a_bucket_to_pin_its_region_leaves_the_read_anonymous() {
let _env = EnvScope::new(&[
("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", ECS_ROLE_URI),
(
crate::BUCKET_OPTIONS_ENV,
r#"{"inmap-model":{"aws_region":"us-east-2"}}"#,
),
]);
let opts = resolved_read_options(PUBLIC_STORE, &[]);
assert_eq!(value(&opts, "aws_region"), Some("us-east-2"));
assert_eq!(
value(&opts, "aws_skip_signature"),
Some("true"),
"a region is not a statement about signing"
);
}
#[test]
fn a_credential_named_against_a_bucket_signs_that_bucket() {
let _env = EnvScope::new(&[(
crate::BUCKET_OPTIONS_ENV,
r#"{"private-bucket":{"aws_access_key_id":"AKIAEXAMPLENOTREAL","aws_secret_access_key":"not-a-real-secret-for-tests"}}"#,
)]);
let opts = resolved_read_options("s3://private-bucket/store.zarr", &[]);
assert_eq!(value(&opts, "aws_skip_signature"), None);
assert_eq!(value(&opts, "aws_access_key_id"), Some("AKIAEXAMPLENOTREAL"));
let opts = resolved_read_options(PUBLIC_STORE, &[]);
assert_eq!(value(&opts, "aws_skip_signature"), Some("true"));
assert_eq!(value(&opts, "aws_access_key_id"), None);
}
#[test]
fn an_unnamed_bucket_is_untouched() {
let _env = EnvScope::new(&[(crate::BUCKET_OPTIONS_ENV, REQUESTER_PAYS)]);
let opts = resolved_read_options("s3://some-other-bucket/store.zarr", &[]);
assert_eq!(value(&opts, "aws_request_payer"), None);
assert_eq!(value(&opts, "aws_skip_signature"), Some("true"));
}
#[test]
fn request_payer_is_a_key_object_store_parses() {
let opts: Vec<(String, String)> = [
("aws_request_payer", "true"),
("aws_region", "us-east-2"),
("aws_skip_signature", "false"),
]
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect();
assert!(
resolve_backend("s3://inmap-model/k", &opts).is_ok(),
"object_store must recognise aws_request_payer"
);
}
#[test]
fn a_malformed_spec_yields_no_options_here_and_is_refused_at_the_transport() {
let _env = EnvScope::new(&[(crate::BUCKET_OPTIONS_ENV, "{not json")]);
let opts = resolved_read_options(PUBLIC_STORE, &[]);
assert_eq!(value(&opts, "aws_request_payer"), None);
assert!(crate::bucket_options_from_env().is_err());
}
#[test]
fn the_read_default_is_not_applied_to_a_write() {
let _env = EnvScope::new(&[
("AWS_ACCESS_KEY_ID", "AKIAEXAMPLENOTREAL"),
("AWS_SECRET_ACCESS_KEY", "not-a-real-secret-for-tests"),
]);
let write_opts = store_options_from_env();
assert_eq!(
value(&write_opts, "aws_skip_signature"),
None,
"the write path signs with the process's own credentials"
);
assert_eq!(
value(
&resolved_read_options(PUBLIC_STORE, &[]),
"aws_skip_signature"
),
Some("true"),
"the read of a public store in the same process does not"
);
}
#[test]
fn an_injected_role_sends_an_unsigned_request() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback");
let port = listener.local_addr().unwrap().port();
let server = std::thread::spawn(move || {
let (mut sock, _) = listener.accept().expect("accept");
let mut buf = [0u8; 4096];
let n = sock.read(&mut buf).expect("read request");
let req = String::from_utf8_lossy(&buf[..n]).to_string();
sock.write_all(
b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\nETag: \"x\"\r\n\
Last-Modified: Thu, 01 Jan 1970 00:00:00 GMT\r\n\r\nhello",
)
.expect("write response");
let _ = sock.flush();
req
});
let endpoint = format!("http://127.0.0.1:{port}");
let _env = EnvScope::new(&[
("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", ECS_ROLE_URI),
("AWS_ENDPOINT_URL", &endpoint),
("AWS_ALLOW_HTTP", "true"),
]);
let url = "s3://inmap-model/isrm_v1.2.1.zarr/";
let mut opts = read_store_options(url, &[]);
apply_s3_defaults(url, &mut opts);
let (store, prefix) = resolve_backend(url, &opts).expect("s3 dispatch");
let rt = runtime().expect("runtime");
let body = rt
.block_on(async move {
let key = prefix.join(".zarray");
store.get(&key).await?.bytes().await
})
.expect("an anonymous GET, with no credential lookup on the way");
assert_eq!(&body[..], b"hello");
let req = server.join().expect("server thread").to_ascii_lowercase();
assert!(
!req.contains("authorization:"),
"a public read must go out unsigned; got:\n{req}"
);
assert!(
!req.contains("x-amz-security-token"),
"no session credential should have been fetched; got:\n{req}"
);
}
}