From e7ed09875dd5d203cf6635d11c86c97ffdac8fd5 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Thu, 22 May 2025 09:45:13 +0200 Subject: [PATCH] Adding upstream version 1.2.3. Signed-off-by: Daniel Baumann --- .appveyor.yml | 29 ++ .gitignore | 6 + .travis.yml | 23 + CHANGELOG.md | 40 ++ Guardfile | 18 + LICENSE | 21 + README.md | 444 +++++++++++++++++ cmd/detect-latest-release/README.md | 20 + cmd/detect-latest-release/main.go | 66 +++ cmd/go-get-release/README.md | 29 ++ cmd/go-get-release/main.go | 132 +++++ cmd/selfupdate-example/main.go | 64 +++ go.mod | 18 + go.sum | 66 +++ scripts/make-release.sh | 26 + selfupdate/detect.go | 206 ++++++++ selfupdate/detect_test.go | 457 ++++++++++++++++++ selfupdate/doc.go | 38 ++ selfupdate/e2e_test.go | 22 + selfupdate/log.go | 30 ++ selfupdate/log_test.go | 30 ++ selfupdate/release.go | 33 ++ selfupdate/testdata/Test.crt | 9 + selfupdate/testdata/Test.pem | 14 + selfupdate/testdata/bar-not-found.gzip | Bin 0 -> 36 bytes selfupdate/testdata/bar-not-found.tar.gz | Bin 0 -> 216 bytes selfupdate/testdata/bar-not-found.tar.xz | Bin 0 -> 248 bytes selfupdate/testdata/bar-not-found.zip | Bin 0 -> 1002 bytes selfupdate/testdata/empty.tar.gz | Bin 0 -> 127 bytes selfupdate/testdata/empty.zip | Bin 0 -> 162 bytes selfupdate/testdata/fake-executable | 1 + selfupdate/testdata/fake-executable.exe | 1 + selfupdate/testdata/foo.tar.gz | Bin 0 -> 241 bytes selfupdate/testdata/foo.tar.xz | Bin 0 -> 260 bytes selfupdate/testdata/foo.tgz | Bin 0 -> 241 bytes selfupdate/testdata/foo.zip | Bin 0 -> 599 bytes selfupdate/testdata/foo.zip.sha256 | 1 + selfupdate/testdata/foo.zip.sig | Bin 0 -> 71 bytes .../testdata/github-release-test/main.go | 5 + selfupdate/testdata/invalid-gzip.tar.gz | 0 selfupdate/testdata/invalid-tar.tar.gz | Bin 0 -> 50 bytes selfupdate/testdata/invalid-tar.tar.xz | Bin 0 -> 64 bytes selfupdate/testdata/invalid-xz.tar.xz | 1 + selfupdate/testdata/invalid.gz | 0 selfupdate/testdata/invalid.xz | 1 + selfupdate/testdata/invalid.zip | 0 selfupdate/testdata/single-file.gz | Bin 0 -> 35 bytes selfupdate/testdata/single-file.gzip | Bin 0 -> 35 bytes selfupdate/testdata/single-file.xz | Bin 0 -> 72 bytes selfupdate/testdata/single-file.zip | Bin 0 -> 169 bytes selfupdate/uncompress.go | 136 ++++++ selfupdate/uncompress_test.go | 133 +++++ selfupdate/update.go | 181 +++++++ selfupdate/update_test.go | 353 ++++++++++++++ selfupdate/updater.go | 99 ++++ selfupdate/updater_test.go | 106 ++++ selfupdate/validate.go | 73 +++ selfupdate/validate_test.go | 136 ++++++ 58 files changed, 3068 insertions(+) create mode 100644 .appveyor.yml create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 CHANGELOG.md create mode 100644 Guardfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cmd/detect-latest-release/README.md create mode 100644 cmd/detect-latest-release/main.go create mode 100644 cmd/go-get-release/README.md create mode 100644 cmd/go-get-release/main.go create mode 100644 cmd/selfupdate-example/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100755 scripts/make-release.sh create mode 100644 selfupdate/detect.go create mode 100644 selfupdate/detect_test.go create mode 100644 selfupdate/doc.go create mode 100644 selfupdate/e2e_test.go create mode 100644 selfupdate/log.go create mode 100644 selfupdate/log_test.go create mode 100644 selfupdate/release.go create mode 100644 selfupdate/testdata/Test.crt create mode 100644 selfupdate/testdata/Test.pem create mode 100644 selfupdate/testdata/bar-not-found.gzip create mode 100644 selfupdate/testdata/bar-not-found.tar.gz create mode 100644 selfupdate/testdata/bar-not-found.tar.xz create mode 100644 selfupdate/testdata/bar-not-found.zip create mode 100644 selfupdate/testdata/empty.tar.gz create mode 100644 selfupdate/testdata/empty.zip create mode 100644 selfupdate/testdata/fake-executable create mode 100644 selfupdate/testdata/fake-executable.exe create mode 100644 selfupdate/testdata/foo.tar.gz create mode 100644 selfupdate/testdata/foo.tar.xz create mode 100644 selfupdate/testdata/foo.tgz create mode 100644 selfupdate/testdata/foo.zip create mode 100644 selfupdate/testdata/foo.zip.sha256 create mode 100644 selfupdate/testdata/foo.zip.sig create mode 100644 selfupdate/testdata/github-release-test/main.go create mode 100644 selfupdate/testdata/invalid-gzip.tar.gz create mode 100644 selfupdate/testdata/invalid-tar.tar.gz create mode 100644 selfupdate/testdata/invalid-tar.tar.xz create mode 100644 selfupdate/testdata/invalid-xz.tar.xz create mode 100644 selfupdate/testdata/invalid.gz create mode 100644 selfupdate/testdata/invalid.xz create mode 100644 selfupdate/testdata/invalid.zip create mode 100644 selfupdate/testdata/single-file.gz create mode 100644 selfupdate/testdata/single-file.gzip create mode 100644 selfupdate/testdata/single-file.xz create mode 100644 selfupdate/testdata/single-file.zip create mode 100644 selfupdate/uncompress.go create mode 100644 selfupdate/uncompress_test.go create mode 100644 selfupdate/update.go create mode 100644 selfupdate/update_test.go create mode 100644 selfupdate/updater.go create mode 100644 selfupdate/updater_test.go create mode 100644 selfupdate/validate.go create mode 100644 selfupdate/validate_test.go diff --git a/.appveyor.yml b/.appveyor.yml new file mode 100644 index 0000000..0d950ff --- /dev/null +++ b/.appveyor.yml @@ -0,0 +1,29 @@ +version: "{build}" +clone_depth: 1 +clone_folder: c:\outside-gopath +environment: + GOPATH: c:\gopath + GO111MODULE: on +install: + - echo %PATH% + - echo %GOPATH% + - go version + - go env + - go get -v -t -d ./... +build: off +test_script: + - go build ./selfupdate + - go build ./cmd/selfupdate-example + - go build ./cmd/detect-latest-release + - go build ./cmd/go-get-release/ + - ps: | + if (Test-Path env:GITHUB_TOKEN) { + go test -v -race "-coverprofile=coverage.txt" ./selfupdate + } else { + go test -v -race -short ./selfupdate + } +after_test: + - "SET PATH=C:\\Python34;C:\\Python34\\Scripts;%PATH%" + - pip install codecov + - codecov -f "coverage.txt" +deploy: off diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d9a6f2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/selfupdate-example +/release +/env.sh +/detect-latest-release +/go-get-release +/coverage.out diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..362b79b --- /dev/null +++ b/.travis.yml @@ -0,0 +1,23 @@ +language: go +os: + - linux + - osx +env: + - GO111MODULE=on +install: + - go version + - go env + - go get -t -d -v ./... +script: + - go build ./selfupdate/ + - go build ./cmd/selfupdate-example/ + - go build ./cmd/detect-latest-release/ + - go build ./cmd/go-get-release/ + - | + if [[ "${GITHUB_TOKEN}" != "" ]]; then + go test -v -race -coverprofile=coverage.txt ./selfupdate + else + go test -v -race -short ./selfupdate + fi +after_success: + - if [ -f coverage.txt ]; then bash <(curl -s https://codecov.io/bash); fi diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1f6045d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,40 @@ +## [v1.2.3] - 2021-01-13 + +- Fix security issues in dependencies; CVE-2020-16845, CVE-2019-11840, CVE-2020-14040 (Thanks to [@bhamail](https://github.com/bhamail)). + +## [v1.2.2] - 2020-04-10 + +- Update `go-github` dependency to v30.1.0 + +## [v1.2.1] - 2019-12-19 + +- Fix `.tgz` file was not handled as `.tar.gz`. + + +## [v1.2.0] - 2019-12-19 + +- New Feature: Filtering releases by matching regular expressions to release names (Thanks to [@fredbi](https://github.com/fredbi)). + Regular expression strings specified at `Filters` field in `Config` struct are used on detecting the + latest release. Please read [documentation](https://godoc.org/github.com/rhysd/go-github-selfupdate/selfupdate#Config) + for more details. +- Allow `{cmd}_{os}_{arch}` format for executable names. +- `.tgz` file name suffix was supported. + + +## [v1.1.0] - 2018-11-10 + +- New Feature: Signature validation for release assets (Thanks to [@tobiaskohlbau](https://github.com/tobiaskohlbau)). + Please read [the instruction](https://github.com/rhysd/go-github-selfupdate#hash-or-signature-validation) for usage. + + +## [v1.0.0] - 2018-09-23 + +First release! :tada: + + +[v1.2.3]: https://github.com/rhysd/go-github-selfupdate/compare/v1.2.2...v1.2.3 +[v1.2.2]: https://github.com/rhysd/go-github-selfupdate/compare/v1.2.1...v1.2.2 +[v1.2.1]: https://github.com/rhysd/go-github-selfupdate/compare/v1.2.0...v1.2.1 +[v1.2.0]: https://github.com/rhysd/go-github-selfupdate/compare/go-get-release...v1.2.0 +[v1.1.0]: https://github.com/rhysd/go-github-selfupdate/compare/v1.0.0...v1.1.0 +[v1.0.0]: https://github.com/rhysd/go-github-selfupdate/compare/example-1.2.4...v1.0.0 diff --git a/Guardfile b/Guardfile new file mode 100644 index 0000000..c548e98 --- /dev/null +++ b/Guardfile @@ -0,0 +1,18 @@ +guard :shell do + watch /^selfupdate\/.+\.go$/ do |m| + puts "#{Time.now}: #{m[0]}" + case m[0] + when /_test\.go$/ + parent = File.dirname m[0] + sources = Dir["#{parent}/*.go"].reject{|p| p.end_with? '_test.go'}.join(' ') + system "go test -v -short #{m[0]} #{sources}" + else + system 'go build ./selfupdate/' + end + end + + watch /^cmd\/selfupdate-example\/.+\.go$/ do |m| + puts "#{Time.now}: #{m[0]}" + system 'go build ./cmd/selfupdate-example/' + end +end diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..540fd4e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +the MIT License + +Copyright (c) 2017 rhysd + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR +THE USE OR OTHER DEALINGS IN THE SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..5daa089 --- /dev/null +++ b/README.md @@ -0,0 +1,444 @@ +Self-Update Mechanism for Go Commands Using GitHub +================================================== + +[![GoDoc Badge][]][GoDoc] +[![TravisCI Status][]][TravisCI] +[![AppVeyor Status][]][AppVeyor] +[![Codecov Status][]][Codecov] + +[go-github-selfupdate][] is a Go library to provide a self-update mechanism to command line tools. + +Go does not provide a way to install/update the stable version of tools. By default, Go command line +tools are updated: + +1. using `go get -u`, but it is not stable because HEAD of the repository is built +2. using system's package manager, but it is harder to release because of depending on the platform +3. downloading executables from GitHub release page, but it requires users to download and put it in an executable path manually + +[go-github-selfupdate][] resolves the problem of 3 by detecting the latest release, downloading it and +putting it in `$GOPATH/bin` automatically. + +[go-github-selfupdate][] detects the information of the latest release via [GitHub Releases API][] and +checks the current version. If a newer version than itself is detected, it downloads the released binary from +GitHub and replaces itself. + +- Automatically detect the latest version of released binary on GitHub +- 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 (using Travis CI and AppVeyor) +- Many archive and compression formats are supported (zip, tar, gzip, xzip) +- Support private repositories +- Support [GitHub Enterprise][] +- Support hash, signature validation (thanks to [@tobiaskohlbau](https://github.com/tobiaskohlbau)) + +And small wrapper CLIs are provided: + +- [detect-latest-release](./cmd/detect-latest-release): Detect the latest release of given GitHub repository from command line +- [go-get-release](./cmd/go-get-release): Like `go get`, but install a release binary from GitHub instead + +[Slide at GoCon 2018 Spring (Japanese)](https://speakerdeck.com/rhysd/go-selfupdate-github-de-turuwozi-ji-atupudetosuru) + +[go-github-selfupdate]: https://github.com/rhysd/go-github-selfupdate +[GitHub Releases API]: https://developer.github.com/v3/repos/releases/ + + + +## Try Out Example + +Example to understand what this library does is prepared as [CLI](./cmd/selfupdate-example/main.go). + +Install it at first. + +``` +$ go get -u github.com/rhysd/go-github-selfupdate/cmd/selfupdate-example +``` + +And check the version by `-version`. `-help` flag is also available to know all flags. + +``` +$ selfupdate-example -version +``` + +It should show `v1.2.3`. + +Then run `-selfupdate` + +``` +$ selfupdate-example -selfupdate +``` + +It should replace itself and finally show a message containing release notes. + +Please check the binary version is updated to `v1.2.4` with `-version`. The binary is up-to-date. +So running `-selfupdate` again only shows 'Current binary is the latest version'. + +### Real World Examples + +Following tools are using this library. + +- [dot-github](https://github.com/rhysd/dot-github) +- [dotfiles](https://github.com/rhysd/dotfiles) +- [github-clone-all](https://github.com/rhysd/github-clone-all) +- [pythonbrew](https://github.com/utahta/pythonbrew) +- [akashic](https://github.com/cowlick/akashic) +- [butler](https://github.com/netzkern/butler) + + + +## Usage + +### Code Usage + +It provides `selfupdate` package. + +- `selfupdate.UpdateSelf()`: Detect the latest version of itself and run self update. +- `selfupdate.UpdateCommand()`: Detect the latest version of given repository and update given command. +- `selfupdate.DetectLatest()`: Detect the latest version of given repository. +- `selfupdate.DetectVersion()`: Detect the user defined version of given repository. +- `selfupdate.UpdateTo()`: Update given command to the binary hosted on given URL. +- `selfupdate.Updater`: Context manager of self-update process. If you want to customize some behavior + of self-update (e.g. specify API token, use GitHub Enterprise, ...), please make an instance of + `Updater` and use its methods. + +Following is the easiest way to use this package. + +```go +import ( + "log" + "github.com/blang/semver" + "github.com/rhysd/go-github-selfupdate/selfupdate" +) + +const version = "1.2.3" + +func doSelfUpdate() { + v := semver.MustParse(version) + latest, err := selfupdate.UpdateSelf(v, "myname/myrepo") + if err != nil { + log.Println("Binary update failed:", err) + return + } + if latest.Version.Equals(v) { + // latest version is the same as current version. It means current binary is up to date. + log.Println("Current binary is the latest version", version) + } else { + log.Println("Successfully updated to version", latest.Version) + log.Println("Release note:\n", latest.ReleaseNotes) + } +} +``` + +Following asks user to update or not. + +```go +import ( + "bufio" + "github.com/blang/semver" + "github.com/rhysd/go-github-selfupdate/selfupdate" + "log" + "os" +) + +const version = "1.2.3" + +func confirmAndSelfUpdate() { + latest, found, err := selfupdate.DetectLatest("owner/repo") + if err != nil { + log.Println("Error occurred while detecting version:", err) + return + } + + v := semver.MustParse(version) + if !found || latest.Version.LTE(v) { + log.Println("Current version is the latest") + return + } + + fmt.Print("Do you want to update to", latest.Version, "? (y/n): ") + input, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil || (input != "y\n" && input != "n\n") { + log.Println("Invalid input") + return + } + if input == "n\n" { + return + } + + exe, err := os.Executable() + if err != nil { + log.Println("Could not locate executable path") + return + } + if err := selfupdate.UpdateTo(latest.AssetURL, exe); err != nil { + log.Println("Error occurred while updating binary:", err) + return + } + log.Println("Successfully updated to version", latest.Version) +} +``` + +If GitHub API token is set to `[token]` section in `gitconfig` or `$GITHUB_TOKEN` environment variable, +this library will use it to call GitHub REST API. It's useful when reaching rate limits or when using +this library with private repositories. + +Note that `os.Args[0]` is not available since it does not provide a full path to executable. Instead, +please use `os.Executable()`. + +Please see [the documentation page][GoDoc] for more detail. + +This library should work with [GitHub Enterprise][]. To configure API base URL, please setup `Updater` +instance and use its methods instead (actually all functions above are just a shortcuts of methods of an +`Updater` instance). + +Following is an example of usage with GitHub Enterprise. + +```go +import ( + "log" + "github.com/blang/semver" + "github.com/rhysd/go-github-selfupdate/selfupdate" +) + +const version = "1.2.3" + +func doSelfUpdate(token string) { + v := semver.MustParse(version) + up, err := selfupdate.NewUpdater(selfupdate.Config{ + APIToken: token, + EnterpriseBaseURL: "https://github.your.company.com/api/v3", + }) + latest, err := up.UpdateSelf(v, "myname/myrepo") + if err != nil { + log.Println("Binary update failed:", err) + return + } + if latest.Version.Equals(v) { + // latest version is the same as current version. It means current binary is up to date. + log.Println("Current binary is the latest version", version) + } else { + log.Println("Successfully updated to version", latest.Version) + log.Println("Release note:\n", latest.ReleaseNotes) + } +} +``` + +If `APIToken` field is not given, it tries to retrieve API token from `[token]` section of `.gitconfig` +or `$GITHUB_TOKEN` environment variable. If no token is found, it raises an error because GitHub Enterprise +API does not work without authentication. + +If your GitHub Enterprise instance's upload URL is different from the base URL, please also set the `EnterpriseUploadURL` +field. + + +### Naming Rules of Released Binaries + +go-github-selfupdate assumes that released binaries are put for each combination of platforms and archs. +Binaries for each platform can be easily built using tools like [gox][] + +You need to put the binaries with the following format. + +``` +{cmd}_{goos}_{goarch}{.ext} +``` + +`{cmd}` is a name of command. +`{goos}` and `{goarch}` are the platform and the arch type of the binary. +`{.ext}` is a file extension. go-github-selfupdate supports `.zip`, `.gzip`, `.tar.gz` and `.tar.xz`. +You can also use blank and it means binary is not compressed. + +If you compress binary, uncompressed directory or file must contain the executable named `{cmd}`. + +And you can also use `-` for separator instead of `_` if you like. + +For example, if your command name is `foo-bar`, one of followings is expected to be put in release +page on GitHub as binary for platform `linux` and arch `amd64`. + +- `foo-bar_linux_amd64` (executable) +- `foo-bar_linux_amd64.zip` (zip file) +- `foo-bar_linux_amd64.tar.gz` (tar file) +- `foo-bar_linux_amd64.xz` (xzip file) +- `foo-bar-linux-amd64.tar.gz` (`-` is also ok for separator) + +If you compress and/or archive your release asset, it must contain an executable named one of followings: + +- `foo-bar` (only command name) +- `foo-bar_linux_amd64` (full name) +- `foo-bar-linux-amd64` (`-` is also ok for separator) + +To archive the executable directly on Windows, `.exe` can be added before file extension like +`foo-bar_windows_amd64.exe.zip`. + +[gox]: https://github.com/mitchellh/gox + + +### Naming Rules of Versions (=Git Tags) + +go-github-selfupdate searches binaries' versions via Git tag names (not a release title). +When your tool's version is `1.2.3`, you should use the version number for tag of the Git +repository (i.e. `1.2.3` or `v1.2.3`). + +This library assumes you adopt [semantic versioning][]. It is necessary for comparing versions +systematically. + +Prefix before version number `\d+\.\d+\.\d+` is automatically omitted. For example, `ver1.2.3` or +`release-1.2.3` are also ok. + +Tags which don't contain a version number are ignored (i.e. `nightly`). And releases marked as `pre-release` +are also ignored. + +[semantic versioning]: https://semver.org/ + + +### Structure of Releases + +In summary, structure of releases on GitHub looks like: + +- `v1.2.0` + - `foo-bar-linux-amd64.tar.gz` + - `foo-bar-linux-386.tar.gz` + - `foo-bar-darwin-amd64.tar.gz` + - `foo-bar-windows-amd64.zip` + - ... (Other binaries for v1.2.0) +- `v1.1.3` + - `foo-bar-linux-amd64.tar.gz` + - `foo-bar-linux-386.tar.gz` + - `foo-bar-darwin-amd64.tar.gz` + - `foo-bar-windows-amd64.zip` + - ... (Other binaries for v1.1.3) +- ... (older versions) + + +### Hash or Signature Validation + +go-github-selfupdate supports hash or signature validatiom of the downloaded files. It comes +with support for sha256 hashes or ECDSA signatures. In addition to internal functions the +user can implement the `Validator` interface for own validation mechanisms. + +```go +// Validator represents an interface which enables additional validation of releases. +type Validator interface { + // Validate validates release bytes against an additional asset bytes. + // See SHA2Validator or ECDSAValidator for more information. + Validate(release, asset []byte) error + // Suffix describes the additional file ending which is used for finding the + // additional asset. + Suffix() string +} +``` + +#### SHA256 + +To verify the integrity by SHA256 generate a hash sum and save it within a file which has the +same naming as original file with the suffix `.sha256`. +For e.g. use sha256sum, the file `selfupdate/testdata/foo.zip.sha256` is generated with: +```shell +sha256sum foo.zip > foo.zip.sha256 +``` + +#### ECDSA +To verify the signature by ECDSA generate a signature and save it within a file which has the +same naming as original file with the suffix `.sig`. +For e.g. use openssl, the file `selfupdate/testdata/foo.zip.sig` is generated with: +```shell +openssl dgst -sha256 -sign Test.pem -out foo.zip.sig foo.zip +``` + +go-github-selfupdate makes use of go internal crypto package. Therefore the used private key +has to be compatbile with FIPS 186-3. + + + +## Development + +### Running tests + +All library sources are put in `/selfupdate` directory. So you can run tests as following +at the top of the repository: + +``` +$ go test -v ./selfupdate +``` + +Some tests are not run without setting a GitHub API token because they call GitHub API too many times. +To run them, please generate an API token and set it to an environment variable. + +``` +$ export GITHUB_TOKEN="{token generated by you}" +$ go test -v ./selfupdate +``` + +The above command runs almost all tests and it's enough to check the behavior before creating a pull request. +Some tests are still not tested because they depend on my personal API access token, though; for repositories +on GitHub Enterprise or private repositories on GitHub. + + +### Debugging + +This library can output logs for debugging. By default, logger is disabled. +You can enable the logger by the following and can know the details of the self update. + +```go +selfupdate.EnableLog() +``` + + +### CI + +Tests run on CIs (Travis CI, Appveyor) are run with the token I generated. However, because of security +reasons, it is not used for the tests for pull requests. In the tests, a GitHub API token is not set and +API rate limit is often exceeding. So please ignore the test failures on creating a pull request. + + + +## Dependencies + +This library utilizes +- [go-github][] to retrieve the information of releases +- [go-update][] to replace current binary +- [semver][] to compare versions +- [xz][] to support XZ compress format + +> Copyright (c) 2013 The go-github AUTHORS. All rights reserved. + +> Copyright 2015 Alan Shreve + +> Copyright (c) 2014 Benedikt Lang + +> Copyright (c) 2014-2016 Ulrich Kunitz + +[go-github]: https://github.com/google/go-github +[go-update]: https://github.com/inconshreveable/go-update +[semver]: https://github.com/blang/semver +[xz]: https://github.com/ulikunitz/xz + + + +## What is different from [tj/go-update][]? + +This library's goal is the same as tj/go-update, but it's different in following points. + +tj/go-update: + +- does not support Windows +- only allows `v` for version prefix +- does not ignore pre-release +- has [only a few tests](https://github.com/tj/go-update/blob/master/update_test.go) +- supports Apex store for putting releases + +[tj/go-update]: https://github.com/tj/go-update + + + +## License + +Distributed under the [MIT License](LICENSE) + +[GoDoc Badge]: https://godoc.org/github.com/rhysd/go-github-selfupdate/selfupdate?status.svg +[GoDoc]: https://godoc.org/github.com/rhysd/go-github-selfupdate/selfupdate +[TravisCI Status]: https://travis-ci.org/rhysd/go-github-selfupdate.svg?branch=master +[TravisCI]: https://travis-ci.org/rhysd/go-github-selfupdate +[AppVeyor Status]: https://ci.appveyor.com/api/projects/status/1tpyd9q9tw3ime5u/branch/master?svg=true +[AppVeyor]: https://ci.appveyor.com/project/rhysd/go-github-selfupdate/branch/master +[Codecov Status]: https://codecov.io/gh/rhysd/go-github-selfupdate/branch/master/graph/badge.svg +[Codecov]: https://codecov.io/gh/rhysd/go-github-selfupdate +[GitHub Enterprise]: https://enterprise.github.com/home diff --git a/cmd/detect-latest-release/README.md b/cmd/detect-latest-release/README.md new file mode 100644 index 0000000..86773b1 --- /dev/null +++ b/cmd/detect-latest-release/README.md @@ -0,0 +1,20 @@ +This command line tool is a small wrapper of [`selfupdate.DetectLatest()`](https://godoc.org/github.com/rhysd/go-github-selfupdate/selfupdate#DetectLatest). + +Please install using `go get`. + +``` +$ go get -u github.com/rhysd/go-github-selfupdate/cmd/detect-latest-release +``` + +To know the usage, please try the command without any argument. + +``` +$ detect-latest-release +``` + +For example, following shows the latest version of [github-clone-all](https://github.com/rhysd/github-clone-all). + +``` +$ detect-latest-release rhysd/github-clone-all +``` + diff --git a/cmd/detect-latest-release/main.go b/cmd/detect-latest-release/main.go new file mode 100644 index 0000000..bf8de19 --- /dev/null +++ b/cmd/detect-latest-release/main.go @@ -0,0 +1,66 @@ +package main + +import ( + "flag" + "fmt" + "github.com/rhysd/go-github-selfupdate/selfupdate" + "os" + "regexp" + "strings" +) + +func usage() { + fmt.Fprintln(os.Stderr, "Usage: detect-latest-release [flags] {repo}\n\n {repo} must be URL to GitHub repository or in 'owner/name' format.\n\nFlags:\n") + flag.PrintDefaults() +} + +func main() { + asset := flag.Bool("asset", false, "Output URL to asset") + notes := flag.Bool("release-notes", false, "Output release notes additionally") + url := flag.Bool("url", false, "Output URL for release page") + + flag.Usage = usage + flag.Parse() + + if flag.NArg() != 1 { + usage() + os.Exit(1) + } + + repo := flag.Arg(0) + if strings.HasPrefix(repo, "https://") { + repo = repo[len("https://"):] + } + if strings.HasPrefix(repo, "github.com/") { + repo = repo[len("github.com/"):] + } + + matched, err := regexp.MatchString("[^/]+/[^/]+", repo) + if err != nil { + panic(err) + } + if !matched { + usage() + os.Exit(1) + } + + latest, found, err := selfupdate.DetectLatest(repo) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if !found { + fmt.Println("No release was found") + } else { + if *asset { + fmt.Println(latest.AssetURL) + } else if *url { + fmt.Println(latest.URL) + } else { + fmt.Println(latest.Version) + if *notes { + fmt.Printf("\nRelease Notes:\n%s\n", latest.ReleaseNotes) + } + } + } +} diff --git a/cmd/go-get-release/README.md b/cmd/go-get-release/README.md new file mode 100644 index 0000000..42b36e3 --- /dev/null +++ b/cmd/go-get-release/README.md @@ -0,0 +1,29 @@ +Like `go get`, but it downloads and installs the latest release binary from GitHub instead. + +Please download a binary from [release page](https://github.com/rhysd/go-github-selfupdate/releases/tag/go-get-release) +and put it in `$PATH` or build from source with `go get`. + +``` +$ go get -u github.com/rhysd/go-github-selfupdate/cmd/go-get-release +``` + +Usage is quite similar to `go get`. But `{package}` must be hosted on GitHub. So it needs to start with `github.com/`. + +``` +$ go-get-release {package} +``` + +Please note that this command assumes that specified package is following Git tag naming rules and +released binaries naming rules described in [README](../../README.md). + +For example, following command downloads and installs the released binary of [ghr](https://github.com/tcnksm/ghr) +to `$GOPATH/bin`. + +``` +$ go-get-release github.com/tcnksm/ghr +Command was updated to the latest version 0.5.4: /Users/you/.go/bin/ghr + +$ ghr -version +ghr version v0.5.4 (a12ff1c) +``` + diff --git a/cmd/go-get-release/main.go b/cmd/go-get-release/main.go new file mode 100644 index 0000000..d4b9d5b --- /dev/null +++ b/cmd/go-get-release/main.go @@ -0,0 +1,132 @@ +package main + +import ( + "flag" + "fmt" + "github.com/rhysd/go-github-selfupdate/selfupdate" + "go/build" + "io" + "net/http" + "os" + "path/filepath" + "strings" +) + +var version = "1.0.0" + +func usage() { + fmt.Fprintln(os.Stderr, `Usage: go-get-release [flags] {package} + + go-get-release is like "go get", but it downloads the latest release from + GitHub. {package} must start with "github.com/". + +Flags:`) + flag.PrintDefaults() +} + +func getCommand(pkg string) string { + _, cmd := filepath.Split(pkg) + if cmd == "" { + // When pkg path is ending with path separator, we need to split it out. + // i.e. github.com/rhysd/foo/cmd/bar/ + _, cmd = filepath.Split(cmd) + } + return cmd +} + +func parseSlug(pkg string) (string, bool) { + pkg = pkg[len("github.com/"):] + first := false + for i, r := range pkg { + if r == '/' { + if !first { + first = true + } else { + return pkg[:i], true + } + } + } + if first { + // When 'github.com/foo/bar' is specified, reaching here. + return pkg, true + } + return "", false +} + +func installFrom(url, cmd, path string) error { + res, err := http.Get(url) + if err != nil { + return fmt.Errorf("Failed to download release binary from %s: %s", url, err) + } + defer res.Body.Close() + if res.StatusCode != 200 { + return fmt.Errorf("Failed to download release binary from %s: Invalid response ", url) + } + executable, err := selfupdate.UncompressCommand(res.Body, url, cmd) + if err != nil { + return fmt.Errorf("Failed to uncompress downloaded asset from %s: %s", url, err) + } + bin, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE, 0755) + if err != nil { + return err + } + if _, err := io.Copy(bin, executable); err != nil { + return fmt.Errorf("Failed to write binary to %s: %s", path, err) + } + return nil +} + +func main() { + help := flag.Bool("help", false, "Show help") + ver := flag.Bool("version", false, "Show version") + + flag.Usage = usage + flag.Parse() + + if *ver { + fmt.Println(version) + os.Exit(0) + } + + if *help || flag.NArg() != 1 || !strings.HasPrefix(flag.Arg(0), "github.com/") { + usage() + os.Exit(1) + } + + slug, ok := parseSlug(flag.Arg(0)) + if !ok { + usage() + os.Exit(1) + } + + latest, found, err := selfupdate.DetectLatest(slug) + if err != nil { + fmt.Fprintln(os.Stderr, "Error while detecting the latest version:", err) + os.Exit(1) + } + if !found { + fmt.Fprintln(os.Stderr, "No release was found in", slug) + os.Exit(1) + } + + cmd := getCommand(flag.Arg(0)) + cmdPath := filepath.Join(build.Default.GOPATH, "bin", cmd) + if _, err := os.Stat(cmdPath); err != nil { + // When executable is not existing yet + if err := installFrom(latest.AssetURL, cmd, cmdPath); err != nil { + fmt.Fprintf(os.Stderr, "Error while installing the release binary from %s: %s\n", latest.AssetURL, err) + os.Exit(1) + } + } else { + if err := selfupdate.UpdateTo(latest.AssetURL, cmdPath); err != nil { + fmt.Fprintf(os.Stderr, "Error while replacing the binary with %s: %s\n", latest.AssetURL, err) + os.Exit(1) + } + } + + fmt.Printf(`Command was updated to the latest version %s: %s + +Release Notes: +%s +`, latest.Version, cmdPath, latest.ReleaseNotes) +} diff --git a/cmd/selfupdate-example/main.go b/cmd/selfupdate-example/main.go new file mode 100644 index 0000000..4fa3d4e --- /dev/null +++ b/cmd/selfupdate-example/main.go @@ -0,0 +1,64 @@ +package main + +import ( + "flag" + "fmt" + "github.com/blang/semver" + "github.com/rhysd/go-github-selfupdate/selfupdate" + "os" +) + +const version = "1.2.3" + +func selfUpdate(slug string) error { + selfupdate.EnableLog() + + previous := semver.MustParse(version) + latest, err := selfupdate.UpdateSelf(previous, slug) + if err != nil { + return err + } + + if previous.Equals(latest.Version) { + fmt.Println("Current binary is the latest version", version) + } else { + fmt.Println("Update successfully done to version", latest.Version) + fmt.Println("Release note:\n", latest.ReleaseNotes) + } + return nil +} + +func usage() { + fmt.Fprintln(os.Stderr, "Usage: selfupdate-example [flags]\n") + flag.PrintDefaults() +} + +func main() { + help := flag.Bool("help", false, "Show this help") + ver := flag.Bool("version", false, "Show version") + update := flag.Bool("selfupdate", false, "Try go-github-selfupdate via GitHub") + slug := flag.String("slug", "rhysd/go-github-selfupdate", "Repository of this command") + + flag.Usage = usage + flag.Parse() + + if *help { + usage() + os.Exit(0) + } + + if *ver { + fmt.Println(version) + os.Exit(0) + } + + if *update { + if err := selfUpdate(*slug); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + os.Exit(0) + } + + usage() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7306dc9 --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module github.com/rhysd/go-github-selfupdate + +require ( + github.com/blang/semver v3.5.1+incompatible + github.com/google/go-github/v30 v30.1.0 + github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf + github.com/kr/pretty v0.1.0 // indirect + github.com/onsi/gomega v1.4.2 // indirect + github.com/tcnksm/go-gitconfig v0.1.2 + github.com/ulikunitz/xz v0.5.9 + golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad // indirect + golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288 + golang.org/x/text v0.3.5 // indirect + google.golang.org/appengine v1.3.0 // indirect + gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect +) + +go 1.13 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3512262 --- /dev/null +++ b/go.sum @@ -0,0 +1,66 @@ +github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= +github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/go-github/v30 v30.1.0 h1:VLDx+UolQICEOKu2m4uAoMti1SxuEBAl7RSEG16L+Oo= +github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQFEufcolZ95JfU8= +github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7VjIE6z8dIvMsI4/s+1qr5EL+zoIGev1BQj1eoJ8= +github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/onsi/ginkgo v1.6.0 h1:Ix8l273rp3QzYgXSR+c8d1fTG7UPgYkOSELPhiY/YGw= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.2 h1:3mYCb7aPxS/RU7TI1y4rkEn1oKmPRjNJLNEXgw7MH2I= +github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/tcnksm/go-gitconfig v0.1.2 h1:iiDhRitByXAEyjgBqsKi9QU4o2TNtv9kPP3RgPgXBPw= +github.com/tcnksm/go-gitconfig v0.1.2/go.mod h1:/8EhP4H7oJZdIPyT+/UIsG87kTzrzM4UsLGSItWYCpE= +github.com/ulikunitz/xz v0.5.9 h1:RsKRIA2MO8x56wkkcd3LbtcE/uMszhb6DpRf+3uwa3I= +github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad h1:DN0cp81fZ3njFcrLCytUHRSUkqBjfTo4Tx9RJTWs0EY= +golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJVlRHBcsciT5bXOrH628= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288 h1:JIqe8uIcRBHXDQVvZtHwp80ai3Lw3IJAeJEs55Dc1W0= +golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ= +golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.3.0 h1:FBSsiFRMz3LBeXIomRnVzrQwSDj4ibvcRexLG0LZGQk= +google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/scripts/make-release.sh b/scripts/make-release.sh new file mode 100755 index 0000000..d9c8213 --- /dev/null +++ b/scripts/make-release.sh @@ -0,0 +1,26 @@ +#! /bin/bash + +set -e + +if [ ! -d .git ]; then + echo 'Run this script from root of repository' 1>&2 + exit 1 +fi + +executable=selfupdate-example + +rm -rf release +gox -verbose ./cmd/$executable +mkdir -p release +mv selfupdate-example_* release/ +cd release +for bin in *; do + if [[ "$bin" == *windows* ]]; then + command="${executable}.exe" + else + command="$executable" + fi + mv "$bin" "$command" + zip "${bin}.zip" "$command" + rm "$command" +done diff --git a/selfupdate/detect.go b/selfupdate/detect.go new file mode 100644 index 0000000..6ca1af7 --- /dev/null +++ b/selfupdate/detect.go @@ -0,0 +1,206 @@ +package selfupdate + +import ( + "fmt" + "regexp" + "runtime" + "strings" + + "github.com/blang/semver" + "github.com/google/go-github/v30/github" +) + +var reVersion = regexp.MustCompile(`\d+\.\d+\.\d+`) + +func findAssetFromRelease(rel *github.RepositoryRelease, + suffixes []string, targetVersion string, filters []*regexp.Regexp) (*github.ReleaseAsset, semver.Version, bool) { + + if targetVersion != "" && targetVersion != rel.GetTagName() { + log.Println("Skip", rel.GetTagName(), "not matching to specified version", targetVersion) + return nil, semver.Version{}, false + } + + if targetVersion == "" && rel.GetDraft() { + log.Println("Skip draft version", rel.GetTagName()) + return nil, semver.Version{}, false + } + if targetVersion == "" && rel.GetPrerelease() { + log.Println("Skip pre-release version", rel.GetTagName()) + return nil, semver.Version{}, false + } + + verText := rel.GetTagName() + indices := reVersion.FindStringIndex(verText) + if indices == nil { + log.Println("Skip version not adopting semver", verText) + return nil, semver.Version{}, false + } + if indices[0] > 0 { + log.Println("Strip prefix of version", verText[:indices[0]], "from", verText) + verText = verText[indices[0]:] + } + + // If semver cannot parse the version text, it means that the text is not adopting + // the semantic versioning. So it should be skipped. + ver, err := semver.Make(verText) + if err != nil { + log.Println("Failed to parse a semantic version", verText) + return nil, semver.Version{}, false + } + + for _, asset := range rel.Assets { + name := asset.GetName() + if len(filters) > 0 { + // if some filters are defined, match them: if any one matches, the asset is selected + matched := false + for _, filter := range filters { + if filter.MatchString(name) { + log.Println("Selected filtered asset", name) + matched = true + break + } + log.Printf("Skipping asset %q not matching filter %v\n", name, filter) + } + if !matched { + continue + } + } + + for _, s := range suffixes { + if strings.HasSuffix(name, s) { // require version, arch etc + // default: assume single artifact + return asset, ver, true + } + } + } + + log.Println("No suitable asset was found in release", rel.GetTagName()) + return nil, semver.Version{}, false +} + +func findValidationAsset(rel *github.RepositoryRelease, validationName string) (*github.ReleaseAsset, bool) { + for _, asset := range rel.Assets { + if asset.GetName() == validationName { + return asset, true + } + } + return nil, false +} + +func findReleaseAndAsset(rels []*github.RepositoryRelease, + targetVersion string, + filters []*regexp.Regexp) (*github.RepositoryRelease, *github.ReleaseAsset, semver.Version, bool) { + // Generate candidates + suffixes := make([]string, 0, 2*7*2) + for _, sep := range []rune{'_', '-'} { + for _, ext := range []string{".zip", ".tar.gz", ".tgz", ".gzip", ".gz", ".tar.xz", ".xz", ""} { + suffix := fmt.Sprintf("%s%c%s%s", runtime.GOOS, sep, runtime.GOARCH, ext) + suffixes = append(suffixes, suffix) + if runtime.GOOS == "windows" { + suffix = fmt.Sprintf("%s%c%s.exe%s", runtime.GOOS, sep, runtime.GOARCH, ext) + suffixes = append(suffixes, suffix) + } + } + } + + var ver semver.Version + var asset *github.ReleaseAsset + var release *github.RepositoryRelease + + // Find the latest version from the list of releases. + // Returned list from GitHub API is in the order of the date when created. + // ref: https://github.com/rhysd/go-github-selfupdate/issues/11 + for _, rel := range rels { + if a, v, ok := findAssetFromRelease(rel, suffixes, targetVersion, filters); ok { + // Note: any version with suffix is less than any version without suffix. + // e.g. 0.0.1 > 0.0.1-beta + if release == nil || v.GTE(ver) { + ver = v + asset = a + release = rel + } + } + } + + if release == nil { + log.Println("Could not find any release for", runtime.GOOS, "and", runtime.GOARCH) + return nil, nil, semver.Version{}, false + } + + return release, asset, ver, true +} + +// DetectLatest tries to get the latest version of the repository on GitHub. 'slug' means 'owner/name' formatted string. +// It fetches releases information from GitHub API 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, tar&zip or tar&zxip. +// 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(slug string) (release *Release, found bool, err error) { + return up.DetectVersion(slug, "") +} + +// DetectVersion tries to get the given version of the repository on Github. `slug` means `owner/name` formatted string. +// And version indicates the required version. +func (up *Updater) DetectVersion(slug string, version string) (release *Release, found bool, err error) { + repo := strings.Split(slug, "/") + if len(repo) != 2 || repo[0] == "" || repo[1] == "" { + return nil, false, fmt.Errorf("Invalid slug format. It should be 'owner/name': %s", slug) + } + + rels, res, err := up.api.Repositories.ListReleases(up.apiCtx, repo[0], repo[1], nil) + if err != nil { + log.Println("API returned an error response:", err) + if res != nil && res.StatusCode == 404 { + // 404 means repository not found or release not found. It's not an error here. + err = nil + log.Println("API returned 404. Repository or release not found") + } + return nil, false, err + } + + rel, asset, ver, found := findReleaseAndAsset(rels, version, up.filters) + if !found { + return nil, false, nil + } + + url := asset.GetBrowserDownloadURL() + log.Println("Successfully fetched the latest release. tag:", rel.GetTagName(), ", name:", rel.GetName(), ", URL:", rel.GetURL(), ", Asset:", url) + + publishedAt := rel.GetPublishedAt().Time + release = &Release{ + ver, + url, + asset.GetSize(), + asset.GetID(), + -1, + rel.GetHTMLURL(), + rel.GetBody(), + rel.GetName(), + &publishedAt, + repo[0], + repo[1], + } + + if up.validator != nil { + validationName := asset.GetName() + up.validator.Suffix() + validationAsset, ok := findValidationAsset(rel, validationName) + if !ok { + return nil, false, fmt.Errorf("Failed finding validation file %q", validationName) + } + release.ValidationAssetID = validationAsset.GetID() + } + + return release, true, nil +} + +// DetectLatest detects the latest release of the slug (owner/repo). +// This function is a shortcut version of updater.DetectLatest() method. +func DetectLatest(slug string) (*Release, bool, error) { + return DefaultUpdater().DetectLatest(slug) +} + +// DetectVersion detects the given release of the slug (owner/repo) from its version. +func DetectVersion(slug string, version string) (*Release, bool, error) { + return DefaultUpdater().DetectVersion(slug, version) +} diff --git a/selfupdate/detect_test.go b/selfupdate/detect_test.go new file mode 100644 index 0000000..d64fe65 --- /dev/null +++ b/selfupdate/detect_test.go @@ -0,0 +1,457 @@ +package selfupdate + +import ( + "fmt" + "os" + "regexp" + "strings" + "testing" + + "github.com/blang/semver" + "github.com/google/go-github/v30/github" +) + +func TestDetectReleaseWithVersionPrefix(t *testing.T) { + r, ok, err := DetectLatest("rhysd/github-clone-all") + if err != nil { + t.Fatal("Fetch failed:", err) + } + if !ok { + t.Fatal("Failed to detect latest") + } + if r == nil { + t.Fatal("Release detected but nil returned for it") + } + if r.Version.LE(semver.MustParse("2.0.0")) { + t.Error("Incorrect version:", r.Version) + } + if !strings.HasSuffix(r.AssetURL, ".zip") && !strings.HasSuffix(r.AssetURL, ".tar.gz") { + t.Error("Incorrect URL for asset:", r.AssetURL) + } + if r.URL == "" { + t.Error("Document URL should not be empty") + } + if r.ReleaseNotes == "" { + t.Error("Description should not be empty for this repo") + } + if r.Name == "" { + t.Error("Release name is unexpectedly empty") + } + if r.AssetByteSize == 0 { + t.Error("Asset's size is unexpectedly zero") + } + if r.AssetID == 0 { + t.Error("Asset's ID is unexpectedly zero") + } + if r.PublishedAt.IsZero() { + t.Error("Release time is unexpectedly zero") + } + if r.RepoOwner != "rhysd" { + t.Error("Repo owner is not correct:", r.RepoOwner) + } + if r.RepoName != "github-clone-all" { + t.Error("Repo name was not properly detectd:", r.RepoName) + } +} + +func TestDetectVersionExisting(t *testing.T) { + testVersion := "v2.2.0" + r, ok, err := DetectVersion("rhysd/github-clone-all", testVersion) + if err != nil { + t.Fatal("Fetch failed:", err) + } + if !ok { + t.Fatalf("Failed to detect %s", testVersion) + } + if r == nil { + t.Fatal("Release detected but nil returned for it") + } +} + +func TestDetectVersionNotExisting(t *testing.T) { + r, ok, err := DetectVersion("rhysd/github-clone-all", "foobar") + if err != nil { + t.Fatal("Fetch failed:", err) + } + if ok { + t.Fatal("Failed to correctly detect foobar") + } + if r != nil { + t.Fatal("Release not detected but got a returned value for it") + } +} + +func TestDetectReleasesForVariousArchives(t *testing.T) { + for _, tc := range []struct { + slug string + prefix string + }{ + {"rhysd-test/test-release-zip", "v"}, + {"rhysd-test/test-release-tar", "v"}, + {"rhysd-test/test-release-gzip", "v"}, + {"rhysd-test/test-release-xz", "release-v"}, + {"rhysd-test/test-release-tar-xz", "release-"}, + } { + t.Run(tc.slug, func(t *testing.T) { + r, ok, err := DetectLatest(tc.slug) + if err != nil { + t.Fatal("Fetch failed:", err) + } + if !ok { + t.Fatal(tc.slug, "not found") + } + if r == nil { + t.Fatal("Release not detected") + } + if !r.Version.Equals(semver.MustParse("1.2.3")) { + t.Error("") + } + url := fmt.Sprintf("https://github.com/%s/releases/tag/%s1.2.3", tc.slug, tc.prefix) + if r.URL != url { + t.Error("URL is not correct. Want", url, "but got", r.URL) + } + if r.ReleaseNotes == "" { + t.Error("Release note is unexpectedly empty") + } + if !strings.HasPrefix(r.AssetURL, fmt.Sprintf("https://github.com/%s/releases/download/%s1.2.3/", tc.slug, tc.prefix)) { + t.Error("Unexpected asset URL:", r.AssetURL) + } + if r.Name == "" { + t.Error("Release name is unexpectedly empty") + } + if r.AssetByteSize == 0 { + t.Error("Asset's size is unexpectedly zero") + } + if r.AssetID == 0 { + t.Error("Asset's ID is unexpectedly zero") + } + if r.PublishedAt.IsZero() { + t.Error("Release time is unexpectedly zero") + } + if r.RepoOwner != "rhysd-test" { + t.Error("Repo owner should be rhysd-test:", r.RepoOwner) + } + if !strings.HasPrefix(r.RepoName, "test-release-") { + t.Error("Repo name was not properly detectd:", r.RepoName) + } + }) + } +} + +func TestDetectReleaseButNoAsset(t *testing.T) { + _, ok, err := DetectLatest("rhysd/clever-f.vim") + if err != nil { + t.Fatal("Fetch failed:", err) + } + if ok { + t.Fatal("When no asset found, result should be marked as 'not found'") + } +} + +func TestDetectNoRelease(t *testing.T) { + _, ok, err := DetectLatest("rhysd/clever-f.vim") + if err != nil { + t.Fatal("Fetch failed:", err) + } + if ok { + t.Fatal("When no release found, result should be marked as 'not found'") + } +} + +func TestInvalidSlug(t *testing.T) { + up := DefaultUpdater() + + for _, slug := range []string{ + "foo", + "/", + "foo/", + "/bar", + "foo/bar/piyo", + } { + _, _, err := up.DetectLatest(slug) + if err == nil { + t.Error(slug, "should be invalid slug") + } + if !strings.Contains(err.Error(), "Invalid slug format") { + t.Error("Unexpected error for", slug, ":", err) + } + } +} + +func TestNonExistingRepo(t *testing.T) { + v, ok, err := DetectLatest("rhysd/non-existing-repo") + if err != nil { + t.Fatal("Non-existing repo should not cause an error:", v) + } + if ok { + t.Fatal("Release for non-existing repo should not be found") + } +} + +func TestNoReleaseFound(t *testing.T) { + _, ok, err := DetectLatest("rhysd/misc") + if err != nil { + t.Fatal("Repo having no release should not cause an error:", err) + } + if ok { + t.Fatal("Repo having no release should not be found") + } +} + +func TestDetectFromBrokenGitHubEnterpriseURL(t *testing.T) { + up, err := NewUpdater(Config{APIToken: "hogehoge", EnterpriseBaseURL: "https://example.com"}) + if err != nil { + t.Fatal(err) + } + _, ok, _ := up.DetectLatest("foo/bar") + if ok { + t.Fatal("Invalid GitHub Enterprise base URL should raise an error") + } +} + +func TestDetectFromGitHubEnterpriseRepo(t *testing.T) { + token := os.Getenv("GITHUB_ENTERPRISE_TOKEN") + base := os.Getenv("GITHUB_ENTERPRISE_BASE_URL") + repo := os.Getenv("GITHUB_ENTERPRISE_REPO") + if token == "" { + t.Skip("because token for GHE is not found") + } + if base == "" { + t.Skip("because base URL for GHE is not found") + } + if repo == "" { + t.Skip("because repo slug for GHE is not found") + } + + up, err := NewUpdater(Config{APIToken: token, EnterpriseBaseURL: base}) + if err != nil { + t.Fatal(err) + } + + r, ok, err := up.DetectLatest(repo) + if err != nil { + t.Fatal("Fetch failed:", err) + } + if !ok { + t.Fatal(repo, "not found") + } + if r == nil { + t.Fatal("Release not detected") + } + if !r.Version.Equals(semver.MustParse("1.2.3")) { + t.Error("") + } +} + +func TestFindReleaseAndAsset(t *testing.T) { + EnableLog() + type findReleaseAndAssetFixture struct { + name string + rels *github.RepositoryRelease + targetVersion string + filters []*regexp.Regexp + expectedAsset string + expectedVersion string + expectedFound bool + } + + rel1 := "rel1" + v1 := "1.0.0" + rel11 := "rel11" + v11 := "1.1.0" + asset1 := "asset1.gz" + asset2 := "asset2.gz" + wrongAsset1 := "asset1.yaml" + asset11 := "asset11.gz" + url1 := "https://asset1" + url2 := "https://asset2" + url11 := "https://asset11" + for _, fixture := range []findReleaseAndAssetFixture{ + { + name: "empty fixture", + rels: nil, + targetVersion: "", + filters: nil, + expectedFound: false, + }, + { + name: "find asset, no filters", + rels: &github.RepositoryRelease{ + Name: &rel1, + TagName: &v1, + Assets: []*github.ReleaseAsset{ + { + Name: &asset1, + URL: &url1, + }, + }, + }, + targetVersion: "1.0.0", + expectedAsset: asset1, + expectedVersion: "1.0.0", + expectedFound: true, + }, + { + name: "don't find asset with wrong extension, no filters", + rels: &github.RepositoryRelease{ + Name: &rel11, + TagName: &v11, + Assets: []*github.ReleaseAsset{ + { + Name: &wrongAsset1, + URL: &url11, + }, + }, + }, + targetVersion: "1.1.0", + expectedFound: false, + }, + { + name: "find asset with different name, no filters", + rels: &github.RepositoryRelease{ + Name: &rel11, + TagName: &v11, + Assets: []*github.ReleaseAsset{ + { + Name: &asset1, + URL: &url11, + }, + }, + }, + targetVersion: "1.1.0", + expectedAsset: asset1, + expectedVersion: "1.1.0", + expectedFound: true, + }, + { + name: "find asset, no filters (2)", + rels: &github.RepositoryRelease{ + Name: &rel11, + TagName: &v11, + Assets: []*github.ReleaseAsset{ + { + Name: &asset11, + URL: &url11, + }, + }, + }, + targetVersion: "1.1.0", + expectedAsset: asset11, + expectedVersion: "1.1.0", + filters: nil, + expectedFound: true, + }, + { + name: "find asset, match filter", + rels: &github.RepositoryRelease{ + Name: &rel11, + TagName: &v11, + Assets: []*github.ReleaseAsset{ + { + Name: &asset11, + URL: &url11, + }, + { + Name: &asset1, + URL: &url1, + }, + }, + }, + targetVersion: "1.1.0", + filters: []*regexp.Regexp{regexp.MustCompile("11")}, + expectedAsset: asset11, + expectedVersion: "1.1.0", + expectedFound: true, + }, + { + name: "find asset, match another filter", + rels: &github.RepositoryRelease{ + Name: &rel11, + TagName: &v11, + Assets: []*github.ReleaseAsset{ + { + Name: &asset11, + URL: &url11, + }, + { + Name: &asset1, + URL: &url1, + }, + }, + }, + targetVersion: "1.1.0", + filters: []*regexp.Regexp{regexp.MustCompile("([^1])1{1}([^1])")}, + expectedAsset: asset1, + expectedVersion: "1.1.0", + expectedFound: true, + }, + { + name: "find asset, match any filter", + rels: &github.RepositoryRelease{ + Name: &rel11, + TagName: &v11, + Assets: []*github.ReleaseAsset{ + { + Name: &asset11, + URL: &url11, + }, + { + Name: &asset2, + URL: &url2, + }, + }, + }, + targetVersion: "1.1.0", + filters: []*regexp.Regexp{ + regexp.MustCompile("([^1])1{1}([^1])"), + regexp.MustCompile("([^1])2{1}([^1])"), + }, + expectedAsset: asset2, + expectedVersion: "1.1.0", + expectedFound: true, + }, + { + name: "find asset, match no filter", + rels: &github.RepositoryRelease{ + Name: &rel11, + TagName: &v11, + Assets: []*github.ReleaseAsset{ + { + Name: &asset11, + URL: &url11, + }, + { + Name: &asset2, + URL: &url2, + }, + }, + }, + targetVersion: "1.1.0", + filters: []*regexp.Regexp{ + regexp.MustCompile("another"), + regexp.MustCompile("binary"), + }, + expectedFound: false, + }, + } { + asset, ver, found := findAssetFromRelease(fixture.rels, []string{".gz"}, fixture.targetVersion, fixture.filters) + if fixture.expectedFound { + if !found { + t.Errorf("expected to find an asset for this fixture: %q", fixture.name) + continue + } + if asset.Name == nil { + t.Errorf("invalid asset struct returned from fixture: %q, got: %v", fixture.name, asset) + continue + } + if *asset.Name != fixture.expectedAsset { + t.Errorf("expected asset %q in fixture: %q, got: %s", fixture.expectedAsset, fixture.name, *asset.Name) + continue + } + t.Logf("asset %v, %v", asset, ver) + } else if found { + t.Errorf("expected not to find an asset for this fixture: %q, but got: %v", fixture.name, asset) + } + } + +} diff --git a/selfupdate/doc.go b/selfupdate/doc.go new file mode 100644 index 0000000..38fc167 --- /dev/null +++ b/selfupdate/doc.go @@ -0,0 +1,38 @@ +/* +Package selfupdate provides self-update mechanism to Go command line tools. + +Go does not provide the way to install/update the stable version of tools. By default, Go command line tools are updated + +- using `go get -u` (updating to HEAD) +- using system's package manager (depending on the platform) +- downloading executables from GitHub release page manually + +By using this library, you will get 4th choice: + +- from your command line tool directly (and automatically) + +go-github-selfupdate detects the information of the latest release via GitHub Releases API and check the current version. +If newer version than itself is detected, it downloads released binary from GitHub and replaces itself. + +- Automatically detects the latest version of released binary on GitHub +- 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 +- Many archive and compression formats are supported (zip, gzip, xzip, tar) + +There are some naming rules. Please read following links. + +Naming Rules of Released Binaries: + https://github.com/rhysd/go-github-selfupdate#naming-rules-of-released-binaries + +Naming Rules of Git Tags: + https://github.com/rhysd/go-github-selfupdate#naming-rules-of-git-tags + +This package is hosted on GitHub: + https://github.com/rhysd/go-github-selfupdate + +Small CLI tools as wrapper of this library are available also: + https://github.com/rhysd/go-github-selfupdate/cmd/detect-latest-release + https://github.com/rhysd/go-github-selfupdate/cmd/go-get-release +*/ +package selfupdate diff --git a/selfupdate/e2e_test.go b/selfupdate/e2e_test.go new file mode 100644 index 0000000..6ae1481 --- /dev/null +++ b/selfupdate/e2e_test.go @@ -0,0 +1,22 @@ +package selfupdate + +import ( + "os" + "os/exec" + "testing" +) + +func TestRunSelfUpdateExample(t *testing.T) { + if testing.Short() { + t.Skip("skipping test in short mode.") + } + + t.Skip("TODO") + + if err := exec.Command("go", "build", "../cmd/selfupdate-example").Run(); err != nil { + t.Fatal(err) + } + defer os.Remove("selfupdate-example") + + // TODO +} diff --git a/selfupdate/log.go b/selfupdate/log.go new file mode 100644 index 0000000..7c5e7ac --- /dev/null +++ b/selfupdate/log.go @@ -0,0 +1,30 @@ +package selfupdate + +import ( + "io/ioutil" + stdlog "log" + "os" +) + +var log = stdlog.New(ioutil.Discard, "", 0) +var logEnabled = false + +// EnableLog enables to output logging messages in library +func EnableLog() { + if logEnabled { + return + } + logEnabled = true + log.SetOutput(os.Stderr) + log.SetFlags(stdlog.Ltime) +} + +// DisableLog disables to output logging messages in library +func DisableLog() { + if !logEnabled { + return + } + logEnabled = false + log.SetOutput(ioutil.Discard) + log.SetFlags(0) +} diff --git a/selfupdate/log_test.go b/selfupdate/log_test.go new file mode 100644 index 0000000..59f37b4 --- /dev/null +++ b/selfupdate/log_test.go @@ -0,0 +1,30 @@ +package selfupdate + +import ( + "testing" +) + +func TestEnableDisableLog(t *testing.T) { + defer DisableLog() + + EnableLog() + if !logEnabled { + t.Fatal("Log should be enabled") + } + EnableLog() + if !logEnabled { + t.Fatal("Log should be enabled") + } + DisableLog() + if logEnabled { + t.Fatal("Log should not be enabled") + } + DisableLog() + if logEnabled { + t.Fatal("Log should not be enabled") + } + EnableLog() + if !logEnabled { + t.Fatal("Log should be enabled") + } +} diff --git a/selfupdate/release.go b/selfupdate/release.go new file mode 100644 index 0000000..014ac47 --- /dev/null +++ b/selfupdate/release.go @@ -0,0 +1,33 @@ +package selfupdate + +import ( + "time" + + "github.com/blang/semver" +) + +// Release represents a release asset for current OS and arch. +type Release struct { + // Version is the version of the release + Version semver.Version + // AssetURL is a URL to the uploaded file for the release + AssetURL string + // AssetSize represents the size of asset in bytes + AssetByteSize int + // AssetID is the ID of the asset on GitHub + AssetID int64 + // ValidationAssetID is the ID of additional validaton asset on GitHub + ValidationAssetID int64 + // URL is a URL to release page for browsing + URL string + // ReleaseNotes is a release notes of the release + ReleaseNotes string + // Name represents a name of the release + Name string + // PublishedAt is the time when the release was published + PublishedAt *time.Time + // RepoOwner is the owner of the repository of the release + RepoOwner string + // RepoName is the name of the repository of the release + RepoName string +} diff --git a/selfupdate/testdata/Test.crt b/selfupdate/testdata/Test.crt new file mode 100644 index 0000000..7f4eff3 --- /dev/null +++ b/selfupdate/testdata/Test.crt @@ -0,0 +1,9 @@ +-----BEGIN CERTIFICATE----- +MIIBLzCB1qADAgECAgglJIDaK1tbjjAKBggqhkjOPQQDAjAPMQ0wCwYDVQQDEwRU +ZXN0MCAXDTE4MTEwNjE1MTcwMFoYDzIxMTgxMTA2MTUxNzAwWjAPMQ0wCwYDVQQD +EwRUZXN0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEs8fjo/Mi5A3c2v2YxV6A +QPJnr70qYMEpsmqn0BTcI8RhZUgB46tWqeDYdO15yQKbZjfI/dr0fvS21jyW0GSX +rKMaMBgwCwYDVR0PBAQDAgXgMAkGA1UdEwQCMAAwCgYIKoZIzj0EAwIDSAAwRQIh +AI1pUr0nrw3m++sR8HEBoejM5Qh1QJA7gF9y1jY6rc/aAiALFPJXckSLAQuq5IvQ +7cugOPws7/OoUo1124LKPugISg== +-----END CERTIFICATE----- diff --git a/selfupdate/testdata/Test.pem b/selfupdate/testdata/Test.pem new file mode 100644 index 0000000..c240365 --- /dev/null +++ b/selfupdate/testdata/Test.pem @@ -0,0 +1,14 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIJvTkRedVrQDNjCb9/RfVjzRwz8S059Y1J6w2N8gy8jVoAoGCCqGSM49 +AwEHoUQDQgAEs8fjo/Mi5A3c2v2YxV6AQPJnr70qYMEpsmqn0BTcI8RhZUgB46tW +qeDYdO15yQKbZjfI/dr0fvS21jyW0GSXrA== +-----END EC PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIBLzCB1qADAgECAgglJIDaK1tbjjAKBggqhkjOPQQDAjAPMQ0wCwYDVQQDEwRU +ZXN0MCAXDTE4MTEwNjE1MTcwMFoYDzIxMTgxMTA2MTUxNzAwWjAPMQ0wCwYDVQQD +EwRUZXN0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEs8fjo/Mi5A3c2v2YxV6A +QPJnr70qYMEpsmqn0BTcI8RhZUgB46tWqeDYdO15yQKbZjfI/dr0fvS21jyW0GSX +rKMaMBgwCwYDVR0PBAQDAgXgMAkGA1UdEwQCMAAwCgYIKoZIzj0EAwIDSAAwRQIh +AI1pUr0nrw3m++sR8HEBoejM5Qh1QJA7gF9y1jY6rc/aAiALFPJXckSLAQuq5IvQ +7cugOPws7/OoUo1124LKPugISg== +-----END CERTIFICATE----- diff --git a/selfupdate/testdata/bar-not-found.gzip b/selfupdate/testdata/bar-not-found.gzip new file mode 100644 index 0000000000000000000000000000000000000000..05e1b20e42c291ec06a41a8147be3eccdf06fd3c GIT binary patch literal 36 scmb2|=HLhwbBkhNF37CRXV5-*LMM!2(WEPng!C9RUL>*eGB7Xz0K*UpcmMzZ literal 0 HcmV?d00001 diff --git a/selfupdate/testdata/bar-not-found.tar.gz b/selfupdate/testdata/bar-not-found.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..1b1a3396347971684d9f6ef044c7c2774f628879 GIT binary patch literal 216 zcmV;}04M(+iwFoOAVyjM17cxvEpBggEoN_ZZe%WWVR8WN)lCY*Fc5~}93?mC^!`BpCfFc3f8%4XwA5Vcjr_a( zRh{ui{w}wZe*-1|>v^mC6F7eVbNvo{N~Z(ij8G_q6xqP{ z1h9WQ5Km4eB|9QuqO@YT#11m7nWsbgXD~lu?=sE_w}Mr#!A=wScq~tol=arO4F^>Z zFR$1hKb$dNlp3_YZI4lqs7dR30)ko@@sR|1kKV(=<WH|Cy|EGQ`AS86;N zjvnF>7L}RW@m1QD2<=Q3VLV4vtXKzXl9!8brn?h0<*ItHStaEUFG)@ yyY7b{RaJKPQQr#xAOHY+5~w90k8nf)0n`D2PyhhmJAd}E#Ao{g000001X)@L_i9D} literal 0 HcmV?d00001 diff --git a/selfupdate/testdata/bar-not-found.zip b/selfupdate/testdata/bar-not-found.zip new file mode 100644 index 0000000000000000000000000000000000000000..d2b1a88823092d69ba919a02705a0d7e5d51ebc8 GIT binary patch literal 1002 zcmWIWW@h1H0D%Ql=X!$~P=b#^h9N1jNH;IPL^mzJG%rOzG=!6Zc@Cdj)CM3ft>9*0 zWckX-z`!B`)DZwTXEB;NLKx;GCM846T1cu{qFBvJNlgPg4P+b$<8;~#G^Yt-7?+lx z4>oE7&=?RV+9*t47GzdZ&#ZhxW-&6!G2@Cy322N8FuZjHG0|g{6%wl$enpsx8K=l* zdSI9diD96*Sfc~CxuOg(a~T+xG)}>4E;zOc8;u#)$VNZLFd7{5Kx47SI>N>1#tNah pSPz&TP(v4z3Me(V3e#M0ioj(qD;v;%3~WHi$Hc&J45*ud0RZtLwnP8` literal 0 HcmV?d00001 diff --git a/selfupdate/testdata/empty.tar.gz b/selfupdate/testdata/empty.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..959fb3e3233f32320eccad91676bdadd0bbd4c7c GIT binary patch literal 127 zcmb2|=HU1%?-s?toSIuuQmI#xSj6!5+(zC*1`IBN{f~Gz^!7e?Z0ca+R9$%Dn@bUY z=!$#Ub;U>T&R<_@-RitFHb3`^(JU`nStq;cld|9K{WhUU&$RVM-(J%_)4y6Bmw0x3 d+ns%HzTGtW`&fVh2L60wyuBpbkwJrj0RZ7rI4b}E literal 0 HcmV?d00001 diff --git a/selfupdate/testdata/empty.zip b/selfupdate/testdata/empty.zip new file mode 100644 index 0000000000000000000000000000000000000000..afdaf0cda996a80535d75ae189fa7ddf50a66c5b GIT binary patch literal 162 zcmWIWW@h1H00FP5bG^X~D8a@c!;qR=P*SNM8p6rI{F~P;ikZ(XsB?l?|kd5eR*Nv;&C4003)^74HB5 literal 0 HcmV?d00001 diff --git a/selfupdate/testdata/fake-executable b/selfupdate/testdata/fake-executable new file mode 100644 index 0000000..2fcb8d1 --- /dev/null +++ b/selfupdate/testdata/fake-executable @@ -0,0 +1 @@ +this file is used for passing check of file existence in update tests. diff --git a/selfupdate/testdata/fake-executable.exe b/selfupdate/testdata/fake-executable.exe new file mode 100644 index 0000000..2fcb8d1 --- /dev/null +++ b/selfupdate/testdata/fake-executable.exe @@ -0,0 +1 @@ +this file is used for passing check of file existence in update tests. diff --git a/selfupdate/testdata/foo.tar.gz b/selfupdate/testdata/foo.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..b2f972f260325eeb7b917cdf01ae08c0db471f3d GIT binary patch literal 241 zcmVe;E_7jX0PWUI3c@fD1>mebMQ&gwe?3n`LkmKsiHmxA zlSXtQDisnJzK@pK1ZLndA$}aUtTK^w#*}JH6;hX4Fj7*ICL@htf`mk-tjEk$(d@|I;v6eFUz0|5?iUTP5-S rYvG>%&RuHtShfF4{!$U{e*gdg00000000000D#r5qzk={04M+e3*~VK literal 0 HcmV?d00001 diff --git a/selfupdate/testdata/foo.tar.xz b/selfupdate/testdata/foo.tar.xz new file mode 100644 index 0000000000000000000000000000000000000000..c4b3dd198bb905a62d304da51fc28827c691d98b GIT binary patch literal 260 zcmV+f0sH>_H+ooF000E$*0e?f03iVu0001VFXf})C;tG%T>vv1>|OxfG-0AA4iM3v zJ5fq{MX*FP0r>ahRA%q)@vD#^wM*SDQK_WW!SSrzE|EVRjQ$OsH?wDXL3av!I1jMZ zo@MHyzMnWx+qAfcF1+|3?R}UakB245l}@{Csn}c|nuE_*9K_Cs4H+33PaiHr8%uEJ z1{ss}5CgV|v`04g=TPrLhL5W>4Xk4SS@jSEF@i(6qUgB?KlzunzweB&g$RR92dDbU+>?e3!h0pJ0EPyhgAew1mk#Ao{g K000001X)@-?s!N5 literal 0 HcmV?d00001 diff --git a/selfupdate/testdata/foo.tgz b/selfupdate/testdata/foo.tgz new file mode 100644 index 0000000000000000000000000000000000000000..b2f972f260325eeb7b917cdf01ae08c0db471f3d GIT binary patch literal 241 zcmVe;E_7jX0PWUI3c@fD1>mebMQ&gwe?3n`LkmKsiHmxA zlSXtQDisnJzK@pK1ZLndA$}aUtTK^w#*}JH6;hX4Fj7*ICL@htf`mk-tjEk$(d@|I;v6eFUz0|5?iUTP5-S rYvG>%&RuHtShfF4{!$U{e*gdg00000000000D#r5qzk={04M+e3*~VK literal 0 HcmV?d00001 diff --git a/selfupdate/testdata/foo.zip b/selfupdate/testdata/foo.zip new file mode 100644 index 0000000000000000000000000000000000000000..c0f567436e921cd5b6121e4511510f4c2c1077b0 GIT binary patch literal 599 zcmWIWW@h1H0D+_lbG^X~D8a%Y!;qGruOAx1$-sPq#VzU*5SLbPGcdAzWn^Gr5do?T zfa{I~>ehIX#Lf#81z~om?xe&bumL-Pnn4)NfRc>NVg(>7Ni8nnLYS6-<`$S~K=*)6 zI|4Kbgwaex7=Z2>4ybbqGAkh_fII}k7$z_>$uZ*!5D93|2r#^L1Ti7O!wLx=JSJfV z4#Xs2FfuG@w1b)i4l1BwSb{0Q8{sU>uz@)XWLOW>FmOl#4Z|8r2*WVL3E8l-P{Sah WhRdIq^21Q0O1_p)_{ill=8CWw?b8_;zfc&Ki(?3r6_V)n;qa52o S{Ux&U+dkJa0>v3vBBKDGBou1^ literal 0 HcmV?d00001 diff --git a/selfupdate/testdata/invalid-xz.tar.xz b/selfupdate/testdata/invalid-xz.tar.xz new file mode 100644 index 0000000..ce01362 --- /dev/null +++ b/selfupdate/testdata/invalid-xz.tar.xz @@ -0,0 +1 @@ +hello diff --git a/selfupdate/testdata/invalid.gz b/selfupdate/testdata/invalid.gz new file mode 100644 index 0000000..e69de29 diff --git a/selfupdate/testdata/invalid.xz b/selfupdate/testdata/invalid.xz new file mode 100644 index 0000000..ce01362 --- /dev/null +++ b/selfupdate/testdata/invalid.xz @@ -0,0 +1 @@ +hello diff --git a/selfupdate/testdata/invalid.zip b/selfupdate/testdata/invalid.zip new file mode 100644 index 0000000..e69de29 diff --git a/selfupdate/testdata/single-file.gz b/selfupdate/testdata/single-file.gz new file mode 100644 index 0000000000000000000000000000000000000000..a4766d6473faf89a9e0c687ced2b9aee17f8f1d1 GIT binary patch literal 35 rcmb2|=HPfL>K4VooRnC^pndX$P8h?YNmm{T=`m=$NMh$@U|;|M#E%Ml literal 0 HcmV?d00001 diff --git a/selfupdate/testdata/single-file.gzip b/selfupdate/testdata/single-file.gzip new file mode 100644 index 0000000000000000000000000000000000000000..5a5411c458e3b64b2e84d633c2aff7d2ccd5e3e0 GIT binary patch literal 35 rcmb2|=HS>Q>K4VooRnC^pndX$P8h?YNmm{T=`m=$NMh$@U|;|MzG4bU literal 0 HcmV?d00001 diff --git a/selfupdate/testdata/single-file.xz b/selfupdate/testdata/single-file.xz new file mode 100644 index 0000000000000000000000000000000000000000..a4fb5cb47b3223fc2fefdae57d57d44dafd2f5c8 GIT binary patch literal 72 zcmexsUKJ6=z`*kC+7>q^21Q0O1_p)_{ill=8F)%EGK&>}s3f(xgbOIlz-+SVbcs!y XCj+A@Z=vLoZSvbb*D``6SR$hU3p^B( literal 0 HcmV?d00001 diff --git a/selfupdate/testdata/single-file.zip b/selfupdate/testdata/single-file.zip new file mode 100644 index 0000000000000000000000000000000000000000..838a01c4926e32ddc0d52ba6ddaa816136d4cf41 GIT binary patch literal 169 zcmWIWW@h1H0D;TX=6Y+qNMh#&vO$=cL53kIu_!czlY#lJfLjy@msW5yFtU7QWME(s z0V*lU$ShU>qLS3&60QJmMkYCCTxLlCl`}8^H8Lz|1hK#-vqDTpGc~}Ql?|kX5ePkj Jv=@lO008#Y9RvUX literal 0 HcmV?d00001 diff --git a/selfupdate/uncompress.go b/selfupdate/uncompress.go new file mode 100644 index 0000000..0449ec9 --- /dev/null +++ b/selfupdate/uncompress.go @@ -0,0 +1,136 @@ +package selfupdate + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "fmt" + "github.com/ulikunitz/xz" + "io" + "io/ioutil" + "path/filepath" + "runtime" + "strings" +) + +func matchExecutableName(cmd, target string) bool { + if cmd == target { + return true + } + + o, a := runtime.GOOS, runtime.GOARCH + + // When the contained executable name is full name (e.g. foo_darwin_amd64), + // it is also regarded as a target executable file. (#19) + for _, d := range []rune{'_', '-'} { + c := fmt.Sprintf("%s%c%s%c%s", cmd, d, o, d, a) + if o == "windows" { + c += ".exe" + } + if c == target { + return true + } + } + + return false +} + +func unarchiveTar(src io.Reader, url, cmd string) (io.Reader, error) { + t := tar.NewReader(src) + for { + h, err := t.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("Failed to unarchive .tar file: %s", err) + } + _, name := filepath.Split(h.Name) + if matchExecutableName(cmd, name) { + log.Println("Executable file", h.Name, "was found in tar archive") + return t, nil + } + } + + return nil, fmt.Errorf("File '%s' for the command is not found in %s", cmd, url) +} + +// UncompressCommand uncompresses the given source. Archive and compression format is +// automatically detected from 'url' parameter, which represents the URL of asset. +// This returns a reader for the uncompressed command given by 'cmd'. '.zip', +// '.tar.gz', '.tar.xz', '.tgz', '.gz' and '.xz' are supported. +func UncompressCommand(src io.Reader, url, cmd string) (io.Reader, error) { + if strings.HasSuffix(url, ".zip") { + log.Println("Uncompressing zip file", url) + + // Zip format requires its file size for uncompressing. + // So we need to read the HTTP response into a buffer at first. + buf, err := ioutil.ReadAll(src) + if err != nil { + return nil, fmt.Errorf("Failed to create buffer for zip file: %s", err) + } + + r := bytes.NewReader(buf) + z, err := zip.NewReader(r, r.Size()) + if err != nil { + return nil, fmt.Errorf("Failed to uncompress zip file: %s", err) + } + + for _, file := range z.File { + _, name := filepath.Split(file.Name) + if !file.FileInfo().IsDir() && matchExecutableName(cmd, name) { + log.Println("Executable file", file.Name, "was found in zip archive") + return file.Open() + } + } + + return nil, fmt.Errorf("File '%s' for the command is not found in %s", cmd, url) + } else if strings.HasSuffix(url, ".tar.gz") || strings.HasSuffix(url, ".tgz") { + log.Println("Uncompressing tar.gz file", url) + + gz, err := gzip.NewReader(src) + if err != nil { + return nil, fmt.Errorf("Failed to uncompress .tar.gz file: %s", err) + } + + return unarchiveTar(gz, url, cmd) + } else if strings.HasSuffix(url, ".gzip") || strings.HasSuffix(url, ".gz") { + log.Println("Uncompressing gzip file", url) + + r, err := gzip.NewReader(src) + if err != nil { + return nil, fmt.Errorf("Failed to uncompress gzip file downloaded from %s: %s", url, err) + } + + name := r.Header.Name + if !matchExecutableName(cmd, name) { + return nil, fmt.Errorf("File name '%s' does not match to command '%s' found in %s", name, cmd, url) + } + + log.Println("Executable file", name, "was found in gzip file") + return r, nil + } else if strings.HasSuffix(url, ".tar.xz") { + log.Println("Uncompressing tar.xz file", url) + + xzip, err := xz.NewReader(src) + if err != nil { + return nil, fmt.Errorf("Failed to uncompress .tar.xz file: %s", err) + } + + return unarchiveTar(xzip, url, cmd) + } else if strings.HasSuffix(url, ".xz") { + log.Println("Uncompressing xzip file", url) + + xzip, err := xz.NewReader(src) + if err != nil { + return nil, fmt.Errorf("Failed to uncompress xzip file downloaded from %s: %s", url, err) + } + + log.Println("Uncompressed file from xzip is assumed to be an executable", cmd) + return xzip, nil + } + + log.Println("Uncompression is not needed", url) + return src, nil +} diff --git a/selfupdate/uncompress_test.go b/selfupdate/uncompress_test.go new file mode 100644 index 0000000..12bb812 --- /dev/null +++ b/selfupdate/uncompress_test.go @@ -0,0 +1,133 @@ +package selfupdate + +import ( + "bytes" + "io/ioutil" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCompressionNotRequired(t *testing.T) { + buf := []byte{'a', 'b', 'c'} + want := bytes.NewReader(buf) + r, err := UncompressCommand(want, "https://github.com/foo/bar/releases/download/v1.2.3/foo", "foo") + if err != nil { + t.Fatal(err) + } + have, err := ioutil.ReadAll(r) + if err != nil { + t.Fatal(err) + } + for i, b := range have { + if buf[i] != b { + t.Error(i, "th elem is not the same as wanted. want", buf[i], "but got", b) + } + } +} + +func getArchiveFileExt(file string) string { + if strings.HasSuffix(file, ".tar.gz") { + return ".tar.gz" + } + if strings.HasSuffix(file, ".tar.xz") { + return ".tar.xz" + } + return filepath.Ext(file) +} + +func TestUncompress(t *testing.T) { + for _, n := range []string{ + "testdata/foo.zip", + "testdata/single-file.zip", + "testdata/single-file.gz", + "testdata/single-file.gzip", + "testdata/foo.tar.gz", + "testdata/foo.tgz", + "testdata/foo.tar.xz", + "testdata/single-file.xz", + } { + t.Run(n, func(t *testing.T) { + f, err := os.Open(n) + if err != nil { + t.Fatal(err) + } + + ext := getArchiveFileExt(n) + url := "https://github.com/foo/bar/releases/download/v1.2.3/bar" + ext + r, err := UncompressCommand(f, url, "bar") + if err != nil { + t.Fatal(err) + } + + bytes, err := ioutil.ReadAll(r) + if err != nil { + t.Fatal(err) + } + s := string(bytes) + if s != "this is test\n" { + t.Fatal("Uncompressing zip failed into unexpected content", s) + } + }) + } +} + +func TestUncompressInvalidArchive(t *testing.T) { + for _, a := range []struct { + name string + msg string + }{ + {"testdata/invalid.zip", "not a valid zip file"}, + {"testdata/invalid.gz", "Failed to uncompress gzip file"}, + {"testdata/invalid-tar.tar.gz", "Failed to unarchive .tar file"}, + {"testdata/invalid-gzip.tar.gz", "Failed to uncompress .tar.gz file"}, + {"testdata/invalid.xz", "Failed to uncompress xzip file"}, + {"testdata/invalid-tar.tar.xz", "Failed to unarchive .tar file"}, + {"testdata/invalid-xz.tar.xz", "Failed to uncompress .tar.xz file"}, + } { + f, err := os.Open(a.name) + if err != nil { + t.Fatal(err) + } + + ext := getArchiveFileExt(a.name) + url := "https://github.com/foo/bar/releases/download/v1.2.3/bar" + ext + _, err = UncompressCommand(f, url, "bar") + if err == nil { + t.Fatal("Error should be raised") + } + if !strings.Contains(err.Error(), a.msg) { + t.Fatal("Unexpected error:", err) + } + } +} + +func TestTargetNotFound(t *testing.T) { + for _, tc := range []struct { + name string + msg string + }{ + {"testdata/empty.zip", "command is not found"}, + {"testdata/bar-not-found.zip", "command is not found"}, + {"testdata/bar-not-found.gzip", "does not match to command"}, + {"testdata/empty.tar.gz", "command is not found"}, + {"testdata/bar-not-found.tar.gz", "command is not found"}, + } { + t.Run(tc.name, func(t *testing.T) { + f, err := os.Open(tc.name) + if err != nil { + t.Fatal(err) + } + ext := getArchiveFileExt(tc.name) + url := "https://github.com/foo/bar/releases/download/v1.2.3/bar" + ext + _, err = UncompressCommand(f, url, "bar") + if err == nil { + t.Fatal("Error should be raised for") + } + if !strings.Contains(err.Error(), tc.msg) { + t.Fatal("Unexpected error:", err) + } + }) + } +} diff --git a/selfupdate/update.go b/selfupdate/update.go new file mode 100644 index 0000000..1ae0d7d --- /dev/null +++ b/selfupdate/update.go @@ -0,0 +1,181 @@ +package selfupdate + +import ( + "bytes" + "fmt" + "io" + "io/ioutil" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/blang/semver" + "github.com/inconshreveable/go-update" +) + +func uncompressAndUpdate(src io.Reader, assetURL, cmdPath string) error { + _, cmd := filepath.Split(cmdPath) + asset, err := UncompressCommand(src, assetURL, cmd) + if err != nil { + return err + } + + log.Println("Will update", cmdPath, "to the latest downloaded from", assetURL) + return update.Apply(asset, update.Options{ + TargetPath: cmdPath, + }) +} + +func (up *Updater) downloadDirectlyFromURL(assetURL string) (io.ReadCloser, error) { + req, err := http.NewRequest("GET", assetURL, nil) + if err != nil { + return nil, fmt.Errorf("Failed to create HTTP request to %s: %s", assetURL, err) + } + + req.Header.Add("Accept", "application/octet-stream") + req = req.WithContext(up.apiCtx) + + // OAuth HTTP client is not available to download blob from URL when the URL is a redirect URL + // returned from GitHub Releases API (response status 400). + // Use default HTTP client instead. + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("Failed to download a release file from %s: %s", assetURL, err) + } + + if res.StatusCode != 200 { + return nil, fmt.Errorf("Failed to download a release file from %s: Not successful status %d", assetURL, res.StatusCode) + } + + return res.Body, nil +} + +// UpdateTo downloads an executable from GitHub Releases API and replace current binary with the downloaded one. +// It downloads a release asset via GitHub Releases API so this function is available for update releases on private repository. +// If a redirect occurs, it fallbacks into directly downloading from the redirect URL. +func (up *Updater) UpdateTo(rel *Release, cmdPath string) error { + var client http.Client + src, redirectURL, err := up.api.Repositories.DownloadReleaseAsset(up.apiCtx, rel.RepoOwner, rel.RepoName, rel.AssetID, &client) + if err != nil { + return fmt.Errorf("Failed to call GitHub Releases API for getting an asset(ID: %d) for repository '%s/%s': %s", rel.AssetID, rel.RepoOwner, rel.RepoName, err) + } + if redirectURL != "" { + log.Println("Redirect URL was returned while trying to download a release asset from GitHub API. Falling back to downloading from asset URL directly:", redirectURL) + src, err = up.downloadDirectlyFromURL(redirectURL) + if err != nil { + return err + } + } + defer src.Close() + + data, err := ioutil.ReadAll(src) + if err != nil { + return fmt.Errorf("Failed reading asset body: %v", err) + } + + if up.validator == nil { + return uncompressAndUpdate(bytes.NewReader(data), rel.AssetURL, cmdPath) + } + + validationSrc, validationRedirectURL, err := up.api.Repositories.DownloadReleaseAsset(up.apiCtx, rel.RepoOwner, rel.RepoName, rel.ValidationAssetID, &client) + if err != nil { + return fmt.Errorf("Failed to call GitHub Releases API for getting an validation asset(ID: %d) for repository '%s/%s': %s", rel.ValidationAssetID, rel.RepoOwner, rel.RepoName, err) + } + if validationRedirectURL != "" { + log.Println("Redirect URL was returned while trying to download a release validation asset from GitHub API. Falling back to downloading from asset URL directly:", redirectURL) + validationSrc, err = up.downloadDirectlyFromURL(validationRedirectURL) + if err != nil { + return err + } + } + + defer validationSrc.Close() + + validationData, err := ioutil.ReadAll(validationSrc) + if err != nil { + return fmt.Errorf("Failed reading validation asset body: %v", err) + } + + if err := up.validator.Validate(data, validationData); err != nil { + return fmt.Errorf("Failed validating asset content: %v", err) + } + + return uncompressAndUpdate(bytes.NewReader(data), rel.AssetURL, cmdPath) +} + +// UpdateCommand updates a given command binary to the latest version. +// 'slug' represents 'owner/name' repository on GitHub and 'current' means the current version. +func (up *Updater) UpdateCommand(cmdPath string, current semver.Version, slug string) (*Release, error) { + if runtime.GOOS == "windows" && !strings.HasSuffix(cmdPath, ".exe") { + // Ensure to add '.exe' to given path on Windows + cmdPath = cmdPath + ".exe" + } + + stat, err := os.Lstat(cmdPath) + if err != nil { + return nil, fmt.Errorf("Failed to stat '%s'. File may not exist: %s", cmdPath, err) + } + if stat.Mode()&os.ModeSymlink != 0 { + p, err := filepath.EvalSymlinks(cmdPath) + if err != nil { + return nil, fmt.Errorf("Failed to resolve symlink '%s' for executable: %s", cmdPath, err) + } + cmdPath = p + } + + rel, ok, err := up.DetectLatest(slug) + if err != nil { + return nil, err + } + if !ok { + log.Println("No release detected. Current version is considered up-to-date") + return &Release{Version: current}, nil + } + if current.Equals(rel.Version) { + log.Println("Current version", current, "is the latest. Update is not needed") + return rel, nil + } + log.Println("Will update", cmdPath, "to the latest version", rel.Version) + if err := up.UpdateTo(rel, cmdPath); err != nil { + return nil, err + } + return rel, nil +} + +// UpdateSelf updates the running executable itself to the latest version. +// 'slug' represents 'owner/name' repository on GitHub and 'current' means the current version. +func (up *Updater) UpdateSelf(current semver.Version, slug string) (*Release, error) { + cmdPath, err := os.Executable() + if err != nil { + return nil, err + } + return up.UpdateCommand(cmdPath, current, slug) +} + +// UpdateTo downloads an executable from assetURL and replace the current binary with the downloaded one. +// This function is low-level API to update the binary. Because it does not use GitHub API and downloads asset directly from the URL via HTTP, +// this function is not available to update a release for private repositories. +// cmdPath is a file path to command executable. +func UpdateTo(assetURL, cmdPath string) error { + up := DefaultUpdater() + src, err := up.downloadDirectlyFromURL(assetURL) + if err != nil { + return err + } + defer src.Close() + return uncompressAndUpdate(src, assetURL, cmdPath) +} + +// UpdateCommand updates a given command binary to the latest version. +// This function is a shortcut version of updater.UpdateCommand. +func UpdateCommand(cmdPath string, current semver.Version, slug string) (*Release, error) { + return DefaultUpdater().UpdateCommand(cmdPath, current, slug) +} + +// UpdateSelf updates the running executable itself to the latest version. +// This function is a shortcut version of updater.UpdateSelf. +func UpdateSelf(current semver.Version, slug string) (*Release, error) { + return DefaultUpdater().UpdateSelf(current, slug) +} diff --git a/selfupdate/update_test.go b/selfupdate/update_test.go new file mode 100644 index 0000000..7dfcef2 --- /dev/null +++ b/selfupdate/update_test.go @@ -0,0 +1,353 @@ +package selfupdate + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/blang/semver" +) + +func setupTestBinary() { + if err := exec.Command("go", "build", "./testdata/github-release-test/").Run(); err != nil { + panic(err) + } +} + +func teardownTestBinary() { + bin := "github-release-test" + if runtime.GOOS == "windows" { + bin = "github-release-test.exe" + } + if err := os.Remove(bin); err != nil { + panic(err) + } +} + +func TestUpdateCommand(t *testing.T) { + if testing.Short() { + t.Skip("skip tests in short mode.") + } + + for _, slug := range []string{ + "rhysd-test/test-release-zip", + "rhysd-test/test-release-tar", + "rhysd-test/test-release-gzip", + "rhysd-test/test-release-tar-xz", + "rhysd-test/test-release-xz", + "rhysd-test/test-release-contain-version", + } { + t.Run(slug, func(t *testing.T) { + setupTestBinary() + defer teardownTestBinary() + latest := semver.MustParse("1.2.3") + prev := semver.MustParse("1.2.2") + rel, err := UpdateCommand("github-release-test", prev, slug) + if err != nil { + t.Fatal(err) + } + if rel.Version.NE(latest) { + t.Error("Version is not latest", rel.Version) + } + bytes, err := exec.Command(filepath.FromSlash("./github-release-test")).Output() + if err != nil { + t.Fatal("Failed to run test binary after update:", err) + } + out := string(bytes) + if out != "v1.2.3\n" { + t.Error("Output from test binary after update is unexpected:", out) + } + }) + } +} + +func TestUpdateViaSymlink(t *testing.T) { + if testing.Short() { + t.Skip("skip tests in short mode.") + } + if runtime.GOOS == "windows" && os.Getenv("APPVEYOR") == "" { + t.Skip("skipping because creating symlink on windows requires the root privilege") + } + + setupTestBinary() + defer teardownTestBinary() + exePath := "github-release-test" + symPath := "github-release-test-sym" + if runtime.GOOS == "windows" { + exePath = "github-release-test.exe" + symPath = "github-release-test-sym.exe" + } + if err := os.Symlink(exePath, symPath); err != nil { + t.Fatal(err) + } + defer os.Remove(symPath) + + latest := semver.MustParse("1.2.3") + prev := semver.MustParse("1.2.2") + rel, err := UpdateCommand(symPath, prev, "rhysd-test/test-release-zip") + if err != nil { + t.Fatal(err) + } + if rel.Version.NE(latest) { + t.Error("Version is not latest", rel.Version) + } + + // Test not symbolic link, but actual physical executable + bytes, err := exec.Command(filepath.FromSlash("./github-release-test")).Output() + if err != nil { + t.Fatal("Failed to run test binary after update:", err) + } + out := string(bytes) + if out != "v1.2.3\n" { + t.Error("Output from test binary after update is unexpected:", out) + } + + s, err := os.Lstat(symPath) + if err != nil { + t.Fatal(err) + } + if s.Mode()&os.ModeSymlink == 0 { + t.Fatalf("%s is not a symlink.", symPath) + } + p, err := filepath.EvalSymlinks(symPath) + if err != nil { + t.Fatal(err) + } + if p != exePath { + t.Fatal("Created symlink no loger points the executable:", p) + } +} + +func TestUpdateBrokenSymlinks(t *testing.T) { + if runtime.GOOS == "windows" && os.Getenv("APPVEYOR") == "" { + t.Skip("skipping because creating symlink on windows requires the root privilege") + } + + // unknown-xxx -> unknown-yyy -> {not existing} + xxx := "unknown-xxx" + yyy := "unknown-yyy" + if runtime.GOOS == "windows" { + xxx = "unknown-xxx.exe" + yyy = "unknown-yyy.exe" + } + if err := os.Symlink("not-existing", yyy); err != nil { + t.Fatal(err) + } + defer os.Remove(yyy) + if err := os.Symlink(yyy, xxx); err != nil { + t.Fatal(err) + } + defer os.Remove(xxx) + + v := semver.MustParse("1.2.2") + for _, p := range []string{yyy, xxx} { + _, err := UpdateCommand(p, v, "owner/repo") + if err == nil { + t.Fatal("Error should occur for unlinked symlink", p) + } + if !strings.Contains(err.Error(), "Failed to resolve symlink") { + t.Fatal("Unexpected error for broken symlink", p, err) + } + } +} + +func TestNotExistingCommandPath(t *testing.T) { + _, err := UpdateCommand("not-existing-command-path", semver.MustParse("1.2.2"), "owner/repo") + if err == nil { + t.Fatal("Not existing command path should cause an error") + } + if !strings.Contains(err.Error(), "File may not exist") { + t.Fatal("Unexpected error for not existing command path", err) + } +} + +func TestNoReleaseFoundForUpdate(t *testing.T) { + v := semver.MustParse("1.0.0") + fake := filepath.FromSlash("./testdata/fake-executable") + rel, err := UpdateCommand(fake, v, "rhysd/misc") + if err != nil { + t.Fatal("No release should not make an error:", err) + } + if rel.Version.NE(v) { + t.Error("No release should return the current version as the latest:", rel.Version) + } + if rel.URL != "" { + t.Error("Browse URL should be empty when no release found:", rel.URL) + } + if rel.AssetURL != "" { + t.Error("Asset URL should be empty when no release found:", rel.AssetURL) + } + if rel.ReleaseNotes != "" { + t.Error("Release notes should be empty when no release found:", rel.ReleaseNotes) + } +} + +func TestCurrentIsTheLatest(t *testing.T) { + if testing.Short() { + t.Skip("skip tests in short mode.") + } + setupTestBinary() + defer teardownTestBinary() + + v := semver.MustParse("1.2.3") + rel, err := UpdateCommand("github-release-test", v, "rhysd-test/test-release-zip") + if err != nil { + t.Fatal(err) + } + if rel.Version.NE(v) { + t.Error("v1.2.3 should be the latest:", rel.Version) + } + if rel.URL == "" { + t.Error("Browse URL should not be empty when release found:", rel.URL) + } + if rel.AssetURL == "" { + t.Error("Asset URL should not be empty when release found:", rel.AssetURL) + } + if rel.ReleaseNotes == "" { + t.Error("Release notes should not be empty when release found:", rel.ReleaseNotes) + } +} + +func TestBrokenBinaryUpdate(t *testing.T) { + if testing.Short() { + t.Skip("skip tests in short mode.") + } + + fake := filepath.FromSlash("./testdata/fake-executable") + _, err := UpdateCommand(fake, semver.MustParse("1.2.2"), "rhysd-test/test-incorrect-release") + if err == nil { + t.Fatal("Error should occur for broken package") + } + if !strings.Contains(err.Error(), "Failed to uncompress .tar.gz file") { + t.Fatal("Unexpected error:", err) + } +} + +func TestInvalidSlugForUpdate(t *testing.T) { + fake := filepath.FromSlash("./testdata/fake-executable") + _, err := UpdateCommand(fake, semver.MustParse("1.0.0"), "rhysd/") + if err == nil { + t.Fatal("Unknown repo should cause an error") + } + if !strings.Contains(err.Error(), "Invalid slug format") { + t.Fatal("Unexpected error:", err) + } +} + +func TestInvalidAssetURL(t *testing.T) { + err := UpdateTo("https://github.com/rhysd/non-existing-repo/releases/download/v1.2.3/foo.zip", "foo") + if err == nil { + t.Fatal("Error should occur for URL not found") + } + if !strings.Contains(err.Error(), "Failed to download a release file") { + t.Fatal("Unexpected error:", err) + } +} + +func TestBrokenAsset(t *testing.T) { + asset := "https://github.com/rhysd-test/test-incorrect-release/releases/download/invalid/broken-zip.zip" + err := UpdateTo(asset, "foo") + if err == nil { + t.Fatal("Error should occur for URL not found") + } + if !strings.Contains(err.Error(), "Failed to uncompress zip file") { + t.Fatal("Unexpected error:", err) + } +} + +func TestBrokenGitHubEnterpriseURL(t *testing.T) { + up, err := NewUpdater(Config{APIToken: "hogehoge", EnterpriseBaseURL: "https://example.com"}) + if err != nil { + t.Fatal(err) + } + err = up.UpdateTo(&Release{AssetURL: "https://example.com"}, "foo") + if err == nil { + t.Fatal("Invalid GitHub Enterprise base URL should raise an error") + } + if !strings.Contains(err.Error(), "Failed to call GitHub Releases API for getting an asset") { + t.Error("Unexpected error occurred:", err) + } +} + +func TestUpdateFromGitHubEnterprise(t *testing.T) { + token := os.Getenv("GITHUB_ENTERPRISE_TOKEN") + base := os.Getenv("GITHUB_ENTERPRISE_BASE_URL") + repo := os.Getenv("GITHUB_ENTERPRISE_REPO") + if token == "" { + t.Skip("because token for GHE is not found") + } + if base == "" { + t.Skip("because base URL for GHE is not found") + } + if repo == "" { + t.Skip("because repo slug for GHE is not found") + } + + setupTestBinary() + defer teardownTestBinary() + + up, err := NewUpdater(Config{APIToken: token, EnterpriseBaseURL: base}) + if err != nil { + t.Fatal(err) + } + + latest := semver.MustParse("1.2.3") + prev := semver.MustParse("1.2.2") + rel, err := up.UpdateCommand("github-release-test", prev, repo) + if err != nil { + t.Fatal(err) + } + + if rel.Version.NE(latest) { + t.Error("Version is not latest", rel.Version) + } + + bytes, err := exec.Command(filepath.FromSlash("./github-release-test")).Output() + if err != nil { + t.Fatal("Failed to run test binary after update:", err) + } + + out := string(bytes) + if out != "v1.2.3\n" { + t.Error("Output from test binary after update is unexpected:", out) + } +} + +func TestUpdateFromGitHubPrivateRepo(t *testing.T) { + token := os.Getenv("GITHUB_PRIVATE_TOKEN") + if token == "" { + t.Skip("because GITHUB_PRIVATE_TOKEN is not set") + } + + setupTestBinary() + defer teardownTestBinary() + + up, err := NewUpdater(Config{APIToken: token}) + if err != nil { + t.Fatal(err) + } + + latest := semver.MustParse("1.2.3") + prev := semver.MustParse("1.2.2") + rel, err := up.UpdateCommand("github-release-test", prev, "rhysd/private-release-test") + if err != nil { + t.Fatal(err) + } + + if rel.Version.NE(latest) { + t.Error("Version is not latest", rel.Version) + } + + bytes, err := exec.Command(filepath.FromSlash("./github-release-test")).Output() + if err != nil { + t.Fatal("Failed to run test binary after update:", err) + } + + out := string(bytes) + if out != "v1.2.3\n" { + t.Error("Output from test binary after update is unexpected:", out) + } +} diff --git a/selfupdate/updater.go b/selfupdate/updater.go new file mode 100644 index 0000000..32cf5e0 --- /dev/null +++ b/selfupdate/updater.go @@ -0,0 +1,99 @@ +package selfupdate + +import ( + "context" + "fmt" + "net/http" + "os" + "regexp" + + "github.com/google/go-github/v30/github" + gitconfig "github.com/tcnksm/go-gitconfig" + "golang.org/x/oauth2" +) + +// Updater is responsible for managing the context of self-update. +// It contains GitHub client and its context. +type Updater struct { + api *github.Client + apiCtx context.Context + validator Validator + filters []*regexp.Regexp +} + +// Config represents the configuration of self-update. +type Config struct { + // APIToken represents GitHub API token. If it's not empty, it will be used for authentication of GitHub API + APIToken string + // EnterpriseBaseURL is a base URL of GitHub API. If you want to use this library with GitHub Enterprise, + // please set "https://{your-organization-address}/api/v3/" to this field. + EnterpriseBaseURL string + // EnterpriseUploadURL is a URL to upload stuffs to GitHub Enterprise instance. This is often the same as an API base URL. + // So if this field is not set and EnterpriseBaseURL is set, EnterpriseBaseURL is also set to this field. + EnterpriseUploadURL string + // Validator represents types which enable additional validation of downloaded release. + Validator Validator + // Filters are regexp used to filter on specific assets for releases with multiple assets. + // 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 +} + +func newHTTPClient(ctx context.Context, token string) *http.Client { + if token == "" { + return http.DefaultClient + } + src := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) + return oauth2.NewClient(ctx, src) +} + +// NewUpdater creates a new updater instance. It initializes GitHub API client. +// If you set your API token to $GITHUB_TOKEN, the client will use it. +func NewUpdater(config Config) (*Updater, error) { + token := config.APIToken + if token == "" { + token = os.Getenv("GITHUB_TOKEN") + } + if token == "" { + token, _ = gitconfig.GithubToken() + } + ctx := context.Background() + hc := newHTTPClient(ctx, token) + + filtersRe := make([]*regexp.Regexp, 0, len(config.Filters)) + for _, filter := range config.Filters { + re, err := regexp.Compile(filter) + if err != nil { + return nil, fmt.Errorf("Could not compile regular expression %q for filtering releases: %v", filter, err) + } + filtersRe = append(filtersRe, re) + } + + if config.EnterpriseBaseURL == "" { + client := github.NewClient(hc) + return &Updater{api: client, apiCtx: ctx, validator: config.Validator, filters: filtersRe}, nil + } + + u := config.EnterpriseUploadURL + if u == "" { + u = config.EnterpriseBaseURL + } + client, err := github.NewEnterpriseClient(config.EnterpriseBaseURL, u, hc) + if err != nil { + return nil, err + } + return &Updater{api: client, apiCtx: ctx, validator: config.Validator, filters: filtersRe}, nil +} + +// DefaultUpdater creates a new updater instance with default configuration. +// It initializes GitHub API client with default API base URL. +// If you set your API token to $GITHUB_TOKEN, the client will use it. +func DefaultUpdater() *Updater { + token := os.Getenv("GITHUB_TOKEN") + if token == "" { + token, _ = gitconfig.GithubToken() + } + ctx := context.Background() + client := newHTTPClient(ctx, token) + return &Updater{api: github.NewClient(client), apiCtx: ctx} +} diff --git a/selfupdate/updater_test.go b/selfupdate/updater_test.go new file mode 100644 index 0000000..e9f9076 --- /dev/null +++ b/selfupdate/updater_test.go @@ -0,0 +1,106 @@ +package selfupdate + +import ( + "os" + "strings" + "testing" +) + +func TestGitHubTokenEnv(t *testing.T) { + token := os.Getenv("GITHUB_TOKEN") + if token == "" { + t.Skip("because $GITHUB_TOKEN is not set") + } + _ = DefaultUpdater() + if _, err := NewUpdater(Config{}); err != nil { + t.Error("Failed to initialize updater with empty config") + } + if _, err := NewUpdater(Config{APIToken: token}); err != nil { + t.Error("Failed to initialize updater with API token config") + } +} + +func TestGitHubTokenIsNotSet(t *testing.T) { + token := os.Getenv("GITHUB_TOKEN") + if token != "" { + defer os.Setenv("GITHUB_TOKEN", token) + } + os.Setenv("GITHUB_TOKEN", "") + _ = DefaultUpdater() + if _, err := NewUpdater(Config{}); err != nil { + t.Error("Failed to initialize updater with empty config") + } +} + +func TestGitHubEnterpriseClient(t *testing.T) { + url := "https://github.company.com/api/v3/" + up, err := NewUpdater(Config{APIToken: "hogehoge", EnterpriseBaseURL: url}) + if err != nil { + t.Fatal(err) + } + if up.api.BaseURL.String() != url { + t.Error("Base URL was set to", up.api.BaseURL, ", want", url) + } + if up.api.UploadURL.String() != url { + t.Error("Upload URL was set to", up.api.UploadURL, ", want", url) + } + + url2 := "https://upload.github.company.com/api/v3/" + up, err = NewUpdater(Config{ + APIToken: "hogehoge", + EnterpriseBaseURL: url, + EnterpriseUploadURL: url2, + }) + if err != nil { + t.Fatal(err) + } + if up.api.BaseURL.String() != url { + t.Error("Base URL was set to", up.api.BaseURL, ", want", url) + } + if up.api.UploadURL.String() != url2 { + t.Error("Upload URL was set to", up.api.UploadURL, ", want", url2) + } +} + +func TestGitHubEnterpriseClientInvalidURL(t *testing.T) { + _, err := NewUpdater(Config{APIToken: "hogehoge", EnterpriseBaseURL: ":this is not a URL"}) + if err == nil { + t.Fatal("Invalid URL should raise an error") + } +} + +func TestCompileRegexForFiltering(t *testing.T) { + filters := []string{ + "^hello$", + "^(\\d\\.)+\\d$", + } + up, err := NewUpdater(Config{ + Filters: filters, + }) + if err != nil { + t.Fatal(err) + } + if len(up.filters) != 2 { + t.Fatalf("Wanted 2 regexes but got %d", len(up.filters)) + } + for i, r := range up.filters { + want := filters[i] + got := r.String() + if want != got { + t.Errorf("Compiled regex is %q but specified was %q", got, want) + } + } +} + +func TestFilterRegexIsBroken(t *testing.T) { + _, err := NewUpdater(Config{ + Filters: []string{"(foo"}, + }) + if err == nil { + t.Fatal("Error unexpectedly did not occur") + } + msg := err.Error() + if !strings.Contains(msg, "Could not compile regular expression \"(foo\" for filtering releases") { + t.Fatalf("Error message is unexpected: %q", msg) + } +} diff --git a/selfupdate/validate.go b/selfupdate/validate.go new file mode 100644 index 0000000..10066aa --- /dev/null +++ b/selfupdate/validate.go @@ -0,0 +1,73 @@ +package selfupdate + +import ( + "crypto/ecdsa" + "crypto/sha256" + "encoding/asn1" + "fmt" + "math/big" +) + +// Validator represents an interface which enables additional validation of releases. +type Validator interface { + // Validate validates release bytes against an additional asset bytes. + // See SHA2Validator or ECDSAValidator for more information. + Validate(release, asset []byte) error + // Suffix describes the additional file ending which is used for finding the + // additional asset. + Suffix() string +} + +// SHA2Validator specifies a SHA256 validator for additional file validation +// before updating. +type SHA2Validator struct { +} + +// Validate validates the SHA256 sum of the release against the contents of an +// additional asset file. +func (v *SHA2Validator) Validate(release, asset []byte) error { + calculatedHash := fmt.Sprintf("%x", sha256.Sum256(release)) + hash := fmt.Sprintf("%s", asset[:sha256.BlockSize]) + if calculatedHash != hash { + return fmt.Errorf("sha2: validation failed: hash mismatch: expected=%q, got=%q", calculatedHash, hash) + } + return nil +} + +// Suffix returns the suffix for SHA2 validation. +func (v *SHA2Validator) Suffix() string { + return ".sha256" +} + +// ECDSAValidator specifies a ECDSA validator for additional file validation +// before updating. +type ECDSAValidator struct { + PublicKey *ecdsa.PublicKey +} + +// Validate validates the ECDSA signature the release against the signature +// contained in an additional asset file. +// additional asset file. +func (v *ECDSAValidator) Validate(input, signature []byte) error { + h := sha256.New() + h.Write(input) + + var rs struct { + R *big.Int + S *big.Int + } + if _, err := asn1.Unmarshal(signature, &rs); err != nil { + return fmt.Errorf("failed to unmarshal ecdsa signature: %v", err) + } + + if !ecdsa.Verify(v.PublicKey, h.Sum([]byte{}), rs.R, rs.S) { + return fmt.Errorf("ecdsa: signature verification failed") + } + + return nil +} + +// Suffix returns the suffix for ECDSA validation. +func (v *ECDSAValidator) Suffix() string { + return ".sig" +} diff --git a/selfupdate/validate_test.go b/selfupdate/validate_test.go new file mode 100644 index 0000000..3c6ed6e --- /dev/null +++ b/selfupdate/validate_test.go @@ -0,0 +1,136 @@ +package selfupdate + +import ( + "crypto/ecdsa" + "crypto/x509" + "encoding/pem" + "io/ioutil" + "testing" +) + +func TestSHA2Validator(t *testing.T) { + validator := &SHA2Validator{} + data, err := ioutil.ReadFile("testdata/foo.zip") + if err != nil { + t.Fatal(err) + } + hashData, err := ioutil.ReadFile("testdata/foo.zip.sha256") + if err != nil { + t.Fatal(err) + } + if err := validator.Validate(data, hashData); err != nil { + t.Fatal(err) + } +} + +func TestSHA2ValidatorFail(t *testing.T) { + validator := &SHA2Validator{} + data, err := ioutil.ReadFile("testdata/foo.zip") + if err != nil { + t.Fatal(err) + } + hashData, err := ioutil.ReadFile("testdata/foo.zip.sha256") + if err != nil { + t.Fatal(err) + } + hashData[0] = '0' + if err := validator.Validate(data, hashData); err == nil { + t.Fatal(err) + } +} + +func TestECDSAValidator(t *testing.T) { + pemData, err := ioutil.ReadFile("testdata/Test.crt") + if err != nil { + t.Fatal(err) + } + + block, _ := pem.Decode(pemData) + if block == nil || block.Type != "CERTIFICATE" { + t.Fatalf("failed to decode PEM block") + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("failed to parse certificate") + } + + pubKey, ok := cert.PublicKey.(*ecdsa.PublicKey) + if !ok { + t.Errorf("PublicKey is not ECDSA") + } + + validator := &ECDSAValidator{ + PublicKey: pubKey, + } + data, err := ioutil.ReadFile("testdata/foo.zip") + if err != nil { + t.Fatal(err) + } + signatureData, err := ioutil.ReadFile("testdata/foo.zip.sig") + if err != nil { + t.Fatal(err) + } + if err := validator.Validate(data, signatureData); err != nil { + t.Fatal(err) + } +} + +func TestECDSAValidatorFail(t *testing.T) { + pemData, err := ioutil.ReadFile("testdata/Test.crt") + if err != nil { + t.Fatal(err) + } + + block, _ := pem.Decode(pemData) + if block == nil || block.Type != "CERTIFICATE" { + t.Fatalf("failed to decode PEM block") + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("failed to parse certificate") + } + + pubKey, ok := cert.PublicKey.(*ecdsa.PublicKey) + if !ok { + t.Errorf("PublicKey is not ECDSA") + } + + validator := &ECDSAValidator{ + PublicKey: pubKey, + } + data, err := ioutil.ReadFile("testdata/foo.tar.xz") + if err != nil { + t.Fatal(err) + } + signatureData, err := ioutil.ReadFile("testdata/foo.zip.sig") + if err != nil { + t.Fatal(err) + } + if err := validator.Validate(data, signatureData); err == nil { + t.Fatal(err) + } +} + +func TestValidatorSuffix(t *testing.T) { + for _, test := range []struct { + v Validator + suffix string + }{ + { + v: &SHA2Validator{}, + suffix: ".sha256", + }, + { + v: &ECDSAValidator{}, + suffix: ".sig", + }, + } { + want := test.suffix + got := test.v.Suffix() + if want != got { + t.Errorf("Wanted %q but got %q", want, got) + } + } +}