Skip to main content

par_term/
shader_installer.rs

1//! Shared shader installation logic.
2//!
3//! Used by both the CLI (`install-shaders` command) and the UI (shader install dialog).
4//!
5//! # Error Handling Convention
6//!
7//! Functions here return `Result<T, String>` (string errors for UI display)
8//! so callers can surface messages to the user without a conversion step.
9//! New functions added to this module should follow the same pattern.
10
11use crate::config::Config;
12use par_term_update::manifest::{self, FileStatus, Manifest};
13use std::io::{Cursor, Read};
14use std::path::Path;
15
16/// Result of shader installation
17#[derive(Debug, Default)]
18pub struct InstallResult {
19    /// Number of files installed
20    pub installed: usize,
21    /// Number of files skipped (unchanged)
22    pub skipped: usize,
23    /// Files that need user confirmation (modified bundled files)
24    pub needs_confirmation: Vec<String>,
25    /// Files that were removed (no longer in bundle)
26    pub removed: usize,
27}
28
29/// Result of shader uninstallation
30#[derive(Debug, Default)]
31pub struct UninstallResult {
32    /// Number of files removed
33    pub removed: usize,
34    /// Number of files kept (user-created or modified)
35    pub kept: usize,
36    /// Files that need user confirmation
37    pub needs_confirmation: Vec<String>,
38}
39
40/// Install shaders from GitHub release
41/// Returns the number of shaders installed
42pub fn install_shaders() -> Result<usize, String> {
43    const REPO: &str = "paulrobello/par-term";
44    let shaders_dir = Config::shaders_dir();
45
46    // Fetch latest release info
47    let api_url = format!("https://api.github.com/repos/{}/releases/latest", REPO);
48    let (download_url, checksum_url) = get_shaders_download_url(&api_url, REPO)?;
49
50    // Download the zip file with optional SHA256 verification.
51    let zip_data = download_and_verify(&download_url, checksum_url.as_deref())?;
52
53    // Create shaders directory if it doesn't exist
54    std::fs::create_dir_all(&shaders_dir)
55        .map_err(|e| format!("Failed to create shaders directory: {}", e))?;
56
57    // Extract shaders
58    extract_shaders(&zip_data, &shaders_dir)?;
59
60    // Count installed shaders
61    let count = count_shader_files(&shaders_dir);
62
63    Ok(count)
64}
65
66/// Get the download URL (and optional `.sha256` URL) for shaders.zip from the latest release.
67///
68/// Returns `(zip_url, Option<sha256_url>)`. The SHA256 URL is present only when a
69/// `shaders.zip.sha256` asset exists in the release.
70pub fn get_shaders_download_url(
71    api_url: &str,
72    repo: &str,
73) -> Result<(String, Option<String>), String> {
74    let mut body = crate::http::get_validated(api_url, None)?.into_body();
75
76    let body_str = body
77        .read_to_string()
78        .map_err(|e| format!("Failed to read response body: {}", e))?;
79
80    // Parse JSON to find shaders.zip and optional shaders.zip.sha256 browser_download_url.
81    let search_pattern = "\"browser_download_url\":\"";
82    let target_file = "shaders.zip";
83    let checksum_file = "shaders.zip.sha256";
84
85    let mut zip_url: Option<String> = None;
86    let mut sha256_url: Option<String> = None;
87
88    for (i, _) in body_str.match_indices(search_pattern) {
89        let url_start = i + search_pattern.len();
90        if let Some(url_end) = body_str[url_start..].find('"') {
91            let url = &body_str[url_start..url_start + url_end];
92            if url.ends_with(checksum_file) {
93                sha256_url = Some(url.to_string());
94            } else if url.ends_with(target_file) {
95                zip_url = Some(url.to_string());
96            }
97        }
98    }
99
100    match zip_url {
101        Some(url) => Ok((url, sha256_url)),
102        None => Err(format!(
103            "Could not find shaders.zip in the latest release.\n\
104             Please check https://github.com/{}/releases",
105            repo
106        )),
107    }
108}
109
110/// Download `zip_url` and, when `checksum_url` is provided, fetch and verify
111/// its SHA256 checksum. Returns the raw zip bytes on success.
112///
113/// # Security
114///
115/// When `checksum_url` is `None` the installation is **refused**: a missing
116/// checksum means the download cannot be validated, so releases predating the
117/// checksum asset are not installable through this path.
118pub fn download_and_verify(zip_url: &str, checksum_url: Option<&str>) -> Result<Vec<u8>, String> {
119    let zip_data = download_file(zip_url)?;
120
121    match checksum_url {
122        Some(csum_url) => {
123            // Fetch the expected checksum (plain text: "<hex_digest>  shaders.zip\n")
124            let checksum_body = download_file(csum_url)?;
125            let checksum_str = String::from_utf8_lossy(&checksum_body);
126            // The file may be "<hex>  filename" or just "<hex>\n"
127            let expected_hex = checksum_str
128                .split_whitespace()
129                .next()
130                .ok_or_else(|| "Checksum file is empty or malformed".to_string())?;
131            verify_sha256(&zip_data, expected_hex)?;
132            crate::debug_info!(
133                "SHADER_INSTALL",
134                "SHA256 checksum verified for shaders.zip: {}",
135                expected_hex
136            );
137        }
138        None => {
139            // SEC-004: No checksum asset found. Reject the installation rather than
140            // proceeding without integrity verification. A missing checksum means
141            // the download cannot be validated and MITM injection is possible.
142            //
143            // Older releases that predate checksum assets are no longer installable
144            // via this path. Users requiring older shaders must install manually.
145            log::error!(
146                "par-term shader install: no shaders.zip.sha256 asset found in release. \
147                 Installation aborted — cannot verify download integrity. \
148                 Ensure the release includes a shaders.zip.sha256 asset."
149            );
150            return Err(
151                "Shader installation requires a shaders.zip.sha256 checksum asset in the \
152                 GitHub release. No checksum asset was found for this release. \
153                 Installation cannot proceed without integrity verification. \
154                 Please report this to the par-term maintainers."
155                    .to_string(),
156            );
157        }
158    }
159
160    Ok(zip_data)
161}
162
163/// Download a file from URL and return its contents.
164///
165/// # Security
166///
167/// - Enforces HTTPS-only and the GitHub host allowlist on the URL **and on every
168///   redirect hop**, via [`crate::http::download_file`]. A release-asset download
169///   redirects twice before it lands, so validating only the URL passed in would
170///   leave the hops that actually fetch the bytes unchecked.
171/// - Enforces a 50 MB size limit to prevent memory exhaustion.
172/// - Callers that require integrity checking should use [`download_file_with_checksum`].
173pub fn download_file(url: &str) -> Result<Vec<u8>, String> {
174    crate::http::download_file(url)
175}
176
177/// Download a file and verify its SHA256 checksum.
178///
179/// `expected_hex` is the lowercase hex-encoded expected SHA256 digest.
180/// Returns an error if the digest does not match, preventing installation of
181/// tampered or corrupted downloads.
182///
183/// # Security
184///
185/// This is the preferred download function for release assets. Checksum
186/// verification ensures MITM-injected or corrupted payloads are rejected
187/// before extraction.
188pub fn download_file_with_checksum(url: &str, expected_hex: &str) -> Result<Vec<u8>, String> {
189    let bytes = download_file(url)?;
190    verify_sha256(&bytes, expected_hex)?;
191    Ok(bytes)
192}
193
194/// Compute the lowercase hex SHA256 digest of `data`.
195///
196/// Uses the `sha2` crate (already a workspace dependency) rather than a
197/// hand-rolled implementation, following SEC-008.
198pub fn sha256_hex(data: &[u8]) -> String {
199    use sha2::{Digest, Sha256};
200    let digest = Sha256::digest(data);
201    // Format each byte as two lowercase hex digits
202    digest.iter().fold(String::with_capacity(64), |mut acc, b| {
203        use std::fmt::Write as _;
204        write!(acc, "{:02x}", b).unwrap_or_default();
205        acc
206    })
207}
208
209/// Verify that `data` has the given lowercase hex SHA256 digest.
210/// Returns `Ok(())` on match, `Err(...)` on mismatch or parse failure.
211pub fn verify_sha256(data: &[u8], expected_hex: &str) -> Result<(), String> {
212    let actual = sha256_hex(data);
213    if actual.eq_ignore_ascii_case(expected_hex) {
214        Ok(())
215    } else {
216        Err(format!(
217            "SHA256 checksum mismatch — download may be corrupt or tampered.\n\
218             Expected: {}\n\
219             Actual:   {}",
220            expected_hex, actual
221        ))
222    }
223}
224
225/// Extract shaders from zip data to target directory
226pub fn extract_shaders(zip_data: &[u8], target_dir: &Path) -> Result<(), String> {
227    use std::io::Cursor;
228    use zip::ZipArchive;
229
230    let reader = Cursor::new(zip_data);
231    let mut archive = ZipArchive::new(reader).map_err(|e| format!("Failed to open zip: {}", e))?;
232
233    for i in 0..archive.len() {
234        let mut file = archive
235            .by_index(i)
236            .map_err(|e| format!("Failed to read zip entry: {}", e))?;
237
238        let outpath = match file.enclosed_name() {
239            Some(path) => path.to_owned(),
240            None => continue,
241        };
242
243        if file.is_dir() {
244            continue;
245        }
246
247        // Handle paths - the zip contains "shaders/" prefix
248        let relative_path = outpath.strip_prefix("shaders/").unwrap_or(&outpath);
249
250        if relative_path.as_os_str().is_empty() {
251            continue;
252        }
253
254        let final_path = target_dir.join(relative_path);
255
256        // Zip-slip protection: ensure the final path stays within the target
257        // directory. A crafted zip could contain paths like "../../etc/cron.d/malware"
258        // that escape the target directory after joining.
259        if !final_path.starts_with(target_dir) {
260            log::warn!(
261                "Skipping zip entry outside target directory: {} resolves to {}",
262                relative_path.display(),
263                final_path.display()
264            );
265            continue;
266        }
267
268        // Create parent directories if needed
269        if let Some(parent) = final_path.parent() {
270            std::fs::create_dir_all(parent)
271                .map_err(|e| format!("Failed to create directory: {}", e))?;
272        }
273
274        // Extract file
275        let mut outfile = std::fs::File::create(&final_path)
276            .map_err(|e| format!("Failed to create file: {}", e))?;
277        std::io::copy(&mut file, &mut outfile)
278            .map_err(|e| format!("Failed to write file: {}", e))?;
279    }
280
281    Ok(())
282}
283
284/// Count .glsl files in directory
285pub fn count_shader_files(dir: &Path) -> usize {
286    let mut count = 0;
287    if let Ok(entries) = std::fs::read_dir(dir) {
288        for entry in entries.flatten() {
289            if let Some(ext) = entry.path().extension()
290                && ext == "glsl"
291            {
292                count += 1;
293            }
294        }
295    }
296    count
297}
298
299/// Check if directory contains any .glsl files
300pub fn has_shader_files(dir: &Path) -> bool {
301    if let Ok(entries) = std::fs::read_dir(dir) {
302        for entry in entries.flatten() {
303            if let Some(ext) = entry.path().extension()
304                && ext == "glsl"
305            {
306                return true;
307            }
308        }
309    }
310    false
311}
312
313/// Extract manifest from zip data
314pub fn extract_manifest_from_zip(zip_data: &[u8]) -> Result<Manifest, String> {
315    use zip::ZipArchive;
316
317    let reader = Cursor::new(zip_data);
318    let mut archive = ZipArchive::new(reader).map_err(|e| format!("Failed to open zip: {}", e))?;
319
320    // Look for manifest.json in the zip (may be at root or in shaders/ prefix)
321    let manifest_names = ["manifest.json", "shaders/manifest.json"];
322
323    for name in &manifest_names {
324        if let Ok(mut file) = archive.by_name(name) {
325            let mut content = String::new();
326            file.read_to_string(&mut content)
327                .map_err(|e| format!("Failed to read manifest: {}", e))?;
328            return serde_json::from_str(&content)
329                .map_err(|e| format!("Failed to parse manifest: {}", e));
330        }
331    }
332
333    Err("No manifest.json found in zip".to_string())
334}
335
336/// Count files tracked by manifest in directory
337pub fn count_manifest_files(dir: &Path) -> usize {
338    if let Ok(manifest) = Manifest::load(dir) {
339        manifest.files.len()
340    } else {
341        0
342    }
343}
344
345/// Get version from installed manifest
346pub fn get_installed_version(dir: &Path) -> Option<String> {
347    Manifest::load(dir).ok().map(|m| m.version)
348}
349
350/// Detect bundled shader files that have been modified by the user.
351///
352/// Returns a list of relative paths that differ from the recorded manifest
353/// hashes. If no manifest is present, an empty vector is returned.
354pub fn detect_modified_bundled_shaders() -> Result<Vec<String>, String> {
355    let shaders_dir = Config::shaders_dir();
356
357    let manifest = match Manifest::load(&shaders_dir) {
358        Ok(manifest) => manifest,
359        Err(_) => return Ok(Vec::new()),
360    };
361
362    let mut modified = Vec::new();
363
364    for file in &manifest.files {
365        let path = shaders_dir.join(&file.path);
366        let status = manifest::check_file_status(&path, &file.path, &manifest);
367        if status == FileStatus::Modified {
368            modified.push(file.path.clone());
369        }
370    }
371
372    Ok(modified)
373}
374
375/// Install shaders with manifest support
376///
377/// Downloads shaders from GitHub and installs them using manifest tracking.
378/// - Compares new manifest against existing files
379/// - Skips unchanged files
380/// - Tracks modified files that need user confirmation
381/// - Returns detailed installation result
382pub fn install_shaders_with_manifest(force_overwrite: bool) -> Result<InstallResult, String> {
383    const REPO: &str = "paulrobello/par-term";
384    let shaders_dir = Config::shaders_dir();
385
386    // Fetch latest release info
387    let api_url = format!("https://api.github.com/repos/{}/releases/latest", REPO);
388    let (download_url, checksum_url) = get_shaders_download_url(&api_url, REPO)?;
389
390    // Download the zip file with optional SHA256 verification.
391    let zip_data = download_and_verify(&download_url, checksum_url.as_deref())?;
392
393    // Extract manifest from the new zip
394    let new_manifest = extract_manifest_from_zip(&zip_data)?;
395
396    // Create shaders directory if it doesn't exist
397    std::fs::create_dir_all(&shaders_dir)
398        .map_err(|e| format!("Failed to create shaders directory: {}", e))?;
399
400    // Load existing manifest if present
401    let old_manifest = Manifest::load(&shaders_dir).ok();
402
403    let mut result = InstallResult::default();
404
405    // Build map of new files
406    let new_file_map = new_manifest.file_map();
407
408    // Check each file in new manifest
409    for new_file in &new_manifest.files {
410        let file_path = shaders_dir.join(&new_file.path);
411        let status = manifest::check_file_status(&file_path, &new_file.path, &new_manifest);
412
413        match status {
414            FileStatus::Missing => {
415                // File doesn't exist, will be installed
416            }
417            FileStatus::Unchanged => {
418                // File exists and matches - skip
419                result.skipped += 1;
420                continue;
421            }
422            FileStatus::Modified => {
423                if !force_overwrite {
424                    // User has modified this file - needs confirmation
425                    result.needs_confirmation.push(new_file.path.clone());
426                    result.skipped += 1;
427                    continue;
428                }
429                // force_overwrite is true, will be installed
430            }
431            FileStatus::UserCreated => {
432                // This shouldn't happen for files in new manifest, but skip anyway
433                result.skipped += 1;
434                continue;
435            }
436        }
437    }
438
439    // Now actually extract the files
440    extract_shaders_with_manifest(&zip_data, &shaders_dir, &new_manifest, force_overwrite)?;
441
442    // Count installed files (all files in manifest minus skipped)
443    result.installed = new_manifest.files.len() - result.skipped;
444
445    // Check for removed files (in old manifest but not in new)
446    if let Some(old_manifest) = old_manifest {
447        for old_file in &old_manifest.files {
448            if !new_file_map.contains_key(old_file.path.as_str()) {
449                let old_path = shaders_dir.join(&old_file.path);
450                if old_path.exists() {
451                    // Check if file matches old manifest (unmodified bundled file)
452                    let status =
453                        manifest::check_file_status(&old_path, &old_file.path, &old_manifest);
454                    if status == FileStatus::Unchanged || force_overwrite {
455                        // Safe to remove - it's an unmodified bundled file
456                        if std::fs::remove_file(&old_path).is_ok() {
457                            result.removed += 1;
458                        }
459                    }
460                }
461            }
462        }
463    }
464
465    // Save the new manifest
466    new_manifest.save(&shaders_dir)?;
467
468    Ok(result)
469}
470
471/// Extract shaders from zip with manifest awareness
472fn extract_shaders_with_manifest(
473    zip_data: &[u8],
474    target_dir: &Path,
475    manifest: &Manifest,
476    force_overwrite: bool,
477) -> Result<(), String> {
478    use zip::ZipArchive;
479
480    let reader = Cursor::new(zip_data);
481    let mut archive = ZipArchive::new(reader).map_err(|e| format!("Failed to open zip: {}", e))?;
482
483    // Build set of files to install from manifest
484    let manifest_files: std::collections::HashSet<&str> =
485        manifest.files.iter().map(|f| f.path.as_str()).collect();
486
487    for i in 0..archive.len() {
488        let mut file = archive
489            .by_index(i)
490            .map_err(|e| format!("Failed to read zip entry: {}", e))?;
491
492        let outpath = match file.enclosed_name() {
493            Some(path) => path.to_owned(),
494            None => continue,
495        };
496
497        if file.is_dir() {
498            continue;
499        }
500
501        // Handle paths - the zip contains "shaders/" prefix
502        let relative_path = outpath.strip_prefix("shaders/").unwrap_or(&outpath);
503        let relative_path_str = relative_path.to_string_lossy();
504
505        if relative_path.as_os_str().is_empty() {
506            continue;
507        }
508
509        // Always extract manifest.json
510        let is_manifest = relative_path_str == "manifest.json";
511
512        // Skip files not in manifest (except manifest.json itself)
513        if !is_manifest && !manifest_files.contains(&relative_path_str.as_ref()) {
514            continue;
515        }
516
517        let final_path = target_dir.join(relative_path);
518
519        // Zip-slip protection: ensure the final path stays within the target
520        // directory. A crafted zip could contain paths like "../../etc/cron.d/malware"
521        // that escape the target directory after joining.
522        if !final_path.starts_with(target_dir) {
523            log::warn!(
524                "Skipping zip entry outside target directory: {} resolves to {}",
525                relative_path.display(),
526                final_path.display()
527            );
528            continue;
529        }
530
531        // Check if file exists and is modified (skip unless force)
532        if !is_manifest && final_path.exists() && !force_overwrite {
533            let status = manifest::check_file_status(&final_path, &relative_path_str, manifest);
534            if status == FileStatus::Modified {
535                continue; // Skip modified files unless force
536            }
537        }
538
539        // Create parent directories if needed
540        if let Some(parent) = final_path.parent() {
541            std::fs::create_dir_all(parent)
542                .map_err(|e| format!("Failed to create directory: {}", e))?;
543        }
544
545        // Extract file
546        let mut outfile = std::fs::File::create(&final_path)
547            .map_err(|e| format!("Failed to create file: {}", e))?;
548        std::io::copy(&mut file, &mut outfile)
549            .map_err(|e| format!("Failed to write file: {}", e))?;
550    }
551
552    Ok(())
553}
554
555/// Uninstall bundled shaders
556///
557/// Only removes files that are tracked by the manifest.
558/// - Preserves user-created files
559/// - Optionally preserves modified bundled files
560pub fn uninstall_shaders(force: bool) -> Result<UninstallResult, String> {
561    let shaders_dir = Config::shaders_dir();
562
563    // Load manifest
564    let manifest = Manifest::load(&shaders_dir)
565        .map_err(|_| "No manifest found - cannot determine which files are bundled".to_string())?;
566
567    let mut result = UninstallResult::default();
568
569    // Process each file in manifest
570    for manifest_file in &manifest.files {
571        let file_path = shaders_dir.join(&manifest_file.path);
572
573        if !file_path.exists() {
574            continue;
575        }
576
577        let status = manifest::check_file_status(&file_path, &manifest_file.path, &manifest);
578
579        match status {
580            FileStatus::Unchanged => {
581                // Unmodified bundled file - safe to remove
582                if std::fs::remove_file(&file_path).is_ok() {
583                    result.removed += 1;
584                }
585            }
586            FileStatus::Modified => {
587                if force {
588                    // Force removal of modified files
589                    if std::fs::remove_file(&file_path).is_ok() {
590                        result.removed += 1;
591                    }
592                } else {
593                    // Needs user confirmation
594                    result.needs_confirmation.push(manifest_file.path.clone());
595                    result.kept += 1;
596                }
597            }
598            FileStatus::UserCreated | FileStatus::Missing => {
599                // Not a bundled file or already gone
600                result.kept += 1;
601            }
602        }
603    }
604
605    // Remove manifest file itself
606    let manifest_path = shaders_dir.join("manifest.json");
607    if manifest_path.exists() && std::fs::remove_file(&manifest_path).is_ok() {
608        result.removed += 1;
609    }
610
611    // Try to remove empty directories
612    cleanup_empty_dirs(&shaders_dir);
613
614    Ok(result)
615}
616
617/// Remove empty directories recursively
618fn cleanup_empty_dirs(dir: &Path) {
619    if let Ok(entries) = std::fs::read_dir(dir) {
620        for entry in entries.flatten() {
621            let path = entry.path();
622            if path.is_dir() {
623                cleanup_empty_dirs(&path);
624                // Try to remove if empty (will fail if not empty, which is fine)
625                let _ = std::fs::remove_dir(&path);
626            }
627        }
628    }
629}
630
631#[cfg(test)]
632mod tests {
633    use super::*;
634
635    /// Live check that the release-API lookup still works now that it goes
636    /// through [`crate::http::get_validated`], which enforces the host allowlist
637    /// and follows redirects itself instead of letting the agent do it.
638    ///
639    /// Ignored by default because it needs the network. Run with
640    /// `cargo test -p par-term --lib -- --ignored release_api`.
641    #[test]
642    #[ignore = "requires network access to api.github.com"]
643    fn live_release_api_lookup_survives_per_hop_validation() {
644        const REPO: &str = "paulrobello/par-term";
645        let api_url = format!("https://api.github.com/repos/{}/releases/latest", REPO);
646
647        let (zip_url, checksum_url) = get_shaders_download_url(&api_url, REPO)
648            .expect("the release API lookup must survive allowlist validation");
649
650        assert!(
651            zip_url.ends_with("shaders.zip"),
652            "unexpected zip asset URL: {zip_url}"
653        );
654        // Every hop of this URL is re-validated when it is downloaded, so it has
655        // to be on the allowlist before the download is even attempted.
656        crate::http::validate_download_url(&zip_url)
657            .expect("the asset URL the API returns must be on the download allowlist");
658
659        if let Some(csum_url) = checksum_url {
660            crate::http::validate_download_url(&csum_url)
661                .expect("the checksum URL must be on the download allowlist");
662        }
663    }
664}