Skip to main content

qubit_fs/uri/
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//! Secret-free resource URI values.
10
11use std::fmt::Display;
12use std::fmt::Formatter;
13use std::fmt::Result as FmtResult;
14
15use fluent_uri::Uri as FluentUri;
16use qubit_redact::RedactionPolicy;
17use qubit_redact::formats::uri::UriRedactionBoundary;
18
19use super::invalid_uri;
20use crate::error::FsResult;
21
22/// A validated URI that cannot contain sensitive credentials or a fragment.
23///
24/// # Examples
25///
26/// ```rust
27/// use qubit_fs::path::Uri;
28///
29/// let uri = Uri::parse("file:///tmp/report.txt")?;
30/// assert_eq!("file", uri.scheme());
31/// # Ok::<(), qubit_fs::FsError>(())
32/// ```
33#[derive(Clone, Debug, Eq, Hash, PartialEq)]
34pub struct Uri {
35    /// RFC 3986 parser-owned lexical URI representation.
36    parsed: FluentUri<String>,
37}
38
39impl Uri {
40    /// Parses a secret-free RFC 3986 URI.
41    ///
42    /// Returns an invalid-URI error for malformed syntax, fragments, or URI
43    /// components classified as sensitive by the fixed standard policy.
44    ///
45    /// # Parameters
46    /// - `text`: URI text to parse.
47    /// - `policy`: Redaction policy used to reject sensitive components.
48    ///
49    /// # Errors
50    /// Returns an invalid-URI error for malformed syntax, fragments, or
51    /// sensitive components rejected by `policy`.
52    #[inline]
53    pub fn parse(text: &str) -> FsResult<Self> {
54        Self::parse_with_policy(text, &RedactionPolicy::standard())
55    }
56
57    /// Parses a secret-free URI using an explicit redaction policy snapshot.
58    ///
59    /// # Parameters
60    ///
61    /// * `text` - URI text to parse and canonicalize.
62    /// * `policy` - Policy used to classify sensitive URI components.
63    ///
64    /// The standard policy is always applied as a non-removable safety floor;
65    /// an explicit policy can only add classifications. Provider-specific
66    /// credentials that are not recognized by either policy remain the
67    /// provider's responsibility to remove before constructing a URI.
68    #[inline]
69    pub fn parse_with_policy(text: &str, policy: &RedactionPolicy) -> FsResult<Self> {
70        let parsed = parse_canonical(text)?;
71        let floor = RedactionPolicy::standard();
72        reject_secrets(&parsed, &floor)?;
73        if policy != &floor {
74            reject_secrets(&parsed, policy)?;
75        }
76        Ok(Self { parsed })
77    }
78
79    /// Returns the normalized lowercase scheme.
80    #[inline]
81    #[must_use]
82    pub fn scheme(&self) -> &str {
83        self.parsed.scheme().as_str()
84    }
85
86    /// Returns the raw RFC 3986 authority when it is syntactically present.
87    #[inline]
88    #[must_use]
89    pub fn authority(&self) -> Option<&str> {
90        self.parsed.authority().map(|authority| authority.as_str())
91    }
92
93    /// Returns whether an authority delimiter was present, including empty
94    /// authority.
95    #[inline]
96    #[must_use]
97    pub fn has_authority(&self) -> bool {
98        self.parsed.has_authority()
99    }
100
101    /// Returns the raw percent-encoded path without decoding separators.
102    #[inline]
103    #[must_use]
104    pub fn path(&self) -> &str {
105        self.parsed.path().as_str()
106    }
107
108    /// Returns the raw ordered query text when a query delimiter was present.
109    #[inline]
110    #[must_use]
111    pub fn query(&self) -> Option<&str> {
112        self.parsed.query().map(|query| query.as_str())
113    }
114
115    /// Returns the complete validated canonical URI spelling.
116    #[inline]
117    #[must_use]
118    pub fn as_str(&self) -> &str {
119        self.parsed.as_str()
120    }
121}
122
123impl Display for Uri {
124    /// Formats the lossless validated URI spelling.
125    #[inline]
126    fn fmt(&self, formatter: &mut Formatter<'_>) -> FmtResult {
127        formatter.write_str(self.as_str())
128    }
129}
130
131/// Parses a URI after normalizing only the case-insensitive scheme.
132pub(crate) fn parse_canonical(text: &str) -> FsResult<FluentUri<String>> {
133    let (scheme, rest) = text
134        .split_once(':')
135        .ok_or_else(|| invalid_uri("URI must include a scheme"))?;
136    if scheme.is_empty() {
137        return Err(invalid_uri("URI scheme must not be empty"));
138    }
139    let canonical = format!("{}:{rest}", scheme.to_ascii_lowercase());
140    FluentUri::parse(canonical).map_err(|_| invalid_uri("URI is malformed"))
141}
142
143/// Rejects fragments and URI components classified as sensitive.
144pub(crate) fn reject_secrets(parsed: &FluentUri<String>, policy: &RedactionPolicy) -> FsResult<()> {
145    if parsed.fragment().is_some() {
146        return Err(invalid_uri("URI fragments are not supported"));
147    }
148    match UriRedactionBoundary::new(policy).inspect_uri(parsed.as_str()) {
149        Ok(inspection) if !inspection.contains_sensitive() => {}
150        Ok(_) => {
151            return Err(invalid_uri("sensitive URI components are not supported"));
152        }
153        Err(_) => {
154            return Err(invalid_uri("URI contains invalid or uninspectable components"));
155        }
156    }
157    Ok(())
158}
159
160/// Classifies a raw metadata key through the shared URI query policy.
161pub(crate) fn query_pair_is_sensitive(key: &str) -> bool {
162    RedactionPolicy::standard().sensitivity_for(key).is_some()
163}
164
165#[cfg(test)]
166mod tests {
167    use std::hint::black_box;
168
169    use super::Uri;
170
171    #[test]
172    fn uri_accessors_are_executed_at_runtime() {
173        let parse: fn(&str) -> crate::error::FsResult<Uri> = black_box(Uri::parse);
174        let scheme: for<'a> fn(&'a Uri) -> &'a str = black_box(Uri::scheme);
175        let authority: for<'a> fn(&'a Uri) -> Option<&'a str> = black_box(Uri::authority);
176        let has_authority: fn(&Uri) -> bool = black_box(Uri::has_authority);
177        let path: for<'a> fn(&'a Uri) -> &'a str = black_box(Uri::path);
178        let query: for<'a> fn(&'a Uri) -> Option<&'a str> = black_box(Uri::query);
179        let as_str: for<'a> fn(&'a Uri) -> &'a str = black_box(Uri::as_str);
180
181        let uri = parse("HTTPS://example.test/path?query=value").expect("URI should parse");
182        assert_eq!("https", scheme(&uri));
183        assert_eq!(Some("example.test"), authority(&uri));
184        assert!(has_authority(&uri));
185        assert_eq!("/path", path(&uri));
186        assert_eq!(Some("query=value"), query(&uri));
187        assert_eq!("https://example.test/path?query=value", as_str(&uri));
188        assert_eq!(as_str(&uri), format!("{uri}"));
189    }
190}