1use crate::SyncError;
28use base64::Engine;
29use base64::engine::general_purpose::URL_SAFE_NO_PAD;
30use sha2::{Digest, Sha256};
31use std::collections::HashMap;
32use std::io::{Read, Write};
33use std::net::{TcpListener, TcpStream};
34use std::sync::atomic::{AtomicBool, Ordering};
35use std::time::{Duration, Instant};
36
37pub(crate) fn open(ipv6: bool) -> Result<(TcpListener, String), SyncError> {
42 let bound = TcpListener::bind("127.0.0.1:0");
43 let listener = match bound {
44 Ok(listener) => listener,
45 Err(_) if ipv6 => TcpListener::bind("[::1]:0").map_err(|e| SyncError::Network(e.to_string()))?,
46 Err(e) => return Err(SyncError::Network(e.to_string())),
47 };
48 let address = listener.local_addr().map_err(|e| SyncError::Network(e.to_string()))?;
49 let redirect = if address.is_ipv6() { format!("http://[::1]:{}", address.port()) } else { format!("http://127.0.0.1:{}", address.port()) };
50 Ok((listener, redirect))
51}
52
53pub(crate) fn random(bytes: usize) -> String {
55 let mut out = Vec::with_capacity(bytes);
56 while out.len() < bytes {
57 out.extend_from_slice(uuid::Uuid::new_v4().as_bytes());
58 }
59 out.truncate(bytes);
60 URL_SAFE_NO_PAD.encode(out)
61}
62
63pub(crate) fn challenge(verifier: &str) -> String {
65 URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()))
66}
67
68pub(crate) fn query(pairs: &[(&str, &str)]) -> String {
70 pairs.iter().map(|(k, v)| format!("{k}={}", encode(v))).collect::<Vec<_>>().join("&")
71}
72
73pub(crate) fn encode(text: &str) -> String {
75 text.bytes().map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }).collect()
76}
77
78pub(crate) fn decode(text: &str) -> String {
80 let bytes = text.as_bytes();
81 let mut out = Vec::with_capacity(bytes.len());
82 let mut i = 0;
83 while i < bytes.len() {
84 match bytes[i] {
85 b'%' if i + 2 < bytes.len() => {
86 let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok().and_then(|h| u8::from_str_radix(h, 16).ok());
87 match hex {
88 Some(b) => {
89 out.push(b);
90 i += 3;
91 }
92 None => {
93 out.push(b'%');
94 i += 1;
95 }
96 }
97 }
98 b'+' => {
99 out.push(b' ');
100 i += 1;
101 }
102 b => {
103 out.push(b);
104 i += 1;
105 }
106 }
107 }
108 String::from_utf8_lossy(&out).to_string()
109}
110
111pub(crate) struct Answer {
114 pub params: HashMap<String, String>,
115 pub state_ok: bool,
116}
117
118impl Answer {
119 pub fn get(&self, key: &str) -> Option<&str> {
120 self.params.get(key).map(String::as_str)
121 }
122}
123
124pub(crate) enum Verdict {
127 Done(&'static str, Result<String, SyncError>),
129 Ignored(&'static str),
131}
132
133pub(crate) struct Ends {
135 pub cancelled: SyncError,
136 pub timed_out: SyncError,
137}
138
139const LINE_BYTES: usize = 16 * 1024;
143const LINE_TIME: Duration = Duration::from_secs(2);
145const LATE_CONNECTIONS: usize = 16;
149
150fn request_line(stream: &TcpStream, stop: &AtomicBool) -> Option<String> {
156 let started = Instant::now();
157 let mut head: Vec<u8> = Vec::with_capacity(4096);
158 let mut chunk = [0u8; 4096];
159 let line = |head: &[u8]| head.iter().position(|&b| b == b'\n').map(|end| String::from_utf8_lossy(&head[..end]).trim_end_matches('\r').to_string());
160 loop {
161 if stop.load(Ordering::Relaxed) {
162 return None;
163 }
164 let left = LINE_TIME.checked_sub(started.elapsed())?;
165 stream.set_read_timeout(Some(left.clamp(Duration::from_millis(1), Duration::from_millis(200)))).ok()?;
167 match (&*stream).read(&mut chunk) {
168 Ok(0) => return line(&head),
169 Ok(read) => {
170 let from = head.len().saturating_sub(3);
172 head.extend_from_slice(&chunk[..read]);
173 let tail = &head[from..];
174 if tail.windows(4).any(|w| w == b"\r\n\r\n") || tail.windows(2).any(|w| w == b"\n\n") {
175 return line(&head);
176 }
177 if head.len() > LINE_BYTES {
178 return None;
179 }
180 }
181 Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut | std::io::ErrorKind::Interrupted) => continue,
182 Err(_) => return None,
183 }
184 }
185}
186
187pub(crate) fn wait(listener: &TcpListener, state: &str, deadline: Duration, stop: &AtomicBool, ends: Ends, judge: impl Fn(&Answer) -> Verdict) -> Result<String, SyncError> {
191 listener.set_nonblocking(true).map_err(|e| SyncError::Network(e.to_string()))?;
192 let until = Instant::now() + deadline;
193 let mut late = 0;
194 loop {
195 if stop.load(Ordering::Relaxed) {
196 return Err(ends.cancelled);
197 }
198 let (mut stream, _) = match listener.accept() {
202 Ok(accepted) => accepted,
203 Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
204 if Instant::now() > until {
205 return Err(ends.timed_out);
206 }
207 std::thread::sleep(Duration::from_millis(200));
208 continue;
209 }
210 Err(e) => return Err(SyncError::Network(e.to_string())),
211 };
212 if Instant::now() > until {
214 late += 1;
215 if late > LATE_CONNECTIONS {
216 return Err(ends.timed_out);
217 }
218 }
219 let _ = stream.set_nonblocking(false);
220 let _ = stream.set_write_timeout(Some(LINE_TIME));
221 let Some(line) = request_line(&stream, stop) else { continue };
222 let target = line.split_whitespace().nth(1).unwrap_or("");
224 let Some(params) = target.strip_prefix("/?") else {
225 let _ = stream.write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
226 continue;
227 };
228 let params: HashMap<String, String> = params.split('&').filter_map(|p| p.split_once('=')).map(|(k, v)| (k.to_string(), decode(v))).collect();
229 let state_ok = params.get("state").is_some_and(|said| subtle::ConstantTimeEq::ct_eq(said.as_bytes(), state.as_bytes()).into());
230 let (said, outcome) = match judge(&Answer { params, state_ok }) {
231 Verdict::Done(said, outcome) => (said, Some(outcome)),
232 Verdict::Ignored(said) => (said, None),
233 };
234 let body = crate::translator().text(said, None).replace('&', "&").replace('<', "<");
236 let page = format!("<!doctype html><meta charset=utf-8><title>Sioul</title><p style=\"font:1.2em sans-serif;margin:3em\">{body}</p>");
237 let _ = stream.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{page}", page.len()).as_bytes());
238 if let Some(outcome) = outcome {
239 return outcome;
240 }
241 }
242}
243
244#[cfg(test)]
249pub(crate) fn browser_answer(port: u16, target: &str, size: usize) -> std::io::Result<String> {
250 let head = format!(
251 "GET {target} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: keep-alive\r\nUpgrade-Insecure-Requests: 1\r\nUser-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36\r\nAccept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7\r\nSec-Fetch-Site: cross-site\r\nSec-Fetch-Mode: navigate\r\nSec-Fetch-Dest: document\r\nsec-ch-ua: \"Chromium\";v=\"141\", \"Not?A_Brand\";v=\"8\"\r\nsec-ch-ua-mobile: ?0\r\nsec-ch-ua-platform: \"Linux\"\r\nAccept-Encoding: gzip, deflate, br, zstd\r\nAccept-Language: fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7\r\n"
252 );
253 let cookie = |pad: usize| format!("Cookie: pad={}\r\n", "c".repeat(pad));
254 let pad = size.saturating_sub(head.len() + cookie(0).len() + 2);
255 let request = format!("{head}{}\r\n", if pad > 0 { cookie(pad) } else { String::new() });
256 let mut stream = std::net::TcpStream::connect(("127.0.0.1", port))?;
257 stream.set_read_timeout(Some(Duration::from_secs(10)))?;
258 stream.write_all(request.as_bytes())?;
259 let mut answer = Vec::new();
260 stream.read_to_end(&mut answer)?;
261 Ok(String::from_utf8_lossy(&answer).to_string())
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 #[test]
273 fn a_browser_gets_its_page_and_an_orderly_end() {
274 let judge = |answer: &Answer| match (answer.state_ok, answer.get("code")) {
275 (true, Some(code)) => Verdict::Done("google-page-granted", Ok(code.to_string())),
276 _ => Verdict::Ignored("google-page-foreign"),
277 };
278 for size in (800..=3000).step_by(110) {
279 let (listener, _) = open(false).unwrap();
280 let port = listener.local_addr().unwrap().port();
281 let browser = std::thread::spawn(move || browser_answer(port, "/?state=the-state&code=the-code&scope=openid", size));
282 let ends = Ends { cancelled: SyncError::Message("cancelled".into()), timed_out: SyncError::Message("timed out".into()) };
283 assert_eq!(wait(&listener, "the-state", Duration::from_secs(10), &AtomicBool::new(false), ends, judge).unwrap(), "the-code", "{size} bytes");
284 let answer = browser.join().unwrap();
285 assert!(answer.as_ref().is_ok_and(|page| page.starts_with("HTTP/1.1 200") && page.contains("</p>")), "{size} bytes: {answer:?}");
286 }
287 }
288
289 #[test]
290 fn values_go_and_come_back_whole() {
291 assert_eq!(encode("a b/c@d.example"), "a%20b%2Fc%40d.example");
292 assert_eq!(decode("a%20b+c%2Fd%zz"), "a b c/d%zz");
293 assert_eq!(query(&[("a", "1 2"), ("b", "x&y")]), "a=1%202&b=x%26y");
294 let verifier = random(48);
296 assert_eq!(verifier.len(), 64);
297 assert_eq!(challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"), "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", "RFC 7636's example, appendix B");
298 }
299
300 #[test]
305 fn a_slow_or_endless_stranger_does_not_hold_the_door() {
306 let judge = |answer: &Answer| match (answer.state_ok, answer.get("code")) {
307 (true, Some(code)) => Verdict::Done("google-page-granted", Ok(code.to_string())),
308 _ => Verdict::Ignored("google-page-foreign"),
309 };
310 let ends = || Ends { cancelled: SyncError::Message("cancelled".into()), timed_out: SyncError::Message("timed out".into()) };
311 let (listener, _) = open(false).unwrap();
313 let port = listener.local_addr().unwrap().port();
314 let dribbler = std::thread::spawn(move || {
315 let mut stream = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap();
316 for byte in b"GET /?state=".iter().cycle().take(40) {
317 if stream.write_all(&[*byte]).is_err() {
318 break;
319 }
320 std::thread::sleep(Duration::from_millis(300));
321 }
322 });
323 let stop = std::sync::Arc::new(AtomicBool::new(false));
324 let stopper = {
325 let stop = std::sync::Arc::clone(&stop);
326 std::thread::spawn(move || {
327 std::thread::sleep(Duration::from_millis(500));
328 stop.store(true, Ordering::Relaxed);
329 })
330 };
331 let started = Instant::now();
332 assert_eq!(wait(&listener, "the-state", Duration::from_secs(60), &stop, ends(), judge).err(), Some(SyncError::Message("cancelled".into())));
333 assert!(started.elapsed() < Duration::from_millis(1500), "Cancel heard within a fifth of a second: {:?}", started.elapsed());
334 stopper.join().unwrap();
335 let (listener, _) = open(false).unwrap();
337 let port = listener.local_addr().unwrap().port();
338 let endless = std::thread::spawn(move || {
339 let mut stream = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap();
340 let _ = stream.write_all(&[b'A'; 64 * 1024]);
341 let mut browser = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap();
342 browser.write_all(b"GET /?state=the-state&code=the-code HTTP/1.1\r\n\r\n").unwrap();
343 let mut answer = String::new();
344 let _ = browser.read_to_string(&mut answer);
345 answer
346 });
347 let started = Instant::now();
348 assert_eq!(wait(&listener, "the-state", Duration::from_secs(60), &AtomicBool::new(false), ends(), judge).unwrap(), "the-code");
349 assert!(started.elapsed() < Duration::from_secs(3), "{:?}", started.elapsed());
350 assert!(endless.join().unwrap().starts_with("HTTP/1.1 200"));
351 dribbler.join().unwrap();
352 let (listener, _) = open(false).unwrap();
354 let port = listener.local_addr().unwrap().port();
355 let strangers: Vec<std::net::TcpStream> = (0..20)
356 .map(|_| {
357 let mut stream = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap();
358 stream.write_all(b"GET /?state=another HTTP/1.1\r\n\r\n").unwrap();
359 stream
360 })
361 .collect();
362 let heard = std::sync::atomic::AtomicUsize::new(0);
363 let counting = |answer: &Answer| {
364 heard.fetch_add(1, Ordering::Relaxed);
365 judge(answer)
366 };
367 assert_eq!(wait(&listener, "the-state", Duration::ZERO, &AtomicBool::new(false), ends(), counting).err(), Some(SyncError::Message("timed out".into())));
368 assert_eq!(heard.load(Ordering::Relaxed), LATE_CONNECTIONS);
369 drop(strangers);
370 }
371
372 #[test]
375 fn the_door_is_on_the_loopback_address() {
376 let (listener, redirect) = open(false).unwrap();
377 assert!(listener.local_addr().unwrap().ip().is_loopback());
378 assert_eq!(redirect, format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port()));
379 }
380}