1use std::time::Duration;
23use ureq::Agent;
24use ureq::tls::{RootCerts, TlsConfig, TlsProvider};
25
26const HTTP_TIMEOUT: Duration = Duration::from_secs(30);
28
29pub const MAX_API_RESPONSE_SIZE: u64 = 10 * 1024 * 1024;
31
32pub const MAX_DOWNLOAD_SIZE: u64 = 50 * 1024 * 1024;
34
35const ALLOWED_HOSTS: &[&str] = &[
42 "github.com",
43 "api.github.com",
44 "objects.githubusercontent.com",
45 "github-releases.githubusercontent.com",
46];
47
48pub fn validate_update_url(url: &str) -> Result<(), String> {
57 let parsed = url::Url::parse(url).map_err(|e| format!("Invalid URL '{}': {}", url, e))?;
58
59 match parsed.scheme() {
61 "https" => {}
62 scheme => {
63 return Err(format!(
64 "Insecure URL scheme '{}' rejected; only HTTPS is allowed. \
65 URL: {}",
66 scheme, url
67 ));
68 }
69 }
70
71 let host = parsed.host_str().unwrap_or("");
73 if !ALLOWED_HOSTS.contains(&host) {
74 return Err(format!(
75 "URL host '{}' is not in the allowed list for update operations. \
76 Allowed hosts: {}. \
77 URL: {}",
78 host,
79 ALLOWED_HOSTS.join(", "),
80 url
81 ));
82 }
83
84 Ok(())
85}
86
87pub fn agent() -> Agent {
89 let tls_config = TlsConfig::builder()
90 .provider(TlsProvider::NativeTls)
91 .root_certs(RootCerts::PlatformVerifier)
92 .build();
93
94 Agent::config_builder()
95 .tls_config(tls_config)
96 .timeout_global(Some(HTTP_TIMEOUT))
97 .build()
98 .into()
99}
100
101pub fn download_file(url: &str) -> Result<Vec<u8>, String> {
114 validate_update_url(url)?;
116
117 let bytes = agent()
118 .get(url)
119 .header("User-Agent", "par-term")
120 .call()
121 .map_err(|e| {
122 format!(
123 "Failed to download '{}': {}. \
124 Check your internet connection and try again. \
125 If the problem persists, download manually from: \
126 https://github.com/paulrobello/par-term/releases",
127 url, e
128 )
129 })?
130 .into_body()
131 .with_config()
132 .limit(MAX_DOWNLOAD_SIZE)
133 .read_to_vec()
134 .map_err(|e| {
135 format!(
136 "Failed to read downloaded content from '{}': {}. \
137 The response may have been truncated or the connection dropped.",
138 url, e
139 )
140 })?;
141
142 Ok(bytes)
143}
144
145pub fn validate_binary_content(data: &[u8]) -> Result<(), String> {
158 let os = std::env::consts::OS;
159
160 match os {
161 "macos" => {
162 if data.len() < 4 || &data[..4] != b"PK\x03\x04" {
164 let preview = format_bytes_preview(data);
165 return Err(format!(
166 "Downloaded content does not look like a ZIP archive (expected PK\\x03\\x04 \
167 header for macOS release). Got: {}. \
168 This may indicate a corrupt download or an unexpected server response. \
169 Please try again or download manually from: \
170 https://github.com/paulrobello/par-term/releases",
171 preview
172 ));
173 }
174 }
175 "linux" => {
176 if data.len() < 4 || &data[..4] != b"\x7fELF" {
178 let preview = format_bytes_preview(data);
179 return Err(format!(
180 "Downloaded content does not look like an ELF binary (expected \\x7fELF \
181 header for Linux release). Got: {}. \
182 This may indicate a corrupt download or an unexpected server response. \
183 Please try again or download manually from: \
184 https://github.com/paulrobello/par-term/releases",
185 preview
186 ));
187 }
188 }
189 "windows" => {
190 if data.len() < 2 || &data[..2] != b"MZ" {
192 let preview = format_bytes_preview(data);
193 return Err(format!(
194 "Downloaded content does not look like a Windows executable (expected MZ \
195 header for Windows release). Got: {}. \
196 This may indicate a corrupt download or an unexpected server response. \
197 Please try again or download manually from: \
198 https://github.com/paulrobello/par-term/releases",
199 preview
200 ));
201 }
202 }
203 other => {
204 log::warn!(
206 "Binary content validation skipped: unknown platform '{}'. \
207 Proceeding without magic-byte check.",
208 other
209 );
210 }
211 }
212
213 Ok(())
214}
215
216fn format_bytes_preview(data: &[u8]) -> String {
220 let take = data.len().min(16);
221 let hex: Vec<String> = data[..take].iter().map(|b| format!("{:02x}", b)).collect();
222 let ascii: String = data[..take]
223 .iter()
224 .map(|&b| if b.is_ascii_graphic() { b as char } else { '.' })
225 .collect();
226 format!("[{}] \"{}\"", hex.join(" "), ascii)
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 #[test]
236 fn test_valid_api_github_com() {
237 assert!(
238 validate_update_url(
239 "https://api.github.com/repos/paulrobello/par-term/releases/latest"
240 )
241 .is_ok()
242 );
243 }
244
245 #[test]
246 fn test_valid_objects_githubusercontent_com() {
247 assert!(validate_update_url(
248 "https://objects.githubusercontent.com/github-production-release-asset-123/par-term-linux-x86_64"
249 )
250 .is_ok());
251 }
252
253 #[test]
254 fn test_valid_github_releases() {
255 assert!(
256 validate_update_url(
257 "https://github-releases.githubusercontent.com/123/par-term-linux-x86_64"
258 )
259 .is_ok()
260 );
261 }
262
263 #[test]
264 fn test_valid_github_com() {
265 assert!(validate_update_url("https://github.com/paulrobello/par-term/releases").is_ok());
266 }
267
268 #[test]
269 fn test_rejected_http_scheme() {
270 let result =
271 validate_update_url("http://api.github.com/repos/paulrobello/par-term/releases/latest");
272 assert!(result.is_err());
273 let msg = result.unwrap_err();
274 assert!(
275 msg.contains("http"),
276 "Error should mention the bad scheme: {msg}"
277 );
278 assert!(
279 msg.contains("HTTPS"),
280 "Error should mention HTTPS requirement: {msg}"
281 );
282 }
283
284 #[test]
285 fn test_rejected_file_scheme() {
286 let result = validate_update_url("file:///etc/passwd");
287 assert!(result.is_err());
288 let msg = result.unwrap_err();
289 assert!(
290 msg.contains("file"),
291 "Error should mention the bad scheme: {msg}"
292 );
293 }
294
295 #[test]
296 fn test_rejected_unknown_host() {
297 let result = validate_update_url("https://evil.example.com/par-term-linux-x86_64");
298 assert!(result.is_err());
299 let msg = result.unwrap_err();
300 assert!(
301 msg.contains("evil.example.com"),
302 "Error should name the rejected host: {msg}"
303 );
304 assert!(
305 msg.contains("allowed list"),
306 "Error should mention the allowlist: {msg}"
307 );
308 }
309
310 #[test]
311 fn test_rejected_lookalike_host() {
312 let result = validate_update_url("https://fake.api.github.com/releases");
314 assert!(result.is_err());
315 }
316
317 #[test]
318 fn test_rejected_invalid_url() {
319 let result = validate_update_url("not a url at all");
320 assert!(result.is_err());
321 let msg = result.unwrap_err();
322 assert!(
323 msg.contains("Invalid URL"),
324 "Error should mention parse failure: {msg}"
325 );
326 }
327
328 #[test]
331 #[cfg(target_os = "macos")]
332 fn test_macos_valid_zip() {
333 let data = b"PK\x03\x04rest of zip content";
335 assert!(validate_binary_content(data).is_ok());
336 }
337
338 #[test]
339 #[cfg(target_os = "macos")]
340 fn test_macos_invalid_not_zip() {
341 let data = b"<html>404 Not Found</html>";
342 let result = validate_binary_content(data);
343 assert!(result.is_err());
344 let msg = result.unwrap_err();
345 assert!(msg.contains("ZIP"), "Error should mention ZIP: {msg}");
346 }
347
348 #[test]
349 #[cfg(target_os = "linux")]
350 fn test_linux_valid_elf() {
351 let data = b"\x7fELFrest of elf binary";
352 assert!(validate_binary_content(data).is_ok());
353 }
354
355 #[test]
356 #[cfg(target_os = "linux")]
357 fn test_linux_invalid_not_elf() {
358 let data = b"<html>404 Not Found</html>";
359 let result = validate_binary_content(data);
360 assert!(result.is_err());
361 let msg = result.unwrap_err();
362 assert!(msg.contains("ELF"), "Error should mention ELF: {msg}");
363 }
364
365 #[test]
366 #[cfg(windows)]
367 fn test_windows_valid_pe() {
368 let data = b"MZrest of PE binary";
369 assert!(validate_binary_content(data).is_ok());
370 }
371
372 #[test]
373 #[cfg(windows)]
374 fn test_windows_invalid_not_pe() {
375 let data = b"<html>404 Not Found</html>";
376 let result = validate_binary_content(data);
377 assert!(result.is_err());
378 let msg = result.unwrap_err();
379 assert!(msg.contains("MZ"), "Error should mention MZ: {msg}");
380 }
381
382 #[test]
383 fn test_validate_binary_content_empty() {
384 let data: &[u8] = &[];
387 let os = std::env::consts::OS;
388 let result = validate_binary_content(data);
389 match os {
390 "macos" | "linux" | "windows" => {
391 assert!(result.is_err(), "Empty data should be rejected on {os}");
392 }
393 _ => {
394 assert!(result.is_ok());
396 }
397 }
398 }
399
400 #[test]
403 fn test_format_bytes_preview_short() {
404 let preview = format_bytes_preview(b"PK");
405 assert!(
406 preview.contains("50 4b"),
407 "Should contain hex for 'PK': {preview}"
408 );
409 assert!(
410 preview.contains("PK"),
411 "Should contain ASCII for 'PK': {preview}"
412 );
413 }
414
415 #[test]
416 fn test_format_bytes_preview_non_ascii() {
417 let preview = format_bytes_preview(b"\x7f\x00\xff");
418 assert!(
420 preview.contains("..."),
421 "Non-printable bytes should show as dots: {preview}"
422 );
423 }
424}