Skip to main content

qubit_fs/uri/
connection_uri.rs

1// =============================================================================
2//    Copyright (c) 2025 - 2026 Haixing Hu.
3//
4//    SPDX-License-Identifier: Apache-2.0
5//
6//    Licensed under the Apache License, Version 2.0.
7// =============================================================================
8
9//! Redacted connection URI values.
10
11use std::fmt::Debug;
12use std::fmt::Display;
13use std::fmt::Formatter;
14use std::fmt::Result as FmtResult;
15
16use fluent_uri::Uri as FluentUri;
17use qubit_redact::RedactionCompletion;
18use qubit_redact::RedactionPolicy;
19use qubit_redact::formats::uri::UriRedactionBoundary;
20
21use super::invalid_uri;
22use super::uri::Uri;
23use super::uri::parse_canonical;
24use crate::error::FsResult;
25
26/// A connection URI whose normal formatting always redacts credentials.
27///
28/// The standard redaction policy is an unavoidable safety floor. An explicit
29/// policy may classify additional provider-specific fields, but cannot make a
30/// standard sensitive component safe. Providers remain responsible for
31/// clearing credentials that neither policy recognizes.
32///
33/// # Examples
34///
35/// ```
36/// use qubit_fs::path::ConnectionUri;
37///
38/// let uri = ConnectionUri::parse("s3://reports-bucket/daily.csv")?;
39/// assert_eq!("s3", uri.scheme());
40/// # Ok::<(), qubit_fs::FsError>(())
41/// ```
42#[derive(Clone, Eq, PartialEq)]
43pub struct ConnectionUri {
44    /// RFC 3986 parser-owned raw connection URI representation.
45    parsed: FluentUri<String>,
46    /// Immutable policy snapshot used for secret classification and display.
47    redaction_policy: RedactionPolicy,
48}
49
50impl ConnectionUri {
51    /// Parses a connection URI with optional credentials but no fragment.
52    ///
53    /// Returns an invalid-URI error for malformed syntax or a fragment.
54    ///
55    /// # Parameters
56    /// - `text`: Connection URI text to parse.
57    /// - `policy`: Redaction policy used when producing redacted views.
58    ///
59    /// # Errors
60    /// Returns an invalid-URI error for malformed syntax or a fragment.
61    pub fn parse(text: &str) -> FsResult<Self> {
62        Self::parse_with_policy(text, &RedactionPolicy::standard())
63    }
64
65    /// Parses a connection URI using an explicit redaction policy snapshot.
66    ///
67    /// # Parameters
68    ///
69    /// * `text` - URI text to parse and canonicalize.
70    /// * `policy` - Policy captured for later secret classification and
71    ///   formatting.
72    pub fn parse_with_policy(text: &str, policy: &RedactionPolicy) -> FsResult<Self> {
73        let parsed = parse_canonical(text)?;
74        if parsed.fragment().is_some() {
75            return Err(invalid_uri("URI fragments are not supported"));
76        }
77        Ok(Self {
78            parsed,
79            redaction_policy: policy.clone(),
80        })
81    }
82
83    /// Returns the normalized URI scheme without exposing credential-bearing
84    /// components.
85    #[must_use]
86    #[inline]
87    pub fn scheme(&self) -> &str {
88        self.parsed.scheme().as_str()
89    }
90
91    /// Returns whether the URI contains any component classified as sensitive
92    /// by the standard floor or the policy snapshot captured during parsing.
93    ///
94    /// Username-only userinfo is not considered a secret because it can be
95    /// paired with an external credential reference. Classification uses
96    /// metadata-only inspection, so the diagnostic output budget cannot hide
97    /// a late sensitive component. Invalid inspection, including an exceeded
98    /// input budget or invalid encoded component, is treated conservatively as
99    /// secret-bearing.
100    ///
101    /// # Returns
102    ///
103    /// `false` only after inspection passes through without a sensitive
104    /// component; `true` after redaction or any invalid inspection result.
105    #[inline]
106    #[must_use]
107    pub fn has_embedded_secret(&self) -> bool {
108        UriRedactionBoundary::new(&self.redaction_policy)
109            .inspect_uri(self.parsed.as_str())
110            .map_or(true, |inspection| inspection.contains_sensitive())
111    }
112
113    /// Converts this connection URI to a secret-free resource URI.
114    ///
115    /// # Errors
116    ///
117    /// Returns an invalid-URI error when the connection URI contains sensitive
118    /// components that cannot appear in [`Uri`].
119    #[inline]
120    pub fn try_to_uri(&self) -> FsResult<Uri> {
121        Uri::parse_with_policy(self.parsed.as_str(), &self.redaction_policy)
122    }
123
124    /// Gives `inspect` ephemeral access to the unredacted URI text.
125    ///
126    /// The callback result is returned unchanged; callers must not use it to
127    /// expose secret data through ordinary formatting or serialization.
128    #[inline]
129    pub fn expose_unredacted<R>(&self, inspect: impl FnOnce(&str) -> R) -> R {
130        inspect(self.parsed.as_str())
131    }
132
133    /// Renders the connection URI under the structured completion contract.
134    ///
135    /// A complete result preserves the full log-safe rendering. A truncated
136    /// result contains only a substitute for omitted output, while an
137    /// exhausted result means that no safe substitute fit and processing must
138    /// stop without reading further input. Both incomplete states are mapped
139    /// to one outer marker so normal formatting never exposes or mistakes a
140    /// partial connection URI for a complete resource location.
141    ///
142    /// # Returns
143    ///
144    /// The complete redacted URI, or `<truncated>` when redaction did not
145    /// complete. The standard URI boundary masks sensitive components while
146    /// retaining non-sensitive URI structure even when application rules add
147    /// provider-specific classifications.
148    #[inline]
149    #[must_use]
150    fn redacted_text(&self) -> String {
151        let redaction = UriRedactionBoundary::new(&self.redaction_policy).redact_uri(self.parsed.as_str());
152        match redaction.summary().completion() {
153            RedactionCompletion::Complete => redaction
154                .into_complete_text()
155                .expect("complete redaction must retain text")
156                .into_string(),
157            RedactionCompletion::Truncated | RedactionCompletion::Exhausted => "<truncated>".to_owned(),
158        }
159    }
160}
161
162impl Display for ConnectionUri {
163    /// Formats only the redacted connection URI.
164    #[inline]
165    fn fmt(&self, formatter: &mut Formatter<'_>) -> FmtResult {
166        formatter.write_str(&self.redacted_text())
167    }
168}
169
170impl Debug for ConnectionUri {
171    /// Formats only the redacted connection URI for diagnostics.
172    #[inline]
173    fn fmt(&self, formatter: &mut Formatter<'_>) -> FmtResult {
174        let redacted = self.redacted_text();
175        formatter.debug_tuple("ConnectionUri").field(&redacted).finish()
176    }
177}