diff --git a/component/updater/update_core.go b/component/updater/update_core.go index 83a303af5c..83760332aa 100644 --- a/component/updater/update_core.go +++ b/component/updater/update_core.go @@ -7,7 +7,6 @@ import ( "fmt" "io" "os" - "os/exec" "path/filepath" "runtime" "strings" @@ -81,6 +80,10 @@ func (u *CoreUpdater) Update(currentExePath string, channel string, force bool) u.mu.Lock() defer u.mu.Unlock() + currentExePath, err = filepath.EvalSymlinks(currentExePath) + if err != nil { + return fmt.Errorf("resolve currentExePath: %w", err) + } info, err := os.Stat(currentExePath) if err != nil { return fmt.Errorf("check currentExePath %q: %w", currentExePath, err) @@ -145,6 +148,12 @@ func (u *CoreUpdater) Update(currentExePath string, channel string, force bool) updateExePath := filepath.Join(updateDir, updateExeName) backupExePath := filepath.Join(backupDir, filepath.Base(currentExePath)) + if err = os.RemoveAll(updateDir); err != nil { + return fmt.Errorf("cleaning stale update directory: %w", err) + } + if cleanupErr := cleanStagedFiles(currentExePath); cleanupErr != nil { + log.Warnln("updater: cleaning stale replacement files: %v", cleanupErr) + } defer u.clean(updateDir) err = u.download(updateDir, packagePath, packageURL) @@ -157,13 +166,29 @@ func (u *CoreUpdater) Update(currentExePath string, channel string, force bool) return fmt.Errorf("unpacking: %w", err) } - err = u.backup(currentExePath, backupExePath, backupDir) + metadata := metadataFromFileInfo(info) + fileOps := defaultFileOperations() + stagedPath, err := stageFile(updateExePath, currentExePath, metadata, true, fileOps) + if err != nil { + return fmt.Errorf("staging replacement: %w", err) + } + defer func() { + _ = os.Remove(stagedPath) + }() + + currentMoved, err := u.backup(currentExePath, backupExePath, backupDir, fileOps) if err != nil { - return fmt.Errorf("backuping: %w", err) + return fmt.Errorf("backing up: %w", err) } - err = u.copyFile(updateExePath, currentExePath) + replaced, err := commitStagedFile(stagedPath, currentExePath, fileOps) if err != nil { + if currentMoved || replaced { + rollbackErr := u.rollback(backupExePath, currentExePath, metadata, fileOps) + if rollbackErr != nil { + return fmt.Errorf("replacing: %w; rollback failed: %v", err, rollbackErr) + } + } return fmt.Errorf("replacing: %w", err) } @@ -268,23 +293,46 @@ func (u *CoreUpdater) unpack(updateDir, packagePath string, fileMode os.FileMode } // backup creates a backup of the current executable file. -func (u *CoreUpdater) backup(currentExePath, backupExePath, backupDir string) (err error) { +func (u *CoreUpdater) backup(currentExePath, backupExePath, backupDir string, fileOps fileOperations) (currentMoved bool, err error) { log.Infoln("updater: backing up current ExecFile:%s to %s", currentExePath, backupExePath) - _ = os.Mkdir(backupDir, 0o755) + err = os.MkdirAll(backupDir, 0o755) + if err != nil { + return false, fmt.Errorf("creating backup directory: %w", err) + } // On Windows, since the running executable cannot be overwritten or deleted, it uses os.Rename to move the file to the backup path. // On other platforms, it copies the file to the backup path, preserving the original file and its permissions. // The backup directory is created if it does not exist. if runtime.GOOS == "windows" { - err = os.Rename(currentExePath, backupExePath) + err = os.Remove(backupExePath) + if err != nil && !os.IsNotExist(err) { + return false, fmt.Errorf("removing previous backup: %w", err) + } + err = fileOps.rename(currentExePath, backupExePath) + if err == nil { + currentMoved = true + } } else { - err = u.copyFile(currentExePath, backupExePath) + _, err = atomicCopyFile(currentExePath, backupExePath, fileOps) } if err != nil { - return err + return currentMoved, err } - return nil + return currentMoved, nil +} + +// rollback restores the complete backup when replacement changed or moved the +// current executable before a later operation failed. +func (u *CoreUpdater) rollback(backupExePath, currentExePath string, metadata fileMetadata, fileOps fileOperations) error { + log.Warnln("updater: rolling back %s from %s", currentExePath, backupExePath) + + if runtime.GOOS == "windows" { + return fileOps.rename(backupExePath, currentExePath) + } + + _, err := atomicReplaceFile(backupExePath, currentExePath, metadata, false, fileOps) + return err } // clean removes the temporary directory itself and all it's contents. @@ -413,62 +461,3 @@ func (u *CoreUpdater) zipFileUnpack(zipfile, outDir string, fileMode os.FileMode return outputName, nil } - -// Copy file on disk -func (u *CoreUpdater) copyFile(src, dst string) (err error) { - rc, err := os.Open(src) - if err != nil { - return fmt.Errorf("os.Open(%s): %w", src, err) - } - - defer func() { - closeErr := rc.Close() - if closeErr != nil && err == nil { - err = closeErr - } - }() - - info, err := rc.Stat() - if err != nil { - return fmt.Errorf("rc.Stat(): %w", err) - } - - // Create the output file - // If the file does not exist, creates it with permissions perm (before umask); - // otherwise truncates it before writing, without changing permissions. - wc, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode()) - if err != nil { - // On some file system (such as Android's /data) maybe return error: "text file busy" - // Let's delete the target file and recreate it - err = os.Remove(dst) - if err != nil { - return fmt.Errorf("os.Remove(%s): %w", dst, err) - } - wc, err = os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode()) - if err != nil { - return fmt.Errorf("os.OpenFile(%s): %w", dst, err) - } - } - - defer func() { - closeErr := wc.Close() - if closeErr != nil && err == nil { - err = closeErr - } - }() - - _, err = io.Copy(wc, rc) - if err != nil { - return fmt.Errorf("io.Copy(): %w", err) - } - - if runtime.GOOS == "darwin" { - err = exec.Command("/usr/bin/codesign", "--sign", "-", dst).Run() - if err != nil { - log.Warnln("codesign failed: %v", err) - } - } - - log.Infoln("updater: copy: %s to %s", src, dst) - return nil -} diff --git a/component/updater/update_core_file.go b/component/updater/update_core_file.go new file mode 100644 index 0000000000..262007e2b7 --- /dev/null +++ b/component/updater/update_core_file.go @@ -0,0 +1,179 @@ +package updater + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +type fileMetadata struct { + mode os.FileMode + ownership fileOwnership +} + +type fileOperations struct { + copy func(io.Writer, io.Reader) (int64, error) + rename func(string, string) error + sign func(string) error + verify func(string) error + syncDir func(string) error +} + +func defaultFileOperations() fileOperations { + return fileOperations{ + copy: io.Copy, + rename: os.Rename, + sign: signCoreFile, + verify: verifyCoreFile, + syncDir: syncDirectory, + } +} + +func metadataFromFileInfo(info os.FileInfo) fileMetadata { + return fileMetadata{ + mode: info.Mode(), + ownership: ownershipFromFileInfo(info), + } +} + +// stageFile copies src into a unique file beside dst. The destination is not +// touched until commitStagedFile atomically renames the fully prepared file. +func stageFile(src, dst string, metadata fileMetadata, sign bool, fileOps fileOperations) (tempPath string, err error) { + srcFile, err := os.Open(src) + if err != nil { + return "", fmt.Errorf("opening source %s: %w", src, err) + } + srcOpen := true + defer func() { + if srcOpen { + _ = srcFile.Close() + } + }() + + tempFile, err := os.CreateTemp(filepath.Dir(dst), stagedFilePrefix(dst)) + if err != nil { + return "", fmt.Errorf("creating replacement beside %s: %w", dst, err) + } + createdPath := tempFile.Name() + tempPath = createdPath + tempOpen := true + keepTemp := false + defer func() { + if tempOpen { + _ = tempFile.Close() + } + if !keepTemp { + _ = os.Remove(createdPath) + } + }() + + if _, err = fileOps.copy(tempFile, srcFile); err != nil { + return "", fmt.Errorf("copying %s: %w", src, err) + } + if err = tempFile.Sync(); err != nil { + return "", fmt.Errorf("syncing replacement %s: %w", tempPath, err) + } + if err = tempFile.Close(); err != nil { + tempOpen = false + return "", fmt.Errorf("closing replacement %s: %w", tempPath, err) + } + tempOpen = false + if err = srcFile.Close(); err != nil { + srcOpen = false + return "", fmt.Errorf("closing source %s: %w", src, err) + } + srcOpen = false + + if err = applyFileMetadata(tempPath, metadata); err != nil { + return "", err + } + if sign { + if err = fileOps.sign(tempPath); err != nil { + return "", fmt.Errorf("signing replacement: %w", err) + } + // codesign may rewrite the file. Reapply trusted metadata before the + // final sync and verification. + if err = applyFileMetadata(tempPath, metadata); err != nil { + return "", err + } + } + if err = syncFile(tempPath); err != nil { + return "", err + } + if sign { + if err = fileOps.verify(tempPath); err != nil { + return "", fmt.Errorf("verifying replacement signature: %w", err) + } + } + + keepTemp = true + return tempPath, nil +} + +func applyFileMetadata(path string, metadata fileMetadata) error { + if err := applyFileOwnership(path, metadata.ownership); err != nil { + return fmt.Errorf("preserving ownership on %s: %w", path, err) + } + + mode := metadata.mode.Perm() | metadata.mode&(os.ModeSetuid|os.ModeSetgid|os.ModeSticky) + if err := os.Chmod(path, mode); err != nil { + return fmt.Errorf("preserving mode on %s: %w", path, err) + } + return nil +} + +func commitStagedFile(tempPath, dst string, fileOps fileOperations) (replaced bool, err error) { + if err = fileOps.rename(tempPath, dst); err != nil { + return false, fmt.Errorf("renaming %s to %s: %w", tempPath, dst, err) + } + if err = fileOps.syncDir(filepath.Dir(dst)); err != nil { + return true, fmt.Errorf("syncing destination directory: %w", err) + } + return true, nil +} + +func atomicReplaceFile(src, dst string, metadata fileMetadata, sign bool, fileOps fileOperations) (replaced bool, err error) { + tempPath, err := stageFile(src, dst, metadata, sign, fileOps) + if err != nil { + return false, err + } + defer func() { + _ = os.Remove(tempPath) + }() + return commitStagedFile(tempPath, dst, fileOps) +} + +func atomicCopyFile(src, dst string, fileOps fileOperations) (replaced bool, err error) { + info, err := os.Stat(src) + if err != nil { + return false, fmt.Errorf("stating source %s: %w", src, err) + } + return atomicReplaceFile(src, dst, metadataFromFileInfo(info), false, fileOps) +} + +func stagedFilePrefix(dst string) string { + return "." + filepath.Base(dst) + ".update-" +} + +// cleanStagedFiles removes orphaned files left if a previous updater process +// was killed before its deferred cleanup could run. +func cleanStagedFiles(dst string) error { + dir := filepath.Dir(dst) + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + prefix := stagedFilePrefix(dst) + var cleanupErr error + for _, entry := range entries { + if entry.IsDir() || !strings.HasPrefix(entry.Name(), prefix) { + continue + } + if err = os.Remove(filepath.Join(dir, entry.Name())); err != nil && cleanupErr == nil { + cleanupErr = err + } + } + return cleanupErr +} diff --git a/component/updater/update_core_owner_other.go b/component/updater/update_core_owner_other.go new file mode 100644 index 0000000000..3051db876e --- /dev/null +++ b/component/updater/update_core_owner_other.go @@ -0,0 +1,17 @@ +//go:build !aix && !android && !darwin && !dragonfly && !freebsd && !illumos && !linux && !netbsd && !openbsd && !solaris + +package updater + +import "os" + +type fileOwnership struct { + valid bool +} + +func ownershipFromFileInfo(_ os.FileInfo) fileOwnership { + return fileOwnership{} +} + +func applyFileOwnership(_ string, _ fileOwnership) error { + return nil +} diff --git a/component/updater/update_core_owner_unix.go b/component/updater/update_core_owner_unix.go new file mode 100644 index 0000000000..07aff31374 --- /dev/null +++ b/component/updater/update_core_owner_unix.go @@ -0,0 +1,37 @@ +//go:build aix || android || darwin || dragonfly || freebsd || illumos || linux || netbsd || openbsd || solaris + +package updater + +import ( + "errors" + "os" + "syscall" +) + +type fileOwnership struct { + uid int + gid int + valid bool +} + +func ownershipFromFileInfo(info os.FileInfo) fileOwnership { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return fileOwnership{} + } + return fileOwnership{uid: int(stat.Uid), gid: int(stat.Gid), valid: true} +} + +func applyFileOwnership(path string, ownership fileOwnership) error { + if !ownership.valid { + return nil + } + err := os.Chown(path, ownership.uid, ownership.gid) + if err != nil && os.Geteuid() != 0 && (errors.Is(err, syscall.EPERM) || errors.Is(err, syscall.EACCES)) { + // A non-root updater may be unable to chown even when the newly-created + // file already has the desired ownership. Do not turn that into a false + // upgrade failure. + return nil + } + return err +} diff --git a/component/updater/update_core_owner_unix_test.go b/component/updater/update_core_owner_unix_test.go new file mode 100644 index 0000000000..cff7e9154b --- /dev/null +++ b/component/updater/update_core_owner_unix_test.go @@ -0,0 +1,83 @@ +//go:build aix || android || darwin || dragonfly || freebsd || illumos || linux || netbsd || openbsd || solaris + +package updater + +import ( + "os" + "path/filepath" + "testing" +) + +func TestAtomicReplacePreservesOwnership(t *testing.T) { + src, dst, metadata := replacementFixture(t) + before, err := os.Stat(dst) + if err != nil { + t.Fatal(err) + } + beforeOwnership := ownershipFromFileInfo(before) + fileOps := defaultFileOperations() + fileOps.sign = func(string) error { return nil } + fileOps.verify = func(string) error { return nil } + + if _, err = atomicReplaceFile(src, dst, metadata, true, fileOps); err != nil { + t.Fatalf("atomicReplaceFile() error = %v", err) + } + after, err := os.Stat(dst) + if err != nil { + t.Fatal(err) + } + afterOwnership := ownershipFromFileInfo(after) + if !beforeOwnership.valid || !afterOwnership.valid { + t.Skip("filesystem ownership metadata unavailable") + } + if beforeOwnership.uid != afterOwnership.uid || beforeOwnership.gid != afterOwnership.gid { + t.Fatalf( + "target ownership = %d:%d, want %d:%d", + afterOwnership.uid, + afterOwnership.gid, + beforeOwnership.uid, + beforeOwnership.gid, + ) + } +} + +func TestBackupPreservesMetadata(t *testing.T) { + dir := t.TempDir() + current := filepath.Join(dir, "verge-mihomo") + backupDir := filepath.Join(dir, "meta-backup") + backup := filepath.Join(backupDir, filepath.Base(current)) + writeTestFile(t, current, "old core", 0o751) + before, err := os.Stat(current) + if err != nil { + t.Fatal(err) + } + beforeOwnership := ownershipFromFileInfo(before) + + moved, err := (&CoreUpdater{}).backup(current, backup, backupDir, defaultFileOperations()) + if err != nil { + t.Fatalf("backup() error = %v", err) + } + if moved { + t.Fatal("Unix backup unexpectedly moved the current executable") + } + assertFileContent(t, current, "old core") + assertFileContent(t, backup, "old core") + after, err := os.Stat(backup) + if err != nil { + t.Fatal(err) + } + if got, want := after.Mode().Perm(), os.FileMode(0o751); got != want { + t.Fatalf("backup mode = %v, want %v", got, want) + } + afterOwnership := ownershipFromFileInfo(after) + if beforeOwnership.valid && afterOwnership.valid && + (beforeOwnership.uid != afterOwnership.uid || beforeOwnership.gid != afterOwnership.gid) { + t.Fatalf( + "backup ownership = %d:%d, want %d:%d", + afterOwnership.uid, + afterOwnership.gid, + beforeOwnership.uid, + beforeOwnership.gid, + ) + } +} diff --git a/component/updater/update_core_sign_darwin.go b/component/updater/update_core_sign_darwin.go new file mode 100644 index 0000000000..7596a087a8 --- /dev/null +++ b/component/updater/update_core_sign_darwin.go @@ -0,0 +1,23 @@ +package updater + +import ( + "fmt" + "os/exec" + "strings" +) + +func signCoreFile(path string) error { + output, err := exec.Command("/usr/bin/codesign", "--force", "--sign", "-", path).CombinedOutput() + if err != nil { + return fmt.Errorf("codesign %s: %w: %s", path, err, strings.TrimSpace(string(output))) + } + return nil +} + +func verifyCoreFile(path string) error { + output, err := exec.Command("/usr/bin/codesign", "--verify", "--strict", "--verbose=2", path).CombinedOutput() + if err != nil { + return fmt.Errorf("codesign verify %s: %w: %s", path, err, strings.TrimSpace(string(output))) + } + return nil +} diff --git a/component/updater/update_core_sign_other.go b/component/updater/update_core_sign_other.go new file mode 100644 index 0000000000..ef7d07b807 --- /dev/null +++ b/component/updater/update_core_sign_other.go @@ -0,0 +1,11 @@ +//go:build !darwin + +package updater + +func signCoreFile(_ string) error { + return nil +} + +func verifyCoreFile(_ string) error { + return nil +} diff --git a/component/updater/update_core_sync_other.go b/component/updater/update_core_sync_other.go new file mode 100644 index 0000000000..df99ca6f28 --- /dev/null +++ b/component/updater/update_core_sync_other.go @@ -0,0 +1,11 @@ +//go:build !aix && !android && !darwin && !dragonfly && !freebsd && !illumos && !linux && !netbsd && !openbsd && !solaris && !windows + +package updater + +func syncFile(_ string) error { + return nil +} + +func syncDirectory(_ string) error { + return nil +} diff --git a/component/updater/update_core_sync_unix.go b/component/updater/update_core_sync_unix.go new file mode 100644 index 0000000000..75a970d170 --- /dev/null +++ b/component/updater/update_core_sync_unix.go @@ -0,0 +1,45 @@ +//go:build aix || android || darwin || dragonfly || freebsd || illumos || linux || netbsd || openbsd || solaris + +package updater + +import ( + "errors" + "fmt" + "os" + "syscall" +) + +func syncFile(path string) (err error) { + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("opening %s for sync: %w", path, err) + } + defer func() { + closeErr := file.Close() + if err == nil && closeErr != nil { + err = fmt.Errorf("closing %s after sync: %w", path, closeErr) + } + }() + if err = file.Sync(); err != nil { + return fmt.Errorf("syncing %s: %w", path, err) + } + return nil +} + +func syncDirectory(path string) (err error) { + dir, err := os.Open(path) + if err != nil { + return err + } + defer func() { + closeErr := dir.Close() + if err == nil { + err = closeErr + } + }() + if err = dir.Sync(); errors.Is(err, syscall.EINVAL) || errors.Is(err, syscall.ENOTSUP) { + // Some Unix filesystems do not support syncing directories. + return nil + } + return err +} diff --git a/component/updater/update_core_sync_windows.go b/component/updater/update_core_sync_windows.go new file mode 100644 index 0000000000..3ca1943a9d --- /dev/null +++ b/component/updater/update_core_sync_windows.go @@ -0,0 +1,12 @@ +package updater + +// The staged file's writable handle is synced before it is explicitly closed. +// Windows does not support syncing directories, and reopening an executable +// after restoring a read-only mode can fail unnecessarily. +func syncFile(_ string) error { + return nil +} + +func syncDirectory(_ string) error { + return nil +} diff --git a/component/updater/update_core_test.go b/component/updater/update_core_test.go index eb7c4a5565..6c714b47eb 100644 --- a/component/updater/update_core_test.go +++ b/component/updater/update_core_test.go @@ -1,10 +1,205 @@ package updater import ( + "errors" "fmt" + "io" + "os" + "path/filepath" + "runtime" "testing" ) func TestCoreBaseName(t *testing.T) { fmt.Println("Core base name =", DefaultCoreUpdater.CoreBaseName()) } + +func TestAtomicReplaceCopyFailureLeavesTargetUntouched(t *testing.T) { + src, dst, metadata := replacementFixture(t) + fileOps := defaultFileOperations() + copyErr := errors.New("injected copy failure") + fileOps.copy = func(dst io.Writer, src io.Reader) (int64, error) { + n, _ := io.CopyN(dst, src, 3) + return n, copyErr + } + + replaced, err := atomicReplaceFile(src, dst, metadata, true, fileOps) + if !errors.Is(err, copyErr) { + t.Fatalf("expected copy error, got %v", err) + } + if replaced { + t.Fatal("target reported as replaced after copy failure") + } + assertFileContent(t, dst, "old core") + assertNoStagedFiles(t, dst) +} + +func TestAtomicReplaceSignFailureLeavesTargetUntouched(t *testing.T) { + src, dst, metadata := replacementFixture(t) + fileOps := defaultFileOperations() + signErr := errors.New("injected signing failure") + fileOps.sign = func(string) error { return signErr } + + replaced, err := atomicReplaceFile(src, dst, metadata, true, fileOps) + if !errors.Is(err, signErr) { + t.Fatalf("expected signing error, got %v", err) + } + if replaced { + t.Fatal("target reported as replaced after signing failure") + } + assertFileContent(t, dst, "old core") + assertNoStagedFiles(t, dst) +} + +func TestAtomicReplaceRenameFailureLeavesTargetUntouched(t *testing.T) { + src, dst, metadata := replacementFixture(t) + fileOps := defaultFileOperations() + renameErr := errors.New("injected rename failure") + fileOps.sign = func(string) error { return nil } + fileOps.verify = func(string) error { return nil } + fileOps.rename = func(string, string) error { return renameErr } + + replaced, err := atomicReplaceFile(src, dst, metadata, true, fileOps) + if !errors.Is(err, renameErr) { + t.Fatalf("expected rename error, got %v", err) + } + if replaced { + t.Fatal("target reported as replaced after rename failure") + } + assertFileContent(t, dst, "old core") + assertNoStagedFiles(t, dst) +} + +func TestAtomicReplaceSuccessPreservesModeAndCleansTemp(t *testing.T) { + src, dst, metadata := replacementFixture(t) + fileOps := defaultFileOperations() + var signed, verified bool + fileOps.sign = func(path string) error { + signed = true + if filepath.Dir(path) != filepath.Dir(dst) { + t.Fatalf("replacement staged outside target directory: %s", path) + } + return nil + } + fileOps.verify = func(string) error { + verified = true + return nil + } + + replaced, err := atomicReplaceFile(src, dst, metadata, true, fileOps) + if err != nil { + t.Fatalf("atomicReplaceFile() error = %v", err) + } + if !replaced || !signed || !verified { + t.Fatalf("replaced=%v signed=%v verified=%v", replaced, signed, verified) + } + assertFileContent(t, dst, "new core contents") + info, err := os.Stat(dst) + if err != nil { + t.Fatal(err) + } + if got, want := info.Mode().Perm(), os.FileMode(0o751); got != want { + t.Fatalf("target mode = %v, want %v", got, want) + } + assertNoStagedFiles(t, dst) +} + +func TestCleanStagedFilesRemovesOrphans(t *testing.T) { + dir := t.TempDir() + dst := filepath.Join(dir, "verge-mihomo") + orphan := filepath.Join(dir, stagedFilePrefix(dst)+"orphan") + unrelated := filepath.Join(dir, ".unrelated") + if err := os.WriteFile(orphan, []byte("partial"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(unrelated, []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + + if err := cleanStagedFiles(dst); err != nil { + t.Fatalf("cleanStagedFiles() error = %v", err) + } + if _, err := os.Stat(orphan); !os.IsNotExist(err) { + t.Fatalf("orphan still exists or stat failed unexpectedly: %v", err) + } + assertFileContent(t, unrelated, "keep") +} + +func TestRollbackRestoresBackup(t *testing.T) { + dir := t.TempDir() + backup := filepath.Join(dir, "backup") + current := filepath.Join(dir, "current") + writeTestFile(t, backup, "old core", 0o751) + writeTestFile(t, current, "new core", 0o700) + info, err := os.Stat(backup) + if err != nil { + t.Fatal(err) + } + fileOps := defaultFileOperations() + if runtime.GOOS == "windows" { + if err = os.Remove(current); err != nil { + t.Fatal(err) + } + } + + if err = (&CoreUpdater{}).rollback(backup, current, metadataFromFileInfo(info), fileOps); err != nil { + t.Fatalf("rollback() error = %v", err) + } + assertFileContent(t, current, "old core") + if runtime.GOOS != "windows" { + assertFileContent(t, backup, "old core") + currentInfo, statErr := os.Stat(current) + if statErr != nil { + t.Fatal(statErr) + } + if got, want := currentInfo.Mode().Perm(), os.FileMode(0o751); got != want { + t.Fatalf("rolled back mode = %v, want %v", got, want) + } + } +} + +func replacementFixture(t *testing.T) (src, dst string, metadata fileMetadata) { + t.Helper() + dir := t.TempDir() + src = filepath.Join(dir, "downloaded-core") + dst = filepath.Join(dir, "verge-mihomo") + writeTestFile(t, src, "new core contents", 0o700) + writeTestFile(t, dst, "old core", 0o751) + info, err := os.Stat(dst) + if err != nil { + t.Fatal(err) + } + return src, dst, metadataFromFileInfo(info) +} + +func writeTestFile(t *testing.T, path, content string, mode os.FileMode) { + t.Helper() + if err := os.WriteFile(path, []byte(content), mode); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, mode); err != nil { + t.Fatal(err) + } +} + +func assertFileContent(t *testing.T, path, want string) { + t.Helper() + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != want { + t.Fatalf("%s content = %q, want %q", path, got, want) + } +} + +func assertNoStagedFiles(t *testing.T, dst string) { + t.Helper() + matches, err := filepath.Glob(filepath.Join(filepath.Dir(dst), stagedFilePrefix(dst)+"*")) + if err != nil { + t.Fatal(err) + } + if len(matches) != 0 { + t.Fatalf("staged files were not cleaned: %v", matches) + } +}