From 2e4cf1cef06e24a8c75bba433ebe827ba9cccb23 Mon Sep 17 00:00:00 2001 From: harald Date: Wed, 16 Sep 2026 12:33:57 +0200 Subject: [PATCH] feat(upgrade): implement in-place self-upgrade command and bump version to v0.6.0 --- CHANGELOG.md | 14 ++ Cargo.lock | 162 ++++++++++++++- Cargo.toml | 6 +- src/lib.rs | 1 + src/main.rs | 29 +++ src/upgrade.rs | 454 ++++++++++++++++++++++++++++++++++++++++++ tests/upgrade_test.rs | 160 +++++++++++++++ 7 files changed, 824 insertions(+), 2 deletions(-) create mode 100644 src/upgrade.rs create mode 100644 tests/upgrade_test.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index f332810..e1c2339 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ Alle nennenswerten Änderungen an diesem Projekt werden in dieser Datei dokument Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/) und dieses Projekt folgt den Richtlinien von [Semantic Versioning](https://semver.org/lang/de/). +## [0.6.0] - 2026-09-16 + +### Added +- **`sanctum upgrade` (Alias: `sanctum update`) - In-Place Self-Upgrade Engine**: + - Ermöglicht das direkte Aktualisieren der laufenden Sanctum-Binary ohne externe Paketmanager oder manuelle Download-Schritte. + - **Plattformunabhängiger In-Place-Austausch**: Löst das Windows-Dateilock-Problem (`ERROR_ACCESS_DENIED`) via `self-replace` (Umbenennen der laufenden Executable und verzögerte Bereinigung). Auf Linux/Unix wird ein sauberer atomarer Tausch mit Ausführungsrechten (`0o755`) durchgeführt. + - **Kryptografische Integritätsprüfung**: Vor dem Austausch wird jedes heruntergeladene Binary zwingend gegen die offizielle `SHA256SUMS.txt` des Gitea-Releases via SHA-256 verifiziert. Bei Abweichungen bleibt die bestehende Installation vollständig unberührt. + - **Prüfmodus (`--check` / `-c`)**: Schneller Abgleich mit der Gitea-Releases-API zur Feststellung, ob ein Update vorliegt, ohne Downloads durchzuführen. + - **Automatisierungsmodus (`--yes` / `-y`)**: Führt das Upgrade ohne interaktive Bestätigung durch – ideal für geplante Aufgaben und Skripte. + - **Force-Option (`--force` / `-f`)**: Erlaubt das Erzwingen einer Neuinstallation / Aktualisierung auch bei gleicher Version oder erkannter Paketmanager-Umgebung. + - **Paketmanager-Erkennung**: Erkennt automatisch, ob Sanctum über Scoop oder Winget ausgeführt wird, und empfiehlt den entsprechenden Paketmanager-Befehl (`scoop update sanctum` bzw. `winget upgrade HaraldPansi.Sanctum`). +- **Testsuite**: + - `tests/upgrade_test.rs`: 5 neue Tests zur Verifikation von Checksum-Parsing, SemVer-Vergleich, Paketmanager-Erkennung, SHA-256 Hash-Validierung und Gitea Release API-JSON-Deserialisierung. + ## [0.5.0] - 2026-09-16 ### Added diff --git a/Cargo.lock b/Cargo.lock index 04e5ba6..6f010ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -512,6 +512,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "find-msvc-tools" version = "0.1.12" @@ -1020,6 +1026,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.3" @@ -1334,6 +1346,20 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rpassword" version = "7.5.4" @@ -1369,6 +1395,54 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -1377,7 +1451,7 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "sanctum" -version = "0.5.0" +version = "0.6.0" dependencies = [ "aes-gcm", "anyhow", @@ -1396,13 +1470,17 @@ dependencies = [ "rand", "rpassword", "rusqlite", + "self-replace", + "semver", "serde", "serde_json", + "sha2", "thiserror", "tokio", "tracing", "tracing-subscriber", "tray-item", + "ureq", "zeroize", ] @@ -1412,6 +1490,23 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "self-replace" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ec815b5eab420ab893f63393878d89c90fdd94c0bcc44c07abb8ad95552fb7" +dependencies = [ + "fastrand", + "tempfile", + "windows-sys 0.52.0", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -1466,6 +1561,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1564,6 +1670,19 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "2.0.20" @@ -1780,6 +1899,29 @@ dependencies = [ "subtle", ] +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.8" @@ -1884,6 +2026,24 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "windows-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index dcd68cc..606dc7c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sanctum" -version = "0.5.0" +version = "0.6.0" edition = "2021" authors = ["Harald Pansi ", "Sanctum Engineering Team"] description = "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV" @@ -36,6 +36,10 @@ dyn-clone = "1.0" lz4_flex = "0.11" bip39 = { version = "2.2", features = ["zeroize"] } hex = "0.4" +self-replace = "1.5" +ureq = { version = "2.10", default-features = false, features = ["json", "tls"] } +sha2 = "0.10" +semver = "1.0" [target.'cfg(windows)'.dependencies] tray-item = "0.10" diff --git a/src/lib.rs b/src/lib.rs index 52732cc..0c27f68 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ pub mod recovery; pub mod storage; pub mod sync; pub mod ui; +pub mod upgrade; pub mod verify; pub mod vfs; pub mod windows; diff --git a/src/main.rs b/src/main.rs index 6980182..14e2bb4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -248,6 +248,26 @@ enum Commands { /// Entfernt die .sanctum Explorer-Integration aus der Benutzer-Registry Unregister, + + /// Aktualisiert Sanctum auf die neueste Version (In-Place Self-Upgrade) + #[command(alias = "update")] + Upgrade { + /// Prüft nur auf verfügbare Aktualisierungen, ohne Dateien herunterzuladen + #[arg(short = 'c', long, default_value_t = false)] + check: bool, + + /// Führt die Aktualisierung ohne interaktive Bestätigung durch + #[arg(short = 'y', long, default_value_t = false)] + yes: bool, + + /// Erzwingt die Aktualisierung (auch bei gleicher Version oder erkannter Paketmanager-Verwaltung) + #[arg(short = 'f', long, default_value_t = false)] + force: bool, + + /// Benutzerdefinierte Gitea-Basis-URL (Standard: https://gitea.pansi.eu) + #[arg(long, default_value = "https://gitea.pansi.eu")] + url: String, + }, } fn parse_drive_letter(s: &str) -> Result { @@ -1246,6 +1266,15 @@ async fn run() -> Result<()> { println!(" {} .sanctum Dateiverknüpfungen wurden aus der Registry entfernt.", ui::green("✔")); println!(); } + Commands::Upgrade { check, yes, force, url } => { + let options = sanctum::upgrade::UpgradeOptions { + check_only: check, + yes, + force, + base_url: url, + }; + sanctum::upgrade::run_upgrade(&options)?; + } } Ok(()) diff --git a/src/upgrade.rs b/src/upgrade.rs new file mode 100644 index 0000000..1ff0567 --- /dev/null +++ b/src/upgrade.rs @@ -0,0 +1,454 @@ +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{bail, Context, Result}; +use semver::Version; +use serde::Deserialize; +use sha2::{Digest, Sha256}; + +use crate::ui; + +#[cfg(windows)] +pub const TARGET_ASSET_NAME: &str = "sanctum.exe"; + +#[cfg(not(windows))] +pub const TARGET_ASSET_NAME: &str = "sanctum"; + +pub const CHECKSUM_ASSET_NAME: &str = "SHA256SUMS.txt"; + +/// Repräsentiert ein Asset in einem Gitea-Release +#[derive(Debug, Deserialize, Clone)] +pub struct ReleaseAsset { + pub id: u64, + pub name: String, + pub size: u64, + pub browser_download_url: String, +} + +/// Repräsentiert ein Release von der Gitea Releases-API +#[derive(Debug, Deserialize, Clone)] +pub struct GiteaRelease { + pub id: u64, + pub tag_name: String, + pub name: String, + pub body: String, + pub html_url: String, + pub published_at: Option, + pub assets: Vec, +} + +/// Optionen für das Upgrade +#[derive(Debug, Clone)] +pub struct UpgradeOptions { + pub check_only: bool, + pub yes: bool, + pub force: bool, + pub base_url: String, +} + +impl Default for UpgradeOptions { + fn default() -> Self { + Self { + check_only: false, + yes: false, + force: false, + base_url: "https://gitea.pansi.eu".to_string(), + } + } +} + +/// Erkannter Paketmanager +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PackageManager { + Scoop, + Winget, +} + +impl PackageManager { + pub fn name(&self) -> &'static str { + match self { + PackageManager::Scoop => "Scoop", + PackageManager::Winget => "Winget", + } + } + + pub fn upgrade_command(&self) -> &'static str { + match self { + PackageManager::Scoop => "scoop update sanctum", + PackageManager::Winget => "winget upgrade HaraldPansi.Sanctum", + } + } +} + +/// Prüft, ob die laufende Binary durch einen Paketmanager (Scoop oder Winget) verwaltet wird. +pub fn detect_package_manager() -> Option { + if let Ok(exe_path) = std::env::current_exe() { + detect_package_manager_from_path(&exe_path) + } else { + None + } +} + +/// Hilfsfunktion für die Paketmanager-Erkennung anhand eines Pfads (für Tests isolierbar). +pub fn detect_package_manager_from_path(path: &Path) -> Option { + let s = path.to_string_lossy().to_lowercase(); + if s.contains("scoop\\apps\\sanctum") + || s.contains("scoop/apps/sanctum") + || s.contains("scoop\\shims") + || s.contains("scoop/shims") + { + return Some(PackageManager::Scoop); + } + if s.contains("winget\\packages") + || s.contains("winget/packages") + || s.contains("winget\\links") + || s.contains("winget/links") + { + return Some(PackageManager::Winget); + } + None +} + +/// Ruft das neueste Release von der Gitea Releases-API ab. +pub fn fetch_latest_release(base_url: &str) -> Result { + let base = base_url.trim_end_matches('/'); + let api_url = format!("{}/api/v1/repos/harald/sanctum/releases/latest", base); + + let resp = ureq::get(&api_url) + .set("User-Agent", &format!("sanctum/{}", env!("CARGO_PKG_VERSION"))) + .set("Accept", "application/json") + .timeout(Duration::from_secs(15)) + .call() + .with_context(|| format!("Netzwerkfehler beim Abrufen der Releases von '{}'", api_url))?; + + let release: GiteaRelease = resp + .into_json() + .context("Ungültiges JSON-Format von der Gitea Releases-API empfangen")?; + + Ok(release) +} + +/// Lädt eine Datei von einer URL als Text herunter. +pub fn fetch_text(url: &str) -> Result { + let resp = ureq::get(url) + .set("User-Agent", &format!("sanctum/{}", env!("CARGO_PKG_VERSION"))) + .timeout(Duration::from_secs(15)) + .call() + .with_context(|| format!("Fehler beim Herunterladen von '{}'", url))?; + + let text = resp + .into_string() + .with_context(|| format!("Fehler beim Lesen des Texts von '{}'", url))?; + + Ok(text) +} + +/// Extrahiert die SHA-256 Prüfsumme für ein bestimmtes Asset aus dem Inhalt von `SHA256SUMS.txt`. +pub fn parse_checksum_for_asset(sha256sums_content: &str, target_asset: &str) -> Option { + for line in sha256sums_content.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + let parts: Vec<&str> = trimmed.split_whitespace().collect(); + if parts.len() >= 2 { + let hash = parts[0]; + let filename = parts[1].trim_start_matches('*'); + if filename.eq_ignore_ascii_case(target_asset) && hash.len() == 64 { + return Some(hash.to_lowercase()); + } + } + } + None +} + +/// Bereinigt einen Versionsstring von führenden 'v' / 'V' Zeichen und parst ihn als SemVer. +pub fn parse_clean_version(ver_str: &str) -> Result { + let clean = ver_str.trim().trim_start_matches(|c| c == 'v' || c == 'V'); + Version::parse(clean).with_context(|| format!("Ungültiges SemVer-Format: '{}'", ver_str)) +} + +/// Vergleicht die aktuelle Version mit der entfernten Version. +/// Gibt `true` zurück, wenn `remote > current`. +pub fn is_newer_version(current: &str, remote: &str) -> bool { + let cur = match parse_clean_version(current) { + Ok(v) => v, + Err(_) => return false, + }; + let rem = match parse_clean_version(remote) { + Ok(v) => v, + Err(_) => return false, + }; + rem > cur +} + +/// Lädt das Ziel-Binary in eine temporäre Datei herunter und validiert dabei den SHA-256 Hash. +pub fn download_and_verify_binary( + download_url: &str, + expected_hash: &str, + expected_size: u64, +) -> Result { + let resp = ureq::get(download_url) + .set("User-Agent", &format!("sanctum/{}", env!("CARGO_PKG_VERSION"))) + .timeout(Duration::from_secs(120)) + .call() + .with_context(|| format!("Fehler beim Starten des Downloads von '{}'", download_url))?; + + let mut reader = resp.into_reader(); + + let temp_dir = std::env::temp_dir(); + let temp_file_path = temp_dir.join(format!( + "sanctum_update_{}_{}", + std::process::id(), + TARGET_ASSET_NAME + )); + + let mut temp_file = File::create(&temp_file_path).with_context(|| { + format!( + "Konnte temporäre Update-Datei '{}' nicht erstellen", + temp_file_path.display() + ) + })?; + + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 64 * 1024]; // 64 KB Puffer + let mut downloaded_bytes = 0u64; + + println!(" • Herunterladen von: {}", download_url); + print!(" • Fortschritt: "); + let _ = std::io::stdout().flush(); + + loop { + let n = reader + .read(&mut buffer) + .context("Fehler beim Lesen des Download-Datenstroms")?; + if n == 0 { + break; + } + + temp_file + .write_all(&buffer[..n]) + .context("Fehler beim Schreiben in die temporäre Datei")?; + hasher.update(&buffer[..n]); + + downloaded_bytes += n as u64; + + if expected_size > 0 { + let percent = (downloaded_bytes as f64 / expected_size as f64 * 100.0).min(100.0); + print!( + "\r • Fortschritt: {} / {} ({:.1}%)", + ui::format_bytes(downloaded_bytes), + ui::format_bytes(expected_size), + percent + ); + } else { + print!( + "\r • Fortschritt: {}", + ui::format_bytes(downloaded_bytes) + ); + } + let _ = std::io::stdout().flush(); + } + println!(); + + temp_file + .flush() + .context("Fehler beim Flush der temporären Datei")?; + drop(temp_file); + + // SHA-256 Integritätsprüfung + let calculated_hash = hex::encode(hasher.finalize()).to_lowercase(); + let expected_lower = expected_hash.to_lowercase(); + + if calculated_hash != expected_lower { + let _ = fs::remove_file(&temp_file_path); + bail!( + "Kryptografische Prüfsumme ungültig!\n Erwartet: {}\n Berechnet: {}\nDas Update wurde aus Sicherheitsgründen verworfen.", + expected_lower, + calculated_hash + ); + } + + println!(" • SHA-256: {} (verifiziert)", ui::green(&calculated_hash[..16])); + + Ok(temp_file_path) +} + +/// Führt den In-Place-Austausch des laufenden Binaries durch. +pub fn apply_upgrade(temp_binary: &Path) -> Result { + let current_exe = std::env::current_exe() + .context("Konnte den Pfad der aktuell ausgeführten Datei nicht ermitteln")?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(temp_binary) + .context("Konnte Metadaten der Update-Datei nicht lesen")? + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(temp_binary, perms) + .context("Konnte Ausführungsrechte (chmod +x / 0755) nicht setzen")?; + } + + self_replace::self_replace(temp_binary) + .context("In-Place-Ersetzung der laufenden Anwendungsdatei fehlgeschlagen")?; + + let _ = fs::remove_file(temp_binary); + + Ok(current_exe) +} + +/// Führt den gesamten Upgrade-Workflow aus. +pub fn run_upgrade(options: &UpgradeOptions) -> Result<()> { + let current_version = env!("CARGO_PKG_VERSION"); + + println!("┌─────────────────────────────────────────────────────────────┐"); + println!("│ Sanctum — In-Place Upgrade & Aktualisierungsdienst │"); + println!("└─────────────────────────────────────────────────────────────┘"); + println!(" • Aktuelle Version: {}", ui::cyan(&format!("v{}", current_version))); + println!(" • Server: {}", options.base_url); + println!(); + + // 1. Paketmanager-Prüfung + if let Some(pm) = detect_package_manager() { + println!( + " {} Sanctum wird anscheinend über {} verwaltet!", + ui::yellow("[!]"), + pm.name() + ); + println!( + " Empfohlener Aktualisierungsbefehl: {}", + ui::cyan(pm.upgrade_command()) + ); + println!(); + + if !options.force && !options.yes && !options.check_only { + print!(" Möchten Sie das direkte In-Place-Upgrade trotzdem fortsetzen? [j/N]: "); + let _ = std::io::stdout().flush(); + let mut answer = String::new(); + std::io::stdin() + .read_line(&mut answer) + .context("Fehler beim Einlesen der Benutzereingabe")?; + let trimmed = answer.trim().to_lowercase(); + if trimmed != "j" && trimmed != "ja" && trimmed != "y" && trimmed != "yes" { + println!(" Upgrade abgebrochen."); + return Ok(()); + } + println!(); + } + } + + // 2. Neuestes Release abfragen + println!(" {} Suche nach neuesten Releases auf Gitea ...", ui::dim("[-]")); + let release = fetch_latest_release(&options.base_url)?; + let remote_tag = &release.tag_name; + let is_newer = is_newer_version(current_version, remote_tag); + + println!(" • Neueste Version: {}", if is_newer { ui::green(remote_tag) } else { ui::cyan(remote_tag) }); + println!(" • Release-Name: {}", release.name); + if let Some(ref pub_at) = release.published_at { + println!(" • Veröffentlicht: {}", pub_at); + } + println!(" • Release-URL: {}", release.html_url); + println!(); + + if !is_newer && !options.force { + println!( + " {} Sie verwenden bereits die neueste Version von Sanctum.", + ui::green("✔") + ); + return Ok(()); + } + + if options.check_only { + if is_newer { + println!( + " {} Ein Update auf {} ist verfügbar! Führen Sie 'sanctum upgrade' aus.", + ui::yellow("[!]"), + remote_tag + ); + } + return Ok(()); + } + + // 3. Ziel-Asset und Checksumme identifizieren + let target_asset = release + .assets + .iter() + .find(|a| a.name.eq_ignore_ascii_case(TARGET_ASSET_NAME)) + .ok_or_else(|| { + anyhow::anyhow!( + "Im Release '{}' wurde kein passendes Binary für Ihr Betriebssystem ('{}') gefunden!", + remote_tag, + TARGET_ASSET_NAME + ) + })?; + + let checksum_asset = release + .assets + .iter() + .find(|a| a.name.eq_ignore_ascii_case(CHECKSUM_ASSET_NAME)) + .ok_or_else(|| { + anyhow::anyhow!( + "Im Release '{}' wurde keine Prüfsummendatei ('{}') gefunden!", + remote_tag, + CHECKSUM_ASSET_NAME + ) + })?; + + // 4. Prüfsummen herunterladen und extrahieren + println!(" {} Lade Prüfsummen ({}) herunter ...", ui::dim("[-]"), CHECKSUM_ASSET_NAME); + let sums_content = fetch_text(&checksum_asset.browser_download_url)?; + let expected_hash = parse_checksum_for_asset(&sums_content, TARGET_ASSET_NAME).ok_or_else(|| { + anyhow::anyhow!( + "Prüfsummendatei enthält keinen SHA-256 Eintrag für '{}'!", + TARGET_ASSET_NAME + ) + })?; + + // 5. Interaktive Bestätigung (sofern nicht -y / --yes) + if !options.yes { + println!(" • Ziel-Binary: {} ({})", target_asset.name, ui::format_bytes(target_asset.size)); + println!(" • Erwarteter Hash: {}...", &expected_hash[..16]); + println!(); + print!( + " Möchten Sie Sanctum jetzt von v{} auf {} aktualisieren? [J/n]: ", + current_version, remote_tag + ); + let _ = std::io::stdout().flush(); + let mut answer = String::new(); + std::io::stdin() + .read_line(&mut answer) + .context("Fehler beim Einlesen der Benutzereingabe")?; + let trimmed = answer.trim().to_lowercase(); + if trimmed == "n" || trimmed == "nein" || trimmed == "no" { + println!(" Upgrade abgebrochen."); + return Ok(()); + } + println!(); + } + + // 6. Download & Verifikation + println!(" {} Lade neues Binary herunter ...", ui::dim("[-]")); + let temp_file = download_and_verify_binary( + &target_asset.browser_download_url, + &expected_hash, + target_asset.size, + )?; + + // 7. In-Place-Austausch + println!(" {} Führe In-Place-Austausch durch ...", ui::dim("[-]")); + let updated_exe = apply_upgrade(&temp_file)?; + + println!(); + println!("┌─────────────────────────────────────────────────────────────┐"); + println!("│ ✔ Sanctum wurde erfolgreich auf {} aktualisiert! │", remote_tag); + println!("└─────────────────────────────────────────────────────────────┘"); + println!(" • Pfad: {}", updated_exe.display()); + println!(" • Neue Version: {}", ui::green(remote_tag)); + println!(); + + Ok(()) +} diff --git a/tests/upgrade_test.rs b/tests/upgrade_test.rs new file mode 100644 index 0000000..53b8f0d --- /dev/null +++ b/tests/upgrade_test.rs @@ -0,0 +1,160 @@ +use std::fs::File; +use std::io::Write; +use std::path::Path; + +use sanctum::upgrade::{ + detect_package_manager_from_path, is_newer_version, parse_checksum_for_asset, + parse_clean_version, GiteaRelease, PackageManager, +}; +use sha2::{Digest, Sha256}; + +#[test] +fn test_parse_checksum_for_asset() { + let sample_checksums = r#" +# Offizielle SHA-256 Prüfsummen für Sanctum Release +2f069a8cc41f9fa6a2559e2952e5669523c21c175df15f6d80f0473bbe2fa60e sanctum-v0.5.0-windows-x86_64.zip +e0c64ecc12c7f49180487b638f1152eccaf5c90270a93880db52ca56eb1c1d63 sanctum.exe +557dd84f339336899fdd67e69d894dcd6e32f3e0bff2ae8a3057df596eddf4b4 sanctum-v0.5.0-linux-x86_64.tar.gz +2816f00cb15825d92f35f93fd667ed04d36501763a70b9399dd0ab9355c9448d *sanctum +"#; + + // Windows Binary + let win_hash = parse_checksum_for_asset(sample_checksums, "sanctum.exe"); + assert_eq!( + win_hash.as_deref(), + Some("e0c64ecc12c7f49180487b638f1152eccaf5c90270a93880db52ca56eb1c1d63") + ); + + // Linux Binary (mit führendem Asterisk im Format) + let linux_hash = parse_checksum_for_asset(sample_checksums, "sanctum"); + assert_eq!( + linux_hash.as_deref(), + Some("2816f00cb15825d92f35f93fd667ed04d36501763a70b9399dd0ab9355c9448d") + ); + + // Nicht existentes Asset + let non_existent = parse_checksum_for_asset(sample_checksums, "nonexistent.exe"); + assert_eq!(non_existent, None); +} + +#[test] +fn test_semver_comparisons() { + // Normaler Versionssprung + assert!(is_newer_version("0.5.0", "0.6.0")); + assert!(is_newer_version("0.5.0", "v0.6.0")); + assert!(is_newer_version("v0.5.0", "V0.5.1")); + assert!(is_newer_version("0.5.0", "1.0.0")); + + // Gleiche Version + assert!(!is_newer_version("0.6.0", "0.6.0")); + assert!(!is_newer_version("0.6.0", "v0.6.0")); + assert!(!is_newer_version("v0.6.0", "0.6.0")); + + // Ältere Version + assert!(!is_newer_version("0.6.0", "0.5.0")); + assert!(!is_newer_version("0.6.0", "v0.4.1")); + + // Parsing mit führenden 'v' / 'V' + let v1 = parse_clean_version("v0.6.0").expect("Parse v0.6.0"); + let v2 = parse_clean_version("V1.2.3").expect("Parse V1.2.3"); + assert_eq!(v1.major, 0); + assert_eq!(v1.minor, 6); + assert_eq!(v2.major, 1); + assert_eq!(v2.minor, 2); +} + +#[test] +fn test_package_manager_detection() { + // Scoop Pfade + let scoop_path = Path::new(r"C:\Users\harald\scoop\apps\sanctum\current\sanctum.exe"); + assert_eq!( + detect_package_manager_from_path(scoop_path), + Some(PackageManager::Scoop) + ); + + let scoop_shim = Path::new(r"C:\Users\harald\scoop\shims\sanctum.exe"); + assert_eq!( + detect_package_manager_from_path(scoop_shim), + Some(PackageManager::Scoop) + ); + + // Winget Pfade + let winget_path = Path::new( + r"C:\Users\harald\AppData\Local\Microsoft\WinGet\Packages\HaraldPansi.Sanctum_Microsoft.Winget.Source_8wekyb3d8bbwe\sanctum.exe", + ); + assert_eq!( + detect_package_manager_from_path(winget_path), + Some(PackageManager::Winget) + ); + + // Standard Standalone Pfade (kein Paketmanager) + let standalone = Path::new(r"C:\Tools\sanctum\sanctum.exe"); + assert_eq!(detect_package_manager_from_path(standalone), None); + + let linux_usr = Path::new("/usr/local/bin/sanctum"); + assert_eq!(detect_package_manager_from_path(linux_usr), None); +} + +#[test] +fn test_hash_integrity_calculation() { + let test_file = std::env::temp_dir().join(format!("sanctum_test_{}.bin", std::process::id())); + + let payload = b"Sanctum Standalone Binary Payload for Integrity Testing"; + { + let mut f = File::create(&test_file).expect("create file"); + f.write_all(payload).expect("write"); + } + + let mut hasher = Sha256::new(); + hasher.update(payload); + let expected = hex::encode(hasher.finalize()); + + // Datei erneut lesen und hashen + let mut reader = File::open(&test_file).expect("open"); + let mut file_hasher = Sha256::new(); + std::io::copy(&mut reader, &mut file_hasher).expect("copy"); + let calculated = hex::encode(file_hasher.finalize()); + + assert_eq!(expected, calculated); + let _ = std::fs::remove_file(&test_file); +} + +#[test] +fn test_json_deserialization_of_gitea_release() { + let gitea_json = r##"{ + "id": 27, + "tag_name": "v0.5.0", + "name": "Sanctum v0.5.0 (Windows x86_64)", + "body": "Release v0.5.0 - sanctum sync", + "html_url": "https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.5.0", + "published_at": "2026-09-16T10:44:18+02:00", + "assets": [ + { + "id": 38, + "name": "sanctum.exe", + "size": 5467136, + "browser_download_url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.5.0/sanctum.exe" + }, + { + "id": 40, + "name": "sanctum", + "size": 5033952, + "browser_download_url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.5.0/sanctum" + }, + { + "id": 41, + "name": "SHA256SUMS.txt", + "size": 563, + "browser_download_url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.5.0/SHA256SUMS.txt" + } + ] + }"##; + + let release: GiteaRelease = serde_json::from_str(gitea_json).expect("Deserialisierung erfolgreich"); + assert_eq!(release.id, 27); + assert_eq!(release.tag_name, "v0.5.0"); + assert_eq!(release.assets.len(), 3); + assert_eq!(release.assets[0].name, "sanctum.exe"); + assert_eq!(release.assets[1].name, "sanctum"); + assert_eq!(release.assets[2].name, "SHA256SUMS.txt"); +}