1pub 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
70pub 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 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 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 assert!(debug_output.contains("my-bucket"));
433 assert!(debug_output.contains("us-east-1"));
434 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 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}