From 75ddcc2fd4b499c70938d930e9b9de0e6ef5dffb Mon Sep 17 00:00:00 2001 From: Alenbosh Date: Wed, 21 Oct 2026 19:05:32 +0530 Subject: [PATCH 1/7] feat(linux): port transactional installer upgrade mode and in-app updater to Filee 2.0.0 --- installer/linux/INSTALL.txt | 20 +- installer/linux/install.sh | 310 +++++++- src/Filee.App/App.axaml.cs | 2 + src/Filee.App/Services/UpdateService.cs | 643 ++++++++++++++- .../Office/LibreOfficeConverter.cs | 43 +- .../Vector/GhostscriptConverter.cs | 46 +- tests/Filee.App.Tests/UpdateServiceTests.cs | 512 +++++++++++- .../LinuxInstallerTests.cs | 734 ++++++++++++++++++ 8 files changed, 2274 insertions(+), 36 deletions(-) mode change 100644 => 100755 installer/linux/install.sh create mode 100644 tests/Filee.Platform.Tests/LinuxInstallerTests.cs diff --git a/installer/linux/INSTALL.txt b/installer/linux/INSTALL.txt index 2852fddb1..e2b5d4c5c 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..f23bca4ca --- a/installer/linux/install.sh +++ b/installer/linux/install.sh @@ -1,30 +1,298 @@ #!/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 +restart=0 + +for arg do + case "$arg" in + --upgrade|-u) + upgrade=1 + ;; + --restart) + restart=1 + ;; + --help|-h) + printf '%s\n' "Usage: $0 [--upgrade|-u] [--restart]" + exit 0 + ;; + *) + printf '%s\n' "Unknown option: $arg" >&2 + printf '%s\n' "Usage: $0 [--upgrade|-u] [--restart]" >&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 +} + +# Only enable internal test failure hooks if explicitly requested by the test harness +# AND executing within an isolated temporary test directory. +test_hooks_enabled=0 +if [ "${_FILEE_TEST_HARNESS:-}" = "com.filee.test.harness" ]; then + case "$data_dir" in + /tmp/*|/var/tmp/*|*/filee-installer-test-*) + test_hooks_enabled=1 + ;; + esac 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 (skipped during in-app updates). + if [ "${FILEE_IN_APP_UPDATE:-0}" != "1" ]; then + 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 + 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" + + # Test hook: simulate failure after backup but before activation. + if [ "$test_hooks_enabled" -eq 1 ] && [ "${_FILEE_TEST_FAIL_ACTIVATION:-0}" = "1" ]; then + rm -rf "$staging_dir" + fi + + # Test hook: simulate activation destination conflict. + if [ "$test_hooks_enabled" -eq 1 ] && [ "${_FILEE_TEST_FAIL_ACTIVATION_OCCUPIED:-0}" = "1" ]; then + mkdir -p "$install_dir" + touch "$install_dir/conflict" + chmod 555 "$install_dir" + fi + + # Activate new installation from staging. + if ! mv "$staging_dir" "$install_dir"; then + exit 1 + fi + activated=1 + + # Test hook: simulate failure during post-swap validation. + if [ "$test_hooks_enabled" -eq 1 ] && [ "${_FILEE_TEST_FAIL_POST_VALIDATION:-0}" = "1" ]; then + rm -f "$install_dir/.filee-package" + fi + + # Test hook: simulate failure during post-swap validation where failed_dir cleanup will fail. + if [ "$test_hooks_enabled" -eq 1 ] && [ "${_FILEE_TEST_FAIL_CLEANUP_FAILED_DIR:-0}" = "1" ]; then + mkdir -p "$install_dir/locked-folder" + touch "$install_dir/locked-folder/file" + chmod 555 "$install_dir/locked-folder" + rm -f "$install_dir/.filee-package" + fi + + # Test hook: simulate quarantine move failure during rollback. + if [ "$test_hooks_enabled" -eq 1 ] && [ "${_FILEE_TEST_FAIL_QUARANTINE:-0}" = "1" ]; then + mkdir -p "$failed_dir" + touch "$failed_dir/conflict" + exit 1 + fi + + # Test hook: simulate replacement of install_dir by a foreign directory before rollback. + if [ "$test_hooks_enabled" -eq 1 ] && [ "${_FILEE_TEST_FAIL_FOREIGN_DESTINATION:-0}" = "1" ]; then + rm -rf "$install_dir" + mkdir -p "$install_dir" + echo "important-foreign-data" > "$install_dir/important.txt" + exit 1 + fi + + # Test hook: simulate failure during rollback with occupied destination. + if [ "$test_hooks_enabled" -eq 1 ] && { [ "${_FILEE_TEST_FAIL_ROLLBACK_OCCUPIED:-0}" = "1" ] || [ "${_FILEE_TEST_FAIL_ROLLBACK:-0}" = "1" ]; }; then + rm -rf "$install_dir" + mkdir -p "$install_dir" + touch "$install_dir/conflict" + chmod 555 "$install_dir" + exit 1 + fi + + # Test hook: simulate failure during rollback with absent destination. + if [ "$test_hooks_enabled" -eq 1 ] && [ "${_FILEE_TEST_FAIL_ROLLBACK_ABSENT:-0}" = "1" ]; then + rm -rf "$install_dir" + chmod 555 "$data_dir/filee" + exit 1 + fi + + # 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 +314,17 @@ 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." + if [ "$restart" -eq 1 ] && [ "${FILEE_IN_APP_UPDATE:-0}" != "1" ]; then + nohup "$install_dir/Filee" /dev/null 2>&1 & + printf '%s\n' "Restarted Filee." + fi +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 618e0d554..51e671614 100644 --- a/src/Filee.App/App.axaml.cs +++ b/src/Filee.App/App.axaml.cs @@ -200,6 +200,8 @@ private void ListenForLaterLaunches(SingleInstance instance, ILogger log) private static void WatchForUpdates(UserDataStore store) { var updates = AppHost.Get(); + updates.RequestApplicationShutdown = () => + (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.Shutdown(); // 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) => diff --git a/src/Filee.App/Services/UpdateService.cs b/src/Filee.App/Services/UpdateService.cs index 12d5d6171..35fe47470 100644 --- a/src/Filee.App/Services/UpdateService.cs +++ b/src/Filee.App/Services/UpdateService.cs @@ -5,8 +5,11 @@ 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; @@ -84,15 +87,72 @@ 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; } + /// Whether installs by itself (; tests set it). internal bool InstallsItself { get; init; } = IsInstalled; @@ -163,7 +223,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) { @@ -264,14 +326,17 @@ public async Task UpdateAsync() } string installer; + string? workspace = null; try { + workspace = CreateSecureWorkspace(); var progress = new Progress(percent => DownloadPercent = percent); - installer = await DownloadInstallerAsync(Path.Combine(Path.GetTempPath(), "Filee-update"), progress); + 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; } @@ -279,16 +344,53 @@ public async Task UpdateAsync() Process? setup; try { - setup = Process.Start(new ProcessStartInfo(installer, InstallerArguments) { UseShellExecute = true }); + if (TargetPlatform == "win") + { + setup = StartProcess(new ProcessStartInfo(installer, InstallerArguments) { UseShellExecute = true }); + } + else if (TargetPlatform.StartsWith("linux-", StringComparison.OrdinalIgnoreCase)) + { + 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 startInfo = new ProcessStartInfo("/bin/sh") + { + WorkingDirectory = payloadDir, + UseShellExecute = false, + }; + startInfo.ArgumentList.Add(scriptPath); + startInfo.ArgumentList.Add("--upgrade"); + startInfo.ArgumentList.Add("--restart"); + startInfo.Environment["FILEE_IN_APP_UPDATE"] = "1"; + setup = StartProcess(startInfo); + } + else + { + throw new PlatformNotSupportedException("In-app update is only supported on Windows and Linux"); + } } - catch (Win32Exception ex) when (ex.NativeErrorCode == ErrorCancelled) + catch (Win32Exception ex) when (TargetPlatform == "win" && ex.NativeErrorCode == ErrorCancelled) { _log.LogInformation("Update cancelled at the administrator prompt"); Step = UpdateStep.Cancelled; + DeleteSecureWorkspace(workspace); return; } - catch (Exception ex) when (ex is Win32Exception or InvalidOperationException) + catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or PlatformNotSupportedException) { + DeleteSecureWorkspace(workspace); Fail("start", ex); return; } @@ -299,11 +401,78 @@ public async Task UpdateAsync() 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. + // the administrator prompt was declined (Setup asks for it itself), or the user cancelled, or an error occurred. using (setup) + { await setup.WaitForExitAsync(); - _log.LogInformation("Installer ended with exit code {Code}", setup.ExitCode); - Step = setup.ExitCode == 0 ? UpdateStep.Idle : UpdateStep.Cancelled; + _log.LogInformation("Installer ended with exit code {Code}", setup.ExitCode); + if (setup.ExitCode == 0) + { + if (TargetPlatform.StartsWith("linux-", StringComparison.OrdinalIgnoreCase)) + { + var targetInstallDir = LinuxInstallDirectoryOverride ?? GetLinuxInstallDirectory(); + var expectedVersion = LatestVersion; + if (string.IsNullOrWhiteSpace(expectedVersion)) + { + var fileName = Path.GetFileName(installer); + var parts = fileName.Split('-'); + if (parts.Length >= 2 && TryParse(parts[1], out var parsedVer)) + expectedVersion = parsedVer.ToString(3); + expectedVersion ??= ""; + } + if (!VerifyInstalledVersion(targetInstallDir, expectedVersion, out var actualVersion)) + { + var recovery = FindLinuxRecoveryInfo(targetInstallDir); + var message = $"Update verification failed: installed version '{actualVersion ?? "unknown"}' does not match expected version '{expectedVersion}'. {recovery}"; + _log.LogError("{Message}", message); + DeleteSecureWorkspace(workspace); + Fail("verify", new InvalidOperationException(message)); + return; + } + // Success on Linux: spawn detached restart watcher and shut down current instance. + try + { + var newExe = Path.Combine(targetInstallDir, "Filee"); + var currentPid = Environment.ProcessId; + var restartWatcher = new ProcessStartInfo("/bin/sh") + { + WorkingDirectory = targetInstallDir, + UseShellExecute = false, + }; + restartWatcher.ArgumentList.Add("-c"); + restartWatcher.ArgumentList.Add("for _ in $(seq 1 600); do if ! kill -0 \"$1\" 2>/dev/null; then exec \"$2\"; fi; sleep 0.1; done; exit 1"); + restartWatcher.ArgumentList.Add("_"); + restartWatcher.ArgumentList.Add(currentPid.ToString()); + restartWatcher.ArgumentList.Add(newExe); + restartWatcher.Environment.Remove("FILEE_IN_APP_UPDATE"); + StartProcess(restartWatcher); + } + catch (Exception ex) + { + _log.LogWarning(ex, "Failed to launch restart watcher for {Version}", expectedVersion); + } + RequestApplicationShutdown?.Invoke(); + } + Step = UpdateStep.Idle; + DeleteSecureWorkspace(workspace); + } + else if (TargetPlatform == "win") + { + Step = UpdateStep.Cancelled; + DeleteSecureWorkspace(workspace); + } + else + { + var targetInstallDir = LinuxInstallDirectoryOverride ?? GetLinuxInstallDirectory(); + var recovery = FindLinuxRecoveryInfo(targetInstallDir); + var message = string.IsNullOrEmpty(recovery) + ? $"Installer ended with exit code {setup.ExitCode}" + : $"Installer ended with exit code {setup.ExitCode}. {recovery}"; + _log.LogError("{Message}", message); + DeleteSecureWorkspace(workspace); + Fail("install", new InvalidOperationException(message)); + } + } } /// @@ -333,6 +502,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) @@ -396,6 +566,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 (). @@ -404,6 +577,7 @@ 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); @@ -423,12 +597,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() ?? "" : ""; @@ -436,14 +616,256 @@ 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)) + } + 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); } + /// + /// 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; + mode |= 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); + mode |= 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) { @@ -477,6 +899,195 @@ private void TryDelete(string path) } } + /// + /// 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) + { + if (string.IsNullOrWhiteSpace(baseDir)) + { + baseDir = Path.GetTempPath(); + if (OperatingSystem.IsLinux()) + { + var runtimeDir = Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR"); + if (!string.IsNullOrWhiteSpace(runtimeDir) && Directory.Exists(runtimeDir)) + baseDir = runtimeDir; + } + } + + for (var attempt = 0; attempt < 100; attempt++) + { + var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(); + var candidate = Path.Combine(baseDir, $"filee-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 + } + } + + /// Locates the installation directory on Linux. + internal static string GetLinuxInstallDirectory() + { + var overrideDir = Environment.GetEnvironmentVariable("FILEE_INSTALL_DIR"); + if (!string.IsNullOrWhiteSpace(overrideDir)) + return overrideDir; + if (IsLinuxInstalled(AppContext.BaseDirectory)) + return AppContext.BaseDirectory; + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "filee", "application"); + } + + /// Reads the installed Filee version from the specified installation folder. + internal static string? GetInstalledVersion(string installDir) + { + var dll = Path.Combine(installDir, "Filee.dll"); + if (File.Exists(dll)) + { + try + { + var info = FileVersionInfo.GetVersionInfo(dll); + if (!string.IsNullOrWhiteSpace(info.ProductVersion)) + return info.ProductVersion.Split('+')[0].Trim(); + if (!string.IsNullOrWhiteSpace(info.FileVersion)) + return info.FileVersion.Trim(); + } + catch + { + // Fall through + } + } + + var exe = Path.Combine(installDir, "Filee"); + if (File.Exists(exe)) + { + try + { + var info = FileVersionInfo.GetVersionInfo(exe); + if (!string.IsNullOrWhiteSpace(info.ProductVersion)) + return info.ProductVersion.Split('+')[0].Trim(); + if (!string.IsNullOrWhiteSpace(info.FileVersion)) + return info.FileVersion.Trim(); + } + catch + { + // Fall through + } + } + + var cli = Path.Combine(installDir, "filee-cli"); + if (File.Exists(cli)) + { + try + { + using var proc = Process.Start(new ProcessStartInfo(cli, "version") + { + RedirectStandardOutput = true, + UseShellExecute = false, + }); + if (proc is not null && proc.WaitForExit(3000) && proc.ExitCode == 0) + { + var line = proc.StandardOutput.ReadLine()?.Trim(); + if (line?.StartsWith("filee ", StringComparison.OrdinalIgnoreCase) == true) + return line["filee ".Length..].Trim(); + } + } + catch + { + // Fall through + } + } + + var marker = Path.Combine(installDir, ".filee-version"); + if (File.Exists(marker)) + { + try + { + return File.ReadAllText(marker).Trim(); + } + catch + { + // Fall through + } + } + + return null; + } + + /// Verifies that the version installed in matches . + internal static bool VerifyInstalledVersion(string installDir, string expectedVersion, out string? actualVersion) + { + actualVersion = GetInstalledVersion(installDir); + if (actualVersion is null) + return false; + + if (TryParse(expectedVersion, out var expected) && TryParse(actualVersion, out var actual)) + return expected == actual; + + return string.Equals(actualVersion.Trim(), expectedVersion.Trim(), StringComparison.OrdinalIgnoreCase); + } + + private static string FindLinuxRecoveryInfo(string installDir) + { + var parent = Path.GetDirectoryName(installDir); + if (string.IsNullOrWhiteSpace(parent) || !Directory.Exists(parent)) + return ""; + + try + { + var backups = Directory.GetDirectories(parent, ".backup-*"); + if (backups.Length > 0) + { + var latestBackup = backups.OrderByDescending(Directory.GetCreationTimeUtc).First(); + return $"Previous installation retained at '{latestBackup}'. To recover manually: mv '{latestBackup}' '{installDir}'"; + } + } + catch + { + // Ignore directory enumeration failures + } + + return "No backup directory found."; + } + /// 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.Engines/Office/LibreOfficeConverter.cs b/src/Filee.Engines/Office/LibreOfficeConverter.cs index 68aa84d1e..6ef958ad4 100644 --- a/src/Filee.Engines/Office/LibreOfficeConverter.cs +++ b/src/Filee.Engines/Office/LibreOfficeConverter.cs @@ -17,6 +17,8 @@ // * Its ZIP reader refuses some containers other readers open (Tencent Docs exports, issue #141), saying only "source // file could not be loaded": an Office Open XML / OpenDocument file is tried once more as a repacked copy. +using System.Collections.Concurrent; +using System.Diagnostics; using Filee.Core.Conversion; using Filee.Core.Formats; using Filee.Engines.Infrastructure; @@ -96,11 +98,50 @@ public EngineStatus GetStatus() return _soffice is null ? EngineStatus.Unavailable("engine.reason.not_installed") : !EngineEnvironment.UsesFileesCopy("libreoffice") - ? EngineStatus.Available(_soffice, _hasHwpFilter ? "H2Orestart" : null) + ? EngineStatus.Available(_soffice, VersionOf(_soffice) + (_hasHwpFilter ? " · H2Orestart" : "")) : EngineStatus.Available(_soffice, EngineVersions.Component("libreoffice") + (_hasHwpFilter ? $" · H2Orestart {EngineVersions.Component("h2orestart")}" : "")); } + private static readonly ConcurrentDictionary Versions = new(); + + private static string VersionOf(string soffice) => + Versions.GetOrAdd($"{soffice}|{SafeLastWriteTicks(soffice)}", _ => + { + try + { + using var process = Process.Start(new ProcessStartInfo(soffice, "--version") + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }); + if (process is null) return "LibreOffice"; + if (!process.WaitForExit(3000)) + { + process.Kill(entireProcessTree: true); + return "LibreOffice"; + } + if (process.ExitCode != 0) return "LibreOffice"; + var line = process.StandardOutput.ReadLine()?.Trim(); + if (string.IsNullOrWhiteSpace(line)) return "LibreOffice"; + return line.StartsWith("LibreOffice", StringComparison.OrdinalIgnoreCase) + ? line + : (char.IsDigit(line[0]) || line.StartsWith('v') || line.StartsWith('V') ? $"LibreOffice {line}" : "LibreOffice"); + } + catch + { + return "LibreOffice"; + } + }); + + private static long SafeLastWriteTicks(string path) + { + try { return File.GetLastWriteTimeUtc(path).Ticks; } + catch { return 0; } + } + public async Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) { var soffice = _soffice ?? throw new InvalidOperationException("LibreOffice was not found."); diff --git a/src/Filee.Engines/Vector/GhostscriptConverter.cs b/src/Filee.Engines/Vector/GhostscriptConverter.cs index 06f06f5a2..35c8fde81 100644 --- a/src/Filee.Engines/Vector/GhostscriptConverter.cs +++ b/src/Filee.Engines/Vector/GhostscriptConverter.cs @@ -4,6 +4,8 @@ // SVG → EPS, …) is planned through PDF or PNG by the route planner. "Compress PDF" may also run a PDF through pdfwrite // (PdfSharpConverter calls CompressPdfAsync; there is no PDF → PDF edge here, so routes don't change). +using System.Collections.Concurrent; +using System.Diagnostics; using Filee.Core.Conversion; using Filee.Core.Presets; using Filee.Engines.Infrastructure; @@ -48,10 +50,52 @@ public sealed class GhostscriptConverter : IConverter, IImageTransformer new("ps", "png"), ]; + private static readonly ConcurrentDictionary Versions = new(); + public EngineStatus GetStatus() { _executable = Locate(); - return _executable is null ? EngineStatus.Unavailable("engine.reason.not_installed") : EngineStatus.Available(_executable, EngineEnvironment.UsesFileesCopy("ghostscript") ? EngineVersions.Component("ghostscript") : null); + return _executable is null + ? EngineStatus.Unavailable("engine.reason.not_installed") + : EngineStatus.Available(_executable, EngineEnvironment.UsesFileesCopy("ghostscript") + ? EngineVersions.Component("ghostscript") + : VersionOf(_executable)); + } + + private static string? VersionOf(string gs) => + Versions.GetOrAdd($"{gs}|{SafeLastWriteTicks(gs)}", _ => + { + try + { + using var process = Process.Start(new ProcessStartInfo(gs, "--version") + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }); + if (process is null) return "Ghostscript"; + if (!process.WaitForExit(3000)) + { + process.Kill(entireProcessTree: true); + return "Ghostscript"; + } + if (process.ExitCode != 0) return "Ghostscript"; + var line = process.StandardOutput.ReadLine()?.Trim(); + return !string.IsNullOrWhiteSpace(line) && (char.IsDigit(line[0]) || line.StartsWith('v') || line.StartsWith('V')) + ? $"Ghostscript {line}" + : "Ghostscript"; + } + catch + { + return "Ghostscript"; + } + }); + + private static long SafeLastWriteTicks(string path) + { + try { return File.GetLastWriteTimeUtc(path).Ticks; } + catch { return 0; } } public async Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) diff --git a/tests/Filee.App.Tests/UpdateServiceTests.cs b/tests/Filee.App.Tests/UpdateServiceTests.cs index 137abb56b..d4ccd96b9 100644 --- a/tests/Filee.App.Tests/UpdateServiceTests.cs +++ b/tests/Filee.App.Tests/UpdateServiceTests.cs @@ -1,5 +1,9 @@ +using System.Diagnostics; +using System.Formats.Tar; +using System.IO.Compression; using System.Net; using System.Net.Sockets; +using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -247,7 +251,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; @@ -283,4 +287,510 @@ public void Download_page_is_the_latest_release() Assert.EndsWith("/releases/latest", UpdateService.LatestReleaseUrl); 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_invokes_installer_with_upgrade_and_restart() + { + 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(); + File.WriteAllText(Path.Combine(mockInstallDir, ".filee-version"), "9.9.9"); + + 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); + return Process.Start(new ProcessStartInfo("true") { UseShellExecute = false }); + }; + + await updates.UpdateAsync(); + + Assert.Equal(UpdateStep.Idle, updates.Step); + Assert.True(processes.Count >= 2); + + var installerPsi = processes[0]; + Assert.Equal("/bin/sh", installerPsi.FileName); + Assert.Equal(3, installerPsi.ArgumentList.Count); + Assert.EndsWith("install.sh", installerPsi.ArgumentList[0]); + Assert.Equal("--upgrade", installerPsi.ArgumentList[1]); + Assert.Equal("--restart", installerPsi.ArgumentList[2]); + Assert.Equal("1", installerPsi.Environment["FILEE_IN_APP_UPDATE"]); + Assert.Equal(installerPsi.WorkingDirectory, Path.GetDirectoryName(installerPsi.ArgumentList[0])); + + var restartPsi = processes[1]; + Assert.Equal("/bin/sh", restartPsi.FileName); + Assert.Equal("-c", restartPsi.ArgumentList[0]); + Assert.Contains("kill -0", restartPsi.ArgumentList[1]); + Assert.Equal(Environment.ProcessId.ToString(), restartPsi.ArgumentList[3]); + Assert.Equal(Path.Combine(mockInstallDir, "Filee"), restartPsi.ArgumentList[4]); + Assert.Equal(mockInstallDir, restartPsi.WorkingDirectory); + Assert.False(restartPsi.Environment.ContainsKey("FILEE_IN_APP_UPDATE")); + + Assert.True(shutdownRequested, "Application shutdown must be requested after verified installation"); + } + + [AvaloniaFact] + public async Task UpdateAsync_on_linux_handles_installer_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 backupDir = Path.Combine(Path.GetDirectoryName(mockInstallDir)!, ".backup-123"); + Directory.CreateDirectory(backupDir); + + 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 => + Process.Start(new ProcessStartInfo("false") { UseShellExecute = false }); + + await updates.UpdateAsync(); + + Assert.Equal(UpdateStep.Failed, updates.Step); + Assert.Equal(1, failedCount); + Assert.NotNull(updates.LastError); + Assert.Contains("Installer ended with exit code 1", updates.LastError.Message); + Assert.Contains("mv '" + backupDir + "' '" + mockInstallDir + "'", updates.LastError.Message); + Assert.False(shutdownRequested, "Application shutdown must not be requested when installer fails"); + } + + [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"); + File.CreateSymbolicLink(symlinkPath, realDir); + + 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 tasks = Enumerable.Range(0, 5).Select(_ => Task.Run(() => UpdateService.CreateSecureWorkspace())).ToArray(); + var workspaces = await Task.WhenAll(tasks); + + try + { + Assert.Equal(5, workspaces.Distinct().Count()); + Assert.All(workspaces, w => Assert.True(Directory.Exists(w))); + } + finally + { + foreach (var w in workspaces) + UpdateService.DeleteSecureWorkspace(w); + } + } + + [Fact] + public void VerifyInstalledVersion_detects_mismatch() + { + var folder = TempFolder(); + File.WriteAllText(Path.Combine(folder, ".filee-version"), "1.0.0"); + + Assert.True(UpdateService.VerifyInstalledVersion(folder, "1.0.0", out var actual)); + Assert.Equal("1.0.0", actual); + + Assert.False(UpdateService.VerifyInstalledVersion(folder, "1.0.2", out actual)); + Assert.Equal("1.0.0", actual); + + var emptyFolder = TempFolder(); + Assert.False(UpdateService.VerifyInstalledVersion(emptyFolder, "1.0.0", out actual)); + Assert.Null(actual); + } + + [AvaloniaFact] + public async Task UpdateAsync_on_linux_fails_when_installed_version_does_not_match() + { + 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(); + File.WriteAllText(Path.Combine(mockInstallDir, ".filee-version"), "1.0.0"); // Mismatch: expecting 9.9.9 + + 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 => + Process.Start(new ProcessStartInfo("true") { UseShellExecute = false }); + + await updates.UpdateAsync(); + + Assert.Equal(UpdateStep.Failed, updates.Step); + Assert.Equal(1, failedCount); + Assert.NotNull(updates.LastError); + Assert.Contains("Update verification failed", updates.LastError.Message); + Assert.False(shutdownRequested, "Application shutdown must not be requested when version verification fails"); + } + + 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)); + } + } } diff --git a/tests/Filee.Platform.Tests/LinuxInstallerTests.cs b/tests/Filee.Platform.Tests/LinuxInstallerTests.cs new file mode 100644 index 000000000..5d49b342b --- /dev/null +++ b/tests/Filee.Platform.Tests/LinuxInstallerTests.cs @@ -0,0 +1,734 @@ +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; + + 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"); + } + + [Fact] + public void First_time_installation_succeeds_in_isolated_environment() + { + if (!OperatingSystem.IsLinux()) + return; + + 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() + { + if (!OperatingSystem.IsLinux()) + return; + + 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() + { + if (!OperatingSystem.IsLinux()) + return; + + 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() + { + if (!OperatingSystem.IsLinux()) + return; + + 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() + { + if (!OperatingSystem.IsLinux()) + return; + + 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() + { + if (!OperatingSystem.IsLinux()) + return; + + 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() + { + if (!OperatingSystem.IsLinux()) + return; + + 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() + { + if (!OperatingSystem.IsLinux()) + return; + + 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: _FILEE_TEST_FAIL_ACTIVATION=1 + var (exitCode, output, error) = RunInstaller(v2Source, env.DataDir, env.HomeDir, + new Dictionary { ["_FILEE_TEST_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() + { + if (!OperatingSystem.IsLinux()) + return; + + 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: _FILEE_TEST_FAIL_POST_VALIDATION=1 + var (exitCode, output, error) = RunInstaller(v2Source, env.DataDir, env.HomeDir, + new Dictionary { ["_FILEE_TEST_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() + { + if (!OperatingSystem.IsLinux()) + return; + + 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: _FILEE_TEST_FAIL_ROLLBACK_OCCUPIED=1 + var (exitCode, output, error) = RunInstaller(v2Source, env.DataDir, env.HomeDir, + new Dictionary { ["_FILEE_TEST_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() + { + if (!OperatingSystem.IsLinux()) + return; + + 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: _FILEE_TEST_FAIL_ACTIVATION_OCCUPIED=1 + var (exitCode, output, error) = RunInstaller(v2Source, env.DataDir, env.HomeDir, + new Dictionary { ["_FILEE_TEST_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() + { + if (!OperatingSystem.IsLinux()) + return; + + 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: _FILEE_TEST_FAIL_ROLLBACK_ABSENT=1 + var (exitCode, output, error) = RunInstaller(v2Source, env.DataDir, env.HomeDir, + new Dictionary { ["_FILEE_TEST_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() + { + if (!OperatingSystem.IsLinux()) + return; + + 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: _FILEE_TEST_FAIL_QUARANTINE=1 + var (exitCode, output, error) = RunInstaller(v2Source, env.DataDir, env.HomeDir, + new Dictionary { ["_FILEE_TEST_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() + { + if (!OperatingSystem.IsLinux()) + return; + + 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: _FILEE_TEST_FAIL_CLEANUP_FAILED_DIR=1 + var (exitCode, output, error) = RunInstaller(v2Source, env.DataDir, env.HomeDir, + new Dictionary { ["_FILEE_TEST_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() + { + if (!OperatingSystem.IsLinux()) + return; + + 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() + { + if (!OperatingSystem.IsLinux()) + return; + + 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: _FILEE_TEST_FAIL_FOREIGN_DESTINATION=1 + var (exitCode, output, error) = RunInstaller(v2Source, env.DataDir, env.HomeDir, + new Dictionary { ["_FILEE_TEST_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_hooks_are_ignored_in_normal_environment_even_if_environment_variable_is_set() + { + if (!OperatingSystem.IsLinux()) + return; + + 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"); + + // Set failure hook variable, but do NOT pass _FILEE_TEST_HARNESS + var (exitCode, output, error) = RunInstaller(v2Source, env.DataDir, env.HomeDir, + new Dictionary { ["_FILEE_TEST_FAIL_ACTIVATION"] = "1" }, passTestHarness: false, "--upgrade"); + + // The upgrade MUST succeed because test hooks are locked down and ignored without harness token + 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()); + + // Second check: set failure hook variable with an INVALID test harness token + var (exitCode2, output2, error2) = RunInstaller(v2Source, env.DataDir, env.HomeDir, + new Dictionary { ["_FILEE_TEST_FAIL_ACTIVATION"] = "1", ["_FILEE_TEST_HARNESS"] = "bogus-token" }, passTestHarness: false, "--upgrade"); + + Assert.True(exitCode2 == 0, $"Upgrade failed unexpectedly with bogus token. Output: {output2}. Error: {error2}"); + Assert.Contains("Upgraded Filee at", output2); + } + + [Fact] + public void Installation_and_upgrade_succeed_with_spaces_and_special_characters_in_paths() + { + if (!OperatingSystem.IsLinux()) + return; + + 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()); + } + + 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); + } + + [Fact] + public void Upgrade_with_in_app_update_env_var_skips_Filee_quit_and_restart() + { + if (!OperatingSystem.IsLinux()) + return; + + 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"); + + // 1. Without FILEE_IN_APP_UPDATE, 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); + + // 2. With FILEE_IN_APP_UPDATE=1, upgrade skips Filee --quit and succeeds + var inAppEnv = new Dictionary { ["FILEE_IN_APP_UPDATE"] = "1" }; + var (succCode, succOut, succErr) = RunInstaller(v2Dir, env.DataDir, env.HomeDir, inAppEnv, "--upgrade", "--restart"); + Assert.True(succCode == 0, $"Expected upgrade to succeed. Output: {succOut}. Error: {succErr}"); + Assert.Equal("v2.0.0", File.ReadAllText(Path.Combine(installDir, "version.txt")).Trim()); + } + + 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 static (int ExitCode, string Output, string Error) RunInstaller( + string sourceDir, string dataDir, string homeDir, params string[] args) => + RunInstaller(sourceDir, dataDir, homeDir, null, passTestHarness: true, args); + + private static (int ExitCode, string Output, string Error) RunInstaller( + string sourceDir, string dataDir, string homeDir, IDictionary? extraEnv, params string[] args) => + RunInstaller(sourceDir, dataDir, homeDir, extraEnv, passTestHarness: true, args); + + private static (int ExitCode, string Output, string Error) RunInstaller( + string sourceDir, string dataDir, string homeDir, IDictionary? extraEnv, bool passTestHarness, 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"] = Environment.GetEnvironmentVariable("PATH") ?? "/bin:/usr/bin"; + if (passTestHarness) + psi.Environment["_FILEE_TEST_HARNESS"] = "com.filee.test.harness"; + + 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 + { + // Reset write permissions on any created conflict folders so Directory.Delete succeeds + foreach (var dir in Directory.GetDirectories(_root, "*", SearchOption.AllDirectories)) + { + try { File.SetUnixFileMode(dir, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); } catch { } + } + if (Directory.Exists(_root)) + Directory.Delete(_root, recursive: true); + } + catch + { + // Ignore temporary cleanup errors + } + } +} From cf388bdec14388cc646a66d0484936fc81ef2cf5 Mon Sep 17 00:00:00 2001 From: Alenbosh Date: Thu, 22 Oct 2026 12:48:16 +0530 Subject: [PATCH 2/7] chore: separate LibreOffice and Ghostscript version probing for dedicated PR --- .../Office/LibreOfficeConverter.cs | 43 +---------------- .../Vector/GhostscriptConverter.cs | 46 +------------------ 2 files changed, 2 insertions(+), 87 deletions(-) diff --git a/src/Filee.Engines/Office/LibreOfficeConverter.cs b/src/Filee.Engines/Office/LibreOfficeConverter.cs index 6ef958ad4..68aa84d1e 100644 --- a/src/Filee.Engines/Office/LibreOfficeConverter.cs +++ b/src/Filee.Engines/Office/LibreOfficeConverter.cs @@ -17,8 +17,6 @@ // * Its ZIP reader refuses some containers other readers open (Tencent Docs exports, issue #141), saying only "source // file could not be loaded": an Office Open XML / OpenDocument file is tried once more as a repacked copy. -using System.Collections.Concurrent; -using System.Diagnostics; using Filee.Core.Conversion; using Filee.Core.Formats; using Filee.Engines.Infrastructure; @@ -98,50 +96,11 @@ public EngineStatus GetStatus() return _soffice is null ? EngineStatus.Unavailable("engine.reason.not_installed") : !EngineEnvironment.UsesFileesCopy("libreoffice") - ? EngineStatus.Available(_soffice, VersionOf(_soffice) + (_hasHwpFilter ? " · H2Orestart" : "")) + ? EngineStatus.Available(_soffice, _hasHwpFilter ? "H2Orestart" : null) : EngineStatus.Available(_soffice, EngineVersions.Component("libreoffice") + (_hasHwpFilter ? $" · H2Orestart {EngineVersions.Component("h2orestart")}" : "")); } - private static readonly ConcurrentDictionary Versions = new(); - - private static string VersionOf(string soffice) => - Versions.GetOrAdd($"{soffice}|{SafeLastWriteTicks(soffice)}", _ => - { - try - { - using var process = Process.Start(new ProcessStartInfo(soffice, "--version") - { - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - }); - if (process is null) return "LibreOffice"; - if (!process.WaitForExit(3000)) - { - process.Kill(entireProcessTree: true); - return "LibreOffice"; - } - if (process.ExitCode != 0) return "LibreOffice"; - var line = process.StandardOutput.ReadLine()?.Trim(); - if (string.IsNullOrWhiteSpace(line)) return "LibreOffice"; - return line.StartsWith("LibreOffice", StringComparison.OrdinalIgnoreCase) - ? line - : (char.IsDigit(line[0]) || line.StartsWith('v') || line.StartsWith('V') ? $"LibreOffice {line}" : "LibreOffice"); - } - catch - { - return "LibreOffice"; - } - }); - - private static long SafeLastWriteTicks(string path) - { - try { return File.GetLastWriteTimeUtc(path).Ticks; } - catch { return 0; } - } - public async Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) { var soffice = _soffice ?? throw new InvalidOperationException("LibreOffice was not found."); diff --git a/src/Filee.Engines/Vector/GhostscriptConverter.cs b/src/Filee.Engines/Vector/GhostscriptConverter.cs index 35c8fde81..06f06f5a2 100644 --- a/src/Filee.Engines/Vector/GhostscriptConverter.cs +++ b/src/Filee.Engines/Vector/GhostscriptConverter.cs @@ -4,8 +4,6 @@ // SVG → EPS, …) is planned through PDF or PNG by the route planner. "Compress PDF" may also run a PDF through pdfwrite // (PdfSharpConverter calls CompressPdfAsync; there is no PDF → PDF edge here, so routes don't change). -using System.Collections.Concurrent; -using System.Diagnostics; using Filee.Core.Conversion; using Filee.Core.Presets; using Filee.Engines.Infrastructure; @@ -50,52 +48,10 @@ public sealed class GhostscriptConverter : IConverter, IImageTransformer new("ps", "png"), ]; - private static readonly ConcurrentDictionary Versions = new(); - public EngineStatus GetStatus() { _executable = Locate(); - return _executable is null - ? EngineStatus.Unavailable("engine.reason.not_installed") - : EngineStatus.Available(_executable, EngineEnvironment.UsesFileesCopy("ghostscript") - ? EngineVersions.Component("ghostscript") - : VersionOf(_executable)); - } - - private static string? VersionOf(string gs) => - Versions.GetOrAdd($"{gs}|{SafeLastWriteTicks(gs)}", _ => - { - try - { - using var process = Process.Start(new ProcessStartInfo(gs, "--version") - { - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - }); - if (process is null) return "Ghostscript"; - if (!process.WaitForExit(3000)) - { - process.Kill(entireProcessTree: true); - return "Ghostscript"; - } - if (process.ExitCode != 0) return "Ghostscript"; - var line = process.StandardOutput.ReadLine()?.Trim(); - return !string.IsNullOrWhiteSpace(line) && (char.IsDigit(line[0]) || line.StartsWith('v') || line.StartsWith('V')) - ? $"Ghostscript {line}" - : "Ghostscript"; - } - catch - { - return "Ghostscript"; - } - }); - - private static long SafeLastWriteTicks(string path) - { - try { return File.GetLastWriteTimeUtc(path).Ticks; } - catch { return 0; } + return _executable is null ? EngineStatus.Unavailable("engine.reason.not_installed") : EngineStatus.Available(_executable, EngineEnvironment.UsesFileesCopy("ghostscript") ? EngineVersions.Component("ghostscript") : null); } public async Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) From c6e9d7e3cd5a9a397339b6b2b2a7402145b1a0d3 Mon Sep 17 00:00:00 2001 From: Alenbosh Date: Thu, 22 Oct 2026 13:39:06 +0530 Subject: [PATCH 3/7] fix(updater): address PR #360 review feedback for Linux updater and installer - Start detached helper to wait for old Filee PID to exit before running install.sh --upgrade - Launch new Filee instance with --background --whats-new upon successful update - Reuse App.Quit() for graceful application shutdown before file swap - Remove FILEE_TEST_* fault-injection hooks from shipped install.sh - Implement fault injection in LinuxInstallerTests via external mv shim on PATH - Remove --restart option, FILEE_INSTALL_DIR override, and .filee-version fallback - Preserve Windows update directory %TEMP%\Filee-update and download reuse - Securely stage Linux update archives in XDG_DATA_HOME/filee/.update-* (0700) - Mask extracted tar entry permissions to RwxMask (0x1FF) stripping setuid/setgid/sticky - Fix FindLinuxRecoveryInfo trailing path separator handling - Redirect and append install.sh stdout and stderr to Filee daily log - Update LinuxInstallerTests to use Assert.SkipUnless(OperatingSystem.IsLinux(), ...) - Document Linux installer and update lifecycle in docs/ARCHITECTURE.md --- docs/ARCHITECTURE.md | 25 ++ installer/linux/install.sh | 90 +----- src/Filee.App/App.axaml.cs | 3 +- src/Filee.App/Services/UpdateService.cs | 285 ++++++++---------- tests/Filee.App.Tests/UpdateServiceTests.cs | 125 ++++---- .../LinuxInstallerTests.cs | 279 +++++++++++------ 6 files changed, 410 insertions(+), 397 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4a7c9e40f..1adb830c4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -349,3 +349,28 @@ 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.sh b/installer/linux/install.sh index f23bca4ca..cdfefacf8 100755 --- a/installer/linux/install.sh +++ b/installer/linux/install.sh @@ -6,23 +6,19 @@ set -eu # Preserves downloaded conversion engines (~/.local/share/Filee/engines) and user settings (~/.config/Filee). upgrade=0 -restart=0 for arg do case "$arg" in --upgrade|-u) upgrade=1 ;; - --restart) - restart=1 - ;; --help|-h) - printf '%s\n' "Usage: $0 [--upgrade|-u] [--restart]" + printf '%s\n' "Usage: $0 [--upgrade|-u]" exit 0 ;; *) printf '%s\n' "Unknown option: $arg" >&2 - printf '%s\n' "Usage: $0 [--upgrade|-u] [--restart]" >&2 + printf '%s\n' "Usage: $0 [--upgrade|-u]" >&2 exit 1 ;; esac @@ -40,16 +36,6 @@ command -v python3 >/dev/null 2>&1 || { exit 1 } -# Only enable internal test failure hooks if explicitly requested by the test harness -# AND executing within an isolated temporary test directory. -test_hooks_enabled=0 -if [ "${_FILEE_TEST_HARNESS:-}" = "com.filee.test.harness" ]; then - case "$data_dir" in - /tmp/*|/var/tmp/*|*/filee-installer-test-*) - test_hooks_enabled=1 - ;; - esac -fi 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 @@ -100,13 +86,11 @@ else exit 1 fi - # Tell running instance to save and exit cleanly before touching files (skipped during in-app updates). - if [ "${FILEE_IN_APP_UPDATE:-0}" != "1" ]; then - 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 + # 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 @@ -215,68 +199,12 @@ else # Move active installation to backup. mv "$install_dir" "$backup_dir" - # Test hook: simulate failure after backup but before activation. - if [ "$test_hooks_enabled" -eq 1 ] && [ "${_FILEE_TEST_FAIL_ACTIVATION:-0}" = "1" ]; then - rm -rf "$staging_dir" - fi - - # Test hook: simulate activation destination conflict. - if [ "$test_hooks_enabled" -eq 1 ] && [ "${_FILEE_TEST_FAIL_ACTIVATION_OCCUPIED:-0}" = "1" ]; then - mkdir -p "$install_dir" - touch "$install_dir/conflict" - chmod 555 "$install_dir" - fi - # Activate new installation from staging. if ! mv "$staging_dir" "$install_dir"; then exit 1 fi activated=1 - # Test hook: simulate failure during post-swap validation. - if [ "$test_hooks_enabled" -eq 1 ] && [ "${_FILEE_TEST_FAIL_POST_VALIDATION:-0}" = "1" ]; then - rm -f "$install_dir/.filee-package" - fi - - # Test hook: simulate failure during post-swap validation where failed_dir cleanup will fail. - if [ "$test_hooks_enabled" -eq 1 ] && [ "${_FILEE_TEST_FAIL_CLEANUP_FAILED_DIR:-0}" = "1" ]; then - mkdir -p "$install_dir/locked-folder" - touch "$install_dir/locked-folder/file" - chmod 555 "$install_dir/locked-folder" - rm -f "$install_dir/.filee-package" - fi - - # Test hook: simulate quarantine move failure during rollback. - if [ "$test_hooks_enabled" -eq 1 ] && [ "${_FILEE_TEST_FAIL_QUARANTINE:-0}" = "1" ]; then - mkdir -p "$failed_dir" - touch "$failed_dir/conflict" - exit 1 - fi - - # Test hook: simulate replacement of install_dir by a foreign directory before rollback. - if [ "$test_hooks_enabled" -eq 1 ] && [ "${_FILEE_TEST_FAIL_FOREIGN_DESTINATION:-0}" = "1" ]; then - rm -rf "$install_dir" - mkdir -p "$install_dir" - echo "important-foreign-data" > "$install_dir/important.txt" - exit 1 - fi - - # Test hook: simulate failure during rollback with occupied destination. - if [ "$test_hooks_enabled" -eq 1 ] && { [ "${_FILEE_TEST_FAIL_ROLLBACK_OCCUPIED:-0}" = "1" ] || [ "${_FILEE_TEST_FAIL_ROLLBACK:-0}" = "1" ]; }; then - rm -rf "$install_dir" - mkdir -p "$install_dir" - touch "$install_dir/conflict" - chmod 555 "$install_dir" - exit 1 - fi - - # Test hook: simulate failure during rollback with absent destination. - if [ "$test_hooks_enabled" -eq 1 ] && [ "${_FILEE_TEST_FAIL_ROLLBACK_ABSENT:-0}" = "1" ]; then - rm -rf "$install_dir" - chmod 555 "$data_dir/filee" - exit 1 - fi - # 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 @@ -321,10 +249,6 @@ fi if [ "$upgrade" -eq 1 ]; then printf '%s\n' "Upgraded Filee at $install_dir." - if [ "$restart" -eq 1 ] && [ "${FILEE_IN_APP_UPDATE:-0}" != "1" ]; then - nohup "$install_dir/Filee" /dev/null 2>&1 & - printf '%s\n' "Restarted Filee." - fi else printf '%s\n' "Installed Filee at $install_dir" "CLI: $bin_dir/filee (add $bin_dir to PATH if needed)." fi diff --git a/src/Filee.App/App.axaml.cs b/src/Filee.App/App.axaml.cs index 51e671614..718193803 100644 --- a/src/Filee.App/App.axaml.cs +++ b/src/Filee.App/App.axaml.cs @@ -200,8 +200,7 @@ private void ListenForLaterLaunches(SingleInstance instance, ILogger log) private static void WatchForUpdates(UserDataStore store) { var updates = AppHost.Get(); - updates.RequestApplicationShutdown = () => - (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.Shutdown(); + 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) => diff --git a/src/Filee.App/Services/UpdateService.cs b/src/Filee.App/Services/UpdateService.cs index 35fe47470..834dd562f 100644 --- a/src/Filee.App/Services/UpdateService.cs +++ b/src/Filee.App/Services/UpdateService.cs @@ -14,6 +14,7 @@ using System.Text.Json; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; +using Filee.Core.Settings; using Microsoft.Extensions.Logging; namespace Filee.App.Services; @@ -325,154 +326,131 @@ public async Task UpdateAsync() return; } - string installer; - string? workspace = null; - 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) + if (TargetPlatform == "win") { - DeleteSecureWorkspace(workspace); - 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); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or IOException + or InvalidDataException or UnauthorizedAccessException) + { + Fail("download", ex); + return; + } - Process? setup; - try - { - if (TargetPlatform == "win") + Process? setup; + try { setup = StartProcess(new ProcessStartInfo(installer, InstallerArguments) { UseShellExecute = true }); } - else if (TargetPlatform.StartsWith("linux-", StringComparison.OrdinalIgnoreCase)) + catch (Win32Exception ex) when (ex.NativeErrorCode == ErrorCancelled) { - 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 startInfo = new ProcessStartInfo("/bin/sh") - { - WorkingDirectory = payloadDir, - UseShellExecute = false, - }; - startInfo.ArgumentList.Add(scriptPath); - startInfo.ArgumentList.Add("--upgrade"); - startInfo.ArgumentList.Add("--restart"); - startInfo.Environment["FILEE_IN_APP_UPDATE"] = "1"; - setup = StartProcess(startInfo); + _log.LogInformation("Update cancelled at the administrator prompt"); + Step = UpdateStep.Cancelled; + return; } - else + catch (Exception ex) when (ex is Win32Exception or InvalidOperationException) { - throw new PlatformNotSupportedException("In-app update is only supported on Windows and Linux"); + Fail("start", ex); + return; } - } - catch (Win32Exception ex) when (TargetPlatform == "win" && ex.NativeErrorCode == ErrorCancelled) - { - _log.LogInformation("Update cancelled at the administrator prompt"); - Step = UpdateStep.Cancelled; - DeleteSecureWorkspace(workspace); - return; - } - catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or PlatformNotSupportedException) - { - DeleteSecureWorkspace(workspace); - Fail("start", ex); - return; - } - _log.LogInformation("Started the installer for {Version}", LatestVersion); - Step = UpdateStep.Installing; - if (setup is null) + _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; + } - // 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, or an error occurred. - using (setup) + if (TargetPlatform.StartsWith("linux-", StringComparison.OrdinalIgnoreCase)) { - await setup.WaitForExitAsync(); - _log.LogInformation("Installer ended with exit code {Code}", setup.ExitCode); - if (setup.ExitCode == 0) + 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) { - if (TargetPlatform.StartsWith("linux-", StringComparison.OrdinalIgnoreCase)) - { - var targetInstallDir = LinuxInstallDirectoryOverride ?? GetLinuxInstallDirectory(); - var expectedVersion = LatestVersion; - if (string.IsNullOrWhiteSpace(expectedVersion)) - { - var fileName = Path.GetFileName(installer); - var parts = fileName.Split('-'); - if (parts.Length >= 2 && TryParse(parts[1], out var parsedVer)) - expectedVersion = parsedVer.ToString(3); - expectedVersion ??= ""; - } - if (!VerifyInstalledVersion(targetInstallDir, expectedVersion, out var actualVersion)) - { - var recovery = FindLinuxRecoveryInfo(targetInstallDir); - var message = $"Update verification failed: installed version '{actualVersion ?? "unknown"}' does not match expected version '{expectedVersion}'. {recovery}"; - _log.LogError("{Message}", message); - DeleteSecureWorkspace(workspace); - Fail("verify", new InvalidOperationException(message)); - return; - } - // Success on Linux: spawn detached restart watcher and shut down current instance. - try - { - var newExe = Path.Combine(targetInstallDir, "Filee"); - var currentPid = Environment.ProcessId; - var restartWatcher = new ProcessStartInfo("/bin/sh") - { - WorkingDirectory = targetInstallDir, - UseShellExecute = false, - }; - restartWatcher.ArgumentList.Add("-c"); - restartWatcher.ArgumentList.Add("for _ in $(seq 1 600); do if ! kill -0 \"$1\" 2>/dev/null; then exec \"$2\"; fi; sleep 0.1; done; exit 1"); - restartWatcher.ArgumentList.Add("_"); - restartWatcher.ArgumentList.Add(currentPid.ToString()); - restartWatcher.ArgumentList.Add(newExe); - restartWatcher.Environment.Remove("FILEE_IN_APP_UPDATE"); - StartProcess(restartWatcher); - } - catch (Exception ex) - { - _log.LogWarning(ex, "Failed to launch restart watcher for {Version}", expectedVersion); - } - RequestApplicationShutdown?.Invoke(); - } - Step = UpdateStep.Idle; DeleteSecureWorkspace(workspace); + Fail("download", ex); + return; + } + + var stagingDir = Path.Combine(workspace, "payload"); + string payloadDir; + try + { + Directory.CreateDirectory(stagingDir); + payloadDir = ExtractLinuxArchive(installer, stagingDir); } - else if (TargetPlatform == "win") + catch (Exception ex) when (ex is InvalidDataException or IOException or UnauthorizedAccessException) { - Step = UpdateStep.Cancelled; DeleteSecureWorkspace(workspace); + Fail("extract", ex); + return; } - else + + 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"); + + 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\"; exit 1; fi; " + + "\"$2\" --upgrade >> \"$4\" 2>&1; " + + "status=$?; " + + "rm -rf \"$5\" 2>/dev/null; " + + "if [ $status -eq 0 ]; then exec \"$3\" --background --whats-new; else echo \"Update failed with status $status\" >> \"$4\"; exit $status; 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); + + helper = StartProcess(startInfo); + } + catch (Exception ex) when (ex is Win32Exception or InvalidOperationException) { - var targetInstallDir = LinuxInstallDirectoryOverride ?? GetLinuxInstallDirectory(); - var recovery = FindLinuxRecoveryInfo(targetInstallDir); - var message = string.IsNullOrEmpty(recovery) - ? $"Installer ended with exit code {setup.ExitCode}" - : $"Installer ended with exit code {setup.ExitCode}. {recovery}"; - _log.LogError("{Message}", message); DeleteSecureWorkspace(workspace); - Fail("install", new InvalidOperationException(message)); + Fail("start", ex); + return; } + + _log.LogInformation("Started update helper for {Version}, exiting current process", LatestVersion); + Step = UpdateStep.Installing; + RequestApplicationShutdown?.Invoke(); + return; } + + throw new PlatformNotSupportedException("In-app update is only supported on Windows and Linux"); } /// @@ -633,6 +611,11 @@ internal static (ReleaseFile? Installer, ReleaseFile? Checksums) FindFiles(JsonE 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). @@ -704,8 +687,7 @@ internal static string ExtractLinuxArchive(string archivePath, string destinatio if (OperatingSystem.IsLinux()) { - var mode = entry.Mode; - mode |= UnixFileMode.UserRead | UnixFileMode.UserWrite; + var mode = (entry.Mode & RwxMask) | UnixFileMode.UserRead | UnixFileMode.UserWrite; File.SetUnixFileMode(destinationPath, mode); } break; @@ -860,8 +842,7 @@ private static void EnsureExecutable(string path) { if (OperatingSystem.IsLinux() && File.Exists(path)) { - var mode = File.GetUnixFileMode(path); - mode |= UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; + var mode = (File.GetUnixFileMode(path) & RwxMask) | UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; File.SetUnixFileMode(path, mode); } } @@ -907,19 +888,26 @@ internal static string CreateSecureWorkspace(string? baseDir = null) { if (string.IsNullOrWhiteSpace(baseDir)) { - baseDir = Path.GetTempPath(); if (OperatingSystem.IsLinux()) { - var runtimeDir = Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR"); - if (!string.IsNullOrWhiteSpace(runtimeDir) && Directory.Exists(runtimeDir)) - baseDir = runtimeDir; + var dataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); + var root = !string.IsNullOrWhiteSpace(dataHome) + ? dataHome + : Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + baseDir = Path.Combine(root, "filee"); + } + else + { + baseDir = Path.GetTempPath(); } } + Directory.CreateDirectory(baseDir); + for (var attempt = 0; attempt < 100; attempt++) { var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(); - var candidate = Path.Combine(baseDir, $"filee-update-{Environment.ProcessId}-{token}"); + var candidate = Path.Combine(baseDir, $".update-{Environment.ProcessId}-{token}"); if (Path.Exists(candidate)) continue; @@ -968,12 +956,13 @@ internal static void DeleteSecureWorkspace(string? workspacePath) /// Locates the installation directory on Linux. internal static string GetLinuxInstallDirectory() { - var overrideDir = Environment.GetEnvironmentVariable("FILEE_INSTALL_DIR"); - if (!string.IsNullOrWhiteSpace(overrideDir)) - return overrideDir; if (IsLinuxInstalled(AppContext.BaseDirectory)) return AppContext.BaseDirectory; - return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "filee", "application"); + var dataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); + var root = !string.IsNullOrWhiteSpace(dataHome) + ? dataHome + : Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + return Path.Combine(root, "filee", "application"); } /// Reads the installed Filee version from the specified installation folder. @@ -1036,19 +1025,6 @@ internal static string GetLinuxInstallDirectory() } } - var marker = Path.Combine(installDir, ".filee-version"); - if (File.Exists(marker)) - { - try - { - return File.ReadAllText(marker).Trim(); - } - catch - { - // Fall through - } - } - return null; } @@ -1065,9 +1041,10 @@ internal static bool VerifyInstalledVersion(string installDir, string expectedVe return string.Equals(actualVersion.Trim(), expectedVersion.Trim(), StringComparison.OrdinalIgnoreCase); } - private static string FindLinuxRecoveryInfo(string installDir) + internal static string FindLinuxRecoveryInfo(string installDir) { - var parent = Path.GetDirectoryName(installDir); + var cleanInstallDir = Path.TrimEndingDirectorySeparator(installDir); + var parent = Path.GetDirectoryName(cleanInstallDir); if (string.IsNullOrWhiteSpace(parent) || !Directory.Exists(parent)) return ""; @@ -1077,7 +1054,7 @@ private static string FindLinuxRecoveryInfo(string installDir) if (backups.Length > 0) { var latestBackup = backups.OrderByDescending(Directory.GetCreationTimeUtc).First(); - return $"Previous installation retained at '{latestBackup}'. To recover manually: mv '{latestBackup}' '{installDir}'"; + return $"Previous installation retained at '{latestBackup}'. To recover manually: mv '{latestBackup}' '{cleanInstallDir}'"; } } catch diff --git a/tests/Filee.App.Tests/UpdateServiceTests.cs b/tests/Filee.App.Tests/UpdateServiceTests.cs index d4ccd96b9..1f4db3262 100644 --- a/tests/Filee.App.Tests/UpdateServiceTests.cs +++ b/tests/Filee.App.Tests/UpdateServiceTests.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.Diagnostics; using System.Formats.Tar; using System.IO.Compression; @@ -456,7 +457,7 @@ public void ExtractLinuxArchive_rejects_missing_payload_files() } [AvaloniaFact] - public async Task UpdateAsync_on_linux_downloads_extracts_and_invokes_installer_with_upgrade_and_restart() + public async Task UpdateAsync_on_linux_downloads_extracts_and_delegates_to_detached_helper() { var tarBytes = CreateTarGz(writer => { @@ -468,7 +469,6 @@ public async Task UpdateAsync_on_linux_downloads_extracts_and_invokes_installer_ var network = new FakeLinuxRelease(tarBytes, hash, "Filee-9.9.9-linux-x64.tar.gz", "9.9.9"); var mockInstallDir = TempFolder(); - File.WriteAllText(Path.Combine(mockInstallDir, ".filee-version"), "9.9.9"); var updates = new UpdateService(NullLogger.Instance, network) { @@ -491,32 +491,25 @@ public async Task UpdateAsync_on_linux_downloads_extracts_and_invokes_installer_ await updates.UpdateAsync(); - Assert.Equal(UpdateStep.Idle, updates.Step); - Assert.True(processes.Count >= 2); + Assert.Equal(UpdateStep.Installing, updates.Step); + Assert.Single(processes); - var installerPsi = processes[0]; - Assert.Equal("/bin/sh", installerPsi.FileName); - Assert.Equal(3, installerPsi.ArgumentList.Count); - Assert.EndsWith("install.sh", installerPsi.ArgumentList[0]); - Assert.Equal("--upgrade", installerPsi.ArgumentList[1]); - Assert.Equal("--restart", installerPsi.ArgumentList[2]); - Assert.Equal("1", installerPsi.Environment["FILEE_IN_APP_UPDATE"]); - Assert.Equal(installerPsi.WorkingDirectory, Path.GetDirectoryName(installerPsi.ArgumentList[0])); + 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.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]); - var restartPsi = processes[1]; - Assert.Equal("/bin/sh", restartPsi.FileName); - Assert.Equal("-c", restartPsi.ArgumentList[0]); - Assert.Contains("kill -0", restartPsi.ArgumentList[1]); - Assert.Equal(Environment.ProcessId.ToString(), restartPsi.ArgumentList[3]); - Assert.Equal(Path.Combine(mockInstallDir, "Filee"), restartPsi.ArgumentList[4]); - Assert.Equal(mockInstallDir, restartPsi.WorkingDirectory); - Assert.False(restartPsi.Environment.ContainsKey("FILEE_IN_APP_UPDATE")); - - Assert.True(shutdownRequested, "Application shutdown must be requested after verified installation"); + Assert.True(shutdownRequested, "Application shutdown must be requested before file swap"); } [AvaloniaFact] - public async Task UpdateAsync_on_linux_handles_installer_failure() + public async Task UpdateAsync_on_linux_handles_helper_start_failure() { var tarBytes = CreateTarGz(writer => { @@ -528,8 +521,6 @@ public async Task UpdateAsync_on_linux_handles_installer_failure() var network = new FakeLinuxRelease(tarBytes, hash, "Filee-9.9.9-linux-x64.tar.gz", "9.9.9"); var mockInstallDir = TempFolder(); - var backupDir = Path.Combine(Path.GetDirectoryName(mockInstallDir)!, ".backup-123"); - Directory.CreateDirectory(backupDir); var updates = new UpdateService(NullLogger.Instance, network) { @@ -548,16 +539,15 @@ public async Task UpdateAsync_on_linux_handles_installer_failure() updates.RequestApplicationShutdown = () => shutdownRequested = true; updates.StartProcess = psi => - Process.Start(new ProcessStartInfo("false") { UseShellExecute = false }); + 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("Installer ended with exit code 1", updates.LastError.Message); - Assert.Contains("mv '" + backupDir + "' '" + mockInstallDir + "'", updates.LastError.Message); - Assert.False(shutdownRequested, "Application shutdown must not be requested when installer fails"); + Assert.Contains("Cannot spawn helper process", updates.LastError.Message); + Assert.False(shutdownRequested, "Application shutdown must not be requested when helper fails to start"); } [Fact] @@ -637,7 +627,14 @@ public void DeleteSecureWorkspace_refuses_to_follow_symlink() var linkContainer = TempFolder(); var symlinkPath = Path.Combine(linkContainer, "link-to-real"); - File.CreateSymbolicLink(symlinkPath, realDir); + 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); @@ -667,7 +664,10 @@ public async Task Concurrent_secure_workspaces_are_isolated() public void VerifyInstalledVersion_detects_mismatch() { var folder = TempFolder(); - File.WriteAllText(Path.Combine(folder, ".filee-version"), "1.0.0"); + var cli = Path.Combine(folder, "filee-cli"); + File.WriteAllText(cli, "#!/bin/sh\necho \"filee 1.0.0\"\n"); + if (OperatingSystem.IsLinux()) + File.SetUnixFileMode(cli, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); Assert.True(UpdateService.VerifyInstalledVersion(folder, "1.0.0", out var actual)); Assert.Equal("1.0.0", actual); @@ -680,47 +680,52 @@ public void VerifyInstalledVersion_detects_mismatch() Assert.Null(actual); } - [AvaloniaFact] - public async Task UpdateAsync_on_linux_fails_when_installed_version_does_not_match() + [Fact] + public void Tar_extraction_masks_mode_to_rwx_bits() { 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 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 hash = Sha256(tarBytes); - var network = new FakeLinuxRelease(tarBytes, hash, "Filee-9.9.9-linux-x64.tar.gz", "9.9.9"); + var archiveFile = Path.Combine(TempFolder(), "payload.tar.gz"); + File.WriteAllBytes(archiveFile, tarBytes); - var mockInstallDir = TempFolder(); - File.WriteAllText(Path.Combine(mockInstallDir, ".filee-version"), "1.0.0"); // Mismatch: expecting 9.9.9 + var destFolder = TempFolder(); + var payloadDir = UpdateService.ExtractLinuxArchive(archiveFile, destFolder); - var updates = new UpdateService(NullLogger.Instance, network) + if (OperatingSystem.IsLinux()) { - 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 fileeMode = File.GetUnixFileMode(Path.Combine(payloadDir, "Filee")); + var installMode = File.GetUnixFileMode(Path.Combine(payloadDir, "install.sh")); - var failedCount = 0; - updates.UpdateFailed += (_, _) => failedCount++; + 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"); - var shutdownRequested = false; - updates.RequestApplicationShutdown = () => shutdownRequested = true; + 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"); + } + } - updates.StartProcess = psi => - Process.Start(new ProcessStartInfo("true") { UseShellExecute = false }); + [Fact] + public void FindLinuxRecoveryInfo_handles_trailing_directory_separator() + { + var baseDir = TempFolder(); + var installDir = Path.Combine(baseDir, "application"); + Directory.CreateDirectory(installDir); - await updates.UpdateAsync(); + var backupDir = Path.Combine(baseDir, ".backup-123"); + Directory.CreateDirectory(backupDir); - Assert.Equal(UpdateStep.Failed, updates.Step); - Assert.Equal(1, failedCount); - Assert.NotNull(updates.LastError); - Assert.Contains("Update verification failed", updates.LastError.Message); - Assert.False(shutdownRequested, "Application shutdown must not be requested when version verification fails"); + var recovery = UpdateService.FindLinuxRecoveryInfo(installDir + Path.DirectorySeparatorChar); + Assert.Contains(backupDir, recovery); + Assert.Contains("To recover manually: mv", recovery); } private static byte[] CreateTarGz(Action populate) diff --git a/tests/Filee.Platform.Tests/LinuxInstallerTests.cs b/tests/Filee.Platform.Tests/LinuxInstallerTests.cs index 5d49b342b..ce70a3616 100644 --- a/tests/Filee.Platform.Tests/LinuxInstallerTests.cs +++ b/tests/Filee.Platform.Tests/LinuxInstallerTests.cs @@ -8,6 +8,7 @@ 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() { @@ -20,13 +21,128 @@ public LinuxInstallerTests() 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() { - if (!OperatingSystem.IsLinux()) - return; + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); var env = CreateEnvironment(); CreateMockPayload(env.SourceDir, "v1.0.0"); @@ -58,8 +174,7 @@ public void First_time_installation_succeeds_in_isolated_environment() [Fact] public void First_time_installation_fails_if_already_installed() { - if (!OperatingSystem.IsLinux()) - return; + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); var env = CreateEnvironment(); CreateMockPayload(env.SourceDir, "v1.0.0"); @@ -76,8 +191,7 @@ public void First_time_installation_fails_if_already_installed() [Fact] public void Upgrade_succeeds_and_replaces_binaries_while_preserving_engine_folder_and_settings() { - if (!OperatingSystem.IsLinux()) - return; + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); var env = CreateEnvironment(); CreateMockPayload(env.SourceDir, "v1.0.0"); @@ -129,8 +243,7 @@ public void Upgrade_succeeds_and_replaces_binaries_while_preserving_engine_folde [Fact] public void Upgrade_fails_if_not_already_installed() { - if (!OperatingSystem.IsLinux()) - return; + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); var env = CreateEnvironment(); CreateMockPayload(env.SourceDir, "v1.0.0"); @@ -144,8 +257,7 @@ public void Upgrade_fails_if_not_already_installed() [Fact] public void Upgrade_fails_if_marker_is_invalid_and_preserves_original_install() { - if (!OperatingSystem.IsLinux()) - return; + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); var env = CreateEnvironment(); CreateMockPayload(env.SourceDir, "v1.0.0"); @@ -169,8 +281,7 @@ public void Upgrade_fails_if_marker_is_invalid_and_preserves_original_install() [Fact] public void Upgrade_fails_if_payload_is_missing_executables_and_preserves_original_install() { - if (!OperatingSystem.IsLinux()) - return; + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); var env = CreateEnvironment(); CreateMockPayload(env.SourceDir, "v1.0.0"); @@ -194,8 +305,7 @@ public void Upgrade_fails_if_payload_is_missing_executables_and_preserves_origin [Fact] public void Upgrade_invokes_quit_on_running_filee_instance() { - if (!OperatingSystem.IsLinux()) - return; + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); var env = CreateEnvironment(); CreateMockPayload(env.SourceDir, "v1.0.0"); @@ -221,8 +331,7 @@ public void Upgrade_invokes_quit_on_running_filee_instance() [Fact] public void Upgrade_rolls_back_if_activation_move_fails() { - if (!OperatingSystem.IsLinux()) - return; + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); var env = CreateEnvironment(); CreateMockPayload(env.SourceDir, "v1.0.0"); @@ -233,9 +342,9 @@ public void Upgrade_rolls_back_if_activation_move_fails() var v2Source = Path.Combine(_root, "source-v2"); CreateMockPayload(v2Source, "v2.0.0"); - // Simulate failure during activation move: _FILEE_TEST_FAIL_ACTIVATION=1 + // Simulate failure during activation move using external mv shim var (exitCode, output, error) = RunInstaller(v2Source, env.DataDir, env.HomeDir, - new Dictionary { ["_FILEE_TEST_FAIL_ACTIVATION"] = "1" }, "--upgrade"); + new Dictionary { ["SHIM_FAIL_ACTIVATION"] = "1" }, "--upgrade"); Assert.NotEqual(0, exitCode); Assert.Contains("Restored the previous Filee installation", output + error); @@ -249,8 +358,7 @@ public void Upgrade_rolls_back_if_activation_move_fails() [Fact] public void Upgrade_rolls_back_if_post_swap_validation_fails() { - if (!OperatingSystem.IsLinux()) - return; + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); var env = CreateEnvironment(); CreateMockPayload(env.SourceDir, "v1.0.0"); @@ -261,9 +369,9 @@ public void Upgrade_rolls_back_if_post_swap_validation_fails() var v2Source = Path.Combine(_root, "source-v2"); CreateMockPayload(v2Source, "v2.0.0"); - // Simulate failure during post-swap validation: _FILEE_TEST_FAIL_POST_VALIDATION=1 + // Simulate failure during post-swap validation using external mv shim var (exitCode, output, error) = RunInstaller(v2Source, env.DataDir, env.HomeDir, - new Dictionary { ["_FILEE_TEST_FAIL_POST_VALIDATION"] = "1" }, "--upgrade"); + new Dictionary { ["SHIM_FAIL_POST_VALIDATION"] = "1" }, "--upgrade"); Assert.NotEqual(0, exitCode); Assert.Contains("Activated installation failed post-swap validation", output + error); @@ -278,8 +386,7 @@ public void Upgrade_rolls_back_if_post_swap_validation_fails() [Fact] public void Upgrade_reports_critical_error_and_preserves_backup_if_rollback_destination_is_occupied() { - if (!OperatingSystem.IsLinux()) - return; + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); var env = CreateEnvironment(); CreateMockPayload(env.SourceDir, "v1.0.0"); @@ -288,9 +395,9 @@ public void Upgrade_reports_critical_error_and_preserves_backup_if_rollback_dest var v2Source = Path.Combine(_root, "source-v2"); CreateMockPayload(v2Source, "v2.0.0"); - // Simulate failure during rollback where destination is occupied: _FILEE_TEST_FAIL_ROLLBACK_OCCUPIED=1 + // Simulate failure during rollback where destination is occupied using external mv shim var (exitCode, output, error) = RunInstaller(v2Source, env.DataDir, env.HomeDir, - new Dictionary { ["_FILEE_TEST_FAIL_ROLLBACK_OCCUPIED"] = "1" }, "--upgrade"); + 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); @@ -314,8 +421,7 @@ public void Upgrade_reports_critical_error_and_preserves_backup_if_rollback_dest [Fact] public void Upgrade_reports_critical_error_if_destination_occupied_during_activation() { - if (!OperatingSystem.IsLinux()) - return; + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); var env = CreateEnvironment(); CreateMockPayload(env.SourceDir, "v1.0.0"); @@ -324,9 +430,9 @@ public void Upgrade_reports_critical_error_if_destination_occupied_during_activa var v2Source = Path.Combine(_root, "source-v2"); CreateMockPayload(v2Source, "v2.0.0"); - // Simulate destination conflict during activation: _FILEE_TEST_FAIL_ACTIVATION_OCCUPIED=1 + // Simulate destination conflict during activation using external mv shim var (exitCode, output, error) = RunInstaller(v2Source, env.DataDir, env.HomeDir, - new Dictionary { ["_FILEE_TEST_FAIL_ACTIVATION_OCCUPIED"] = "1" }, "--upgrade"); + 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); @@ -350,8 +456,7 @@ public void Upgrade_reports_critical_error_if_destination_occupied_during_activa [Fact] public void Upgrade_reports_critical_error_and_preserves_backup_if_rollback_destination_is_absent_but_move_fails() { - if (!OperatingSystem.IsLinux()) - return; + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); var env = CreateEnvironment(); CreateMockPayload(env.SourceDir, "v1.0.0"); @@ -360,9 +465,9 @@ public void Upgrade_reports_critical_error_and_preserves_backup_if_rollback_dest var v2Source = Path.Combine(_root, "source-v2"); CreateMockPayload(v2Source, "v2.0.0"); - // Simulate failure during rollback when install_dir is absent: _FILEE_TEST_FAIL_ROLLBACK_ABSENT=1 + // 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 { ["_FILEE_TEST_FAIL_ROLLBACK_ABSENT"] = "1" }, "--upgrade"); + 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); @@ -386,8 +491,7 @@ public void Upgrade_reports_critical_error_and_preserves_backup_if_rollback_dest [Fact] public void Upgrade_preserves_destination_and_backup_if_quarantine_fails() { - if (!OperatingSystem.IsLinux()) - return; + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); var env = CreateEnvironment(); CreateMockPayload(env.SourceDir, "v1.0.0"); @@ -396,9 +500,9 @@ public void Upgrade_preserves_destination_and_backup_if_quarantine_fails() var v2Source = Path.Combine(_root, "source-v2"); CreateMockPayload(v2Source, "v2.0.0"); - // Simulate quarantine failure during rollback: _FILEE_TEST_FAIL_QUARANTINE=1 + // Simulate quarantine failure during rollback using external mv shim var (exitCode, output, error) = RunInstaller(v2Source, env.DataDir, env.HomeDir, - new Dictionary { ["_FILEE_TEST_FAIL_QUARANTINE"] = "1" }, "--upgrade"); + new Dictionary { ["SHIM_FAIL_QUARANTINE"] = "1" }, "--upgrade"); Assert.NotEqual(0, exitCode); Assert.Contains("CRITICAL: Update failed and automatic rollback could not complete", output + error); @@ -422,8 +526,7 @@ public void Upgrade_preserves_destination_and_backup_if_quarantine_fails() [Fact] public void Upgrade_restores_backup_and_reports_quarantine_if_quarantine_cleanup_fails() { - if (!OperatingSystem.IsLinux()) - return; + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); var env = CreateEnvironment(); CreateMockPayload(env.SourceDir, "v1.0.0"); @@ -432,9 +535,9 @@ public void Upgrade_restores_backup_and_reports_quarantine_if_quarantine_cleanup var v2Source = Path.Combine(_root, "source-v2"); CreateMockPayload(v2Source, "v2.0.0"); - // Simulate quarantine cleanup failure: _FILEE_TEST_FAIL_CLEANUP_FAILED_DIR=1 + // Simulate quarantine cleanup failure using external mv shim var (exitCode, output, error) = RunInstaller(v2Source, env.DataDir, env.HomeDir, - new Dictionary { ["_FILEE_TEST_FAIL_CLEANUP_FAILED_DIR"] = "1" }, "--upgrade"); + new Dictionary { ["SHIM_FAIL_CLEANUP_FAILED_DIR"] = "1" }, "--upgrade"); Assert.NotEqual(0, exitCode); Assert.Contains("Update failed. Restored the previous Filee installation", output + error); @@ -460,8 +563,7 @@ public void Upgrade_restores_backup_and_reports_quarantine_if_quarantine_cleanup [Fact] public void Upgrade_preserves_preexisting_backup_and_failed_directories_from_earlier_runs() { - if (!OperatingSystem.IsLinux()) - return; + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); var env = CreateEnvironment(); CreateMockPayload(env.SourceDir, "v1.0.0"); @@ -502,8 +604,7 @@ public void Upgrade_preserves_preexisting_backup_and_failed_directories_from_ear [Fact] public void Upgrade_preserves_foreign_directory_if_destination_was_replaced_before_rollback() { - if (!OperatingSystem.IsLinux()) - return; + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); var env = CreateEnvironment(); CreateMockPayload(env.SourceDir, "v1.0.0"); @@ -512,9 +613,9 @@ public void Upgrade_preserves_foreign_directory_if_destination_was_replaced_befo var v2Source = Path.Combine(_root, "source-v2"); CreateMockPayload(v2Source, "v2.0.0"); - // Simulate replacement of install_dir by a foreign directory before rollback: _FILEE_TEST_FAIL_FOREIGN_DESTINATION=1 + // 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 { ["_FILEE_TEST_FAIL_FOREIGN_DESTINATION"] = "1" }, "--upgrade"); + 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); @@ -538,10 +639,9 @@ public void Upgrade_preserves_foreign_directory_if_destination_was_replaced_befo } [Fact] - public void Test_hooks_are_ignored_in_normal_environment_even_if_environment_variable_is_set() + public void Test_hook_environment_variables_have_no_effect_on_installer() { - if (!OperatingSystem.IsLinux()) - return; + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); var env = CreateEnvironment(); CreateMockPayload(env.SourceDir, "v1.0.0"); @@ -550,30 +650,21 @@ public void Test_hooks_are_ignored_in_normal_environment_even_if_environment_var var v2Source = Path.Combine(_root, "source-v2"); CreateMockPayload(v2Source, "v2.0.0"); - // Set failure hook variable, but do NOT pass _FILEE_TEST_HARNESS + // 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" }, passTestHarness: false, "--upgrade"); + new Dictionary { ["_FILEE_TEST_FAIL_ACTIVATION"] = "1", ["_FILEE_TEST_HARNESS"] = "com.filee.test.harness" }, "--upgrade"); - // The upgrade MUST succeed because test hooks are locked down and ignored without harness token 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()); - - // Second check: set failure hook variable with an INVALID test harness token - var (exitCode2, output2, error2) = RunInstaller(v2Source, env.DataDir, env.HomeDir, - new Dictionary { ["_FILEE_TEST_FAIL_ACTIVATION"] = "1", ["_FILEE_TEST_HARNESS"] = "bogus-token" }, passTestHarness: false, "--upgrade"); - - Assert.True(exitCode2 == 0, $"Upgrade failed unexpectedly with bogus token. Output: {output2}. Error: {error2}"); - Assert.Contains("Upgraded Filee at", output2); } [Fact] public void Installation_and_upgrade_succeed_with_spaces_and_special_characters_in_paths() { - if (!OperatingSystem.IsLinux()) - return; + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); var id = Guid.NewGuid().ToString("N"); var sourceDir = Path.Combine(_root, "source path with spaces " + id); @@ -604,23 +695,10 @@ public void Installation_and_upgrade_succeed_with_spaces_and_special_characters_ Assert.Equal("v2.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); - } - [Fact] - public void Upgrade_with_in_app_update_env_var_skips_Filee_quit_and_restart() + public void Upgrade_fails_if_running_filee_cannot_gracefully_shut_down() { - if (!OperatingSystem.IsLinux()) - return; + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires Linux."); var env = CreateEnvironment(); CreateMockPayload(env.SourceDir, "v1.0.0"); @@ -639,16 +717,23 @@ public void Upgrade_with_in_app_update_env_var_skips_Filee_quit_and_restart() var v2Dir = Path.Combine(_root, "source-v2-" + Guid.NewGuid().ToString("N")[..8]); CreateMockPayload(v2Dir, "v2.0.0"); - // 1. Without FILEE_IN_APP_UPDATE, upgrade calls Filee --quit, which fails with code 42 + // 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()); + } - // 2. With FILEE_IN_APP_UPDATE=1, upgrade skips Filee --quit and succeeds - var inAppEnv = new Dictionary { ["FILEE_IN_APP_UPDATE"] = "1" }; - var (succCode, succOut, succErr) = RunInstaller(v2Dir, env.DataDir, env.HomeDir, inAppEnv, "--upgrade", "--restart"); - Assert.True(succCode == 0, $"Expected upgrade to succeed. Output: {succOut}. Error: {succErr}"); - Assert.Equal("v2.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) @@ -671,16 +756,12 @@ private void CreateMockPayload(string dir, string version) File.SetUnixFileMode(script, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); } - private static (int ExitCode, string Output, string Error) RunInstaller( + private (int ExitCode, string Output, string Error) RunInstaller( string sourceDir, string dataDir, string homeDir, params string[] args) => - RunInstaller(sourceDir, dataDir, homeDir, null, passTestHarness: true, args); - - private static (int ExitCode, string Output, string Error) RunInstaller( - string sourceDir, string dataDir, string homeDir, IDictionary? extraEnv, params string[] args) => - RunInstaller(sourceDir, dataDir, homeDir, extraEnv, passTestHarness: true, args); + RunInstaller(sourceDir, dataDir, homeDir, null, args); - private static (int ExitCode, string Output, string Error) RunInstaller( - string sourceDir, string dataDir, string homeDir, IDictionary? extraEnv, bool passTestHarness, params string[] 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") @@ -696,9 +777,7 @@ private static (int ExitCode, string Output, string Error) RunInstaller( psi.Environment["XDG_DATA_HOME"] = dataDir; psi.Environment["HOME"] = homeDir; - psi.Environment["PATH"] = Environment.GetEnvironmentVariable("PATH") ?? "/bin:/usr/bin"; - if (passTestHarness) - psi.Environment["_FILEE_TEST_HARNESS"] = "com.filee.test.harness"; + psi.Environment["PATH"] = $"{_shimBinDir}:{Environment.GetEnvironmentVariable("PATH") ?? "/bin:/usr/bin"}"; if (extraEnv is not null) { @@ -718,10 +797,14 @@ public void Dispose() { try { - // Reset write permissions on any created conflict folders so Directory.Delete succeeds - foreach (var dir in Directory.GetDirectories(_root, "*", SearchOption.AllDirectories)) + if (OperatingSystem.IsLinux() && Directory.Exists(_root)) { - try { File.SetUnixFileMode(dir, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); } catch { } + 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); From 26640d582a426e8c290940429ca61605b1492c28 Mon Sep 17 00:00:00 2001 From: Alenbosh Date: Thu, 22 Oct 2026 13:49:04 +0530 Subject: [PATCH 4/7] style(docs): remove trailing blank line at EOF in ARCHITECTURE.md --- docs/ARCHITECTURE.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1adb830c4..ee921b36c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -373,4 +373,3 @@ and standalone shell scripts (`install.sh`, `uninstall.sh`). (`~/.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. - From 213bd40f747d02593eccd76e229b4de90966efa7 Mon Sep 17 00:00:00 2001 From: Alenbosh Date: Thu, 22 Oct 2026 20:46:05 +0530 Subject: [PATCH 5/7] fix(updater): address follow-up review feedback --- src/Filee.App/App.axaml.cs | 6 +- src/Filee.App/Services/UpdateService.cs | 254 +++++++++++--------- src/Filee.App/Services/WindowService.cs | 7 +- tests/Filee.App.Tests/UpdateServiceTests.cs | 236 +++++++++++++++--- 4 files changed, 357 insertions(+), 146 deletions(-) diff --git a/src/Filee.App/App.axaml.cs b/src/Filee.App/App.axaml.cs index 718193803..d17609f2c 100644 --- a/src/Filee.App/App.axaml.cs +++ b/src/Filee.App/App.axaml.cs @@ -137,6 +137,8 @@ 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. + _ = Task.Run(() => UpdateService.DeleteStaleWorkspaces()); // Once LibreOffice is installed: prepare its profiles while the user isn't converting yet. AppHost.Get().WarmUpInBackground(TimeSpan.FromSeconds(10)); @@ -212,7 +214,9 @@ private static void WatchForUpdates(UserDataStore store) store.SaveSettings(); AppHost.Get().ShowUpdateNotice(version); }; - updates.UpdateFailed += async (_, _) => await AppHost.Get().ShowUpdateFailedAsync(); + updates.UpdateFailed += async (_, _) => await AppHost.Get().ShowUpdateFailedAsync(updates.LastFailureLogPath); + if (updates.CheckPendingFailure(store.Directory)) + Dispatcher.UIThread.Post(async () => await AppHost.Get().ShowUpdateFailedAsync(updates.LastFailureLogPath)); updates.StartPeriodicChecks(() => store.Settings.CheckForUpdates, TimeSpan.FromSeconds(20)); DispatcherTimer.RunOnce(() => NotifyEngineUpdates(store), TimeSpan.FromSeconds(25)); } diff --git a/src/Filee.App/Services/UpdateService.cs b/src/Filee.App/Services/UpdateService.cs index 834dd562f..174a9241b 100644 --- a/src/Filee.App/Services/UpdateService.cs +++ b/src/Filee.App/Services/UpdateService.cs @@ -154,6 +154,12 @@ private static string DefaultTargetPlatform() /// 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; @@ -410,6 +416,7 @@ public async Task UpdateAsync() 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 @@ -422,11 +429,17 @@ public async Task UpdateAsync() 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\"; exit 1; fi; " + + "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 exec \"$3\" --background --whats-new; else echo \"Update failed with status $status\" >> \"$4\"; exit $status; fi" + "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()); @@ -434,6 +447,7 @@ public async Task UpdateAsync() startInfo.ArgumentList.Add(newExe); startInfo.ArgumentList.Add(logFile); startInfo.ArgumentList.Add(workspace); + startInfo.ArgumentList.Add(markerFile); helper = StartProcess(startInfo); } @@ -561,6 +575,51 @@ private void Fail(string stage, Exception ex) 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; @@ -880,27 +939,27 @@ 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) { - if (string.IsNullOrWhiteSpace(baseDir)) - { - if (OperatingSystem.IsLinux()) - { - var dataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); - var root = !string.IsNullOrWhiteSpace(dataHome) - ? dataHome - : Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - baseDir = Path.Combine(root, "filee"); - } - else - { - baseDir = Path.GetTempPath(); - } - } + baseDir = string.IsNullOrWhiteSpace(baseDir) ? GetDefaultWorkspaceBaseDirectory() : baseDir; Directory.CreateDirectory(baseDir); @@ -953,116 +1012,95 @@ internal static void DeleteSecureWorkspace(string? workspacePath) } } - /// Locates the installation directory on Linux. - internal static string GetLinuxInstallDirectory() + /// + /// 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 (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"); - } + if (pid <= 0) + return false; - /// Reads the installed Filee version from the specified installation folder. - internal static string? GetInstalledVersion(string installDir) - { - var dll = Path.Combine(installDir, "Filee.dll"); - if (File.Exists(dll)) + try { - try - { - var info = FileVersionInfo.GetVersionInfo(dll); - if (!string.IsNullOrWhiteSpace(info.ProductVersion)) - return info.ProductVersion.Split('+')[0].Trim(); - if (!string.IsNullOrWhiteSpace(info.FileVersion)) - return info.FileVersion.Trim(); - } - catch - { - // Fall through - } - } + if (OperatingSystem.IsLinux()) + return Directory.Exists($"/proc/{pid}"); - var exe = Path.Combine(installDir, "Filee"); - if (File.Exists(exe)) - { - try - { - var info = FileVersionInfo.GetVersionInfo(exe); - if (!string.IsNullOrWhiteSpace(info.ProductVersion)) - return info.ProductVersion.Split('+')[0].Trim(); - if (!string.IsNullOrWhiteSpace(info.FileVersion)) - return info.FileVersion.Trim(); - } - catch - { - // Fall through - } + using var proc = Process.GetProcessById(pid); + return !proc.HasExited; } - - var cli = Path.Combine(installDir, "filee-cli"); - if (File.Exists(cli)) + catch (ArgumentException) { - try - { - using var proc = Process.Start(new ProcessStartInfo(cli, "version") - { - RedirectStandardOutput = true, - UseShellExecute = false, - }); - if (proc is not null && proc.WaitForExit(3000) && proc.ExitCode == 0) - { - var line = proc.StandardOutput.ReadLine()?.Trim(); - if (line?.StartsWith("filee ", StringComparison.OrdinalIgnoreCase) == true) - return line["filee ".Length..].Trim(); - } - } - catch - { - // Fall through - } + return false; } - - return null; - } - - /// Verifies that the version installed in matches . - internal static bool VerifyInstalledVersion(string installDir, string expectedVersion, out string? actualVersion) - { - actualVersion = GetInstalledVersion(installDir); - if (actualVersion is null) + catch (InvalidOperationException) + { return false; - - if (TryParse(expectedVersion, out var expected) && TryParse(actualVersion, out var actual)) - return expected == actual; - - return string.Equals(actualVersion.Trim(), expectedVersion.Trim(), StringComparison.OrdinalIgnoreCase); + } + catch (Exception) + { + // If in doubt (e.g. access denied), assume alive to safeguard against deleting active workspaces + return true; + } } - internal static string FindLinuxRecoveryInfo(string installDir) + /// + /// 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). + /// + public static void DeleteStaleWorkspaces(string? root = null, TimeSpan? age = null, ILogger? log = null) { - var cleanInstallDir = Path.TrimEndingDirectorySeparator(installDir); - var parent = Path.GetDirectoryName(cleanInstallDir); - if (string.IsNullOrWhiteSpace(parent) || !Directory.Exists(parent)) - return ""; - + var threshold = age ?? TimeSpan.FromDays(1); try { - var backups = Directory.GetDirectories(parent, ".backup-*"); - if (backups.Length > 0) + 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-*")) { - var latestBackup = backups.OrderByDescending(Directory.GetCreationTimeUtc).First(); - return $"Previous installation retained at '{latestBackup}'. To recover manually: mv '{latestBackup}' '{cleanInstallDir}'"; + if (folder.LinkTarget is not null || folder.Attributes.HasFlag(FileAttributes.ReparsePoint)) + continue; + + try + { + var parts = folder.Name.Split('-'); + if (parts.Length >= 2 && int.TryParse(parts[1], out var pid)) + { + 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 + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - // Ignore directory enumeration failures + log?.LogWarning(ex, "Failed to enumerate update workspaces in {Root}", root); } + } - return "No backup directory found."; + /// 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 . diff --git a/src/Filee.App/Services/WindowService.cs b/src/Filee.App/Services/WindowService.cs index ac4347e7f..54fac612f 100644 --- a/src/Filee.App/Services/WindowService.cs +++ b/src/Filee.App/Services/WindowService.cs @@ -321,10 +321,13 @@ public Task ConfirmAsync(Window owner, string message, bool destructive = public Task MessageAsync(Window? owner, string message) => ShowMessageAsync(owner, message, destructive: false, withCancel: false); /// The in-app update failed: say so, then open the website to download the new version there. - public async Task ShowUpdateFailedAsync() + public async Task ShowUpdateFailedAsync(string? logPath = null) { _updateNotice?.Close(); - await MessageAsync(_main is { IsVisible: true } main ? main : null, loc["update.failed_alert"]); + var message = loc["update.failed_alert"]; + if (!string.IsNullOrWhiteSpace(logPath)) + message += $"\n\nLog: {logPath}"; + await MessageAsync(_main is { IsVisible: true } main ? main : null, message); services.GetRequiredService().OpenDownloadPage(); } diff --git a/tests/Filee.App.Tests/UpdateServiceTests.cs b/tests/Filee.App.Tests/UpdateServiceTests.cs index 1f4db3262..c21e64b8c 100644 --- a/tests/Filee.App.Tests/UpdateServiceTests.cs +++ b/tests/Filee.App.Tests/UpdateServiceTests.cs @@ -500,10 +500,13 @@ public async Task UpdateAsync_on_linux_downloads_extracts_and_delegates_to_detac 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"); } @@ -645,41 +648,33 @@ public void DeleteSecureWorkspace_refuses_to_follow_symlink() [Fact] public async Task Concurrent_secure_workspaces_are_isolated() { - var tasks = Enumerable.Range(0, 5).Select(_ => Task.Run(() => UpdateService.CreateSecureWorkspace())).ToArray(); - var workspaces = await Task.WhenAll(tasks); - + 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.All(workspaces, w => + { + Assert.True(Directory.Exists(w)); + Assert.StartsWith(isolatedDir, w); + }); } finally { - foreach (var w in workspaces) - UpdateService.DeleteSecureWorkspace(w); + try + { + if (Directory.Exists(isolatedDir)) + Directory.Delete(isolatedDir, recursive: true); + } + catch + { + // Best effort test cleanup + } } } - [Fact] - public void VerifyInstalledVersion_detects_mismatch() - { - var folder = TempFolder(); - var cli = Path.Combine(folder, "filee-cli"); - File.WriteAllText(cli, "#!/bin/sh\necho \"filee 1.0.0\"\n"); - if (OperatingSystem.IsLinux()) - File.SetUnixFileMode(cli, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); - - Assert.True(UpdateService.VerifyInstalledVersion(folder, "1.0.0", out var actual)); - Assert.Equal("1.0.0", actual); - - Assert.False(UpdateService.VerifyInstalledVersion(folder, "1.0.2", out actual)); - Assert.Equal("1.0.0", actual); - - var emptyFolder = TempFolder(); - Assert.False(UpdateService.VerifyInstalledVersion(emptyFolder, "1.0.0", out actual)); - Assert.Null(actual); - } - [Fact] public void Tar_extraction_masks_mode_to_rwx_bits() { @@ -714,18 +709,189 @@ public void Tar_extraction_masks_mode_to_rwx_bits() } [Fact] - public void FindLinuxRecoveryInfo_handles_trailing_directory_separator() + 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 baseDir = TempFolder(); - var installDir = Path.Combine(baseDir, "application"); - Directory.CreateDirectory(installDir); + 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 activeDir = Path.Combine(root, $".update-{Environment.ProcessId}-active123"); + var deadPid = 999999; + while (UpdateService.IsProcessAlive(deadPid)) + deadPid++; + + var deadStaleDir = Path.Combine(root, $".update-{deadPid}-stale456"); + var deadFreshDir = Path.Combine(root, $".update-{deadPid}-fresh789"); + + 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 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, + }); - var backupDir = Path.Combine(baseDir, ".backup-123"); - Directory.CreateDirectory(backupDir); + Assert.NotNull(proc); + Assert.True(proc.WaitForExit(5000)); + Assert.Equal(1, proc.ExitCode); - var recovery = UpdateService.FindLinuxRecoveryInfo(installDir + Path.DirectorySeparatorChar); - Assert.Contains(backupDir, recovery); - Assert.Contains("To recover manually: mv", recovery); + 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) From 8fd4bc55f4173d73323ea57932031b79ebe0b18a Mon Sep 17 00:00:00 2001 From: Alenbosh Date: Fri, 23 Oct 2026 13:44:09 +0530 Subject: [PATCH 6/7] fix(updater): restrict stale workspace sweep to Linux and Filee-owned names --- src/Filee.App/App.axaml.cs | 5 +- src/Filee.App/Services/UpdateService.cs | 39 +++++++++-- tests/Filee.App.Tests/UpdateServiceTests.cs | 72 ++++++++++++++++++++- 3 files changed, 104 insertions(+), 12 deletions(-) diff --git a/src/Filee.App/App.axaml.cs b/src/Filee.App/App.axaml.cs index d17609f2c..69e948010 100644 --- a/src/Filee.App/App.axaml.cs +++ b/src/Filee.App/App.axaml.cs @@ -137,8 +137,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. - _ = Task.Run(() => UpdateService.DeleteStaleWorkspaces()); + // 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)); diff --git a/src/Filee.App/Services/UpdateService.cs b/src/Filee.App/Services/UpdateService.cs index 174a9241b..1c7504900 100644 --- a/src/Filee.App/Services/UpdateService.cs +++ b/src/Filee.App/Services/UpdateService.cs @@ -1044,10 +1044,34 @@ internal static bool IsProcessAlive(int pid) } } + /// + /// 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). + /// (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) { @@ -1064,14 +1088,15 @@ public static void DeleteStaleWorkspaces(string? root = null, TimeSpan? age = nu 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 { - var parts = folder.Name.Split('-'); - if (parts.Length >= 2 && int.TryParse(parts[1], out var pid)) - { - if (IsProcessAlive(pid)) - continue; - } + if (IsProcessAlive(pid)) + continue; if (DateTime.UtcNow - folder.LastWriteTimeUtc > threshold) { diff --git a/tests/Filee.App.Tests/UpdateServiceTests.cs b/tests/Filee.App.Tests/UpdateServiceTests.cs index c21e64b8c..7f0ee7d92 100644 --- a/tests/Filee.App.Tests/UpdateServiceTests.cs +++ b/tests/Filee.App.Tests/UpdateServiceTests.cs @@ -768,13 +768,16 @@ public void CheckPendingFailure_handles_malformed_marker_safely() public void DeleteStaleWorkspaces_preserves_active_pid_and_deletes_stale_dead_pid() { var root = TempFolder(); - var activeDir = Path.Combine(root, $".update-{Environment.ProcessId}-active123"); + 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}-stale456"); - var deadFreshDir = Path.Combine(root, $".update-{deadPid}-fresh789"); + var deadStaleDir = Path.Combine(root, $".update-{deadPid}-{token2}"); + var deadFreshDir = Path.Combine(root, $".update-{deadPid}-{token3}"); Directory.CreateDirectory(activeDir); Directory.CreateDirectory(deadStaleDir); @@ -789,6 +792,69 @@ public void DeleteStaleWorkspaces_preserves_active_pid_and_deletes_stale_dead_pi 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() { From 853cb1a732aa0f8902646a2b16eb6a259f66227f Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Fri, 23 Oct 2026 18:38:39 +0900 Subject: [PATCH 7/7] Own message for a rolled-back Linux update; sweep only on Linux After a failed install the helper rolls back and starts the old Filee, which showed "Couldn't download the update" with an English "Log:" line. It now says the update couldn't be installed and the previous version was kept (update.rolled_back_alert), with the log path in a translated line (update.rolled_back_log), in all 14 languages. A failure in the current run keeps update.failed_alert without a log line from an earlier run. DeleteStaleWorkspaces does nothing on other platforms when no root is given: there the default root is the shared temp folder. The Linux update test no longer starts a real `true`, which is missing on Windows without Git's tools on PATH. Co-Authored-By: Claude Opus 5.5 --- src/Filee.App/App.axaml.cs | 5 +++-- src/Filee.App/Assets/i18n/ar.json | 2 ++ src/Filee.App/Assets/i18n/en.json | 2 ++ src/Filee.App/Assets/i18n/es.json | 2 ++ src/Filee.App/Assets/i18n/fr.json | 2 ++ src/Filee.App/Assets/i18n/hi.json | 2 ++ src/Filee.App/Assets/i18n/ja.json | 2 ++ src/Filee.App/Assets/i18n/ko.json | 2 ++ src/Filee.App/Assets/i18n/ne.json | 2 ++ src/Filee.App/Assets/i18n/pl.json | 2 ++ src/Filee.App/Assets/i18n/pt-BR.json | 2 ++ src/Filee.App/Assets/i18n/ru.json | 2 ++ src/Filee.App/Assets/i18n/th.json | 2 ++ src/Filee.App/Assets/i18n/zh-CN.json | 2 ++ src/Filee.App/Assets/i18n/zh-TW.json | 2 ++ src/Filee.App/Services/UpdateService.cs | 4 ++++ src/Filee.App/Services/WindowService.cs | 17 ++++++++++++++--- tests/Filee.App.Tests/UpdateServiceTests.cs | 3 ++- 18 files changed, 51 insertions(+), 6 deletions(-) diff --git a/src/Filee.App/App.axaml.cs b/src/Filee.App/App.axaml.cs index 712d39792..ff84c26b9 100644 --- a/src/Filee.App/App.axaml.cs +++ b/src/Filee.App/App.axaml.cs @@ -218,9 +218,10 @@ private static void WatchForUpdates(UserDataStore store) store.SaveSettings(); AppHost.Get().ShowUpdateNotice(version); }; - updates.UpdateFailed += async (_, _) => await AppHost.Get().ShowUpdateFailedAsync(updates.LastFailureLogPath); + 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().ShowUpdateFailedAsync(updates.LastFailureLogPath)); + 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 7350a25e6..65b0a74f7 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -306,6 +306,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 d6da2e01a..2862f45e4 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -318,6 +318,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 46d0a29db..b72c5684b 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -306,6 +306,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 5e3a7d679..a9c7bac83 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -318,6 +318,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 ee9731ca2..9def7be19 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -306,6 +306,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 9ebf86df2..59140afe1 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -306,6 +306,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 0ed433f0a..ec327ab7b 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -318,6 +318,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 b94e00435..b9310384d 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -306,6 +306,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 6dfe83f68..8a41889be 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -318,6 +318,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 de22e05eb..b0409006d 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -318,6 +318,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 fdef315d0..9fa13eff1 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -318,6 +318,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 dafee3314..94c151324 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -306,6 +306,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 04342f28c..1aa872d38 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -318,6 +318,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 f63d6a08e..6f9c6f1da 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -318,6 +318,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 1c7504900..45a0459ed 100644 --- a/src/Filee.App/Services/UpdateService.cs +++ b/src/Filee.App/Services/UpdateService.cs @@ -1075,6 +1075,10 @@ internal static bool TryParseWorkspaceName(string name, out int pid) /// 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 { diff --git a/src/Filee.App/Services/WindowService.cs b/src/Filee.App/Services/WindowService.cs index 54fac612f..0272d5780 100644 --- a/src/Filee.App/Services/WindowService.cs +++ b/src/Filee.App/Services/WindowService.cs @@ -321,12 +321,23 @@ public Task ConfirmAsync(Window owner, string message, bool destructive = public Task MessageAsync(Window? owner, string message) => ShowMessageAsync(owner, message, destructive: false, withCancel: false); /// The in-app update failed: say so, then open the website to download the new version there. - public async Task ShowUpdateFailedAsync(string? logPath = null) + public async Task ShowUpdateFailedAsync() { _updateNotice?.Close(); - var message = loc["update.failed_alert"]; + await MessageAsync(_main is { IsVisible: true } main ? main : null, loc["update.failed_alert"]); + 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\nLog: {logPath}"; + message += "\n\n" + loc.Format("update.rolled_back_log", logPath); await MessageAsync(_main is { IsVisible: true } main ? main : null, message); services.GetRequiredService().OpenDownloadPage(); } diff --git a/tests/Filee.App.Tests/UpdateServiceTests.cs b/tests/Filee.App.Tests/UpdateServiceTests.cs index 7f0ee7d92..edc832f3f 100644 --- a/tests/Filee.App.Tests/UpdateServiceTests.cs +++ b/tests/Filee.App.Tests/UpdateServiceTests.cs @@ -486,7 +486,8 @@ public async Task UpdateAsync_on_linux_downloads_extracts_and_delegates_to_detac updates.StartProcess = psi => { processes.Add(psi); - return Process.Start(new ProcessStartInfo("true") { UseShellExecute = false }); + // No real process: `true` is missing on Windows without Git's tools on PATH. + return null; }; await updates.UpdateAsync();