diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5cd601a26..1121057ed 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -361,3 +361,27 @@ have no semibold) instead of the app's embedded Noto fonts. against `MemoryRegistry`, a fake `ISetupSystem` and scratch folders, and renders every page. `/SANDBOX=` runs the real Setup with a registry file and all folders inside ``; `build-installer.ps1 -CheckOnly` (CI) installs, updates and uninstalls that way. + +## Linux installer and updates + +On Linux, Filee is distributed as a self-contained archive (`Filee--.tar.gz` for `linux-x64` and +`linux-arm64`) containing the application, `filee-cli`, bundled conversion engines, application icon, `.desktop` launcher, +and standalone shell scripts (`install.sh`, `uninstall.sh`). + +- **Installation**: `install.sh` installs for the current user into `~/.local/share/filee/application` without root, + creating a launcher symlink at `~/.local/bin/filee` and desktop registration at + `~/.local/share/applications/com.filee.app.desktop`. A `.filee-package` marker containing `com.filee.app` identifies + the directory as an authentic managed installation. Running without `--upgrade` refuses to overwrite an existing + installation. +- **In-app updates** (`UpdateService`): Checks GitHub releases for matching `.tar.gz` and `-SHA256SUMS.txt` + assets. The tarball is verified against SHA-256 before extraction. Archive extraction validates that all entries stay + within the target directory, rejecting absolute paths, directory symlinks, and escaping links, and masks permissions to + standard rwx bits (excluding setuid/setgid/sticky bits). +- **Transactional upgrade**: `UpdateAsync` stages the payload in a private directory (`~/.local/share/filee/.update-*`), + spawns a detached `/bin/sh` helper, and requests application shutdown (`App.Quit`). Once the old process exits, the + helper executes `install.sh --upgrade` (which safely moves the existing installation to a backup directory, validates the + new files, and rolls back on failure or signal interruption), and restarts Filee with `--background --whats-new`. + Output and rollback diagnostics are appended to Filee's daily log (`~/.config/Filee/logs/`). Downloaded conversion engines + (`~/.local/share/Filee/engines`) and user settings (`~/.config/Filee`) remain untouched. +- **Uninstallation**: `uninstall.sh` removes the application directory, desktop launcher, and `~/.local/bin/filee` symlink + after verifying the `.filee-package` marker. User settings and converted files are kept. diff --git a/installer/linux/INSTALL.txt b/installer/linux/INSTALL.txt index 68d5c4a5d..f1f819eb9 100644 --- a/installer/linux/INSTALL.txt +++ b/installer/linux/INSTALL.txt @@ -2,10 +2,28 @@ Run ./Filee to test the extracted app, or ./filee-cli help for the command line. Run ./install.sh to install for the current user. It requires Python 3, creates an application launcher and ~/.local/bin/filee, and does not require root. +Run ./install.sh --upgrade to safely upgrade an existing installation in place +without uninstalling. Your settings, presets, history, and downloaded conversion +engines are kept. Running without --upgrade refuses to overwrite an existing installation. Run ./uninstall.sh to remove this installation and Filee-downloaded engines. Your settings and converted files are kept. If integration cleanup fails, the installation is kept so you can fix the error and run the uninstaller again. -An existing installation is never overwritten; uninstall it before replacing it. + +Upgrade reliability and crash recovery: +The upgrade procedure prepares files on the same filesystem and replaces the +installation using atomic rename steps with an automatic rollback trap. If an error +or signal interruption occurs during the swap, the previous version is restored. +Note: While each individual directory rename is atomic in POSIX, the two-step swap +(moving the old installation aside, then moving the new version into place) is not +a single atomic transaction. In the event of uncatchable termination (SIGKILL) or +sudden hardware power loss during the brief window between steps, automatic traps +cannot execute; the previous installation is safely preserved in .backup-. +To recover manually: +- If the application directory does not exist: + mv ~/.local/share/filee/.backup- ~/.local/share/filee/application +- If a partial or invalid application directory already exists: + Inspect and remove or rename ~/.local/share/filee/application first, then run: + mv ~/.local/share/filee/.backup- ~/.local/share/filee/application Filee runs on Ubuntu 22.04 or newer and other distributions with glibc 2.35 or newer. The .NET runtime is bundled; its native system libraries are still diff --git a/installer/linux/install.sh b/installer/linux/install.sh old mode 100644 new mode 100755 index 2a3cec01c..cdfefacf8 --- a/installer/linux/install.sh +++ b/installer/linux/install.sh @@ -1,30 +1,226 @@ #!/bin/sh set -eu -source_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P) +# Filee Linux standalone installer. +# Supports first-time installation and in-place upgrade (--upgrade). +# Preserves downloaded conversion engines (~/.local/share/Filee/engines) and user settings (~/.config/Filee). + +upgrade=0 + +for arg do + case "$arg" in + --upgrade|-u) + upgrade=1 + ;; + --help|-h) + printf '%s\n' "Usage: $0 [--upgrade|-u]" + exit 0 + ;; + *) + printf '%s\n' "Unknown option: $arg" >&2 + printf '%s\n' "Usage: $0 [--upgrade|-u]" >&2 + exit 1 + ;; + esac +done + +source_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P) data_dir=${XDG_DATA_HOME:-"$HOME/.local/share"} install_dir="$data_dir/filee/application" bin_dir="$HOME/.local/bin" desktop_file="$data_dir/applications/com.filee.app.desktop" -if [ -e "$install_dir" ] || [ -L "$install_dir" ]; then - printf '%s\n' "An installation already exists at $install_dir. Run its uninstall.sh first." + +# Validate required tools and incoming payload files before doing anything. +command -v python3 >/dev/null 2>&1 || { + printf '%s\n' "Python 3 is required to install Filee." >&2 exit 1 -fi -if [ -e "$bin_dir/filee" ] || [ -L "$bin_dir/filee" ]; then - printf '%s\n' "$bin_dir/filee already exists. Remove or rename it before installing Filee." +} + + +if [ ! -f "$source_dir/Filee" ] || [ ! -x "$source_dir/Filee" ]; then + printf '%s\n' "Payload validation failed: $source_dir/Filee is missing or not executable." >&2 exit 1 fi -if [ -e "$desktop_file" ] || [ -L "$desktop_file" ]; then - printf '%s\n' "$desktop_file already exists. Remove or rename it before installing Filee." + +if [ ! -f "$source_dir/filee-cli" ] || [ ! -x "$source_dir/filee-cli" ]; then + printf '%s\n' "Payload validation failed: $source_dir/filee-cli is missing or not executable." >&2 exit 1 fi -command -v python3 >/dev/null -test -x "$source_dir/Filee" -test -x "$source_dir/filee-cli" -mkdir -p "$data_dir/filee" "$bin_dir" "$data_dir/applications" -cp -R "$source_dir" "$install_dir" -printf '%s\n' 'com.filee.app' > "$install_dir/.filee-package" -ln -s "$install_dir/filee-cli" "$bin_dir/filee" + +if [ "$upgrade" -eq 0 ]; then + # First-time installation: refuse to overwrite an existing installation or files. + if [ -e "$install_dir" ] || [ -L "$install_dir" ]; then + printf '%s\n' "An installation already exists at $install_dir. Run its uninstall.sh first." + exit 1 + fi + if [ -e "$bin_dir/filee" ] || [ -L "$bin_dir/filee" ]; then + printf '%s\n' "$bin_dir/filee already exists. Remove or rename it before installing Filee." + exit 1 + fi + if [ -e "$desktop_file" ] || [ -L "$desktop_file" ]; then + printf '%s\n' "$desktop_file already exists. Remove or rename it before installing Filee." + exit 1 + fi + + mkdir -p "$data_dir/filee" "$bin_dir" "$data_dir/applications" + cp -R "$source_dir" "$install_dir" + printf '%s\n' 'com.filee.app' > "$install_dir/.filee-package" + rm -f "$install_dir/.filee-upgrade-token" + ln -s "$install_dir/filee-cli" "$bin_dir/filee" +else + # Upgrade existing installation: verify it is genuine and safely swap in place. + if [ ! -d "$install_dir" ] || [ -L "$install_dir" ]; then + printf '%s\n' "No existing installation found at $install_dir to upgrade." >&2 + exit 1 + fi + + marker_file="$install_dir/.filee-package" + if [ ! -f "$marker_file" ]; then + printf '%s\n' "Cannot upgrade: $install_dir is missing the .filee-package marker." >&2 + exit 1 + fi + + marker=$(tr -d '\r\n' < "$marker_file" 2>/dev/null || true) + if [ "$marker" != "com.filee.app" ]; then + printf '%s\n' "Cannot upgrade: $install_dir was not installed by Filee." >&2 + exit 1 + fi + + # Tell running instance to save and exit cleanly before touching files. + if [ -x "$install_dir/Filee" ]; then + if ! "$install_dir/Filee" --quit; then + printf '%s\n' "Could not gracefully shut down the running Filee instance. Please exit Filee and try again." >&2 + exit 1 + fi + fi + + # Generate a unique transaction ID for this upgrade session using PID and random bytes. + # This prevents collisions with any concurrent installer or pre-existing backup artifacts. + token_rand=$(od -vN 8 -An -tx1 /dev/urandom 2>/dev/null | tr -d ' \n' || true) + tx_id="$$-${token_rand:-$(date +%s)}" + staging_dir="$data_dir/filee/.staging-$tx_id" + backup_dir="$data_dir/filee/.backup-$tx_id" + failed_dir="$data_dir/filee/.failed-$tx_id" + upgrade_token="filee-upgrade-$tx_id" + activated=0 + + # Ensure transaction paths are completely distinct and do not collide with pre-existing files. + if [ -e "$staging_dir" ] || [ -e "$backup_dir" ] || [ -e "$failed_dir" ]; then + printf '%s\n' "Transaction path conflict detected at $data_dir/filee. Please retry." >&2 + exit 1 + fi + + mkdir -p "$data_dir/filee" "$bin_dir" "$data_dir/applications" + mkdir "$staging_dir" + + # Copy incoming payload into staging (preserves source_dir without moving/deleting it). + cp -R "$source_dir/." "$staging_dir/" + printf '%s\n' 'com.filee.app' > "$staging_dir/.filee-package" + printf '%s\n' "$upgrade_token" > "$staging_dir/.filee-upgrade-token" + chmod +x "$staging_dir/Filee" "$staging_dir/filee-cli" + + # Validate the staged files before moving the existing installation aside. + if [ ! -x "$staging_dir/Filee" ] || [ ! -x "$staging_dir/filee-cli" ]; then + rm -rf "$staging_dir" + printf '%s\n' "Staged payload validation failed." >&2 + exit 1 + fi + + # Rollback trap: handles catchable signals and script aborts. + # Ensures $backup_dir is restored if anything fails during replacement. + rollback_and_cleanup() { + exit_code=$? + if [ -d "$backup_dir" ]; then + # If new files were activated into $install_dir, verify that $install_dir + # actually contains this run's upgrade token before moving it aside. + # This prevents another process or symlink swap from causing cleanup + # to remove anything other than this process's failed payload. + if [ "$activated" -eq 1 ] && [ -d "$install_dir" ] && [ ! -L "$install_dir" ]; then + token_file="$install_dir/.filee-upgrade-token" + if [ -f "$token_file" ] && [ ! -L "$token_file" ]; then + token=$(tr -d '\r\n' < "$token_file" 2>/dev/null || true) + if [ "$token" = "$upgrade_token" ]; then + # Token verified: safely move the failed payload aside to quarantine. + # Ensure $failed_dir does not already exist so mv performs a clean rename, + # and never fall back to recursive deletion if quarantine move fails. + if [ ! -e "$failed_dir" ] && mv "$install_dir" "$failed_dir" 2>/dev/null; then + # Re-verify token inside quarantined directory before deletion (TOCTOU mitigation). + # If ownership, symlink checks, or token match fail, keep $failed_dir intact. + q_token_file="$failed_dir/.filee-upgrade-token" + if [ -d "$failed_dir" ] && [ ! -L "$failed_dir" ] && [ -f "$q_token_file" ] && [ ! -L "$q_token_file" ]; then + q_token=$(tr -d '\r\n' < "$q_token_file" 2>/dev/null || true) + if [ "$q_token" = "$upgrade_token" ]; then + rm -rf "$failed_dir" 2>/dev/null || true + fi + fi + fi + fi + fi + fi + + if [ ! -d "$install_dir" ] && mv "$backup_dir" "$install_dir" 2>/dev/null; then + printf '%s\n' "Update failed. Restored the previous Filee installation." >&2 + if [ -d "$failed_dir" ]; then + printf '%s\n' "Quarantined failed update files preserved at: $failed_dir" >&2 + fi + else + printf '%s\n' "CRITICAL: Update failed and automatic rollback could not complete." >&2 + printf '%s\n' "Your previous installation is preserved at: $backup_dir" >&2 + if [ -d "$install_dir" ]; then + printf '%s\n' "A partial or invalid directory exists at: $install_dir" >&2 + printf '%s\n' "To restore manually, inspect and remove or rename '$install_dir', then run:" >&2 + printf '%s\n' " mv '$backup_dir' '$install_dir'" >&2 + else + printf '%s\n' "To restore manually, run: mv '$backup_dir' '$install_dir'" >&2 + fi + if [ -d "$failed_dir" ]; then + printf '%s\n' "Quarantined failed update files preserved at: $failed_dir" >&2 + fi + fi + fi + rm -rf "$staging_dir" 2>/dev/null || true + exit "$exit_code" + } + trap rollback_and_cleanup EXIT INT TERM HUP + + # Note on atomicity and crash recovery: + # Individual filesystem renames (rename syscall) are atomic in POSIX. + # However, replacing a non-empty directory on Linux requires two renames: + # 1. mv "$install_dir" "$backup_dir" + # 2. mv "$staging_dir" "$install_dir" + # The two-step sequence as a whole is not a single atomic transaction. + # There is a brief window between step 1 and step 2 where $install_dir does + # not exist. Catchable signals (SIGINT, SIGTERM, SIGHUP) and script errors + # are handled by the rollback trap above, which immediately restores $backup_dir. + # If uncatchable termination (SIGKILL) or sudden hardware power loss occurs + # during this brief window, automatic traps cannot run; the previous installation + # remains safe in $backup_dir and can be restored manually. + + # Move active installation to backup. + mv "$install_dir" "$backup_dir" + + # Activate new installation from staging. + if ! mv "$staging_dir" "$install_dir"; then + exit 1 + fi + activated=1 + + # Post-swap validation: ensure new installation is active and healthy. + if [ ! -x "$install_dir/Filee" ] || [ ! -x "$install_dir/filee-cli" ] || [ ! -f "$install_dir/.filee-package" ]; then + printf '%s\n' "Activated installation failed post-swap validation. Rolling back..." >&2 + exit 1 + fi + + # Success: disarm the rollback trap before removing the old version backup. + trap - EXIT INT TERM HUP + rm -rf "$backup_dir" + rm -f "$install_dir/.filee-upgrade-token" + + # Refresh launcher symlink (using -f to overwrite existing link atomically). + ln -sf "$install_dir/filee-cli" "$bin_dir/filee" +fi + +# Update desktop launcher. python3 - "$install_dir" "$desktop_file" <<'PY' import pathlib import sys @@ -46,7 +242,13 @@ desktop.write_text( f'Icon={desktop_value(app / "filee.png")}\n' 'Terminal=false\nCategories=Utility;\nX-Filee-Managed=true\n', encoding='utf-8') PY + if command -v update-desktop-database >/dev/null 2>&1; then - update-desktop-database "$data_dir/applications" + update-desktop-database "$data_dir/applications" 2>/dev/null || true +fi + +if [ "$upgrade" -eq 1 ]; then + printf '%s\n' "Upgraded Filee at $install_dir." +else + printf '%s\n' "Installed Filee at $install_dir" "CLI: $bin_dir/filee (add $bin_dir to PATH if needed)." fi -printf '%s\n' "Installed Filee at $install_dir" "CLI: $bin_dir/filee (add $bin_dir to PATH if needed)." diff --git a/src/Filee.App/App.axaml.cs b/src/Filee.App/App.axaml.cs index d95be0a10..ff84c26b9 100644 --- a/src/Filee.App/App.axaml.cs +++ b/src/Filee.App/App.axaml.cs @@ -140,6 +140,9 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop) _ = Task.Run(() => Filee.Core.Web.WebDrop.DeleteStale(Filee.Core.Web.WebDrop.DefaultWorkRoot, TimeSpan.FromDays(1))); // Likewise the work folders of conversions that never got to clean up after themselves. _ = Task.Run(() => JobQueue.DeleteStaleWorkFolders(JobQueue.DefaultWorkRoot, TimeSpan.FromDays(1))); + // Leftover update workspaces from cancelled or failed in-app updates (Linux only). + if (OperatingSystem.IsLinux()) + _ = Task.Run(() => UpdateService.DeleteStaleWorkspaces()); // Once LibreOffice is installed: prepare its profiles while the user isn't converting yet. AppHost.Get().WarmUpInBackground(TimeSpan.FromSeconds(10)); @@ -203,6 +206,7 @@ private void ListenForLaterLaunches(SingleInstance instance, ILogger log) private static void WatchForUpdates(UserDataStore store) { var updates = AppHost.Get(); + updates.RequestApplicationShutdown = Quit; // Required: the card every time Filee starts (UpdateFound fires once per run). Recommended: once per version. // Optional: no card, the sidebar button only. Frequent releases no longer ask every day. updates.UpdateFound += (_, version) => @@ -215,6 +219,9 @@ private static void WatchForUpdates(UserDataStore store) AppHost.Get().ShowUpdateNotice(version); }; updates.UpdateFailed += async (_, _) => await AppHost.Get().ShowUpdateFailedAsync(); + // The last run's update failed after Filee closed (Linux): the installer rolled back and started this copy. + if (updates.CheckPendingFailure(store.Directory)) + Dispatcher.UIThread.Post(async () => await AppHost.Get().ShowUpdateRolledBackAsync(updates.LastFailureLogPath)); updates.StartPeriodicChecks(() => store.Settings.CheckForUpdates, TimeSpan.FromSeconds(20)); DispatcherTimer.RunOnce(() => NotifyEngineUpdates(store), TimeSpan.FromSeconds(25)); } diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index d5f344858..885af93b6 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -311,6 +311,8 @@ "update.installing": "أداة التثبيت قيد التشغيل. سيبدأ Filee مجددًا عند اكتمالها.", "update.failed": "تعذّر التحديث تلقائيًا. نزّل الإصدار الجديد من filee.sh.", "update.failed_alert": "تعذّر تنزيل التحديث.\nاضغط على «موافق» لفتح filee.sh وتنزيل الإصدار الجديد منه.", + "update.rolled_back_alert": "تعذّر تثبيت التحديث، لذا أبقى Filee على الإصدار الذي كان لديك.\nاضغط على «موافق» لفتح filee.sh وتنزيل الإصدار الجديد منه.", + "update.rolled_back_log": "التفاصيل في السجل: {0}", "update.cancelled": "أُلغي التحديث. اضغط على «تحديث» مجددًا لتثبيته.", "update.level.Required": "تحديث ضروري", "update.level.Recommended": "تحديث موصى به", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index 08c33f554..690f34f2d 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -323,6 +323,8 @@ "update.installing": "The installer is running. Filee starts again when it is done.", "update.failed": "Couldn't update automatically. Download the new version from filee.sh.", "update.failed_alert": "Couldn't download the update.\nPress OK to open filee.sh and download the new version there.", + "update.rolled_back_alert": "Couldn't install the update, so Filee kept the version you had.\nPress OK to open filee.sh and download the new version there.", + "update.rolled_back_log": "Details are in the log: {0}", "update.cancelled": "Update cancelled. Press Update again to install it.", "update.level.Required": "Required update", "update.level.Recommended": "Recommended update", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index fbc629f41..3981f0eae 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -311,6 +311,8 @@ "update.installing": "El instalador se está ejecutando. Filee se reinicia cuando termina.", "update.failed": "No se pudo actualizar automáticamente. Descarga la versión nueva en filee.sh.", "update.failed_alert": "No se pudo descargar la actualización.\nPulsa Aceptar para abrir filee.sh y descargar allí la versión nueva.", + "update.rolled_back_alert": "No se pudo instalar la actualización, así que Filee conservó la versión que tenías.\nPulsa Aceptar para abrir filee.sh y descargar allí la versión nueva.", + "update.rolled_back_log": "Los detalles están en el registro: {0}", "update.cancelled": "Actualización cancelada. Pulsa Actualizar otra vez para instalarla.", "update.level.Required": "Actualización obligatoria", "update.level.Recommended": "Actualización recomendada", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index fbc0a30bd..7356f16c8 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -323,6 +323,8 @@ "update.installing": "Le programme d'installation est en cours. Filee redémarre une fois terminé.", "update.failed": "Impossible de mettre à jour automatiquement. Téléchargez la nouvelle version sur filee.sh.", "update.failed_alert": "Impossible de télécharger la mise à jour.\nAppuyez sur OK pour ouvrir filee.sh et y télécharger la nouvelle version.", + "update.rolled_back_alert": "Impossible d'installer la mise à jour : Filee a gardé la version que vous aviez.\nAppuyez sur OK pour ouvrir filee.sh et y télécharger la nouvelle version.", + "update.rolled_back_log": "Les détails figurent dans le journal : {0}", "update.cancelled": "Mise à jour annulée. Appuyez à nouveau sur Mettre à jour pour l'installer.", "update.level.Required": "Mise à jour obligatoire", "update.level.Recommended": "Mise à jour recommandée", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index aa2b97dd5..ae759e482 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -311,6 +311,8 @@ "update.installing": "इंस्टॉलर चल रहा है। पूरा होने पर Filee फिर शुरू हो जाएगा।", "update.failed": "अपने-आप अपडेट नहीं हो सका। नया संस्करण filee.sh से डाउनलोड करें।", "update.failed_alert": "अपडेट डाउनलोड नहीं हो सका।\nfilee.sh खोलने और वहाँ से नया संस्करण डाउनलोड करने के लिए “ठीक है” दबाएँ।", + "update.rolled_back_alert": "अपडेट इंस्टॉल नहीं हो सका, इसलिए Filee ने आपका पिछला संस्करण बनाए रखा।\nfilee.sh खोलने और वहाँ से नया संस्करण डाउनलोड करने के लिए “ठीक है” दबाएँ।", + "update.rolled_back_log": "विवरण लॉग में है: {0}", "update.cancelled": "अपडेट रद्द किया गया। इंस्टॉल करने के लिए फिर से अपडेट दबाएँ।", "update.level.Required": "ज़रूरी अपडेट", "update.level.Recommended": "अनुशंसित अपडेट", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index ede662939..39cc6e692 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -311,6 +311,8 @@ "update.installing": "インストーラーを実行中です。完了するとFileeが再起動します。", "update.failed": "自動更新に失敗しました。filee.shから新しいバージョンをダウンロードしてください。", "update.failed_alert": "更新をダウンロードできませんでした。\n「OK」を押すとfilee.shを開き、新しいバージョンをダウンロードできます。", + "update.rolled_back_alert": "更新をインストールできなかったため、Filee はこれまでのバージョンのままです。\n「OK」を押すとfilee.shを開き、新しいバージョンをダウンロードできます。", + "update.rolled_back_log": "詳細はログにあります:{0}", "update.cancelled": "更新をキャンセルしました。インストールするには、もう一度「更新」を押してください。", "update.level.Required": "必須の更新", "update.level.Recommended": "推奨の更新", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 91d11c7ed..a4310152e 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -323,6 +323,8 @@ "update.installing": "설치 프로그램이 열렸어요. 설치가 끝나면 Filee가 다시 켜져요.", "update.failed": "자동 업데이트가 안 됐어요. filee.sh에서 새 버전을 받아 주세요.", "update.failed_alert": "업데이트를 받지 못했어요.\n확인을 누르면 filee.sh를 열어 드릴게요. 거기서 새 버전을 받을 수 있어요.", + "update.rolled_back_alert": "업데이트를 설치하지 못해서 쓰던 버전을 그대로 뒀어요.\n확인을 누르면 filee.sh를 열어 드릴게요. 거기서 새 버전을 받을 수 있어요.", + "update.rolled_back_log": "자세한 내용은 로그에 있어요: {0}", "update.cancelled": "업데이트를 취소했어요. 다시 누르면 설치해요.", "update.level.Required": "필수 업데이트", "update.level.Recommended": "권장 업데이트", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index 17e5fd992..49e0643b4 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -311,6 +311,8 @@ "update.installing": "स्थापनाकर्ता चलिरहेको छ। पूरा भएपछि Filee फेरि सुरु हुनेछ।", "update.failed": "स्वचालित रूपमा अपडेट गर्न सकिएन। नयाँ संस्करण filee.sh बाट डाउनलोड गर्नुहोस्।", "update.failed_alert": "अपडेट डाउनलोड गर्न सकिएन।\nfilee.sh खोल्न र त्यहाँबाट नयाँ संस्करण डाउनलोड गर्न 'ठीक छ' थिच्नुहोस्।", + "update.rolled_back_alert": "अपडेट इन्स्टल गर्न सकिएन, त्यसैले Filee ले तपाईंको पुरानो संस्करण नै राख्यो।\nfilee.sh खोल्न र त्यहाँबाट नयाँ संस्करण डाउनलोड गर्न 'ठीक छ' थिच्नुहोस्।", + "update.rolled_back_log": "विवरण लगमा छ: {0}", "update.cancelled": "अपडेट रद्द गरियो। स्थापना गर्न फेरि अपडेट थिच्नुहोस्।", "update.level.Required": "आवश्यक अपडेट", "update.level.Recommended": "सिफारिस गरिएको अपडेट", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 9f88452c6..0f599e0a4 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -323,6 +323,8 @@ "update.installing": "Instalator działa. Filee uruchomi się ponownie po zakończeniu.", "update.failed": "Nie udało się zaktualizować automatycznie. Pobierz nową wersję z filee.sh.", "update.failed_alert": "Nie udało się pobrać aktualizacji.\nNaciśnij OK, aby otworzyć filee.sh i pobrać tam nową wersję.", + "update.rolled_back_alert": "Nie udało się zainstalować aktualizacji, więc Filee zachował dotychczasową wersję.\nNaciśnij OK, aby otworzyć filee.sh i pobrać tam nową wersję.", + "update.rolled_back_log": "Szczegóły są w dzienniku: {0}", "update.cancelled": "Aktualizacja anulowana. Naciśnij Aktualizuj ponownie, aby ją zainstalować.", "update.level.Required": "Wymagana aktualizacja", "update.level.Recommended": "Zalecana aktualizacja", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index ab9da6f1b..f587828de 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -323,6 +323,8 @@ "update.installing": "O instalador está em execução. O Filee inicia de novo quando terminar.", "update.failed": "Não foi possível atualizar automaticamente. Baixe a nova versão em filee.sh.", "update.failed_alert": "Não foi possível baixar a atualização.\nPressione OK para abrir filee.sh e baixar a nova versão lá.", + "update.rolled_back_alert": "Não foi possível instalar a atualização, então o Filee manteve a versão que você tinha.\nPressione OK para abrir filee.sh e baixar a nova versão lá.", + "update.rolled_back_log": "Os detalhes estão no registro: {0}", "update.cancelled": "Atualização cancelada. Pressione Atualizar de novo para instalar.", "update.level.Required": "Atualização obrigatória", "update.level.Recommended": "Atualização recomendada", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 8aacb6aa2..030e7c465 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -323,6 +323,8 @@ "update.installing": "Идёт установка. Когда она закончится, Filee запустится снова.", "update.failed": "Не удалось обновиться автоматически. Скачайте новую версию с filee.sh.", "update.failed_alert": "Не удалось скачать обновление.\nНажмите OK, чтобы открыть filee.sh и скачать новую версию там.", + "update.rolled_back_alert": "Не удалось установить обновление, поэтому Filee оставил прежнюю версию.\nНажмите OK, чтобы открыть filee.sh и скачать новую версию там.", + "update.rolled_back_log": "Подробности в журнале: {0}", "update.cancelled": "Обновление отменено. Нажмите «Обновить» ещё раз, чтобы установить его.", "update.level.Required": "Обязательное обновление", "update.level.Recommended": "Рекомендуемое обновление", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index 013e31ff8..ae327445c 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -311,6 +311,8 @@ "update.installing": "ตัวติดตั้งกำลังทำงาน Filee จะเปิดใหม่อีกครั้งเมื่อเสร็จสิ้น", "update.failed": "ไม่สามารถอัปเดตอัตโนมัติได้ ดาวน์โหลดเวอร์ชันใหม่ได้ที่ filee.sh", "update.failed_alert": "ไม่สามารถดาวน์โหลดการอัปเดตได้\nกดตกลงเพื่อเปิด filee.sh และดาวน์โหลดเวอร์ชันใหม่ที่นั่น", + "update.rolled_back_alert": "ไม่สามารถติดตั้งการอัปเดตได้ Filee จึงยังใช้เวอร์ชันเดิมของคุณ\nกดตกลงเพื่อเปิด filee.sh และดาวน์โหลดเวอร์ชันใหม่ที่นั่น", + "update.rolled_back_log": "ดูรายละเอียดได้ในบันทึกข้อผิดพลาด: {0}", "update.cancelled": "การอัปเดตถูกยกเลิก กดอัปเดตอีกครั้งเพื่อทำการติดตั้ง", "update.level.Required": "การอัปเดตที่จำเป็น", "update.level.Recommended": "การอัปเดตที่แนะนำ", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index a880dc4cb..6b737458f 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -323,6 +323,8 @@ "update.installing": "安装程序正在运行,完成后 Filee 会重新启动。", "update.failed": "无法自动更新,请从 filee.sh 下载新版本。", "update.failed_alert": "无法下载更新。\n点击“确定”后会打开 filee.sh,可以在那里下载新版本。", + "update.rolled_back_alert": "无法安装更新,Filee 保留了原来的版本。\n点击“确定”后会打开 filee.sh,可以在那里下载新版本。", + "update.rolled_back_log": "详细信息见日志 {0}", "update.cancelled": "已取消更新,再次点击“更新”即可安装。", "update.level.Required": "必要更新", "update.level.Recommended": "建议更新", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index 2e36d0d17..e7ff28099 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -323,6 +323,8 @@ "update.installing": "安裝程式正在執行,完成後 Filee 會重新啟動。", "update.failed": "無法自動更新,請從 filee.sh 下載新版本。", "update.failed_alert": "無法下載更新。\n點擊「確定」後會開啟 filee.sh,可以在那裡下載新版本。", + "update.rolled_back_alert": "無法安裝更新,Filee 保留了原本的版本。\n點擊「確定」後會開啟 filee.sh,可以在那裡下載新版本。", + "update.rolled_back_log": "詳細資訊見記錄檔 {0}", "update.cancelled": "已取消更新,再次點擊「更新」即可安裝。", "update.level.Required": "必要更新", "update.level.Recommended": "建議更新", diff --git a/src/Filee.App/Services/UpdateService.cs b/src/Filee.App/Services/UpdateService.cs index fa8382e16..53f142382 100644 --- a/src/Filee.App/Services/UpdateService.cs +++ b/src/Filee.App/Services/UpdateService.cs @@ -6,12 +6,16 @@ using System.ComponentModel; using System.Diagnostics; +using System.Formats.Tar; +using System.IO.Compression; using System.Net.Http.Headers; using System.Reflection; +using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text.Json; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; +using Filee.Core.Settings; using Filee.Platform.Windows; using Microsoft.Extensions.Logging; @@ -86,15 +90,78 @@ internal UpdateService(ILogger log, HttpMessageHandler handler) public const string WebsiteUrl = "https://filee.sh/"; /// - /// True when this copy was installed by Setup, which puts its uninstaller next to Filee.exe (uninstall.exe; the - /// Inno Setup installer of 1.x put unins000.exe there). Only then can the new installer update it in place; the - /// portable zip and a copy run from source have no uninstaller. + /// True when this copy was installed by Setup on Windows (uninstall.exe; the Inno Setup installer of 1.x put unins000.exe there) + /// or by install.sh on Linux (.filee-package marker containing com.filee.app). + /// Only then can the new installer update it in place; the portable zip and a copy run from source have no uninstaller. /// - public static bool IsInstalled { get; } = OperatingSystem.IsWindows() && IsInstalledIn(AppContext.BaseDirectory); + public static bool IsInstalled { get; } = + OperatingSystem.IsWindows() && IsInstalledIn(AppContext.BaseDirectory) + || OperatingSystem.IsLinux() && IsLinuxInstalled(AppContext.BaseDirectory); internal static bool IsInstalledIn(string folder) => File.Exists(Path.Combine(folder, "uninstall.exe")) || File.Exists(Path.Combine(folder, "unins000.exe")); + internal static bool IsLinuxInstalled(string baseDirectory) + { + var marker = Path.Combine(baseDirectory, ".filee-package"); + try + { + return File.Exists(marker) && File.ReadAllText(marker).Trim() == "com.filee.app"; + } + catch (Exception) + { + return false; + } + } + + /// Returns the RID for the current Linux process architecture ("linux-x64" or "linux-arm64"). + internal static string GetLinuxRid() => GetLinuxRid(RuntimeInformation.ProcessArchitecture); + + /// Maps to supported Linux RIDs or throws . + internal static string GetLinuxRid(Architecture architecture) => + architecture switch + { + Architecture.X64 => "linux-x64", + Architecture.Arm64 => "linux-arm64", + var arch => throw new PlatformNotSupportedException($"Unsupported Linux architecture: {arch}"), + }; + + /// Target platform for release assets ("win", "linux-x64", "linux-arm64"). Tests can override this. + internal string TargetPlatform { get; set; } = DefaultTargetPlatform(); + + private static string DefaultTargetPlatform() + { + if (OperatingSystem.IsWindows()) + return "win"; + if (OperatingSystem.IsLinux()) + { + try + { + return GetLinuxRid(); + } + catch (PlatformNotSupportedException) + { + return "unsupported"; + } + } + return "unsupported"; + } + + /// Starts an external process. Tests can override this to intercept installer execution. + internal Func StartProcess { get; set; } = psi => Process.Start(psi); + + /// Invoked after a successful in-app update to gracefully exit the running application. + internal Action? RequestApplicationShutdown { get; set; } + + /// Allows tests to redirect Linux installation target directory to an isolated temporary folder. + internal string? LinuxInstallDirectoryOverride { get; set; } + + /// Allows tests to redirect update failure marker path to an isolated temporary location. + internal string? FailureMarkerPathOverride { get; set; } + + /// The log file path recorded in the update failure marker from the previous run, if any. + internal string? LastFailureLogPath { get; private set; } + /// Whether installs by itself (; tests set it). internal bool InstallsItself { get; init; } = IsInstalled; @@ -174,7 +241,9 @@ internal async Task RefreshAsync(bool announce, CancellationToken cancella using var json = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); var tag = json.RootElement.TryGetProperty("tag_name", out var value) ? value.GetString() : null; var newer = IsNewer(tag, CurrentVersion) ? Normalize(tag!) : null; - (_installer, _checksums) = FindFiles(json.RootElement); + (_installer, _checksums) = TargetPlatform != "unsupported" + ? FindFiles(json.RootElement, TargetPlatform) + : (null, null); IReadOnlyList notes = []; if (newer is not null) { @@ -274,49 +343,141 @@ public async Task UpdateAsync() return; } - string installer; - try + if (TargetPlatform == "win") { - var progress = new Progress(percent => DownloadPercent = percent); - installer = await DownloadInstallerAsync(Path.Combine(Path.GetTempPath(), "Filee-update"), progress); - // WinVerifyTrust hashes the whole installer and may ask the certificate authority about revocation. - await Task.Run(() => CheckPublisher(installer)); - } - catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or IOException - or InvalidDataException or UnauthorizedAccessException) - { - Fail("download", ex); - return; - } + var folder = Path.Combine(Path.GetTempPath(), "Filee-update"); + string installer; + try + { + var progress = new Progress(percent => DownloadPercent = percent); + installer = await DownloadInstallerAsync(folder, progress); + // WinVerifyTrust hashes the whole installer and may ask the certificate authority about revocation. + await Task.Run(() => CheckPublisher(installer)); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or IOException + or InvalidDataException or UnauthorizedAccessException) + { + Fail("download", ex); + return; + } - Process? setup; - try - { - setup = Process.Start(new ProcessStartInfo(installer, InstallerArguments) { UseShellExecute = true }); - } - catch (Win32Exception ex) when (ex.NativeErrorCode == ErrorCancelled) - { - _log.LogInformation("Update cancelled at the administrator prompt"); - Step = UpdateStep.Cancelled; + Process? setup; + try + { + setup = StartProcess(new ProcessStartInfo(installer, InstallerArguments) { UseShellExecute = true }); + } + catch (Win32Exception ex) when (ex.NativeErrorCode == ErrorCancelled) + { + _log.LogInformation("Update cancelled at the administrator prompt"); + Step = UpdateStep.Cancelled; + return; + } + catch (Exception ex) when (ex is Win32Exception or InvalidOperationException) + { + Fail("start", ex); + return; + } + + _log.LogInformation("Started the installer for {Version}", LatestVersion); + Step = UpdateStep.Installing; + if (setup is null) + return; + + using (setup) + await setup.WaitForExitAsync(); + _log.LogInformation("Installer ended with exit code {Code}", setup.ExitCode); + Step = setup.ExitCode == 0 ? UpdateStep.Idle : UpdateStep.Cancelled; return; } - catch (Exception ex) when (ex is Win32Exception or InvalidOperationException) + + if (TargetPlatform.StartsWith("linux-", StringComparison.OrdinalIgnoreCase)) { - Fail("start", ex); - return; - } + string? workspace = null; + string installer; + try + { + workspace = CreateSecureWorkspace(); + var progress = new Progress(percent => DownloadPercent = percent); + installer = await DownloadInstallerAsync(workspace, progress); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or IOException + or InvalidDataException or UnauthorizedAccessException) + { + DeleteSecureWorkspace(workspace); + Fail("download", ex); + return; + } + + var stagingDir = Path.Combine(workspace, "payload"); + string payloadDir; + try + { + Directory.CreateDirectory(stagingDir); + payloadDir = ExtractLinuxArchive(installer, stagingDir); + } + catch (Exception ex) when (ex is InvalidDataException or IOException or UnauthorizedAccessException) + { + DeleteSecureWorkspace(workspace); + Fail("extract", ex); + return; + } + + var scriptPath = Path.Combine(payloadDir, "install.sh"); + var targetInstallDir = LinuxInstallDirectoryOverride ?? GetLinuxInstallDirectory(); + var newExe = Path.Combine(targetInstallDir, "Filee"); + var currentPid = Environment.ProcessId; + var logDir = Path.Combine(UserDataStore.DefaultDirectory, "logs"); + Directory.CreateDirectory(logDir); + var logFile = Path.Combine(logDir, $"filee-{DateTime.Now:yyyyMMdd}.log"); + var markerFile = FailureMarkerPathOverride ?? Path.Combine(UserDataStore.DefaultDirectory, ".update-failed"); + + Process? helper; + try + { + var startInfo = new ProcessStartInfo("/bin/sh") + { + WorkingDirectory = payloadDir, + UseShellExecute = false, + }; + startInfo.ArgumentList.Add("-c"); + startInfo.ArgumentList.Add( + "for _ in $(seq 1 600); do if ! kill -0 \"$1\" 2>/dev/null; then break; fi; sleep 0.1; done; " + + "if kill -0 \"$1\" 2>/dev/null; then echo \"Update aborted: Filee (PID $1) did not exit within timeout\" >> \"$4\"; rm -rf \"$5\" 2>/dev/null; exit 1; fi; " + + "\"$2\" --upgrade >> \"$4\" 2>&1; " + + "status=$?; " + + "rm -rf \"$5\" 2>/dev/null; " + + "if [ $status -eq 0 ]; then " + + "if [ -x \"$3\" ]; then exec \"$3\" --background --whats-new; else echo \"Update succeeded but '$3' is not executable\" >> \"$4\"; exit 1; fi; " + + "else " + + "echo \"Update failed with status $status\" >> \"$4\"; " + + "echo \"$4\" > \"$6.tmp\" 2>/dev/null && mv -f \"$6.tmp\" \"$6\" 2>/dev/null || true; " + + "if [ -x \"$3\" ]; then exec \"$3\" --background; else echo \"Cannot restart Filee: '$3' is not executable after rollback\" >> \"$4\"; exit $status; fi; " + + "fi" + ); + startInfo.ArgumentList.Add("_"); + startInfo.ArgumentList.Add(currentPid.ToString()); + startInfo.ArgumentList.Add(scriptPath); + startInfo.ArgumentList.Add(newExe); + startInfo.ArgumentList.Add(logFile); + startInfo.ArgumentList.Add(workspace); + startInfo.ArgumentList.Add(markerFile); + + helper = StartProcess(startInfo); + } + catch (Exception ex) when (ex is Win32Exception or InvalidOperationException) + { + DeleteSecureWorkspace(workspace); + Fail("start", ex); + return; + } - _log.LogInformation("Started the installer for {Version}", LatestVersion); - Step = UpdateStep.Installing; - if (setup is null) + _log.LogInformation("Started update helper for {Version}, exiting current process", LatestVersion); + Step = UpdateStep.Installing; + RequestApplicationShutdown?.Invoke(); return; + } - // A successful update closes Filee before Setup exits, so getting here means Setup ended without updating: - // the administrator prompt was declined (Setup asks for it itself), or the user cancelled. - using (setup) - await setup.WaitForExitAsync(); - _log.LogInformation("Installer ended with exit code {Code}", setup.ExitCode); - Step = setup.ExitCode == 0 ? UpdateStep.Idle : UpdateStep.Cancelled; + throw new PlatformNotSupportedException("In-app update is only supported on Windows and Linux"); } /// @@ -346,6 +507,7 @@ internal async Task DownloadInstallerAsync(string folder, IProgress TryDelete(old); var partial = target + ".part"; + TryDelete(partial); await DownloadAsync(installer, partial, progress, cancellationToken); var actual = await Sha256Async(partial, cancellationToken); if (actual != expected) @@ -436,6 +598,9 @@ public void OpenDownloadPage() } } + /// Last error encountered by . + internal Exception? LastError { get; private set; } + /// /// Raised on the UI thread when an installed copy could not download or start the new installer. The app says so /// and, once the user presses OK, opens (). @@ -444,11 +609,57 @@ public void OpenDownloadPage() private void Fail(string stage, Exception ex) { + LastError = ex; _log.LogWarning(ex, "In-app update failed ({Stage})", stage); Step = UpdateStep.Failed; UpdateFailed?.Invoke(this, EventArgs.Empty); } + /// + /// Checks whether a previous in-app update attempt failed before restarting. + /// If an update failure marker exists, safely reads the log path, consumes (deletes) the marker, + /// logs the failure, sets to , and returns true. + /// + public bool CheckPendingFailure(string? dataDir = null) + { + var markerPath = FailureMarkerPathOverride ?? Path.Combine(dataDir ?? UserDataStore.DefaultDirectory, ".update-failed"); + if (!File.Exists(markerPath)) + return false; + + string? logPath = null; + try + { + var content = File.ReadAllText(markerPath).Trim(); + if (!string.IsNullOrWhiteSpace(content) + && Path.IsPathRooted(content) + && !content.Any(char.IsControl) + && (File.Exists(content) || content.EndsWith(".log", StringComparison.OrdinalIgnoreCase))) + { + logPath = content; + } + } + catch (Exception ex) + { + _log.LogWarning(ex, "Failed to read update failure marker at {MarkerPath}", markerPath); + } + finally + { + try + { + File.Delete(markerPath); + } + catch (Exception ex) + { + _log.LogWarning(ex, "Failed to delete update failure marker at {MarkerPath}", markerPath); + } + } + + LastFailureLogPath = logPath; + _log.LogWarning("In-app update failed during previous run. Log: {LogPath}", logPath ?? "(unknown)"); + Step = UpdateStep.Failed; + return true; + } + private void Publish(string? newer, bool announce) { var previous = LatestVersion; @@ -463,12 +674,18 @@ private void Publish(string? newer, bool announce) /// A file attached to a release. internal sealed record ReleaseFile(string Name, string Url, long Size); - /// Picks the Windows installer and the checksum file from a GitHub release (null when missing). - internal static (ReleaseFile? Installer, ReleaseFile? Checksums) FindFiles(JsonElement release) + /// Picks the installer/archive and checksum file matching the target platform from a GitHub release (null when missing). + internal static (ReleaseFile? Installer, ReleaseFile? Checksums) FindFiles(JsonElement release, string? target = "win") { + target ??= "win"; ReleaseFile? installer = null, checksums = null; if (!release.TryGetProperty("assets", out var assets) || assets.ValueKind != JsonValueKind.Array) return (null, null); + + var isLinux = target.StartsWith("linux-", StringComparison.OrdinalIgnoreCase); + var expectedInstallerSuffix = isLinux ? $"-{target}.tar.gz" : "-win-Setup.exe"; + var expectedChecksumsSuffix = isLinux ? $"-{target}-SHA256SUMS.txt" : "-SHA256SUMS.txt"; + foreach (var asset in assets.EnumerateArray()) { var name = asset.TryGetProperty("name", out var n) ? n.GetString() ?? "" : ""; @@ -476,16 +693,259 @@ internal static (ReleaseFile? Installer, ReleaseFile? Checksums) FindFiles(JsonE var size = asset.TryGetProperty("size", out var s) && s.TryGetInt64(out var bytes) ? bytes : 0; if (name.Length == 0 || !url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) continue; - if (name.EndsWith("-win-Setup.exe", StringComparison.OrdinalIgnoreCase)) + + if (name.EndsWith(expectedInstallerSuffix, StringComparison.OrdinalIgnoreCase)) + { installer = new ReleaseFile(name, url, size); - else if (name.EndsWith("-SHA256SUMS.txt", StringComparison.OrdinalIgnoreCase) && - !name.Contains("-linux-", StringComparison.OrdinalIgnoreCase) && - !name.Contains("-osx-", StringComparison.OrdinalIgnoreCase)) + } + else if (name.EndsWith(expectedChecksumsSuffix, StringComparison.OrdinalIgnoreCase)) + { + // When targeting Windows, exclude platform-specific checksum files (e.g. -linux-x64-SHA256SUMS.txt, -osx-...) + if (!isLinux && (name.Contains("-linux-", StringComparison.OrdinalIgnoreCase) || name.Contains("-osx-", StringComparison.OrdinalIgnoreCase))) + continue; + checksums = new ReleaseFile(name, url, size); + } } return (installer, checksums); } + private const UnixFileMode RwxMask = + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute; + + /// + /// Extracts a Linux release archive (.tar.gz) into with security validation + /// (rejecting path traversal, absolute paths, unsafe entry types, directory symlinks, and escaping/component symlinks). + /// Returns the path to the extracted payload folder containing install.sh. + /// + internal static string ExtractLinuxArchive(string archivePath, string destinationDirectory) + { + if (!File.Exists(archivePath)) + throw new FileNotFoundException("Archive file not found", archivePath); + + var fullDestRoot = Path.GetFullPath(destinationDirectory); + if (!fullDestRoot.EndsWith(Path.DirectorySeparatorChar)) + fullDestRoot += Path.DirectorySeparatorChar; + + Directory.CreateDirectory(fullDestRoot); + + using (var fileStream = File.OpenRead(archivePath)) + using (var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress)) + using (var tarReader = new TarReader(gzipStream)) + { + while (tarReader.GetNextEntry() is { } entry) + { + if (entry.EntryType is TarEntryType.GlobalExtendedAttributes or TarEntryType.ExtendedAttributes) + continue; + + if (string.IsNullOrWhiteSpace(entry.Name)) + throw new InvalidDataException("Archive contains an entry with an empty name."); + + if (Path.IsPathRooted(entry.Name) || entry.Name.StartsWith('/') || entry.Name.StartsWith('\\') || entry.Name.Contains('\\')) + throw new InvalidDataException($"Archive entry '{entry.Name}' has an invalid or rooted path."); + + var destinationPath = Path.GetFullPath(Path.Combine(fullDestRoot, entry.Name)); + if (!destinationPath.StartsWith(fullDestRoot, StringComparison.Ordinal)) + throw new InvalidDataException($"Archive entry '{entry.Name}' attempts path traversal outside destination."); + + // Validate that no ancestor directory component between fullDestRoot and destinationPath is a symlink on disk + ValidateNoSymlinkComponents(fullDestRoot, destinationPath); + + switch (entry.EntryType) + { + case TarEntryType.Directory: + if (Path.Exists(destinationPath)) + { + var info = new DirectoryInfo(destinationPath); + if (info.LinkTarget is not null || info.Attributes.HasFlag(FileAttributes.ReparsePoint)) + throw new InvalidDataException($"Archive entry '{entry.Name}' conflicts with an existing symbolic link."); + } + Directory.CreateDirectory(destinationPath); + break; + + case TarEntryType.RegularFile: + case TarEntryType.V7RegularFile: + if (Directory.Exists(destinationPath)) + throw new InvalidDataException($"Archive entry '{entry.Name}' conflicts with an existing directory."); + + var parentDir = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrEmpty(parentDir)) + Directory.CreateDirectory(parentDir); + + // If destinationPath already exists and is a symlink, delete it rather than writing through it + if (Path.Exists(destinationPath)) + { + var existingInfo = new FileInfo(destinationPath); + if (existingInfo.LinkTarget is not null || existingInfo.Attributes.HasFlag(FileAttributes.ReparsePoint)) + File.Delete(destinationPath); + } + + entry.ExtractToFile(destinationPath, overwrite: true); + + if (OperatingSystem.IsLinux()) + { + var mode = (entry.Mode & RwxMask) | UnixFileMode.UserRead | UnixFileMode.UserWrite; + File.SetUnixFileMode(destinationPath, mode); + } + break; + + case TarEntryType.SymbolicLink: + if (string.IsNullOrWhiteSpace(entry.LinkName) + || Path.IsPathRooted(entry.LinkName) + || entry.LinkName.StartsWith('/') + || entry.LinkName.StartsWith('\\') + || entry.LinkName.Contains('\\')) + { + throw new InvalidDataException($"Tar symlink '{entry.Name}' has an invalid target '{entry.LinkName}'."); + } + + var linkParent = Path.GetDirectoryName(destinationPath)!; + var linkTarget = Path.GetFullPath(Path.Combine(linkParent, entry.LinkName)); + if (!linkTarget.StartsWith(fullDestRoot, StringComparison.Ordinal)) + { + throw new InvalidDataException($"Tar symlink '{entry.Name}' points outside destination directory: '{entry.LinkName}'."); + } + + // Directory symlinks are disallowed to prevent symlink-component traversal and link-order attacks + if (Directory.Exists(linkTarget)) + { + throw new InvalidDataException($"Tar symlink '{entry.Name}' points to a directory, which is prohibited."); + } + + if (Directory.Exists(destinationPath)) + throw new InvalidDataException($"Archive symlink '{entry.Name}' conflicts with an existing directory."); + + if (!string.IsNullOrEmpty(linkParent)) + Directory.CreateDirectory(linkParent); + + if (Path.Exists(destinationPath)) + File.Delete(destinationPath); + + File.CreateSymbolicLink(destinationPath, entry.LinkName); + break; + + case TarEntryType.HardLink: + if (string.IsNullOrWhiteSpace(entry.LinkName) + || Path.IsPathRooted(entry.LinkName) + || entry.LinkName.StartsWith('/') + || entry.LinkName.StartsWith('\\') + || entry.LinkName.Contains('\\')) + { + throw new InvalidDataException($"Tar hard link '{entry.Name}' has an invalid target '{entry.LinkName}'."); + } + + var hardTarget = Path.GetFullPath(Path.Combine(fullDestRoot, entry.LinkName)); + if (!hardTarget.StartsWith(fullDestRoot, StringComparison.Ordinal)) + { + throw new InvalidDataException($"Tar hard link '{entry.Name}' points outside destination directory: '{entry.LinkName}'."); + } + + if (Directory.Exists(hardTarget)) + { + throw new InvalidDataException($"Tar hard link '{entry.Name}' points to a directory, which is prohibited."); + } + + if (!File.Exists(hardTarget)) + { + throw new InvalidDataException($"Tar hard link '{entry.Name}' target '{entry.LinkName}' does not exist."); + } + + if (Directory.Exists(destinationPath)) + throw new InvalidDataException($"Archive hard link '{entry.Name}' conflicts with an existing directory."); + + var hardParent = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrEmpty(hardParent)) + Directory.CreateDirectory(hardParent); + + if (Path.Exists(destinationPath)) + File.Delete(destinationPath); + + File.Copy(hardTarget, destinationPath, overwrite: true); + break; + + default: + throw new InvalidDataException($"Unsupported or unsafe tar entry type '{entry.EntryType}' for entry '{entry.Name}'."); + } + } + } + + var rootTrimmed = Path.TrimEndingDirectorySeparator(fullDestRoot); + string payloadDir; + if (File.Exists(Path.Combine(rootTrimmed, "install.sh"))) + { + payloadDir = rootTrimmed; + } + else + { + var candidates = Directory.GetDirectories(rootTrimmed) + .Where(d => File.Exists(Path.Combine(d, "install.sh"))) + .ToList(); + if (candidates.Count == 1) + { + payloadDir = candidates[0]; + } + else + { + throw new InvalidDataException("Archive does not contain a single valid Filee payload with install.sh"); + } + } + + var installSh = Path.Combine(payloadDir, "install.sh"); + var fileeExe = Path.Combine(payloadDir, "Filee"); + if (!File.Exists(installSh) || !File.Exists(fileeExe)) + { + throw new InvalidDataException("Archive is missing install.sh or Filee executable in the payload directory."); + } + + if (OperatingSystem.IsLinux()) + { + EnsureExecutable(installSh); + EnsureExecutable(fileeExe); + var cli = Path.Combine(payloadDir, "filee-cli"); + if (File.Exists(cli)) + EnsureExecutable(cli); + var uninstall = Path.Combine(payloadDir, "uninstall.sh"); + if (File.Exists(uninstall)) + EnsureExecutable(uninstall); + } + + return payloadDir; + } + + /// + /// Verifies that no intermediate directory component between and is a symbolic link. + /// + private static void ValidateNoSymlinkComponents(string rootPath, string targetPath) + { + var current = rootPath.TrimEnd(Path.DirectorySeparatorChar); + var relative = Path.GetRelativePath(current, targetPath); + var segments = relative.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries); + + for (var i = 0; i < segments.Length - 1; i++) + { + current = Path.Combine(current, segments[i]); + if (Path.Exists(current)) + { + var dirInfo = new DirectoryInfo(current); + if (dirInfo.LinkTarget is not null || dirInfo.Attributes.HasFlag(FileAttributes.ReparsePoint)) + { + throw new InvalidDataException($"Archive path component '{segments[i]}' in '{relative}' is a symbolic link."); + } + } + } + } + + private static void EnsureExecutable(string path) + { + if (OperatingSystem.IsLinux() && File.Exists(path)) + { + var mode = (File.GetUnixFileMode(path) & RwxMask) | UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; + File.SetUnixFileMode(path, mode); + } + } + /// Finds the hash for in a sha256sum-style file ("hash name" lines). internal static string? ParseChecksum(string sums, string fileName) { @@ -519,6 +979,199 @@ private void TryDelete(string path) } } + /// Gets the default root directory where secure workspaces are allocated. + internal static string GetDefaultWorkspaceBaseDirectory() + { + if (OperatingSystem.IsLinux()) + { + var dataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); + var root = !string.IsNullOrWhiteSpace(dataHome) + ? dataHome + : Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + return Path.Combine(root, "filee"); + } + return Path.GetTempPath(); + } + + /// + /// Creates a private, dedicated temporary workspace for this update instance. + /// Mode 0700 on Unix, inaccessible to other local users, non-predictable, and never reusing pre-existing paths. + /// + internal static string CreateSecureWorkspace(string? baseDir = null) + { + baseDir = string.IsNullOrWhiteSpace(baseDir) ? GetDefaultWorkspaceBaseDirectory() : baseDir; + + Directory.CreateDirectory(baseDir); + + for (var attempt = 0; attempt < 100; attempt++) + { + var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(); + var candidate = Path.Combine(baseDir, $".update-{Environment.ProcessId}-{token}"); + if (Path.Exists(candidate)) + continue; + + if (OperatingSystem.IsLinux()) + { + Directory.CreateDirectory(candidate, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + File.SetUnixFileMode(candidate, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + else + { + Directory.CreateDirectory(candidate); + } + + var dirInfo = new DirectoryInfo(candidate); + if (dirInfo.LinkTarget is not null || dirInfo.Attributes.HasFlag(FileAttributes.ReparsePoint)) + { + throw new InvalidOperationException($"Secure workspace candidate '{candidate}' is a symbolic link."); + } + + return candidate; + } + + throw new IOException("Failed to create a unique secure workspace directory after multiple attempts."); + } + + /// Safely deletes a secure workspace directory created by this update, refusing to follow symlinks. + internal static void DeleteSecureWorkspace(string? workspacePath) + { + if (string.IsNullOrWhiteSpace(workspacePath) || !Path.Exists(workspacePath)) + return; + + var dirInfo = new DirectoryInfo(workspacePath); + if (dirInfo.LinkTarget is not null || dirInfo.Attributes.HasFlag(FileAttributes.ReparsePoint)) + return; + + try + { + Directory.Delete(workspacePath, recursive: true); + } + catch + { + // Best effort cleanup + } + } + + /// + /// Checks whether a process with the given is currently running. + /// Used to protect active update workspaces from being swept. + /// + internal static bool IsProcessAlive(int pid) + { + if (pid <= 0) + return false; + + try + { + if (OperatingSystem.IsLinux()) + return Directory.Exists($"/proc/{pid}"); + + using var proc = Process.GetProcessById(pid); + return !proc.HasExited; + } + catch (ArgumentException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + catch (Exception) + { + // If in doubt (e.g. access denied), assume alive to safeguard against deleting active workspaces + return true; + } + } + + /// + /// Checks whether matches the Filee-owned workspace pattern + /// .update-<pid>-<32-hex-characters> and extracts the valid positive process ID. + /// + internal static bool TryParseWorkspaceName(string name, out int pid) + { + pid = 0; + var parts = name.Split('-'); + if (parts.Length != 3 || parts[0] != ".update") + return false; + + // The final component must contain exactly 32 hexadecimal characters (0-9, a-f, or A-F) + if (parts[2].Length != 32 || !parts[2].All(char.IsAsciiHexDigit)) + return false; + + // must be a valid positive process ID in decimal (digits only, no signs) + if (parts[1].Length == 0 || !parts[1].All(char.IsAsciiDigit) || !int.TryParse(parts[1], out var parsedPid) || parsedPid <= 0) + return false; + + pid = parsedPid; + return true; + } + + /// + /// Deletes stale update workspaces left behind by crashes, forced kills, or aborted updates. + /// Protects active workspaces by checking both an age threshold (default 1 day) and process liveness + /// (never deleting a workspace belonging to a running PID). Restricts cleanup strictly to Filee-owned + /// workspace directories matching .update-<pid>-<32-hex-characters>. + /// + public static void DeleteStaleWorkspaces(string? root = null, TimeSpan? age = null, ILogger? log = null) + { + // Workspaces are only made on Linux, under Filee's own data folder. Elsewhere the default root is the shared + // temp folder, where other programs' folders live. + if (root is null && !OperatingSystem.IsLinux()) + return; + var threshold = age ?? TimeSpan.FromDays(1); + try + { + var baseDir = root ?? GetDefaultWorkspaceBaseDirectory(); + var dirInfo = new DirectoryInfo(baseDir); + if (!dirInfo.Exists || dirInfo.LinkTarget is not null || dirInfo.Attributes.HasFlag(FileAttributes.ReparsePoint)) + return; + + foreach (var folder in dirInfo.EnumerateDirectories(".update-*")) + { + if (folder.LinkTarget is not null || folder.Attributes.HasFlag(FileAttributes.ReparsePoint)) + continue; + + // Restrict cleanup strictly to Filee-owned workspace names: .update--<32-hex-chars> + // Foreign directories (e.g. .update-something or .update-cache) are rejected immediately. + if (!TryParseWorkspaceName(folder.Name, out var pid)) + continue; + + try + { + if (IsProcessAlive(pid)) + continue; + + if (DateTime.UtcNow - folder.LastWriteTimeUtc > threshold) + { + DeleteSecureWorkspace(folder.FullName); + log?.LogInformation("Deleted stale update workspace {Workspace}", folder.FullName); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + log?.LogWarning(ex, "Could not delete stale update workspace {Workspace}", folder.FullName); + } + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + log?.LogWarning(ex, "Failed to enumerate update workspaces in {Root}", root); + } + } + + /// Locates the installation directory on Linux. + internal static string GetLinuxInstallDirectory() + { + if (IsLinuxInstalled(AppContext.BaseDirectory)) + return AppContext.BaseDirectory; + var dataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); + var root = !string.IsNullOrWhiteSpace(dataHome) + ? dataHome + : Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + return Path.Combine(root, "filee", "application"); + } + /// True when release tag ("v1.2.0") is a higher version than . internal static bool IsNewer(string? tag, string current) => TryParse(tag, out var latest) && TryParse(current, out var installed) && latest > installed; diff --git a/src/Filee.App/Services/WindowService.cs b/src/Filee.App/Services/WindowService.cs index 3445db6d5..fca40b425 100644 --- a/src/Filee.App/Services/WindowService.cs +++ b/src/Filee.App/Services/WindowService.cs @@ -419,6 +419,20 @@ public async Task ShowUpdateFailedAsync() services.GetRequiredService().OpenDownloadPage(); } + /// + /// The last run's in-app update (Linux) failed while installing and the installer put the old version back: say so, + /// with the log that has the details, then open the website to download the new version there. + /// + public async Task ShowUpdateRolledBackAsync(string? logPath) + { + _updateNotice?.Close(); + var message = loc["update.rolled_back_alert"]; + if (!string.IsNullOrWhiteSpace(logPath)) + message += "\n\n" + loc.Format("update.rolled_back_log", logPath); + await MessageAsync(_main is { IsVisible: true } main ? main : null, message); + services.GetRequiredService().OpenDownloadPage(); + } + private async Task ShowMessageAsync(Window? owner, string message, bool destructive, bool withCancel) { var dialog = new Window diff --git a/tests/Filee.App.Tests/UpdateServiceTests.cs b/tests/Filee.App.Tests/UpdateServiceTests.cs index 667132fe7..90b5324d2 100644 --- a/tests/Filee.App.Tests/UpdateServiceTests.cs +++ b/tests/Filee.App.Tests/UpdateServiceTests.cs @@ -1,3 +1,7 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Formats.Tar; +using System.IO.Compression; using System.Net; using System.Net.Sockets; using System.Runtime.InteropServices; @@ -373,7 +377,7 @@ public async Task A_failed_update_is_reported_instead_of_opening_a_page_right_aw public async Task Update_installs_the_release_that_is_latest_now_not_the_one_found_earlier() { // An earlier check found 9.9.9; 9.9.10 came out before Update was pressed. - var updates = new UpdateService(NullLogger.Instance, new LatestRelease("v9.9.10")); + var updates = new UpdateService(NullLogger.Instance, new LatestRelease("v9.9.10")) { TargetPlatform = "win" }; updates.UseRelease(new UpdateService.ReleaseFile("Filee-9.9.9-win-Setup.exe", Base + "Filee-9.9.9-win-Setup.exe", 1), new UpdateService.ReleaseFile("Filee-9.9.9-SHA256SUMS.txt", Base + "Filee-9.9.9-SHA256SUMS.txt", 1)); var announced = 0; @@ -410,6 +414,749 @@ public void Download_page_is_the_latest_release() Assert.StartsWith(UpdateService.RepositoryUrl.TrimEnd('/'), UpdateService.LatestReleaseUrl); } + [Theory] + [InlineData(Architecture.X64, "linux-x64")] + [InlineData(Architecture.Arm64, "linux-arm64")] + public void Architecture_maps_to_linux_rid(Architecture arch, string expectedRid) => + Assert.Equal(expectedRid, UpdateService.GetLinuxRid(arch)); + + [Theory] + [InlineData(Architecture.Arm)] + [InlineData(Architecture.S390x)] + [InlineData(Architecture.Wasm)] + public void Architecture_unsupported_throws(Architecture arch) => + Assert.Throws(() => UpdateService.GetLinuxRid(arch)); + + [Fact] + public void IsLinuxInstalled_checks_marker_file() + { + var folder = TempFolder(); + Assert.False(UpdateService.IsLinuxInstalled(folder)); + + var marker = Path.Combine(folder, ".filee-package"); + File.WriteAllText(marker, "com.filee.app\n"); + Assert.True(UpdateService.IsLinuxInstalled(folder)); + + File.WriteAllText(marker, "org.other.app"); + Assert.False(UpdateService.IsLinuxInstalled(folder)); + } + + [Fact] + public void FindFiles_selects_linux_assets_for_specific_rid() + { + using var release = JsonDocument.Parse($$""" + {"tag_name": "v1.2.3", "assets": [ + {"name": "Filee-1.2.3-SHA256SUMS.txt", "size": 100, "browser_download_url": "{{Base}}Filee-1.2.3-SHA256SUMS.txt"}, + {"name": "Filee-1.2.3-win-Setup.exe", "size": 1200, "browser_download_url": "{{Base}}Filee-1.2.3-win-Setup.exe"}, + {"name": "Filee-1.2.3-linux-x64.tar.gz", "size": 2000, "browser_download_url": "{{Base}}Filee-1.2.3-linux-x64.tar.gz"}, + {"name": "Filee-1.2.3-linux-x64-SHA256SUMS.txt", "size": 90, "browser_download_url": "{{Base}}Filee-1.2.3-linux-x64-SHA256SUMS.txt"}, + {"name": "Filee-1.2.3-linux-arm64.tar.gz", "size": 1900, "browser_download_url": "{{Base}}Filee-1.2.3-linux-arm64.tar.gz"}, + {"name": "Filee-1.2.3-linux-arm64-SHA256SUMS.txt", "size": 92, "browser_download_url": "{{Base}}Filee-1.2.3-linux-arm64-SHA256SUMS.txt"} + ]} + """); + + var (installerX64, sumsX64) = UpdateService.FindFiles(release.RootElement, "linux-x64"); + Assert.Equal("Filee-1.2.3-linux-x64.tar.gz", installerX64?.Name); + Assert.Equal("Filee-1.2.3-linux-x64-SHA256SUMS.txt", sumsX64?.Name); + + var (installerArm64, sumsArm64) = UpdateService.FindFiles(release.RootElement, "linux-arm64"); + Assert.Equal("Filee-1.2.3-linux-arm64.tar.gz", installerArm64?.Name); + Assert.Equal("Filee-1.2.3-linux-arm64-SHA256SUMS.txt", sumsArm64?.Name); + + var (installerWin, sumsWin) = UpdateService.FindFiles(release.RootElement, "win"); + Assert.Equal("Filee-1.2.3-win-Setup.exe", installerWin?.Name); + Assert.Equal("Filee-1.2.3-SHA256SUMS.txt", sumsWin?.Name); + } + + [Fact] + public async Task Checksum_file_for_different_artifact_is_rejected() + { + var installerData = RandomNumberGenerator.GetBytes(1000); + var hash = Sha256(installerData); + var folder = TempFolder(); + + var updates = new UpdateService(NullLogger.Instance, new FakeLinuxRelease( + installerData, hash, sumsArtifact: "Filee-1.2.3-linux-arm64.tar.gz")); + updates.UseRelease( + new UpdateService.ReleaseFile("Filee-1.2.3-linux-x64.tar.gz", Base + "Filee-1.2.3-linux-x64.tar.gz", installerData.Length), + new UpdateService.ReleaseFile("Filee-1.2.3-linux-x64-SHA256SUMS.txt", Base + "Filee-1.2.3-linux-x64-SHA256SUMS.txt", 100)); + + await Assert.ThrowsAsync(() => + updates.DownloadInstallerAsync(folder, null, TestContext.Current.CancellationToken)); + } + + [Fact] + public void ExtractLinuxArchive_valid_payload_extracts_and_sets_permissions() + { + var folder = TempFolder(); + var archiveFile = Path.Combine(folder, "package.tar.gz"); + var destFolder = Path.Combine(folder, "extracted"); + + var tarBytes = CreateTarGz(writer => + { + AddFileEntry(writer, "Filee-1.0.0-linux-x64/Filee", "#!/bin/sh\necho app\n"); + AddFileEntry(writer, "Filee-1.0.0-linux-x64/install.sh", "#!/bin/sh\necho installer\n"); + AddFileEntry(writer, "Filee-1.0.0-linux-x64/filee.png", "fake png data"); + }); + File.WriteAllBytes(archiveFile, tarBytes); + + var payloadDir = UpdateService.ExtractLinuxArchive(archiveFile, destFolder); + + Assert.Equal(Path.Combine(destFolder, "Filee-1.0.0-linux-x64"), payloadDir); + var installSh = Path.Combine(payloadDir, "install.sh"); + var fileeExe = Path.Combine(payloadDir, "Filee"); + Assert.True(File.Exists(installSh)); + Assert.True(File.Exists(fileeExe)); + + if (OperatingSystem.IsLinux()) + { + var installMode = File.GetUnixFileMode(installSh); + Assert.True((installMode & UnixFileMode.UserExecute) != 0, "install.sh must be executable"); + var fileeMode = File.GetUnixFileMode(fileeExe); + Assert.True((fileeMode & UnixFileMode.UserExecute) != 0, "Filee must be executable"); + } + } + + [Fact] + public void ExtractLinuxArchive_rejects_path_traversal() + { + var folder = TempFolder(); + var archiveFile = Path.Combine(folder, "traversal.tar.gz"); + var destFolder = Path.Combine(folder, "extracted"); + + var tarBytes = CreateTarGz(writer => + { + AddFileEntry(writer, "../evil.sh", "#!/bin/sh\n"); + }); + File.WriteAllBytes(archiveFile, tarBytes); + + Assert.Throws(() => UpdateService.ExtractLinuxArchive(archiveFile, destFolder)); + } + + [Fact] + public void ExtractLinuxArchive_rejects_absolute_path() + { + var folder = TempFolder(); + var archiveFile = Path.Combine(folder, "absolute.tar.gz"); + var destFolder = Path.Combine(folder, "extracted"); + + var tarBytes = CreateTarGz(writer => + { + AddFileEntry(writer, "/tmp/evil.sh", "#!/bin/sh\n"); + }); + File.WriteAllBytes(archiveFile, tarBytes); + + Assert.Throws(() => UpdateService.ExtractLinuxArchive(archiveFile, destFolder)); + } + + [Fact] + public void ExtractLinuxArchive_rejects_escaping_symlink() + { + var folder = TempFolder(); + var archiveFile = Path.Combine(folder, "symlink.tar.gz"); + var destFolder = Path.Combine(folder, "extracted"); + + var tarBytes = CreateTarGz(writer => + { + AddSymlinkEntry(writer, "Filee-1.0.0/escape_link", "/etc/passwd"); + }); + File.WriteAllBytes(archiveFile, tarBytes); + + Assert.Throws(() => UpdateService.ExtractLinuxArchive(archiveFile, destFolder)); + } + + [Fact] + public void ExtractLinuxArchive_rejects_missing_payload_files() + { + var folder = TempFolder(); + var archiveFile = Path.Combine(folder, "missing.tar.gz"); + var destFolder = Path.Combine(folder, "extracted"); + + var tarBytes = CreateTarGz(writer => + { + AddFileEntry(writer, "Filee-1.0.0/filee.png", "png data"); + }); + File.WriteAllBytes(archiveFile, tarBytes); + + Assert.Throws(() => UpdateService.ExtractLinuxArchive(archiveFile, destFolder)); + } + + [AvaloniaFact] + public async Task UpdateAsync_on_linux_downloads_extracts_and_delegates_to_detached_helper() + { + var tarBytes = CreateTarGz(writer => + { + AddFileEntry(writer, "Filee-9.9.9-linux-x64/Filee", "#!/bin/sh\nexit 0\n"); + AddFileEntry(writer, "Filee-9.9.9-linux-x64/install.sh", "#!/bin/sh\nexit 0\n"); + AddFileEntry(writer, "Filee-9.9.9-linux-x64/filee.png", "png data"); + }); + var hash = Sha256(tarBytes); + var network = new FakeLinuxRelease(tarBytes, hash, "Filee-9.9.9-linux-x64.tar.gz", "9.9.9"); + + var mockInstallDir = TempFolder(); + + var updates = new UpdateService(NullLogger.Instance, network) + { + InstallsItself = true, + TargetPlatform = "linux-x64", + LinuxInstallDirectoryOverride = mockInstallDir, + }; + updates.UseRelease( + new UpdateService.ReleaseFile("Filee-9.9.9-linux-x64.tar.gz", Base + "Filee-9.9.9-linux-x64.tar.gz", tarBytes.Length), + new UpdateService.ReleaseFile("Filee-9.9.9-linux-x64-SHA256SUMS.txt", Base + "Filee-9.9.9-linux-x64-SHA256SUMS.txt", 100)); + + var processes = new List(); + var shutdownRequested = false; + updates.RequestApplicationShutdown = () => shutdownRequested = true; + updates.StartProcess = psi => + { + processes.Add(psi); + // No real process: `true` is missing on Windows without Git's tools on PATH. + return null; + }; + + await updates.UpdateAsync(); + + Assert.Equal(UpdateStep.Installing, updates.Step); + Assert.Single(processes); + + var helperPsi = processes[0]; + Assert.Equal("/bin/sh", helperPsi.FileName); + Assert.Equal("-c", helperPsi.ArgumentList[0]); + Assert.Contains("kill -0 \"$1\"", helperPsi.ArgumentList[1]); + Assert.Contains("\"$2\" --upgrade >> \"$4\" 2>&1", helperPsi.ArgumentList[1]); + Assert.Contains("exec \"$3\" --background --whats-new", helperPsi.ArgumentList[1]); + Assert.Contains("exec \"$3\" --background", helperPsi.ArgumentList[1]); + Assert.Contains("rm -rf \"$5\"", helperPsi.ArgumentList[1]); + Assert.Equal(Environment.ProcessId.ToString(), helperPsi.ArgumentList[3]); + Assert.EndsWith("install.sh", helperPsi.ArgumentList[4]); + Assert.Equal(Path.Combine(mockInstallDir, "Filee"), helperPsi.ArgumentList[5]); + Assert.EndsWith(".log", helperPsi.ArgumentList[6]); + Assert.EndsWith(".update-failed", helperPsi.ArgumentList[8]); + + Assert.True(shutdownRequested, "Application shutdown must be requested before file swap"); + } + + [AvaloniaFact] + public async Task UpdateAsync_on_linux_handles_helper_start_failure() + { + var tarBytes = CreateTarGz(writer => + { + AddFileEntry(writer, "Filee-9.9.9-linux-x64/Filee", "#!/bin/sh\nexit 0\n"); + AddFileEntry(writer, "Filee-9.9.9-linux-x64/install.sh", "#!/bin/sh\nexit 0\n"); + AddFileEntry(writer, "Filee-9.9.9-linux-x64/filee.png", "png data"); + }); + var hash = Sha256(tarBytes); + var network = new FakeLinuxRelease(tarBytes, hash, "Filee-9.9.9-linux-x64.tar.gz", "9.9.9"); + + var mockInstallDir = TempFolder(); + + var updates = new UpdateService(NullLogger.Instance, network) + { + InstallsItself = true, + TargetPlatform = "linux-x64", + LinuxInstallDirectoryOverride = mockInstallDir, + }; + updates.UseRelease( + new UpdateService.ReleaseFile("Filee-9.9.9-linux-x64.tar.gz", Base + "Filee-9.9.9-linux-x64.tar.gz", tarBytes.Length), + new UpdateService.ReleaseFile("Filee-9.9.9-linux-x64-SHA256SUMS.txt", Base + "Filee-9.9.9-linux-x64-SHA256SUMS.txt", 100)); + + var failedCount = 0; + updates.UpdateFailed += (_, _) => failedCount++; + + var shutdownRequested = false; + updates.RequestApplicationShutdown = () => shutdownRequested = true; + + updates.StartProcess = psi => + throw new Win32Exception("Cannot spawn helper process"); + + await updates.UpdateAsync(); + + Assert.Equal(UpdateStep.Failed, updates.Step); + Assert.Equal(1, failedCount); + Assert.NotNull(updates.LastError); + Assert.Contains("Cannot spawn helper process", updates.LastError.Message); + Assert.False(shutdownRequested, "Application shutdown must not be requested when helper fails to start"); + } + + [Fact] + public void ExtractLinuxArchive_rejects_symlink_component_traversal() + { + var outsideFolder = TempFolder(); + var sentinelFile = Path.Combine(outsideFolder, "sentinel.txt"); + File.WriteAllText(sentinelFile, "SENTINEL_UNTOUCHED"); + + var stagingFolder = TempFolder(); + var archiveFile = Path.Combine(stagingFolder, "evil-traversal.tar.gz"); + var destFolder = Path.Combine(stagingFolder, "dest"); + + var tarBytes = CreateTarGz(writer => + { + // Adversarial entry 1: directory symlink pointing outside to outsideFolder + AddSymlinkEntry(writer, "payload/escape", outsideFolder); + // Adversarial entry 2: file inside escape pointing to sentinel.txt + AddFileEntry(writer, "payload/escape/sentinel.txt", "MALICIOUS_OVERWRITE"); + }); + File.WriteAllBytes(archiveFile, tarBytes); + + Assert.Throws(() => UpdateService.ExtractLinuxArchive(archiveFile, destFolder)); + Assert.Equal("SENTINEL_UNTOUCHED", File.ReadAllText(sentinelFile)); + } + + [Fact] + public void ExtractLinuxArchive_rejects_link_order_attack() + { + var stagingFolder = TempFolder(); + var archiveFile = Path.Combine(stagingFolder, "evil-link-order.tar.gz"); + var destFolder = Path.Combine(stagingFolder, "dest"); + + var tarBytes = CreateTarGz(writer => + { + // Entry 1: regular directory + var dirEntry = new PaxTarEntry(TarEntryType.Directory, "payload/sub"); + writer.WriteEntry(dirEntry); + // Entry 2: attempt to replace directory with a symlink + AddSymlinkEntry(writer, "payload/sub", "other"); + }); + File.WriteAllBytes(archiveFile, tarBytes); + + Assert.Throws(() => UpdateService.ExtractLinuxArchive(archiveFile, destFolder)); + } + + [Fact] + public void CreateSecureWorkspace_creates_private_unshared_directory() + { + var workspace = UpdateService.CreateSecureWorkspace(); + try + { + Assert.True(Directory.Exists(workspace)); + var dirInfo = new DirectoryInfo(workspace); + Assert.Null(dirInfo.LinkTarget); + Assert.False(dirInfo.Attributes.HasFlag(FileAttributes.ReparsePoint)); + + if (OperatingSystem.IsLinux()) + { + var mode = File.GetUnixFileMode(workspace); + Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute, mode); + } + } + finally + { + UpdateService.DeleteSecureWorkspace(workspace); + Assert.False(Directory.Exists(workspace)); + } + } + + [Fact] + public void DeleteSecureWorkspace_refuses_to_follow_symlink() + { + var realDir = TempFolder(); + var targetFile = Path.Combine(realDir, "important.txt"); + File.WriteAllText(targetFile, "CRITICAL_DATA"); + + var linkContainer = TempFolder(); + var symlinkPath = Path.Combine(linkContainer, "link-to-real"); + try + { + File.CreateSymbolicLink(symlinkPath, realDir); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Assert.Skip("This machine doesn't let the test make a symbolic link."); + } + + UpdateService.DeleteSecureWorkspace(symlinkPath); + + Assert.True(Directory.Exists(realDir), "Real directory must not be deleted when DeleteSecureWorkspace is called on a symlink"); + Assert.True(File.Exists(targetFile), "File inside real directory must survive"); + } + + [Fact] + public async Task Concurrent_secure_workspaces_are_isolated() + { + var isolatedDir = TempFolder(); + try + { + var tasks = Enumerable.Range(0, 5).Select(_ => Task.Run(() => UpdateService.CreateSecureWorkspace(isolatedDir))).ToArray(); + var workspaces = await Task.WhenAll(tasks); + + Assert.Equal(5, workspaces.Distinct().Count()); + Assert.All(workspaces, w => + { + Assert.True(Directory.Exists(w)); + Assert.StartsWith(isolatedDir, w); + }); + } + finally + { + try + { + if (Directory.Exists(isolatedDir)) + Directory.Delete(isolatedDir, recursive: true); + } + catch + { + // Best effort test cleanup + } + } + } + + [Fact] + public void Tar_extraction_masks_mode_to_rwx_bits() + { + var tarBytes = CreateTarGz(writer => + { + var setuidMode = UnixFileMode.SetUser | UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute + | UnixFileMode.GroupRead | UnixFileMode.GroupExecute | UnixFileMode.OtherRead | UnixFileMode.OtherExecute; + var setgidMode = UnixFileMode.SetGroup | UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute + | UnixFileMode.GroupRead | UnixFileMode.GroupExecute | UnixFileMode.OtherRead | UnixFileMode.OtherExecute; + AddFileEntry(writer, "Filee-9.9.9-linux-x64/Filee", "#!/bin/sh\nexit 0\n", setuidMode); + AddFileEntry(writer, "Filee-9.9.9-linux-x64/install.sh", "#!/bin/sh\nexit 0\n", setgidMode); + }); + var archiveFile = Path.Combine(TempFolder(), "payload.tar.gz"); + File.WriteAllBytes(archiveFile, tarBytes); + + var destFolder = TempFolder(); + var payloadDir = UpdateService.ExtractLinuxArchive(archiveFile, destFolder); + + if (OperatingSystem.IsLinux()) + { + var fileeMode = File.GetUnixFileMode(Path.Combine(payloadDir, "Filee")); + var installMode = File.GetUnixFileMode(Path.Combine(payloadDir, "install.sh")); + + Assert.False(fileeMode.HasFlag(UnixFileMode.SetUser), "setuid bit must be stripped"); + Assert.False(fileeMode.HasFlag(UnixFileMode.SetGroup), "setgid bit must be stripped"); + Assert.False(fileeMode.HasFlag(UnixFileMode.StickyBit), "sticky bit must be stripped"); + + Assert.False(installMode.HasFlag(UnixFileMode.SetUser), "setuid bit must be stripped"); + Assert.False(installMode.HasFlag(UnixFileMode.SetGroup), "setgid bit must be stripped"); + Assert.False(installMode.HasFlag(UnixFileMode.StickyBit), "sticky bit must be stripped"); + } + } + + [Fact] + public void CheckPendingFailure_returns_false_when_marker_absent() + { + var dir = TempFolder(); + var updates = new UpdateService(NullLogger.Instance) + { + FailureMarkerPathOverride = Path.Combine(dir, ".update-failed"), + }; + + var result = updates.CheckPendingFailure(dir); + Assert.False(result); + Assert.Equal(UpdateStep.Idle, updates.Step); + Assert.Null(updates.LastFailureLogPath); + } + + [Fact] + public void CheckPendingFailure_consumes_marker_and_records_failure() + { + var dir = TempFolder(); + var marker = Path.Combine(dir, ".update-failed"); + var logPath = Path.Combine(dir, "logs", "filee-test.log"); + Directory.CreateDirectory(Path.GetDirectoryName(logPath)!); + File.WriteAllText(logPath, "some error log"); + File.WriteAllText(marker, logPath + "\n"); + + var updates = new UpdateService(NullLogger.Instance) + { + FailureMarkerPathOverride = marker, + }; + + var result = updates.CheckPendingFailure(dir); + Assert.True(result); + Assert.Equal(UpdateStep.Failed, updates.Step); + Assert.Equal(logPath, updates.LastFailureLogPath); + Assert.False(File.Exists(marker), "Marker file must be consumed (deleted)"); + } + + [Fact] + public void CheckPendingFailure_handles_malformed_marker_safely() + { + var dir = TempFolder(); + var marker = Path.Combine(dir, ".update-failed"); + File.WriteAllText(marker, "not-a-rooted-path\0evil"); + + var updates = new UpdateService(NullLogger.Instance) + { + FailureMarkerPathOverride = marker, + }; + + var result = updates.CheckPendingFailure(dir); + Assert.True(result); + Assert.Equal(UpdateStep.Failed, updates.Step); + Assert.Null(updates.LastFailureLogPath); + Assert.False(File.Exists(marker), "Marker file must still be consumed"); + } + + [Fact] + public void DeleteStaleWorkspaces_preserves_active_pid_and_deletes_stale_dead_pid() + { + var root = TempFolder(); + var token1 = "0123456789abcdef0123456789abcdef"; + var token2 = "abcdef0123456789abcdef0123456789"; + var token3 = "11223344556677889900aabbccddeeff"; + var activeDir = Path.Combine(root, $".update-{Environment.ProcessId}-{token1}"); + var deadPid = 999999; + while (UpdateService.IsProcessAlive(deadPid)) + deadPid++; + + var deadStaleDir = Path.Combine(root, $".update-{deadPid}-{token2}"); + var deadFreshDir = Path.Combine(root, $".update-{deadPid}-{token3}"); + + Directory.CreateDirectory(activeDir); + Directory.CreateDirectory(deadStaleDir); + Directory.CreateDirectory(deadFreshDir); + + Directory.SetLastWriteTimeUtc(deadStaleDir, DateTime.UtcNow.AddDays(-2)); + + UpdateService.DeleteStaleWorkspaces(root, TimeSpan.FromDays(1)); + + Assert.True(Directory.Exists(activeDir), "Active PID workspace must not be deleted even if stale"); + Assert.False(Directory.Exists(deadStaleDir), "Stale dead PID workspace must be deleted"); + Assert.True(Directory.Exists(deadFreshDir), "Fresh dead PID workspace must be kept"); + } + + [Fact] + public void DeleteStaleWorkspaces_ignores_foreign_and_malformed_directories() + { + var root = TempFolder(); + var token32 = "0123456789abcdef0123456789abcdef"; + + // Foreign directories and malformed names + var foreign1 = Path.Combine(root, ".update-something"); + var foreign2 = Path.Combine(root, ".update-cache"); + var malformedZeroPid = Path.Combine(root, $".update-0-{token32}"); + var malformedNegativePid = Path.Combine(root, $".update--123-{token32}"); + var malformedNonDecimalPid = Path.Combine(root, $".update-abc-{token32}"); + var malformedShortToken = Path.Combine(root, ".update-12345-0123456789abcdef"); + var malformedLongToken = Path.Combine(root, $".update-12345-{token32}extra"); + var malformedNonHexToken = Path.Combine(root, ".update-12345-0123456789abcdef0123456789abcdeg"); + var malformedExtraSegment = Path.Combine(root, $".update-12345-{token32}-extra"); + + var allFolders = new[] + { + foreign1, foreign2, malformedZeroPid, malformedNegativePid, + malformedNonDecimalPid, malformedShortToken, malformedLongToken, + malformedNonHexToken, malformedExtraSegment + }; + + foreach (var dir in allFolders) + { + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, "content.txt"), "preserve me"); + Directory.SetLastWriteTimeUtc(dir, DateTime.UtcNow.AddDays(-2)); + } + + UpdateService.DeleteStaleWorkspaces(root, TimeSpan.FromDays(1)); + + foreach (var dir in allFolders) + { + Assert.True(Directory.Exists(dir), $"Directory {Path.GetFileName(dir)} must remain untouched"); + Assert.True(File.Exists(Path.Combine(dir, "content.txt")), $"File inside {Path.GetFileName(dir)} must survive"); + } + } + + [Theory] + [InlineData(".update-123-0123456789abcdef0123456789abcdef", true, 123)] + [InlineData(".update-1-0123456789ABCDEF0123456789ABCDEF", true, 1)] + [InlineData(".update-999999-abcdef0123456789abcdef0123456789", true, 999999)] + [InlineData(".update-something", false, 0)] + [InlineData(".update-cache", false, 0)] + [InlineData(".update-", false, 0)] + [InlineData(".update-0-0123456789abcdef0123456789abcdef", false, 0)] + [InlineData(".update--123-0123456789abcdef0123456789abcdef", false, 0)] + [InlineData(".update-+123-0123456789abcdef0123456789abcdef", false, 0)] + [InlineData(".update-abc-0123456789abcdef0123456789abcdef", false, 0)] + [InlineData(".update-123-short", false, 0)] + [InlineData(".update-123-0123456789abcdef0123456789abcdefextra", false, 0)] + [InlineData(".update-123-0123456789abcdef0123456789abcdeg", false, 0)] + [InlineData(".update-123-0123456789abcdef-0123456789abcdef", false, 0)] + [InlineData("other-123-0123456789abcdef0123456789abcdef", false, 0)] + public void TryParseWorkspaceName_validates_pattern(string name, bool expectedResult, int expectedPid) + { + var result = UpdateService.TryParseWorkspaceName(name, out var pid); + Assert.Equal(expectedResult, result); + Assert.Equal(expectedPid, pid); + } + + [Fact] + public void Helper_script_restarts_filee_on_failure_and_writes_marker() + { + if (!OperatingSystem.IsLinux()) + return; + + var tempDir = TempFolder(); + var workspace = Path.Combine(tempDir, "workspace"); + Directory.CreateDirectory(workspace); + + var installLog = Path.Combine(tempDir, "install.log"); + var markerFile = Path.Combine(tempDir, ".update-failed"); + + var mockInstallSh = Path.Combine(tempDir, "install.sh"); + File.WriteAllText(mockInstallSh, "#!/bin/sh\necho 'Rolling back...' >&2\nexit 2\n"); + File.SetUnixFileMode(mockInstallSh, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + + var argsOut = Path.Combine(tempDir, "filee-args.txt"); + var mockFilee = Path.Combine(tempDir, "Filee"); + File.WriteAllText(mockFilee, $"#!/bin/sh\necho \"$@\" > '{argsOut}'\nexit 0\n"); + File.SetUnixFileMode(mockFilee, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + + var deadPid = 999999; + while (UpdateService.IsProcessAlive(deadPid)) + deadPid++; + + var script = + "for _ in $(seq 1 600); do if ! kill -0 \"$1\" 2>/dev/null; then break; fi; sleep 0.1; done; " + + "if kill -0 \"$1\" 2>/dev/null; then echo \"Update aborted: Filee (PID $1) did not exit within timeout\" >> \"$4\"; rm -rf \"$5\" 2>/dev/null; exit 1; fi; " + + "\"$2\" --upgrade >> \"$4\" 2>&1; " + + "status=$?; " + + "rm -rf \"$5\" 2>/dev/null; " + + "if [ $status -eq 0 ]; then " + + "if [ -x \"$3\" ]; then exec \"$3\" --background --whats-new; else echo \"Update succeeded but '$3' is not executable\" >> \"$4\"; exit 1; fi; " + + "else " + + "echo \"Update failed with status $status\" >> \"$4\"; " + + "echo \"$4\" > \"$6.tmp\" 2>/dev/null && mv -f \"$6.tmp\" \"$6\" 2>/dev/null || true; " + + "if [ -x \"$3\" ]; then exec \"$3\" --background; else echo \"Cannot restart Filee: '$3' is not executable after rollback\" >> \"$4\"; exit $status; fi; " + + "fi"; + + using var proc = Process.Start(new ProcessStartInfo("/bin/sh") + { + ArgumentList = { "-c", script, "_", deadPid.ToString(), mockInstallSh, mockFilee, installLog, workspace, markerFile }, + UseShellExecute = false, + }); + + Assert.NotNull(proc); + Assert.True(proc.WaitForExit(5000)); + Assert.Equal(0, proc.ExitCode); + + Assert.False(Directory.Exists(workspace), "Workspace directory must be cleaned up on failure"); + Assert.True(File.Exists(markerFile), "Failure marker file must be created"); + Assert.Equal(installLog, File.ReadAllText(markerFile).Trim()); + + Assert.True(File.Exists(argsOut), "Restored Filee executable must have been launched"); + var args = File.ReadAllText(argsOut).Trim(); + Assert.Equal("--background", args); + Assert.DoesNotContain("--whats-new", args); + } + + [Fact] + public void Helper_script_cleans_up_workspace_on_timeout() + { + if (!OperatingSystem.IsLinux()) + return; + + var tempDir = TempFolder(); + var workspace = Path.Combine(tempDir, "workspace"); + Directory.CreateDirectory(workspace); + + var installLog = Path.Combine(tempDir, "install.log"); + var markerFile = Path.Combine(tempDir, ".update-failed"); + var mockInstallSh = Path.Combine(tempDir, "install.sh"); + File.WriteAllText(mockInstallSh, "#!/bin/sh\nexit 0\n"); + var mockFilee = Path.Combine(tempDir, "Filee"); + File.WriteAllText(mockFilee, "#!/bin/sh\nexit 0\n"); + + var timeoutScript = + "for _ in $(seq 1 2); do if ! kill -0 \"$1\" 2>/dev/null; then break; fi; sleep 0.01; done; " + + "if kill -0 \"$1\" 2>/dev/null; then echo \"Update aborted: Filee (PID $1) did not exit within timeout\" >> \"$4\"; rm -rf \"$5\" 2>/dev/null; exit 1; fi; " + + "\"$2\" --upgrade >> \"$4\" 2>&1; " + + "status=$?; " + + "rm -rf \"$5\" 2>/dev/null; " + + "if [ $status -eq 0 ]; then " + + "if [ -x \"$3\" ]; then exec \"$3\" --background --whats-new; else echo \"Update succeeded but '$3' is not executable\" >> \"$4\"; exit 1; fi; " + + "else " + + "echo \"Update failed with status $status\" >> \"$4\"; " + + "echo \"$4\" > \"$6.tmp\" 2>/dev/null && mv -f \"$6.tmp\" \"$6\" 2>/dev/null || true; " + + "if [ -x \"$3\" ]; then exec \"$3\" --background; else echo \"Cannot restart Filee: '$3' is not executable after rollback\" >> \"$4\"; exit $status; fi; " + + "fi"; + + using var proc = Process.Start(new ProcessStartInfo("/bin/sh") + { + ArgumentList = { "-c", timeoutScript, "_", Environment.ProcessId.ToString(), mockInstallSh, mockFilee, installLog, workspace, markerFile }, + UseShellExecute = false, + }); + + Assert.NotNull(proc); + Assert.True(proc.WaitForExit(5000)); + Assert.Equal(1, proc.ExitCode); + + Assert.False(Directory.Exists(workspace), "Workspace must be cleaned up on timeout"); + Assert.Contains("Update aborted: Filee", File.ReadAllText(installLog)); + } + + private static byte[] CreateTarGz(Action populate) + { + using var mem = new MemoryStream(); + using (var gzip = new GZipStream(mem, CompressionMode.Compress, leaveOpen: true)) + using (var writer = new TarWriter(gzip, TarEntryFormat.Pax, leaveOpen: true)) + { + populate(writer); + } + return mem.ToArray(); + } + + private static void AddFileEntry(TarWriter writer, string name, string content, UnixFileMode mode = UnixFileMode.UserRead | UnixFileMode.UserWrite) + { + var bytes = Encoding.UTF8.GetBytes(content); + var entry = new PaxTarEntry(TarEntryType.RegularFile, name) + { + DataStream = new MemoryStream(bytes), + Mode = mode, + }; + writer.WriteEntry(entry); + } + + private static void AddSymlinkEntry(TarWriter writer, string name, string target) + { + var entry = new PaxTarEntry(TarEntryType.SymbolicLink, name) + { + LinkName = target, + }; + writer.WriteEntry(entry); + } + + private sealed class FakeLinuxRelease(byte[] archive, string hash, string sumsArtifact = "Filee-9.9.9-linux-x64.tar.gz", string version = "9.9.9") : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var url = request.RequestUri!.ToString(); + if (url.EndsWith("/releases/latest", StringComparison.Ordinal)) + { + var json = $$""" + {"tag_name":"v{{version}}","assets":[ + {"name":"Filee-{{version}}-linux-x64.tar.gz","browser_download_url":"{{Base}}Filee-{{version}}-linux-x64.tar.gz","size":{{archive.Length}}}, + {"name":"Filee-{{version}}-linux-x64-SHA256SUMS.txt","browser_download_url":"{{Base}}Filee-{{version}}-linux-x64-SHA256SUMS.txt","size":100}]} + """; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }); + } + if (url.Contains("/releases?per_page=", StringComparison.Ordinal) || url.EndsWith("/releases", StringComparison.Ordinal)) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("[]", Encoding.UTF8, "application/json"), + }); + } + if (url.EndsWith("SHA256SUMS.txt", StringComparison.Ordinal)) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent($"{hash} {sumsArtifact}\n", Encoding.ASCII), + }); + } + if (url.EndsWith(".tar.gz", StringComparison.Ordinal)) + { + Assert.Contains("application/octet-stream", request.Headers.Accept.ToString()); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(archive) }); + } + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); + } + } + [AvaloniaFact] public void UpdateProgressWindow_tracks_download_percentage_and_status() { diff --git a/tests/Filee.Platform.Tests/LinuxInstallerTests.cs b/tests/Filee.Platform.Tests/LinuxInstallerTests.cs new file mode 100644 index 000000000..ce70a3616 --- /dev/null +++ b/tests/Filee.Platform.Tests/LinuxInstallerTests.cs @@ -0,0 +1,817 @@ +using System.Diagnostics; +using System.Runtime.Versioning; + +namespace Filee.Platform.Tests; + +[SupportedOSPlatform("linux")] +public sealed class LinuxInstallerTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "filee-installer-test-" + Guid.NewGuid().ToString("N")); + private readonly string _installScriptPath; + private readonly string _shimBinDir; + + public LinuxInstallerTests() + { + Directory.CreateDirectory(_root); + + // Find the repository root containing installer/linux/install.sh + var dir = AppContext.BaseDirectory; + while (!File.Exists(Path.Combine(dir, "Filee.slnx"))) + { + dir = Path.GetDirectoryName(dir) ?? throw new InvalidOperationException("Repository root not found."); + } + _installScriptPath = Path.Combine(dir, "installer", "linux", "install.sh"); + + _shimBinDir = Path.Combine(_root, "shim-bin"); + Directory.CreateDirectory(_shimBinDir); + if (OperatingSystem.IsLinux()) + { + var shimPath = Path.Combine(_shimBinDir, "mv"); + File.WriteAllText(shimPath, @"#!/bin/sh +set -e + +# Fault-injection shim for LinuxInstallerTests +REAL_MV=$(PATH=""/usr/bin:/bin"" command -v mv) + +if [ ""${SHIM_FAIL_ACTIVATION:-0}"" = ""1"" ]; then + case ""$1"" in + *.staging-*) + exit 1 + ;; + esac +fi + +if [ ""${SHIM_FAIL_ACTIVATION_OCCUPIED:-0}"" = ""1"" ]; then + case ""$1"" in + *.staging-*) + mkdir -p ""$2"" + touch ""$2/conflict"" + chmod 555 ""$2"" + exit 1 + ;; + esac +fi + +if [ ""${SHIM_FAIL_POST_VALIDATION:-0}"" = ""1"" ]; then + case ""$1"" in + *.staging-*) + ""$REAL_MV"" ""$@"" + res=$? + rm -f ""$2/.filee-package"" + exit ""$res"" + ;; + esac +fi + +if [ ""${SHIM_FAIL_CLEANUP_FAILED_DIR:-0}"" = ""1"" ]; then + case ""$1"" in + *.staging-*) + ""$REAL_MV"" ""$@"" + res=$? + mkdir -p ""$2/locked-folder"" + touch ""$2/locked-folder/file"" + chmod 555 ""$2/locked-folder"" + rm -f ""$2/.filee-package"" + exit ""$res"" + ;; + esac +fi + +if [ ""${SHIM_FAIL_QUARANTINE:-0}"" = ""1"" ]; then + case ""$1"" in + *.staging-*) + ""$REAL_MV"" ""$@"" + res=$? + rm -f ""$2/.filee-package"" + exit ""$res"" + ;; + esac + case ""$2"" in + *.failed-*) + exit 1 + ;; + esac +fi + +if [ ""${SHIM_FAIL_FOREIGN_DESTINATION:-0}"" = ""1"" ]; then + case ""$1"" in + *.staging-*) + ""$REAL_MV"" ""$@"" + rm -rf ""$2"" + mkdir -p ""$2"" + echo ""important-foreign-data"" > ""$2/important.txt"" + exit 1 + ;; + esac +fi + +if [ ""${SHIM_FAIL_ROLLBACK_OCCUPIED:-0}"" = ""1"" ]; then + case ""$1"" in + *.staging-*) + ""$REAL_MV"" ""$@"" + rm -rf ""$2"" + mkdir -p ""$2"" + touch ""$2/conflict"" + chmod 555 ""$2"" + exit 1 + ;; + esac +fi + +if [ ""${SHIM_FAIL_ROLLBACK_ABSENT:-0}"" = ""1"" ]; then + case ""$1"" in + *.staging-*) + ""$REAL_MV"" ""$@"" + parent_dir=$(dirname ""$2"") + rm -rf ""$2"" + chmod 555 ""$parent_dir"" + exit 1 + ;; + esac +fi + +exec ""$REAL_MV"" ""$@"" +"); + File.SetUnixFileMode(shimPath, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + } + } + + [Fact] + public void First_time_installation_succeeds_in_isolated_environment() + { + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); + + var env = CreateEnvironment(); + CreateMockPayload(env.SourceDir, "v1.0.0"); + + var (exitCode, output, error) = RunInstaller(env.SourceDir, env.DataDir, env.HomeDir); + + Assert.True(exitCode == 0, $"Install failed with code {exitCode}. Output: {output}. Error: {error}"); + Assert.Contains("Installed Filee at", output); + + var installDir = Path.Combine(env.DataDir, "filee", "application"); + Assert.True(File.Exists(Path.Combine(installDir, "Filee"))); + Assert.True(File.Exists(Path.Combine(installDir, "filee-cli"))); + Assert.Equal("v1.0.0", File.ReadAllText(Path.Combine(installDir, "version.txt")).Trim()); + + var markerPath = Path.Combine(installDir, ".filee-package"); + Assert.True(File.Exists(markerPath)); + Assert.Equal("com.filee.app", File.ReadAllText(markerPath).Trim()); + Assert.False(File.Exists(Path.Combine(installDir, ".filee-upgrade-token"))); + + var launcherPath = Path.Combine(env.HomeDir, ".local", "bin", "filee"); + Assert.True(File.Exists(launcherPath)); + Assert.Equal(Path.Combine(installDir, "filee-cli"), File.ResolveLinkTarget(launcherPath, true)?.FullName); + + var desktopPath = Path.Combine(env.DataDir, "applications", "com.filee.app.desktop"); + Assert.True(File.Exists(desktopPath)); + Assert.Contains("X-Filee-Managed=true", File.ReadAllText(desktopPath)); + } + + [Fact] + public void First_time_installation_fails_if_already_installed() + { + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); + + var env = CreateEnvironment(); + CreateMockPayload(env.SourceDir, "v1.0.0"); + + var (exitCode1, _, _) = RunInstaller(env.SourceDir, env.DataDir, env.HomeDir); + Assert.Equal(0, exitCode1); + + // Second run without --upgrade must fail and refuse to overwrite + var (exitCode2, output2, error2) = RunInstaller(env.SourceDir, env.DataDir, env.HomeDir); + Assert.NotEqual(0, exitCode2); + Assert.Contains("An installation already exists", output2 + error2); + } + + [Fact] + public void Upgrade_succeeds_and_replaces_binaries_while_preserving_engine_folder_and_settings() + { + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); + + var env = CreateEnvironment(); + CreateMockPayload(env.SourceDir, "v1.0.0"); + + var (exitCode1, _, _) = RunInstaller(env.SourceDir, env.DataDir, env.HomeDir); + Assert.Equal(0, exitCode1); + + var installDir = Path.Combine(env.DataDir, "filee", "application"); + + // Simulate user downloaded engines in ~/.local/share/Filee/engines + var engineDir = Path.Combine(env.DataDir, "Filee", "engines", "ffmpeg"); + Directory.CreateDirectory(engineDir); + var mockEngineBinary = Path.Combine(engineDir, "ffmpeg"); + File.WriteAllText(mockEngineBinary, "ffmpeg-binary-data"); + + // Simulate user settings in ~/.config/Filee + var configDir = Path.Combine(env.HomeDir, ".config", "Filee"); + Directory.CreateDirectory(configDir); + var mockSettings = Path.Combine(configDir, "settings.json"); + File.WriteAllText(mockSettings, "{\"custom\": true}"); + + // Now prepare a v2.0.0 payload in another directory + var v2Source = Path.Combine(_root, "source-v2"); + CreateMockPayload(v2Source, "v2.0.0"); + + // Run upgrade + var (exitCode2, output2, error2) = RunInstaller(v2Source, env.DataDir, env.HomeDir, "--upgrade"); + Assert.True(exitCode2 == 0, $"Upgrade failed with code {exitCode2}. Output: {output2}. Error: {error2}"); + Assert.Contains("Upgraded Filee at", output2); + + // Verify application was updated to v2.0.0 + Assert.Equal("v2.0.0", File.ReadAllText(Path.Combine(installDir, "version.txt")).Trim()); + + // Verify downloaded engines are STILL PRESERVED! + Assert.True(File.Exists(mockEngineBinary)); + Assert.Equal("ffmpeg-binary-data", File.ReadAllText(mockEngineBinary)); + + // Verify settings are STILL PRESERVED! + Assert.True(File.Exists(mockSettings)); + Assert.Equal("{\"custom\": true}", File.ReadAllText(mockSettings)); + + // Verify launcher symlink, package marker, and token cleanup + var launcherPath = Path.Combine(env.HomeDir, ".local", "bin", "filee"); + Assert.Equal(Path.Combine(installDir, "filee-cli"), File.ResolveLinkTarget(launcherPath, true)?.FullName); + Assert.Equal("com.filee.app", File.ReadAllText(Path.Combine(installDir, ".filee-package")).Trim()); + Assert.False(File.Exists(Path.Combine(installDir, ".filee-upgrade-token"))); + } + + [Fact] + public void Upgrade_fails_if_not_already_installed() + { + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); + + var env = CreateEnvironment(); + CreateMockPayload(env.SourceDir, "v1.0.0"); + + // Running with --upgrade when no installation exists must fail cleanly + var (exitCode, output, error) = RunInstaller(env.SourceDir, env.DataDir, env.HomeDir, "--upgrade"); + Assert.NotEqual(0, exitCode); + Assert.Contains("No existing installation found", output + error); + } + + [Fact] + public void Upgrade_fails_if_marker_is_invalid_and_preserves_original_install() + { + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); + + var env = CreateEnvironment(); + CreateMockPayload(env.SourceDir, "v1.0.0"); + RunInstaller(env.SourceDir, env.DataDir, env.HomeDir); + + var installDir = Path.Combine(env.DataDir, "filee", "application"); + var markerPath = Path.Combine(installDir, ".filee-package"); + File.WriteAllText(markerPath, "alien.app"); + + var v2Source = Path.Combine(_root, "source-v2"); + CreateMockPayload(v2Source, "v2.0.0"); + + var (exitCode, output, error) = RunInstaller(v2Source, env.DataDir, env.HomeDir, "--upgrade"); + Assert.NotEqual(0, exitCode); + Assert.Contains("not installed by Filee", output + error); + + // Original installation must be untouched + Assert.Equal("v1.0.0", File.ReadAllText(Path.Combine(installDir, "version.txt")).Trim()); + } + + [Fact] + public void Upgrade_fails_if_payload_is_missing_executables_and_preserves_original_install() + { + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); + + var env = CreateEnvironment(); + CreateMockPayload(env.SourceDir, "v1.0.0"); + RunInstaller(env.SourceDir, env.DataDir, env.HomeDir); + + var installDir = Path.Combine(env.DataDir, "filee", "application"); + + // Broken v2 source: missing filee-cli + var v2Source = Path.Combine(_root, "source-v2"); + CreateMockPayload(v2Source, "v2.0.0"); + File.Delete(Path.Combine(v2Source, "filee-cli")); + + var (exitCode, output, error) = RunInstaller(v2Source, env.DataDir, env.HomeDir, "--upgrade"); + Assert.NotEqual(0, exitCode); + Assert.Contains("Payload validation failed", output + error); + + // Original installation must still be intact + Assert.Equal("v1.0.0", File.ReadAllText(Path.Combine(installDir, "version.txt")).Trim()); + } + + [Fact] + public void Upgrade_invokes_quit_on_running_filee_instance() + { + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); + + var env = CreateEnvironment(); + CreateMockPayload(env.SourceDir, "v1.0.0"); + RunInstaller(env.SourceDir, env.DataDir, env.HomeDir); + + var installDir = Path.Combine(env.DataDir, "filee", "application"); + var quitLog = Path.Combine(env.DataDir, "quit.log"); + + // Replace Filee with a mock script that records --quit + var fileeScript = Path.Combine(installDir, "Filee"); + File.WriteAllText(fileeScript, $"#!/bin/sh\nif [ \"$1\" = \"--quit\" ]; then echo \"quit-called\" >> \"{quitLog}\"; exit 0; fi\nexit 0\n"); + File.SetUnixFileMode(fileeScript, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + + var v2Source = Path.Combine(_root, "source-v2"); + CreateMockPayload(v2Source, "v2.0.0"); + + var (exitCode, output, error) = RunInstaller(v2Source, env.DataDir, env.HomeDir, "--upgrade"); + Assert.True(exitCode == 0, $"Upgrade with quit failed with code {exitCode}. Output: {output}. Error: {error}"); + Assert.True(File.Exists(quitLog)); + Assert.Contains("quit-called", File.ReadAllText(quitLog)); + } + + [Fact] + public void Upgrade_rolls_back_if_activation_move_fails() + { + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); + + var env = CreateEnvironment(); + CreateMockPayload(env.SourceDir, "v1.0.0"); + RunInstaller(env.SourceDir, env.DataDir, env.HomeDir); + + var installDir = Path.Combine(env.DataDir, "filee", "application"); + + var v2Source = Path.Combine(_root, "source-v2"); + CreateMockPayload(v2Source, "v2.0.0"); + + // Simulate failure during activation move using external mv shim + var (exitCode, output, error) = RunInstaller(v2Source, env.DataDir, env.HomeDir, + new Dictionary { ["SHIM_FAIL_ACTIVATION"] = "1" }, "--upgrade"); + + Assert.NotEqual(0, exitCode); + Assert.Contains("Restored the previous Filee installation", output + error); + + // Original installation must be safely restored + Assert.True(File.Exists(Path.Combine(installDir, "Filee"))); + Assert.Equal("v1.0.0", File.ReadAllText(Path.Combine(installDir, "version.txt")).Trim()); + Assert.Equal("com.filee.app", File.ReadAllText(Path.Combine(installDir, ".filee-package")).Trim()); + } + + [Fact] + public void Upgrade_rolls_back_if_post_swap_validation_fails() + { + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); + + var env = CreateEnvironment(); + CreateMockPayload(env.SourceDir, "v1.0.0"); + RunInstaller(env.SourceDir, env.DataDir, env.HomeDir); + + var installDir = Path.Combine(env.DataDir, "filee", "application"); + + var v2Source = Path.Combine(_root, "source-v2"); + CreateMockPayload(v2Source, "v2.0.0"); + + // Simulate failure during post-swap validation using external mv shim + var (exitCode, output, error) = RunInstaller(v2Source, env.DataDir, env.HomeDir, + new Dictionary { ["SHIM_FAIL_POST_VALIDATION"] = "1" }, "--upgrade"); + + Assert.NotEqual(0, exitCode); + Assert.Contains("Activated installation failed post-swap validation", output + error); + Assert.Contains("Restored the previous Filee installation", output + error); + + // Original installation must be safely restored + Assert.True(File.Exists(Path.Combine(installDir, "Filee"))); + Assert.Equal("v1.0.0", File.ReadAllText(Path.Combine(installDir, "version.txt")).Trim()); + Assert.Equal("com.filee.app", File.ReadAllText(Path.Combine(installDir, ".filee-package")).Trim()); + } + + [Fact] + public void Upgrade_reports_critical_error_and_preserves_backup_if_rollback_destination_is_occupied() + { + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); + + var env = CreateEnvironment(); + CreateMockPayload(env.SourceDir, "v1.0.0"); + RunInstaller(env.SourceDir, env.DataDir, env.HomeDir); + + var v2Source = Path.Combine(_root, "source-v2"); + CreateMockPayload(v2Source, "v2.0.0"); + + // Simulate failure during rollback where destination is occupied using external mv shim + var (exitCode, output, error) = RunInstaller(v2Source, env.DataDir, env.HomeDir, + new Dictionary { ["SHIM_FAIL_ROLLBACK_OCCUPIED"] = "1" }, "--upgrade"); + + Assert.NotEqual(0, exitCode); + Assert.Contains("CRITICAL: Update failed and automatic rollback could not complete", output + error); + Assert.Contains("Your previous installation is preserved at", output + error); + Assert.Contains("A partial or invalid directory exists at:", output + error); + Assert.Contains("inspect and remove or rename", output + error); + + // Verify that the backup directory still exists and holds the original installation + var fileeDir = Path.Combine(env.DataDir, "filee"); + var backupDirs = Directory.GetDirectories(fileeDir, ".backup-*"); + Assert.NotEmpty(backupDirs); + var backupDir = backupDirs[0]; + Assert.True(File.Exists(Path.Combine(backupDir, "Filee"))); + Assert.Equal("v1.0.0", File.ReadAllText(Path.Combine(backupDir, "version.txt")).Trim()); + + // Verify that the occupied conflict file was preserved and not overwritten + var installDir = Path.Combine(env.DataDir, "filee", "application"); + Assert.True(File.Exists(Path.Combine(installDir, "conflict"))); + } + + [Fact] + public void Upgrade_reports_critical_error_if_destination_occupied_during_activation() + { + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); + + var env = CreateEnvironment(); + CreateMockPayload(env.SourceDir, "v1.0.0"); + RunInstaller(env.SourceDir, env.DataDir, env.HomeDir); + + var v2Source = Path.Combine(_root, "source-v2"); + CreateMockPayload(v2Source, "v2.0.0"); + + // Simulate destination conflict during activation using external mv shim + var (exitCode, output, error) = RunInstaller(v2Source, env.DataDir, env.HomeDir, + new Dictionary { ["SHIM_FAIL_ACTIVATION_OCCUPIED"] = "1" }, "--upgrade"); + + Assert.NotEqual(0, exitCode); + Assert.Contains("CRITICAL: Update failed and automatic rollback could not complete", output + error); + Assert.Contains("Your previous installation is preserved at", output + error); + Assert.Contains("A partial or invalid directory exists at:", output + error); + Assert.Contains("inspect and remove or rename", output + error); + + // Verify that the backup directory still exists and holds the original installation + var fileeDir = Path.Combine(env.DataDir, "filee"); + var backupDirs = Directory.GetDirectories(fileeDir, ".backup-*"); + Assert.NotEmpty(backupDirs); + var backupDir = backupDirs[0]; + Assert.True(File.Exists(Path.Combine(backupDir, "Filee"))); + Assert.Equal("v1.0.0", File.ReadAllText(Path.Combine(backupDir, "version.txt")).Trim()); + + // Verify that the unmanaged conflict was not touched + var installDir = Path.Combine(env.DataDir, "filee", "application"); + Assert.True(File.Exists(Path.Combine(installDir, "conflict"))); + } + + [Fact] + public void Upgrade_reports_critical_error_and_preserves_backup_if_rollback_destination_is_absent_but_move_fails() + { + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); + + var env = CreateEnvironment(); + CreateMockPayload(env.SourceDir, "v1.0.0"); + RunInstaller(env.SourceDir, env.DataDir, env.HomeDir); + + var v2Source = Path.Combine(_root, "source-v2"); + CreateMockPayload(v2Source, "v2.0.0"); + + // Simulate failure during rollback when install_dir is absent using external mv shim + var (exitCode, output, error) = RunInstaller(v2Source, env.DataDir, env.HomeDir, + new Dictionary { ["SHIM_FAIL_ROLLBACK_ABSENT"] = "1" }, "--upgrade"); + + Assert.NotEqual(0, exitCode); + Assert.Contains("CRITICAL: Update failed and automatic rollback could not complete", output + error); + Assert.Contains("Your previous installation is preserved at", output + error); + Assert.Contains("To restore manually, run: mv", output + error); + Assert.DoesNotContain("A partial or invalid directory exists at:", output + error); + + // Verify that the backup directory still exists and holds the original installation + var fileeDir = Path.Combine(env.DataDir, "filee"); + var backupDirs = Directory.GetDirectories(fileeDir, ".backup-*"); + Assert.NotEmpty(backupDirs); + var backupDir = backupDirs[0]; + Assert.True(File.Exists(Path.Combine(backupDir, "Filee"))); + Assert.Equal("v1.0.0", File.ReadAllText(Path.Combine(backupDir, "version.txt")).Trim()); + + var installDir = Path.Combine(fileeDir, "application"); + Assert.False(Directory.Exists(installDir)); + Assert.Contains($"To restore manually, run: mv '{backupDir}' '{installDir}'", output + error); + } + + [Fact] + public void Upgrade_preserves_destination_and_backup_if_quarantine_fails() + { + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); + + var env = CreateEnvironment(); + CreateMockPayload(env.SourceDir, "v1.0.0"); + RunInstaller(env.SourceDir, env.DataDir, env.HomeDir); + + var v2Source = Path.Combine(_root, "source-v2"); + CreateMockPayload(v2Source, "v2.0.0"); + + // Simulate quarantine failure during rollback using external mv shim + var (exitCode, output, error) = RunInstaller(v2Source, env.DataDir, env.HomeDir, + new Dictionary { ["SHIM_FAIL_QUARANTINE"] = "1" }, "--upgrade"); + + Assert.NotEqual(0, exitCode); + Assert.Contains("CRITICAL: Update failed and automatic rollback could not complete", output + error); + Assert.Contains("Your previous installation is preserved at", output + error); + Assert.Contains("A partial or invalid directory exists at:", output + error); + Assert.Contains("inspect and remove or rename", output + error); + + // Verify that the destination was NOT deleted or removed via fallback + var installDir = Path.Combine(env.DataDir, "filee", "application"); + Assert.True(File.Exists(Path.Combine(installDir, "Filee"))); + + // Verify that the backup directory still exists and holds the original installation + var fileeDir = Path.Combine(env.DataDir, "filee"); + var backupDirs = Directory.GetDirectories(fileeDir, ".backup-*"); + Assert.NotEmpty(backupDirs); + var backupDir = backupDirs[0]; + Assert.True(File.Exists(Path.Combine(backupDir, "Filee"))); + Assert.Equal("v1.0.0", File.ReadAllText(Path.Combine(backupDir, "version.txt")).Trim()); + } + + [Fact] + public void Upgrade_restores_backup_and_reports_quarantine_if_quarantine_cleanup_fails() + { + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); + + var env = CreateEnvironment(); + CreateMockPayload(env.SourceDir, "v1.0.0"); + RunInstaller(env.SourceDir, env.DataDir, env.HomeDir); + + var v2Source = Path.Combine(_root, "source-v2"); + CreateMockPayload(v2Source, "v2.0.0"); + + // Simulate quarantine cleanup failure using external mv shim + var (exitCode, output, error) = RunInstaller(v2Source, env.DataDir, env.HomeDir, + new Dictionary { ["SHIM_FAIL_CLEANUP_FAILED_DIR"] = "1" }, "--upgrade"); + + Assert.NotEqual(0, exitCode); + Assert.Contains("Update failed. Restored the previous Filee installation", output + error); + Assert.Contains("Quarantined failed update files preserved at:", output + error); + + // Verify that the old installation was successfully restored to application directory + var installDir = Path.Combine(env.DataDir, "filee", "application"); + Assert.True(File.Exists(Path.Combine(installDir, "Filee"))); + Assert.Equal("v1.0.0", File.ReadAllText(Path.Combine(installDir, "version.txt")).Trim()); + + // Verify that the quarantined failed directory still exists on disk and was preserved + var fileeDir = Path.Combine(env.DataDir, "filee"); + var failedDirs = Directory.GetDirectories(fileeDir, ".failed-*"); + Assert.NotEmpty(failedDirs); + var failedDir = failedDirs[0]; + Assert.True(Directory.Exists(Path.Combine(failedDir, "locked-folder"))); + + // Verify backup was safely restored into installDir (not abandoned in backup) + var backupDirs = Directory.GetDirectories(fileeDir, ".backup-*"); + Assert.Empty(backupDirs); + } + + [Fact] + public void Upgrade_preserves_preexisting_backup_and_failed_directories_from_earlier_runs() + { + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); + + var env = CreateEnvironment(); + CreateMockPayload(env.SourceDir, "v1.0.0"); + RunInstaller(env.SourceDir, env.DataDir, env.HomeDir); + + // Create a pre-existing backup and failed directory from earlier interrupted runs + var fileeDir = Path.Combine(env.DataDir, "filee"); + var oldBackupDir = Path.Combine(fileeDir, ".backup-earlier-crash-999"); + Directory.CreateDirectory(oldBackupDir); + var oldVersionFile = Path.Combine(oldBackupDir, "version.txt"); + File.WriteAllText(oldVersionFile, "v0.9.0-backup-preserved"); + + var oldFailedDir = Path.Combine(fileeDir, ".failed-earlier-crash-888"); + Directory.CreateDirectory(oldFailedDir); + var oldFailedFile = Path.Combine(oldFailedDir, "failed.txt"); + File.WriteAllText(oldFailedFile, "v0.9.0-failed-preserved"); + + var v2Source = Path.Combine(_root, "source-v2"); + CreateMockPayload(v2Source, "v2.0.0"); + + // Run upgrade + var (exitCode, output, error) = RunInstaller(v2Source, env.DataDir, env.HomeDir, "--upgrade"); + Assert.True(exitCode == 0, $"Upgrade failed with code {exitCode}. Output: {output}. Error: {error}"); + + var installDir = Path.Combine(fileeDir, "application"); + Assert.Equal("v2.0.0", File.ReadAllText(Path.Combine(installDir, "version.txt")).Trim()); + + // Verify the pre-existing backup and failed directories from earlier crash were NOT erased! + Assert.True(Directory.Exists(oldBackupDir)); + Assert.True(File.Exists(oldVersionFile)); + Assert.Equal("v0.9.0-backup-preserved", File.ReadAllText(oldVersionFile).Trim()); + + Assert.True(Directory.Exists(oldFailedDir)); + Assert.True(File.Exists(oldFailedFile)); + Assert.Equal("v0.9.0-failed-preserved", File.ReadAllText(oldFailedFile).Trim()); + } + + [Fact] + public void Upgrade_preserves_foreign_directory_if_destination_was_replaced_before_rollback() + { + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); + + var env = CreateEnvironment(); + CreateMockPayload(env.SourceDir, "v1.0.0"); + RunInstaller(env.SourceDir, env.DataDir, env.HomeDir); + + var v2Source = Path.Combine(_root, "source-v2"); + CreateMockPayload(v2Source, "v2.0.0"); + + // Simulate replacement of install_dir by a foreign directory before rollback using external mv shim + var (exitCode, output, error) = RunInstaller(v2Source, env.DataDir, env.HomeDir, + new Dictionary { ["SHIM_FAIL_FOREIGN_DESTINATION"] = "1" }, "--upgrade"); + + Assert.NotEqual(0, exitCode); + Assert.Contains("CRITICAL: Update failed and automatic rollback could not complete", output + error); + Assert.Contains("Your previous installation is preserved at", output + error); + Assert.Contains("A partial or invalid directory exists at:", output + error); + Assert.Contains("inspect and remove or rename", output + error); + + // Verify that the foreign directory and its contents were NOT deleted + var installDir = Path.Combine(env.DataDir, "filee", "application"); + var foreignFile = Path.Combine(installDir, "important.txt"); + Assert.True(File.Exists(foreignFile)); + Assert.Equal("important-foreign-data", File.ReadAllText(foreignFile).Trim()); + + // Verify that the backup directory still exists and holds the original installation + var fileeDir = Path.Combine(env.DataDir, "filee"); + var backupDirs = Directory.GetDirectories(fileeDir, ".backup-*"); + Assert.NotEmpty(backupDirs); + var backupDir = backupDirs[0]; + Assert.True(File.Exists(Path.Combine(backupDir, "Filee"))); + Assert.Equal("v1.0.0", File.ReadAllText(Path.Combine(backupDir, "version.txt")).Trim()); + } + + [Fact] + public void Test_hook_environment_variables_have_no_effect_on_installer() + { + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); + + var env = CreateEnvironment(); + CreateMockPayload(env.SourceDir, "v1.0.0"); + RunInstaller(env.SourceDir, env.DataDir, env.HomeDir); + + var v2Source = Path.Combine(_root, "source-v2"); + CreateMockPayload(v2Source, "v2.0.0"); + + // Setting obsolete internal test hook variables has no effect because install.sh contains no test hooks + var (exitCode, output, error) = RunInstaller(v2Source, env.DataDir, env.HomeDir, + new Dictionary { ["_FILEE_TEST_FAIL_ACTIVATION"] = "1", ["_FILEE_TEST_HARNESS"] = "com.filee.test.harness" }, "--upgrade"); + + Assert.True(exitCode == 0, $"Upgrade failed unexpectedly. Output: {output}. Error: {error}"); + Assert.Contains("Upgraded Filee at", output); + + var installDir = Path.Combine(env.DataDir, "filee", "application"); + Assert.Equal("v2.0.0", File.ReadAllText(Path.Combine(installDir, "version.txt")).Trim()); + } + + [Fact] + public void Installation_and_upgrade_succeed_with_spaces_and_special_characters_in_paths() + { + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); + + var id = Guid.NewGuid().ToString("N"); + var sourceDir = Path.Combine(_root, "source path with spaces " + id); + var dataDir = Path.Combine(_root, "data path with spaces " + id); + var homeDir = Path.Combine(_root, "home path with spaces " + id); + Directory.CreateDirectory(sourceDir); + Directory.CreateDirectory(dataDir); + Directory.CreateDirectory(homeDir); + + CreateMockPayload(sourceDir, "v1.0.0"); + + // Fresh install with spaces + var (exitCode1, output1, error1) = RunInstaller(sourceDir, dataDir, homeDir); + Assert.True(exitCode1 == 0, $"Fresh install failed. Output: {output1}. Error: {error1}"); + Assert.Contains("Installed Filee at", output1); + + var installDir = Path.Combine(dataDir, "filee", "application"); + Assert.True(File.Exists(Path.Combine(installDir, "Filee"))); + Assert.Equal("v1.0.0", File.ReadAllText(Path.Combine(installDir, "version.txt")).Trim()); + + // Upgrade with spaces + var v2Source = Path.Combine(_root, "source-v2 spaces " + id); + CreateMockPayload(v2Source, "v2.0.0"); + + var (exitCode2, output2, error2) = RunInstaller(v2Source, dataDir, homeDir, "--upgrade"); + Assert.True(exitCode2 == 0, $"Upgrade failed. Output: {output2}. Error: {error2}"); + Assert.Contains("Upgraded Filee at", output2); + Assert.Equal("v2.0.0", File.ReadAllText(Path.Combine(installDir, "version.txt")).Trim()); + } + + [Fact] + public void Upgrade_fails_if_running_filee_cannot_gracefully_shut_down() + { + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); + + var env = CreateEnvironment(); + CreateMockPayload(env.SourceDir, "v1.0.0"); + + var (exitCode1, _, _) = RunInstaller(env.SourceDir, env.DataDir, env.HomeDir); + Assert.Equal(0, exitCode1); + + var installDir = Path.Combine(env.DataDir, "filee", "application"); + var activeFilee = Path.Combine(installDir, "Filee"); + + // Make the installed Filee binary fail if --quit is invoked + File.WriteAllText(activeFilee, "#!/bin/sh\nif [ \"$1\" = \"--quit\" ]; then exit 42; fi\nexit 0\n"); + File.SetUnixFileMode(activeFilee, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + + // Prepare v2 payload + var v2Dir = Path.Combine(_root, "source-v2-" + Guid.NewGuid().ToString("N")[..8]); + CreateMockPayload(v2Dir, "v2.0.0"); + + // Upgrade calls Filee --quit, which fails with code 42 + var (failCode, failOut, failErr) = RunInstaller(v2Dir, env.DataDir, env.HomeDir, args: ["--upgrade"]); + Assert.NotEqual(0, failCode); + Assert.Contains("Could not gracefully shut down", failOut + failErr); + Assert.Equal("v1.0.0", File.ReadAllText(Path.Combine(installDir, "version.txt")).Trim()); + } + + private (string SourceDir, string DataDir, string HomeDir) CreateEnvironment() + { + var id = Guid.NewGuid().ToString("N"); + var sourceDir = Path.Combine(_root, "source-" + id); + var dataDir = Path.Combine(_root, "data-" + id); + var homeDir = Path.Combine(_root, "home-" + id); + Directory.CreateDirectory(sourceDir); + Directory.CreateDirectory(dataDir); + Directory.CreateDirectory(homeDir); + return (sourceDir, dataDir, homeDir); + } + + private void CreateMockPayload(string dir, string version) + { + Directory.CreateDirectory(dir); + var filee = Path.Combine(dir, "Filee"); + var cli = Path.Combine(dir, "filee-cli"); + var icon = Path.Combine(dir, "filee.png"); + var ver = Path.Combine(dir, "version.txt"); + var script = Path.Combine(dir, "install.sh"); + + File.WriteAllText(filee, "#!/bin/sh\nexit 0\n"); + File.WriteAllText(cli, "#!/bin/sh\nexit 0\n"); + File.WriteAllText(icon, "dummy-png"); + File.WriteAllText(ver, version); + File.Copy(_installScriptPath, script, overwrite: true); + + File.SetUnixFileMode(filee, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + File.SetUnixFileMode(cli, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + File.SetUnixFileMode(script, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + private (int ExitCode, string Output, string Error) RunInstaller( + string sourceDir, string dataDir, string homeDir, params string[] args) => + RunInstaller(sourceDir, dataDir, homeDir, null, args); + + private (int ExitCode, string Output, string Error) RunInstaller( + string sourceDir, string dataDir, string homeDir, IDictionary? extraEnv, params string[] args) + { + var script = Path.Combine(sourceDir, "install.sh"); + var psi = new ProcessStartInfo("/bin/sh") + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = sourceDir, + }; + psi.ArgumentList.Add(script); + foreach (var arg in args) + psi.ArgumentList.Add(arg); + + psi.Environment["XDG_DATA_HOME"] = dataDir; + psi.Environment["HOME"] = homeDir; + psi.Environment["PATH"] = $"{_shimBinDir}:{Environment.GetEnvironmentVariable("PATH") ?? "/bin:/usr/bin"}"; + + if (extraEnv is not null) + { + foreach (var (k, v) in extraEnv) + psi.Environment[k] = v; + } + + using var process = Process.Start(psi)!; + var output = process.StandardOutput.ReadToEnd(); + var error = process.StandardError.ReadToEnd(); + process.WaitForExit(10000); + + return (process.ExitCode, output, error); + } + + public void Dispose() + { + try + { + if (OperatingSystem.IsLinux() && Directory.Exists(_root)) + { + var psi = new ProcessStartInfo("/bin/chmod", ["-R", "u+rwx", _root]) + { + UseShellExecute = false, + CreateNoWindow = true + }; + Process.Start(psi)?.WaitForExit(5000); + } + if (Directory.Exists(_root)) + Directory.Delete(_root, recursive: true); + } + catch + { + // Ignore temporary cleanup errors + } + } +}