Skip to main content

loonfs_objectstore/
configured.rs

1//! [`ConfiguredObjectStore`]: one provider client built from configuration,
2//! paired with the direct-transfer issuer that provider supports.
3
4use crate::abs::{AzureAbsStore, AzureAbsStoreConfig};
5use crate::gcs::{GcpGcsStore, GcpGcsStoreConfig};
6use crate::local_fs_store::LocalFsStore;
7use crate::object_store::{Result, SharedObjectStore};
8use crate::presign::{ObjectTransferIssuer, S3CompatiblePresigner, S3PresignerConfig};
9use crate::s3_compatible::{AwsS3StoreConfig, CloudflareR2StoreConfig, S3CompatibleStore};
10use std::path::PathBuf;
11use std::sync::Arc;
12
13/// Identifies the concrete provider backing a [`ConfiguredObjectStore`].
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum ConfiguredObjectStoreKind {
16    /// Stores objects beneath a Unix-family local filesystem root.
17    LocalFs,
18    /// Uses AWS S3 through its SigV4 API.
19    AwsS3,
20    /// Uses Cloudflare R2 through its S3-compatible API.
21    CloudflareR2,
22    /// Uses Google Cloud Storage's native generation-aware API.
23    GcpGcs,
24    /// Uses Azure Blob Storage's native shared-key API.
25    AzureAbs,
26}
27
28impl ConfiguredObjectStoreKind {
29    /// Returns the stable kebab-case label, matching the `kind` tag used in
30    /// config files.
31    pub fn as_str(self) -> &'static str {
32        match self {
33            Self::LocalFs => "local-fs",
34            Self::AwsS3 => "aws-s3",
35            Self::CloudflareR2 => "cloudflare-r2",
36            Self::GcpGcs => "gcp-gcs",
37            Self::AzureAbs => "azure-abs",
38        }
39    }
40}
41
42/// One validated runtime provider, plus the transfer issuer it supports.
43///
44/// Construction is the only thing this type adds: it holds the provider
45/// client as the same shared trait object every handle takes, so callers
46/// dispatch through the provider's own [`ObjectStore`](crate::ObjectStore)
47/// implementation rather than through a copy of it here.
48#[derive(Debug)]
49pub struct ConfiguredObjectStore {
50    inner: SharedObjectStore,
51    transfer_issuer: Option<Arc<dyn ObjectTransferIssuer>>,
52}
53
54impl ConfiguredObjectStore {
55    /// Opens a local-filesystem provider with optional logical key scoping.
56    ///
57    /// Construction fails outside Unix-family platforms, when the root cannot
58    /// be created, or when `key_prefix` is invalid.
59    pub fn local_fs(root: impl Into<PathBuf>, key_prefix: Option<&str>) -> Result<Self> {
60        Ok(Self {
61            inner: Arc::new(LocalFsStore::with_key_prefix(root, key_prefix)?),
62            transfer_issuer: None,
63        })
64    }
65
66    /// Builds an AWS S3 store and matching direct-transfer presigner.
67    ///
68    /// Construction fails when signing, provider, runtime, or key-prefix configuration is invalid.
69    pub fn aws_s3(config: AwsS3StoreConfig) -> Result<Self> {
70        let transfer_issuer = Some(Arc::new(S3CompatiblePresigner::new(S3PresignerConfig {
71            bucket: config.bucket.clone(),
72            region: config.region.clone(),
73            endpoint_url: config.endpoint_url.clone(),
74            access_key_id: config.access_key_id.clone(),
75            secret_access_key: config.secret_access_key.clone(),
76            session_token: config.session_token.clone(),
77            key_prefix: config.key_prefix.clone(),
78            force_path_style: config.force_path_style,
79        })?) as Arc<dyn ObjectTransferIssuer>);
80        let store = S3CompatibleStore::aws_s3(config)?;
81        Ok(Self {
82            inner: Arc::new(store),
83            transfer_issuer,
84        })
85    }
86
87    /// Builds a Cloudflare R2 store and matching direct-transfer presigner.
88    ///
89    /// Construction fails when signing, provider, runtime, or key-prefix configuration is invalid.
90    pub fn cloudflare_r2(config: CloudflareR2StoreConfig) -> Result<Self> {
91        let transfer_issuer = Some(Arc::new(S3CompatiblePresigner::new(S3PresignerConfig {
92            bucket: config.bucket.clone(),
93            region: "auto".to_owned(),
94            endpoint_url: Some(config.endpoint_url.clone()),
95            access_key_id: config.access_key_id.clone(),
96            secret_access_key: config.secret_access_key.clone(),
97            session_token: None,
98            key_prefix: config.key_prefix.clone(),
99            force_path_style: true,
100        })?) as Arc<dyn ObjectTransferIssuer>);
101        let store = S3CompatibleStore::cloudflare_r2(config)?;
102        Ok(Self {
103            inner: Arc::new(store),
104            transfer_issuer,
105        })
106    }
107
108    /// Builds a native GCS store without direct-transfer issuance.
109    ///
110    /// Construction fails when credentials, provider runtime, or key-prefix configuration is invalid.
111    pub fn gcp_gcs(config: GcpGcsStoreConfig) -> Result<Self> {
112        let store = GcpGcsStore::new(config)?;
113        Ok(Self {
114            inner: Arc::new(store),
115            transfer_issuer: None,
116        })
117    }
118
119    /// Builds a native Azure Blob store without direct-transfer issuance.
120    ///
121    /// Construction fails when credentials, provider runtime, or key-prefix configuration is invalid.
122    pub fn azure_abs(config: AzureAbsStoreConfig) -> Result<Self> {
123        let store = AzureAbsStore::new(config)?;
124        Ok(Self {
125            inner: Arc::new(store),
126            transfer_issuer: None,
127        })
128    }
129
130    /// Returns a direct-upload issuer for supported S3-compatible providers.
131    ///
132    /// Local filesystem, GCS, and Azure stores return `None`.
133    pub fn transfer_issuer(&self) -> Option<Arc<dyn ObjectTransferIssuer>> {
134        self.transfer_issuer.clone()
135    }
136
137    /// Hands out the provider client every handle and helper reads through.
138    ///
139    /// Take [`transfer_issuer`](Self::transfer_issuer) first: this consumes
140    /// the configured store.
141    pub fn into_shared(self) -> SharedObjectStore {
142        self.inner
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::ConfiguredObjectStore;
149    use crate::abs::AzureAbsStoreConfig;
150    use crate::gcs::GcpGcsStoreConfig;
151    use crate::keys::wal_head;
152    use crate::local_fs_store::LocalFsStore;
153    use crate::presign::PresignedPutRequest;
154    use crate::s3_compatible::{AwsS3StoreConfig, CloudflareR2StoreConfig};
155    use crate::ObjectStore;
156    use crate::ObjectStoreError;
157    use bytes::Bytes;
158    use loonfs_api::ContentId;
159    use loonfs_api::ContentRef;
160    use std::fs;
161    use std::path::PathBuf;
162    use std::time::{Duration, SystemTime, UNIX_EPOCH};
163
164    const AZURITE_ACCOUNT_KEY: &str =
165        "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==";
166    const FAKE_GCS_SERVICE_ACCOUNT_KEY: &str = r#"{"private_key":"private_key","private_key_id":"private_key_id","client_email":"client_email","disable_oauth":true}"#;
167
168    #[tokio::test]
169    async fn configured_local_fs_scopes_optional_key_prefix() {
170        let temp_dir = unique_temp_dir("configured-store-local");
171        let store = ConfiguredObjectStore::local_fs(&temp_dir, Some("tenant-a"))
172            .expect("construct configured local fs store")
173            .into_shared();
174        let head_key = wal_head("ns-1");
175
176        store
177            .put_overwrite(&head_key, Bytes::from_static(br#"{"ok":true}"#))
178            .await
179            .expect("write scoped object");
180
181        let raw_store = LocalFsStore::new(&temp_dir).expect("open raw store");
182        assert!(raw_store
183            .head(&format!("tenant-a/{head_key}"))
184            .await
185            .expect("head raw scoped object")
186            .is_some());
187        assert_eq!(
188            store
189                .list_prefix("namespaces/ns-1/")
190                .await
191                .expect("list scoped prefix"),
192            vec![head_key]
193        );
194    }
195
196    /// Only the S3-compatible providers can presign, so only they carry a
197    /// direct-transfer issuer.
198    #[test]
199    fn configured_object_store_issues_transfers_for_s3_compatible_providers_only() {
200        let local = ConfiguredObjectStore::local_fs(unique_temp_dir("configured-store-kind"), None)
201            .expect("construct local store");
202        assert!(local.transfer_issuer().is_none());
203
204        let s3 = ConfiguredObjectStore::aws_s3(AwsS3StoreConfig {
205            bucket: "bucket".to_owned(),
206            region: "us-east-1".to_owned(),
207            endpoint_url: Some("http://127.0.0.1:9000".to_owned()),
208            access_key_id: "access".into(),
209            secret_access_key: "secret".into(),
210            session_token: None,
211            key_prefix: Some("tenant-a".to_owned()),
212            force_path_style: true,
213        })
214        .expect("construct s3 store");
215        assert!(s3.transfer_issuer().is_some());
216
217        let r2 = ConfiguredObjectStore::cloudflare_r2(CloudflareR2StoreConfig {
218            bucket: "bucket".to_owned(),
219            account_id: "account".to_owned(),
220            endpoint_url: "https://example.r2.cloudflarestorage.com".to_owned(),
221            access_key_id: "debug-access-key".into(),
222            secret_access_key: "secret".into(),
223            key_prefix: Some("tenant-a".to_owned()),
224        })
225        .expect("construct r2 store");
226        assert!(r2.transfer_issuer().is_some());
227
228        let gcs_service_account_key_path =
229            fake_gcs_service_account_key_file("configured-store-gcs-kind");
230        let gcs = ConfiguredObjectStore::gcp_gcs(GcpGcsStoreConfig {
231            bucket: "bucket".to_owned(),
232            service_account_key_path: gcs_service_account_key_path.display().to_string(),
233            key_prefix: Some("tenant-a".to_owned()),
234        })
235        .expect("construct gcs store");
236        assert!(gcs.transfer_issuer().is_none());
237
238        let azure = ConfiguredObjectStore::azure_abs(AzureAbsStoreConfig {
239            account_name: "devstoreaccount1".to_owned(),
240            container_name: "container".to_owned(),
241            access_key: AZURITE_ACCOUNT_KEY.into(),
242            endpoint_url: None,
243            key_prefix: Some("tenant-a".to_owned()),
244        })
245        .expect("construct azure store");
246        assert!(azure.transfer_issuer().is_none());
247    }
248
249    #[test]
250    fn cloudflare_r2_presigner_uses_path_style_account_endpoint() {
251        let store = ConfiguredObjectStore::cloudflare_r2(CloudflareR2StoreConfig {
252            bucket: "bucket".to_owned(),
253            account_id: "account".to_owned(),
254            endpoint_url: "https://account.r2.cloudflarestorage.com".to_owned(),
255            access_key_id: "access".into(),
256            secret_access_key: "secret".into(),
257            key_prefix: Some("tenant-a".to_owned()),
258        })
259        .expect("construct r2 store");
260        let issuer = store.transfer_issuer().expect("r2 presigner");
261
262        let signed = issuer
263            .presign_put(
264                PresignedPutRequest {
265                    object_key: "content-stores/cs/objects/01/con_0123456789abcdef0123456789abcdef",
266                    content_ref: &ContentRef::blob_v1(ContentId::generate(), b"hello"),
267                    expires_in: Duration::from_secs(900),
268                },
269                UNIX_EPOCH + Duration::from_secs(1_700_000_000),
270            )
271            .expect("presign");
272
273        assert!(signed.url.starts_with(
274            "https://account.r2.cloudflarestorage.com/bucket/tenant-a/content-stores/"
275        ));
276        assert!(!signed.url.starts_with("https://bucket.account."));
277    }
278
279    #[test]
280    fn configured_object_store_debug_redacts_presigner_credentials() {
281        let store = ConfiguredObjectStore::cloudflare_r2(CloudflareR2StoreConfig {
282            bucket: "bucket".to_owned(),
283            account_id: "account".to_owned(),
284            endpoint_url: "https://account.r2.cloudflarestorage.com".to_owned(),
285            access_key_id: "access".into(),
286            secret_access_key: "debug-secret".into(),
287            key_prefix: Some("tenant-a".to_owned()),
288        })
289        .expect("construct r2 store");
290
291        let rendered = format!("{store:?}");
292
293        assert!(!rendered.contains("debug-secret"));
294        assert!(!rendered.contains("debug-access-key"));
295    }
296
297    #[tokio::test]
298    async fn configured_local_fs_preserves_invalid_key_errors() {
299        let temp_dir = unique_temp_dir("configured-store-invalid-key");
300        let store = ConfiguredObjectStore::local_fs(&temp_dir, Some("tenant-a"))
301            .expect("construct configured local fs store")
302            .into_shared();
303
304        let error = store
305            .put_overwrite("../head.json", Bytes::from_static(br#"{"ok":true}"#))
306            .await
307            .expect_err("traversal key should be rejected");
308        assert!(matches!(
309            error,
310            ObjectStoreError::InvalidKey { object_key, .. } if object_key == "../head.json"
311        ));
312    }
313
314    #[allow(clippy::disallowed_methods)]
315    fn unique_temp_dir(label: &str) -> PathBuf {
316        // Test-only unique paths are an entropy boundary, not protocol time.
317        let stamp = SystemTime::now()
318            .duration_since(UNIX_EPOCH)
319            .expect("clock after epoch")
320            .as_nanos();
321        let path = std::env::temp_dir().join(format!("loonfs-objectstore-{label}-{stamp}"));
322        fs::create_dir_all(&path).expect("create temp dir");
323        path
324    }
325
326    fn fake_gcs_service_account_key_file(label: &str) -> PathBuf {
327        let path = unique_temp_dir(label).join("service-account.json");
328        fs::write(&path, FAKE_GCS_SERVICE_ACCOUNT_KEY).expect("write fake service account key");
329        path
330    }
331}