//! # 使用说明
//!
//! ## 1. 列举桶内对象列表
//!
//! ```rust
//! use obs_sdk::{ObsClient, ObsError};
//!
//! static ENDPOINT: &str = "obs.cn-north-4.myhuaweicloud.com";
//! static AK: &str = "YOUR_AK";
//! static SK: &str = "YOUR_SK";
//! static BUCKET_NAME: &str = "bucket_name";
//!
//! #[tokio::test]
//! async fn test_list_prefix() -> Result<(), ObsError> {
//! let client = ObsClient {
//! endpoint: ENDPOINT.to_string(),
//! ak: AK.to_string(),
//! sk: SK.to_string(),
//! bucket: BUCKET_NAME.to_string(),
//! };
//! let res = client.list("tmp", false).await?;
//! println!("{:?}", res);
//! Ok(())
//! }
//! ```
//!
//! ## 2. 上传对象到桶
//!
//! ```rust
//! use obs_sdk::{ObsClient, ObsError};
//!
//! static ENDPOINT: &str = "obs.cn-north-4.myhuaweicloud.com";
//! static AK: &str = "YOUR_AK";
//! static SK: &str = "YOUR_SK";
//! static BUCKET_NAME: &str = "bucket_name";
//!
//! #[tokio::test]
//! async fn test_upload_object() -> Result<(), ObsError> {
//! let client = ObsClient {
//! endpoint: ENDPOINT.to_string(),
//! ak: AK.to_string(),
//! sk: SK.to_string(),
//! bucket: BUCKET_NAME.to_string(),
//! };
//! let res = client.upload_file("tmp_cargo.txt", "Cargo.txt").await?;
//! println!("{:?}", res);
//! Ok(())
//! }
//! ```
//!
//! ## 3. 下载对象到本地目录
//!
//! ```rust
//! use obs_sdk::{ObsClient, ObsError};
//!
//! static ENDPOINT: &str = "obs.cn-north-4.myhuaweicloud.com";
//! static AK: &str = "YOUR_AK";
//! static SK: &str = "YOUR_SK";
//! static BUCKET_NAME: &str = "bucket_name";
//!
//! #[tokio::test]
//! async fn test_download_file02() -> Result<(), ObsError> {
//! let client = ObsClient {
//! endpoint: ENDPOINT.to_string(),
//! ak: AK.to_string(),
//! sk: SK.to_string(),
//! bucket: BUCKET_NAME.to_string(),
//! };
//! let res = client.download_file("2hls_stutter-10.mp4", "video/2hls_stutter-10.mp4", false).await;
//! res
//! }
//! ```
//!
//! ## 4. 下载对象为字节内容
//!
//! ```rust
//! use obs_sdk::{ObsClient, ObsError};
//!
//! static ENDPOINT: &str = "obs.cn-north-4.myhuaweicloud.com";
//! static AK: &str = "YOUR_AK";
//! static SK: &str = "YOUR_SK";
//! static BUCKET_NAME: &str = "bucket_name";
//!
//! #[tokio::test]
//! async fn test_download_file01() -> Result<(), ObsError> {
//! let client = ObsClient {
//! endpoint: ENDPOINT.to_string(),
//! ak: AK.to_string(),
//! sk: SK.to_string(),
//! bucket: BUCKET_NAME.to_string(),
//! };
//! let data = client.download_object("2hls_stutter-10.mp4").await?;
//! let file_path = Path::new("output.mp4");
//! match fs::write(file_path, data) {
//! Ok(_) => println!("文件保存成功{:?}", file_path),
//! Err(e) => eprintln!("文件保存失败:{}", e)
//! }
//! Ok(())
//! }
//! ```
//!
//! ## 5. url鉴权
//!
//! ```rust
//! use obs_sdk::{ObsClient, ObsError};
//!
//! static ENDPOINT: &str = "obs.cn-north-4.myhuaweicloud.com";
//! static AK: &str = "YOUR_AK";
//! static SK: &str = "YOUR_SK";
//! static BUCKET_NAME: &str = "bucket_name";
//!
//! #[test]
//! fn test_url_sign() -> Result<(), ObsError> {
//! let client = ObsClient {
//! endpoint: ENDPOINT.to_string(),
//! ak: AK.to_string(),
//! sk: SK.to_string(),
//! bucket: BUCKET_NAME.to_string(),
//! };
//! let sign_url = client.url_sign("https://ranfs.obs.cn-north-4.myhuaweicloud.com/tmp_cargo.txt")?;
//! println!("sign_url = {}", sign_url);
//! Ok(())
//! }
//! ```
//!
//! ## 6. 批量上传文件
//!
//! ```rust
//! use obs_sdk::{ObsClient, ObsError};
//!
//! static ENDPOINT: &str = "obs.cn-north-4.myhuaweicloud.com";
//! static AK: &str = "YOUR_AK";
//! static SK: &str = "YOUR_SK";
//! static BUCKET_NAME: &str = "bucket_name";
//!
//! #[tokio::test]
//! async fn test_upload_files() -> Result<(), ObsError> {
//! let client = ObsClient {
//! endpoint: ENDPOINT.to_string(),
//! ak: AK.to_string(),
//! sk: SK.to_string(),
//! bucket: BUCKET_NAME.to_string(),
//! };
//! let files = vec![
//! ("file1.txt", "local/path/file1.txt"),
//! ("file2.txt", "local/path/file2.txt"),
//! ("file3.txt", "local/path/file3.txt"),
//! ];
//! let results = client.upload_files(files).await;
//! for (i, result) in results.iter().enumerate() {
//! match result {
//! Ok(_) => println!("文件{}上传成功", i),
//! Err(e) => eprintln!("文件{}上传失败: {:?}", i, e),
//! }
//! }
//! Ok(())
//! }
//! ```
//!
//! ## 7. 批量下载文件
//!
//! ```rust
//! use obs_sdk::{ObsClient, ObsError};
//!
//! static ENDPOINT: &str = "obs.cn-north-4.myhuaweicloud.com";
//! static AK: &str = "YOUR_AK";
//! static SK: &str = "YOUR_SK";
//! static BUCKET_NAME: &str = "bucket_name";
//!
//! #[tokio::test]
//! async fn test_download_files() -> Result<(), ObsError> {
//! let client = ObsClient {
//! endpoint: ENDPOINT.to_string(),
//! ak: AK.to_string(),
//! sk: SK.to_string(),
//! bucket: BUCKET_NAME.to_string(),
//! };
//! let files = vec![
//! ("remote/file1.txt", "local/path/file1.txt", false),
//! ("remote/file2.txt", "local/path/file2.txt", true),
//! ("remote/file3.txt", "local/path/file3.txt", false),
//! ];
//! let results = client.download_files(files).await;
//! for (i, result) in results.iter().enumerate() {
//! match result {
//! Ok(_) => println!("文件{}下载成功", i),
//! Err(e) => eprintln!("文件{}下载失败: {:?}", i, e),
//! }
//! }
//! Ok(())
//! }
//! ```
//!
mod utils;
mod algorithm;
use algorithm::HmacSha1;
use futures::stream::{self, StreamExt};
use std::{fmt, fs, io};
use std::path::Path;
use serde::{Serialize, Deserialize};
use regex::Regex;
use std::vec::Vec;
use url::{Url, form_urlencoded};
use chrono::Local;
use reqwest::StatusCode;
use urlencoding::encode;
/// 华为云OBS客户端
///
pub struct ObsClient {
pub endpoint: String,
pub ak: String,
pub sk: String,
pub bucket: String,
}
pub enum ObsError {
Common(Box<dyn core::error::Error + Send + Sync + 'static>),
}
// 兼容世界上所有的标准错误
impl <E> From<E> for ObsError
where
E: core::error::Error + Send + Sync + 'static
{
fn from(err: E) -> ObsError {
ObsError::Common(Box::new(err))
}
}
impl fmt::Debug for ObsError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let mut builder = f.debug_struct("obs-sdk::Error");
builder.finish()
}
}
impl fmt::Display for ObsError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ObsError::Common(e) => f.write_str(&format!("obs sdk error: {:?}", e))?,
};
Ok(())
}
}
impl ObsClient {
/// 列举指定前缀开头的所有对象元数据,方法内部会构造http请求:
/// ```plain
/// GET / HTTP/1.1
/// Host: bucketname.obs.cn-north-4.myhuaweicloud.com
/// Date: date
/// Authorization: authorization
/// ```
///
/// # 参数
///
/// `prefix` - 对象名前缀
/// `is_all` - 是否列举所有满足prefix的对象,true表示分页查询所有对象,false表示只查询第一页(最多1000个)
///
pub async fn list(&self, prefix: &str, is_all: bool) -> Result<Vec<ObjectMeta>, ObsError> {
let mut all_objects = Vec::new();
let mut marker: Option<String> = None;
loop {
let url = match &marker {
Some(m) => format!("https://{}.{}/?prefix={}&marker={}", self.bucket, self.endpoint, prefix, m),
None => format!("https://{}.{}/?prefix={}", self.bucket, self.endpoint, prefix),
};
let date = utils::now_str_gmt();
let hmacsha1 = HmacSha1();
let string_to_sign = hmacsha1.header_string_to_sign("GET", "", "", &date, "", &format!("/{}/", self.bucket));
let signature = hmacsha1.sign_to_base64string(&string_to_sign, &self.sk);
let authorization = format!("OBS {}:{}", self.ak, signature);
let client = reqwest::Client::new();
let res = client.get(&url)
.header("Date", &date)
.header("Authorization", &authorization)
.send()
.await?;
if !res.status().is_success() {
return Err(ObsError::Common(Box::new(io::Error::new(
io::ErrorKind::Other,
format!("请求失败,状态码={}", res.status())
))));
}
let xml_content_string = res.text().await?;
let result = XmlParser::new(&xml_content_string).parse();
all_objects.extend(result.objects);
if !is_all || !result.is_truncated {
break;
}
marker = result.next_marker;
}
Ok(all_objects)
}
/// 上传对象
///
/// 方法内部构建请求
/// ```plain
/// PUT /object01 HTTP/1.1
/// User-Agent: curl/7.29.0
/// Host: examplebucket.obs.cn-north-4.myhuaweicloud.com
/// Accept: */*
/// Date: WED, 01 Jul 2015 04:11:15 GMT
/// Authorization: OBS H4IPJX0TQTHTHEBQQCEC:gYqplLq30dEX7GMi2qFWyjdFsyw=
/// Content-Length: 10240
/// Expect: 100-continue
///
/// [1024 Byte data content]
/// ```
///
pub async fn upload_object(&self, obj_key: &str, data: Vec<u8>) -> Result<(), ObsError> {
let obj_key = &Self::urlencode(obj_key);
// 构造完整的url地址
let url = format!("https://{}.{}/{}", self.bucket, self.endpoint, obj_key);
let md5_string = utils::base64_md5_str(&data);
// 获取GMT格式的时间字符串
let date = utils::now_str_gmt();
// 创建HmacSha1对象
let hmacsha1 = HmacSha1();
let file_type = &utils::get_mime_type_from_extension(obj_key)
.unwrap_or(String::from("application/octet-stream"));
// 构造签名用的原始字符串
let string_to_sign = hmacsha1.header_string_to_sign("PUT", &md5_string, file_type, &date, "", &format!("/{}/{}", self.bucket, obj_key));
// 根据原始字符串+ak,获取header签名
let signature = hmacsha1.sign_to_base64string(&string_to_sign, &self.sk);
// 构造请求头Authorization的值
let authorization = format!("OBS {}:{}", self.ak, signature);
// 构造http请求
let client = reqwest::Client::new();
let res = client.put(url)
.header("Content-MD5", &md5_string)
.header("Date", &date)
.header("Content-Type", file_type)
.header("Content-Length", data.len())
.header("Authorization", authorization)
.body(data)
.send()
.await?;
let _status: StatusCode = res.status();
Ok(())
}
/// 上传文件
pub async fn upload_file(&self, obj_key: &str, file_path: &str) -> Result<(), ObsError> {
let data = fs::read(file_path)?;
self.upload_object(obj_key, data).await
}
/// 批量上传文件
///
/// 使用异步并发上传多个文件,并发数不超过32个
///
/// # 参数
///
/// `files` - 文件列表,每个元素是(obj_key, file_path)元组
///
/// # 返回
///
/// 返回一个Vec<Result<(), ObsError>>,每个元素对应一个文件的上传结果
///
pub async fn upload_files(&self, files: Vec<(String, String)>) -> Vec<Result<(), ObsError>> {
const MAX_CONCURRENT: usize = 32;
stream::iter(files)
.map(|(obj_key, file_path)| async move {
self.upload_file(&obj_key, &file_path).await
})
.buffer_unordered(MAX_CONCURRENT)
.collect()
.await
}
/// 下载对象,方法内部会构造http请求:
/// ```plain
/// GET /{obj_key} HTTP/1.1
/// Host: {bucket}.obs.cn-north-4.myhuaweicloud.com
/// Date: {date}
/// ```
///
pub async fn download_object(&self, obj_key: &str) -> Result<Vec<u8>, ObsError> {
let obj_key = &Self::urlencode(obj_key);
// 构造完整的url地址
let url = format!("https://{}.{}/{}", self.bucket, self.endpoint, obj_key);
// 获取GMT格式的时间字符串
let date = utils::now_str_gmt();
// 创建HmacSha1对象
let hmacsha1 = HmacSha1();
// 构造签名用的原始字符串
let string_to_sign = hmacsha1.header_string_to_sign("GET", "", "", &date, "", &format!("/{}/{}", self.bucket, obj_key));
// 根据原始字符串+ak,获取header签名
let signature = hmacsha1.sign_to_base64string(&string_to_sign, &self.sk);
// 构造请求头Authorization的值
let authorization = format!("OBS {}:{}", self.ak, signature);
// 构造http请求
let client = reqwest::Client::new();
let res = client.get(url)
.header("Authorization", &authorization)
.header("Date", &date)
.send()
.await?;
// 如果请求成功,则返回字节内容
if res.status().is_success() {
return Ok(res.bytes().await?.to_vec());
}
Err(ObsError::Common(Box::new(io::Error::new(io::ErrorKind::Other, format!("请求失败,状态码={}", res.status())))))
}
/// 删除obs上的对象
pub async fn delete_object(&self, obj_key: &str) -> Result<(), ObsError> {
let obj_key = &Self::urlencode(obj_key);
// 构造完整的url地址
let url = format!("https://{}.{}/{}", self.bucket, self.endpoint, obj_key);
let md5_string = "";
// 获取GMT格式的时间字符串
let date = utils::now_str_gmt();
// 创建HmacSha1对象
let hmacsha1 = HmacSha1();
// 构造签名用的原始字符串
let string_to_sign = hmacsha1.header_string_to_sign("DELETE", &md5_string, "", &date, "", &format!("/{}/{}", self.bucket, obj_key));
// 根据原始字符串+ak,获取header签名
let signature = hmacsha1.sign_to_base64string(&string_to_sign, &self.sk);
// 构造请求头Authorization的值
let authorization = format!("OBS {}:{}", self.ak, signature);
// 构造http请求
let client = reqwest::Client::new();
let res = client.delete(url)
.header("Date", &date)
.header("Authorization", authorization)
.send()
.await;
let res = match res {
Ok(response) => response,
Err(e) => {
return Err(ObsError::Common(Box::new(io::Error::new(io::ErrorKind::Other, e))));
},
};
let status = res.status();
println!("status = {}, {}", status, res.text_with_charset("utf-8").await?);
Ok(())
}
/// 下载文件,并指定本地保存用的文件路径
///
/// # 参数
///
/// `overwrite` - 是否覆盖,true,当文件存在时,覆盖文件,false,当文件存在时,不覆盖文件
///
pub async fn download_file(&self, obj_key: &str, file_path: &str, overwrite: bool) -> Result<(), ObsError> {
let file_path = Path::new(file_path);
// 判断文件是否存在,如果存在,不做任何操作
if file_path.exists() && !overwrite {
return Err(ObsError::Common(Box::new(io::Error::new(io::ErrorKind::AlreadyExists, "文件已存在,请删除文件或设置覆盖参数"))));
}
// 根据父目录是否存在,选择性创建父目录
let parent = file_path.parent().unwrap();
if !parent.exists() {
fs::create_dir_all(&parent)?;
}
// 下载文件,得到原始文件字节内容
let data = self.download_object(obj_key).await?;
// 保存文件
fs::write(file_path, data)?;
Ok(())
}
/// 批量下载文件
///
/// 使用异步并发下载多个文件,并发数不超过32个
///
/// # 参数
///
/// `files` - 文件列表,每个元素是(obj_key, file_path, overwrite)元组
///
/// # 返回
///
/// 返回一个Vec<Result<(), ObsError>>,每个元素对应一个文件的下载结果
///
pub async fn download_files(&self, files: Vec<(String, String, bool)>) -> Vec<Result<(), ObsError>> {
const MAX_CONCURRENT: usize = 32;
stream::iter(files)
.map(|(obj_key, file_path, overwrite)| async move {
self.download_file(&obj_key, &file_path, overwrite).await
})
.buffer_unordered(MAX_CONCURRENT)
.collect()
.await
}
pub fn url_sign(&self, url_str: &str) -> Result<String, ObsError> {
let obs_object_url = Url::parse(url_str)?;
let resource_part = obs_object_url.path();
let host = obs_object_url.host().unwrap();
let domain = match host {
url::Host::Domain(domain) => domain.to_string(),
_ => format!("{}.{}", self.bucket, self.endpoint)
};
let parts: Vec<&str> = domain.split(".").collect();
let bucket_name = parts[0];
let timestamp = utils::timestamp(Local::now(), 3600*2);
// 获取GMT格式的时间字符串
let expires = format!("{}", timestamp);
// 创建HmacSha1对象
let hmacsha1 = HmacSha1();
// 构造签名用的原始字符串
let string_to_sign = hmacsha1.url_string_to_sign("GET", "", "", &expires, "", &format!("/{}{}", bucket_name, resource_part));
// 根据原始字符串+ak,获取header签名
let signature = hmacsha1.sign_to_base64string(&string_to_sign, &self.sk);
let signature = form_urlencoded::byte_serialize(signature.as_bytes()).collect::<String>();
// 构造url
let sign_url = format!("{}?AccessKeyId={}&Expires={}&Signature={}", url_str, self.ak, expires, signature);
Ok(sign_url)
}
fn urlencode(s: &str) -> String {
let tokens: Vec<String> = s.split("/").map(|token| {
encode(token).to_string()
}).collect();
tokens.join("/")
}
}
/// obs对象的元数据信息
///
/// 这个结构体用于表示 OBS 对象的元数据,包含对象的各种属性,如名称、修改时间、内容标识、大小以及存储类型。
#[derive(Serialize, Deserialize, Debug)]
pub struct ObjectMeta {
/// 对象名
///
/// 唯一标识 OBS 存储中的对象
pub key: String,
/// 对象最近一次被修改的时间(UTC时间)
///
/// 该时间戳表示对象在 OBS 存储中最后一次被修改的时刻,采用 UTC 时间格式。
pub last_modified: String,
/// 对象的base64编码的128位MD5摘要
///
/// 这个 ETag 值是对象内容的唯一标识,可以通过该值识别对象内容是否有变化。
pub etag: String,
/// 对象的字节数
///
/// 表示对象在存储中占用的字节大小
pub size: u64,
/// 对象的存储类型:STANDARD,WARM,COLD,DEEP_ARCHIVE
///
/// 不同的存储类型对应不同的存储成本和访问性能,用户可以根据对象的访问频率等因素选择合适的存储类型
pub storage_class: String,
}
/// 列举桶内对象的响应结果
#[derive(Debug)]
struct ListObjectsResult {
objects: Vec<ObjectMeta>,
is_truncated: bool,
next_marker: Option<String>,
}
/// XML解析器
///
/// 用于解析XML格式的响应数据,目前这里面针对obs的接口"列举桶内对象"的响应结果进行解析,没有进行通用的xml解析,其不能作为通用工具使用
struct XmlParser {
xml: String,
}
impl XmlParser {
fn new(xml: &str) -> Self {
XmlParser { xml: xml.to_string() }
}
/// 解析obs接口"列举桶内对象"的响应结果
///
/// 该内部采用正则表达式进行解析,因此依赖外部的regex库
fn parse(&self) -> ListObjectsResult {
let xml = &self.xml;
// 定义解析需要使用的正则表达式
let contents_re = Regex::new(r#"<Contents>(.*?)</Contents>"#).unwrap();
let key_regex = Regex::new(r#"<Key>(.*?)</Key>"#).unwrap();
let last_modified_regex = Regex::new(r#"<LastModified>(.*?)</LastModified>"#).unwrap();
let etag_regex = Regex::new(r#"<ETag>(.*?)</ETag>"#).unwrap();
let size_regex = Regex::new(r#"<Size>(.*?)</Size>"#).unwrap();
let storage_class_regex = Regex::new(r#"<StorageClass>(.*?)</StorageClass>"#).unwrap();
let is_truncated_regex = Regex::new(r#"<IsTruncated>(.*?)</IsTruncated>"#).unwrap();
let next_marker_regex = Regex::new(r#"<NextMarker>(.*?)</NextMarker>"#).unwrap();
// 解析Contents标签内的数据
let mut contents_vec = Vec::new();
for captures in contents_re.captures_iter(xml) {
let inner_content = &captures[1];
let key = key_regex.captures(inner_content).map(|cap| cap[1].to_string()).unwrap_or_default();
let last_modified = last_modified_regex.captures(inner_content).map(|cap| cap[1].to_string()).unwrap_or_default();
let etag = etag_regex.captures(inner_content).map(|cap| cap[1].to_string()).unwrap_or_default();
let size = size_regex.captures(inner_content).and_then(|cap| cap[1].parse().ok()).unwrap_or(0);
let storage_class = storage_class_regex.captures(inner_content).map(|cap| cap[1].to_string()).unwrap_or_default();
let content = ObjectMeta {
key,
last_modified,
etag,
size,
storage_class,
};
contents_vec.push(content);
}
// 解析IsTruncated字段
let is_truncated = is_truncated_regex.captures(xml)
.and_then(|cap| cap[1].to_string().parse::<bool>().ok())
.unwrap_or(false);
// 解析NextMarker字段
let next_marker = next_marker_regex.captures(xml)
.map(|cap| cap[1].to_string());
ListObjectsResult {
objects: contents_vec,
is_truncated,
next_marker,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Duration, Local};
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn test_parse_xml() {
let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><ListBucketResult xmlns="http://obs.myhwclouds.com/doc/2015-06-30/"><Name>obs-products</Name><Prefix>tmp</Prefix><Marker></Marker><MaxKeys>1000</MaxKeys><IsTruncated>false</IsTruncated><Contents><Key>tmp/</Key><LastModified>2024-12-03T12:01:48.020Z</LastModified><ETag>"d41d8cd98f00b204e9800998ecf8427e"</ETag><Size>0</Size><Owner><ID>74df55bf376f41d48959d2aa9deaaf38</ID></Owner><StorageClass>STANDARD</StorageClass></Contents><Contents><Key>tmp/index001.png</Key><LastModified>2025-08-20T07:42:59.813Z</LastModified><ETag>"de317c0b7b6e02b42ef2b9e29bb5906a"</ETag><Size>12082</Size><Owner><ID>74df55bf376f41d48959d2aa9deaaf38</ID></Owner><StorageClass>STANDARD</StorageClass></Contents><Contents><Key>tmp/index002.png</Key><LastModified>2025-08-20T07:52:10.204Z</LastModified><ETag>"de317c0b7b6e02b42ef2b9e29bb5906a"</ETag><Size>12082</Size><Owner><ID>74df55bf376f41d48959d2aa9deaaf38</ID></Owner><StorageClass>STANDARD</StorageClass></Contents></ListBucketResult>"#;
let parser = XmlParser::new(xml);
let result = parser.parse();
let json_data = serde_json::to_string_pretty(&result.objects).unwrap();
println!("{}", json_data);
println!("is_truncated: {}", result.is_truncated);
println!("next_marker: {:?}", result.next_marker);
}
#[test]
fn test_parse_xml_with_next_marker() {
let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><ListBucketResult xmlns="http://obs.myhwclouds.com/doc/2015-06-30/"><Name>obs-products</Name><Prefix>tmp</Prefix><Marker></Marker><MaxKeys>1000</MaxKeys><IsTruncated>true</IsTruncated><NextMarker>tmp/index999.png</NextMarker><Contents><Key>tmp/index001.png</Key><LastModified>2025-08-20T07:42:59.813Z</LastModified><ETag>"de317c0b7b6e02b42ef2b9e29bb5906a"</ETag><Size>12082</Size><Owner><ID>74df55bf376f41d48959d2aa9deaaf38</ID></Owner><StorageClass>STANDARD</StorageClass></Contents></ListBucketResult>"#;
let parser = XmlParser::new(xml);
let result = parser.parse();
assert_eq!(result.is_truncated, true);
assert_eq!(result.next_marker, Some("tmp/index999.png".to_string()));
println!("is_truncated: {}", result.is_truncated);
println!("next_marker: {:?}", result.next_marker);
}
#[test]
fn test_timestamp() {
let now = Local::now();
let two_hours = Duration::hours(2);
let future_time = now + two_hours;
let system_time: SystemTime = future_time.into();
let duration = system_time.duration_since(UNIX_EPOCH).unwrap();
let timestamp = duration.as_secs();
println!("timestamp = {}", timestamp);
}
}