1use crate::config::Config;
12use par_term_update::manifest::{self, FileStatus, Manifest};
13use std::io::{Cursor, Read};
14use std::path::Path;
15
16#[derive(Debug, Default)]
18pub struct InstallResult {
19 pub installed: usize,
21 pub skipped: usize,
23 pub needs_confirmation: Vec<String>,
25 pub removed: usize,
27}
28
29#[derive(Debug, Default)]
31pub struct UninstallResult {
32 pub removed: usize,
34 pub kept: usize,
36 pub needs_confirmation: Vec<String>,
38}
39
40pub fn install_shaders() -> Result<usize, String> {
43 const REPO: &str = "paulrobello/par-term";
44 let shaders_dir = Config::shaders_dir();
45
46 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 let zip_data = download_and_verify(&download_url, checksum_url.as_deref())?;
52
53 std::fs::create_dir_all(&shaders_dir)
55 .map_err(|e| format!("Failed to create shaders directory: {}", e))?;
56
57 extract_shaders(&zip_data, &shaders_dir)?;
59
60 let count = count_shader_files(&shaders_dir);
62
63 Ok(count)
64}
65
66pub 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 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
110pub 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 let checksum_body = download_file(csum_url)?;
125 let checksum_str = String::from_utf8_lossy(&checksum_body);
126 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 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
163pub fn download_file(url: &str) -> Result<Vec<u8>, String> {
174 crate::http::download_file(url)
175}
176
177pub 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
194pub fn sha256_hex(data: &[u8]) -> String {
199 use sha2::{Digest, Sha256};
200 let digest = Sha256::digest(data);
201 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
209pub 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
225pub 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 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 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 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 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
284pub 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
299pub 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
313pub 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 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
336pub 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
345pub fn get_installed_version(dir: &Path) -> Option<String> {
347 Manifest::load(dir).ok().map(|m| m.version)
348}
349
350pub 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
375pub 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 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 let zip_data = download_and_verify(&download_url, checksum_url.as_deref())?;
392
393 let new_manifest = extract_manifest_from_zip(&zip_data)?;
395
396 std::fs::create_dir_all(&shaders_dir)
398 .map_err(|e| format!("Failed to create shaders directory: {}", e))?;
399
400 let old_manifest = Manifest::load(&shaders_dir).ok();
402
403 let mut result = InstallResult::default();
404
405 let new_file_map = new_manifest.file_map();
407
408 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 }
417 FileStatus::Unchanged => {
418 result.skipped += 1;
420 continue;
421 }
422 FileStatus::Modified => {
423 if !force_overwrite {
424 result.needs_confirmation.push(new_file.path.clone());
426 result.skipped += 1;
427 continue;
428 }
429 }
431 FileStatus::UserCreated => {
432 result.skipped += 1;
434 continue;
435 }
436 }
437 }
438
439 extract_shaders_with_manifest(&zip_data, &shaders_dir, &new_manifest, force_overwrite)?;
441
442 result.installed = new_manifest.files.len() - result.skipped;
444
445 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 let status =
453 manifest::check_file_status(&old_path, &old_file.path, &old_manifest);
454 if status == FileStatus::Unchanged || force_overwrite {
455 if std::fs::remove_file(&old_path).is_ok() {
457 result.removed += 1;
458 }
459 }
460 }
461 }
462 }
463 }
464
465 new_manifest.save(&shaders_dir)?;
467
468 Ok(result)
469}
470
471fn 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 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 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 let is_manifest = relative_path_str == "manifest.json";
511
512 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 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 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; }
537 }
538
539 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 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
555pub fn uninstall_shaders(force: bool) -> Result<UninstallResult, String> {
561 let shaders_dir = Config::shaders_dir();
562
563 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 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 if std::fs::remove_file(&file_path).is_ok() {
583 result.removed += 1;
584 }
585 }
586 FileStatus::Modified => {
587 if force {
588 if std::fs::remove_file(&file_path).is_ok() {
590 result.removed += 1;
591 }
592 } else {
593 result.needs_confirmation.push(manifest_file.path.clone());
595 result.kept += 1;
596 }
597 }
598 FileStatus::UserCreated | FileStatus::Missing => {
599 result.kept += 1;
601 }
602 }
603 }
604
605 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 cleanup_empty_dirs(&shaders_dir);
613
614 Ok(result)
615}
616
617fn 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 let _ = std::fs::remove_dir(&path);
626 }
627 }
628 }
629}
630
631#[cfg(test)]
632mod tests {
633 use super::*;
634
635 #[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 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}