Skip to main content

opendal_service_cos/
backend.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18use std::fmt::Debug;
19use std::sync::Arc;
20
21use http::StatusCode;
22use http::Uri;
23use log::debug;
24use reqsign_core::Context;
25use reqsign_core::OsEnv;
26use reqsign_core::Signer;
27use reqsign_file_read_tokio::TokioFileRead;
28use reqsign_tencent_cos::DefaultCredentialProvider;
29use reqsign_tencent_cos::RequestSigner;
30use reqsign_tencent_cos::StaticCredentialProvider;
31
32use super::COS_SCHEME;
33use super::config::CosConfig;
34use super::core::parse_error;
35use super::core::*;
36use super::deleter::CosDeleter;
37use super::lister::CosLister;
38use super::lister::CosListers;
39use super::lister::CosObjectVersionsLister;
40use super::reader::*;
41use super::writer::CosWriter;
42use super::writer::CosWriters;
43use opendal_core::raw::*;
44use opendal_core::*;
45
46/// Tencent-Cloud COS services support.
47#[doc = include_str!("docs.md")]
48#[derive(Default)]
49pub struct CosBuilder {
50    pub(super) config: CosConfig,
51}
52
53impl Debug for CosBuilder {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        f.debug_struct("CosBuilder")
56            .field("config", &self.config)
57            .finish_non_exhaustive()
58    }
59}
60
61impl CosBuilder {
62    /// Set root of this backend.
63    ///
64    /// All operations will happen under this root.
65    pub fn root(mut self, root: &str) -> Self {
66        self.config.root = if root.is_empty() {
67            None
68        } else {
69            Some(root.to_string())
70        };
71
72        self
73    }
74
75    /// Set endpoint of this backend.
76    ///
77    /// NOTE: no bucket or account id in endpoint, we will trim them if exists.
78    ///
79    /// # Examples
80    ///
81    /// - `https://cos.ap-singapore.myqcloud.com`
82    pub fn endpoint(mut self, endpoint: &str) -> Self {
83        if !endpoint.is_empty() {
84            self.config.endpoint = Some(endpoint.trim_end_matches('/').to_string());
85        }
86
87        self
88    }
89
90    /// Set secret_id of this backend.
91    /// - If it is set, we will take user's input first.
92    /// - If not, we will try to load it from environment.
93    pub fn secret_id(mut self, secret_id: &str) -> Self {
94        if !secret_id.is_empty() {
95            self.config.secret_id = Some(secret_id.to_string());
96        }
97
98        self
99    }
100
101    /// Set secret_key of this backend.
102    /// - If it is set, we will take user's input first.
103    /// - If not, we will try to load it from environment.
104    pub fn secret_key(mut self, secret_key: &str) -> Self {
105        if !secret_key.is_empty() {
106            self.config.secret_key = Some(secret_key.to_string());
107        }
108
109        self
110    }
111
112    /// Set security_token (a.k.a. session token) of this backend.
113    ///
114    /// This is used when authenticating via Tencent Cloud STS temporary
115    /// credentials (e.g. obtained from `GetFederationToken` or
116    /// `AssumeRole`). When provided, it will be combined with `secret_id`
117    /// and `secret_key` to sign requests, and the `x-cos-security-token`
118    /// header will be attached automatically.
119    ///
120    /// - If this is set along with `secret_id` and `secret_key`, a static
121    ///   credential provider with the token will be used.
122    /// - If this is not set, the default credential chain in reqsign will
123    ///   try to load credentials (including the token) from environment
124    ///   variables such as `TENCENTCLOUD_TOKEN`,
125    ///   `TENCENTCLOUD_SECURITY_TOKEN`, and `QCLOUD_SECRET_TOKEN`
126    ///   (unless `disable_config_load` is enabled).
127    pub fn security_token(mut self, security_token: &str) -> Self {
128        if !security_token.is_empty() {
129            self.config.security_token = Some(security_token.to_string());
130        }
131
132        self
133    }
134
135    /// Set bucket of this backend.
136    /// The param is required.
137    pub fn bucket(mut self, bucket: &str) -> Self {
138        if !bucket.is_empty() {
139            self.config.bucket = Some(bucket.to_string());
140        }
141
142        self
143    }
144
145    /// Deprecated: COS versioning capability is enabled by default.
146    #[deprecated(
147        since = "0.57.0",
148        note = "COS versioning capability is enabled by default and this option is no longer needed."
149    )]
150    pub fn enable_versioning(self, _enabled: bool) -> Self {
151        self
152    }
153
154    /// Disable config load so that opendal will not load config from
155    /// environment.
156    ///
157    /// For examples:
158    ///
159    /// - envs like `TENCENTCLOUD_SECRET_ID`
160    pub fn disable_config_load(mut self) -> Self {
161        self.config.disable_config_load = true;
162        self
163    }
164}
165
166impl Builder for CosBuilder {
167    type Config = CosConfig;
168
169    fn build(self) -> Result<impl Service> {
170        debug!("backend build started: {:?}", self);
171
172        let root = normalize_root(&self.config.root.unwrap_or_default());
173        debug!("backend use root {root}");
174
175        let bucket = match &self.config.bucket {
176            Some(bucket) => Ok(bucket.to_string()),
177            None => Err(
178                Error::new(ErrorKind::ConfigInvalid, "The bucket is misconfigured")
179                    .with_context("service", COS_SCHEME),
180            ),
181        }?;
182        debug!("backend use bucket {}", bucket);
183
184        let uri = match &self.config.endpoint {
185            Some(endpoint) => endpoint.parse::<Uri>().map_err(|err| {
186                Error::new(ErrorKind::ConfigInvalid, "endpoint is invalid")
187                    .with_context("service", COS_SCHEME)
188                    .with_context("endpoint", endpoint)
189                    .set_source(err)
190            }),
191            None => Err(Error::new(ErrorKind::ConfigInvalid, "endpoint is empty")
192                .with_context("service", COS_SCHEME)),
193        }?;
194
195        let endpoint = build_endpoint(&uri, &bucket)?;
196        debug!("backend use endpoint {}", endpoint);
197
198        let os_env = OsEnv;
199        let ctx = Context::new()
200            .with_file_read(TokioFileRead)
201            .with_env(os_env);
202
203        let mut credential = if self.config.disable_config_load {
204            DefaultCredentialProvider::builder()
205                .no_env()
206                .no_web_identity()
207                .build()
208        } else {
209            DefaultCredentialProvider::new()
210        };
211
212        if let (Some(secret_id), Some(secret_key)) = (
213            self.config.secret_id.as_deref(),
214            self.config.secret_key.as_deref(),
215        ) {
216            let static_provider = if let Some(token) = self.config.security_token.as_deref() {
217                StaticCredentialProvider::with_security_token(secret_id, secret_key, token)
218            } else {
219                StaticCredentialProvider::new(secret_id, secret_key)
220            };
221
222            credential = credential.push_front(static_provider);
223        }
224
225        let signer = Signer::new(ctx, credential, RequestSigner::new());
226
227        let info = ServiceInfo::new(COS_SCHEME, &root, &bucket);
228        let capability = Capability {
229            stat: true,
230            stat_with_if_match: true,
231            stat_with_if_none_match: true,
232            stat_with_version: true,
233
234            read: true,
235            read_with_suffix: true,
236
237            read_with_if_match: true,
238            read_with_if_none_match: true,
239            read_with_if_modified_since: true,
240            read_with_if_unmodified_since: true,
241            read_with_version: true,
242
243            write: true,
244            write_can_empty: true,
245            write_can_append: true,
246            write_can_multi: true,
247            write_with_content_type: true,
248            write_with_cache_control: true,
249            write_with_content_disposition: true,
250            write_with_if_not_exists: true,
251            copy_with_if_not_exists: true,
252            // The min multipart size of COS is 1 MiB.
253            //
254            // ref: <https://www.tencentcloud.com/document/product/436/14112>
255            write_multi_min_size: Some(1024 * 1024),
256            // The max multipart size of COS is 5 GiB.
257            //
258            // ref: <https://www.tencentcloud.com/document/product/436/14112>
259            write_multi_max_size: if cfg!(target_pointer_width = "64") {
260                Some(5 * 1024 * 1024 * 1024)
261            } else {
262                Some(usize::MAX)
263            },
264            write_with_user_metadata: true,
265
266            delete: true,
267            delete_with_version: true,
268            copy: true,
269
270            list: true,
271            list_with_recursive: true,
272            list_with_versions: true,
273            list_with_deleted: true,
274
275            presign: true,
276            presign_stat: true,
277            presign_read: true,
278            presign_write: true,
279
280            shared: true,
281
282            ..Default::default()
283        };
284
285        Ok(CosBackend {
286            core: Arc::new(CosCore {
287                info,
288                capability,
289                bucket: bucket.clone(),
290                root,
291                endpoint,
292                signer,
293            }),
294        })
295    }
296}
297
298/// Backend for Tencent-Cloud COS services.
299#[derive(Debug, Clone)]
300pub struct CosBackend {
301    pub(crate) core: Arc<CosCore>,
302}
303
304impl Service for CosBackend {
305    type Reader = oio::StreamReader<CosReader>;
306    type Writer = CosWriters;
307    type Lister = CosListers;
308    type Deleter = oio::OneShotDeleter<CosDeleter>;
309    type Copier = oio::OneShotCopier;
310    type Composer = ();
311
312    fn info(&self) -> ServiceInfo {
313        self.core.info.clone()
314    }
315
316    fn capability(&self) -> Capability {
317        self.core.capability
318    }
319
320    async fn create_dir(
321        &self,
322        _ctx: &OperationContext,
323        _path: &str,
324        _args: OpCreateDir,
325    ) -> Result<RpCreateDir> {
326        Err(Error::new(
327            ErrorKind::Unsupported,
328            "operation is not supported",
329        ))
330    }
331
332    async fn stat(&self, ctx: &OperationContext, path: &str, args: OpStat) -> Result<RpStat> {
333        let resp = self.core.cos_head_object(ctx, path, &args).await?;
334
335        let status = resp.status();
336
337        match status {
338            StatusCode::OK => {
339                let headers = resp.headers();
340                let mut meta = parse_into_metadata(path, headers)?.into_builder();
341
342                let user_meta = parse_prefixed_headers(headers, "x-cos-meta-");
343                if !user_meta.is_empty() {
344                    meta.user_metadata(user_meta);
345                }
346
347                if let Some(v) = parse_header_to_str(headers, constants::X_COS_VERSION_ID)?
348                    && v != "null"
349                {
350                    meta.version(v);
351                }
352
353                Ok(RpStat::new(meta.build()))
354            }
355            _ => Err(parse_error(
356                ErrorContext::new(ServiceOperation("HeadObject"))
357                    .with_caller_condition(args.is_conditional()),
358                resp,
359            )),
360        }
361    }
362    fn read(&self, ctx: &OperationContext, path: &str, args: OpRead) -> Result<Self::Reader> {
363        let output: oio::StreamReader<CosReader> = {
364            Ok(oio::StreamReader::new(CosReader::new(
365                self.clone(),
366                ctx.clone(),
367                path,
368                args,
369            )))
370        }?;
371
372        Ok(output)
373    }
374
375    fn write(&self, ctx: &OperationContext, path: &str, args: OpWrite) -> Result<Self::Writer> {
376        let output: CosWriters = {
377            let writer = CosWriter::new(self.core.clone(), ctx.clone(), path, args.clone());
378
379            let w = if args.append() {
380                CosWriters::Two(oio::AppendWriter::new(writer))
381            } else {
382                CosWriters::One(oio::MultipartWriter::new(
383                    ctx.executor().clone(),
384                    writer,
385                    args.concurrent(),
386                ))
387            };
388
389            Ok(w)
390        }?;
391
392        Ok(output)
393    }
394
395    fn delete(&self, ctx: &OperationContext) -> Result<Self::Deleter> {
396        let output: oio::OneShotDeleter<CosDeleter> = {
397            Ok(oio::OneShotDeleter::new(CosDeleter::new(
398                self.core.clone(),
399                ctx.clone(),
400            )))
401        }?;
402
403        Ok(output)
404    }
405
406    fn list(&self, ctx: &OperationContext, path: &str, args: OpList) -> Result<Self::Lister> {
407        let output: CosListers = {
408            let l = if args.versions() || args.deleted() {
409                TwoWays::Two(oio::PageLister::new(CosObjectVersionsLister::new(
410                    self.core.clone(),
411                    ctx.clone(),
412                    path,
413                    args,
414                )))
415            } else {
416                TwoWays::One(oio::PageLister::new(CosLister::new(
417                    self.core.clone(),
418                    ctx.clone(),
419                    path,
420                    args.recursive(),
421                    args.limit(),
422                )))
423            };
424
425            Ok(l)
426        }?;
427
428        Ok(output)
429    }
430
431    fn copy(
432        &self,
433        ctx: &OperationContext,
434        from: &str,
435        to: &str,
436        args: OpCopy,
437    ) -> Result<Self::Copier> {
438        let core = self.core.clone();
439        let ctx = ctx.clone();
440        let from = from.to_string();
441        let to = to.to_string();
442        Ok(oio::OneShotCopier::new(async move {
443            let source_size = match args.source_content_length_hint() {
444                Some(size) => size,
445                None => {
446                    let stat_args = options::StatOptions {
447                        version: args.source_version().map(str::to_owned),
448                        ..Default::default()
449                    }
450                    .into();
451                    let resp = core.cos_head_object(&ctx, &from, &stat_args).await?;
452                    match resp.status() {
453                        StatusCode::OK => {
454                            parse_into_metadata(&from, resp.headers())?.content_length()
455                        }
456                        _ => {
457                            return Err(parse_error(
458                                ErrorContext::new(ServiceOperation("HeadObject")),
459                                resp,
460                            ));
461                        }
462                    }
463                }
464            };
465            let resp = core.cos_copy_object(&ctx, &from, &to, &args).await?;
466
467            let status = resp.status();
468
469            match status {
470                StatusCode::OK => Ok(MetadataBuilder::file(source_size).build()),
471                _ => Err(parse_error(
472                    ErrorContext::new(ServiceOperation("CopyObject"))
473                        .with_if_not_exists(args.if_not_exists()),
474                    resp,
475                )),
476            }
477        }))
478    }
479
480    async fn rename(
481        &self,
482        _ctx: &OperationContext,
483        _from: &str,
484        _to: &str,
485        _args: OpRename,
486    ) -> Result<RpRename> {
487        Err(Error::new(
488            ErrorKind::Unsupported,
489            "operation is not supported",
490        ))
491    }
492
493    async fn presign(
494        &self,
495        ctx: &OperationContext,
496        path: &str,
497        args: OpPresign,
498    ) -> Result<RpPresign> {
499        let req = match args.operation() {
500            PresignOperation::Stat(v) => self.core.cos_head_object_request(path, v),
501            PresignOperation::Read(range, v) => self.core.cos_get_object_request(path, *range, v),
502            PresignOperation::Write(v) => {
503                self.core
504                    .cos_put_object_request(path, None, v, Buffer::new())
505            }
506            PresignOperation::Delete(_) => Err(Error::new(
507                ErrorKind::Unsupported,
508                "operation is not supported",
509            )),
510            _ => Err(Error::new(
511                ErrorKind::Unsupported,
512                "operation is not supported",
513            )),
514        };
515        let req = req?;
516        let req = self.core.sign_query(ctx, req, args.expire()).await?;
517
518        // We don't need this request anymore, consume it directly.
519        let (parts, _) = req.into_parts();
520
521        Ok(RpPresign::new(PresignedRequest::new(
522            parts.method,
523            parts.uri,
524            parts.headers,
525        )))
526    }
527}
528
529/// Compose the request endpoint for a bucket, as `scheme://bucket.host[:port]`.
530///
531/// Extracted so it can be unit tested, mirroring `S3Builder::build_endpoint`.
532fn build_endpoint(uri: &Uri, bucket: &str) -> Result<String> {
533    let scheme = uri.scheme_str().unwrap_or("https");
534
535    let host = uri.host().ok_or_else(|| {
536        Error::new(ErrorKind::ConfigInvalid, "endpoint host is empty")
537            .with_context("service", COS_SCHEME)
538            .with_context("endpoint", uri.to_string())
539    })?;
540
541    // If the endpoint already carries the bucket as its leftmost label, don't add it twice.
542    let host = host.strip_prefix(&format!("{bucket}.")).unwrap_or(host);
543
544    // Keep the port. `Uri::host` omits it, so composing from the host alone silently sent every
545    // request to the scheme default.
546    Ok(match uri.port_u16() {
547        Some(port) => format!("{scheme}://{bucket}.{host}:{port}"),
548        None => format!("{scheme}://{bucket}.{host}"),
549    })
550}
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555
556    fn endpoint_of(raw: &str, bucket: &str) -> String {
557        build_endpoint(&raw.parse::<Uri>().unwrap(), bucket).unwrap()
558    }
559
560    #[test]
561    fn build_endpoint_keeps_a_custom_port() {
562        assert_eq!(
563            endpoint_of(
564                "https://cos.internal.example.com:8443",
565                "examplebucket-1250000000"
566            ),
567            "https://examplebucket-1250000000.cos.internal.example.com:8443"
568        );
569    }
570
571    #[test]
572    fn build_endpoint_prefixes_the_bucket() {
573        assert_eq!(
574            endpoint_of(
575                "https://cos.ap-guangzhou.myqcloud.com",
576                "examplebucket-1250000000"
577            ),
578            "https://examplebucket-1250000000.cos.ap-guangzhou.myqcloud.com"
579        );
580    }
581
582    #[test]
583    fn build_endpoint_does_not_repeat_a_bucket_already_in_the_host() {
584        // The previous `replace("//{bucket}.", "//")` could never match, because Uri::host never
585        // contains "//", so this doubled the bucket label.
586        assert_eq!(
587            endpoint_of(
588                "https://examplebucket-1250000000.cos.ap-guangzhou.myqcloud.com",
589                "examplebucket-1250000000"
590            ),
591            "https://examplebucket-1250000000.cos.ap-guangzhou.myqcloud.com"
592        );
593    }
594
595    #[test]
596    fn build_endpoint_defaults_the_scheme_to_https() {
597        // A bare host parses with no scheme; "//host" parses with no host at all, which is why
598        // the missing-host case below is a real input rather than a contrived one.
599        assert_eq!(
600            endpoint_of("cos.ap-guangzhou.myqcloud.com", "b"),
601            "https://b.cos.ap-guangzhou.myqcloud.com"
602        );
603    }
604
605    #[test]
606    fn build_endpoint_reports_a_missing_host_instead_of_panicking() {
607        // Both of these parse to host = None. The previous code called .unwrap() on that.
608        for raw in ["/just/a/path", "//cos.ap-guangzhou.myqcloud.com"] {
609            let uri = raw.parse::<Uri>().unwrap();
610            assert!(
611                build_endpoint(&uri, "b").is_err(),
612                "expected an error for {raw}"
613            );
614        }
615    }
616}