From fd4f7ea7c653136f4d47635f9900c6a589ae3a99 Mon Sep 17 00:00:00 2001 From: Fred Date: Sun, 16 Jun 2024 14:56:19 +0100 Subject: [PATCH] refactoring --- README.md | 8 +-- arm.go | 9 ---- config.go | 14 +++--- detect.go | 2 +- internal/path.go | 21 ++++++++ internal/path_test.go | 15 ++++++ update.go | 3 +- update/apply.go | 108 ++++------------------------------------- update/apply_test.go | 55 +++++++++++++++------ update/hide_test.go | 16 ++++++ update/hide_windows.go | 6 ++- update/options.go | 86 ++++++++++++++++++++++++++++++++ updater.go | 20 +++----- 13 files changed, 216 insertions(+), 147 deletions(-) create mode 100644 internal/path.go create mode 100644 internal/path_test.go create mode 100644 update/hide_test.go create mode 100644 update/options.go diff --git a/README.md b/README.md index 22d0a16..a69e8bd 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Self-Update library for Github, Gitea and Gitlab hosted applications in Go # Introduction -go-selfupdate detects the information of the latest release via a source provider and +`go-selfupdate` detects the information of the latest release via a source provider and checks the current version. If a newer version than itself is detected, it downloads the released binary from the source provider and replaces itself. @@ -43,17 +43,19 @@ the source provider and replaces itself. - Retrieve the proper binary for the OS and arch where the binary is running - Update the binary with rollback support on failure - Tested on Linux, macOS and Windows +- Support for different versions of ARM architectures +- Support macOS universal binaries - Many archive and compression formats are supported (zip, tar, gzip, xzip, bzip2) - Support private repositories - Support hash, signature validation -Two source providers are available: +Three source providers are available: - GitHub - Gitea - Gitlab This library started as a fork of https://github.com/rhysd/go-github-selfupdate. A few things have changed from the original implementation: -- don't expose an external semver.Version type, but provide the same functionality through the API: LessThan, Equal and GreaterThan +- don't expose an external `semver.Version` type, but provide the same functionality through the API: `LessThan`, `Equal` and `GreaterThan` - use an interface to send logs (compatible with standard log.Logger) - able to detect different ARM CPU architectures (the original library wasn't working on my different versions of raspberry pi) - support for assets compressed with bzip2 (.bz2) diff --git a/arm.go b/arm.go index e1bb78f..a01a4ae 100644 --- a/arm.go +++ b/arm.go @@ -2,17 +2,8 @@ package selfupdate import ( "debug/buildinfo" - "os" ) -var goarm uint8 - -//nolint:gochecknoinits -func init() { - // avoid using runtime.goarm directly - goarm = getGOARM(os.Args[0]) -} - func getGOARM(goBinary string) uint8 { build, err := buildinfo.ReadFile(goBinary) if err != nil { diff --git a/config.go b/config.go index 1e364f6..5085239 100644 --- a/config.go +++ b/config.go @@ -2,7 +2,7 @@ package selfupdate // Config represents the configuration of self-update. type Config struct { - // Source where to load the releases from (example: GitHubSource) + // Source where to load the releases from (example: GitHubSource). Source Source // Validator represents types which enable additional validation of downloaded release. Validator Validator @@ -10,14 +10,16 @@ type Config struct { // An asset is selected if it matches any of those, in addition to the regular tag, os, arch, extensions. // Please make sure that your filter(s) uniquely match an asset. Filters []string - // OS is set to the value of runtime.GOOS by default, but you can force another value here + // OS is set to the value of runtime.GOOS by default, but you can force another value here. OS string - // Arch is set to the value of runtime.GOARCH by default, but you can force another value here + // Arch is set to the value of runtime.GOARCH by default, but you can force another value here. Arch string - // Arm 32bits version. Valid values are 0 (unknown), 5, 6 or 7. Default is detected value (if any) + // Arm 32bits version. Valid values are 0 (unknown), 5, 6 or 7. Default is detected value (if available). Arm uint8 - // Draft permits an upgrade to a "draft" version (default to false) + // Arch name to use when using a universal binary (macOS only). Default to none. + UniversalArch string + // Draft permits an upgrade to a "draft" version (default to false). Draft bool - // Prerelease permits an upgrade to a "pre-release" version (default to false) + // Prerelease permits an upgrade to a "pre-release" version (default to false). Prerelease bool } diff --git a/detect.go b/detect.go index f5d5498..1cbc614 100644 --- a/detect.go +++ b/detect.go @@ -15,7 +15,7 @@ var reVersion = regexp.MustCompile(`\d+\.\d+\.\d+`) // It fetches releases information from the source provider and find out the latest release with matching the tag names and asset names. // Drafts and pre-releases are ignored. // Assets would be suffixed by the OS name and the arch name such as 'foo_linux_amd64' where 'foo' is a command name. -// '-' can also be used as a separator. File can be compressed with zip, gzip, zxip, bzip2, tar&gzip or tar&zxip. +// '-' can also be used as a separator. File can be compressed with zip, gzip, xz, bzip2, tar&gzip or tar&xz. // So the asset can have a file extension for the corresponding compression format such as '.zip'. // On Windows, '.exe' also can be contained such as 'foo_windows_amd64.exe.zip'. func (up *Updater) DetectLatest(ctx context.Context, repository Repository) (release *Release, found bool, err error) { diff --git a/internal/path.go b/internal/path.go new file mode 100644 index 0000000..e33f8bb --- /dev/null +++ b/internal/path.go @@ -0,0 +1,21 @@ +package internal + +import ( + "os" + "path/filepath" +) + +// GetExecutablePath returns the path of the executable file with all symlinks resolved. +func GetExecutablePath() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", err + } + + exe, err = filepath.EvalSymlinks(exe) + if err != nil { + return "", err + } + + return exe, nil +} diff --git a/internal/path_test.go b/internal/path_test.go new file mode 100644 index 0000000..3d4a16e --- /dev/null +++ b/internal/path_test.go @@ -0,0 +1,15 @@ +package internal + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetExecutablePath(t *testing.T) { + t.Parallel() + + exe, err := GetExecutablePath() + assert.NoError(t, err) + assert.NotEmpty(t, exe) +} diff --git a/update.go b/update.go index a8842c6..2b3447f 100644 --- a/update.go +++ b/update.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/Masterminds/semver/v3" + "github.com/creativeprojects/go-selfupdate/internal" "github.com/creativeprojects/go-selfupdate/update" ) @@ -82,7 +83,7 @@ func (up *Updater) UpdateCommand(ctx context.Context, cmdPath string, current st // UpdateSelf updates the running executable itself to the latest version. // 'current' is used to check the latest version against the current version. func (up *Updater) UpdateSelf(ctx context.Context, current string, repository Repository) (*Release, error) { - cmdPath, err := os.Executable() + cmdPath, err := internal.GetExecutablePath() if err != nil { return nil, err } diff --git a/update/apply.go b/update/apply.go index a0800dc..400ced9 100644 --- a/update/apply.go +++ b/update/apply.go @@ -3,13 +3,13 @@ package update import ( "bytes" "crypto" - "crypto/x509" - "encoding/pem" "errors" "fmt" "io" "os" "path/filepath" + + "github.com/creativeprojects/go-selfupdate/internal" ) var ( @@ -37,7 +37,7 @@ var ( // back to /path/to/target. // // If the roll back operation fails, the file system is left in an inconsistent state (between steps 5 and 6) where -// there is no new executable file and the old executable file could not be be moved to its original location. In this +// there is no new executable file and the old executable file could not be moved to its original location. In this // case you should notify the user of the bad news and ask them to recover manually. Applications can determine whether // the rollback failed by calling RollbackError, see the documentation on that function for additional detail. func Apply(update io.Reader, opts Options) error { @@ -61,14 +61,16 @@ func Apply(update io.Reader, opts Options) error { opts.Verifier = NewECDSAVerifier() } if opts.TargetMode == 0 { - opts.TargetMode = 0755 + opts.TargetMode = 0o755 } // get target path var err error - opts.TargetPath, err = opts.getPath() - if err != nil { - return err + if len(opts.TargetPath) == 0 { + opts.TargetPath, err = internal.GetExecutablePath() + if err != nil { + return err + } } var newBytes []byte @@ -180,95 +182,3 @@ type rollbackErr struct { error // original error rollbackErr error // error encountered while rolling back } - -// Options for Apply update -type Options struct { - // TargetPath defines the path to the file to update. - // The emptry string means 'the executable file of the running program'. - TargetPath string - - // Create TargetPath replacement with this file mode. If zero, defaults to 0755. - TargetMode os.FileMode - - // Checksum of the new binary to verify against. If nil, no checksum or signature verification is done. - Checksum []byte - - // Public key to use for signature verification. If nil, no signature verification is done. - PublicKey crypto.PublicKey - - // Signature to verify the updated file. If nil, no signature verification is done. - Signature []byte - - // Pluggable signature verification algorithm. If nil, ECDSA is used. - Verifier Verifier - - // Use this hash function to generate the checksum. If not set, SHA256 is used. - Hash crypto.Hash - - // Store the old executable file at this path after a successful update. - // The empty string means the old executable file will be removed after the update. - OldSavePath string -} - -// SetPublicKeyPEM is a convenience method to set the PublicKey property -// used for checking a completed update's signature by parsing a -// Public Key formatted as PEM data. -func (o *Options) SetPublicKeyPEM(pembytes []byte) error { - block, _ := pem.Decode(pembytes) - if block == nil { - return errors.New("couldn't parse PEM data") - } - - pub, err := x509.ParsePKIXPublicKey(block.Bytes) - if err != nil { - return err - } - o.PublicKey = pub - return nil -} - -func (o *Options) getPath() (string, error) { - if o.TargetPath != "" { - return o.TargetPath, nil - } - exe, err := os.Executable() - if err != nil { - return "", err - } - - exe, err = filepath.EvalSymlinks(exe) - if err != nil { - return "", err - } - - return exe, nil -} - -func (o *Options) verifyChecksum(updated []byte) error { - checksum, err := checksumFor(o.Hash, updated) - if err != nil { - return err - } - - if !bytes.Equal(o.Checksum, checksum) { - return fmt.Errorf("updated file has wrong checksum. Expected: %x, got: %x", o.Checksum, checksum) - } - return nil -} - -func (o *Options) verifySignature(updated []byte) error { - checksum, err := checksumFor(o.Hash, updated) - if err != nil { - return err - } - return o.Verifier.VerifySignature(checksum, o.Signature, o.Hash, o.PublicKey) -} - -func checksumFor(h crypto.Hash, payload []byte) ([]byte, error) { - if !h.Available() { - return nil, errors.New("requested hash function not available") - } - hash := h.New() - hash.Write(payload) // guaranteed not to error - return hash.Sum([]byte{}), nil -} diff --git a/update/apply_test.go b/update/apply_test.go index 2c2223a..223180f 100644 --- a/update/apply_test.go +++ b/update/apply_test.go @@ -25,7 +25,7 @@ func cleanup(path string) { // we write with a separate name for each test so that we can run them in parallel func writeOldFile(path string, t *testing.T) { - if err := os.WriteFile(path, oldFile, 0777); err != nil { + if err := os.WriteFile(path, oldFile, 0o777); err != nil { t.Fatalf("Failed to write file for testing preparation: %v", err) } } @@ -46,7 +46,9 @@ func validateUpdate(path string, err error, t *testing.T) { } func TestApplySimple(t *testing.T) { - fName := "TestApplySimple" + t.Parallel() + + fName := t.Name() defer cleanup(fName) writeOldFile(fName, t) @@ -57,7 +59,9 @@ func TestApplySimple(t *testing.T) { } func TestApplyOldSavePath(t *testing.T) { - fName := "TestApplyOldSavePath" + t.Parallel() + + fName := t.Name() defer cleanup(fName) writeOldFile(fName, t) @@ -77,7 +81,9 @@ func TestApplyOldSavePath(t *testing.T) { } func TestVerifyChecksum(t *testing.T) { - fName := "TestVerifyChecksum" + t.Parallel() + + fName := t.Name() defer cleanup(fName) writeOldFile(fName, t) @@ -89,7 +95,9 @@ func TestVerifyChecksum(t *testing.T) { } func TestVerifyChecksumNegative(t *testing.T) { - fName := "TestVerifyChecksumNegative" + t.Parallel() + + fName := t.Name() defer cleanup(fName) writeOldFile(fName, t) @@ -191,7 +199,9 @@ func sign(parsePrivKey func([]byte) (crypto.Signer, error), privatePEM string, s } func TestVerifyECSignature(t *testing.T) { - fName := "TestVerifyECSignature" + t.Parallel() + + fName := t.Name() defer cleanup(fName) writeOldFile(fName, t) @@ -207,7 +217,9 @@ func TestVerifyECSignature(t *testing.T) { } func TestVerifyRSASignature(t *testing.T) { - fName := "TestVerifyRSASignature" + t.Parallel() + + fName := t.Name() defer cleanup(fName) writeOldFile(fName, t) @@ -226,7 +238,9 @@ func TestVerifyRSASignature(t *testing.T) { } func TestVerifyFailBadSignature(t *testing.T) { - fName := "TestVerifyFailBadSignature" + t.Parallel() + + fName := t.Name() defer cleanup(fName) writeOldFile(fName, t) @@ -246,7 +260,9 @@ func TestVerifyFailBadSignature(t *testing.T) { } func TestVerifyFailNoSignature(t *testing.T) { - fName := "TestVerifySignatureWithPEM" + t.Parallel() + + fName := t.Name() defer cleanup(fName) writeOldFile(fName, t) @@ -262,7 +278,10 @@ func TestVerifyFailNoSignature(t *testing.T) { } } -const wrongKey = ` +func TestVerifyFailWrongSignature(t *testing.T) { + t.Parallel() + + const wrongKey = ` -----BEGIN EC PRIVATE KEY----- MIGkAgEBBDBzqYp6N2s8YWYifBjS03/fFfmGeIPcxQEi+bbFeekIYt8NIKIkhD+r hpaIwSmot+qgBwYFK4EEACKhZANiAAR0EC8Usbkc4k30frfEB2ECmsIghu9DJSqE @@ -271,8 +290,7 @@ VBbP/Ff+05HOqwPC7rJMy1VAJLKg7Cw= -----END EC PRIVATE KEY----- ` -func TestVerifyFailWrongSignature(t *testing.T) { - fName := "TestVerifyFailWrongSignature" + fName := t.Name() defer cleanup(fName) writeOldFile(fName, t) @@ -290,7 +308,9 @@ func TestVerifyFailWrongSignature(t *testing.T) { } func TestSignatureButNoPublicKey(t *testing.T) { - fName := "TestSignatureButNoPublicKey" + t.Parallel() + + fName := t.Name() defer cleanup(fName) writeOldFile(fName, t) @@ -304,7 +324,9 @@ func TestSignatureButNoPublicKey(t *testing.T) { } func TestPublicKeyButNoSignature(t *testing.T) { - fName := "TestPublicKeyButNoSignature" + t.Parallel() + + fName := t.Name() defer cleanup(fName) writeOldFile(fName, t) @@ -319,7 +341,10 @@ func TestPublicKeyButNoSignature(t *testing.T) { } func TestWriteError(t *testing.T) { - fName := "TestWriteError" + // fix this test patching the global openFile variable + // t.Parallel() + + fName := t.Name() defer cleanup(fName) writeOldFile(fName, t) diff --git a/update/hide_test.go b/update/hide_test.go new file mode 100644 index 0000000..83754d6 --- /dev/null +++ b/update/hide_test.go @@ -0,0 +1,16 @@ +package update + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHideFile(t *testing.T) { + t.Parallel() + + tempFile := filepath.Join(t.TempDir(), t.Name()) + err := hideFile(tempFile) + assert.NoError(t, err) +} diff --git a/update/hide_windows.go b/update/hide_windows.go index c368b9c..3c2feb7 100644 --- a/update/hide_windows.go +++ b/update/hide_windows.go @@ -9,7 +9,11 @@ func hideFile(path string) error { kernel32 := syscall.NewLazyDLL("kernel32.dll") setFileAttributes := kernel32.NewProc("SetFileAttributesW") - r1, _, err := setFileAttributes.Call(uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(path))), 2) + utf16Str, err := syscall.UTF16PtrFromString(path) + if err != nil { + return err + } + r1, _, err := setFileAttributes.Call(uintptr(unsafe.Pointer(utf16Str)), 2) if r1 == 0 { return err diff --git a/update/options.go b/update/options.go new file mode 100644 index 0000000..ba8edb2 --- /dev/null +++ b/update/options.go @@ -0,0 +1,86 @@ +package update + +import ( + "bytes" + "crypto" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "os" +) + +// Options for Apply update +type Options struct { + // TargetPath defines the path to the file to update. + // The empty string means 'the executable file of the running program'. + TargetPath string + + // Create TargetPath replacement with this file mode. If zero, defaults to 0755. + TargetMode os.FileMode + + // Checksum of the new binary to verify against. If nil, no checksum or signature verification is done. + Checksum []byte + + // Public key to use for signature verification. If nil, no signature verification is done. + PublicKey crypto.PublicKey + + // Signature to verify the updated file. If nil, no signature verification is done. + Signature []byte + + // Pluggable signature verification algorithm. If nil, ECDSA is used. + Verifier Verifier + + // Use this hash function to generate the checksum. If not set, SHA256 is used. + Hash crypto.Hash + + // Store the old executable file at this path after a successful update. + // The empty string means the old executable file will be removed after the update. + OldSavePath string +} + +// SetPublicKeyPEM is a convenience method to set the PublicKey property +// used for checking a completed update's signature by parsing a +// Public Key formatted as PEM data. +func (o *Options) SetPublicKeyPEM(pembytes []byte) error { + block, _ := pem.Decode(pembytes) + if block == nil { + return errors.New("couldn't parse PEM data") + } + + pub, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return err + } + o.PublicKey = pub + return nil +} + +func (o *Options) verifyChecksum(updated []byte) error { + checksum, err := checksumFor(o.Hash, updated) + if err != nil { + return err + } + + if !bytes.Equal(o.Checksum, checksum) { + return fmt.Errorf("updated file has wrong checksum. Expected: %x, got: %x", o.Checksum, checksum) + } + return nil +} + +func (o *Options) verifySignature(updated []byte) error { + checksum, err := checksumFor(o.Hash, updated) + if err != nil { + return err + } + return o.Verifier.VerifySignature(checksum, o.Signature, o.Hash, o.PublicKey) +} + +func checksumFor(h crypto.Hash, payload []byte) ([]byte, error) { + if !h.Available() { + return nil, errors.New("requested hash function not available") + } + hash := h.New() + _, _ = hash.Write(payload) + return hash.Sum([]byte{}), nil +} diff --git a/updater.go b/updater.go index 31dd35e..573ee0a 100644 --- a/updater.go +++ b/updater.go @@ -4,6 +4,8 @@ import ( "fmt" "regexp" "runtime" + + "github.com/creativeprojects/go-selfupdate/internal" ) // Updater is responsible for managing the context of self-update. @@ -27,6 +29,7 @@ func NewUpdater(config Config) (*Updater, error) { source := config.Source if source == nil { // default source is GitHub + // an error can only be returned when using GitHub Enterprise URLs source, _ = NewGitHubSource(GitHubConfig{}) } @@ -40,16 +43,17 @@ func NewUpdater(config Config) (*Updater, error) { } os := config.OS - arch := config.Arch if os == "" { os = runtime.GOOS } + arch := config.Arch if arch == "" { arch = runtime.GOARCH } arm := config.Arm - if arm == 0 && goarm > 0 { - arm = goarm + if arm == 0 { + exe, _ := internal.GetExecutablePath() + arm = getGOARM(exe) } return &Updater{ @@ -73,14 +77,6 @@ func DefaultUpdater() *Updater { if defaultUpdater != nil { return defaultUpdater } - // an error can only be returned when using GitHub Enterprise URLs - // so we're safe here :) - source, _ := NewGitHubSource(GitHubConfig{}) - defaultUpdater = &Updater{ - source: source, - os: runtime.GOOS, - arch: runtime.GOARCH, - arm: goarm, - } + defaultUpdater, _ = NewUpdater(Config{}) return defaultUpdater }