pitchfork_cli/proxy/
mod.rs1pub mod activity;
14pub mod dns;
15pub mod doctor;
16pub mod hostname;
17pub mod hosts;
18pub mod lan_ip;
19pub mod mdns;
20pub mod pac;
21pub mod server;
22pub mod setup;
23pub mod sni;
24pub mod trust;
25pub mod worktree;
26
27pub(crate) struct LogThrottle {
34 last: std::sync::Mutex<Option<std::time::Instant>>,
35 suppressed: std::sync::atomic::AtomicU64,
36}
37
38impl LogThrottle {
39 pub(crate) const fn new() -> Self {
40 Self {
41 last: std::sync::Mutex::new(None),
42 suppressed: std::sync::atomic::AtomicU64::new(0),
43 }
44 }
45
46 pub(crate) fn allow(&self, every: std::time::Duration) -> Option<u64> {
51 use std::sync::atomic::Ordering;
52 let now = std::time::Instant::now();
53 let mut last = match self.last.lock() {
54 Ok(g) => g,
55 Err(e) => e.into_inner(),
56 };
57 match *last {
58 Some(t) if now.duration_since(t) < every => {
59 self.suppressed.fetch_add(1, Ordering::Relaxed);
60 None
61 }
62 _ => {
63 *last = Some(now);
64 Some(self.suppressed.swap(0, Ordering::Relaxed))
65 }
66 }
67 }
68}
69
70pub(crate) fn owns_name(tld: &str, name: &str) -> bool {
80 let name = name.trim_end_matches('.');
81 let tld = tld.trim_matches('.');
82 if tld.is_empty() || name.is_empty() {
83 return false;
84 }
85 if name.eq_ignore_ascii_case(tld) {
86 return true;
87 }
88 let (name, tld) = (name.as_bytes(), tld.as_bytes());
91 name.len() > tld.len() + 1
92 && name[name.len() - tld.len() - 1] == b'.'
93 && name[name.len() - tld.len()..].eq_ignore_ascii_case(tld)
94}
95
96pub(crate) fn is_strictly_under_tld(tld: &str, name: &str) -> bool {
101 !name
102 .trim_end_matches('.')
103 .eq_ignore_ascii_case(tld.trim_matches('.'))
104 && owns_name(tld, name)
105}
106
107pub(crate) fn ascii_case_collisions<'a>(
112 keys: impl Iterator<Item = &'a str>,
113) -> std::collections::HashSet<String> {
114 let mut seen = std::collections::HashSet::new();
115 let mut collisions = std::collections::HashSet::new();
116 for key in keys {
117 let folded = key.to_ascii_lowercase();
118 if !seen.insert(folded.clone()) {
119 collisions.insert(folded);
120 }
121 }
122 collisions
123}
124
125pub fn effective_tld(s: &crate::settings::Settings) -> &str {
129 if s.proxy.lan || !s.proxy.lan_ip.is_empty() {
130 "local"
131 } else {
132 &s.proxy.tld
133 }
134}
135
136pub fn build_proxy_url(host: Option<&str>, s: &crate::settings::Settings) -> Option<String> {
148 if !s.proxy.enable {
149 return None;
150 }
151 let host = host?;
152
153 let scheme = if s.proxy.https { "https" } else { "http" };
154 let tld = effective_tld(s);
155 let standard_port = if s.proxy.https { 443u16 } else { 80u16 };
156
157 let effective_port = u16::try_from(s.proxy.port).ok().filter(|&p| p > 0)?;
158
159 let authority = format!("{host}.{tld}");
160
161 Some(if effective_port == standard_port {
162 format!("{scheme}://{authority}")
163 } else {
164 format!("{scheme}://{authority}:{effective_port}")
165 })
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 #[test]
173 fn a_throttled_message_reports_what_it_suppressed() {
174 use std::time::Duration;
175 let throttle = LogThrottle::new();
176 assert_eq!(throttle.allow(Duration::from_secs(60)), Some(0));
178 for _ in 0..5 {
180 assert_eq!(throttle.allow(Duration::from_secs(60)), None);
181 }
182 assert_eq!(throttle.allow(Duration::ZERO), Some(5));
184 assert_eq!(throttle.allow(Duration::ZERO), Some(0));
186 }
187
188 #[test]
189 fn owns_name_matches_the_apex_and_names_beneath_it() {
190 assert!(owns_name("localhost", "localhost"));
191 assert!(owns_name("localhost", "api.localhost"));
192 assert!(owns_name("localhost", "core.fix-refs.proj.localhost"));
193 assert!(owns_name("localhost", "API.LocalHost"));
194 assert!(owns_name("localhost", "api.localhost."));
195 assert!(owns_name("dev.internal", "api.dev.internal"));
196
197 assert!(!owns_name("localhost", "example.com"));
198 assert!(!owns_name("localhost", "notlocalhost"));
200 assert!(!owns_name("localhost", "localhost.evil.com"));
201 assert!(!owns_name("localhost", ""));
202 assert!(!owns_name("", "api.localhost"));
203 }
204
205 #[test]
206 fn is_strictly_under_tld_excludes_the_apex() {
207 assert!(is_strictly_under_tld("localhost", "api.localhost"));
208 assert!(!is_strictly_under_tld("localhost", "localhost"));
211 assert!(!is_strictly_under_tld("dev.internal", "dev.internal"));
212 assert!(is_strictly_under_tld("dev.internal", "a.dev.internal"));
213 assert!(!is_strictly_under_tld("localhost", "example.com"));
214 }
215
216 #[test]
217 fn test_ascii_case_collisions() {
218 let none = ascii_case_collisions(["myapp", "other", "third"].into_iter());
219 assert!(none.is_empty());
220
221 let folded = ascii_case_collisions(["MyApp", "myapp", "other"].into_iter());
222 assert_eq!(folded.len(), 1);
223 assert!(folded.contains("myapp"));
224
225 let exact = ascii_case_collisions(["dup", "dup"].into_iter());
227 assert!(exact.contains("dup"));
228
229 let unicode = ascii_case_collisions(["café", "CAFÉ"].into_iter());
231 assert!(unicode.is_empty());
232 }
233
234 fn proxy_settings() -> crate::settings::Settings {
235 let mut s = crate::settings::Settings::default();
236 s.proxy.enable = true;
237 s.proxy.https = true;
238 s.proxy.port = 443;
239 s.proxy.tld = "localhost".to_string();
240 s
241 }
242
243 #[test]
245 fn test_build_proxy_url_port_suffix() {
246 let mut s = proxy_settings();
247 assert_eq!(
248 build_proxy_url(Some("api.myproj"), &s).as_deref(),
249 Some("https://api.myproj.localhost")
250 );
251 s.proxy.port = 8088;
252 assert_eq!(
253 build_proxy_url(Some("api.myproj"), &s).as_deref(),
254 Some("https://api.myproj.localhost:8088")
255 );
256 s.proxy.https = false;
257 s.proxy.port = 80;
258 assert_eq!(
259 build_proxy_url(Some("api.myproj"), &s).as_deref(),
260 Some("http://api.myproj.localhost")
261 );
262 }
263
264 #[test]
267 fn test_build_proxy_url_requires_enabled_proxy_and_host() {
268 let mut s = proxy_settings();
269 assert_eq!(build_proxy_url(None, &s), None);
270 s.proxy.enable = false;
271 assert_eq!(build_proxy_url(Some("api.myproj"), &s), None);
272 }
273
274 #[test]
276 fn test_build_proxy_url_uses_lan_tld() {
277 let mut s = proxy_settings();
278 s.proxy.tld = "test".to_string();
279 assert_eq!(effective_tld(&s), "test");
280
281 s.proxy.lan = true;
282 assert_eq!(effective_tld(&s), "local");
283 assert_eq!(
284 build_proxy_url(Some("api.myproj"), &s).as_deref(),
285 Some("https://api.myproj.local")
286 );
287
288 s.proxy.lan = false;
289 s.proxy.lan_ip = "192.168.1.42".to_string();
290 assert_eq!(
291 build_proxy_url(Some("api.myproj"), &s).as_deref(),
292 Some("https://api.myproj.local")
293 );
294 }
295
296 #[test]
298 fn test_build_proxy_url_rejects_invalid_port() {
299 let mut s = proxy_settings();
300 s.proxy.port = 0;
301 assert_eq!(build_proxy_url(Some("api.myproj"), &s), None);
302 s.proxy.port = 70000;
303 assert_eq!(build_proxy_url(Some("api.myproj"), &s), None);
304 }
305}