Skip to main content

sz_orm_storage/
lib.rs

1//! # SZ-ORM Storage — Object Storage
2//!
3//! Provides unified object storage abstraction, supports S3, Aliyun OSS, Qiniu Kodo, Huawei OBS,
4//! Tencent COS, UpYun, and local file system, configurable multi-provider via `StorageBuilder`.
5//!
6//! ## Production Readiness Notes
7//!
8//! The **production readiness grading** for each provider in this crate is as follows:
9//!
10//! | Provider | Module | Status |
11//! |---|---|---|
12//! | Local (local filesystem) | [`local`] | ✅ Production ready |
13//! | S3 (AWS S3 compatible) | `s3_sdk` module (feature = "s3-sdk") | ✅ Production ready (based on `rust-s3` crate) |
14//! | Aliyun OSS | `real` (feature = "real-cloud") | ✅ Production ready (based on OpenDAL) |
15//! | Tencent COS | `real` (feature = "real-cloud") | ✅ Production ready (based on OpenDAL) |
16//! | Huawei OBS | `real` (feature = "real-cloud") | ✅ Production ready (based on OpenDAL) |
17//! | UpYun | `real` (feature = "real-cloud") | ✅ Production ready (based on OpenDAL) |
18//! | Qiniu Kodo | `real` (feature = "real-cloud") | ✅ Production ready (official REST API + HMAC-SHA1) |
19//! | Above clouds (feature not enabled) | [`aliyun`] [`tencent`] [`qiniu`] [`huawei`] [`upyun`] [`s3`] | ⚠️ MOCK-ONLY (in-memory HashMap) |
20//!
21//! ## Using Real Cloud Storage
22//!
23//! ```toml
24//! [dependencies]
25//! sz-orm-storage = { version = "1.2", features = ["real-cloud"] }
26//! ```
27//!
28//! ```no_run
29//! use sz_orm_storage::{Storage, StorageBuilder, StorageProvider};
30//!
31//! # async fn demo() -> Result<(), sz_orm_storage::StorageError> {
32//! let storage = StorageBuilder::new(StorageProvider::AliyunOss(Default::default()))
33//!     .with_bucket("my-bucket")
34//!     .with_endpoint("oss-cn-hangzhou.aliyuncs.com")
35//!     .with_access_key("AK...")
36//!     .with_secret_key("SK...")
37//!     .build()?;
38//! let url = storage.put("a.txt", b"data", "text/plain").await?;
39//! # Ok(())
40//! # }
41//! ```
42//!
43//! When `real-cloud` is enabled, `StorageBuilder::build()` automatically constructs real cloud client;
44//! returns `StorageError::InvalidConfig` when credentials are not configured, does not silently degrade to Mock.
45//!
46//! ## Main Modules
47//!
48//! - [`storage`] — Unified trait and builder
49//! - `real` — Real cloud storage (feature = "real-cloud")
50//! - [`s3`] / [`aliyun`] / [`huawei`] / [`tencent`] / [`qiniu`] / [`upyun`] / [`local`] — Each provider implementation
51
52pub mod advanced;
53pub mod error;
54pub mod storage;
55
56#[cfg(feature = "storage-lifecycle")]
57pub mod lifecycle;
58
59pub mod aliyun;
60pub mod huawei;
61pub mod local;
62pub mod qiniu;
63pub mod s3;
64pub mod tencent;
65pub mod upyun;
66
67#[cfg(feature = "real-cloud")]
68pub mod real;
69
70// 高级存储功能(分片上传、断点续传、生命周期管理、CDN 刷新)
71pub use advanced::{
72    estimate_remaining_seconds, format_size, object_age_days, BucketLifecycle, CdnRefresher,
73    LifecycleAction, LifecycleEvaluationResult, LifecycleRule, MultipartUpload,
74    MultipartUploadSnapshot, Part, RefreshRequest, RefreshStatus, RefreshType,
75    ResumableUploadManager, UploadStatus,
76};
77
78pub use error::StorageError;
79pub use storage::*;
80
81pub use storage::StorageBuilder;
82pub use storage::StorageConfig;
83pub use storage::StorageProvider;
84pub use storage::StorageWrapper;
85
86pub use aliyun::AliyunOssStorage;
87pub use huawei::HuaweiObsStorage;
88pub use local::LocalStorage;
89pub use qiniu::QiniuKodoStorage;
90pub use s3::S3Storage;
91pub use tencent::TencentCosStorage;
92pub use upyun::UpYunStorage;
93
94#[cfg(feature = "real-cloud")]
95pub use real::{OpendalStorage, RealCloudStorage, RealQiniuKodoStorage};
96
97#[cfg(feature = "s3-sdk")]
98pub mod s3_sdk;
99
100#[cfg(feature = "cost-analysis")]
101pub mod cost;
102
103#[cfg(feature = "multicloud-cost-forecast")]
104pub mod multicloud_cost_forecast;
105
106#[cfg(feature = "s3-sdk")]
107pub use s3_sdk::S3SdkStorage;
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    /// Test data directory: prefer F:\test\data (user convention), fall back to environment variable or system temp (CI/Linux)
114    ///
115    /// Note: checking directory existence alone is insufficient to guarantee usability — writability must also be verified,
116    /// to avoid test failures in restricted sandbox environments where directory exists but is not writable.
117    fn test_data_base() -> std::path::PathBuf {
118        let f_drive = std::path::Path::new("F:\\test\\data");
119        if is_dir_writable(f_drive) {
120            return f_drive.to_path_buf();
121        }
122        if let Ok(dir) = std::env::var("SZ_ORM_TEST_DATA_DIR") {
123            let p = std::path::PathBuf::from(&dir);
124            if is_dir_writable(&p) {
125                return p;
126            }
127        }
128        std::env::temp_dir()
129    }
130
131    /// Check if directory exists and is writable: try creating and deleting a probe file in it
132    fn is_dir_writable(dir: &std::path::Path) -> bool {
133        if !dir.exists() {
134            return false;
135        }
136        let probe = dir.join(format!(".probe_{}", std::process::id()));
137        match std::fs::File::create(&probe) {
138            Ok(_) => {
139                let _ = std::fs::remove_file(&probe);
140                true
141            }
142            Err(_) => false,
143        }
144    }
145
146    #[tokio::test]
147    async fn test_local_storage_put_and_get() {
148        let temp_dir = test_data_base().join(format!("storage_test_{}", uuid_simple()));
149        let storage = LocalStorage::new(temp_dir.to_string_lossy());
150
151        let key = "test.txt";
152        let data = b"Hello, World!";
153        let content_type = "text/plain";
154
155        storage.put(key, data, content_type).await.unwrap();
156
157        let retrieved = storage.get(key).await.unwrap();
158        assert_eq!(retrieved, data);
159
160        tokio::fs::remove_dir_all(&temp_dir).await.ok();
161    }
162
163    #[tokio::test]
164    async fn test_local_storage_delete() {
165        let temp_dir = test_data_base().join(format!("storage_test_{}", uuid_simple()));
166        let storage = LocalStorage::new(temp_dir.to_string_lossy());
167
168        let key = "delete_me.txt";
169        storage.put(key, b"test", "text/plain").await.unwrap();
170
171        storage.delete(key).await.unwrap();
172
173        let exists = storage.exists(key).await.unwrap();
174        assert!(!exists);
175
176        tokio::fs::remove_dir_all(&temp_dir).await.ok();
177    }
178
179    #[tokio::test]
180    async fn test_local_storage_exists() {
181        let temp_dir = test_data_base().join(format!("storage_test_{}", uuid_simple()));
182        let storage = LocalStorage::new(temp_dir.to_string_lossy());
183
184        let key = "exists.txt";
185        assert!(!storage.exists(key).await.unwrap());
186
187        storage.put(key, b"test", "text/plain").await.unwrap();
188        assert!(storage.exists(key).await.unwrap());
189
190        tokio::fs::remove_dir_all(&temp_dir).await.ok();
191    }
192
193    #[tokio::test]
194    async fn test_local_storage_not_found() {
195        let temp_dir = test_data_base().join(format!("storage_test_{}", uuid_simple()));
196        let storage = LocalStorage::new(temp_dir.to_string_lossy());
197
198        let result = storage.get("nonexistent.txt").await;
199        assert!(result.is_err());
200        assert!(matches!(result.unwrap_err(), StorageError::NotFound(_)));
201    }
202
203    #[tokio::test]
204    async fn test_storage_builder_local() {
205        let builder = StorageBuilder::new(StorageProvider::Local)
206            .with_bucket("test-bucket")
207            .with_path_prefix("prefix/");
208
209        let wrapper = builder.build().unwrap();
210        assert!(matches!(wrapper, StorageWrapper::Local(_)));
211    }
212
213    #[tokio::test]
214    async fn test_storage_builder_s3() {
215        let builder = StorageBuilder::new(StorageProvider::S3(S3Config::default()))
216            .with_bucket("test-bucket")
217            .with_region("us-west-2")
218            .with_access_key("ak")
219            .with_secret_key("sk");
220
221        let wrapper = builder.build().unwrap();
222        assert!(matches!(wrapper, StorageWrapper::S3(_)));
223    }
224
225    #[cfg(not(feature = "real-cloud"))]
226    #[tokio::test]
227    async fn test_storage_builder_aliyun() {
228        let builder = StorageBuilder::new(StorageProvider::AliyunOss(AliyunConfig::default()))
229            .with_bucket("test-bucket")
230            .with_endpoint("oss-cn-hangzhou.aliyuncs.com")
231            .with_access_key("ak")
232            .with_secret_key("sk");
233
234        let wrapper = builder.build().unwrap();
235        assert!(matches!(wrapper, StorageWrapper::Aliyun(_)));
236    }
237
238    #[cfg(not(feature = "real-cloud"))]
239    #[tokio::test]
240    async fn test_storage_builder_tencent() {
241        let builder = StorageBuilder::new(StorageProvider::TencentCos(TencentConfig::default()))
242            .with_bucket("test-bucket")
243            .with_region("ap-guangzhou");
244
245        let wrapper = builder.build().unwrap();
246        assert!(matches!(wrapper, StorageWrapper::Tencent(_)));
247    }
248
249    #[cfg(not(feature = "real-cloud"))]
250    #[tokio::test]
251    async fn test_storage_builder_qiniu() {
252        let builder = StorageBuilder::new(StorageProvider::QiniuKodo(QiniuConfig::default()))
253            .with_bucket("test-bucket");
254
255        let wrapper = builder.build().unwrap();
256        assert!(matches!(wrapper, StorageWrapper::Qiniu(_)));
257    }
258
259    #[cfg(not(feature = "real-cloud"))]
260    #[tokio::test]
261    async fn test_storage_builder_huawei() {
262        let builder = StorageBuilder::new(StorageProvider::HuaweiObs(HuaweiConfig::default()))
263            .with_bucket("test-bucket")
264            .with_endpoint("obs.cn-north-4.myhuaweicloud.com");
265
266        let wrapper = builder.build().unwrap();
267        assert!(matches!(wrapper, StorageWrapper::Huawei(_)));
268    }
269
270    #[cfg(not(feature = "real-cloud"))]
271    #[tokio::test]
272    async fn test_storage_builder_upyun() {
273        let builder = StorageBuilder::new(StorageProvider::UpYun(UpYunConfig::default()))
274            .with_bucket("test-bucket");
275
276        let wrapper = builder.build().unwrap();
277        assert!(matches!(wrapper, StorageWrapper::Upyun(_)));
278    }
279
280    #[tokio::test]
281    async fn test_builder_s3_actually_works() {
282        let wrapper = StorageBuilder::new(StorageProvider::S3(S3Config::default()))
283            .with_bucket("test-bucket")
284            .with_region("us-west-2")
285            .build()
286            .unwrap();
287
288        let url = wrapper
289            .put("k.txt", b"s3-via-builder", "text/plain")
290            .await
291            .unwrap();
292        assert!(url.starts_with("s3://test-bucket.us-west-2/"));
293
294        let data = wrapper.get("k.txt").await.unwrap();
295        assert_eq!(data, b"s3-via-builder");
296
297        assert!(wrapper.exists("k.txt").await.unwrap());
298        wrapper.delete("k.txt").await.unwrap();
299        assert!(!wrapper.exists("k.txt").await.unwrap());
300    }
301
302    #[cfg(not(feature = "real-cloud"))]
303    #[tokio::test]
304    async fn test_builder_aliyun_actually_works() {
305        let wrapper = StorageBuilder::new(StorageProvider::AliyunOss(AliyunConfig::default()))
306            .with_bucket("bucket")
307            .with_endpoint("oss-cn-hangzhou.aliyuncs.com")
308            .build()
309            .unwrap();
310
311        wrapper
312            .put("k", b"aliyun-via-builder", "text/plain")
313            .await
314            .unwrap();
315        assert_eq!(wrapper.get("k").await.unwrap(), b"aliyun-via-builder");
316    }
317
318    #[cfg(not(feature = "real-cloud"))]
319    #[tokio::test]
320    async fn test_builder_tencent_actually_works() {
321        let wrapper = StorageBuilder::new(StorageProvider::TencentCos(TencentConfig::default()))
322            .with_bucket("bucket")
323            .with_region("ap-guangzhou")
324            .build()
325            .unwrap();
326
327        wrapper
328            .put("k", b"tencent-via-builder", "text/plain")
329            .await
330            .unwrap();
331        assert_eq!(wrapper.get("k").await.unwrap(), b"tencent-via-builder");
332        assert!(wrapper.exists("k").await.unwrap());
333    }
334
335    #[cfg(not(feature = "real-cloud"))]
336    #[tokio::test]
337    async fn test_builder_qiniu_actually_works() {
338        let wrapper = StorageBuilder::new(StorageProvider::QiniuKodo(QiniuConfig::default()))
339            .with_bucket("bucket")
340            .build()
341            .unwrap();
342
343        wrapper
344            .put("k", b"qiniu-via-builder", "text/plain")
345            .await
346            .unwrap();
347        assert_eq!(wrapper.get("k").await.unwrap(), b"qiniu-via-builder");
348    }
349
350    #[cfg(not(feature = "real-cloud"))]
351    #[tokio::test]
352    async fn test_builder_huawei_actually_works() {
353        let wrapper = StorageBuilder::new(StorageProvider::HuaweiObs(HuaweiConfig::default()))
354            .with_bucket("bucket")
355            .with_endpoint("obs.cn-north-4.myhuaweicloud.com")
356            .build()
357            .unwrap();
358
359        wrapper
360            .put("k", b"huawei-via-builder", "text/plain")
361            .await
362            .unwrap();
363        assert_eq!(wrapper.get("k").await.unwrap(), b"huawei-via-builder");
364    }
365
366    #[cfg(not(feature = "real-cloud"))]
367    #[tokio::test]
368    async fn test_builder_upyun_actually_works() {
369        let wrapper = StorageBuilder::new(StorageProvider::UpYun(UpYunConfig::default()))
370            .with_bucket("bucket")
371            .build()
372            .unwrap();
373
374        wrapper
375            .put("k", b"upyun-via-builder", "text/plain")
376            .await
377            .unwrap();
378        assert_eq!(wrapper.get("k").await.unwrap(), b"upyun-via-builder");
379    }
380
381    #[tokio::test]
382    async fn test_storage_wrapper_put() {
383        let temp_dir = test_data_base().join(format!("storage_test_{}", uuid_simple()));
384        let wrapper = StorageWrapper::Local(LocalStorage::new(temp_dir.to_string_lossy()));
385
386        let url = wrapper
387            .put("test.txt", b"data", "text/plain")
388            .await
389            .unwrap();
390        assert!(url.starts_with("local://"));
391
392        tokio::fs::remove_dir_all(&temp_dir).await.ok();
393    }
394
395    #[tokio::test]
396    async fn test_storage_wrapper_get() {
397        let temp_dir = test_data_base().join(format!("storage_test_{}", uuid_simple()));
398        let wrapper = StorageWrapper::Local(LocalStorage::new(temp_dir.to_string_lossy()));
399
400        wrapper
401            .put("test.txt", b"data", "text/plain")
402            .await
403            .unwrap();
404        let data = wrapper.get("test.txt").await.unwrap();
405        assert_eq!(data, b"data");
406
407        tokio::fs::remove_dir_all(&temp_dir).await.ok();
408    }
409
410    #[tokio::test]
411    async fn test_storage_config_default() {
412        let config = StorageConfig::default();
413        assert_eq!(config.bucket, "default-bucket");
414        assert_eq!(config.region, "us-east-1");
415    }
416
417    #[test]
418    fn test_storage_config_debug_masks_secrets() {
419        let config = StorageConfig {
420            bucket: "my-bucket".to_string(),
421            region: "us-east-1".to_string(),
422            endpoint: Some("https://example.com".to_string()),
423            access_key: Some("AKIAIOSFODNN7EXAMPLE".to_string()),
424            secret_key: Some("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string()),
425            path_prefix: Some("prefix/".to_string()),
426            base_path: Some("/tmp".to_string()),
427        };
428
429        let debug_output = format!("{:?}", config);
430
431        // 非敏感字段应正常输出
432        assert!(debug_output.contains("my-bucket"));
433        assert!(debug_output.contains("us-east-1"));
434        // 敏感字段必须被遮掩
435        assert!(debug_output.contains("\"***\""));
436        assert!(!debug_output.contains("AKIAIOSFODNN7EXAMPLE"));
437        assert!(!debug_output.contains("wJalrXUtnFEMI"));
438    }
439
440    #[cfg(feature = "real-cloud")]
441    #[tokio::test]
442    async fn test_real_cloud_requires_credentials() {
443        // real-cloud 下未配置凭据时必须返回 InvalidConfig,禁止静默降级为 Mock
444        for provider in [
445            StorageProvider::AliyunOss(AliyunConfig::default()),
446            StorageProvider::TencentCos(TencentConfig::default()),
447            StorageProvider::QiniuKodo(QiniuConfig::default()),
448            StorageProvider::HuaweiObs(HuaweiConfig::default()),
449            StorageProvider::UpYun(UpYunConfig::default()),
450        ] {
451            let result = StorageBuilder::new(provider)
452                .with_bucket("bucket")
453                .with_region("ap-guangzhou")
454                .with_endpoint("oss-cn-hangzhou.aliyuncs.com")
455                .build();
456            assert!(
457                matches!(result, Err(StorageError::InvalidConfig(_))),
458                "缺少凭据时应返回 InvalidConfig"
459            );
460        }
461    }
462
463    fn uuid_simple() -> String {
464        use std::time::{SystemTime, UNIX_EPOCH};
465        let now = SystemTime::now()
466            .duration_since(UNIX_EPOCH)
467            .unwrap_or_default()
468            .as_nanos();
469        format!("{:x}", now)
470    }
471}