Skip to main content

pacenotes_installer/
http.rs

1//! The installer's only HTTP client: WinHTTP, whose TLS is the OS's own Schannel.
2//! Blocking, so it runs on the worker thread, never the UI thread.
3
4mod http_error;
5mod request_handle;
6mod url_target;
7
8pub use http_error::HttpError;
9
10/// `(bytes_so_far, content_length)`; the length is `None` for a chunked response.
11pub type ProgressFn<'a> = &'a mut dyn FnMut(u64, Option<u64>);
12
13/// Without timeouts a dead server parks the worker forever, since Cancel is
14/// only noticed between chunks.
15const TIMEOUT_RESOLVE_MS: i32 = 10_000;
16const TIMEOUT_CONNECT_MS: i32 = 10_000;
17const TIMEOUT_SEND_MS: i32 = 30_000;
18const TIMEOUT_RECEIVE_MS: i32 = 30_000;
19
20/// Small enough that Cancel is noticed promptly.
21const CHUNK_SIZE: usize = 64 * 1024;
22
23/// The server's access log records it, so a report can be traced to the build that sent it.
24const USER_AGENT: &str = concat!("Pacenotes-Installer/", env!("PACENOTES_VERSION"));
25
26/// What `get` holds in memory: far beyond any real manifest, which runs to
27/// kilobytes, and small enough that a server sending without end cannot
28/// exhaust the machine; that the documents are small is the server's promise,
29/// not ours.
30const MAX_BUFFERED_BYTES: usize = 8 * 1024 * 1024;
31
32/// The whole body in memory, up to `MAX_BUFFERED_BYTES`: right for a manifest,
33/// wrong for a package zip.
34pub fn get(
35    url: &str,
36    bearer: Option<&str>,
37    progress: ProgressFn,
38    cancel: &std::sync::atomic::AtomicBool,
39) -> Result<Vec<u8>, HttpError> {
40    let mut body = Vec::new();
41    let mut too_large = false;
42    let fetched = get_streaming(
43        url,
44        bearer,
45        &mut |chunk| {
46            too_large = body.len() + chunk.len() > MAX_BUFFERED_BYTES;
47            if !too_large {
48                body.extend_from_slice(chunk);
49            }
50            !too_large
51        },
52        progress,
53        cancel,
54    );
55    if too_large {
56        return Err(HttpError::logical(
57            format!(
58                "the response is larger than the {} accepted from {url}",
59                pacenotes_installer::size::human(MAX_BUFFERED_BYTES as u64)
60            ),
61            None,
62        ));
63    }
64    fetched.map(|()| body)
65}
66
67/// Hands each chunk to `sink` as it arrives; `sink` returns false to abort.
68/// The bearer token is a credential and is never logged.
69pub fn get_streaming(
70    url: &str,
71    bearer: Option<&str>,
72    sink: &mut dyn FnMut(&[u8]) -> bool,
73    progress: ProgressFn,
74    cancel: &std::sync::atomic::AtomicBool,
75) -> Result<(), HttpError> {
76    send("GET", url, bearer, None, sink, progress, cancel)
77}
78
79/// Posts JSON without a token, since what the installer sends carries no
80/// identity, and discards the response body.
81pub fn post_json(
82    url: &str,
83    body: &[u8],
84    cancel: &std::sync::atomic::AtomicBool,
85) -> Result<(), HttpError> {
86    let mut ignore_progress = |_: u64, _: Option<u64>| {};
87    send(
88        "POST",
89        url,
90        None,
91        Some(body),
92        &mut |_| true,
93        &mut ignore_progress,
94        cancel,
95    )
96}
97
98/// Enough for a host move followed by a path rewrite, and a loop ends in
99/// milliseconds rather than on the timeout.
100const MAX_REDIRECTS: usize = 3;
101
102/// Follows redirects itself, since WinHTTP left to itself re-sends the bearer
103/// token to whatever origin a 3xx names (measured 2026-10-30).
104///
105/// The token goes only while scheme, host and port match what the caller asked
106/// for, https may never become http, and a request carrying a body is not
107/// redirected at all, rather than quietly re-sent or turned into a GET.
108fn send(
109    method: &str,
110    url: &str,
111    bearer: Option<&str>,
112    body: Option<&[u8]>,
113    sink: &mut dyn FnMut(&[u8]) -> bool,
114    progress: ProgressFn,
115    cancel: &std::sync::atomic::AtomicBool,
116) -> Result<(), HttpError> {
117    let mut target = url_target::UrlTarget::parse(url)?;
118    let origin = target.origin();
119    for _ in 0..=MAX_REDIRECTS {
120        let token = bearer.filter(|_| target.origin() == origin);
121        let open = request_handle::OpenRequest::send(&target, method, token, body)?;
122        let status = open
123            .query_number(windows_sys::Win32::Networking::WinHttp::WINHTTP_QUERY_STATUS_CODE)?
124            as u16;
125        let Some(location) = redirect_location(&open, status, body.is_some())? else {
126            return read_response(&open, status, &target, sink, progress, cancel);
127        };
128        let next = target.resolve(&location)?;
129        if target.secure && !next.secure {
130            return Err(HttpError::logical(
131                format!("refusing a redirect from https to http ({location})"),
132                None,
133            ));
134        }
135        target = next;
136    }
137    Err(HttpError::logical(
138        format!("gave up after {MAX_REDIRECTS} redirects from {url}"),
139        None,
140    ))
141}
142
143/// The `Location` of a 3xx, whose body is never read: it belongs to a response
144/// the caller is not getting, and would otherwise land in a package file.
145fn redirect_location(
146    open: &request_handle::OpenRequest,
147    status: u16,
148    has_body: bool,
149) -> Result<Option<String>, HttpError> {
150    if !(300..400).contains(&status) {
151        return Ok(None);
152    }
153    if has_body {
154        return Err(HttpError::logical(
155            format!("HTTP {status}: refusing to redirect a request with a body"),
156            Some(status),
157        ));
158    }
159    open.query_text(windows_sys::Win32::Networking::WinHttp::WINHTTP_QUERY_LOCATION)
160        .map(Some)
161        .ok_or_else(|| {
162            HttpError::logical(
163                format!("HTTP {status} without a Location header"),
164                Some(status),
165            )
166        })
167}
168
169fn read_response(
170    open: &request_handle::OpenRequest,
171    status: u16,
172    target: &url_target::UrlTarget,
173    sink: &mut dyn FnMut(&[u8]) -> bool,
174    progress: ProgressFn,
175    cancel: &std::sync::atomic::AtomicBool,
176) -> Result<(), HttpError> {
177    if !(200..300).contains(&status) {
178        return Err(HttpError::logical(
179            format!("HTTP {status} for {}", target.url()),
180            Some(status),
181        ));
182    }
183    let content_length = open
184        .query_number(windows_sys::Win32::Networking::WinHttp::WINHTTP_QUERY_CONTENT_LENGTH)
185        .ok()
186        .map(u64::from);
187
188    let bytes_read = read_body(open, sink, progress, content_length, cancel)?;
189    if let Some(expected) = content_length {
190        if bytes_read != expected {
191            return Err(HttpError::logical(
192                format!("truncated response: expected {expected} bytes, read {bytes_read}"),
193                Some(status),
194            ));
195        }
196    }
197    Ok(())
198}
199
200fn read_body(
201    open: &request_handle::OpenRequest,
202    sink: &mut dyn FnMut(&[u8]) -> bool,
203    progress: ProgressFn,
204    content_length: Option<u64>,
205    cancel: &std::sync::atomic::AtomicBool,
206) -> Result<u64, HttpError> {
207    let mut bytes_read: u64 = 0;
208    let mut chunk = [0u8; CHUNK_SIZE];
209    loop {
210        if cancel.load(std::sync::atomic::Ordering::Relaxed) {
211            return Err(HttpError::cancelled());
212        }
213        let read = open.read(&mut chunk)?;
214        if read == 0 {
215            return Ok(bytes_read);
216        }
217        if !sink(&chunk[..read]) {
218            return Err(HttpError::logical("aborted by caller", None));
219        }
220        bytes_read += read as u64;
221        progress(bytes_read, content_length);
222    }
223}