Files
ink-notes/src/updater.rs
T
haraldandClaude Opus 5.5 e399c357d8 feat: Flatpak-Paket (Vorbereitung für Flathub)
- packaging/flatpak/eu.pansi.ink-notes.yml: Runtime freedesktop 26.08,
  Rust aus rust-stable, Abhängigkeiten offline aus cargo-sources.json.
  Rechte: Wayland/X11, GPU, Lesen von Bildern/Downloads/Schreibtisch,
  Schreiben in den Dokumente-Ordner (kein Zugriff auf den ganzen
  Home-Ordner).
- Im Flatpak sucht und installiert ink-notes keine Updates selbst
  (Vorgabe von Flathub); der Update-Dialog verweist auf die
  Software-Verwaltung.
- Export-Ordner aus user-dirs.dirs (z. B. ~/Dokumente) statt fest
  ~/Documents; nur dieser Ordner ist im Flatpak beschreibbar.
- Metainfo und Desktop-Datei auf Englisch mit deutscher Übersetzung,
  Release-Einträge, unterstützte Eingabegeräte.
- Test: cargo-sources.json deckt Cargo.lock ab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:18:37 +02:00

1297 lines
47 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! In-App Updater für ink-notes mit Gitea-Releases-Integration.
//!
//! Sicherheits- und Zuverlässigkeitsmerkmale:
//! - Rust-nativer HTTP-Client (`ureq` mit rustls) ohne externe CLI-Abhängigkeit
//! - Erzwingen von HTTPS (unsicheres HTTP standardmäßig blockiert)
//! - Kein Leak von Authorization-Tokens bei HTTP-Redirects auf fremde Origins
//! - Sichere Asset-Namensbereinigung (Path Traversal Schutz)
//! - Isolierter Benutzer-Cache-Pfad (`$XDG_CACHE_HOME/ink-notes/updates` mit 0700 Rechten)
//! - Integritätsprüfung via SHA-256 (`SHA256SUMS.txt`) mit automatischem Löschen ungültiger Dateien
//! - Pflicht-Signaturprüfung der `SHA256SUMS.txt` via Minisign (eingebauter öffentlicher Schlüssel)
//! - Atomares AppImage-Self-Update via `.new`-Zwischendatei, Backup-Pflicht und atomarem `fs::rename`
//! - Echtes Fortschritts-Streaming (~10 Updates/s)
//! - Korrekte SemVer-Prüfung via `semver` Crate (Prerelease-Schutz standardmäßig aktiv)
use std::collections::HashMap;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::mpsc::{channel, Receiver, Sender};
use std::thread;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::t;
/// Konfiguration für den Gitea-Update-Server.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct UpdaterConfig {
/// Basis-URL der Gitea-Instanz, z. B. `https://gitea.pansi.eu`
pub server_url: String,
/// Repository-Owner, z. B. `harald`
pub repo_owner: String,
/// Repository-Name, z. B. `ink-notes`
pub repo_name: String,
/// Optionales Personal Access Token für private Repositories
pub auth_token: Option<String>,
/// Ob unsicheres HTTP ausnahmsweise erlaubt ist (Standard: false)
#[serde(default)]
pub allow_insecure_http: bool,
/// Ob Prerelease-Versionen als Update angeboten werden sollen (Standard: false)
#[serde(default)]
pub include_prereleases: bool,
}
impl Default for UpdaterConfig {
fn default() -> Self {
let auth_token = std::env::var("INK_NOTES_GITEA_TOKEN").ok();
Self {
server_url: "https://gitea.pansi.eu".to_string(),
repo_owner: "harald".to_string(),
repo_name: "ink-notes".to_string(),
auth_token,
allow_insecure_http: false,
include_prereleases: false,
}
}
}
impl UpdaterConfig {
/// Validiert die Server-URL auf Protokollsicherheit (erzwingt HTTPS).
pub fn validate_url(&self) -> Result<url::Url, String> {
let parsed = url::Url::parse(&self.server_url).map_err(|e| {
t!(
"Ungültige Server-URL '{url}': {e}",
url = self.server_url,
e = e
)
})?;
if parsed.scheme() != "https" && !self.allow_insecure_http {
return Err(t!(
"Unsicheres Protokoll '{scheme}' abgewiesen. Es wird zwingend HTTPS verlangt (z. B. https://gitea.pansi.eu).",
scheme = parsed.scheme()
));
}
Ok(parsed)
}
}
/// Ein Release-Asset aus der Gitea API.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GiteaAsset {
pub id: u64,
pub name: String,
#[serde(default)]
pub size: u64,
#[serde(default)]
pub download_count: u64,
pub browser_download_url: String,
}
/// Gitea Release-Objekt gemäß Swagger /api/v1/repos/{owner}/{repo}/releases/latest.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GiteaRelease {
pub id: u64,
pub tag_name: String,
pub name: String,
#[serde(default)]
pub body: String,
#[serde(default)]
pub prerelease: bool,
#[serde(default)]
pub html_url: String,
#[serde(default)]
pub published_at: Option<String>,
#[serde(default)]
pub assets: Vec<GiteaAsset>,
}
/// Erkannte Art der Installation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RuntimeEnvironment {
/// Läuft als portables AppImage (`APPIMAGE` Umgebungsvariable gesetzt)
AppImage { appimage_path: PathBuf },
/// Systemweit als .deb installiert (z.B. in /usr/bin/ink-notes)
DebPackage { binary_path: PathBuf },
/// Aus Quellcode / Entwicklungs-Binary (z.B. target/debug oder target/release)
PortableOrDev { binary_path: PathBuf },
/// Als Flatpak installiert: Updates kommen über die Software-Verwaltung, ink-notes lädt
/// selbst nichts herunter (Vorgabe von Flathub)
Flatpak,
}
impl RuntimeEnvironment {
/// Erkennt die aktuelle Laufzeitumgebung anhand der Umgebung und des Binärpfads.
pub fn detect() -> Self {
if is_flatpak(
std::env::var("FLATPAK_ID").ok().as_deref(),
Path::new("/.flatpak-info").exists(),
) {
return RuntimeEnvironment::Flatpak;
}
let appimage_var = std::env::var("APPIMAGE").ok();
let current_exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("ink-notes"));
Self::detect_from(appimage_var.as_deref(), &current_exe)
}
/// Testbare Kernlogik zur Erkennung der Laufzeitumgebung.
pub fn detect_from(appimage_var: Option<&str>, current_exe: &Path) -> Self {
if let Some(appimage_path) = appimage_var {
let trimmed = appimage_path.trim();
if !trimmed.is_empty() {
return RuntimeEnvironment::AppImage {
appimage_path: PathBuf::from(trimmed),
};
}
}
let exe_str = current_exe.to_string_lossy();
if exe_str.starts_with("/usr/bin/") || exe_str.starts_with("/usr/local/bin/") {
RuntimeEnvironment::DebPackage {
binary_path: current_exe.to_path_buf(),
}
} else {
RuntimeEnvironment::PortableOrDev {
binary_path: current_exe.to_path_buf(),
}
}
}
pub fn display_name(&self) -> &'static str {
match self {
RuntimeEnvironment::AppImage { .. } => t!("AppImage (Portabel)"),
RuntimeEnvironment::DebPackage { .. } => t!("System-Paket (.deb)"),
RuntimeEnvironment::PortableOrDev { .. } => t!("Entwicklung / Lokal"),
RuntimeEnvironment::Flatpak => "Flatpak",
}
}
/// Ob ink-notes selbst nach Updates suchen und sie installieren darf
pub fn self_update_allowed(&self) -> bool {
!matches!(self, RuntimeEnvironment::Flatpak)
}
}
/// Läuft ink-notes in einer Flatpak-Sandbox (`FLATPAK_ID` gesetzt oder `/.flatpak-info`
/// vorhanden)?
pub fn is_flatpak(flatpak_id: Option<&str>, info_file_exists: bool) -> bool {
flatpak_id.is_some_and(|id| !id.trim().is_empty()) || info_file_exists
}
/// Bereinigt und validiert einen Asset-Dateinamen gegen Path Traversal Angriffe.
pub fn sanitize_asset_name(raw_name: &str) -> Result<String, String> {
let trimmed = raw_name.trim();
if trimmed.is_empty() {
return Err(t!("Asset-Name darf nicht leer sein.").to_string());
}
if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains("..") {
return Err(t!(
"Ungültiger Asset-Name (enthält unzulässige Pfadkomponenten): '{raw_name}'",
raw_name = raw_name
));
}
let path = Path::new(trimmed);
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| t!("Ungültiger Dateiname: '{raw_name}'", raw_name = raw_name))?;
if file_name != trimmed || file_name.starts_with('.') {
return Err(t!(
"Ungültiger oder versteckter Asset-Name: '{raw_name}'",
raw_name = raw_name
));
}
Ok(file_name.to_string())
}
/// Liefert das isolierte Cache-Verzeichnis für Downloads pro Nutzer mit Rechten 0700.
pub fn get_updates_cache_dir() -> Result<PathBuf, String> {
let base = if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") {
let trimmed = xdg.trim();
if !trimmed.is_empty() {
PathBuf::from(trimmed)
} else {
default_user_cache_dir()?
}
} else {
default_user_cache_dir()?
};
let dir = base.join("ink-notes").join("updates");
std::fs::create_dir_all(&dir).map_err(|e| {
t!(
"Konnte Update-Cache-Verzeichnis nicht anlegen ({path}): {e}",
path = dir.display(),
e = e
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
}
Ok(dir)
}
fn default_user_cache_dir() -> Result<PathBuf, String> {
let home = std::env::var("HOME")
.map_err(|_| t!("HOME-Umgebungsvariable ist nicht gesetzt.").to_string())?;
Ok(PathBuf::from(home).join(".cache"))
}
/// Löscht alte temporäre Downloads aus dem Cache-Verzeichnis.
pub fn cleanup_old_updates() {
if let Ok(dir) = get_updates_cache_dir() {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
let _ = std::fs::remove_file(path);
}
}
}
}
}
/// Zerlegt einen Versionstag wie "v0.1.2" oder "0.1.2" in ein `semver::Version`.
pub fn parse_semver(tag: &str) -> Option<semver::Version> {
let clean = tag.trim().trim_start_matches(['v', 'V']);
semver::Version::parse(clean).ok()
}
/// Prüft, ob `remote_tag` numerisch neuer ist als `current_tag`.
/// Prerelease-Versionen (z. B. `-rc1`) werden standardmäßig ausgeschlossen.
pub fn is_newer_version(remote_tag: &str, current_tag: &str, include_prereleases: bool) -> bool {
match (parse_semver(remote_tag), parse_semver(current_tag)) {
(Some(remote), Some(current)) => {
if !include_prereleases && !remote.pre.is_empty() {
return false;
}
remote > current
}
_ => false,
}
}
/// Berechnet den hex-kodierten SHA-256 Hash einer Datei.
pub fn compute_sha256(path: &Path) -> Result<String, String> {
let mut file = std::fs::File::open(path).map_err(|e| {
t!(
"Konnte Datei '{path}' zur Prüfsummenberechnung nicht öffnen: {e}",
path = path.display(),
e = e
)
})?;
let mut hasher = Sha256::new();
let mut buffer = [0u8; 16384];
loop {
let n = file
.read(&mut buffer)
.map_err(|e| t!("Fehler beim Lesen der Prüfsummendatei: {e}", e = e))?;
if n == 0 {
break;
}
hasher.update(&buffer[..n]);
}
Ok(format!("{:x}", hasher.finalize()))
}
/// Parst eine `SHA256SUMS.txt` Datei in ein Mapping von Dateiname -> Hash.
pub fn parse_checksums(content: &str) -> HashMap<String, String> {
let mut map = HashMap::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
let hash = parts[0].to_lowercase();
let raw_file = parts[1].trim_start_matches('*');
if let Ok(safe_name) = sanitize_asset_name(raw_file) {
map.insert(safe_name, hash);
}
}
}
map
}
/// Verifiziert die Integrität einer Datei anhand von `SHA256SUMS.txt`.
/// Bei fehlender oder nicht übereinstimmender Prüfsumme wird die Datei gelöscht!
pub fn verify_checksum(
downloaded_path: &Path,
asset_name: &str,
checksums_content: &str,
) -> Result<(), String> {
let checksums = parse_checksums(checksums_content);
let expected_hash = checksums.get(asset_name).ok_or_else(|| {
let _ = std::fs::remove_file(downloaded_path);
t!("Kein Eintrag für '{asset_name}' in SHA256SUMS.txt gefunden. Heruntergeladene Datei wurde gelöscht.", asset_name = asset_name)
})?;
let actual_hash = compute_sha256(downloaded_path)?;
if !actual_hash.eq_ignore_ascii_case(expected_hash) {
let _ = std::fs::remove_file(downloaded_path);
return Err(t!("Integritätsprüfung fehlgeschlagen für '{asset_name}': Erwartet {expected_hash}, berechnet {actual_hash}. Datei wurde gelöscht.", asset_name = asset_name, expected_hash = expected_hash, actual_hash = actual_hash));
}
Ok(())
}
/// Öffentlicher Minisign-Schlüssel (Base64), mit dem die `SHA256SUMS.txt` jedes Releases
/// signiert sein muss. Ist er leer, lehnt diese Version alle In-App-Updates ab.
/// Erzeugen: `minisign -G -W -p ink-notes.pub -s ink-notes.key`, dann die zweite Zeile
/// aus `ink-notes.pub` hier eintragen (siehe README, Abschnitt Release-Signatur).
pub const UPDATE_SIGNING_PUBLIC_KEY: &str =
"RWTZqQ0ZDCn+PHTpqgI8NwjtgtkJk5QyJ0FYHWZlCZtABsXBcdPDv9WI";
/// Name des Prüfsummen-Assets eines Releases.
pub const CHECKSUMS_ASSET_NAME: &str = "SHA256SUMS.txt";
/// Name der Minisign-Signatur zu [`CHECKSUMS_ASSET_NAME`].
pub const CHECKSUMS_SIGNATURE_ASSET_NAME: &str = "SHA256SUMS.txt.minisig";
/// Prüft die Minisign-Signatur der Prüfsummendatei. Nur aktuelle (prehashed) Signaturen
/// werden akzeptiert; ohne hinterlegten Schlüssel schlägt die Prüfung immer fehl.
pub fn verify_checksums_signature(
checksums: &[u8],
signature_text: &str,
public_key_b64: &str,
) -> Result<(), String> {
let key = public_key_b64.trim();
if key.is_empty() {
return Err(t!("In dieser Version ist kein Signaturschlüssel für Updates hinterlegt. Bitte das Update manuell installieren.").to_string());
}
let public_key = minisign_verify::PublicKey::from_base64(key)
.map_err(|e| t!("Ungültiger Update-Signaturschlüssel: {e}.", e = e))?;
let signature = minisign_verify::Signature::decode(signature_text).map_err(|e| {
t!(
"{file} ist ungültig: {e}.",
file = CHECKSUMS_SIGNATURE_ASSET_NAME,
e = e
)
})?;
public_key
.verify(checksums, &signature, false)
.map_err(|e| {
t!(
"Signaturprüfung von {file} fehlgeschlagen: {e}.",
file = CHECKSUMS_ASSET_NAME,
e = e
)
})
}
/// Lädt einen Text-Asset des Releases über den abgesicherten HTTP-Client.
fn fetch_release_text(
agent: &ureq::Agent,
release: &GiteaRelease,
name: &str,
config: &UpdaterConfig,
) -> Result<String, String> {
let asset = release
.assets
.iter()
.find(|a| a.name == name)
.ok_or_else(|| t!("Release enthält keine {name}.", name = name))?;
http_get_with_auth_guard(
agent,
&asset.browser_download_url,
config.auth_token.as_deref(),
5,
config.allow_insecure_http,
)
.map_err(|e| t!("Konnte {name} nicht laden: {e}.", name = name, e = e))?
.into_string()
.map_err(|e| t!("Konnte {name} nicht dekodieren: {e}.", name = name, e = e))
}
/// Lädt `SHA256SUMS.txt` samt Minisign-Signatur und gibt den Inhalt nur bei gültiger
/// Signatur zurück.
fn fetch_verified_checksums(
agent: &ureq::Agent,
release: &GiteaRelease,
config: &UpdaterConfig,
public_key_b64: &str,
) -> Result<String, String> {
// Ohne Schlüssel gar nicht erst Netzwerkanfragen stellen
if public_key_b64.trim().is_empty() {
return verify_checksums_signature(b"", "", public_key_b64).map(|()| String::new());
}
let checksums = fetch_release_text(agent, release, CHECKSUMS_ASSET_NAME, config)?;
let signature = fetch_release_text(agent, release, CHECKSUMS_SIGNATURE_ASSET_NAME, config)?;
verify_checksums_signature(checksums.as_bytes(), &signature, public_key_b64)?;
Ok(checksums)
}
/// Event-Typen für den asynchronen Worker.
#[derive(Debug)]
pub enum UpdaterEvent {
CheckSuccess(GiteaRelease),
CheckUpToDate(String),
CheckError(String),
DownloadProgress {
percent: f32,
message: String,
},
DownloadSuccess {
file_path: PathBuf,
asset_name: String,
},
DownloadError(String),
}
/// Status des In-App Updaters für die egui-Oberfläche.
#[derive(Debug, Clone, PartialEq)]
pub enum UpdaterStatus {
Idle,
Checking,
UpdateAvailable(GiteaRelease),
UpToDate {
current_version: String,
},
Downloading {
asset_name: String,
percent: f32,
},
DownloadFinished {
file_path: PathBuf,
asset_name: String,
},
Error(String),
}
/// Lehnt jede URL ohne HTTPS ab, sofern unsicheres HTTP nicht ausdrücklich erlaubt ist.
fn ensure_secure_scheme(url: &url::Url, allow_insecure_http: bool) -> Result<(), String> {
match url.scheme() {
"https" => Ok(()),
"http" if allow_insecure_http => Ok(()),
scheme => Err(t!("Unsicheres Protokoll '{scheme}' für '{url}' abgewiesen. Es wird zwingend HTTPS verlangt.", scheme = scheme, url = url)),
}
}
/// Erstellt einen ureq Agent mit sauberen Timeouts.
fn create_http_agent() -> ureq::Agent {
ureq::AgentBuilder::new()
.timeout_connect(Duration::from_secs(10))
.timeout_read(Duration::from_secs(180))
.redirects(0) // Manuelle Redirect-Behandlung zum Schutz des Authorization-Tokens
.build()
}
/// Führt einen GET-Request mit Redirect-Schutz aus:
/// Bei Weiterleitungen auf andere Hosts (z. B. S3, Fremd-CDNs) wird der Authorization-Header verworfen.
fn http_get_with_auth_guard(
agent: &ureq::Agent,
initial_url_str: &str,
token: Option<&str>,
max_redirects: usize,
allow_insecure_http: bool,
) -> Result<ureq::Response, String> {
let mut current_url = url::Url::parse(initial_url_str).map_err(|e| {
t!(
"Ungültige URL '{initial_url_str}': {e}",
initial_url_str = initial_url_str,
e = e
)
})?;
let initial_origin = current_url.origin().ascii_serialization();
for _ in 0..=max_redirects {
// Jede Anfrage prüfen – auch Download-Links aus der API und Weiterleitungsziele
ensure_secure_scheme(&current_url, allow_insecure_http)?;
let mut req = agent.get(current_url.as_str());
// Token NUR anhängen, wenn Origin (Scheme, Host, Port) identisch ist
if let Some(tok) = token {
let tok_trimmed = tok.trim();
if !tok_trimmed.is_empty()
&& current_url.origin().ascii_serialization() == initial_origin
{
req = req.set("Authorization", &format!("token {tok_trimmed}"));
}
}
match req.call() {
Ok(resp) => return Ok(resp),
Err(ureq::Error::Status(code, resp)) if (301..=308).contains(&code) => {
let loc = resp.header("Location").ok_or_else(|| {
t!(
"HTTP {code} Weiterleitung ohne Location-Header erhalten",
code = code
)
})?;
current_url = current_url.join(loc).map_err(|e| {
t!(
"Ungültiges Weiterleitungsziel '{loc}': {e}",
loc = loc,
e = e
)
})?;
continue;
}
Err(ureq::Error::Status(code, resp)) => {
let body = resp.into_string().unwrap_or_default();
return Err(t!(
"Gitea-Server antwortete mit HTTP {code}: {body}",
code = code,
body = body
));
}
Err(ureq::Error::Transport(t)) => {
return Err(t!("Netzwerk-Transportfehler: {t}", t = t));
}
}
}
Err(t!("Zu viele Weiterleitungen (Redirect-Schleife abgebrochen).").to_string())
}
/// Asynchroner Controller für Update-Prüfung und Asset-Download.
pub struct UpdaterController {
config: UpdaterConfig,
runtime: RuntimeEnvironment,
status: UpdaterStatus,
active_release: Option<GiteaRelease>,
receiver: Receiver<UpdaterEvent>,
sender: Sender<UpdaterEvent>,
}
impl UpdaterController {
pub fn new() -> Self {
// Alten Download-Cache beim Start aufräumen
cleanup_old_updates();
let (sender, receiver) = channel();
Self {
config: UpdaterConfig::default(),
runtime: RuntimeEnvironment::detect(),
status: UpdaterStatus::Idle,
active_release: None,
receiver,
sender,
}
}
pub fn status(&self) -> &UpdaterStatus {
&self.status
}
pub fn runtime(&self) -> &RuntimeEnvironment {
&self.runtime
}
pub fn config(&self) -> &UpdaterConfig {
&self.config
}
pub fn set_auth_token(&mut self, token: Option<String>) {
self.config.auth_token = token;
}
pub fn set_server_url(&mut self, url: String) {
self.config.server_url = url;
}
/// Fragt im Hintergrund die Gitea-API ab.
pub fn check_for_updates(&mut self) {
if !self.runtime.self_update_allowed() {
return;
}
if matches!(
self.status,
UpdaterStatus::Checking | UpdaterStatus::Downloading { .. }
) {
return;
}
self.status = UpdaterStatus::Checking;
let config = self.config.clone();
let tx = self.sender.clone();
let current_version = env!("CARGO_PKG_VERSION").to_string();
thread::spawn(move || {
if let Err(e) = config.validate_url() {
let _ = tx.send(UpdaterEvent::CheckError(e));
return;
}
let api_url = format!(
"{}/api/v1/repos/{}/{}/releases/latest",
config.server_url.trim_end_matches('/'),
config.repo_owner,
config.repo_name
);
let agent = create_http_agent();
match http_get_with_auth_guard(
&agent,
&api_url,
config.auth_token.as_deref(),
5,
config.allow_insecure_http,
) {
Ok(resp) => match resp.into_json::<GiteaRelease>() {
Ok(release) => {
if release.prerelease && !config.include_prereleases {
let _ = tx.send(UpdaterEvent::CheckUpToDate(current_version));
} else if is_newer_version(
&release.tag_name,
&current_version,
config.include_prereleases,
) {
let _ = tx.send(UpdaterEvent::CheckSuccess(release));
} else {
let _ = tx.send(UpdaterEvent::CheckUpToDate(current_version));
}
}
Err(e) => {
let _ = tx.send(UpdaterEvent::CheckError(t!(
"Gitea Release-JSON konnte nicht verarbeitet werden: {e}",
e = e
)));
}
},
Err(e) => {
let _ = tx.send(UpdaterEvent::CheckError(e));
}
}
});
}
/// Startet den asynchronen Download eines Release-Assets inklusive Integritätsprüfung.
pub fn download_asset(&mut self, asset: GiteaAsset) {
if matches!(self.status, UpdaterStatus::Downloading { .. }) {
return;
}
let safe_asset_name = match sanitize_asset_name(&asset.name) {
Ok(n) => n,
Err(e) => {
self.status = UpdaterStatus::Error(e);
return;
}
};
self.status = UpdaterStatus::Downloading {
asset_name: safe_asset_name.clone(),
percent: 0.0,
};
let config = self.config.clone();
let tx = self.sender.clone();
let active_release = self.active_release.clone();
thread::spawn(move || {
let cache_dir = match get_updates_cache_dir() {
Ok(d) => d,
Err(e) => {
let _ = tx.send(UpdaterEvent::DownloadError(e));
return;
}
};
let target_file = cache_dir.join(&safe_asset_name);
let agent = create_http_agent();
// 1. Signierte Prüfsummen laden und verifizieren, BEVOR das Paket geladen wird
let checksums_content = match active_release.as_ref() {
Some(release) => match fetch_verified_checksums(
&agent,
release,
&config,
UPDATE_SIGNING_PUBLIC_KEY,
) {
Ok(text) => text,
Err(e) => {
let _ = tx.send(UpdaterEvent::DownloadError(t!(
"{e} Download abgelehnt.",
e = e
)));
return;
}
},
None => {
let _ = tx.send(UpdaterEvent::DownloadError(
t!("Kein Release ausgewählt. Download abgelehnt.").to_string(),
));
return;
}
};
// 2. Download des Release-Assets mit Fortschritts-Streaming
let resp = match http_get_with_auth_guard(
&agent,
&asset.browser_download_url,
config.auth_token.as_deref(),
5,
config.allow_insecure_http,
) {
Ok(r) => r,
Err(e) => {
let _ = tx.send(UpdaterEvent::DownloadError(t!(
"Download-Verbindungsfehler: {e}",
e = e
)));
return;
}
};
let content_length: Option<u64> =
resp.header("Content-Length").and_then(|s| s.parse().ok());
let mut reader = resp.into_reader();
let mut file = match std::fs::File::create(&target_file) {
Ok(f) => f,
Err(e) => {
let _ = tx.send(UpdaterEvent::DownloadError(t!(
"Konnte Zieldatei '{path}' nicht erstellen: {e}",
path = target_file.display(),
e = e
)));
return;
}
};
let mut downloaded: u64 = 0;
let mut buffer = [0u8; 16384];
let mut last_progress_time = Instant::now();
let throttle = Duration::from_millis(100); // Max. 10 Progress-Events pro Sekunde
loop {
let n = match reader.read(&mut buffer) {
Ok(0) => break,
Ok(count) => count,
Err(e) => {
let _ = std::fs::remove_file(&target_file);
let _ = tx.send(UpdaterEvent::DownloadError(t!(
"Fehler beim Lesen des Download-Streams: {e}",
e = e
)));
return;
}
};
if let Err(e) = file.write_all(&buffer[..n]) {
let _ = std::fs::remove_file(&target_file);
let _ = tx.send(UpdaterEvent::DownloadError(t!(
"Fehler beim Schreiben auf die Festplatte: {e}",
e = e
)));
return;
}
downloaded += n as u64;
if last_progress_time.elapsed() >= throttle {
let percent = match content_length {
Some(total) if total > 0 => {
(downloaded as f32 / total as f32).clamp(0.0, 1.0)
}
_ => 0.0,
};
let msg = match content_length {
Some(total) => format!(
"{:.1} / {:.1} MB",
downloaded as f64 / 1_048_576.0,
total as f64 / 1_048_576.0
),
None => format!("{:.1} MB", downloaded as f64 / 1_048_576.0),
};
let _ = tx.send(UpdaterEvent::DownloadProgress {
percent,
message: msg,
});
last_progress_time = Instant::now();
}
}
drop(file);
// 3. Integritätsprüfung des Pakets gegen die signierte SHA256SUMS.txt
if let Err(err) = verify_checksum(&target_file, &safe_asset_name, &checksums_content) {
let _ = tx.send(UpdaterEvent::DownloadError(err));
return;
}
// 4. Wenn es ein AppImage ist, Executable-Bit auf 0755 setzen
if safe_asset_name.ends_with(".AppImage") {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(&target_file) {
let mut perms = meta.permissions();
perms.set_mode(0o755);
let _ = std::fs::set_permissions(&target_file, perms);
}
}
}
let _ = tx.send(UpdaterEvent::DownloadSuccess {
file_path: target_file,
asset_name: safe_asset_name,
});
});
}
/// Startet die sichere Installation des heruntergeladenen Pakets oder öffnet es im System.
pub fn install_or_open(&self, file_path: &Path) -> Result<(), String> {
if !file_path.exists() {
return Err(t!("Die heruntergeladene Datei existiert nicht mehr.").to_string());
}
match &self.runtime {
RuntimeEnvironment::AppImage { appimage_path } => {
let parent_dir = appimage_path.parent().ok_or_else(|| {
t!("Konnte übergeordnetes Verzeichnis des AppImages nicht ermitteln.")
.to_string()
})?;
let file_name = appimage_path
.file_name()
.ok_or_else(|| t!("Ungültiger AppImage-Dateiname.").to_string())?
.to_string_lossy();
let temp_new_file =
parent_dir.join(format!("{}.new.{}", file_name, std::process::id()));
let backup_file = parent_dir.join(format!("{}.bak", file_name));
// 1. Neue Datei im SELBEN Verzeichnis anlegen (garantiert atomares rename)
if let Err(e) = std::fs::copy(file_path, &temp_new_file) {
let _ = std::fs::remove_file(&temp_new_file);
return Err(t!(
"Konnte temporäre Update-Datei nicht schreiben: {e}",
e = e
));
}
// 2. Executable-Bit setzen
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(&temp_new_file) {
let mut perms = meta.permissions();
perms.set_mode(0o755);
let _ = std::fs::set_permissions(&temp_new_file, perms);
}
}
// 3. Backup des alten AppImages anlegen – Fehler NIEMALS ignorieren!
if let Err(e) = std::fs::copy(appimage_path, &backup_file) {
let _ = std::fs::remove_file(&temp_new_file);
return Err(t!(
"Konnte Backup ({path}) nicht anlegen: {e}. Update abgebrochen, altes AppImage unverändert.",
path = backup_file.display(),
e = e
));
}
// 4. Atomar ersetzen via rename
if let Err(e) = std::fs::rename(&temp_new_file, appimage_path) {
let _ = std::fs::remove_file(&temp_new_file);
return Err(t!("Konnte AppImage nicht atomar ersetzen: {e}. Altes AppImage bleibt unverändert.", e = e));
}
// Heruntergeladene Cache-Datei aufräumen
let _ = std::fs::remove_file(file_path);
Ok(())
}
RuntimeEnvironment::DebPackage { .. } => {
match Command::new("xdg-open").arg(file_path).spawn() {
Ok(_) => Ok(()),
Err(e) => Err(t!("Konnte xdg-open nicht ausführen: {e}", e = e)),
}
}
RuntimeEnvironment::PortableOrDev { .. } => {
if let Some(dir) = file_path.parent() {
let _ = Command::new("xdg-open").arg(dir).spawn();
}
Ok(())
}
RuntimeEnvironment::Flatpak => Err(t!(
"Die Flatpak-Version wird über die Software-Verwaltung aktualisiert."
)
.to_string()),
}
}
/// Fragt periodisch ohne Blockieren eingehende Events vom Worker-Thread ab.
pub fn update(&mut self) {
while let Ok(event) = self.receiver.try_recv() {
match event {
UpdaterEvent::CheckSuccess(release) => {
self.active_release = Some(release.clone());
self.status = UpdaterStatus::UpdateAvailable(release);
}
UpdaterEvent::CheckUpToDate(ver) => {
self.status = UpdaterStatus::UpToDate {
current_version: ver,
};
}
UpdaterEvent::CheckError(err) => {
self.status = UpdaterStatus::Error(err);
}
UpdaterEvent::DownloadProgress { percent, .. } => {
if let UpdaterStatus::Downloading { asset_name, .. } = &self.status {
self.status = UpdaterStatus::Downloading {
asset_name: asset_name.clone(),
percent,
};
}
}
UpdaterEvent::DownloadSuccess {
file_path,
asset_name,
} => {
self.status = UpdaterStatus::DownloadFinished {
file_path,
asset_name,
};
}
UpdaterEvent::DownloadError(err) => {
self.status = UpdaterStatus::Error(err);
}
}
}
}
}
impl Default for UpdaterController {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_semver_parsing() {
assert_eq!(parse_semver("v0.1.0"), Some(semver::Version::new(0, 1, 0)));
assert_eq!(parse_semver("0.1.0"), Some(semver::Version::new(0, 1, 0)));
assert_eq!(
parse_semver("v1.2.3-rc1"),
Some(semver::Version::parse("1.2.3-rc1").unwrap())
);
assert_eq!(parse_semver("invalid"), None);
}
#[test]
fn test_is_newer_version_semver_rules() {
// Standardmäßig keine Prereleases anbieten
assert!(is_newer_version("v0.2.0", "0.1.0", false));
assert!(is_newer_version("0.1.1", "0.1.0", false));
assert!(is_newer_version("1.0.0", "0.9.9", false));
assert!(!is_newer_version("0.1.0", "0.1.0", false));
assert!(!is_newer_version("v0.1.0", "0.1.0", false));
assert!(!is_newer_version("0.0.9", "0.1.0", false));
// SemVer Prerelease-Reihenfolge: 1.0.0-rc1 ist kleiner als 1.0.0
assert!(is_newer_version("1.0.0", "1.0.0-rc1", false));
assert!(!is_newer_version("1.0.0-rc1", "1.0.0", false));
assert!(!is_newer_version("1.0.0-rc1", "1.0.0", true));
// Prerelease-Filterung
assert!(!is_newer_version("1.0.0-rc1", "0.9.0", false));
assert!(is_newer_version("1.0.0-rc1", "0.9.0", true));
}
#[test]
fn test_sanitize_asset_name() {
assert_eq!(
sanitize_asset_name("ink-notes_0.1.0_amd64.deb").unwrap(),
"ink-notes_0.1.0_amd64.deb"
);
assert_eq!(
sanitize_asset_name("ink-notes_0.1.0_x86_64.AppImage").unwrap(),
"ink-notes_0.1.0_x86_64.AppImage"
);
// Bösartige Pfade abfangen
assert!(sanitize_asset_name("../../etc/passwd").is_err());
assert!(sanitize_asset_name("/etc/shadow").is_err());
assert!(sanitize_asset_name("..").is_err());
assert!(sanitize_asset_name("").is_err());
assert!(sanitize_asset_name(" ").is_err());
assert!(sanitize_asset_name("subdir/file.deb").is_err());
assert!(sanitize_asset_name(".hidden_file").is_err());
}
#[test]
fn test_checksum_parsing_and_verification() {
let checksums_txt = "\
# SHA256 Prüfsummen
dcdeb7ac87d9e6cb8022e5a71bc7f2c56d779b4fce4efdd5bae5be3b0214884a ink-notes_0.1.0_amd64.deb
53383dcdb49651dcd15358b9d03c7d0482dcbe4dae9f8f9b28e24c5900192229 ink-notes_0.1.0_x86_64.AppImage
";
let parsed = parse_checksums(checksums_txt);
assert_eq!(
parsed.get("ink-notes_0.1.0_amd64.deb").unwrap(),
"dcdeb7ac87d9e6cb8022e5a71bc7f2c56d779b4fce4efdd5bae5be3b0214884a"
);
// Testdatei anlegen
let temp_dir = tempfile::tempdir().unwrap();
let test_file = temp_dir.path().join("test_asset.bin");
std::fs::write(&test_file, b"ink-notes test payload").unwrap();
let real_hash = compute_sha256(&test_file).unwrap();
let valid_sums = format!("{real_hash} test_asset.bin\n");
// 1. Gültige Prüfsumme
assert!(verify_checksum(&test_file, "test_asset.bin", &valid_sums).is_ok());
assert!(test_file.exists());
// 2. Ungültige Prüfsumme -> Muss fehlschlagen UND Datei löschen
let invalid_sums =
"0000000000000000000000000000000000000000000000000000000000000000 test_asset.bin\n";
assert!(verify_checksum(&test_file, "test_asset.bin", invalid_sums).is_err());
assert!(
!test_file.exists(),
"Manipulierte Datei muss gelöscht werden"
);
// 3. Fehlender Eintrag -> Muss fehlschlagen
let test_file2 = temp_dir.path().join("missing.bin");
std::fs::write(&test_file2, b"other data").unwrap();
assert!(verify_checksum(&test_file2, "missing.bin", &valid_sums).is_err());
assert!(
!test_file2.exists(),
"Datei ohne Prüfsumme muss gelöscht werden"
);
}
#[test]
fn test_runtime_environment_detection_logic() {
// AppImage gesetzt
let r1 = RuntimeEnvironment::detect_from(
Some("/home/user/Applications/ink-notes.AppImage"),
Path::new("/tmp/.mount_123/usr/bin/ink-notes"),
);
assert_eq!(
r1,
RuntimeEnvironment::AppImage {
appimage_path: PathBuf::from("/home/user/Applications/ink-notes.AppImage")
}
);
// Systemweites Debian-Paket (/usr/bin)
let r2 = RuntimeEnvironment::detect_from(None, Path::new("/usr/bin/ink-notes"));
assert_eq!(
r2,
RuntimeEnvironment::DebPackage {
binary_path: PathBuf::from("/usr/bin/ink-notes")
}
);
// Lokales Entwicklungs-Binary
let r3 = RuntimeEnvironment::detect_from(
None,
Path::new("/home/user/ink-notes/target/release/ink-notes"),
);
assert_eq!(
r3,
RuntimeEnvironment::PortableOrDev {
binary_path: PathBuf::from("/home/user/ink-notes/target/release/ink-notes")
}
);
}
#[test]
fn test_flatpak_detection_disables_self_update() {
assert!(is_flatpak(Some("eu.pansi.ink-notes"), false));
assert!(is_flatpak(None, true));
assert!(!is_flatpak(Some(" "), false));
assert!(!is_flatpak(None, false));
assert!(!RuntimeEnvironment::Flatpak.self_update_allowed());
assert!(
RuntimeEnvironment::detect_from(None, Path::new("/usr/bin/ink-notes"))
.self_update_allowed()
);
}
#[test]
fn test_https_enforcement() {
let config = UpdaterConfig {
server_url: "https://gitea.pansi.eu".to_string(),
allow_insecure_http: false,
..Default::default()
};
assert!(config.validate_url().is_ok());
let insecure_config = UpdaterConfig {
server_url: "http://gitea.pansi.eu".to_string(),
allow_insecure_http: false,
..Default::default()
};
assert!(insecure_config.validate_url().is_err());
let allowed_insecure = UpdaterConfig {
server_url: "http://gitea.pansi.eu".to_string(),
allow_insecure_http: true,
..Default::default()
};
assert!(allowed_insecure.validate_url().is_ok());
}
/// Nur für Tests erzeugtes Minisign-Schlüsselpaar (der geheime Schlüssel wurde verworfen).
const TEST_PUBLIC_KEY: &str = "RWQr5S8CVgsjS4UA3uY0xqTDua4uRSSzKWpFfJKLCypjgP3NGt9Tmdbh";
const TEST_CHECKSUMS: &str = "abc123 ink-notes_9.9.9_amd64.deb\n";
const TEST_SIGNATURE: &str = "untrusted comment: signature from rsign secret key
RUQr5S8CVgsjS5BYMUx1ICe1E356aP7wesQXw+oKF6Rd8WsvdF1dmCb1ffonXgJ2GhWE5iAWPXQaT8Y/Y4zLIpyS1PA8JCe8eAM=
trusted comment: ink-notes test
iw0NOedyAQg3emO36fHjyfn+AbnI7Idyl+NwFTDJX1YceLM1jSfjIfeIc0D49IQXyGTpM/QnkZJkRO/eI8ZKBQ==
";
#[test]
fn test_checksums_signature_verification() {
// Gültige Signatur
assert!(verify_checksums_signature(
TEST_CHECKSUMS.as_bytes(),
TEST_SIGNATURE,
TEST_PUBLIC_KEY
)
.is_ok());
// Manipulierte Prüfsummen (z. B. ausgetauschter Hash)
let tampered = TEST_CHECKSUMS.replace("abc123", "abc124");
assert!(
verify_checksums_signature(tampered.as_bytes(), TEST_SIGNATURE, TEST_PUBLIC_KEY)
.is_err()
);
// Fremder Schlüssel
let other_key = "RWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3";
assert!(
verify_checksums_signature(TEST_CHECKSUMS.as_bytes(), TEST_SIGNATURE, other_key)
.is_err()
);
// Kein Schlüssel hinterlegt -> strikt ablehnen
let err =
verify_checksums_signature(TEST_CHECKSUMS.as_bytes(), TEST_SIGNATURE, "").unwrap_err();
assert!(err.contains("kein Signaturschlüssel"), "{err}");
// Fehlende / kaputte Signatur
assert!(
verify_checksums_signature(TEST_CHECKSUMS.as_bytes(), "", TEST_PUBLIC_KEY).is_err()
);
// Altes (nicht prehashed) Signaturformat wird nicht akzeptiert
let legacy_sig = "untrusted comment: signature from minisign secret key
RWQf6LRCGA9i59SLOFxz6NxvASXDJeRtuZykwQepbDEGt87ig1BNpWaVWuNrm73YiIiJbq71Wi+dP9eKL8OC351vwIasSSbXxwA=
trusted comment: timestamp:1555779966\tfile:test
QtKMXWyYcwdpZAlPF7tE2ENJkRd1ujvKjlj1m9RtHTBnZPa5WKU5uWRs5GoP5M/VqE81QFuMKI5k/SfNQUaOAA==";
assert!(verify_checksums_signature(b"test", legacy_sig, other_key).is_err());
}
#[test]
fn test_https_enforced_for_every_request() {
let https = url::Url::parse("https://gitea.pansi.eu/a.deb").unwrap();
let http = url::Url::parse("http://cdn.example.com/a.deb").unwrap();
let ftp = url::Url::parse("ftp://example.com/a.deb").unwrap();
assert!(ensure_secure_scheme(&https, false).is_ok());
assert!(ensure_secure_scheme(&http, false).is_err());
assert!(ensure_secure_scheme(&http, true).is_ok());
assert!(ensure_secure_scheme(&ftp, true).is_err());
// Ein http-Download-Link aus der Release-API wird abgewiesen, bevor eine
// Verbindung aufgebaut wird
let agent = create_http_agent();
let err = http_get_with_auth_guard(
&agent,
"http://127.0.0.1:9/ink-notes.AppImage",
Some("geheim"),
5,
false,
)
.unwrap_err();
assert!(err.contains("HTTPS"), "{err}");
}
#[test]
fn test_appimage_atomic_self_update() {
let temp_dir = tempfile::tempdir().unwrap();
let appimage_path = temp_dir.path().join("ink-notes.AppImage");
std::fs::write(&appimage_path, b"original-appimage-v1").unwrap();
let new_download = temp_dir.path().join("downloaded.AppImage");
std::fs::write(&new_download, b"updated-appimage-v2").unwrap();
let controller = UpdaterController {
config: UpdaterConfig::default(),
runtime: RuntimeEnvironment::AppImage {
appimage_path: appimage_path.clone(),
},
status: UpdaterStatus::Idle,
active_release: None,
receiver: channel().1,
sender: channel().0,
};
// Update durchführen
let res = controller.install_or_open(&new_download);
assert!(res.is_ok(), "Update sollte erfolgreich sein: {res:?}");
// Verifizieren: AppImage hat neuen Inhalt
let updated_content = std::fs::read(&appimage_path).unwrap();
assert_eq!(updated_content, b"updated-appimage-v2");
// Backup existiert mit altem Inhalt
let backup_path = temp_dir.path().join("ink-notes.AppImage.bak");
assert!(backup_path.exists(), "Backup-Datei muss existieren");
assert_eq!(
std::fs::read(&backup_path).unwrap(),
b"original-appimage-v1"
);
// Download-Cache-Datei wurde aufgeräumt
assert!(
!new_download.exists(),
"Download-Datei muss nach Erfolg aufgeräumt werden"
);
}
#[test]
fn test_gitea_release_deserialization() {
let json_data = r#"{
"id": 42,
"tag_name": "v0.2.0",
"name": "ink-notes v0.2.0 Release",
"body": "Neuerungen:\n- Zorin OS 18 Optimierung\n- Gitea Auto-Update",
"prerelease": false,
"html_url": "https://gitea.pansi.eu/harald/ink-notes/releases/tag/v0.2.0",
"published_at": "2026-10-06T12:00:00Z",
"assets": [
{
"id": 101,
"name": "ink-notes_0.2.0_amd64.deb",
"size": 4313296,
"download_count": 5,
"browser_download_url": "https://gitea.pansi.eu/harald/ink-notes/releases/download/v0.2.0/ink-notes_0.2.0_amd64.deb"
},
{
"id": 102,
"name": "ink-notes_0.2.0_x86_64.AppImage",
"size": 5872000,
"download_count": 8,
"browser_download_url": "https://gitea.pansi.eu/harald/ink-notes/releases/download/v0.2.0/ink-notes_0.2.0_x86_64.AppImage"
}
]
}"#;
let release: GiteaRelease =
serde_json::from_str(json_data).expect("Sollte Release deserialisieren");
assert_eq!(release.tag_name, "v0.2.0");
assert_eq!(release.assets.len(), 2);
assert_eq!(release.assets[0].name, "ink-notes_0.2.0_amd64.deb");
assert_eq!(release.assets[1].name, "ink-notes_0.2.0_x86_64.AppImage");
assert!(is_newer_version(&release.tag_name, "0.1.0", false));
}
}