par_term_update/
binary_ops.rs1use sha2::{Digest, Sha256};
9
10pub fn get_asset_name() -> Result<&'static str, String> {
12 let os = std::env::consts::OS;
13 let arch = std::env::consts::ARCH;
14
15 match (os, arch) {
16 ("macos", "aarch64") => Ok("par-term-macos-aarch64.zip"),
17 ("macos", "x86_64") => Ok("par-term-macos-x86_64.zip"),
18 ("linux", "aarch64") => Ok("par-term-linux-aarch64"),
19 ("linux", "x86_64") => Ok("par-term-linux-x86_64"),
20 ("windows", "x86_64") => Ok("par-term-windows-x86_64.exe"),
21 _ => Err(format!(
22 "Unsupported platform: {} {}. \
23 Please download manually from GitHub releases.",
24 os, arch
25 )),
26 }
27}
28
29pub fn get_checksum_asset_name() -> Result<String, String> {
33 let asset_name = get_asset_name()?;
34 Ok(format!("{}.sha256", asset_name))
35}
36
37pub fn compute_data_hash(data: &[u8]) -> String {
39 let mut hasher = Sha256::new();
40 hasher.update(data);
41 format!("{:x}", hasher.finalize())
42}
43
44pub struct DownloadUrls {
46 pub binary_url: String,
48 pub checksum_url: Option<String>,
50}
51
52pub fn get_download_urls(api_url: &str) -> Result<DownloadUrls, String> {
54 let asset_name = get_asset_name()?;
55 let checksum_name = get_checksum_asset_name()?;
56
57 crate::http::validate_update_url(api_url)?;
59
60 let mut body = crate::http::agent()
61 .get(api_url)
62 .header("User-Agent", "par-term")
63 .header("Accept", "application/vnd.github+json")
64 .call()
65 .map_err(|e| {
66 format!(
67 "Failed to fetch release info from '{}': {}. \
68 Check your internet connection and try again.",
69 api_url, e
70 )
71 })?
72 .into_body();
73
74 let body_str = body
75 .with_config()
76 .limit(crate::http::MAX_API_RESPONSE_SIZE)
77 .read_to_string()
78 .map_err(|e| format!("Failed to read response body: {}", e))?;
79
80 let json: serde_json::Value =
82 serde_json::from_str(&body_str).map_err(|e| format!("Failed to parse JSON: {}", e))?;
83
84 let mut binary_url: Option<String> = None;
85 let mut checksum_url: Option<String> = None;
86
87 if let Some(assets) = json.get("assets").and_then(|a| a.as_array()) {
88 for asset in assets {
89 if let Some(url) = asset.get("browser_download_url").and_then(|u| u.as_str()) {
90 if url.ends_with(&checksum_name) {
91 crate::http::validate_update_url(url).map_err(|e| {
95 format!(
96 "Checksum asset URL from GitHub release failed validation: {}",
97 e
98 )
99 })?;
100 checksum_url = Some(url.to_string());
101 } else if url.ends_with(asset_name) {
102 crate::http::validate_update_url(url).map_err(|e| {
103 format!(
104 "Binary asset URL from GitHub release failed validation: {}",
105 e
106 )
107 })?;
108 binary_url = Some(url.to_string());
109 }
110 }
111 }
112 }
113
114 match binary_url {
115 Some(url) => Ok(DownloadUrls {
116 binary_url: url,
117 checksum_url,
118 }),
119 None => Err(format!(
120 "Could not find asset '{}' in the latest GitHub release.\n\
121 This platform ({} {}) may not yet have a prebuilt binary for this release.\n\
122 Please download manually from https://github.com/paulrobello/par-term/releases",
123 asset_name,
124 std::env::consts::OS,
125 std::env::consts::ARCH,
126 )),
127 }
128}
129
130pub fn get_binary_download_url(api_url: &str) -> Result<String, String> {
135 get_download_urls(api_url).map(|urls| urls.binary_url)
136}
137
138pub(crate) fn parse_checksum_file(content: &str) -> Result<String, String> {
144 let trimmed = content.trim();
145 if trimmed.is_empty() {
146 return Err("Checksum file is empty".to_string());
147 }
148
149 let hash = trimmed
151 .split_whitespace()
152 .next()
153 .ok_or_else(|| "Checksum file is empty".to_string())?
154 .to_lowercase();
155
156 if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
158 return Err(format!(
159 "Checksum file does not contain a valid SHA256 hash (got '{}')",
160 hash
161 ));
162 }
163
164 Ok(hash)
165}
166
167pub(crate) fn verify_download(data: &[u8], checksum_url: Option<&str>) -> Result<(), String> {
175 let checksum_url = match checksum_url {
176 Some(url) => url,
177 None => {
178 log::warn!(
180 "No .sha256 checksum file found in release — \
181 skipping integrity verification. \
182 This is expected for older releases."
183 );
184 return Ok(());
185 }
186 };
187
188 let checksum_data = crate::http::download_file(checksum_url).map_err(|e| {
193 format!(
194 "Failed to download checksum file from {}: {}\n\
195 Update aborted for security — cannot verify binary integrity without checksum.\n\
196 This may indicate a network issue or a targeted attack blocking checksum verification.\n\
197 If the problem persists, please download manually from:\n\
198 https://github.com/paulrobello/par-term/releases",
199 checksum_url, e
200 )
201 })?;
202
203 let checksum_content = String::from_utf8(checksum_data)
204 .map_err(|_| "Checksum file contains invalid UTF-8".to_string())?;
205
206 let expected_hash = parse_checksum_file(&checksum_content)?;
207 let actual_hash = compute_data_hash(data);
208
209 if actual_hash != expected_hash {
210 return Err(format!(
211 "Checksum verification failed!\n\
212 Expected: {}\n\
213 Actual: {}\n\
214 The downloaded binary may be corrupted or tampered with. \
215 Update aborted for safety.",
216 expected_hash, actual_hash
217 ));
218 }
219
220 log::info!("SHA256 checksum verified successfully");
221 Ok(())
222}
223
224pub fn cleanup_old_binary() {
231 #[cfg(windows)]
232 {
233 if let Ok(current_exe) = std::env::current_exe() {
234 let old_path = current_exe.with_extension("old");
235 if old_path.exists() {
236 match std::fs::remove_file(&old_path) {
237 Ok(()) => {
238 log::info!(
239 "Cleaned up old binary from previous update: {}",
240 old_path.display()
241 );
242 }
243 Err(e) => {
244 log::warn!(
245 "Failed to clean up old binary {}: {}",
246 old_path.display(),
247 e
248 );
249 }
250 }
251 }
252 }
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 #[test]
261 fn test_get_asset_name() {
262 let result = get_asset_name();
264 assert!(
265 result.is_ok(),
266 "get_asset_name() should succeed on supported platforms"
267 );
268 let name = result.unwrap();
269 assert!(
270 name.starts_with("par-term-"),
271 "Asset name should start with 'par-term-'"
272 );
273 }
274
275 #[test]
276 fn test_get_checksum_asset_name() {
277 let result = get_checksum_asset_name();
278 assert!(result.is_ok());
279 let name = result.unwrap();
280 assert!(
281 name.ends_with(".sha256"),
282 "Checksum asset name should end with .sha256, got '{}'",
283 name
284 );
285 assert!(
286 name.starts_with("par-term-"),
287 "Checksum asset name should start with 'par-term-', got '{}'",
288 name
289 );
290 }
291
292 #[test]
293 fn test_compute_data_hash_known_value() {
294 let hash = compute_data_hash(b"hello world");
296 assert_eq!(
297 hash,
298 "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
299 );
300 }
301
302 #[test]
303 fn test_compute_data_hash_empty() {
304 let hash = compute_data_hash(b"");
305 assert_eq!(
306 hash,
307 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
308 );
309 }
310
311 #[test]
312 fn test_parse_checksum_file_plain_hash() {
313 let content = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9\n";
314 let hash = parse_checksum_file(content).unwrap();
315 assert_eq!(
316 hash,
317 "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
318 );
319 }
320
321 #[test]
322 fn test_parse_checksum_file_with_filename() {
323 let content = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9 par-term-linux-x86_64\n";
324 let hash = parse_checksum_file(content).unwrap();
325 assert_eq!(
326 hash,
327 "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
328 );
329 }
330
331 #[test]
332 fn test_parse_checksum_file_uppercase_normalized() {
333 let content = "B94D27B9934D3E08A52E52D7DA7DABFAC484EFE37A5380EE9088F7ACE2EFCDE9\n";
334 let hash = parse_checksum_file(content).unwrap();
335 assert_eq!(
336 hash,
337 "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
338 );
339 }
340
341 #[test]
342 fn test_parse_checksum_file_empty() {
343 let result = parse_checksum_file("");
344 assert!(result.is_err());
345 assert!(result.unwrap_err().contains("empty"));
346 }
347
348 #[test]
349 fn test_parse_checksum_file_invalid_hash() {
350 let result = parse_checksum_file("not-a-hash");
351 assert!(result.is_err());
352 assert!(result.unwrap_err().contains("valid SHA256"));
353 }
354
355 #[test]
356 fn test_parse_checksum_file_wrong_length() {
357 let result = parse_checksum_file("d41d8cd98f00b204e9800998ecf8427e");
359 assert!(result.is_err());
360 assert!(result.unwrap_err().contains("valid SHA256"));
361 }
362
363 #[test]
364 fn test_verify_download_no_checksum_url() {
365 let data = b"some binary data";
367 let result = verify_download(data, None);
368 assert!(result.is_ok());
369 }
370}