Skip to main content

par_term_update/
binary_ops.rs

1//! Binary download, hash verification, and cleanup operations for self-update.
2//!
3//! This module is responsible for:
4//! - Determining the platform-specific asset name and download URLs
5//! - Computing and verifying SHA256 checksums
6//! - Cleaning up leftover `.old` binaries from previous updates
7
8use sha2::{Digest, Sha256};
9
10/// Get the platform-specific asset name for the current OS/architecture.
11pub 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
29/// Get the checksum asset name for the current platform.
30///
31/// Returns the expected `.sha256` filename, e.g. `par-term-macos-aarch64.zip.sha256`.
32pub fn get_checksum_asset_name() -> Result<String, String> {
33    let asset_name = get_asset_name()?;
34    Ok(format!("{}.sha256", asset_name))
35}
36
37/// Compute SHA256 hash of in-memory data, returning the lowercase hex string.
38pub fn compute_data_hash(data: &[u8]) -> String {
39    let mut hasher = Sha256::new();
40    hasher.update(data);
41    format!("{:x}", hasher.finalize())
42}
43
44/// Download URLs for the binary and optional checksum from a GitHub release.
45pub struct DownloadUrls {
46    /// URL for the platform binary/archive asset
47    pub binary_url: String,
48    /// URL for the `.sha256` checksum file, if present in the release
49    pub checksum_url: Option<String>,
50}
51
52/// Get the download URLs for the platform binary and checksum from the release API response.
53pub 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    // Validate the API URL before making the request.
58    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    // Parse JSON and extract browser_download_url values from assets array
81    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                    // Validate each download URL extracted from the release JSON
92                    // before storing it — a compromised release payload could
93                    // otherwise inject a URL pointing to an attacker-controlled host.
94                    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
130/// Get the download URL for the platform binary from the release API response.
131///
132/// This is a convenience wrapper around [`get_download_urls`] that returns only
133/// the binary URL, for callers that don't need checksum verification.
134pub fn get_binary_download_url(api_url: &str) -> Result<String, String> {
135    get_download_urls(api_url).map(|urls| urls.binary_url)
136}
137
138/// Parse expected hash from a `.sha256` checksum file.
139///
140/// Supports two common formats:
141/// - Plain hash: `abcdef1234...`
142/// - BSD/GNU style: `abcdef1234...  filename`
143pub(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    // Take the first whitespace-delimited token as the hex hash
150    let hash = trimmed
151        .split_whitespace()
152        .next()
153        .ok_or_else(|| "Checksum file is empty".to_string())?
154        .to_lowercase();
155
156    // Validate it looks like a SHA256 hex string (64 hex chars)
157    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
167/// Verify the downloaded data against a SHA256 checksum from the release.
168///
169/// Returns `Ok(())` if verification passes or no checksum is available
170/// (with a warning log for older releases).
171/// Returns `Err` if:
172/// - A checksum URL exists but the download fails (security: abort unverified updates)
173/// - The checksum does not match (binary may be corrupted or tampered with)
174pub(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            // No checksum available for this release (older releases)
179            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    // Download the checksum file
189    // SECURITY: If a checksum URL exists but download fails, we MUST abort the update.
190    // Returning Ok(()) here would allow a MITM attacker to block the checksum URL
191    // while allowing the binary URL through, resulting in an unverified install.
192    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
224/// Clean up leftover `.old` binary from a previous self-update.
225///
226/// On Windows, the running exe cannot be deleted or overwritten, so during
227/// self-update we rename it to `.old`. This function removes that stale
228/// file on the next startup. It is safe to call on all platforms — on
229/// non-Windows it is a no-op.
230pub 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        // Should return a valid asset name for the current platform
263        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        // SHA256 of "hello world"
295        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        // 32 hex chars (MD5 length) instead of 64 (SHA256 length)
358        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        // Should succeed with warning when no checksum URL is available
366        let data = b"some binary data";
367        let result = verify_download(data, None);
368        assert!(result.is_ok());
369    }
370}