use std::time::Duration;
use ureq::Agent;
use ureq::tls::{RootCerts, TlsConfig, TlsProvider};
const HTTP_TIMEOUT: Duration = Duration::from_secs(30);
pub const MAX_API_RESPONSE_SIZE: u64 = 10 * 1024 * 1024;
pub const MAX_DOWNLOAD_SIZE: u64 = 50 * 1024 * 1024;
const ALLOWED_HOSTS: &[&str] = &[
"github.com",
"api.github.com",
"objects.githubusercontent.com",
"github-releases.githubusercontent.com",
];
pub fn validate_update_url(url: &str) -> Result<(), String> {
let parsed = url::Url::parse(url).map_err(|e| format!("Invalid URL '{}': {}", url, e))?;
match parsed.scheme() {
"https" => {}
scheme => {
return Err(format!(
"Insecure URL scheme '{}' rejected; only HTTPS is allowed. \
URL: {}",
scheme, url
));
}
}
let host = parsed.host_str().unwrap_or("");
if !ALLOWED_HOSTS.contains(&host) {
return Err(format!(
"URL host '{}' is not in the allowed list for update operations. \
Allowed hosts: {}. \
URL: {}",
host,
ALLOWED_HOSTS.join(", "),
url
));
}
Ok(())
}
pub fn agent() -> Agent {
let tls_config = TlsConfig::builder()
.provider(TlsProvider::NativeTls)
.root_certs(RootCerts::PlatformVerifier)
.build();
Agent::config_builder()
.tls_config(tls_config)
.timeout_global(Some(HTTP_TIMEOUT))
.build()
.into()
}
pub fn download_file(url: &str) -> Result<Vec<u8>, String> {
validate_update_url(url)?;
let bytes = agent()
.get(url)
.header("User-Agent", "par-term")
.call()
.map_err(|e| {
format!(
"Failed to download '{}': {}. \
Check your internet connection and try again. \
If the problem persists, download manually from: \
https://github.com/paulrobello/par-term/releases",
url, e
)
})?
.into_body()
.with_config()
.limit(MAX_DOWNLOAD_SIZE)
.read_to_vec()
.map_err(|e| {
format!(
"Failed to read downloaded content from '{}': {}. \
The response may have been truncated or the connection dropped.",
url, e
)
})?;
Ok(bytes)
}
pub fn validate_binary_content(data: &[u8]) -> Result<(), String> {
let os = std::env::consts::OS;
match os {
"macos" => {
if data.len() < 4 || &data[..4] != b"PK\x03\x04" {
let preview = format_bytes_preview(data);
return Err(format!(
"Downloaded content does not look like a ZIP archive (expected PK\\x03\\x04 \
header for macOS release). Got: {}. \
This may indicate a corrupt download or an unexpected server response. \
Please try again or download manually from: \
https://github.com/paulrobello/par-term/releases",
preview
));
}
}
"linux" => {
if data.len() < 4 || &data[..4] != b"\x7fELF" {
let preview = format_bytes_preview(data);
return Err(format!(
"Downloaded content does not look like an ELF binary (expected \\x7fELF \
header for Linux release). Got: {}. \
This may indicate a corrupt download or an unexpected server response. \
Please try again or download manually from: \
https://github.com/paulrobello/par-term/releases",
preview
));
}
}
"windows" => {
if data.len() < 2 || &data[..2] != b"MZ" {
let preview = format_bytes_preview(data);
return Err(format!(
"Downloaded content does not look like a Windows executable (expected MZ \
header for Windows release). Got: {}. \
This may indicate a corrupt download or an unexpected server response. \
Please try again or download manually from: \
https://github.com/paulrobello/par-term/releases",
preview
));
}
}
other => {
log::warn!(
"Binary content validation skipped: unknown platform '{}'. \
Proceeding without magic-byte check.",
other
);
}
}
Ok(())
}
fn format_bytes_preview(data: &[u8]) -> String {
let take = data.len().min(16);
let hex: Vec<String> = data[..take].iter().map(|b| format!("{:02x}", b)).collect();
let ascii: String = data[..take]
.iter()
.map(|&b| if b.is_ascii_graphic() { b as char } else { '.' })
.collect();
format!("[{}] \"{}\"", hex.join(" "), ascii)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_api_github_com() {
assert!(
validate_update_url(
"https://api.github.com/repos/paulrobello/par-term/releases/latest"
)
.is_ok()
);
}
#[test]
fn test_valid_objects_githubusercontent_com() {
assert!(validate_update_url(
"https://objects.githubusercontent.com/github-production-release-asset-123/par-term-linux-x86_64"
)
.is_ok());
}
#[test]
fn test_valid_github_releases() {
assert!(
validate_update_url(
"https://github-releases.githubusercontent.com/123/par-term-linux-x86_64"
)
.is_ok()
);
}
#[test]
fn test_valid_github_com() {
assert!(validate_update_url("https://github.com/paulrobello/par-term/releases").is_ok());
}
#[test]
fn test_rejected_http_scheme() {
let result =
validate_update_url("http://api.github.com/repos/paulrobello/par-term/releases/latest");
assert!(result.is_err());
let msg = result.unwrap_err();
assert!(
msg.contains("http"),
"Error should mention the bad scheme: {msg}"
);
assert!(
msg.contains("HTTPS"),
"Error should mention HTTPS requirement: {msg}"
);
}
#[test]
fn test_rejected_file_scheme() {
let result = validate_update_url("file:///etc/passwd");
assert!(result.is_err());
let msg = result.unwrap_err();
assert!(
msg.contains("file"),
"Error should mention the bad scheme: {msg}"
);
}
#[test]
fn test_rejected_unknown_host() {
let result = validate_update_url("https://evil.example.com/par-term-linux-x86_64");
assert!(result.is_err());
let msg = result.unwrap_err();
assert!(
msg.contains("evil.example.com"),
"Error should name the rejected host: {msg}"
);
assert!(
msg.contains("allowed list"),
"Error should mention the allowlist: {msg}"
);
}
#[test]
fn test_rejected_lookalike_host() {
let result = validate_update_url("https://fake.api.github.com/releases");
assert!(result.is_err());
}
#[test]
fn test_rejected_invalid_url() {
let result = validate_update_url("not a url at all");
assert!(result.is_err());
let msg = result.unwrap_err();
assert!(
msg.contains("Invalid URL"),
"Error should mention parse failure: {msg}"
);
}
#[test]
#[cfg(target_os = "macos")]
fn test_macos_valid_zip() {
let data = b"PK\x03\x04rest of zip content";
assert!(validate_binary_content(data).is_ok());
}
#[test]
#[cfg(target_os = "macos")]
fn test_macos_invalid_not_zip() {
let data = b"<html>404 Not Found</html>";
let result = validate_binary_content(data);
assert!(result.is_err());
let msg = result.unwrap_err();
assert!(msg.contains("ZIP"), "Error should mention ZIP: {msg}");
}
#[test]
#[cfg(target_os = "linux")]
fn test_linux_valid_elf() {
let data = b"\x7fELFrest of elf binary";
assert!(validate_binary_content(data).is_ok());
}
#[test]
#[cfg(target_os = "linux")]
fn test_linux_invalid_not_elf() {
let data = b"<html>404 Not Found</html>";
let result = validate_binary_content(data);
assert!(result.is_err());
let msg = result.unwrap_err();
assert!(msg.contains("ELF"), "Error should mention ELF: {msg}");
}
#[test]
#[cfg(windows)]
fn test_windows_valid_pe() {
let data = b"MZrest of PE binary";
assert!(validate_binary_content(data).is_ok());
}
#[test]
#[cfg(windows)]
fn test_windows_invalid_not_pe() {
let data = b"<html>404 Not Found</html>";
let result = validate_binary_content(data);
assert!(result.is_err());
let msg = result.unwrap_err();
assert!(msg.contains("MZ"), "Error should mention MZ: {msg}");
}
#[test]
fn test_validate_binary_content_empty() {
let data: &[u8] = &[];
let os = std::env::consts::OS;
let result = validate_binary_content(data);
match os {
"macos" | "linux" | "windows" => {
assert!(result.is_err(), "Empty data should be rejected on {os}");
}
_ => {
assert!(result.is_ok());
}
}
}
#[test]
fn test_format_bytes_preview_short() {
let preview = format_bytes_preview(b"PK");
assert!(
preview.contains("50 4b"),
"Should contain hex for 'PK': {preview}"
);
assert!(
preview.contains("PK"),
"Should contain ASCII for 'PK': {preview}"
);
}
#[test]
fn test_format_bytes_preview_non_ascii() {
let preview = format_bytes_preview(b"\x7f\x00\xff");
assert!(
preview.contains("..."),
"Non-printable bytes should show as dots: {preview}"
);
}
}