1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum ConfiguredObjectStoreKind {
16 LocalFs,
18 AwsS3,
20 CloudflareR2,
22 GcpGcs,
24 AzureAbs,
26}
27
28impl ConfiguredObjectStoreKind {
29 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#[derive(Debug)]
49pub struct ConfiguredObjectStore {
50 inner: SharedObjectStore,
51 transfer_issuer: Option<Arc<dyn ObjectTransferIssuer>>,
52}
53
54impl ConfiguredObjectStore {
55 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 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 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 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 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 pub fn transfer_issuer(&self) -> Option<Arc<dyn ObjectTransferIssuer>> {
134 self.transfer_issuer.clone()
135 }
136
137 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 #[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 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}