Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 57 additions & 68 deletions component/updater/update_core.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Comment thread
LovePeachBlossom marked this conversation as resolved.
Outdated
}

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)
}

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
179 changes: 179 additions & 0 deletions component/updater/update_core_file.go
Original file line number Diff line number Diff line change
@@ -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
}
17 changes: 17 additions & 0 deletions component/updater/update_core_owner_other.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading