pitchfork_cli/proxy/
pac.rs1pub const PAC_PATH: &str = "/proxy.pac";
15
16use crate::proxy::hostname::MAX_HOSTNAME_LEN as MAX_HOSTNAME;
22
23const MAX_LABEL: usize = 63;
25
26pub fn is_valid_tld(tld: &str) -> bool {
38 if tld.is_empty() || tld.len() > MAX_HOSTNAME - 2 {
39 return false;
40 }
41 tld.split('.').all(|label| {
42 !label.is_empty()
43 && label.len() <= MAX_LABEL
44 && !label.starts_with('-')
45 && !label.ends_with('-')
46 && label
47 .bytes()
48 .all(|b| b.is_ascii_alphanumeric() || b == b'-')
49 })
50}
51
52pub fn hostname_fits(label: &str, tld: &str) -> bool {
57 label.len() <= MAX_LABEL && label.len() + 1 + tld.len() <= MAX_HOSTNAME
58}
59
60pub fn generate(tld: &str, host: &str, port: u16) -> crate::Result<String> {
65 if !is_valid_tld(tld) {
66 miette::bail!(
67 "proxy.tld {tld:?} is not a valid host name suffix, so no PAC file can be generated"
68 );
69 }
70 let tld = tld.to_ascii_lowercase();
71 Ok(format!(
72 "// Generated by pitchfork. Routes *.{tld} through the local proxy.\n\
73 function FindProxyForURL(url, host) {{\n\
74 \x20 host = host.toLowerCase();\n\
75 \x20 if (host === \"{tld}\" || dnsDomainIs(host, \".{tld}\")) {{\n\
76 \x20 return \"PROXY {host_}:{port}\";\n\
77 \x20 }}\n\
78 \x20 return \"DIRECT\";\n\
79 }}\n",
80 host_ = host,
81 ))
82}
83
84pub fn url(host: &str, port: u16) -> String {
86 format!("http://{host}:{port}{PAC_PATH}")
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92
93 #[test]
94 fn generates_a_script_that_routes_the_tld_and_nothing_else() {
95 let pac = generate("localhost", "127.0.0.1", 8443).unwrap();
96 assert!(pac.contains("function FindProxyForURL(url, host)"));
97 assert!(pac.contains(r#"host === "localhost""#));
98 assert!(pac.contains(r#"dnsDomainIs(host, ".localhost")"#));
99 assert!(pac.contains(r#"return "PROXY 127.0.0.1:8443";"#));
100 assert!(pac.contains(r#"return "DIRECT";"#));
101 }
102
103 #[test]
104 fn tld_is_lowercased_and_host_matching_is_case_insensitive() {
105 let pac = generate("TEST", "127.0.0.1", 80).unwrap();
106 assert!(pac.contains(r#"dnsDomainIs(host, ".test")"#));
107 assert!(pac.contains("host = host.toLowerCase();"));
108 }
109
110 #[test]
111 fn multi_label_tld_is_accepted() {
112 let pac = generate("dev.internal", "127.0.0.1", 443).unwrap();
113 assert!(pac.contains(r#"dnsDomainIs(host, ".dev.internal")"#));
114 }
115
116 #[test]
117 fn invalid_tlds_are_rejected_rather_than_escaped() {
118 for bad in [
119 "", "a\"b", "a b", "a;b", ".test", "test.", "a\nb", "a..b", "a/b",
120 ] {
121 assert!(
122 generate(bad, "127.0.0.1", 443).is_err(),
123 "expected {bad:?} to be rejected"
124 );
125 }
126 }
127
128 #[test]
129 fn labels_must_be_real_dns_labels() {
130 for bad in [
133 "-test",
134 "test-",
135 "a.-b",
136 "a.b-",
137 &"x".repeat(64),
138 &format!("a.{}", "x".repeat(64)),
139 &"a".repeat(252),
140 ] {
141 assert!(!is_valid_tld(bad), "expected {bad:?} to be rejected");
142 }
143 for good in ["localhost", "test", "dev.internal", "my-tld", "a-b.c-d"] {
144 assert!(is_valid_tld(good), "expected {good:?} to be allowed");
145 }
146 assert!(is_valid_tld(&"x".repeat(63)));
147 }
148
149 #[test]
150 fn a_name_longer_than_a_hostname_does_not_fit() {
151 assert!(hostname_fits("api", "localhost"));
152 assert!(hostname_fits("a", &"x".repeat(251)));
154 assert!(!hostname_fits("ab", &"x".repeat(251)));
155 }
156
157 #[test]
158 fn a_label_over_63_bytes_does_not_fit_even_in_a_short_name() {
159 assert!(hostname_fits(&"a".repeat(63), "localhost"));
162 assert!(!hostname_fits(&"a".repeat(64), "localhost"));
163 }
164
165 #[test]
166 fn url_points_at_the_proxy_listener() {
167 assert_eq!(url("127.0.0.1", 8443), "http://127.0.0.1:8443/proxy.pac");
168 }
169}