Verify restored destinations before completing updater recovery

A successful rename is not by itself the recovered-byte postcondition. Keep the snapshot and suppress activation when the recorded destination is still a link or no longer matches the saved bytes; connected no-op and changed-destination controls exercise that boundary.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-06 03:09:38 +01:00
parent cf92426ec1
commit 79d6ad642a
3 changed files with 12 additions and 3 deletions
@@ -6421,6 +6421,8 @@ Installer failure, mismatched version and failure to restore a previously active
service share one rollback path. It stops the service, stages and compares all
saved files before any replacement, and renames each into its recorded original
path without following a new symlink or treating a directory as a destination.
Every restored destination must itself be regular and byte-equal to its saved
copy before recovery may activate the service or discard the backup.
Files absent before the attempt remain absent after recovery, including the
legacy/current binary alternative and `VERSION`. Metadata is preserved. A
partial restore must not invoke the RETURN restart backstop. The backup remains
@@ -647,6 +647,8 @@ func TestAutoUpdateBackupTransaction(t *testing.T) {
{name: "rollback_copy_failure", fault: "restore_copy", outcome: "failure", retained: true},
{name: "corrupt_rollback_copy", fault: "restore_corrupt", outcome: "failure", retained: true},
{name: "rollback_rename_failure", fault: "restore_rename", outcome: "failure", retained: true},
{name: "rollback_rename_noop", fault: "restore_noop", outcome: "failure", retained: true},
{name: "rollback_destination_changed", fault: "restore_changed", outcome: "failure", retained: true},
{name: "version_restore_failure", fault: "version_rename", outcome: "failure", retained: true},
{name: "new_destination_directory", outcome: "directory", retained: true},
{name: "successful_update", success: true},
@@ -773,8 +775,10 @@ cp() {
mv() {
local dest="${*: -1}"
if [[ "$FAULT" == restore_rename && "$dest" == "$INSTALL_DIR/bin/pulse" ]]; then return 1; fi
if [[ "$FAULT" == restore_noop && "$dest" == "$INSTALL_DIR/bin/pulse" ]]; then return 0; fi
if [[ "$FAULT" == version_rename && "$dest" == "$INSTALL_DIR/VERSION" ]]; then return 1; fi
command mv "$@"
command mv "$@" || return
if [[ "$FAULT" == restore_changed && "$dest" == "$INSTALL_DIR/bin/pulse" ]]; then printf 'changed\n' > "$dest"; fi
}
curl() {
local out="" url="" arg
@@ -840,7 +844,7 @@ else
[[ -z "$backup" || ! -e "$backup" ]] || { echo 'unneeded backup leaked'; exit 1; }
fi
# Incomplete file restoration must not activate an unverified executable.
case "$FAULT" in restore_tmp|restore_copy|restore_corrupt|restore_rename|version_rename|stop)
case "$FAULT" in restore_tmp|restore_copy|restore_corrupt|restore_rename|restore_noop|restore_changed|version_rename|stop)
if [[ -f "$FIXTURE_DIR/service-operations" ]]; then
! grep -Eq '^(start|restart):' "$FIXTURE_DIR/service-operations"
fi
+4 -1
View File
@@ -552,7 +552,10 @@ perform_update() {
done
for i in "${!file_paths[@]}"; do
if [[ "${file_present[i]}" == "true" ]]; then
if [[ -d "${file_paths[i]}" ]] || ! mv -f -- "${restore_files[i]}" "${file_paths[i]}"; then
if [[ -d "${file_paths[i]}" ]] ||
! mv -f -- "${restore_files[i]}" "${file_paths[i]}" ||
[[ -L "${file_paths[i]}" ]] ||
! cmp -s -- "$backup_dir/${backup_names[i]}" "${file_paths[i]}"; then
log error "Could not restore ${file_paths[i]}; backup retained at $backup_dir; manual recovery required"
return 1
fi