feat(upgrade): implement in-place self-upgrade command and bump version to v0.6.0

This commit is contained in:
2026-09-16 12:33:57 +02:00
parent 1048253497
commit 2e4cf1cef0
7 changed files with 824 additions and 2 deletions
+1
View File
@@ -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;
+29
View File
@@ -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<char> {
@@ -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(())
+454
View File
@@ -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<String>,
pub assets: Vec<ReleaseAsset>,
}
/// 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<PackageManager> {
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<PackageManager> {
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<GiteaRelease> {
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<String> {
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<String> {
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<Version> {
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<PathBuf> {
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<PathBuf> {
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(())
}