- 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>
1297 lines
47 KiB
Rust
1297 lines
47 KiB
Rust
//! 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(), ¤t_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(¤t_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,
|
||
¤t_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));
|
||
}
|
||
}
|