diff --git a/.github/workflows/roundtrip.yml b/.github/workflows/roundtrip.yml index 30982c4..c9920b8 100644 --- a/.github/workflows/roundtrip.yml +++ b/.github/workflows/roundtrip.yml @@ -53,6 +53,7 @@ jobs: cmake --build "$native_build" --config Release "$native_build/RivetThemeTest" dbus-run-session -- "$native_build/RivetGlobalShortcutsTest" + "$native_build/RivetUpdateHandoffTest" cp "$native_build/RivetIntegration" "$stage/RivetIntegration" chmod +x "$stage/RivetIntegration" diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f6eb9b..afa9f48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## Unreleased + +- Add the crash-safe process-external Linux AppImage handoff (issue #290). + `AppImageHandoff::LaunchReplacement` in the linked `rivet::platform` target + writes a generated first-party shell script — the counterpart of the + Windows `MsiHandoff` — that waits a bounded time for the launching process + to exit, performs the atomic replace with staging, SHA-256 re-check, + schema-1 journal phases, and idempotent rollback, relaunches the + replacement, and commits only after it survives the settle window; + otherwise it restores the previous AppImage (unless the signed manifest + forbids rollback) and records a failure marker for `ConsumeFailure` on the + next launch. A preflight heals attempts that died between the two renames, + so the launch path is never left without an executable, and deb/rpm + installations stay owned by the system package manager. + ## 0.12.0 - Make cross-platform verification first-class. `raco rivet matrix diff --git a/docs/release-and-updates.md b/docs/release-and-updates.md index 41ede3b..7842720 100644 --- a/docs/release-and-updates.md +++ b/docs/release-and-updates.md @@ -197,12 +197,33 @@ Only the final OS handoff remains native, and Rivet ships that too: and relaunches the application. The release callback runs after the helper exists so single-instance ownership is dropped before the new process starts. `ConsumeFailure` and `CleanupStaleAttempts` complete the startup lifecycle. -- Linux keeps `prepare-platform-installation` / - `execute-platform-installation!` as the blessed AppImage swap path; GTK hosts - must not duplicate it. The linked `rivet::platform` target exposes - `DetectLinuxInstallKind` for pre-download UI policy and - `RelaunchInstalledAppImage` for the post-commit exec handoff, including lease - release. deb/rpm installations remain owned by the system package manager. +- Linux hosts that apply an AppImage update must not run the swap + themselves: a crash between moving the old AppImage aside and moving the + new one into place would leave no executable at the launch path, and the + dead process could never run recovery. The linked `rivet::platform` target + therefore exposes `AppImageHandoff::LaunchReplacement`, the process-external + counterpart of `MsiHandoff::LaunchVerifiedInstaller`. After the managed + updater has produced a verified AppImage, the handoff writes a generated + first-party shell script into a process-specific attempt directory under + `$XDG_STATE_HOME/Rivet/updates//`, waits a bounded time for + the launching process to exit, and only then performs the atomic replace — + staging copy beside the target, SHA-256 re-check, renamed swap, schema-1 + journal phases, idempotent rollback — before relaunching the replacement + and committing once it survives the settle window. A replacement that dies + inside the window restores the previous AppImage (unless the signed + manifest forbids rollback) and records `failure.txt` for + `ConsumeFailure`/`CleanupStaleAttempts` on the next launch. A helper that + itself died between the two renames is healed by the next attempt's + preflight, so the launch path is never left without an executable. Release + the single-instance lease through the `release_lease` callback once the + helper is confirmed running, then exit promptly. The journal uses the same + field names and phases as `execute-platform-installation!`, so an + interrupted handoff stays recoverable with + `recover-platform-installation!` once the caller rebuilds the plan; do not + call recovery from the relaunched process while a helper may still be + running. `DetectLinuxInstallKind` remains the pre-download UI policy check, + `RelaunchInstalledAppImage` the post-commit exec handoff, and deb/rpm + installations remain owned by the system package manager. The product supplies application id, current version, feed URL, public key, key id, a persistent rollout bucket, and localized UI. It does not own manifest diff --git a/platform/linux/Integration/update_handoff_test.cpp b/platform/linux/Integration/update_handoff_test.cpp index f9c7b2a..b3f6826 100644 --- a/platform/linux/Integration/update_handoff_test.cpp +++ b/platform/linux/Integration/update_handoff_test.cpp @@ -1,11 +1,150 @@ #include "update_handoff.hpp" +#include +#include + #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include -int main() { - using rivet::update::DetectLinuxInstallKind; - using rivet::update::LinuxInstallKind; +namespace { + +using rivet::update::AppImageHandoff; +using rivet::update::AppImageHandoffOptions; +using rivet::update::DetectLinuxInstallKind; +using rivet::update::LinuxInstallKind; + +std::string const application_id = "org.rivet.handoff-test"; + +std::string read_text(std::filesystem::path const& path) { + std::ifstream input(path, std::ios::binary); + return std::string((std::istreambuf_iterator(input)), + std::istreambuf_iterator()); +} + +void write_executable(std::filesystem::path const& path, + std::string const& contents) { + std::ofstream output(path, std::ios::binary | std::ios::trunc); + output << contents; + output.close(); + assert(output); + assert(::chmod(path.c_str(), 0755) == 0); +} + +std::string sha256_of(std::filesystem::path const& path) { + auto const command = "sha256sum '" + path.string() + "' 2>/dev/null"; + FILE* pipe = ::popen(command.c_str(), "r"); + assert(pipe != nullptr); + char buffer[128]; + std::string line; + while (std::fgets(buffer, sizeof(buffer), pipe) != nullptr) line += buffer; + int const status = ::pclose(pipe); + assert(status == 0); + return line.substr(0, line.find(' ')); +} + +std::filesystem::path updates_root() { + char const* state = std::getenv("XDG_STATE_HOME"); + assert(state != nullptr && state[0] != '\0'); + return std::filesystem::path(state) / "Rivet" / "updates" / application_id; +} + +void scratch_workspace() { + std::error_code ec; + std::filesystem::remove_all(updates_root(), ec); +} + +// The helper waits for its launching process to exit, so every scenario +// forks: the child calls LaunchReplacement (its own pid is the one polled) +// and then either exits immediately or lingers. Returns the child pid — +// the attempt directory is named after it. +pid_t launch_from_child(std::filesystem::path const& candidate, + std::filesystem::path const& target, + AppImageHandoffOptions const& options, + unsigned exit_timeout_seconds, int linger_seconds) { + pid_t const child = ::fork(); + assert(child >= 0); + if (child == 0) { + try { + AppImageHandoff::LaunchReplacement(application_id, candidate, target, + nullptr, exit_timeout_seconds, + options); + } catch (...) { + std::abort(); + } + if (linger_seconds > 0) ::sleep(linger_seconds); + ::_exit(0); + } + int status = 0; + assert(::waitpid(child, &status, 0) == child); + assert(WIFEXITED(status) && WEXITSTATUS(status) == 0); + return child; +} + +std::filesystem::path journal_for(std::filesystem::path const& target) { + return std::filesystem::path(target.string() + ".rivet-handoff-journal"); +} + +std::optional find_attempt(pid_t child) { + std::error_code ec; + for (auto const& entry : + std::filesystem::directory_iterator(updates_root(), ec)) { + if (!entry.is_directory(ec)) continue; + if (entry.path().filename().string().rfind(std::to_string(child) + "-", + 0) == 0) { + return entry.path(); + } + } + return std::nullopt; +} + +struct Outcome { + std::filesystem::path attempt; + std::string failure; // empty on a committed handoff +}; + +// A committed or rolled-back handoff deletes its journal; a failed one +// leaves the failure marker behind. +Outcome wait_for_outcome(pid_t child, std::filesystem::path const& target, + int timeout_seconds) { + std::optional attempt; + for (int waited = 0; waited < timeout_seconds * 20 && !attempt; ++waited) { + attempt = find_attempt(child); + if (!attempt) std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + if (!attempt) { + std::cerr << "no handoff attempt directory appeared\n"; + std::abort(); + } + auto const deadline = std::chrono::steady_clock::now() + + std::chrono::seconds(timeout_seconds); + for (;;) { + auto const failure = *attempt / "failure.txt"; + if (std::filesystem::is_regular_file(failure)) { + return Outcome{*attempt, read_text(failure)}; + } + if (!std::filesystem::is_regular_file(journal_for(target))) { + return Outcome{*attempt, std::string()}; + } + if (std::chrono::steady_clock::now() > deadline) { + std::cerr << "handoff never settled in " << attempt->string() << "\n"; + std::abort(); + } + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } +} + +} // namespace +int main() { assert(DetectLinuxInstallKind("/tmp/RivetHost", "/tmp/App.AppImage") == LinuxInstallKind::appimage); assert(DetectLinuxInstallKind("/opt/example/RivetHost", "") == @@ -14,4 +153,115 @@ int main() { LinuxInstallKind::package_manager); assert(DetectLinuxInstallKind("./RivetHost", "") == LinuxInstallKind::portable); + + auto const workspace = std::filesystem::temp_directory_path() / + ("rivet-handoff-test-" + std::to_string(::getpid())); + std::filesystem::create_directories(workspace); + ::setenv("XDG_STATE_HOME", workspace.string().c_str(), 1); + scratch_workspace(); + + auto const old_appimage = workspace / "old.AppImage"; + auto const new_appimage = workspace / "new.AppImage"; + std::string const old_payload = "#!/bin/sh\nsleep 4\n"; + std::string const replacement_payload = "#!/bin/sh\nsleep 4\n"; + write_executable(old_appimage, old_payload); + write_executable(new_appimage, replacement_payload); + auto const digest = sha256_of(new_appimage); + + // Successful handoff: the replacement survives the settle window, the + // backup is gone, no failure marker remains, and the target now holds the + // candidate payload. + { + AppImageHandoffOptions options; + options.version = "2.0.0"; + options.artifact_sha256 = digest; + options.settle_seconds = 1; + auto const child = launch_from_child(new_appimage, old_appimage, options, 10, 0); + auto const outcome = wait_for_outcome(child, old_appimage, 30); + assert(outcome.failure.empty()); + assert(read_text(old_appimage) == replacement_payload); + assert(!std::filesystem::exists(old_appimage.string() + ".rivet-backup")); + assert(!std::filesystem::exists(outcome.attempt / "failure.txt")); + } + + scratch_workspace(); + write_executable(old_appimage, old_payload); + write_executable(new_appimage, replacement_payload); + + // Unhealthy replacement rolls back to the previous AppImage and records + // the failure for the next launch. + { + auto const crashing = workspace / "crashing.AppImage"; + write_executable(crashing, "#!/bin/sh\nexit 1\n"); + AppImageHandoffOptions options; + options.version = "2.0.0"; + options.artifact_sha256 = sha256_of(crashing); + options.settle_seconds = 1; + auto const child = launch_from_child(crashing, old_appimage, options, 10, 0); + auto const outcome = wait_for_outcome(child, old_appimage, 30); + assert(outcome.failure == "replacement-unhealthy"); + assert(read_text(old_appimage) == old_payload); + auto const consumed = AppImageHandoff::ConsumeFailure(application_id); + assert(consumed && consumed->message == "replacement-unhealthy"); + assert(!AppImageHandoff::ConsumeFailure(application_id).has_value()); + } + + scratch_workspace(); + write_executable(old_appimage, old_payload); + write_executable(new_appimage, replacement_payload); + + // With rollback forbidden by signed policy the failing replacement stays + // in place and the marker names the policy. + { + auto const crashing = workspace / "crashing.AppImage"; + write_executable(crashing, "#!/bin/sh\nexit 1\n"); + AppImageHandoffOptions options; + options.version = "2.0.0"; + options.artifact_sha256 = sha256_of(crashing); + options.settle_seconds = 1; + options.rollback_allowed = false; + auto const child = launch_from_child(crashing, old_appimage, options, 10, 0); + auto const outcome = wait_for_outcome(child, old_appimage, 30); + assert(outcome.failure == "replacement-unhealthy-rollback-forbidden"); + assert(read_text(old_appimage) == "#!/bin/sh\nexit 1\n"); + } + + scratch_workspace(); + write_executable(old_appimage, old_payload); + write_executable(new_appimage, replacement_payload); + + // A candidate that does not match its signed digest fails closed before + // anything moves. + { + AppImageHandoffOptions options; + options.version = "2.0.0"; + options.artifact_sha256 = std::string(64, '0'); + options.settle_seconds = 1; + auto const child = launch_from_child(new_appimage, old_appimage, options, 10, 0); + auto const outcome = wait_for_outcome(child, old_appimage, 30); + assert(outcome.failure == "candidate-mismatch"); + assert(read_text(old_appimage) == old_payload); + } + + scratch_workspace(); + write_executable(old_appimage, old_payload); + write_executable(new_appimage, replacement_payload); + + // A launching process that misses its exit window is reported for the + // next launch instead of letting the helper race a live application. + { + AppImageHandoffOptions options; + options.version = "2.0.0"; + options.artifact_sha256 = digest; + options.settle_seconds = 1; + auto const child = launch_from_child(new_appimage, old_appimage, options, 1, 5); + auto const outcome = wait_for_outcome(child, old_appimage, 30); + assert(outcome.failure == "process-exit-timeout"); + assert(read_text(old_appimage) == old_payload); + } + + scratch_workspace(); + std::filesystem::remove_all(workspace); + + return 0; } diff --git a/platform/linux/update/update_handoff.cpp b/platform/linux/update/update_handoff.cpp index a9ee6a3..8a837e1 100644 --- a/platform/linux/update/update_handoff.cpp +++ b/platform/linux/update/update_handoff.cpp @@ -1,49 +1,548 @@ #include "update_handoff.hpp" +#include +#include +#include #include #include -#include +#include +#include +#include +#include +#include #include #include +#include namespace rivet::update { +namespace { -LinuxInstallKind DetectLinuxInstallKind( - std::filesystem::path const& executable, - char const* appimage_environment) { - auto const* appimage = appimage_environment; - if (appimage == nullptr) appimage = std::getenv("APPIMAGE"); - if (appimage != nullptr && appimage[0] != '\0') { - return LinuxInstallKind::appimage; +std::string safe_component(std::string const& value) { + std::string cleaned; + cleaned.reserve(value.size()); + for (char const ch : value) { + cleaned.push_back(std::isalnum(static_cast(ch)) || ch == '.' || + ch == '_' || ch == '-' + ? ch + : '_'); } - auto normalized = std::filesystem::absolute(executable).lexically_normal(); - auto const text = normalized.generic_string(); - if (text.rfind("/opt/", 0) == 0 || text.rfind("/usr/", 0) == 0) { - return LinuxInstallKind::package_manager; + if (cleaned.empty() || cleaned == "." || cleaned == "..") { + throw std::invalid_argument("application id has no safe path component"); } - return LinuxInstallKind::portable; + return cleaned; } -[[noreturn]] void RelaunchInstalledAppImage( - std::filesystem::path const& appimage, - std::vector arguments, - std::function release_lease) { - if (!appimage.is_absolute() || !std::filesystem::is_regular_file(appimage)) { - throw std::invalid_argument( - "installed AppImage must be an absolute existing file"); +// Paths, ids, versions, and digests travel both into the generated shell +// script and into its JSON journal lines, so anything that could terminate a +// quoted shell word, start a parameter expansion, or close a JSON string is +// rejected up front instead of escaped. Everyday installation paths — +// spaces, Unicode, hyphens — pass unchanged. +void reject_unsafe_text(char const* what, std::string const& value) { + for (char const ch : value) { + auto const byte = static_cast(ch); + if (byte < 0x20 || ch == '"' || ch == '\'' || ch == '\\' || ch == '$' || + ch == '`') { + throw std::invalid_argument(std::string(what) + + " contains a character that is unsafe in a " + "handoff script"); + } } - if (release_lease) release_lease(); + if (value.empty()) { + throw std::invalid_argument(std::string(what) + " is empty"); + } +} + +std::filesystem::path state_root() { + // XDG defaults: $XDG_STATE_HOME wins, $HOME/.local/state is the fallback. + if (char const* state = std::getenv("XDG_STATE_HOME")) { + if (state[0] != '\0') return std::filesystem::path(state); + } + if (char const* home = std::getenv("HOME")) { + if (home[0] != '\0') { + return std::filesystem::path(home) / ".local" / "state"; + } + } + throw std::runtime_error( + "neither XDG_STATE_HOME nor HOME is set; cannot place the update " + "handoff state directory"); +} + +std::filesystem::path update_root(std::string const& application_id) { + auto root = state_root() / "Rivet" / "updates" / safe_component(application_id); + std::error_code ec; + std::filesystem::create_directories(root, ec); + if (ec) { + throw std::system_error(ec, "could not create the update handoff root"); + } + return root; +} + +std::string absolute_text(std::filesystem::path const& value) { + return std::filesystem::absolute(value).lexically_normal().generic_string(); +} + +std::string json_string(std::string const& value) { + // reject_unsafe_text already removed the characters JSON would escape; + // this stays defensive for future callers. + std::string escaped; + escaped.reserve(value.size() + 2); + escaped.push_back('"'); + for (char const ch : value) { + switch (ch) { + case '"': escaped += "\\\""; break; + case '\\': escaped += "\\\\"; break; + default: + if (static_cast(ch) < 0x20) { + char buffer[8]; + std::snprintf(buffer, sizeof(buffer), "\\u%04x", + static_cast(static_cast(ch))); + escaped += buffer; + } else { + escaped.push_back(ch); + } + } + } + escaped.push_back('"'); + return escaped; +} - auto executable = appimage.string(); +std::string journal_line(char const* phase, + std::string const& application_id, + std::string const& version, + std::string const& artifact_sha256, + std::string const& downloaded, + std::string const& backup) { + // Field names and phases mirror rivet/distribution's install journal + // (schema 1) so an interrupted handoff is recoverable with + // recover-platform-installation! once the caller rebuilds the plan. + std::string digest = artifact_sha256; + for (auto& ch : digest) ch = static_cast(std::tolower( + static_cast(ch))); + return "{\"schema\":1,\"application_id\":" + json_string(application_id) + + ",\"version\":" + json_string(version) + + ",\"artifact_sha256\":" + json_string(digest) + + ",\"downloaded_path\":" + json_string(downloaded) + + ",\"backup_path\":" + json_string(backup) + + ",\"phase\":\"" + phase + "\"}"; +} + +std::string sh_single_quoted(std::string const& value) { + // reject_unsafe_text has already rejected the quote character, so plain + // single quotes are inert even for arguments containing spaces. + return "'" + value + "'"; +} + +std::string journal_writer(char const* phase, + std::string const& record) { + return std::string(" ") + phase + ") printf '%s\\n' " + + sh_single_quoted(record) + " > \"$RIVET_JOURNAL\" ;;\n"; +} + +std::string handoff_script(std::string const& application_id, + std::string const& version, + std::string const& artifact_sha256, + std::string const& downloaded, + std::string const& backup, + unsigned exit_timeout_seconds, + unsigned settle_seconds, + bool rollback_allowed, + std::vector const& relaunch_arguments) { + // The journal records every phase the runtime transaction would write, so + // a handoff that dies mid-flight stays auditable by the shared recovery + // tooling. Phases that never ran simply never reach the file. + std::string writers; + for (char const* phase : + {"prepared", "installing", "installed", "restarting", "restarted", + "checking", "healthy", "committing", "committed", "rollback-started", + "rolled-back", "rollback-failed"}) { + writers += journal_writer(phase, + journal_line(phase, application_id, version, + artifact_sha256, downloaded, + backup)); + } + + std::string arguments; + for (auto const& argument : relaunch_arguments) { + reject_unsafe_text("relaunch argument", argument); + arguments += " " + sh_single_quoted(argument); + } + + std::string script; + script += "#!/bin/sh\n"; + script += "# Rivet AppImage handoff - generated; safe to delete.\n"; + script += "# Performs the same atomic replace and journal phases as\n"; + script += "# execute-platform-installation!, out of process, so the\n"; + script += "# updating application can exit before its own files move.\n"; + script += "set -u\n"; + script += "RIVET_TARGET=\"$1\"\n"; + script += "RIVET_BACKUP=\"$2\"\n"; + script += "RIVET_EMPTY=\"$3\"\n"; + script += "RIVET_STAGING=\"$4\"\n"; + script += "RIVET_JOURNAL=\"$5\"\n"; + script += "RIVET_FAILURE=\"$6\"\n"; + script += "RIVET_CANDIDATE=\"$7\"\n"; + script += "RIVET_OLD_PID=\"$8\"\n"; + script += "RIVET_LOCK=\"$9\"\n"; + script += "rivet_write_journal() {\n case \"$1\" in\n" + writers + + " esac\n}\n"; + script += "rivet_fail() {\n"; + script += " printf '%s\\n' \"$1\" > \"$RIVET_FAILURE\"\n"; + script += " exit 1\n"; + script += "}\n"; + // A missing owner PID means the lock is stale from a dead helper; a live + // owner means another handoff owns the installation right now. + script += "rivet_lock_acquired() {\n"; + script += " if mkdir \"$RIVET_LOCK\" 2>/dev/null; then\n"; + script += " printf '%s\\n' \"$RIVET_OLD_PID\" > \"$RIVET_LOCK/pid\"\n"; + script += " return 0\n"; + script += " fi\n"; + script += " RIVET_OTHER=$(cat \"$RIVET_LOCK/pid\" 2>/dev/null)\n"; + script += " if [ -n \"$RIVET_OTHER\" ] && ! kill -0 \"$RIVET_OTHER\" " + "2>/dev/null; then\n"; + script += " rm -rf \"$RIVET_LOCK\"\n"; + script += " if mkdir \"$RIVET_LOCK\" 2>/dev/null; then\n"; + script += " printf '%s\\n' \"$RIVET_OLD_PID\" > \"$RIVET_LOCK/pid\"\n"; + script += " return 0\n"; + script += " fi\n"; + script += " fi\n"; + script += " return 1\n"; + script += "}\n"; + script += "if ! rivet_lock_acquired; then\n"; + script += " rivet_fail transaction-busy\n"; + script += "fi\n"; + script += "trap 'rm -rf \"$RIVET_LOCK\"' EXIT\n"; + script += "# A previous handoff that died between the two renames must not\n"; + script += "# leave the launch path without an executable: bring the\n"; + script += "# installation back to a runnable state before starting.\n"; + script += "rivet_preflight() {\n"; + script += " if [ ! -f \"$RIVET_TARGET\" ] && [ -f \"$RIVET_BACKUP\" ]; then\n"; + script += " mv -f \"$RIVET_BACKUP\" \"$RIVET_TARGET\"\n"; + script += " fi\n"; + script += " # Target and backup together mean the swap completed but the\n"; + script += " # commit did not; the target is already the verified candidate,\n"; + script += " # so dropping the backup finishes that transaction.\n"; + script += " if [ -f \"$RIVET_TARGET\" ] && [ -f \"$RIVET_BACKUP\" ]; then\n"; + script += " rm -f \"$RIVET_BACKUP\"\n"; + script += " fi\n"; + script += " rm -rf \"$RIVET_STAGING\"\n"; + script += " rm -f \"$RIVET_JOURNAL\"\n"; + script += "}\n"; + script += "rivet_preflight\n"; + script += "rivet_write_journal prepared\n"; + script += "rivet_n=0\n"; + script += "while kill -0 \"$RIVET_OLD_PID\" 2>/dev/null; do\n"; + script += " if [ \"$rivet_n\" -ge " + std::to_string( + exit_timeout_seconds == 0 + ? 1 + : exit_timeout_seconds) + + " ]; then\n"; + script += " rivet_fail process-exit-timeout\n"; + script += " fi\n"; + script += " sleep 1\n"; + script += " rivet_n=$((rivet_n + 1))\n"; + script += "done\n"; + script += "rivet_write_journal installing\n"; + script += "mkdir -p \"$RIVET_STAGING\"\n"; + script += "cp \"$RIVET_CANDIDATE\" " + "\"$RIVET_STAGING/application.AppImage\" || rivet_fail " + "staging-failed\n"; + script += "chmod 755 \"$RIVET_STAGING/application.AppImage\"\n"; + script += "RIVET_ACTUAL=$(sha256sum \"$RIVET_STAGING/application.AppImage\" " + "2>/dev/null | cut -d' ' -f1)\n"; + script += "if [ \"$RIVET_ACTUAL\" != " + sh_single_quoted(artifact_sha256) + + " ]; then\n"; + script += " rivet_fail candidate-mismatch\n"; + script += "fi\n"; + script += "if [ -f \"$RIVET_TARGET\" ]; then\n"; + script += " mv -f \"$RIVET_TARGET\" \"$RIVET_BACKUP\" || rivet_fail " + "replace-backup-failed\n"; + script += "else\n"; + script += " printf 'no previous installation\\n' > \"$RIVET_EMPTY\"\n"; + script += "fi\n"; + script += "if ! mv -f \"$RIVET_STAGING/application.AppImage\" " + "\"$RIVET_TARGET\"; then\n"; + script += " if [ " + + std::string(rollback_allowed ? "'1' = '1'" : "'0' = '1'") + + " ]; then\n"; + script += " rivet_write_journal rollback-started\n"; + script += " if [ -f \"$RIVET_BACKUP\" ]; then\n"; + script += " rm -f \"$RIVET_TARGET\"\n"; + script += " mv -f \"$RIVET_BACKUP\" \"$RIVET_TARGET\"\n"; + script += " elif [ -f \"$RIVET_EMPTY\" ]; then\n"; + script += " rm -f \"$RIVET_TARGET\" \"$RIVET_EMPTY\"\n"; + script += " fi\n"; + script += " rivet_write_journal rolled-back\n"; + script += " rm -f \"$RIVET_JOURNAL\"\n"; + script += " fi\n"; + script += " rivet_fail replace-install-failed\n"; + script += "fi\n"; + script += "rivet_write_journal installed\n"; + script += "rivet_write_journal restarting\n"; + script += "\"$RIVET_TARGET\"" + arguments + " /dev/null; then\n"; + script += " rivet_write_journal healthy\n"; + script += " rivet_write_journal committing\n"; + script += " rm -f \"$RIVET_BACKUP\" \"$RIVET_EMPTY\"\n"; + script += " rivet_write_journal committed\n"; + script += " rm -f \"$RIVET_JOURNAL\"\n"; + script += " exit 0\n"; + script += "fi\n"; + script += "if [ " + + std::string(rollback_allowed ? "'1' = '1'" : "'0' = '1'") + + " ]; then\n"; + script += " rivet_write_journal rollback-started\n"; + script += " if [ -f \"$RIVET_BACKUP\" ]; then\n"; + script += " rm -f \"$RIVET_TARGET\"\n"; + script += " mv -f \"$RIVET_BACKUP\" \"$RIVET_TARGET\"\n"; + script += " elif [ -f \"$RIVET_EMPTY\" ]; then\n"; + script += " rm -f \"$RIVET_TARGET\" \"$RIVET_EMPTY\"\n"; + script += " fi\n"; + script += " rivet_write_journal rolled-back\n"; + script += " rm -f \"$RIVET_JOURNAL\"\n"; + script += " rivet_fail replacement-unhealthy\n"; + script += "else\n"; + script += " rivet_fail replacement-unhealthy-rollback-forbidden\n"; + script += "fi\n"; + return script; +} + +std::optional newest_failure( + std::filesystem::path const& root) { + std::optional result; + std::filesystem::file_time_type newest{}; + std::error_code ec; + for (auto const& entry : std::filesystem::directory_iterator(root, ec)) { + auto marker = entry.path() / "failure.txt"; + if (!entry.is_directory(ec) || !std::filesystem::is_regular_file(marker, ec)) { + continue; + } + auto stamp = std::filesystem::last_write_time(marker, ec); + if (!ec && (!result || stamp > newest)) { + result = marker; + newest = stamp; + } + } + return result; +} + +// Detaches /bin/sh on the generated script. Returns only on success; +// failures throw std::system_error after cleaning the attempt directory. +void launch_detached(std::filesystem::path const& attempt, + std::filesystem::path const& script_path, + std::filesystem::path const& log_path, + std::vector const& arguments) { + int error_pipe[2]; + if (::pipe(error_pipe) != 0) { + throw std::system_error(errno, std::generic_category(), + "could not create the handoff error pipe"); + } + if (::fcntl(error_pipe[1], F_SETFD, FD_CLOEXEC) != 0) { + auto const error = errno; + ::close(error_pipe[0]); + ::close(error_pipe[1]); + throw std::system_error(error, std::generic_category(), + "could not guard the handoff error pipe"); + } + + std::vector storage; + storage.reserve(arguments.size() + 2); + storage.push_back("sh"); + storage.push_back(script_path.string()); + for (auto const& argument : arguments) storage.push_back(argument); std::vector argv; - argv.reserve(arguments.size() + 2); - argv.push_back(executable.data()); - for (auto& argument : arguments) argv.push_back(argument.data()); + argv.reserve(storage.size() + 1); + for (auto& value : storage) argv.push_back(value.data()); argv.push_back(nullptr); - ::execv(executable.c_str(), argv.data()); - throw std::system_error(errno, std::generic_category(), - "could not relaunch installed AppImage"); + + pid_t const child = ::fork(); + if (child < 0) { + auto const error = errno; + ::close(error_pipe[0]); + ::close(error_pipe[1]); + throw std::system_error(error, std::generic_category(), + "could not fork the AppImage handoff"); + } + if (child == 0) { + ::close(error_pipe[0]); + int const devnull = ::open("/dev/null", O_RDONLY); + if (devnull >= 0) { + ::dup2(devnull, STDIN_FILENO); + if (devnull != STDIN_FILENO) ::close(devnull); + } + int const log = ::open(log_path.c_str(), O_WRONLY | O_CREAT | O_APPEND, + 0600); + if (log >= 0) { + ::dup2(log, STDOUT_FILENO); + ::dup2(log, STDERR_FILENO); + if (log != STDOUT_FILENO && log != STDERR_FILENO) ::close(log); + } + ::setsid(); + // Every path the script sees is absolute, so the working directory of + // the detached helper does not matter. + ::execv("/bin/sh", argv.data()); + int const error = errno; + ssize_t const ignored = ::write(error_pipe[1], &error, sizeof(error)); + static_cast(ignored); + ::_exit(127); + } + + ::close(error_pipe[1]); + int exec_error = 0; + ssize_t const read_bytes = + ::read(error_pipe[0], &exec_error, sizeof(exec_error)); + ::close(error_pipe[0]); + if (read_bytes == static_cast(sizeof(exec_error))) { + int status = 0; + ::waitpid(child, &status, 0); + std::error_code ignored; + std::filesystem::remove_all(attempt, ignored); + throw std::system_error(exec_error, std::generic_category(), + "could not execute the AppImage handoff script"); + } +} + +} // namespace + +std::optional AppImageHandoff::ConsumeFailure( + std::string const& application_id) { + auto const marker = newest_failure(update_root(application_id)); + if (!marker) return std::nullopt; + std::ifstream input(*marker, std::ios::binary); + std::string bytes((std::istreambuf_iterator(input)), + std::istreambuf_iterator()); + std::error_code ignored; + std::filesystem::remove(*marker, ignored); + if (!bytes.empty() && bytes.back() == '\n') bytes.pop_back(); + if (bytes.empty()) bytes = "update-install-failed"; + return Failure{bytes}; +} + +void AppImageHandoff::CleanupStaleAttempts(std::string const& application_id, + unsigned max_age_hours) { + auto const root = update_root(application_id); + auto const cutoff = std::filesystem::file_time_type::clock::now() - + std::chrono::hours(max_age_hours); + std::error_code ec; + for (auto const& entry : std::filesystem::directory_iterator(root, ec)) { + if (!entry.is_directory(ec)) continue; + auto const stamp = entry.last_write_time(ec); + if (!ec && stamp < cutoff) { + std::filesystem::remove_all(entry.path(), ec); + ec.clear(); + } + } +} + +void AppImageHandoff::LaunchReplacement( + std::string const& application_id, + std::filesystem::path const& verified_appimage, + std::filesystem::path const& target_appimage, + std::function release_lease, + unsigned exit_timeout_seconds, + AppImageHandoffOptions const& options) { + reject_unsafe_text("application id", application_id); + reject_unsafe_text("signed version", options.version); + std::string artifact_sha256 = options.artifact_sha256; + for (auto& ch : artifact_sha256) { + ch = static_cast(std::tolower(static_cast(ch))); + } + if (artifact_sha256.size() != 64) { + throw std::invalid_argument( + "signed artifact digest must be 64 hexadecimal characters"); + } + for (char const ch : artifact_sha256) { + if (!std::isxdigit(static_cast(ch))) { + throw std::invalid_argument( + "signed artifact digest must be 64 hexadecimal characters"); + } + } + if (!verified_appimage.is_absolute() || + !std::filesystem::is_regular_file(verified_appimage)) { + throw std::invalid_argument( + "verified AppImage must be an absolute existing file"); + } + if (!target_appimage.is_absolute()) { + throw std::invalid_argument("target AppImage must be an absolute path"); + } + + auto const root = update_root(application_id); + auto const now = + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + auto const attempt = + root / (std::to_string(::getpid()) + "-" + std::to_string(now)); + std::error_code ec; + std::filesystem::create_directories(attempt, ec); + if (ec) { + throw std::system_error(ec, "could not create the handoff attempt directory"); + } + + // The swap only renames within the target directory, so the candidate is + // staged beside the target first — exactly what stage-appimage! does for + // the in-process transaction. Same-filesystem renames stay atomic even + // when the download cache lives on another mount. + auto const target_text = absolute_text(target_appimage); + std::filesystem::path const backup(target_text + ".rivet-backup"); + std::filesystem::path const staging(target_text + ".rivet-staging"); + std::filesystem::path const empty(target_text + ".rivet-backup.empty"); + std::filesystem::path const lock(target_text + ".rivet-handoff-lock"); + auto const script_path = attempt / "handoff.sh"; + auto const log_path = attempt / "handoff.log"; + auto const failure_path = attempt / "failure.txt"; + // Deterministic sibling path: the next launch checks exactly this file to + // decide whether an interrupted handoff needs recovery, and the preflight + // clears it once the installation is physically consistent again. + std::filesystem::path const journal(target_text + ".rivet-handoff-journal"); + + auto const script = handoff_script( + application_id, options.version, artifact_sha256, + absolute_text(verified_appimage), absolute_text(backup), + exit_timeout_seconds, options.settle_seconds, options.rollback_allowed, + options.relaunch_arguments); + { + std::ofstream output(script_path, std::ios::binary | std::ios::trunc); + output.write(script.data(), static_cast(script.size())); + output.close(); + if (!output) { + std::error_code cleanup; + std::filesystem::remove_all(attempt, cleanup); + throw std::system_error(std::make_error_code(std::errc::io_error), + "could not write the AppImage handoff script"); + } + if (::chmod(script_path.c_str(), 0700) != 0) { + auto const error = errno; + std::error_code cleanup; + std::filesystem::remove_all(attempt, cleanup); + throw std::system_error(error, std::generic_category(), + "could not guard the AppImage handoff script"); + } + } + + std::vector arguments; + arguments.push_back(absolute_text(target_appimage)); + arguments.push_back(absolute_text(backup)); + arguments.push_back(absolute_text(empty)); + arguments.push_back(absolute_text(staging)); + arguments.push_back(absolute_text(journal)); + arguments.push_back(absolute_text(failure_path)); + arguments.push_back(absolute_text(verified_appimage)); + arguments.push_back(std::to_string(::getpid())); + arguments.push_back(absolute_text(lock)); + + try { + launch_detached(attempt, script_path, log_path, arguments); + } catch (...) { + std::error_code cleanup; + std::filesystem::remove_all(attempt, cleanup); + throw; + } + if (release_lease) release_lease(); } } // namespace rivet::update diff --git a/platform/linux/update/update_handoff.hpp b/platform/linux/update/update_handoff.hpp index 8ef8509..bf58e39 100644 --- a/platform/linux/update/update_handoff.hpp +++ b/platform/linux/update/update_handoff.hpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -24,4 +25,68 @@ LinuxInstallKind DetectLinuxInstallKind( std::vector arguments = {}, std::function release_lease = {}); +// Options carried into the generated AppImage handoff. `version` and +// `artifact_sha256` are the signed manifest's fields; they only feed the +// transaction journal so an interrupted handoff stays auditable and +// recoverable by rivet/distribution's recovery tooling. `rollback_allowed` +// mirrors the signed manifest's rollback policy: when false the handoff never +// restores the replaced AppImage and leaves the failure for the next launch +// instead. +struct AppImageHandoffOptions final { + std::vector relaunch_arguments; + std::string version; + std::string artifact_sha256; + bool rollback_allowed = true; + unsigned settle_seconds = 5; +}; + +// Process-external AppImage replacement, the Linux counterpart of +// MsiHandoff. A GTK host that exits to apply an update must not run the +// replacement itself: a crash between moving the old AppImage aside and +// moving the new one into place would leave no executable at the launch +// path, and the dead process could never run recovery itself. The handoff +// instead writes a generated first-party shell script into a +// process-specific attempt directory and detaches it, so the running process +// can simply exit after releasing its single-instance lease. +// +// The detached script waits a bounded time for the launching process to +// exit, performs the same atomic replace as +// `execute-platform-installation!` (staging copy, renamed swap, journal +// phases, idempotent rollback), relaunches the replacement, commits only +// after it survives the settle window, and restores the previous AppImage +// otherwise. Attempt directories carry a `failure.txt` marker for the next +// successful launch to consume. +class AppImageHandoff final { + public: + struct Failure final { + std::string message; + }; + + // Reads and removes the newest failure marker across attempt directories, + // or returns nullopt when no attempt failed. An empty marker reads as + // "update-install-failed". + static std::optional ConsumeFailure( + std::string const& application_id); + + // Removes attempt directories older than `max_age_hours`. + static void CleanupStaleAttempts(std::string const& application_id, + unsigned max_age_hours = 24); + + // Detaches the handoff for an AppImage update whose artifact has already + // been authenticated and size/SHA-256 verified by the managed updater. + // `verified_appimage` is the downloaded candidate; `target_appimage` is the + // currently installed path (typically the APPIMAGE environment value). The + // script waits `exit_timeout_seconds` for this process to exit, so the + // caller must exit promptly: release the single-instance lease through + // `release_lease` (which runs once the helper is confirmed running) and + // return. Throws std::system_error if the helper cannot be created. + static void LaunchReplacement( + std::string const& application_id, + std::filesystem::path const& verified_appimage, + std::filesystem::path const& target_appimage, + std::function release_lease = {}, + unsigned exit_timeout_seconds = 30, + AppImageHandoffOptions const& options = {}); +}; + } // namespace rivet::update diff --git a/rivet/tests/linux-update-handoff.rkt b/rivet/tests/linux-update-handoff.rkt new file mode 100644 index 0000000..9edc9e2 --- /dev/null +++ b/rivet/tests/linux-update-handoff.rkt @@ -0,0 +1,84 @@ +#lang racket/base + +;; Guards the first-party Linux AppImage handoff (issue #290). The shell +;; script the helper generates is the crash-safe counterpart of the Windows +;; MsiHandoff install.cmd; these assertions pin the lifecycle steps that +;; every GTK host depends on, on hosts that cannot run the Linux suite. + +(require rackunit + racket/file + racket/runtime-path + racket/string) + +(define-runtime-path repository-root "../..") +(define source + (file->string + (build-path repository-root "platform" "linux" "update" + "update_handoff.cpp"))) +(define header + (file->string + (build-path repository-root "platform" "linux" "update" + "update_handoff.hpp"))) +(define linux-sources + (file->string + (build-path repository-root "platform" "linux" + "rivet_linux_sources.cmake"))) + +;; Detached lifecycle: the helper waits a bounded time for the launching +;; process, then drives the transaction out of process. +(for ([required (in-list '("kill -0 \\\"$RIVET_OLD_PID\\\"" + "process-exit-timeout" + "sleep 1" + "transaction-busy" + "rivet_lock_acquired" + "rivet_preflight" + ".rivet-backup" + ".rivet-backup.empty" + ".rivet-staging" + ".rivet-handoff-journal" + "no previous installation" + "sha256sum" + "candidate-mismatch" + "replace-backup-failed" + "replace-install-failed" + "replacement-unhealthy" + "replacement-unhealthy-rollback-forbidden" + "failure.txt" + "mv -f \\\"$RIVET_TARGET\\\" \\\"$RIVET_BACKUP\\\"" + "mv -f \\\"$RIVET_STAGING/application.AppImage\\\""))]) + (check-true (string-contains? source required) + (format "Linux handoff lost required lifecycle step: ~a" + required))) + +;; Journal phases stay aligned with rivet/distribution's schema-1 install +;; journal so recovery tooling can consume an interrupted handoff. +(for ([phase (in-list '("prepared" "installing" "installed" "restarting" + "restarted" "checking" "healthy" "committing" + "committed" "rollback-started" "rolled-back" + "rollback-failed"))]) + (check-true (string-contains? source (format "\"~a\"" phase)) + (format "Linux handoff journal lost phase: ~a" phase))) + +;; Detached execution and the lease-release ordering the docs promise. +(for ([required (in-list '("setsid" + "execv" + "/bin/sh" + "release_lease" + "XDG_STATE_HOME"))]) + (check-true (string-contains? source required) + (format "Linux handoff lost required execution step: ~a" + required))) + +;; The public lifecycle mirrors MsiHandoff: launch, consume the failure +;; marker, clean stale attempts. +(for ([required (in-list '("ConsumeFailure" + "CleanupStaleAttempts" + "LaunchReplacement" + "AppImageHandoffOptions" + "rollback_allowed" + "settle_seconds"))]) + (check-true (string-contains? header required) + (format "Linux handoff header lost required API: ~a" required))) + +(check-true (string-contains? linux-sources "update/update_handoff.cpp") + "the released Linux platform library must compile the handoff")