2 Commits
Author SHA1 Message Date
harald 2da1ea55a9 chore(release): update scoop and winget manifests for v0.6.0
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
2026-09-16 12:40:37 +02:00
harald 2e4cf1cef0 feat(upgrade): implement in-place self-upgrade command and bump version to v0.6.0 2026-09-16 12:33:57 +02:00
9 changed files with 831 additions and 9 deletions
+14
View File
@@ -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/) 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/). 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 ## [0.5.0] - 2026-09-16
### Added ### Added
Generated
+161 -1
View File
@@ -512,6 +512,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.12" version = "0.1.12"
@@ -1020,6 +1026,12 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.3" version = "0.8.3"
@@ -1334,6 +1346,20 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" 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]] [[package]]
name = "rpassword" name = "rpassword"
version = "7.5.4" version = "7.5.4"
@@ -1369,6 +1395,54 @@ dependencies = [
"smallvec", "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]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.23" version = "1.0.23"
@@ -1377,7 +1451,7 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]] [[package]]
name = "sanctum" name = "sanctum"
version = "0.5.0" version = "0.6.0"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"anyhow", "anyhow",
@@ -1396,13 +1470,17 @@ dependencies = [
"rand", "rand",
"rpassword", "rpassword",
"rusqlite", "rusqlite",
"self-replace",
"semver",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"thiserror", "thiserror",
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"tray-item", "tray-item",
"ureq",
"zeroize", "zeroize",
] ]
@@ -1412,6 +1490,23 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 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]] [[package]]
name = "serde" name = "serde"
version = "1.0.229" version = "1.0.229"
@@ -1466,6 +1561,17 @@ dependencies = [
"digest", "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]] [[package]]
name = "sharded-slab" name = "sharded-slab"
version = "0.1.7" version = "0.1.7"
@@ -1564,6 +1670,19 @@ dependencies = [
"syn 2.0.119", "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]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.20" version = "2.0.20"
@@ -1780,6 +1899,29 @@ dependencies = [
"subtle", "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]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@@ -1884,6 +2026,24 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.62.2" version = "0.62.2"
+5 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "sanctum" name = "sanctum"
version = "0.5.0" version = "0.6.0"
edition = "2021" edition = "2021"
authors = ["Harald Pansi <harald@pansi.eu>", "Sanctum Engineering Team"] authors = ["Harald Pansi <harald@pansi.eu>", "Sanctum Engineering Team"]
description = "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV" 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" lz4_flex = "0.11"
bip39 = { version = "2.2", features = ["zeroize"] } bip39 = { version = "2.2", features = ["zeroize"] }
hex = "0.4" 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] [target.'cfg(windows)'.dependencies]
tray-item = "0.10" tray-item = "0.10"
+3 -3
View File
@@ -1,12 +1,12 @@
{ {
"version": "0.5.0", "version": "0.6.0",
"description": "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV", "description": "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV",
"homepage": "https://gitea.pansi.eu/harald/sanctum", "homepage": "https://gitea.pansi.eu/harald/sanctum",
"license": "MIT", "license": "MIT",
"architecture": { "architecture": {
"64bit": { "64bit": {
"url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.5.0/sanctum-v0.5.0-windows-x86_64.zip", "url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.6.0/sanctum-v0.6.0-windows-x86_64.zip",
"hash": "2f069a8cc41f9fa6a2559e2952e5669523c21c175df15f6d80f0473bbe2fa60e", "hash": "5f2a66bd5ff2ab322b92108fde243c2c5c8d331d55ea98b76f0c94cd8c536a69",
"bin": "sanctum.exe" "bin": "sanctum.exe"
} }
}, },
+4 -4
View File
@@ -1,6 +1,6 @@
# yaml-language-server: $schema=https://aka.ms/winget-manifest.singleton.1.6.0.schema.json # yaml-language-server: $schema=https://aka.ms/winget-manifest.singleton.1.6.0.schema.json
PackageIdentifier: HaraldPansi.Sanctum PackageIdentifier: HaraldPansi.Sanctum
PackageVersion: 0.5.0 PackageVersion: 0.6.0
PackageName: Sanctum PackageName: Sanctum
Publisher: Harald Pansi Publisher: Harald Pansi
PublisherUrl: https://gitea.pansi.eu/harald PublisherUrl: https://gitea.pansi.eu/harald
@@ -18,7 +18,7 @@ Tags:
- webdav - webdav
- container - container
- plausible-deniability - plausible-deniability
ReleaseNotesUrl: https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.5.0 ReleaseNotesUrl: https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.6.0
Installers: Installers:
- Architecture: x64 - Architecture: x64
InstallerType: zip InstallerType: zip
@@ -26,7 +26,7 @@ Installers:
NestedInstallerFiles: NestedInstallerFiles:
- RelativeFilePath: sanctum.exe - RelativeFilePath: sanctum.exe
PortableCommandAlias: sanctum PortableCommandAlias: sanctum
InstallerUrl: https://gitea.pansi.eu/harald/sanctum/releases/download/v0.5.0/sanctum-v0.5.0-windows-x86_64.zip InstallerUrl: https://gitea.pansi.eu/harald/sanctum/releases/download/v0.6.0/sanctum-v0.6.0-windows-x86_64.zip
InstallerSha256: 2f069a8cc41f9fa6a2559e2952e5669523c21c175df15f6d80f0473bbe2fa60e InstallerSha256: 5f2a66bd5ff2ab322b92108fde243c2c5c8d331d55ea98b76f0c94cd8c536a69
ManifestType: singleton ManifestType: singleton
ManifestVersion: 1.6.0 ManifestVersion: 1.6.0
+1
View File
@@ -6,6 +6,7 @@ pub mod recovery;
pub mod storage; pub mod storage;
pub mod sync; pub mod sync;
pub mod ui; pub mod ui;
pub mod upgrade;
pub mod verify; pub mod verify;
pub mod vfs; pub mod vfs;
pub mod windows; pub mod windows;
+29
View File
@@ -248,6 +248,26 @@ enum Commands {
/// Entfernt die .sanctum Explorer-Integration aus der Benutzer-Registry /// Entfernt die .sanctum Explorer-Integration aus der Benutzer-Registry
Unregister, 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> { 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!(" {} .sanctum Dateiverknüpfungen wurden aus der Registry entfernt.", ui::green(""));
println!(); 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(()) 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(())
}
+160
View File
@@ -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");
}