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}