1use 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#[derive(Clone, Debug, Eq, Hash, PartialEq)]
34pub struct Uri {
35 parsed: FluentUri<String>,
37}
38
39impl Uri {
40 #[inline]
53 pub fn parse(text: &str) -> FsResult<Self> {
54 Self::parse_with_policy(text, &RedactionPolicy::standard())
55 }
56
57 #[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 #[inline]
81 #[must_use]
82 pub fn scheme(&self) -> &str {
83 self.parsed.scheme().as_str()
84 }
85
86 #[inline]
88 #[must_use]
89 pub fn authority(&self) -> Option<&str> {
90 self.parsed.authority().map(|authority| authority.as_str())
91 }
92
93 #[inline]
96 #[must_use]
97 pub fn has_authority(&self) -> bool {
98 self.parsed.has_authority()
99 }
100
101 #[inline]
103 #[must_use]
104 pub fn path(&self) -> &str {
105 self.parsed.path().as_str()
106 }
107
108 #[inline]
110 #[must_use]
111 pub fn query(&self) -> Option<&str> {
112 self.parsed.query().map(|query| query.as_str())
113 }
114
115 #[inline]
117 #[must_use]
118 pub fn as_str(&self) -> &str {
119 self.parsed.as_str()
120 }
121}
122
123impl Display for Uri {
124 #[inline]
126 fn fmt(&self, formatter: &mut Formatter<'_>) -> FmtResult {
127 formatter.write_str(self.as_str())
128 }
129}
130
131pub(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
143pub(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
160pub(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}