1use std::fmt::{self, Display, Formatter};
2
3use rustc_hash::FxHashSet;
4use url::Url;
5use uv_redacted::DisplaySafeUrl;
6
7#[derive(
9 Copy,
10 Clone,
11 Debug,
12 Default,
13 Hash,
14 Eq,
15 PartialEq,
16 Ord,
17 PartialOrd,
18 serde::Serialize,
19 serde::Deserialize,
20)]
21#[serde(rename_all = "kebab-case")]
22#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
23pub enum AuthPolicy {
24 #[default]
30 Auto,
31 Always,
36 Never,
40}
41
42impl Display for AuthPolicy {
43 fn fmt(&self, f: &mut Formatter) -> fmt::Result {
44 match self {
45 Self::Auto => write!(f, "auto"),
46 Self::Always => write!(f, "always"),
47 Self::Never => write!(f, "never"),
48 }
49 }
50}
51
52#[derive(Debug, Clone, Hash, Eq, PartialEq)]
56pub struct Index {
57 pub url: DisplaySafeUrl,
58 pub root_url: DisplaySafeUrl,
61 pub auth_policy: AuthPolicy,
62}
63
64impl Index {
65 pub fn is_prefix_for(&self, url: &Url) -> bool {
66 if self.root_url.scheme() != url.scheme()
67 || self.root_url.host_str() != url.host_str()
68 || self.root_url.port_or_known_default() != url.port_or_known_default()
69 {
70 return false;
71 }
72
73 is_path_prefix(self.root_url.path(), url.path())
74 }
75}
76
77pub(crate) fn is_path_prefix(prefix: &str, path: &str) -> bool {
82 if prefix == path {
83 return true;
84 }
85
86 let Some(suffix) = path.strip_prefix(prefix) else {
87 return false;
88 };
89
90 prefix.ends_with('/') || suffix.starts_with('/')
91}
92
93#[derive(Debug, Default, Clone, Eq, PartialEq)]
98pub struct Indexes(FxHashSet<Index>);
99
100impl Indexes {
101 pub fn new() -> Self {
102 Self(FxHashSet::default())
103 }
104
105 pub fn from_indexes(urls: impl IntoIterator<Item = Index>) -> Self {
107 let mut index_urls = Self::new();
108 for url in urls {
109 index_urls.0.insert(url);
110 }
111 index_urls
112 }
113
114 pub fn index_for(&self, url: &Url) -> Option<&Index> {
116 self.find_prefix_index(url)
117 }
118
119 pub fn auth_policy_for(&self, url: &Url) -> AuthPolicy {
121 self.find_prefix_index(url)
122 .map(|index| index.auth_policy)
123 .unwrap_or(AuthPolicy::Auto)
124 }
125
126 fn find_prefix_index(&self, url: &Url) -> Option<&Index> {
127 self.0.iter().find(|&index| index.is_prefix_for(url))
128 }
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 fn index(root_url: &str, auth_policy: AuthPolicy) -> Index {
136 let root_url = DisplaySafeUrl::parse(root_url).unwrap();
137 Index {
138 url: root_url.clone(),
139 root_url,
140 auth_policy,
141 }
142 }
143
144 #[test]
145 fn test_index_path_prefix_requires_segment_boundary() {
146 let index = index("https://example.com/simple", AuthPolicy::Always);
147
148 for url in [
149 "https://example.com/simple",
150 "https://example.com/simple/",
151 "https://example.com/simple/anyio",
152 ] {
153 assert!(
154 index.is_prefix_for(&Url::parse(url).unwrap()),
155 "Failed to match URL with prefix: {url}"
156 );
157 }
158
159 for url in [
160 "https://example.com/simpleevil",
161 "https://example.com/simple-evil",
162 "https://example.com/simpl",
163 ] {
164 assert!(
165 !index.is_prefix_for(&Url::parse(url).unwrap()),
166 "Should not match URL with partial path segment: {url}"
167 );
168 }
169 }
170}