Skip to main content

sz_orm_storage/
lib.rs

1//! # SZ-ORM Storage — 对象存储
2//!
3//! 提供统一的对象存储抽象,支持 S3、阿里云 OSS、七牛 Kodo、华为 OBS、
4//! 腾讯 COS、又拍云以及本地文件系统,可通过 `StorageBuilder` 配置多 provider。
5//!
6//! ## ⚠️ 生产可用性说明(**重要**)
7//!
8//! 本 crate 中各 provider 的**生产可用性分级**如下:
9//!
10//! | Provider | 模块 | 状态 |
11//! |---|---|---|
12//! | Local(本地文件系统) | [`local`] | ✅ 生产可用 |
13//! | S3(AWS S3 兼容) | [`s3_sdk`](feature = "s3-sdk") | ✅ 生产可用(基于 `rust-s3` crate) |
14//! | S3(默认) | [`s3`] | ⚠️ **MOCK-ONLY**(内存 HashMap) |
15//! | Aliyun OSS | [`aliyun`] | ⚠️ **MOCK-ONLY**(内存 HashMap) |
16//! | Huawei OBS | [`huawei`] | ⚠️ **MOCK-ONLY**(内存 HashMap) |
17//! | Qiniu Kodo | [`qiniu`] | ⚠️ **MOCK-ONLY**(内存 HashMap) |
18//! | Tencent COS | [`tencent`] | ⚠️ **MOCK-ONLY**(内存 HashMap) |
19//! | UpYun | [`upyun`] | ⚠️ **MOCK-ONLY**(内存 HashMap) |
20//!
21//! **6 个云存储 provider(aliyun/huawei/qiniu/tencent/upyun/s3 默认)为内存 Mock 实现,
22//! 不与真实云服务交互,请勿用于生产环境。**
23//! 如需生产使用,请:
24//! - S3:启用 `s3-sdk` feature 使用 [`s3_sdk::S3SdkStorage`]
25//! - 其他云:基于 [`storage::Storage`] trait 接入官方 SDK 自行实现
26//!
27//! ## 主要模块
28//!
29//! - [`storage`] — 统一 trait 与构建器
30//! - [`s3`] / [`aliyun`] / [`huawei`] / [`tencent`] / [`qiniu`] / [`upyun`] / [`local`] — 各 provider 实现
31
32pub mod advanced;
33pub mod error;
34pub mod storage;
35
36pub mod aliyun;
37pub mod huawei;
38pub mod local;
39pub mod qiniu;
40pub mod s3;
41pub mod tencent;
42pub mod upyun;
43
44// 高级存储功能(分片上传、断点续传、生命周期管理、CDN 刷新)
45pub use advanced::{
46    estimate_remaining_seconds, format_size, object_age_days, BucketLifecycle, CdnRefresher,
47    LifecycleAction, LifecycleEvaluationResult, LifecycleRule, MultipartUpload,
48    MultipartUploadSnapshot, Part, RefreshRequest, RefreshStatus, RefreshType,
49    ResumableUploadManager, UploadStatus,
50};
51
52pub use error::StorageError;
53pub use storage::*;
54
55pub use storage::StorageBuilder;
56pub use storage::StorageConfig;
57pub use storage::StorageProvider;
58pub use storage::StorageWrapper;
59
60pub use aliyun::AliyunOssStorage;
61pub use huawei::HuaweiObsStorage;
62pub use local::LocalStorage;
63pub use qiniu::QiniuKodoStorage;
64pub use s3::S3Storage;
65pub use tencent::TencentCosStorage;
66pub use upyun::UpYunStorage;
67
68#[cfg(feature = "s3-sdk")]
69pub mod s3_sdk;
70
71#[cfg(feature = "s3-sdk")]
72pub use s3_sdk::S3SdkStorage;
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    /// 测试数据目录:优先 F:\test\data(用户规范),回退到环境变量或系统 temp(CI/Linux)
79    ///
80    /// 注意:仅检查目录存在不足以保证可用——还需验证可写性,
81    /// 以避免在受限沙箱环境中因目录存在但不可写导致测试失败。
82    fn test_data_base() -> std::path::PathBuf {
83        let f_drive = std::path::Path::new("F:\\test\\data");
84        if is_dir_writable(f_drive) {
85            return f_drive.to_path_buf();
86        }
87        if let Ok(dir) = std::env::var("SZ_ORM_TEST_DATA_DIR") {
88            let p = std::path::PathBuf::from(&dir);
89            if is_dir_writable(&p) {
90                return p;
91            }
92        }
93        std::env::temp_dir()
94    }
95
96    /// 检查目录是否存在且可写:尝试在其中创建并删除一个探测文件
97    fn is_dir_writable(dir: &std::path::Path) -> bool {
98        if !dir.exists() {
99            return false;
100        }
101        let probe = dir.join(format!(".probe_{}", std::process::id()));
102        match std::fs::File::create(&probe) {
103            Ok(_) => {
104                let _ = std::fs::remove_file(&probe);
105                true
106            }
107            Err(_) => false,
108        }
109    }
110
111    #[tokio::test]
112    async fn test_local_storage_put_and_get() {
113        let temp_dir = test_data_base().join(format!("storage_test_{}", uuid_simple()));
114        let storage = LocalStorage::new(temp_dir.to_string_lossy());
115
116        let key = "test.txt";
117        let data = b"Hello, World!";
118        let content_type = "text/plain";
119
120        storage.put(key, data, content_type).await.unwrap();
121
122        let retrieved = storage.get(key).await.unwrap();
123        assert_eq!(retrieved, data);
124
125        tokio::fs::remove_dir_all(&temp_dir).await.ok();
126    }
127
128    #[tokio::test]
129    async fn test_local_storage_delete() {
130        let temp_dir = test_data_base().join(format!("storage_test_{}", uuid_simple()));
131        let storage = LocalStorage::new(temp_dir.to_string_lossy());
132
133        let key = "delete_me.txt";
134        storage.put(key, b"test", "text/plain").await.unwrap();
135
136        storage.delete(key).await.unwrap();
137
138        let exists = storage.exists(key).await.unwrap();
139        assert!(!exists);
140
141        tokio::fs::remove_dir_all(&temp_dir).await.ok();
142    }
143
144    #[tokio::test]
145    async fn test_local_storage_exists() {
146        let temp_dir = test_data_base().join(format!("storage_test_{}", uuid_simple()));
147        let storage = LocalStorage::new(temp_dir.to_string_lossy());
148
149        let key = "exists.txt";
150        assert!(!storage.exists(key).await.unwrap());
151
152        storage.put(key, b"test", "text/plain").await.unwrap();
153        assert!(storage.exists(key).await.unwrap());
154
155        tokio::fs::remove_dir_all(&temp_dir).await.ok();
156    }
157
158    #[tokio::test]
159    async fn test_local_storage_not_found() {
160        let temp_dir = test_data_base().join(format!("storage_test_{}", uuid_simple()));
161        let storage = LocalStorage::new(temp_dir.to_string_lossy());
162
163        let result = storage.get("nonexistent.txt").await;
164        assert!(result.is_err());
165        assert!(matches!(result.unwrap_err(), StorageError::NotFound(_)));
166    }
167
168    #[tokio::test]
169    async fn test_storage_builder_local() {
170        let builder = StorageBuilder::new(StorageProvider::Local)
171            .with_bucket("test-bucket")
172            .with_path_prefix("prefix/");
173
174        let wrapper = builder.build().unwrap();
175        assert!(matches!(wrapper, StorageWrapper::Local(_)));
176    }
177
178    #[tokio::test]
179    async fn test_storage_builder_s3() {
180        let builder = StorageBuilder::new(StorageProvider::S3(S3Config::default()))
181            .with_bucket("test-bucket")
182            .with_region("us-west-2")
183            .with_access_key("ak")
184            .with_secret_key("sk");
185
186        let wrapper = builder.build().unwrap();
187        assert!(matches!(wrapper, StorageWrapper::S3(_)));
188    }
189
190    #[tokio::test]
191    async fn test_storage_builder_aliyun() {
192        let builder = StorageBuilder::new(StorageProvider::AliyunOss(AliyunConfig::default()))
193            .with_bucket("test-bucket")
194            .with_endpoint("oss-cn-hangzhou.aliyuncs.com")
195            .with_access_key("ak")
196            .with_secret_key("sk");
197
198        let wrapper = builder.build().unwrap();
199        assert!(matches!(wrapper, StorageWrapper::Aliyun(_)));
200    }
201
202    #[tokio::test]
203    async fn test_storage_builder_tencent() {
204        let builder = StorageBuilder::new(StorageProvider::TencentCos(TencentConfig::default()))
205            .with_bucket("test-bucket")
206            .with_region("ap-guangzhou");
207
208        let wrapper = builder.build().unwrap();
209        assert!(matches!(wrapper, StorageWrapper::Tencent(_)));
210    }
211
212    #[tokio::test]
213    async fn test_storage_builder_qiniu() {
214        let builder = StorageBuilder::new(StorageProvider::QiniuKodo(QiniuConfig::default()))
215            .with_bucket("test-bucket");
216
217        let wrapper = builder.build().unwrap();
218        assert!(matches!(wrapper, StorageWrapper::Qiniu(_)));
219    }
220
221    #[tokio::test]
222    async fn test_storage_builder_huawei() {
223        let builder = StorageBuilder::new(StorageProvider::HuaweiObs(HuaweiConfig::default()))
224            .with_bucket("test-bucket")
225            .with_endpoint("obs.cn-north-4.myhuaweicloud.com");
226
227        let wrapper = builder.build().unwrap();
228        assert!(matches!(wrapper, StorageWrapper::Huawei(_)));
229    }
230
231    #[tokio::test]
232    async fn test_storage_builder_upyun() {
233        let builder = StorageBuilder::new(StorageProvider::UpYun(UpYunConfig::default()))
234            .with_bucket("test-bucket");
235
236        let wrapper = builder.build().unwrap();
237        assert!(matches!(wrapper, StorageWrapper::Upyun(_)));
238    }
239
240    #[tokio::test]
241    async fn test_builder_s3_actually_works() {
242        let wrapper = StorageBuilder::new(StorageProvider::S3(S3Config::default()))
243            .with_bucket("test-bucket")
244            .with_region("us-west-2")
245            .build()
246            .unwrap();
247
248        let url = wrapper
249            .put("k.txt", b"s3-via-builder", "text/plain")
250            .await
251            .unwrap();
252        assert!(url.starts_with("s3://test-bucket.us-west-2/"));
253
254        let data = wrapper.get("k.txt").await.unwrap();
255        assert_eq!(data, b"s3-via-builder");
256
257        assert!(wrapper.exists("k.txt").await.unwrap());
258        wrapper.delete("k.txt").await.unwrap();
259        assert!(!wrapper.exists("k.txt").await.unwrap());
260    }
261
262    #[tokio::test]
263    async fn test_builder_aliyun_actually_works() {
264        let wrapper = StorageBuilder::new(StorageProvider::AliyunOss(AliyunConfig::default()))
265            .with_bucket("bucket")
266            .with_endpoint("oss-cn-hangzhou.aliyuncs.com")
267            .build()
268            .unwrap();
269
270        wrapper
271            .put("k", b"aliyun-via-builder", "text/plain")
272            .await
273            .unwrap();
274        assert_eq!(wrapper.get("k").await.unwrap(), b"aliyun-via-builder");
275    }
276
277    #[tokio::test]
278    async fn test_builder_tencent_actually_works() {
279        let wrapper = StorageBuilder::new(StorageProvider::TencentCos(TencentConfig::default()))
280            .with_bucket("bucket")
281            .with_region("ap-guangzhou")
282            .build()
283            .unwrap();
284
285        wrapper
286            .put("k", b"tencent-via-builder", "text/plain")
287            .await
288            .unwrap();
289        assert_eq!(wrapper.get("k").await.unwrap(), b"tencent-via-builder");
290        assert!(wrapper.exists("k").await.unwrap());
291    }
292
293    #[tokio::test]
294    async fn test_builder_qiniu_actually_works() {
295        let wrapper = StorageBuilder::new(StorageProvider::QiniuKodo(QiniuConfig::default()))
296            .with_bucket("bucket")
297            .build()
298            .unwrap();
299
300        wrapper
301            .put("k", b"qiniu-via-builder", "text/plain")
302            .await
303            .unwrap();
304        assert_eq!(wrapper.get("k").await.unwrap(), b"qiniu-via-builder");
305    }
306
307    #[tokio::test]
308    async fn test_builder_huawei_actually_works() {
309        let wrapper = StorageBuilder::new(StorageProvider::HuaweiObs(HuaweiConfig::default()))
310            .with_bucket("bucket")
311            .with_endpoint("obs.cn-north-4.myhuaweicloud.com")
312            .build()
313            .unwrap();
314
315        wrapper
316            .put("k", b"huawei-via-builder", "text/plain")
317            .await
318            .unwrap();
319        assert_eq!(wrapper.get("k").await.unwrap(), b"huawei-via-builder");
320    }
321
322    #[tokio::test]
323    async fn test_builder_upyun_actually_works() {
324        let wrapper = StorageBuilder::new(StorageProvider::UpYun(UpYunConfig::default()))
325            .with_bucket("bucket")
326            .build()
327            .unwrap();
328
329        wrapper
330            .put("k", b"upyun-via-builder", "text/plain")
331            .await
332            .unwrap();
333        assert_eq!(wrapper.get("k").await.unwrap(), b"upyun-via-builder");
334    }
335
336    #[tokio::test]
337    async fn test_storage_wrapper_put() {
338        let temp_dir = test_data_base().join(format!("storage_test_{}", uuid_simple()));
339        let wrapper = StorageWrapper::Local(LocalStorage::new(temp_dir.to_string_lossy()));
340
341        let url = wrapper
342            .put("test.txt", b"data", "text/plain")
343            .await
344            .unwrap();
345        assert!(url.starts_with("local://"));
346
347        tokio::fs::remove_dir_all(&temp_dir).await.ok();
348    }
349
350    #[tokio::test]
351    async fn test_storage_wrapper_get() {
352        let temp_dir = test_data_base().join(format!("storage_test_{}", uuid_simple()));
353        let wrapper = StorageWrapper::Local(LocalStorage::new(temp_dir.to_string_lossy()));
354
355        wrapper
356            .put("test.txt", b"data", "text/plain")
357            .await
358            .unwrap();
359        let data = wrapper.get("test.txt").await.unwrap();
360        assert_eq!(data, b"data");
361
362        tokio::fs::remove_dir_all(&temp_dir).await.ok();
363    }
364
365    #[tokio::test]
366    async fn test_storage_config_default() {
367        let config = StorageConfig::default();
368        assert_eq!(config.bucket, "default-bucket");
369        assert_eq!(config.region, "us-east-1");
370    }
371
372    #[test]
373    fn test_storage_config_debug_masks_secrets() {
374        let config = StorageConfig {
375            bucket: "my-bucket".to_string(),
376            region: "us-east-1".to_string(),
377            endpoint: Some("https://example.com".to_string()),
378            access_key: Some("AKIAIOSFODNN7EXAMPLE".to_string()),
379            secret_key: Some("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string()),
380            path_prefix: Some("prefix/".to_string()),
381            base_path: Some("/tmp".to_string()),
382        };
383
384        let debug_output = format!("{:?}", config);
385
386        // 非敏感字段应正常输出
387        assert!(debug_output.contains("my-bucket"));
388        assert!(debug_output.contains("us-east-1"));
389        // 敏感字段必须被遮掩
390        assert!(debug_output.contains("\"***\""));
391        assert!(!debug_output.contains("AKIAIOSFODNN7EXAMPLE"));
392        assert!(!debug_output.contains("wJalrXUtnFEMI"));
393    }
394
395    fn uuid_simple() -> String {
396        use std::time::{SystemTime, UNIX_EPOCH};
397        let now = SystemTime::now()
398            .duration_since(UNIX_EPOCH)
399            .unwrap_or_default()
400            .as_nanos();
401        format!("{:x}", now)
402    }
403}