├── .github └── workflows │ └── build.yml ├── .gitignore ├── .golangci.yml ├── LICENSE ├── Makefile ├── README.md ├── arch.go ├── arch_test.go ├── arm.go ├── arm_test.go ├── bogusreader_test.go ├── cmd ├── detect-latest-release │ ├── README.md │ ├── main.go │ └── update.go ├── get-release │ ├── README.md │ └── main.go ├── macho │ ├── .gitignore │ ├── Makefile │ └── main.go ├── serve-repo │ ├── logger.go │ └── main.go ├── source.go └── source_test.go ├── codecov.yml ├── config.go ├── decompress.go ├── decompress_test.go ├── detect.go ├── detect_test.go ├── doc.go ├── errors.go ├── gitea_release.go ├── gitea_source.go ├── gitea_source_test.go ├── github_release.go ├── github_source.go ├── github_source_test.go ├── gitlab_release.go ├── gitlab_source.go ├── gitlab_source_test.go ├── go.mod ├── go.sum ├── http_release.go ├── http_source.go ├── http_source_test.go ├── internal ├── path.go ├── path_test.go ├── resolve_path_unix.go └── resolve_path_windows.go ├── log.go ├── log_test.go ├── mockdata_test.go ├── package.go ├── path.go ├── path_test.go ├── reader_test.go ├── release.go ├── release_test.go ├── repository.go ├── repository_id.go ├── repository_id_test.go ├── repository_slug.go ├── repository_slug_test.go ├── sonar-project.properties ├── source.go ├── source_test.go ├── testdata ├── SHA256SUM ├── Test.crt ├── Test.pem ├── bar-not-found.gzip ├── bar-not-found.tar.gz ├── bar-not-found.tar.xz ├── bar-not-found.zip ├── empty.tar.gz ├── empty.zip ├── fake-executable ├── fake-executable.exe ├── foo.tar.gz ├── foo.tar.xz ├── foo.tgz ├── foo.zip ├── foo.zip.sha256 ├── foo.zip.sig ├── hello │ └── main.go ├── http_repo │ ├── manifest.yaml │ └── v0.1.2 │ │ └── example_linux_amd64.tar.gz ├── invalid-gzip.tar.gz ├── invalid-tar.tar.gz ├── invalid-tar.tar.xz ├── invalid-xz.tar.xz ├── invalid.bz2 ├── invalid.gz ├── invalid.xz ├── invalid.zip ├── new_version.tar.gz ├── new_version.zip ├── single-file.bz2 ├── single-file.gz ├── single-file.gzip ├── single-file.xz └── single-file.zip ├── token.go ├── token_test.go ├── universal_binary.go ├── update.go ├── update ├── LICENSE ├── apply.go ├── apply_test.go ├── doc.go ├── hide_noop.go ├── hide_test.go ├── hide_windows.go ├── options.go └── verifier.go ├── update_test.go ├── updater.go ├── updater_test.go ├── validate.go └── validate_test.go /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | 11 | build: 12 | name: Build and test 13 | runs-on: ${{ matrix.os }} 14 | strategy: 15 | matrix: 16 | go_version: ['1.24'] 17 | os: [ubuntu-latest, windows-latest, macos-latest] 18 | 19 | steps: 20 | 21 | - name: Check out code into the Go module directory 22 | uses: actions/checkout@v4 23 | 24 | - name: Set up Go ${{ matrix.go_version }} 25 | uses: actions/setup-go@v5 26 | with: 27 | go-version: ${{ matrix.go_version }} 28 | check-latest: true 29 | cache: true 30 | 31 | - name: Get dependencies 32 | run: | 33 | go mod download 34 | 35 | - name: Build 36 | run: go build -v ./... 37 | 38 | - name: Test 39 | shell: bash 40 | run: | 41 | if [[ "${GITHUB_TOKEN}" != "" ]]; then 42 | go test -v -race -coverprofile=coverage.txt . ./update 43 | else 44 | go test -v -race -short -coverprofile=coverage.txt . ./update 45 | fi 46 | 47 | - name: Code coverage with codecov 48 | uses: codecov/codecov-action@v4 49 | with: 50 | env_vars: OS,GO 51 | file: ./coverage.txt 52 | flags: unittests 53 | fail_ci_if_error: false 54 | verbose: true 55 | token: ${{ secrets.CODECOV_TOKEN }} 56 | 57 | - name: Archive code coverage results 58 | uses: actions/upload-artifact@v4 59 | with: 60 | name: code-coverage-report-${{ matrix.os }} 61 | path: coverage.txt 62 | 63 | sonarCloudTrigger: 64 | needs: build 65 | name: SonarCloud Trigger 66 | if: github.event_name != 'pull_request' 67 | runs-on: ubuntu-latest 68 | steps: 69 | - name: Clone Repository 70 | uses: actions/checkout@v4 71 | with: 72 | # Disabling shallow clone is recommended for improving relevancy of reporting 73 | fetch-depth: 0 74 | 75 | - name: Download code coverage results 76 | uses: actions/download-artifact@v4 77 | 78 | - name: Display structure of downloaded files 79 | run: ls -R 80 | 81 | - name: Analyze with SonarCloud 82 | uses: sonarsource/sonarqube-scan-action@v4 83 | env: 84 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 85 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 86 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /selfupdate-example 2 | /release 3 | /env.sh 4 | /detect-latest-release 5 | /go-get-release 6 | /coverage.out 7 | /coverage.txt 8 | /cmd/restic 9 | /.vscode/ 10 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | enable: 3 | - asasalint 4 | - asciicheck 5 | - bidichk 6 | - bodyclose 7 | - contextcheck 8 | - errname 9 | - gocheckcompilerdirectives 10 | - gosec 11 | - maintidx 12 | - misspell 13 | - nilnil 14 | - noctx 15 | - nolintlint 16 | - predeclared 17 | - reassign 18 | - sloglint 19 | - spancheck 20 | - unconvert 21 | - unparam 22 | - usestdlibvars 23 | 24 | linters-settings: 25 | gosec: 26 | excludes: 27 | - G101 # Potential hardcoded credentials 28 | staticcheck: 29 | checks: ["all", "-SA1019"] # "golang.org/x/crypto/openpgp" is deprecated 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | the MIT License 2 | 3 | Copyright (c) 2017 rhysd 4 | Copyright (c) 2020 CreativeProjects 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 10 | of the Software, and to permit persons to whom the Software is furnished to do so, 11 | subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 17 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR 18 | PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 19 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 20 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR 21 | THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # 2 | # Makefile for go-selfupdate 3 | # 4 | GOCMD=go 5 | GOBUILD=$(GOCMD) build 6 | GOINSTALL=$(GOCMD) install 7 | GORUN=$(GOCMD) run 8 | GOCLEAN=$(GOCMD) clean 9 | GOTEST=$(GOCMD) test 10 | GOTOOL=$(GOCMD) tool 11 | GOGET=$(GOCMD) get 12 | GOPATH?=`$(GOCMD) env GOPATH` 13 | 14 | TESTS=. ./update 15 | COVERAGE_FILE=coverage.txt 16 | 17 | BUILD_DATE=`date` 18 | BUILD_COMMIT=`git rev-parse HEAD` 19 | 20 | README=README.md 21 | TOC_START=<\!--ts--> 22 | TOC_END=<\!--te--> 23 | TOC_PATH=toc.md 24 | 25 | .PHONY: all test build coverage full-coverage clean toc 26 | 27 | all: test build 28 | 29 | build: 30 | $(GOBUILD) -v ./... 31 | 32 | test: 33 | $(GOTEST) -race -v $(TESTS) 34 | 35 | coverage: 36 | $(GOTEST) -short -coverprofile=$(COVERAGE_FILE) $(TESTS) 37 | $(GOTOOL) cover -html=$(COVERAGE_FILE) 38 | 39 | full-coverage: 40 | $(GOTEST) -coverprofile=$(COVERAGE_FILE) $(TESTS) 41 | $(GOTOOL) cover -html=$(COVERAGE_FILE) 42 | 43 | clean: 44 | rm detect-latest-release go-get-release coverage.txt 45 | $(GOCLEAN) 46 | 47 | toc: 48 | @echo "[*] $@" 49 | $(GOINSTALL) github.com/ekalinin/github-markdown-toc.go/cmd/gh-md-toc@latest 50 | cat ${README} | gh-md-toc --hide-footer > ${TOC_PATH} 51 | sed -i ".1" "/${TOC_START}/,/${TOC_END}/{//!d;}" "${README}" 52 | sed -i ".2" "/${TOC_START}/r ${TOC_PATH}" "${README}" 53 | rm ${README}.1 ${README}.2 ${TOC_PATH} 54 | 55 | .PHONY: lint 56 | lint: 57 | @echo "[*] $@" 58 | GOOS=darwin golangci-lint run 59 | GOOS=linux golangci-lint run 60 | GOOS=windows golangci-lint run 61 | 62 | .PHONY: fix 63 | fix: 64 | @echo "[*] $@" 65 | $(GOCMD) mod tidy 66 | $(GOCMD) fix ./... 67 | GOOS=darwin golangci-lint run --fix 68 | GOOS=linux golangci-lint run --fix 69 | GOOS=windows golangci-lint run --fix 70 | -------------------------------------------------------------------------------- /arch.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | const ( 8 | minARM = 5 9 | maxARM = 7 10 | ) 11 | 12 | // getAdditionalArch we can use depending on the type of CPU 13 | func getAdditionalArch(arch string, goarm uint8, universalArch string) []string { 14 | const defaultArchCapacity = 3 15 | additionalArch := make([]string, 0, defaultArchCapacity) 16 | 17 | if arch == "arm" && goarm >= minARM && goarm <= maxARM { 18 | // more precise arch at the top of the list 19 | for v := goarm; v >= minARM; v-- { 20 | additionalArch = append(additionalArch, fmt.Sprintf("armv%d", v)) 21 | } 22 | additionalArch = append(additionalArch, "arm") 23 | return additionalArch 24 | } 25 | 26 | additionalArch = append(additionalArch, arch) 27 | if arch == "amd64" { 28 | additionalArch = append(additionalArch, "x86_64") 29 | } 30 | if universalArch != "" { 31 | additionalArch = append(additionalArch, universalArch) 32 | } 33 | return additionalArch 34 | } 35 | -------------------------------------------------------------------------------- /arch_test.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestAdditionalArch(t *testing.T) { 11 | testData := []struct { 12 | arch string 13 | goarm uint8 14 | universalArch string 15 | expected []string 16 | }{ 17 | {"arm64", 0, "", []string{"arm64"}}, 18 | {"arm64", 0, "all", []string{"arm64", "all"}}, 19 | {"arm", 8, "", []string{"arm"}}, // armv8 is called arm64 - this shouldn't happen 20 | {"arm", 7, "", []string{"armv7", "armv6", "armv5", "arm"}}, 21 | {"arm", 6, "", []string{"armv6", "armv5", "arm"}}, 22 | {"arm", 5, "", []string{"armv5", "arm"}}, 23 | {"arm", 4, "", []string{"arm"}}, // go is not supporting below armv5 24 | {"amd64", 0, "", []string{"amd64", "x86_64"}}, 25 | {"amd64", 0, "all", []string{"amd64", "x86_64", "all"}}, 26 | } 27 | 28 | for _, testItem := range testData { 29 | t.Run(fmt.Sprintf("%s-%d", testItem.arch, testItem.goarm), func(t *testing.T) { 30 | result := getAdditionalArch(testItem.arch, testItem.goarm, testItem.universalArch) 31 | assert.Equal(t, testItem.expected, result) 32 | }) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /arm.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "debug/buildinfo" 5 | ) 6 | 7 | func getGOARM(goBinary string) uint8 { 8 | build, err := buildinfo.ReadFile(goBinary) 9 | if err != nil { 10 | return 0 11 | } 12 | for _, setting := range build.Settings { 13 | if setting.Key == "GOARM" { 14 | // the value is coming from the linker, so it should be safe to convert 15 | return setting.Value[0] - '0' 16 | } 17 | } 18 | return 0 19 | } 20 | -------------------------------------------------------------------------------- /arm_test.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "runtime" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestGetGOARM(t *testing.T) { 15 | if runtime.GOOS == "windows" { 16 | t.Skip("skipping test on windows") 17 | } 18 | t.Parallel() 19 | 20 | testCases := []struct { 21 | goOS string 22 | goArch string 23 | goArm string 24 | expectedGoArm uint8 25 | }{ 26 | {"linux", "arm", "7", 7}, 27 | {"linux", "arm", "6", 6}, 28 | {"linux", "arm", "5", 5}, 29 | {"linux", "arm", "", 7}, // armv7 is the default 30 | {"linux", "arm64", "", 0}, 31 | {"linux", "amd64", "", 0}, 32 | {"darwin", "arm64", "", 0}, 33 | } 34 | 35 | for _, tc := range testCases { 36 | t.Run(tc.goOS+" "+tc.goArch+" "+tc.goArm, func(t *testing.T) { 37 | tempBinary := t.TempDir() + "/tempBinary-" + tc.goOS + tc.goArch + "v" + tc.goArm 38 | buildCmd := fmt.Sprintf("GOOS=%s GOARCH=%s GOARM=%s go build -o %s ./testdata/hello", tc.goOS, tc.goArch, tc.goArm, tempBinary) 39 | cmd := exec.Command("sh", "-c", buildCmd) 40 | cmd.Stdout = os.Stdout 41 | cmd.Stderr = os.Stderr 42 | err := cmd.Run() 43 | require.NoError(t, err) 44 | 45 | goArm := getGOARM(tempBinary) 46 | assert.Equal(t, tc.expectedGoArm, goArm) 47 | }) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /bogusreader_test.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | ) 7 | 8 | type bogusReader struct{} 9 | 10 | func (r *bogusReader) Read(p []byte) (n int, err error) { 11 | return 0, errors.New("cannot read") 12 | } 13 | 14 | // Verify interface 15 | var _ io.Reader = &bogusReader{} 16 | -------------------------------------------------------------------------------- /cmd/detect-latest-release/README.md: -------------------------------------------------------------------------------- 1 | This command line tool is a small wrapper of [`selfupdate.DetectLatest()`](https://pkg.go.dev/github.com/creativeprojects/go-selfupdate/selfupdate#DetectLatest). 2 | 3 | Please install using `go get`. 4 | 5 | ``` 6 | $ go get -u github.com/creativeprojects/go-selfupdate/cmd/detect-latest-release 7 | ``` 8 | 9 | To know the usage, please try the command without any argument. 10 | 11 | ``` 12 | $ detect-latest-release 13 | ``` 14 | 15 | For example, following shows the latest version of [resticprofile](https://github.com/creativeprojects/resticprofile). 16 | 17 | ``` 18 | $ detect-latest-release creativeprojects/resticprofile 19 | ``` 20 | 21 | -------------------------------------------------------------------------------- /cmd/detect-latest-release/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "os" 9 | 10 | "github.com/creativeprojects/go-selfupdate" 11 | "github.com/creativeprojects/go-selfupdate/cmd" 12 | ) 13 | 14 | const ( 15 | usageBloc = ` 16 | Usage: detect-latest-release [flags] {repository} 17 | 18 | {repository} can be: 19 | - URL to a repository 20 | - "owner/repository_name" couple separated by a "/" 21 | - numeric ID for Gitlab only 22 | 23 | ` 24 | ) 25 | 26 | func usage() { 27 | fmt.Fprint(os.Stderr, usageBloc, "Flags:\n") 28 | flag.PrintDefaults() 29 | } 30 | 31 | func main() { 32 | var help, verbose bool 33 | var cvsType, forceOS, forceArch, baseURL string 34 | flag.BoolVar(&help, "h", false, "Show help") 35 | flag.BoolVar(&verbose, "v", false, "Display debugging information") 36 | flag.StringVar(&cvsType, "t", "auto", "Version control: \"github\", \"gitea\", \"gitlab\" or \"http\"") 37 | flag.StringVar(&forceOS, "o", "", "OS name to use (windows, darwin, linux, etc)") 38 | flag.StringVar(&forceArch, "a", "", "CPU architecture to use (amd64, arm64, etc)") 39 | flag.StringVar(&baseURL, "u", "", "Base URL for VCS on http or dedicated instances") 40 | 41 | flag.Usage = usage 42 | flag.Parse() 43 | 44 | if help || flag.NArg() != 1 { 45 | usage() 46 | return 47 | } 48 | 49 | if verbose { 50 | selfupdate.SetLogger(log.New(os.Stdout, "", 0)) 51 | } 52 | 53 | repo := flag.Arg(0) 54 | 55 | domain, slug, err := cmd.SplitDomainSlug(repo) 56 | if err != nil { 57 | fmt.Fprintln(os.Stderr, err) 58 | os.Exit(1) 59 | } 60 | 61 | if domain == "" && baseURL != "" { 62 | domain = baseURL 63 | } 64 | 65 | if verbose { 66 | fmt.Printf("slug %q on domain %q\n", slug, domain) 67 | } 68 | 69 | source, err := cmd.GetSource(cvsType, domain) 70 | if err != nil { 71 | fmt.Fprintln(os.Stderr, err) 72 | os.Exit(1) 73 | } 74 | 75 | cfg := selfupdate.Config{ 76 | Source: source, 77 | } 78 | if forceOS != "" { 79 | cfg.OS = forceOS 80 | } 81 | if forceArch != "" { 82 | cfg.Arch = forceArch 83 | } 84 | updater, err := selfupdate.NewUpdater(cfg) 85 | if err != nil { 86 | fmt.Fprintln(os.Stderr, err) 87 | os.Exit(1) 88 | } 89 | 90 | latest, found, err := updater.DetectLatest(context.Background(), selfupdate.ParseSlug(slug)) 91 | if err != nil { 92 | fmt.Fprintln(os.Stderr, err) 93 | os.Exit(1) 94 | } 95 | if !found { 96 | fmt.Println("No release found") 97 | return 98 | } 99 | fmt.Printf("Latest version: %s\n", latest.Version()) 100 | fmt.Printf("Download URL: %q\n", latest.AssetURL) 101 | fmt.Printf("Release URL: %q\n", latest.URL) 102 | fmt.Printf("Release Notes:\n%s\n", latest.ReleaseNotes) 103 | } 104 | -------------------------------------------------------------------------------- /cmd/detect-latest-release/update.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "runtime" 8 | 9 | "github.com/creativeprojects/go-selfupdate" 10 | ) 11 | 12 | // keep this function here, this is the example from the README 13 | // 14 | //nolint:unused 15 | func update(version string) error { 16 | latest, found, err := selfupdate.DetectLatest(context.Background(), selfupdate.ParseSlug("creativeprojects/resticprofile")) 17 | if err != nil { 18 | return fmt.Errorf("error occurred while detecting version: %w", err) 19 | } 20 | if !found { 21 | return fmt.Errorf("latest version for %s/%s could not be found from github repository", runtime.GOOS, runtime.GOARCH) 22 | } 23 | 24 | if latest.LessOrEqual(version) { 25 | log.Printf("Current version (%s) is the latest", version) 26 | return nil 27 | } 28 | 29 | exe, err := selfupdate.ExecutablePath() 30 | if err != nil { 31 | return fmt.Errorf("could not locate executable path: %w", err) 32 | } 33 | if err := selfupdate.UpdateTo(context.Background(), latest.AssetURL, latest.AssetName, exe); err != nil { 34 | return fmt.Errorf("error occurred while updating binary: %w", err) 35 | } 36 | log.Printf("Successfully updated to version %s", latest.Version()) 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /cmd/get-release/README.md: -------------------------------------------------------------------------------- 1 | Download and install the latest release of any binary from GitHub. 2 | 3 | ## Installation 4 | 5 | ``` 6 | $ go get -u github.com/creativeprojects/go-selfupdate/cmd/get-release 7 | ``` 8 | 9 | ## Usage 10 | 11 | Usage is quite similar to `go get`. 12 | 13 | ``` 14 | $ get-release {package} 15 | ``` 16 | 17 | Please note that this command assumes that specified package is following Git tag naming rules and 18 | released binaries naming rules described in [README](../../README.md). 19 | 20 | For example, following command downloads and installs the released binary of [ghr](https://github.com/tcnksm/ghr) 21 | to `$GOPATH/bin`. 22 | 23 | ``` 24 | $ get-release github.com/tcnksm/ghr 25 | Command was updated to the latest version 0.5.4: /Users/you/.go/bin/ghr 26 | 27 | $ ghr -version 28 | ghr version v0.5.4 (a12ff1c) 29 | ``` 30 | 31 | -------------------------------------------------------------------------------- /cmd/get-release/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "go/build" 8 | "io" 9 | "log" 10 | "net/http" 11 | "os" 12 | "path/filepath" 13 | "runtime" 14 | "strings" 15 | 16 | "github.com/creativeprojects/go-selfupdate" 17 | "github.com/creativeprojects/go-selfupdate/cmd" 18 | ) 19 | 20 | func main() { 21 | var help, verbose bool 22 | var cvsType, baseURL string 23 | flag.BoolVar(&help, "h", false, "Show help") 24 | flag.BoolVar(&verbose, "v", false, "Display debugging information") 25 | flag.StringVar(&cvsType, "t", "auto", "Version control: \"github\", \"gitea\", \"gitlab\" or \"http\"") 26 | flag.StringVar(&baseURL, "u", "", "Base URL for VCS on http or dedicated instances") 27 | 28 | flag.Usage = usage 29 | flag.Parse() 30 | 31 | if help || flag.NArg() != 1 { 32 | usage() 33 | return 34 | } 35 | 36 | if verbose { 37 | selfupdate.SetLogger(log.New(os.Stdout, "", 0)) 38 | } 39 | 40 | repo := flag.Arg(0) 41 | 42 | domain, slug, err := cmd.SplitDomainSlug(repo) 43 | if err != nil { 44 | fmt.Fprintln(os.Stderr, err) 45 | os.Exit(1) 46 | } 47 | 48 | if domain == "" && baseURL != "" { 49 | domain = baseURL 50 | } 51 | 52 | if verbose { 53 | fmt.Printf("slug %q on domain %q\n", slug, domain) 54 | } 55 | 56 | source, err := cmd.GetSource(cvsType, domain) 57 | if err != nil { 58 | fmt.Fprintln(os.Stderr, err) 59 | os.Exit(1) 60 | } 61 | 62 | ctx := context.Background() 63 | updater, err := selfupdate.NewUpdater(selfupdate.Config{ 64 | Source: source, 65 | }) 66 | if err != nil { 67 | fmt.Fprintln(os.Stderr, err) 68 | os.Exit(1) 69 | } 70 | latest, found, err := updater.DetectLatest(ctx, selfupdate.ParseSlug(slug)) 71 | if err != nil { 72 | fmt.Fprintln(os.Stderr, "Error while detecting the latest version:", err) 73 | os.Exit(1) 74 | } 75 | if !found { 76 | fmt.Fprintln(os.Stderr, "No release found in", slug) 77 | os.Exit(1) 78 | } 79 | 80 | cmd := getCommand(flag.Arg(0)) 81 | cmdPath := filepath.Join(build.Default.GOPATH, "bin", cmd) 82 | if _, err := os.Stat(cmdPath); err != nil { 83 | // When executable is not existing yet 84 | if err := installFrom(ctx, latest.AssetURL, cmd, cmdPath); err != nil { 85 | fmt.Fprintf(os.Stderr, "Error while installing the release binary from %s: %s\n", latest.AssetURL, err) 86 | os.Exit(1) 87 | } 88 | } else { 89 | if err := updater.UpdateTo(ctx, latest, cmdPath); err != nil { 90 | fmt.Fprintf(os.Stderr, "Error while replacing the binary with %s: %s\n", latest.AssetURL, err) 91 | os.Exit(1) 92 | } 93 | } 94 | 95 | fmt.Printf(`Command was updated to the latest version %s: %s 96 | 97 | Release Notes: 98 | %s 99 | `, latest.Version(), cmdPath, latest.ReleaseNotes) 100 | } 101 | 102 | func usage() { 103 | fmt.Fprintln(os.Stderr, ` 104 | Usage: get-release [flags] {package} 105 | 106 | get-release is like "go get github.com/owner/repo@latest". 107 | {package} is using the same format: "github.com/owner/repo". 108 | 109 | Flags:`) 110 | flag.PrintDefaults() 111 | } 112 | 113 | func getCommand(pkg string) string { 114 | pkg = strings.TrimSuffix(pkg, "/") 115 | _, cmd := filepath.Split(pkg) 116 | return cmd 117 | } 118 | 119 | func installFrom(ctx context.Context, url, cmd, path string) error { 120 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) 121 | if err != nil { 122 | return fmt.Errorf("failed to create request to download release binary from %s: %s", url, err) 123 | } 124 | res, err := http.DefaultClient.Do(req) 125 | if err != nil { 126 | return fmt.Errorf("failed to download release binary from %s: %s", url, err) 127 | } 128 | defer res.Body.Close() 129 | if res.StatusCode != http.StatusOK { 130 | return fmt.Errorf("failed to download release binary from %s: Invalid response ", url) 131 | } 132 | executable, err := selfupdate.DecompressCommand(res.Body, url, cmd, runtime.GOOS, runtime.GOARCH) 133 | if err != nil { 134 | return fmt.Errorf("failed to decompress downloaded asset from %s: %s", url, err) 135 | } 136 | bin, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE, 0755) 137 | if err != nil { 138 | return err 139 | } 140 | if _, err := io.Copy(bin, executable); err != nil { 141 | return fmt.Errorf("failed to write binary to %s: %s", path, err) 142 | } 143 | return nil 144 | } 145 | -------------------------------------------------------------------------------- /cmd/macho/.gitignore: -------------------------------------------------------------------------------- 1 | /macho_* -------------------------------------------------------------------------------- /cmd/macho/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build 2 | build: 3 | GOOS=darwin GOARCH=amd64 go build -o macho_amd64 . 4 | GOOS=darwin GOARCH=arm64 go build -o macho_arm64 . 5 | lipo -create -output macho_universal macho_amd64 macho_arm64 6 | -------------------------------------------------------------------------------- /cmd/macho/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "debug/macho" 5 | "fmt" 6 | "os" 7 | ) 8 | 9 | func main() { 10 | fatFile, err := macho.OpenFat(os.Args[0]) 11 | if err != nil { 12 | fmt.Printf("not a universal binary: %s\n", err) 13 | } else { 14 | fmt.Printf("this is a universal binary\n") 15 | fatFile.Close() 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /cmd/serve-repo/logger.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log/slog" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | type ( 10 | // struct for holding response details 11 | responseData struct { 12 | status int 13 | size int 14 | } 15 | 16 | // our http.ResponseWriter implementation 17 | loggingResponseWriter struct { 18 | http.ResponseWriter // compose original http.ResponseWriter 19 | responseData *responseData 20 | } 21 | ) 22 | 23 | func (r *loggingResponseWriter) Write(b []byte) (int, error) { 24 | size, err := r.ResponseWriter.Write(b) // write response using original http.ResponseWriter 25 | r.responseData.size += size // capture size 26 | return size, err 27 | } 28 | 29 | func (r *loggingResponseWriter) WriteHeader(statusCode int) { 30 | r.ResponseWriter.WriteHeader(statusCode) // write status code using original http.ResponseWriter 31 | r.responseData.status = statusCode // capture status code 32 | } 33 | 34 | func WithLogging(h http.Handler) http.Handler { 35 | loggingFn := func(rw http.ResponseWriter, req *http.Request) { 36 | start := time.Now() 37 | 38 | responseData := &responseData{ 39 | status: 0, 40 | size: 0, 41 | } 42 | lrw := loggingResponseWriter{ 43 | ResponseWriter: rw, // compose original http.ResponseWriter 44 | responseData: responseData, 45 | } 46 | h.ServeHTTP(&lrw, req) // inject our implementation of http.ResponseWriter 47 | 48 | duration := time.Since(start) 49 | 50 | slog.Info("request completed", 51 | "uri", req.RequestURI, 52 | "method", req.Method, 53 | "status", responseData.status, // get captured status code 54 | "duration", duration, 55 | "size", responseData.size, // get captured size 56 | ) 57 | } 58 | return http.HandlerFunc(loggingFn) 59 | } 60 | -------------------------------------------------------------------------------- /cmd/serve-repo/main.go: -------------------------------------------------------------------------------- 1 | // Simple implementation of a HTTP server to be used by the updater with the http source 2 | package main 3 | 4 | import ( 5 | "flag" 6 | "log" 7 | "net/http" 8 | "path" 9 | "time" 10 | ) 11 | 12 | func main() { 13 | var root, listen, prefix, fixedSlug string 14 | flag.StringVar(&root, "repo", "", "Root path of the file server") 15 | flag.StringVar(&listen, "listen", "localhost:9947", "IP address and port used for the HTTP server") 16 | flag.StringVar(&prefix, "path-prefix", "/repo", "Prefix to the root path of the HTTP server") 17 | flag.StringVar(&fixedSlug, "fixed-slug", "creativeprojects/resticprofile", "Only answer on this particular slug. When NOT specified the files will be served with the slug in the path.") 18 | flag.Parse() 19 | 20 | mux := http.NewServeMux() 21 | fs := http.FileServer(http.Dir(root)) 22 | pathPrefix := path.Join("/", prefix, fixedSlug) 23 | log.Print("listening on http://" + listen + pathPrefix) 24 | mux.Handle(pathPrefix+"/", http.StripPrefix(pathPrefix, WithLogging(fs))) 25 | server := http.Server{ 26 | Addr: listen, 27 | Handler: mux, 28 | ReadHeaderTimeout: 15 * time.Second, 29 | } 30 | _ = server.ListenAndServe() 31 | } 32 | -------------------------------------------------------------------------------- /cmd/source.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strings" 7 | 8 | "github.com/creativeprojects/go-selfupdate" 9 | ) 10 | 11 | // SplitDomainSlug tries to make sense of the repository string 12 | // and returns a domain name (if present) and a slug. 13 | // 14 | // Example of valid entries: 15 | // 16 | // - "owner/name" 17 | // - "github.com/owner/name" 18 | // - "http://github.com/owner/name" 19 | func SplitDomainSlug(repo string) (domain, slug string, err error) { 20 | // simple case first => only a slug 21 | parts := strings.Split(repo, "/") 22 | if len(parts) == 2 { 23 | if parts[0] == "" || parts[1] == "" { 24 | return "", "", fmt.Errorf("invalid slug or URL %q", repo) 25 | } 26 | return "", repo, nil 27 | } 28 | // trim trailing / 29 | repo = strings.TrimSuffix(repo, "/") 30 | 31 | if !strings.HasPrefix(repo, "http") && !strings.Contains(repo, "://") && !strings.HasPrefix(repo, "/") { 32 | // add missing scheme 33 | repo = "https://" + repo 34 | } 35 | 36 | repoURL, err := url.Parse(repo) 37 | if err != nil { 38 | return "", "", err 39 | } 40 | 41 | // make sure hostname looks like a real domain name 42 | if !strings.Contains(repoURL.Hostname(), ".") { 43 | return "", "", fmt.Errorf("invalid domain name %q", repoURL.Hostname()) 44 | } 45 | domain = repoURL.Scheme + "://" + repoURL.Host 46 | slug = strings.TrimPrefix(repoURL.Path, "/") 47 | 48 | if slug == "" { 49 | return "", "", fmt.Errorf("invalid URL %q", repo) 50 | } 51 | return domain, slug, nil 52 | } 53 | 54 | func GetSource(cvsType, domain string) (selfupdate.Source, error) { 55 | if cvsType != "auto" && cvsType != "" { 56 | source, err := getSourceFromName(cvsType, domain) 57 | if err != nil { 58 | return nil, err 59 | } 60 | return source, nil 61 | } 62 | 63 | source, err := getSourceFromURL(domain) 64 | if err != nil { 65 | return nil, err 66 | } 67 | return source, nil 68 | } 69 | 70 | func getSourceFromName(name, domain string) (selfupdate.Source, error) { 71 | switch name { 72 | case "gitea": 73 | return selfupdate.NewGiteaSource(selfupdate.GiteaConfig{BaseURL: domain}) 74 | 75 | case "gitlab": 76 | return selfupdate.NewGitLabSource(selfupdate.GitLabConfig{BaseURL: domain}) 77 | 78 | case "http": 79 | return selfupdate.NewHttpSource(selfupdate.HttpConfig{BaseURL: domain}) 80 | 81 | default: 82 | return newGitHubSource(domain) 83 | } 84 | } 85 | 86 | func getSourceFromURL(domain string) (selfupdate.Source, error) { 87 | if strings.Contains(domain, "gitea") { 88 | return selfupdate.NewGiteaSource(selfupdate.GiteaConfig{BaseURL: domain}) 89 | } 90 | if strings.Contains(domain, "gitlab") { 91 | return selfupdate.NewGitLabSource(selfupdate.GitLabConfig{BaseURL: domain}) 92 | } 93 | return newGitHubSource(domain) 94 | } 95 | 96 | func newGitHubSource(domain string) (*selfupdate.GitHubSource, error) { 97 | config := selfupdate.GitHubConfig{} 98 | if domain != "" && !strings.HasSuffix(domain, "://github.com") { 99 | config.EnterpriseBaseURL = domain 100 | } 101 | return selfupdate.NewGitHubSource(config) 102 | } 103 | -------------------------------------------------------------------------------- /cmd/source_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestSplitDomainSlug(t *testing.T) { 10 | fixtures := []struct { 11 | repo string 12 | domain string 13 | slug string 14 | isValid bool 15 | }{ 16 | {"owner/name", "", "owner/name", true}, 17 | {"owner/name/", "", "", false}, 18 | {"/owner/name", "", "", false}, 19 | {"github.com/owner/name", "https://github.com", "owner/name", true}, 20 | {"http://github.com/owner/name", "http://github.com", "owner/name", true}, 21 | {"http://github.com", "", "", false}, 22 | {"http://github.com/", "", "", false}, 23 | {"https://github.com/", "", "", false}, 24 | {"github.com/", "", "", false}, 25 | {"github.com", "", "", false}, 26 | } 27 | 28 | for _, fixture := range fixtures { 29 | t.Run(fixture.repo, func(t *testing.T) { 30 | domain, slug, err := SplitDomainSlug(fixture.repo) 31 | assert.Equal(t, fixture.domain, domain) 32 | assert.Equal(t, fixture.slug, slug) 33 | if fixture.isValid { 34 | assert.NoError(t, err) 35 | } else { 36 | assert.Error(t, err) 37 | t.Log(err) 38 | } 39 | }) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | notify: 3 | after_n_builds: 3 4 | 5 | comment: 6 | after_n_builds: 3 7 | 8 | coverage: 9 | round: nearest 10 | status: 11 | project: 12 | default: 13 | target: auto 14 | threshold: "2%" 15 | patch: 16 | default: 17 | target: "70%" 18 | threshold: "2%" 19 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | // Config represents the configuration of self-update. 4 | type Config struct { 5 | // Source where to load the releases from (example: GitHubSource). 6 | Source Source 7 | // Validator represents types which enable additional validation of downloaded release. 8 | Validator Validator 9 | // Filters are regexp used to filter on specific assets for releases with multiple assets. 10 | // An asset is selected if it matches any of those, in addition to the regular tag, os, arch, extensions. 11 | // Please make sure that your filter(s) uniquely match an asset. 12 | Filters []string 13 | // OS is set to the value of runtime.GOOS by default, but you can force another value here. 14 | OS string 15 | // Arch is set to the value of runtime.GOARCH by default, but you can force another value here. 16 | Arch string 17 | // Arm 32bits version. Valid values are 0 (unknown), 5, 6 or 7. Default is detected value (if available). 18 | Arm uint8 19 | // Arch name for macOS universal binary. Default to none. 20 | // If set, the updater will only pick the universal binary if the Arch is not found. 21 | UniversalArch string 22 | // Draft permits an upgrade to a "draft" version (default to false). 23 | Draft bool 24 | // Prerelease permits an upgrade to a "pre-release" version (default to false). 25 | Prerelease bool 26 | // To prevent automatic removal of the old binary, and allow you to test an update prior to manual removal. 27 | OldSavePath string 28 | } 29 | -------------------------------------------------------------------------------- /decompress.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "archive/tar" 5 | "archive/zip" 6 | "bytes" 7 | "compress/bzip2" 8 | "compress/gzip" 9 | "errors" 10 | "fmt" 11 | "io" 12 | "path/filepath" 13 | "regexp" 14 | "strings" 15 | 16 | "github.com/ulikunitz/xz" 17 | ) 18 | 19 | var ( 20 | fileTypes = []struct { 21 | ext string 22 | decompress func(src io.Reader, cmd, os, arch string) (io.Reader, error) 23 | }{ 24 | {".zip", unzip}, 25 | {".tar.gz", untar}, 26 | {".tgz", untar}, 27 | {".gzip", gunzip}, 28 | {".gz", gunzip}, 29 | {".tar.xz", untarxz}, 30 | {".xz", unxz}, 31 | {".bz2", unbz2}, 32 | } 33 | // pattern copied from bottom of the page: https://semver.org/ 34 | semverPattern = `(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?` 35 | ) 36 | 37 | // DecompressCommand decompresses the given source. Archive and compression format is 38 | // automatically detected from 'url' parameter, which represents the URL of asset, 39 | // or simply a filename (with an extension). 40 | // This returns a reader for the decompressed command given by 'cmd'. '.zip', 41 | // '.tar.gz', '.tar.xz', '.tgz', '.gz', '.bz2' and '.xz' are supported. 42 | // 43 | // These wrapped errors can be returned: 44 | // - ErrCannotDecompressFile 45 | // - ErrExecutableNotFoundInArchive 46 | func DecompressCommand(src io.Reader, url, cmd, os, arch string) (io.Reader, error) { 47 | for _, fileType := range fileTypes { 48 | if strings.HasSuffix(url, fileType.ext) { 49 | return fileType.decompress(src, cmd, os, arch) 50 | } 51 | } 52 | log.Print("File is not compressed") 53 | return src, nil 54 | } 55 | 56 | func unzip(src io.Reader, cmd, os, arch string) (io.Reader, error) { 57 | log.Print("Decompressing zip file") 58 | 59 | // Zip format requires its file size for Decompressing. 60 | // So we need to read the HTTP response into a buffer at first. 61 | buf, err := io.ReadAll(src) 62 | if err != nil { 63 | return nil, fmt.Errorf("%w zip file: %v", ErrCannotDecompressFile, err) 64 | } 65 | 66 | r := bytes.NewReader(buf) 67 | z, err := zip.NewReader(r, r.Size()) 68 | if err != nil { 69 | return nil, fmt.Errorf("%w zip file: %s", ErrCannotDecompressFile, err) 70 | } 71 | 72 | for _, file := range z.File { 73 | _, name := filepath.Split(file.Name) 74 | if !file.FileInfo().IsDir() && matchExecutableName(cmd, os, arch, name) { 75 | log.Printf("Executable file %q was found in zip archive", file.Name) 76 | return file.Open() 77 | } 78 | } 79 | 80 | return nil, fmt.Errorf("%w in zip file: %q", ErrExecutableNotFoundInArchive, cmd) 81 | } 82 | 83 | func untar(src io.Reader, cmd, os, arch string) (io.Reader, error) { 84 | log.Print("Decompressing tar.gz file") 85 | 86 | gz, err := gzip.NewReader(src) 87 | if err != nil { 88 | return nil, fmt.Errorf("%w tar.gz file: %s", ErrCannotDecompressFile, err) 89 | } 90 | 91 | return unarchiveTar(gz, cmd, os, arch) 92 | } 93 | 94 | func gunzip(src io.Reader, cmd, os, arch string) (io.Reader, error) { 95 | log.Print("Decompressing gzip file") 96 | 97 | r, err := gzip.NewReader(src) 98 | if err != nil { 99 | return nil, fmt.Errorf("%w gzip file: %s", ErrCannotDecompressFile, err) 100 | } 101 | 102 | name := r.Header.Name 103 | if !matchExecutableName(cmd, os, arch, name) { 104 | return nil, fmt.Errorf("%w: expected %q but found %q", ErrExecutableNotFoundInArchive, cmd, name) 105 | } 106 | 107 | log.Printf("Executable file %q was found in gzip file", name) 108 | return r, nil 109 | } 110 | 111 | func untarxz(src io.Reader, cmd, os, arch string) (io.Reader, error) { 112 | log.Print("Decompressing tar.xz file") 113 | 114 | xzip, err := xz.NewReader(src) 115 | if err != nil { 116 | return nil, fmt.Errorf("%w tar.xz file: %s", ErrCannotDecompressFile, err) 117 | } 118 | 119 | return unarchiveTar(xzip, cmd, os, arch) 120 | } 121 | 122 | func unxz(src io.Reader, cmd, os, arch string) (io.Reader, error) { 123 | log.Print("Decompressing xzip file") 124 | 125 | xzip, err := xz.NewReader(src) 126 | if err != nil { 127 | return nil, fmt.Errorf("%w xzip file: %s", ErrCannotDecompressFile, err) 128 | } 129 | 130 | log.Printf("Decompressed file from xzip is assumed to be an executable: %s", cmd) 131 | return xzip, nil 132 | } 133 | 134 | func unbz2(src io.Reader, cmd, os, arch string) (io.Reader, error) { 135 | log.Print("Decompressing bzip2 file") 136 | 137 | bz2 := bzip2.NewReader(src) 138 | 139 | log.Printf("Decompressed file from bzip2 is assumed to be an executable: %s", cmd) 140 | return bz2, nil 141 | } 142 | 143 | func matchExecutableName(cmd, os, arch, target string) bool { 144 | cmd = strings.TrimSuffix(cmd, ".exe") 145 | pattern := regexp.MustCompile( 146 | fmt.Sprintf( 147 | `^%s([_-]v?%s)?([_-]%s[_-]%s)?(\.exe)?$`, 148 | regexp.QuoteMeta(cmd), 149 | semverPattern, 150 | regexp.QuoteMeta(os), 151 | regexp.QuoteMeta(arch), 152 | ), 153 | ) 154 | return pattern.MatchString(target) 155 | } 156 | 157 | func unarchiveTar(src io.Reader, cmd, os, arch string) (io.Reader, error) { 158 | t := tar.NewReader(src) 159 | for { 160 | h, err := t.Next() 161 | if errors.Is(err, io.EOF) { 162 | break 163 | } 164 | if err != nil { 165 | return nil, fmt.Errorf("%w tar file: %s", ErrCannotDecompressFile, err) 166 | } 167 | _, name := filepath.Split(h.Name) 168 | if matchExecutableName(cmd, os, arch, name) { 169 | log.Printf("Executable file %q was found in tar archive", h.Name) 170 | return t, nil 171 | } 172 | } 173 | return nil, fmt.Errorf("%w in tar: %q", ErrExecutableNotFoundInArchive, cmd) 174 | } 175 | -------------------------------------------------------------------------------- /decompress_test.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | "runtime" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | func TestCompressionNotRequired(t *testing.T) { 17 | buf := []byte{'a', 'b', 'c'} 18 | want := bytes.NewReader(buf) 19 | r, err := DecompressCommand(want, "https://github.com/foo/bar/releases/download/v1.2.3/foo", "foo", runtime.GOOS, runtime.GOOS) 20 | require.NoError(t, err) 21 | 22 | have, err := io.ReadAll(r) 23 | require.NoError(t, err) 24 | assert.Equal(t, buf, have) 25 | } 26 | 27 | func getArchiveFileExt(file string) string { 28 | if strings.HasSuffix(file, ".tar.gz") { 29 | return ".tar.gz" 30 | } 31 | if strings.HasSuffix(file, ".tar.xz") { 32 | return ".tar.xz" 33 | } 34 | return filepath.Ext(file) 35 | } 36 | 37 | func TestDecompress(t *testing.T) { 38 | for _, testCase := range []string{ 39 | "testdata/foo.zip", 40 | "testdata/single-file.zip", 41 | "testdata/single-file.gz", 42 | "testdata/single-file.gzip", 43 | "testdata/foo.tar.gz", 44 | "testdata/foo.tgz", 45 | "testdata/foo.tar.xz", 46 | "testdata/single-file.xz", 47 | "testdata/single-file.bz2", 48 | } { 49 | t.Run(testCase, func(t *testing.T) { 50 | f, err := os.Open(testCase) 51 | require.NoError(t, err) 52 | 53 | ext := getArchiveFileExt(testCase) 54 | url := "https://github.com/foo/bar/releases/download/v1.2.3/bar" + ext 55 | r, err := DecompressCommand(f, url, "bar", runtime.GOOS, runtime.GOOS) 56 | require.NoError(t, err) 57 | 58 | content, err := io.ReadAll(r) 59 | require.NoError(t, err) 60 | 61 | assert.Equal(t, "this is test\n", string(content), "Decompressing zip failed into unexpected content") 62 | }) 63 | } 64 | } 65 | 66 | func TestDecompressInvalidArchive(t *testing.T) { 67 | for _, testCase := range []struct { 68 | name string 69 | msg string 70 | }{ 71 | {"testdata/invalid.zip", "failed to decompress zip file"}, 72 | {"testdata/invalid.gz", "failed to decompress gzip file"}, 73 | {"testdata/invalid-tar.tar.gz", "failed to decompress tar file"}, 74 | {"testdata/invalid-gzip.tar.gz", "failed to decompress tar.gz file"}, 75 | {"testdata/invalid.xz", "failed to decompress xzip file"}, 76 | {"testdata/invalid-tar.tar.xz", "failed to decompress tar file"}, 77 | {"testdata/invalid-xz.tar.xz", "failed to decompress tar.xz file"}, 78 | } { 79 | f, err := os.Open(testCase.name) 80 | require.NoError(t, err) 81 | 82 | ext := getArchiveFileExt(testCase.name) 83 | url := "https://github.com/foo/bar/releases/download/v1.2.3/bar" + ext 84 | _, err = DecompressCommand(f, url, "bar", runtime.GOOS, runtime.GOOS) 85 | assert.ErrorIs(t, err, ErrCannotDecompressFile) 86 | if !strings.Contains(err.Error(), testCase.msg) { 87 | t.Fatal("Unexpected error:", err) 88 | } 89 | } 90 | } 91 | 92 | func TestTargetNotFound(t *testing.T) { 93 | for _, testCase := range []struct { 94 | name string 95 | msg string 96 | }{ 97 | {"testdata/empty.zip", "not found"}, 98 | {"testdata/bar-not-found.zip", "not found"}, 99 | {"testdata/bar-not-found.gzip", "not found"}, 100 | {"testdata/empty.tar.gz", "not found"}, 101 | {"testdata/bar-not-found.tar.gz", "not found"}, 102 | } { 103 | t.Run(testCase.name, func(t *testing.T) { 104 | f, err := os.Open(testCase.name) 105 | require.NoError(t, err) 106 | 107 | ext := getArchiveFileExt(testCase.name) 108 | url := "https://github.com/foo/bar/releases/download/v1.2.3/bar" + ext 109 | _, err = DecompressCommand(f, url, "bar", runtime.GOOS, runtime.GOOS) 110 | assert.ErrorIs(t, err, ErrExecutableNotFoundInArchive) 111 | }) 112 | } 113 | } 114 | 115 | func TestMatchExecutableName(t *testing.T) { 116 | testData := []struct { 117 | cmd string 118 | os string 119 | arch string 120 | target string 121 | found bool 122 | }{ 123 | // valid 124 | {"gostuff", "linux", "amd64", "gostuff", true}, 125 | {"gostuff", "linux", "amd64", "gostuff_0.16.0", true}, 126 | {"gostuff", "linux", "amd64", "gostuff-0.16.0", true}, 127 | {"gostuff", "linux", "amd64", "gostuff_v0.16.0", true}, 128 | {"gostuff", "linux", "amd64", "gostuff-v0.16.0", true}, 129 | {"gostuff", "linux", "amd64", "gostuff_linux_amd64", true}, 130 | {"gostuff", "linux", "amd64", "gostuff-linux-amd64", true}, 131 | {"gostuff", "linux", "amd64", "gostuff_0.16.0_linux_amd64", true}, 132 | {"gostuff", "linux", "amd64", "gostuff-0.16.0-linux-amd64", true}, 133 | {"gostuff", "linux", "amd64", "gostuff_v0.16.0_linux_amd64", true}, 134 | {"gostuff", "linux", "amd64", "gostuff-v0.16.0-linux-amd64", true}, 135 | // invalid 136 | {"gostuff", "linux", "amd64", "gostuff_darwin_amd64", false}, 137 | {"gostuff", "linux", "amd64", "gostuff0.16.0", false}, 138 | {"gostuff", "linux", "amd64", "gostuffv0.16.0", false}, 139 | {"gostuff", "linux", "amd64", "gostuff_0.16.0_amd64", false}, 140 | {"gostuff", "linux", "amd64", "gostuff_v0.16.0_amd64", false}, 141 | {"gostuff", "linux", "amd64", "gostuff_0.16.0_linux", false}, 142 | {"gostuff", "linux", "amd64", "gostuff_v0.16.0_linux", false}, 143 | // windows valid 144 | {"gostuff", "windows", "amd64", "gostuff.exe", true}, 145 | {"gostuff", "windows", "amd64", "gostuff_0.16.0.exe", true}, 146 | {"gostuff", "windows", "amd64", "gostuff-0.16.0.exe", true}, 147 | {"gostuff", "windows", "amd64", "gostuff_v0.16.0.exe", true}, 148 | {"gostuff", "windows", "amd64", "gostuff-v0.16.0.exe", true}, 149 | {"gostuff", "windows", "amd64", "gostuff_windows_amd64.exe", true}, 150 | {"gostuff", "windows", "amd64", "gostuff-windows-amd64.exe", true}, 151 | {"gostuff", "windows", "amd64", "gostuff_0.16.0_windows_amd64.exe", true}, 152 | {"gostuff", "windows", "amd64", "gostuff-0.16.0-windows-amd64.exe", true}, 153 | {"gostuff", "windows", "amd64", "gostuff_v0.16.0_windows_amd64.exe", true}, 154 | {"gostuff", "windows", "amd64", "gostuff-v0.16.0-windows-amd64.exe", true}, 155 | // windows invalid 156 | {"gostuff", "windows", "amd64", "gostuff_darwin_amd64.exe", false}, 157 | {"gostuff", "windows", "amd64", "gostuff0.16.0.exe", false}, 158 | {"gostuff", "windows", "amd64", "gostuff_0.16.0_amd64.exe", false}, 159 | {"gostuff", "windows", "amd64", "gostuff_0.16.0_windows.exe", false}, 160 | {"gostuff", "windows", "amd64", "gostuffv0.16.0.exe", false}, 161 | {"gostuff", "windows", "amd64", "gostuff_v0.16.0_amd64.exe", false}, 162 | {"gostuff", "windows", "amd64", "gostuff_v0.16.0_windows.exe", false}, 163 | } 164 | 165 | for _, testItem := range testData { 166 | t.Run(testItem.target, func(t *testing.T) { 167 | assert.Equal(t, testItem.found, matchExecutableName(testItem.cmd, testItem.os, testItem.arch, testItem.target)) 168 | }) 169 | // also try with .exe already in cmd for windows 170 | if testItem.os == "windows" { 171 | t.Run(testItem.target, func(t *testing.T) { 172 | assert.Equal(t, testItem.found, matchExecutableName(testItem.cmd+".exe", testItem.os, testItem.arch, testItem.target)) 173 | }) 174 | } 175 | } 176 | } 177 | 178 | func TestErrorFromReader(t *testing.T) { 179 | extensions := []string{ 180 | "zip", 181 | "tar.gz", 182 | "tgz", 183 | "gzip", 184 | "gz", 185 | "tar.xz", 186 | "xz", 187 | "bz2", 188 | } 189 | 190 | for _, extension := range extensions { 191 | t.Run(extension, func(t *testing.T) { 192 | reader, err := DecompressCommand(&bogusReader{}, "foo."+extension, "foo."+extension, runtime.GOOS, runtime.GOARCH) 193 | if err != nil { 194 | t.Log(err) 195 | assert.ErrorIs(t, err, ErrCannotDecompressFile) 196 | } else { 197 | // bz2 does not return an error straight away: it only fails when you start reading from the output reader 198 | _, err = io.ReadAll(reader) 199 | t.Log(err) 200 | assert.Error(t, err) 201 | } 202 | }) 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /detect.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/Masterminds/semver/v3" 10 | ) 11 | 12 | var reVersion = regexp.MustCompile(`\d+\.\d+\.\d+`) 13 | 14 | // DetectLatest tries to get the latest version from the source provider. 15 | // It fetches releases information from the source provider and find out the latest release with matching the tag names and asset names. 16 | // Drafts and pre-releases are ignored. 17 | // Assets would be suffixed by the OS name and the arch name such as 'foo_linux_amd64' where 'foo' is a command name. 18 | // '-' can also be used as a separator. File can be compressed with zip, gzip, xz, bzip2, tar&gzip or tar&xz. 19 | // So the asset can have a file extension for the corresponding compression format such as '.zip'. 20 | // On Windows, '.exe' also can be contained such as 'foo_windows_amd64.exe.zip'. 21 | func (up *Updater) DetectLatest(ctx context.Context, repository Repository) (release *Release, found bool, err error) { 22 | return up.DetectVersion(ctx, repository, "") 23 | } 24 | 25 | // DetectVersion tries to get the given version from the source provider. 26 | // And version indicates the required version. 27 | func (up *Updater) DetectVersion(ctx context.Context, repository Repository, version string) (release *Release, found bool, err error) { 28 | rels, err := up.source.ListReleases(ctx, repository) 29 | if err != nil { 30 | return nil, false, err 31 | } 32 | 33 | rel, asset, ver, found := up.findReleaseAndAsset(rels, version) 34 | if !found { 35 | return nil, false, nil 36 | } 37 | 38 | return up.validateReleaseAsset(repository, rel, asset, ver) 39 | } 40 | 41 | func (up *Updater) validateReleaseAsset( 42 | repository Repository, 43 | rel SourceRelease, 44 | asset SourceAsset, 45 | ver *semver.Version, 46 | ) (release *Release, found bool, err error) { 47 | log.Printf("Successfully fetched release %s, name: %s, URL: %s, asset: %s", 48 | rel.GetTagName(), 49 | rel.GetName(), 50 | rel.GetURL(), 51 | asset.GetBrowserDownloadURL(), 52 | ) 53 | 54 | release = &Release{ 55 | version: ver, 56 | repository: repository, 57 | AssetURL: asset.GetBrowserDownloadURL(), 58 | AssetByteSize: asset.GetSize(), 59 | AssetID: asset.GetID(), 60 | AssetName: asset.GetName(), 61 | ValidationAssetID: -1, 62 | ValidationAssetURL: "", 63 | URL: rel.GetURL(), 64 | ReleaseID: rel.GetID(), 65 | ReleaseNotes: rel.GetReleaseNotes(), 66 | Name: rel.GetName(), 67 | PublishedAt: rel.GetPublishedAt(), 68 | Prerelease: rel.GetPrerelease(), 69 | OS: up.os, 70 | Arch: up.arch, 71 | Arm: up.arm, 72 | } 73 | 74 | if up.validator != nil { 75 | validationName := up.validator.GetValidationAssetName(asset.GetName()) 76 | validationAsset, ok := findValidationAsset(rel, validationName) 77 | if ok { 78 | release.ValidationAssetID = validationAsset.GetID() 79 | release.ValidationAssetURL = validationAsset.GetBrowserDownloadURL() 80 | } else { 81 | err = fmt.Errorf("%w: %q", ErrValidationAssetNotFound, validationName) 82 | } 83 | 84 | for err == nil { 85 | release.ValidationChain = append(release.ValidationChain, struct { 86 | ValidationAssetID int64 87 | ValidationAssetName, ValidationAssetURL string 88 | }{ 89 | ValidationAssetID: validationAsset.GetID(), 90 | ValidationAssetName: validationAsset.GetName(), 91 | ValidationAssetURL: validationAsset.GetBrowserDownloadURL(), 92 | }) 93 | 94 | if len(release.ValidationChain) > 20 { 95 | err = fmt.Errorf("failed adding validation step %q: recursive validation nesting depth exceeded", validationAsset.GetName()) 96 | break 97 | } 98 | 99 | if rv, ok := up.validator.(RecursiveValidator); ok && rv.MustContinueValidation(validationAsset.GetName()) { 100 | validationName = up.validator.GetValidationAssetName(validationAsset.GetName()) 101 | if validationName != validationAsset.GetName() { 102 | validationAsset, ok = findValidationAsset(rel, validationName) 103 | if !ok { 104 | err = fmt.Errorf("%w: %q", ErrValidationAssetNotFound, validationName) 105 | } 106 | continue 107 | } 108 | } 109 | 110 | break 111 | } 112 | } 113 | 114 | if found = err == nil; !found { 115 | release = nil 116 | } 117 | return 118 | } 119 | 120 | // findValidationAsset returns the source asset used for validation 121 | func findValidationAsset(rel SourceRelease, validationName string) (SourceAsset, bool) { 122 | for _, asset := range rel.GetAssets() { 123 | if asset.GetName() == validationName { 124 | return asset, true 125 | } 126 | } 127 | return nil, false 128 | } 129 | 130 | // findReleaseAndAsset returns the release and asset matching the target version, or latest if target version is empty 131 | func (up *Updater) findReleaseAndAsset(rels []SourceRelease, targetVersion string) (SourceRelease, SourceAsset, *semver.Version, bool) { 132 | // we put the detected arch at the end of the list: that's fine for ARM so far, 133 | // as the additional arch are more accurate than the generic one 134 | for _, arch := range getAdditionalArch(up.arch, up.arm, up.universalArch) { 135 | release, asset, version, found := up.findReleaseAndAssetForArch(arch, rels, targetVersion) 136 | if found { 137 | return release, asset, version, found 138 | } 139 | } 140 | 141 | return nil, nil, nil, false 142 | } 143 | 144 | func (up *Updater) findReleaseAndAssetForArch(arch string, rels []SourceRelease, targetVersion string, 145 | ) (SourceRelease, SourceAsset, *semver.Version, bool) { 146 | var ver *semver.Version 147 | var asset SourceAsset 148 | var release SourceRelease 149 | 150 | log.Printf("Searching for a possible candidate for os %q and arch %q", up.os, arch) 151 | 152 | // Find the latest version from the list of releases. 153 | // Returned list from GitHub API is in the order of the date when created. 154 | for _, rel := range rels { 155 | if a, v, ok := up.findAssetFromRelease(rel, up.getSuffixes(arch), targetVersion); ok { 156 | // Note: any version with suffix is less than any version without suffix. 157 | // e.g. 0.0.1 > 0.0.1-beta 158 | if release == nil || v.GreaterThan(ver) { 159 | ver = v 160 | asset = a 161 | release = rel 162 | } 163 | } 164 | } 165 | 166 | if release == nil { 167 | log.Printf("Could not find any release for os %q and arch %q", up.os, arch) 168 | return nil, nil, nil, false 169 | } 170 | 171 | return release, asset, ver, true 172 | } 173 | 174 | func (up *Updater) findAssetFromRelease(rel SourceRelease, suffixes []string, targetVersion string) (SourceAsset, *semver.Version, bool) { 175 | if rel == nil { 176 | log.Print("No source release information") 177 | return nil, nil, false 178 | } 179 | if targetVersion != "" && targetVersion != rel.GetTagName() { 180 | log.Printf("Skip %s not matching to specified version %s", rel.GetTagName(), targetVersion) 181 | return nil, nil, false 182 | } 183 | 184 | if rel.GetDraft() && !up.draft && targetVersion == "" { 185 | log.Printf("Skip draft version %s", rel.GetTagName()) 186 | return nil, nil, false 187 | } 188 | if rel.GetPrerelease() && !up.prerelease && targetVersion == "" { 189 | log.Printf("Skip pre-release version %s", rel.GetTagName()) 190 | return nil, nil, false 191 | } 192 | 193 | verText := rel.GetTagName() 194 | indices := reVersion.FindStringIndex(verText) 195 | if indices == nil { 196 | log.Printf("Skip version not adopting semver: %s", verText) 197 | return nil, nil, false 198 | } 199 | if indices[0] > 0 { 200 | verText = verText[indices[0]:] 201 | } 202 | 203 | // If semver cannot parse the version text, it means that the text is not adopting 204 | // the semantic versioning. So it should be skipped. 205 | ver, err := semver.NewVersion(verText) 206 | if err != nil { 207 | log.Printf("Failed to parse a semantic version: %s", verText) 208 | return nil, nil, false 209 | } 210 | 211 | for _, asset := range rel.GetAssets() { 212 | // try names first 213 | name := asset.GetName() 214 | // case insensitive search 215 | name = strings.ToLower(name) 216 | 217 | if up.hasFilters() { 218 | if up.assetMatchFilters(name) { 219 | return asset, ver, true 220 | } 221 | } else { 222 | if up.assetMatchSuffixes(name, suffixes) { 223 | return asset, ver, true 224 | } 225 | } 226 | 227 | // then try from filename (Gitlab can assign human names to release assets) 228 | name = asset.GetBrowserDownloadURL() 229 | // case insensitive search 230 | name = strings.ToLower(name) 231 | 232 | if up.hasFilters() { 233 | if up.assetMatchFilters(name) { 234 | return asset, ver, true 235 | } 236 | } else { 237 | if up.assetMatchSuffixes(name, suffixes) { 238 | return asset, ver, true 239 | } 240 | } 241 | } 242 | 243 | log.Printf("No suitable asset was found in release %s", rel.GetTagName()) 244 | return nil, nil, false 245 | } 246 | 247 | func (up *Updater) hasFilters() bool { 248 | return len(up.filters) > 0 249 | } 250 | 251 | func (up *Updater) assetMatchFilters(name string) bool { 252 | if len(up.filters) > 0 { 253 | // if some filters are defined, match them: if any one matches, the asset is selected 254 | for _, filter := range up.filters { 255 | if filter.MatchString(name) { 256 | log.Printf("Selected filtered asset: %s", name) 257 | return true 258 | } 259 | log.Printf("Skipping asset %q not matching filter %v\n", name, filter) 260 | } 261 | } 262 | return false 263 | } 264 | 265 | func (up *Updater) assetMatchSuffixes(name string, suffixes []string) bool { 266 | for _, suffix := range suffixes { 267 | if strings.HasSuffix(name, suffix) { // require version, arch etc 268 | // assuming a unique artifact will be a match (or first one will do) 269 | return true 270 | } 271 | } 272 | return false 273 | } 274 | 275 | // getSuffixes returns all candidates to check against the assets 276 | func (up *Updater) getSuffixes(arch string) []string { 277 | suffixes := make([]string, 0) 278 | for _, sep := range []rune{'_', '-'} { 279 | for _, ext := range []string{".zip", ".tar.gz", ".tgz", ".gzip", ".gz", ".tar.xz", ".xz", ".bz2", ""} { 280 | suffix := fmt.Sprintf("%s%c%s%s", up.os, sep, arch, ext) 281 | suffixes = append(suffixes, suffix) 282 | if up.os == "windows" { 283 | suffix = fmt.Sprintf("%s%c%s.exe%s", up.os, sep, arch, ext) 284 | suffixes = append(suffixes, suffix) 285 | } 286 | } 287 | } 288 | return suffixes 289 | } 290 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | go-selfupdate detects the information of the latest release via GitHub Releases API and checks the current version. 3 | If newer version than itself is detected, it downloads released binary from GitHub and replaces itself. 4 | 5 | - Automatically detects the latest version of released binary on GitHub 6 | 7 | - Retrieve the proper binary for the OS and arch where the binary is running 8 | 9 | - Update the binary with rollback support on failure 10 | 11 | - Tested on Linux, macOS and Windows 12 | 13 | - Many archive and compression formats are supported (zip, gzip, xzip, bzip2, tar) 14 | 15 | There are some naming rules. Please read following links. 16 | 17 | Naming Rules of Released Binaries: 18 | https://github.com/creativeprojects/go-selfupdate#naming-rules-of-released-binaries 19 | 20 | Naming Rules of Git Tags: 21 | https://github.com/creativeprojects/go-selfupdate#naming-rules-of-versions-git-tags 22 | 23 | This package is hosted on GitHub: 24 | https://github.com/creativeprojects/go-selfupdate 25 | 26 | Small CLI tools as wrapper of this library are available also: 27 | https://github.com/creativeprojects/go-selfupdate/cmd/detect-latest-release 28 | https://github.com/creativeprojects/go-selfupdate/cmd/go-get-release 29 | */ 30 | package selfupdate 31 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import "errors" 4 | 5 | // Possible errors returned 6 | var ( 7 | ErrNotSupported = errors.New("operation not supported") 8 | ErrInvalidSlug = errors.New("invalid slug format, expected 'owner/name'") 9 | ErrIncorrectParameterOwner = errors.New("incorrect parameter \"owner\"") 10 | ErrIncorrectParameterRepo = errors.New("incorrect parameter \"repo\"") 11 | ErrInvalidID = errors.New("invalid repository ID, expected 'owner/name' but found number") 12 | ErrInvalidRelease = errors.New("invalid release (nil argument)") 13 | ErrAssetNotFound = errors.New("asset not found") 14 | ErrValidationAssetNotFound = errors.New("validation file not found") 15 | ErrValidatorNotFound = errors.New("file did not match a configured validator") 16 | ErrIncorrectChecksumFile = errors.New("incorrect checksum file format") 17 | ErrChecksumValidationFailed = errors.New("sha256 validation failed") 18 | ErrHashNotFound = errors.New("hash not found in checksum file") 19 | ErrECDSAValidationFailed = errors.New("ECDSA signature verification failed") 20 | ErrInvalidECDSASignature = errors.New("invalid ECDSA signature") 21 | ErrInvalidPGPSignature = errors.New("invalid PGP signature") 22 | ErrPGPKeyRingNotSet = errors.New("PGP key ring not set") 23 | ErrCannotDecompressFile = errors.New("failed to decompress") 24 | ErrExecutableNotFoundInArchive = errors.New("executable not found") 25 | ) 26 | -------------------------------------------------------------------------------- /gitea_release.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "time" 5 | 6 | "code.gitea.io/sdk/gitea" 7 | ) 8 | 9 | type GiteaRelease struct { 10 | releaseID int64 11 | name string 12 | tagName string 13 | url string 14 | draft bool 15 | prerelease bool 16 | publishedAt time.Time 17 | releaseNotes string 18 | assets []SourceAsset 19 | } 20 | 21 | func NewGiteaRelease(from *gitea.Release) *GiteaRelease { 22 | release := &GiteaRelease{ 23 | releaseID: from.ID, 24 | name: from.Title, 25 | tagName: from.TagName, 26 | url: "", //FIXME: we kind of have no url ? 27 | publishedAt: from.PublishedAt, 28 | releaseNotes: from.Note, 29 | draft: from.IsDraft, 30 | prerelease: from.IsPrerelease, 31 | assets: make([]SourceAsset, len(from.Attachments)), 32 | } 33 | 34 | for i, fromAsset := range from.Attachments { 35 | release.assets[i] = NewGiteaAsset(fromAsset) 36 | } 37 | 38 | return release 39 | } 40 | 41 | func (r *GiteaRelease) GetID() int64 { 42 | return r.releaseID 43 | } 44 | 45 | func (r *GiteaRelease) GetTagName() string { 46 | return r.tagName 47 | } 48 | 49 | func (r *GiteaRelease) GetDraft() bool { 50 | return r.draft 51 | } 52 | 53 | func (r *GiteaRelease) GetPrerelease() bool { 54 | return r.prerelease 55 | } 56 | 57 | func (r *GiteaRelease) GetPublishedAt() time.Time { 58 | return r.publishedAt 59 | } 60 | 61 | func (r *GiteaRelease) GetReleaseNotes() string { 62 | return r.releaseNotes 63 | } 64 | 65 | func (r *GiteaRelease) GetName() string { 66 | return r.name 67 | } 68 | 69 | func (r *GiteaRelease) GetURL() string { 70 | return r.url 71 | } 72 | 73 | func (r *GiteaRelease) GetAssets() []SourceAsset { 74 | return r.assets 75 | } 76 | 77 | type GiteaAsset struct { 78 | id int64 79 | name string 80 | size int 81 | url string 82 | } 83 | 84 | func NewGiteaAsset(from *gitea.Attachment) *GiteaAsset { 85 | return &GiteaAsset{ 86 | id: from.ID, 87 | name: from.Name, 88 | size: int(from.Size), 89 | url: from.DownloadURL, 90 | } 91 | } 92 | 93 | func (a *GiteaAsset) GetID() int64 { 94 | return a.id 95 | } 96 | 97 | func (a *GiteaAsset) GetName() string { 98 | return a.name 99 | } 100 | 101 | func (a *GiteaAsset) GetSize() int { 102 | return a.size 103 | } 104 | 105 | func (a *GiteaAsset) GetBrowserDownloadURL() string { 106 | return a.url 107 | } 108 | 109 | // Verify interface 110 | var ( 111 | _ SourceRelease = &GiteaRelease{} 112 | _ SourceAsset = &GiteaAsset{} 113 | ) 114 | -------------------------------------------------------------------------------- /gitea_source.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "os" 9 | 10 | "code.gitea.io/sdk/gitea" 11 | ) 12 | 13 | // GiteaConfig is an object to pass to NewGiteaSource 14 | type GiteaConfig struct { 15 | // APIToken represents Gitea API token. If it's not empty, it will be used for authentication for the API 16 | APIToken string 17 | // BaseURL is a base URL of your gitea instance. This parameter has NO default value. 18 | BaseURL string 19 | // Deprecated: Context option is no longer used 20 | Context context.Context 21 | } 22 | 23 | // GiteaSource is used to load release information from Gitea 24 | type GiteaSource struct { 25 | api *gitea.Client 26 | token string 27 | baseURL string 28 | } 29 | 30 | // NewGiteaSource creates a new NewGiteaSource from a config object. 31 | // It initializes a Gitea API Client. 32 | // If you set your API token to the $GITEA_TOKEN environment variable, the client will use it. 33 | // You can pass an empty GiteaSource{} to use the default configuration 34 | func NewGiteaSource(config GiteaConfig) (*GiteaSource, error) { 35 | token := config.APIToken 36 | if token == "" { 37 | // try the environment variable 38 | token = os.Getenv("GITEA_TOKEN") 39 | } 40 | if config.BaseURL == "" { 41 | return nil, fmt.Errorf("gitea base url must be set") 42 | } 43 | 44 | ctx := config.Context 45 | if ctx == nil { 46 | ctx = context.Background() 47 | } 48 | 49 | client, err := gitea.NewClient(config.BaseURL, gitea.SetContext(ctx), gitea.SetToken(token)) 50 | if err != nil { 51 | return nil, fmt.Errorf("error connecting to gitea: %w", err) 52 | } 53 | 54 | return &GiteaSource{ 55 | api: client, 56 | token: token, 57 | baseURL: config.BaseURL, 58 | }, nil 59 | } 60 | 61 | // ListReleases returns all available releases 62 | func (s *GiteaSource) ListReleases(ctx context.Context, repository Repository) ([]SourceRelease, error) { 63 | owner, repo, err := repository.GetSlug() 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | s.api.SetContext(ctx) 69 | rels, res, err := s.api.ListReleases(owner, repo, gitea.ListReleasesOptions{}) 70 | if err != nil { 71 | if res != nil && res.StatusCode == http.StatusNotFound { 72 | // repository not found or release not found. It's not an error here. 73 | log.Print("Repository or release not found") 74 | return nil, nil 75 | } 76 | log.Printf("API returned an error response: %s", err) 77 | return nil, err 78 | } 79 | releases := make([]SourceRelease, len(rels)) 80 | for i, rel := range rels { 81 | releases[i] = NewGiteaRelease(rel) 82 | } 83 | return releases, nil 84 | } 85 | 86 | // DownloadReleaseAsset downloads an asset from a release. 87 | // It returns an io.ReadCloser: it is your responsibility to Close it. 88 | func (s *GiteaSource) DownloadReleaseAsset(ctx context.Context, rel *Release, assetID int64) (io.ReadCloser, error) { 89 | if rel == nil { 90 | return nil, ErrInvalidRelease 91 | } 92 | owner, repo, err := rel.repository.GetSlug() 93 | if err != nil { 94 | return nil, err 95 | } 96 | s.api.SetContext(ctx) 97 | attachment, _, err := s.api.GetReleaseAttachment(owner, repo, rel.ReleaseID, assetID) 98 | if err != nil { 99 | return nil, fmt.Errorf("failed to call Gitea Releases API for getting the asset ID %d on repository '%s/%s': %w", assetID, owner, repo, err) 100 | } 101 | 102 | client := http.DefaultClient 103 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, attachment.DownloadURL, http.NoBody) 104 | if err != nil { 105 | log.Print(err) 106 | return nil, err 107 | } 108 | 109 | if s.token != "" { 110 | // verify request is from same domain not to leak token 111 | ok, err := canUseTokenForDomain(s.baseURL, attachment.DownloadURL) 112 | if err != nil { 113 | return nil, err 114 | } 115 | if ok { 116 | req.Header.Set("Authorization", "token "+s.token) 117 | } 118 | } 119 | response, err := client.Do(req) 120 | 121 | if err != nil { 122 | log.Print(err) 123 | return nil, err 124 | } 125 | 126 | return response.Body, nil 127 | } 128 | 129 | // Verify interface 130 | var _ Source = &GiteaSource{} 131 | -------------------------------------------------------------------------------- /gitea_source_test.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestGiteaTokenEnv(t *testing.T) { 13 | token := os.Getenv("GITEA_TOKEN") 14 | if token == "" { 15 | t.Skip("because $GITEA_TOKEN is not set") 16 | } 17 | 18 | if _, err := NewGiteaSource(GiteaConfig{BaseURL: "https://git.lbsfilm.at"}); err != nil { 19 | t.Error("Failed to initialize Gitea source with URL") 20 | } 21 | if _, err := NewGiteaSource(GiteaConfig{APIToken: token}); err != nil { 22 | t.Error("Failed to initialize Gitea source with API token config") 23 | } 24 | } 25 | 26 | func TestGiteaTokenIsNotSet(t *testing.T) { 27 | t.Setenv("GITHUB_TOKEN", "") 28 | 29 | if _, err := NewGiteaSource(GiteaConfig{BaseURL: "https://git.lbsfilm.at"}); err != nil { 30 | t.Error("Failed to initialize Gitea source with URL") 31 | } 32 | } 33 | 34 | func TestGiteaListReleasesContextCancelled(t *testing.T) { 35 | source, err := NewGiteaSource(GiteaConfig{BaseURL: "https://git.lbsfilm.at"}) 36 | require.NoError(t, err) 37 | 38 | ctx, cancelFn := context.WithCancel(context.Background()) 39 | cancelFn() 40 | 41 | _, err = source.ListReleases(ctx, ParseSlug("creativeprojects/resticprofile")) 42 | assert.ErrorIs(t, err, context.Canceled) 43 | } 44 | 45 | func TestGiteaDownloadReleaseAssetContextCancelled(t *testing.T) { 46 | source, err := NewGiteaSource(GiteaConfig{BaseURL: "https://git.lbsfilm.at"}) 47 | require.NoError(t, err) 48 | 49 | ctx, cancelFn := context.WithCancel(context.Background()) 50 | cancelFn() 51 | 52 | _, err = source.DownloadReleaseAsset(ctx, &Release{repository: ParseSlug("creativeprojects/resticprofile")}, 11) 53 | assert.ErrorIs(t, err, context.Canceled) 54 | } 55 | 56 | func TestGiteaDownloadReleaseAssetWithNilRelease(t *testing.T) { 57 | source, err := NewGiteaSource(GiteaConfig{BaseURL: "https://git.lbsfilm.at"}) 58 | require.NoError(t, err) 59 | 60 | _, err = source.DownloadReleaseAsset(context.Background(), nil, 11) 61 | assert.ErrorIs(t, err, ErrInvalidRelease) 62 | } 63 | -------------------------------------------------------------------------------- /github_release.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/google/go-github/v30/github" 7 | ) 8 | 9 | type GitHubRelease struct { 10 | releaseID int64 11 | name string 12 | tagName string 13 | url string 14 | draft bool 15 | prerelease bool 16 | publishedAt time.Time 17 | releaseNotes string 18 | assets []SourceAsset 19 | } 20 | 21 | func NewGitHubRelease(from *github.RepositoryRelease) *GitHubRelease { 22 | release := &GitHubRelease{ 23 | releaseID: from.GetID(), 24 | name: from.GetName(), 25 | tagName: from.GetTagName(), 26 | url: from.GetHTMLURL(), 27 | publishedAt: from.GetPublishedAt().Time, 28 | releaseNotes: from.GetBody(), 29 | draft: from.GetDraft(), 30 | prerelease: from.GetPrerelease(), 31 | assets: make([]SourceAsset, len(from.Assets)), 32 | } 33 | for i, fromAsset := range from.Assets { 34 | release.assets[i] = NewGitHubAsset(fromAsset) 35 | } 36 | return release 37 | } 38 | 39 | func (a *GitHubRelease) GetID() int64 { 40 | return a.releaseID 41 | } 42 | 43 | func (r *GitHubRelease) GetTagName() string { 44 | return r.tagName 45 | } 46 | 47 | func (r *GitHubRelease) GetDraft() bool { 48 | return r.draft 49 | } 50 | 51 | func (r *GitHubRelease) GetPrerelease() bool { 52 | return r.prerelease 53 | } 54 | 55 | func (r *GitHubRelease) GetPublishedAt() time.Time { 56 | return r.publishedAt 57 | } 58 | 59 | func (r *GitHubRelease) GetReleaseNotes() string { 60 | return r.releaseNotes 61 | } 62 | 63 | func (r *GitHubRelease) GetName() string { 64 | return r.name 65 | } 66 | 67 | func (r *GitHubRelease) GetURL() string { 68 | return r.url 69 | } 70 | 71 | func (r *GitHubRelease) GetAssets() []SourceAsset { 72 | return r.assets 73 | } 74 | 75 | type GitHubAsset struct { 76 | id int64 77 | name string 78 | size int 79 | url string 80 | } 81 | 82 | func NewGitHubAsset(from *github.ReleaseAsset) *GitHubAsset { 83 | return &GitHubAsset{ 84 | id: from.GetID(), 85 | name: from.GetName(), 86 | size: from.GetSize(), 87 | url: from.GetBrowserDownloadURL(), 88 | } 89 | } 90 | 91 | func (a *GitHubAsset) GetID() int64 { 92 | return a.id 93 | } 94 | 95 | func (a *GitHubAsset) GetName() string { 96 | return a.name 97 | } 98 | 99 | func (a *GitHubAsset) GetSize() int { 100 | return a.size 101 | } 102 | 103 | func (a *GitHubAsset) GetBrowserDownloadURL() string { 104 | return a.url 105 | } 106 | 107 | // Verify interface 108 | var ( 109 | _ SourceRelease = &GitHubRelease{} 110 | _ SourceAsset = &GitHubAsset{} 111 | ) 112 | -------------------------------------------------------------------------------- /github_source.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "os" 9 | 10 | "github.com/google/go-github/v30/github" 11 | "golang.org/x/oauth2" 12 | ) 13 | 14 | // GitHubConfig is an object to pass to NewGitHubSource 15 | type GitHubConfig struct { 16 | // APIToken represents GitHub API token. If it's not empty, it will be used for authentication of GitHub API 17 | APIToken string 18 | // EnterpriseBaseURL is a base URL of GitHub API. If you want to use this library with GitHub Enterprise, 19 | // please set "https://{your-organization-address}/api/v3/" to this field. 20 | EnterpriseBaseURL string 21 | // EnterpriseUploadURL is a URL to upload stuffs to GitHub Enterprise instance. This is often the same as an API base URL. 22 | // So if this field is not set and EnterpriseBaseURL is set, EnterpriseBaseURL is also set to this field. 23 | EnterpriseUploadURL string 24 | // Deprecated: Context option is no longer used 25 | Context context.Context 26 | } 27 | 28 | // GitHubSource is used to load release information from GitHub 29 | type GitHubSource struct { 30 | api *github.Client 31 | } 32 | 33 | // NewGitHubSource creates a new GitHubSource from a config object. 34 | // It initializes a GitHub API client. 35 | // If you set your API token to the $GITHUB_TOKEN environment variable, the client will use it. 36 | // You can pass an empty GitHubSource{} to use the default configuration 37 | // The function will return an error if the GitHub Enterprise URLs in the config object cannot be parsed 38 | func NewGitHubSource(config GitHubConfig) (*GitHubSource, error) { 39 | token := config.APIToken 40 | if token == "" { 41 | // try the environment variable 42 | token = os.Getenv("GITHUB_TOKEN") 43 | } 44 | hc := newHTTPClient(token) 45 | 46 | if config.EnterpriseBaseURL == "" { 47 | // public (or private) repository on standard GitHub offering 48 | client := github.NewClient(hc) 49 | return &GitHubSource{ 50 | api: client, 51 | }, nil 52 | } 53 | 54 | u := config.EnterpriseUploadURL 55 | if u == "" { 56 | u = config.EnterpriseBaseURL 57 | } 58 | client, err := github.NewEnterpriseClient(config.EnterpriseBaseURL, u, hc) 59 | if err != nil { 60 | return nil, fmt.Errorf("cannot parse GitHub enterprise URL: %w", err) 61 | } 62 | return &GitHubSource{ 63 | api: client, 64 | }, nil 65 | } 66 | 67 | // ListReleases returns all available releases 68 | func (s *GitHubSource) ListReleases(ctx context.Context, repository Repository) ([]SourceRelease, error) { 69 | owner, repo, err := repository.GetSlug() 70 | if err != nil { 71 | return nil, err 72 | } 73 | rels, res, err := s.api.Repositories.ListReleases(ctx, owner, repo, nil) 74 | if err != nil { 75 | if res != nil && res.StatusCode == http.StatusNotFound { 76 | // repository not found or release not found. It's not an error here. 77 | log.Print("Repository or release not found") 78 | return nil, nil 79 | } 80 | log.Printf("API returned an error response: %s", err) 81 | return nil, err 82 | } 83 | releases := make([]SourceRelease, len(rels)) 84 | for i, rel := range rels { 85 | releases[i] = NewGitHubRelease(rel) 86 | } 87 | return releases, nil 88 | } 89 | 90 | // DownloadReleaseAsset downloads an asset from a release. 91 | // It returns an io.ReadCloser: it is your responsibility to Close it. 92 | func (s *GitHubSource) DownloadReleaseAsset(ctx context.Context, rel *Release, assetID int64) (io.ReadCloser, error) { 93 | if rel == nil { 94 | return nil, ErrInvalidRelease 95 | } 96 | owner, repo, err := rel.repository.GetSlug() 97 | if err != nil { 98 | return nil, err 99 | } 100 | // create a new http client so the GitHub library can download the redirected file (if any) 101 | client := http.DefaultClient 102 | rc, _, err := s.api.Repositories.DownloadReleaseAsset(ctx, owner, repo, assetID, client) 103 | if err != nil { 104 | return nil, fmt.Errorf("failed to call GitHub Releases API for getting the asset ID %d on repository '%s/%s': %w", assetID, owner, repo, err) 105 | } 106 | return rc, nil 107 | } 108 | 109 | func newHTTPClient(token string) *http.Client { 110 | if token == "" { 111 | return http.DefaultClient 112 | } 113 | src := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) 114 | return oauth2.NewClient(context.Background(), src) 115 | } 116 | 117 | // Verify interface 118 | var _ Source = &GitHubSource{} 119 | -------------------------------------------------------------------------------- /github_source_test.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestGitHubTokenEnv(t *testing.T) { 13 | token := os.Getenv("GITHUB_TOKEN") 14 | if token == "" { 15 | t.Skip("because $GITHUB_TOKEN is not set") 16 | } 17 | 18 | if _, err := NewGitHubSource(GitHubConfig{}); err != nil { 19 | t.Error("Failed to initialize GitHub source with empty config") 20 | } 21 | if _, err := NewGitHubSource(GitHubConfig{APIToken: token}); err != nil { 22 | t.Error("Failed to initialize GitHub source with API token config") 23 | } 24 | } 25 | 26 | func TestGitHubTokenIsNotSet(t *testing.T) { 27 | t.Setenv("GITHUB_TOKEN", "") 28 | 29 | if _, err := NewGitHubSource(GitHubConfig{}); err != nil { 30 | t.Error("Failed to initialize GitHub source with empty config") 31 | } 32 | } 33 | 34 | func TestGitHubEnterpriseClientInvalidURL(t *testing.T) { 35 | _, err := NewGitHubSource(GitHubConfig{APIToken: "my_token", EnterpriseBaseURL: ":this is not a URL"}) 36 | if err == nil { 37 | t.Fatal("Invalid URL should raise an error") 38 | } 39 | } 40 | 41 | func TestGitHubEnterpriseClientValidURL(t *testing.T) { 42 | _, err := NewGitHubSource(GitHubConfig{APIToken: "my_token", EnterpriseBaseURL: "http://localhost"}) 43 | if err != nil { 44 | t.Fatal("Failed to initialize GitHub source with valid URL") 45 | } 46 | } 47 | 48 | func TestGitHubListReleasesContextCancelled(t *testing.T) { 49 | source, err := NewGitHubSource(GitHubConfig{}) 50 | require.NoError(t, err) 51 | 52 | ctx, cancelFn := context.WithCancel(context.Background()) 53 | cancelFn() 54 | 55 | _, err = source.ListReleases(ctx, ParseSlug("creativeprojects/resticprofile")) 56 | assert.ErrorIs(t, err, context.Canceled) 57 | } 58 | 59 | func TestGitHubDownloadReleaseAssetContextCancelled(t *testing.T) { 60 | source, err := NewGitHubSource(GitHubConfig{}) 61 | require.NoError(t, err) 62 | 63 | ctx, cancelFn := context.WithCancel(context.Background()) 64 | cancelFn() 65 | 66 | _, err = source.DownloadReleaseAsset(ctx, &Release{repository: ParseSlug("creativeprojects/resticprofile")}, 11) 67 | assert.ErrorIs(t, err, context.Canceled) 68 | } 69 | 70 | func TestGitHubDownloadReleaseAssetWithNilRelease(t *testing.T) { 71 | source, err := NewGitHubSource(GitHubConfig{}) 72 | require.NoError(t, err) 73 | 74 | _, err = source.DownloadReleaseAsset(context.Background(), nil, 11) 75 | assert.ErrorIs(t, err, ErrInvalidRelease) 76 | } 77 | -------------------------------------------------------------------------------- /gitlab_release.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/xanzy/go-gitlab" 7 | ) 8 | 9 | type GitLabRelease struct { 10 | releaseID int64 11 | name string 12 | tagName string 13 | url string 14 | publishedAt time.Time 15 | description string 16 | assets []SourceAsset 17 | } 18 | 19 | func NewGitLabRelease(from *gitlab.Release) *GitLabRelease { 20 | release := &GitLabRelease{ 21 | releaseID: 0, 22 | name: from.Name, 23 | tagName: from.TagName, 24 | url: from.Commit.WebURL, 25 | publishedAt: *from.ReleasedAt, 26 | description: from.Description, 27 | assets: make([]SourceAsset, len(from.Assets.Links)), 28 | } 29 | for i, fromLink := range from.Assets.Links { 30 | release.assets[i] = NewGitLabAsset(fromLink) 31 | } 32 | return release 33 | } 34 | 35 | func (r *GitLabRelease) GetID() int64 { 36 | return 0 37 | } 38 | 39 | func (r *GitLabRelease) GetTagName() string { 40 | return r.tagName 41 | } 42 | 43 | func (r *GitLabRelease) GetDraft() bool { 44 | return false 45 | } 46 | 47 | func (r *GitLabRelease) GetPrerelease() bool { 48 | return false 49 | } 50 | 51 | func (r *GitLabRelease) GetPublishedAt() time.Time { 52 | return r.publishedAt 53 | } 54 | 55 | func (r *GitLabRelease) GetReleaseNotes() string { 56 | return r.description 57 | } 58 | 59 | func (r *GitLabRelease) GetName() string { 60 | return r.name 61 | } 62 | 63 | func (r *GitLabRelease) GetURL() string { 64 | return r.url 65 | } 66 | 67 | func (r *GitLabRelease) GetAssets() []SourceAsset { 68 | return r.assets 69 | } 70 | 71 | type GitLabAsset struct { 72 | id int64 73 | name string 74 | url string 75 | } 76 | 77 | func NewGitLabAsset(from *gitlab.ReleaseLink) *GitLabAsset { 78 | return &GitLabAsset{ 79 | id: int64(from.ID), 80 | name: from.Name, 81 | url: from.URL, 82 | } 83 | } 84 | 85 | func (a *GitLabAsset) GetID() int64 { 86 | return a.id 87 | } 88 | 89 | func (a *GitLabAsset) GetName() string { 90 | return a.name 91 | } 92 | 93 | func (a *GitLabAsset) GetSize() int { 94 | return 0 95 | } 96 | 97 | func (a *GitLabAsset) GetBrowserDownloadURL() string { 98 | return a.url 99 | } 100 | 101 | // Verify interface 102 | var ( 103 | _ SourceRelease = &GitLabRelease{} 104 | _ SourceAsset = &GitLabAsset{} 105 | ) 106 | -------------------------------------------------------------------------------- /gitlab_source.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "os" 9 | 10 | "github.com/xanzy/go-gitlab" 11 | ) 12 | 13 | // GitLabConfig is an object to pass to NewGitLabSource 14 | type GitLabConfig struct { 15 | // APIToken represents GitLab API token. If it's not empty, it will be used for authentication for the API 16 | APIToken string 17 | // BaseURL is a base URL of your private GitLab instance 18 | BaseURL string 19 | } 20 | 21 | // GitLabSource is used to load release information from GitLab 22 | type GitLabSource struct { 23 | api *gitlab.Client 24 | token string 25 | baseURL string 26 | } 27 | 28 | // NewGitLabSource creates a new GitLabSource from a config object. 29 | // It initializes a GitLab API client. 30 | // If you set your API token to the $GITLAB_TOKEN environment variable, the client will use it. 31 | // You can pass an empty GitLabSource{} to use the default configuration 32 | // The function will return an error if the GitLab Enterprise URLs in the config object cannot be parsed 33 | func NewGitLabSource(config GitLabConfig) (*GitLabSource, error) { 34 | token := config.APIToken 35 | if token == "" { 36 | // try the environment variable 37 | token = os.Getenv("GITLAB_TOKEN") 38 | } 39 | option := make([]gitlab.ClientOptionFunc, 0, 1) 40 | if config.BaseURL != "" { 41 | option = append(option, gitlab.WithBaseURL(config.BaseURL)) 42 | } 43 | client, err := gitlab.NewClient(token, option...) 44 | if err != nil { 45 | return nil, fmt.Errorf("cannot create GitLab client: %w", err) 46 | } 47 | return &GitLabSource{ 48 | api: client, 49 | token: token, 50 | baseURL: config.BaseURL, 51 | }, nil 52 | } 53 | 54 | // ListReleases returns all available releases 55 | func (s *GitLabSource) ListReleases(ctx context.Context, repository Repository) ([]SourceRelease, error) { 56 | pid, err := repository.Get() 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | rels, _, err := s.api.Releases.ListReleases(pid, nil, gitlab.WithContext(ctx)) 62 | if err != nil { 63 | return nil, fmt.Errorf("list releases: %w", err) 64 | } 65 | releases := make([]SourceRelease, len(rels)) 66 | for i, rel := range rels { 67 | releases[i] = NewGitLabRelease(rel) 68 | } 69 | return releases, nil 70 | } 71 | 72 | // DownloadReleaseAsset downloads an asset from a release. 73 | // It returns an io.ReadCloser: it is your responsibility to Close it. 74 | func (s *GitLabSource) DownloadReleaseAsset(ctx context.Context, rel *Release, assetID int64) (io.ReadCloser, error) { 75 | if rel == nil { 76 | return nil, ErrInvalidRelease 77 | } 78 | var downloadUrl string 79 | if rel.AssetID == assetID { 80 | downloadUrl = rel.AssetURL 81 | } else if rel.ValidationAssetID == assetID { 82 | downloadUrl = rel.ValidationAssetURL 83 | } 84 | if downloadUrl == "" { 85 | return nil, fmt.Errorf("asset ID %d: %w", assetID, ErrAssetNotFound) 86 | } 87 | 88 | log.Printf("downloading %q", downloadUrl) 89 | client := http.DefaultClient 90 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadUrl, http.NoBody) 91 | if err != nil { 92 | log.Print(err) 93 | return nil, err 94 | } 95 | 96 | if s.token != "" { 97 | // verify request is from same domain not to leak token 98 | ok, err := canUseTokenForDomain(s.baseURL, downloadUrl) 99 | if err != nil { 100 | return nil, err 101 | } 102 | if ok { 103 | req.Header.Set("PRIVATE-TOKEN", s.token) 104 | } 105 | } 106 | response, err := client.Do(req) 107 | 108 | if err != nil { 109 | log.Print(err) 110 | return nil, err 111 | } 112 | 113 | return response.Body, nil 114 | } 115 | 116 | // Verify interface 117 | var _ Source = &GitLabSource{} 118 | -------------------------------------------------------------------------------- /gitlab_source_test.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestGitLabTokenEnv(t *testing.T) { 13 | token := os.Getenv("GITLAB_TOKEN") 14 | if token == "" { 15 | t.Skip("because $GITLAB_TOKEN is not set") 16 | } 17 | 18 | if _, err := NewGitLabSource(GitLabConfig{}); err != nil { 19 | t.Error("Failed to initialize GitLab source with empty config") 20 | } 21 | if _, err := NewGitLabSource(GitLabConfig{APIToken: token}); err != nil { 22 | t.Error("Failed to initialize GitLab source with API token config") 23 | } 24 | } 25 | 26 | func TestGitLabTokenIsNotSet(t *testing.T) { 27 | t.Setenv("GITLAB_TOKEN", "") 28 | 29 | if _, err := NewGitLabSource(GitLabConfig{}); err != nil { 30 | t.Error("Failed to initialize GitLab source with empty config") 31 | } 32 | } 33 | 34 | func TestGitLabEnterpriseClientInvalidURL(t *testing.T) { 35 | _, err := NewGitLabSource(GitLabConfig{APIToken: "my_token", BaseURL: ":this is not a URL"}) 36 | if err == nil { 37 | t.Fatal("Invalid URL should raise an error") 38 | } 39 | } 40 | 41 | func TestGitLabEnterpriseClientValidURL(t *testing.T) { 42 | _, err := NewGitLabSource(GitLabConfig{APIToken: "my_token", BaseURL: "http://localhost"}) 43 | if err != nil { 44 | t.Fatal("Failed to initialize GitLab source with valid URL") 45 | } 46 | } 47 | 48 | func TestGitLabListReleasesContextCancelled(t *testing.T) { 49 | source, err := NewGitLabSource(GitLabConfig{}) 50 | require.NoError(t, err) 51 | 52 | ctx, cancelFn := context.WithCancel(context.Background()) 53 | cancelFn() 54 | 55 | _, err = source.ListReleases(ctx, ParseSlug("creativeprojects/resticprofile")) 56 | assert.ErrorIs(t, err, context.Canceled) 57 | } 58 | 59 | func TestGitLabDownloadReleaseAssetContextCancelled(t *testing.T) { 60 | source, err := NewGitLabSource(GitLabConfig{}) 61 | require.NoError(t, err) 62 | 63 | ctx, cancelFn := context.WithCancel(context.Background()) 64 | cancelFn() 65 | 66 | _, err = source.DownloadReleaseAsset(ctx, &Release{ 67 | AssetID: 11, 68 | AssetURL: "http://localhost/", 69 | }, 11) 70 | assert.ErrorIs(t, err, context.Canceled) 71 | } 72 | 73 | func TestGitLabDownloadReleaseAssetWithNilRelease(t *testing.T) { 74 | source, err := NewGitLabSource(GitLabConfig{}) 75 | require.NoError(t, err) 76 | 77 | _, err = source.DownloadReleaseAsset(context.Background(), nil, 11) 78 | assert.ErrorIs(t, err, ErrInvalidRelease) 79 | } 80 | 81 | func TestGitLabDownloadReleaseAssetNotFound(t *testing.T) { 82 | source, err := NewGitLabSource(GitLabConfig{}) 83 | require.NoError(t, err) 84 | 85 | _, err = source.DownloadReleaseAsset(context.Background(), &Release{ 86 | AssetID: 11, 87 | ValidationAssetID: 12, 88 | }, 13) 89 | assert.ErrorIs(t, err, ErrAssetNotFound) 90 | } 91 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/creativeprojects/go-selfupdate 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.2 6 | 7 | require ( 8 | code.gitea.io/sdk/gitea v0.21.0 9 | github.com/Masterminds/semver/v3 v3.3.1 10 | github.com/google/go-github/v30 v30.1.0 11 | github.com/stretchr/testify v1.10.0 12 | github.com/ulikunitz/xz v0.5.12 13 | github.com/xanzy/go-gitlab v0.115.0 14 | golang.org/x/crypto v0.37.0 15 | golang.org/x/oauth2 v0.29.0 16 | golang.org/x/sys v0.32.0 17 | gopkg.in/yaml.v3 v3.0.1 18 | ) 19 | 20 | require ( 21 | github.com/42wim/httpsig v1.2.2 // indirect 22 | github.com/davecgh/go-spew v1.1.1 // indirect 23 | github.com/davidmz/go-pageant v1.0.2 // indirect 24 | github.com/go-fed/httpsig v1.1.0 // indirect 25 | github.com/google/go-querystring v1.1.0 // indirect 26 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 27 | github.com/hashicorp/go-retryablehttp v0.7.7 // indirect 28 | github.com/hashicorp/go-version v1.7.0 // indirect 29 | github.com/pmezard/go-difflib v1.0.0 // indirect 30 | golang.org/x/time v0.11.0 // indirect 31 | ) 32 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | code.gitea.io/sdk/gitea v0.21.0 h1:69n6oz6kEVHRo1+APQQyizkhrZrLsTLXey9142pfkD4= 2 | code.gitea.io/sdk/gitea v0.21.0/go.mod h1:tnBjVhuKJCn8ibdyyhvUyxrR1Ca2KHEoTWoukNhXQPA= 3 | github.com/42wim/httpsig v1.2.2 h1:ofAYoHUNs/MJOLqQ8hIxeyz2QxOz8qdSVvp3PX/oPgA= 4 | github.com/42wim/httpsig v1.2.2/go.mod h1:P/UYo7ytNBFwc+dg35IubuAUIs8zj5zzFIgUCEl55WY= 5 | github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= 6 | github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= 7 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 8 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= 10 | github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= 11 | github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 12 | github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 13 | github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= 14 | github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= 15 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 16 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 17 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 18 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 19 | github.com/google/go-github/v30 v30.1.0 h1:VLDx+UolQICEOKu2m4uAoMti1SxuEBAl7RSEG16L+Oo= 20 | github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQFEufcolZ95JfU8= 21 | github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= 22 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 23 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 24 | github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 25 | github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 26 | github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= 27 | github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= 28 | github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= 29 | github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= 30 | github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= 31 | github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= 32 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 33 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 34 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 35 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 36 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 37 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 38 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 39 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 40 | github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc= 41 | github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= 42 | github.com/xanzy/go-gitlab v0.115.0 h1:6DmtItNcVe+At/liXSgfE/DZNZrGfalQmBRmOcJjOn8= 43 | github.com/xanzy/go-gitlab v0.115.0/go.mod h1:5XCDtM7AM6WMKmfDdOiEpyRWUqui2iS9ILfvCZ2gJ5M= 44 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 45 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 46 | golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= 47 | golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= 48 | golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 49 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 50 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 51 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 52 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 53 | golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= 54 | golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= 55 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 56 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 57 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 58 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 59 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 60 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 61 | golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= 62 | golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= 63 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 64 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 65 | golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= 66 | golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 67 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 68 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 69 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 70 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 71 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 72 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 73 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 74 | -------------------------------------------------------------------------------- /http_release.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Mr. Gecko's Media (James Coleman). http://mrgeckosmedia.com/ 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | package selfupdate 22 | 23 | import ( 24 | "time" 25 | ) 26 | 27 | type HttpAsset struct { 28 | ID int64 `yaml:"id"` 29 | Name string `yaml:"name"` 30 | Size int `yaml:"size"` 31 | URL string `yaml:"url"` 32 | } 33 | 34 | func (a *HttpAsset) GetID() int64 { 35 | return a.ID 36 | } 37 | 38 | func (a *HttpAsset) GetName() string { 39 | return a.Name 40 | } 41 | 42 | func (a *HttpAsset) GetSize() int { 43 | return a.Size 44 | } 45 | 46 | func (a *HttpAsset) GetBrowserDownloadURL() string { 47 | return a.URL 48 | } 49 | 50 | var _ SourceAsset = &HttpAsset{} 51 | 52 | type HttpRelease struct { 53 | ID int64 `yaml:"id"` 54 | Name string `yaml:"name"` 55 | TagName string `yaml:"tag_name"` 56 | URL string `yaml:"url"` 57 | Draft bool `yaml:"draft"` 58 | Prerelease bool `yaml:"prerelease"` 59 | PublishedAt time.Time `yaml:"published_at"` 60 | ReleaseNotes string `yaml:"release_notes"` 61 | Assets []*HttpAsset `yaml:"assets"` 62 | } 63 | 64 | func (r *HttpRelease) GetID() int64 { 65 | return r.ID 66 | } 67 | 68 | func (r *HttpRelease) GetTagName() string { 69 | return r.TagName 70 | } 71 | 72 | func (r *HttpRelease) GetDraft() bool { 73 | return r.Draft 74 | } 75 | 76 | func (r *HttpRelease) GetPrerelease() bool { 77 | return r.Prerelease 78 | } 79 | 80 | func (r *HttpRelease) GetPublishedAt() time.Time { 81 | return r.PublishedAt 82 | } 83 | 84 | func (r *HttpRelease) GetReleaseNotes() string { 85 | return r.ReleaseNotes 86 | } 87 | 88 | func (r *HttpRelease) GetName() string { 89 | return r.Name 90 | } 91 | 92 | func (r *HttpRelease) GetURL() string { 93 | return r.URL 94 | } 95 | 96 | func (r *HttpRelease) GetAssets() []SourceAsset { 97 | assets := make([]SourceAsset, len(r.Assets)) 98 | for i, asset := range r.Assets { 99 | assets[i] = asset 100 | } 101 | return assets 102 | } 103 | 104 | var _ SourceRelease = &HttpRelease{} 105 | -------------------------------------------------------------------------------- /http_source.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Mr. Gecko's Media (James Coleman). http://mrgeckosmedia.com/ 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | package selfupdate 22 | 23 | import ( 24 | "context" 25 | "fmt" 26 | "io" 27 | "net/http" 28 | "net/url" 29 | 30 | yaml "gopkg.in/yaml.v3" 31 | ) 32 | 33 | type HttpManifest struct { 34 | LastReleaseID int64 `yaml:"last_release_id"` 35 | LastAssetID int64 `yaml:"last_asset_id"` 36 | Releases []*HttpRelease `yaml:"releases"` 37 | } 38 | 39 | // HttpConfig is an object to pass to NewHttpSource 40 | type HttpConfig struct { 41 | // BaseURL is a base URL of your update server. This parameter has NO default value. 42 | BaseURL string 43 | // HTTP Transport Config 44 | Transport *http.Transport 45 | // Additional headers 46 | Headers http.Header 47 | } 48 | 49 | // HttpSource is used to load release information from an http repository 50 | type HttpSource struct { 51 | baseURL string 52 | transport *http.Transport 53 | headers http.Header 54 | } 55 | 56 | // NewHttpSource creates a new HttpSource from a config object. 57 | func NewHttpSource(config HttpConfig) (*HttpSource, error) { 58 | // Validate Base URL. 59 | if config.BaseURL == "" { 60 | return nil, fmt.Errorf("http base url must be set") 61 | } 62 | _, perr := url.ParseRequestURI(config.BaseURL) 63 | if perr != nil { 64 | return nil, perr 65 | } 66 | 67 | // Setup standard transport if not set. 68 | if config.Transport == nil { 69 | config.Transport = &http.Transport{} 70 | } 71 | 72 | // Return new source. 73 | return &HttpSource{ 74 | baseURL: config.BaseURL, 75 | transport: config.Transport, 76 | headers: config.Headers, 77 | }, nil 78 | } 79 | 80 | // Returns a full URI for a relative path URI. 81 | func (s *HttpSource) uriRelative(uri, owner, repo string) string { 82 | // If URI is blank, its blank. 83 | if uri != "" { 84 | // If we're able to parse the URI, a full URI is already defined. 85 | _, perr := url.ParseRequestURI(uri) 86 | if perr != nil { 87 | // Join the paths if possible to make a full URI. 88 | newURL, jerr := url.JoinPath(s.baseURL, owner, repo, uri) 89 | if jerr == nil { 90 | uri = newURL 91 | } 92 | } 93 | } 94 | return uri 95 | } 96 | 97 | // ListReleases returns all available releases 98 | func (s *HttpSource) ListReleases(ctx context.Context, repository Repository) ([]SourceRelease, error) { 99 | owner, repo, err := repository.GetSlug() 100 | if err != nil { 101 | return nil, err 102 | } 103 | 104 | // Make repository URI. 105 | uri, err := url.JoinPath(s.baseURL, owner, repo, "manifest.yaml") 106 | if err != nil { 107 | return nil, err 108 | } 109 | 110 | // Setup HTTP client. 111 | client := &http.Client{Transport: s.transport} 112 | 113 | // Make repository request. 114 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri, http.NoBody) 115 | if err != nil { 116 | return nil, err 117 | } 118 | 119 | // Add headers to request. 120 | req.Header = s.headers 121 | 122 | // Perform the request. 123 | res, err := client.Do(req) 124 | if err != nil { 125 | return nil, err 126 | } 127 | if res.StatusCode != http.StatusOK { 128 | res.Body.Close() 129 | return nil, fmt.Errorf("HTTP request failed with status code %d", res.StatusCode) 130 | } 131 | 132 | // Decode the response. 133 | manifest := new(HttpManifest) 134 | defer res.Body.Close() 135 | decoder := yaml.NewDecoder(res.Body) 136 | err = decoder.Decode(manifest) 137 | if err != nil { 138 | return nil, err 139 | } 140 | 141 | // Make a release array. 142 | releases := make([]SourceRelease, len(manifest.Releases)) 143 | for i, release := range manifest.Releases { 144 | // Update URLs to relative path with repository. 145 | release.URL = s.uriRelative(release.URL, owner, repo) 146 | for b, asset := range release.Assets { 147 | release.Assets[b].URL = s.uriRelative(asset.URL, owner, repo) 148 | } 149 | 150 | // Set the release. 151 | releases[i] = release 152 | } 153 | 154 | return releases, nil 155 | } 156 | 157 | // DownloadReleaseAsset downloads an asset from a release. 158 | // It returns an io.ReadCloser: it is your responsibility to Close it. 159 | func (s *HttpSource) DownloadReleaseAsset(ctx context.Context, rel *Release, assetID int64) (io.ReadCloser, error) { 160 | if rel == nil { 161 | return nil, ErrInvalidRelease 162 | } 163 | 164 | // Determine download url based on asset id. 165 | var downloadUrl string 166 | if rel.AssetID == assetID { 167 | downloadUrl = rel.AssetURL 168 | } else if rel.ValidationAssetID == assetID { 169 | downloadUrl = rel.ValidationAssetURL 170 | } 171 | if downloadUrl == "" { 172 | return nil, fmt.Errorf("asset ID %d: %w", assetID, ErrAssetNotFound) 173 | } 174 | 175 | // Setup HTTP client. 176 | client := &http.Client{Transport: s.transport} 177 | 178 | // Make request. 179 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadUrl, http.NoBody) 180 | if err != nil { 181 | return nil, err 182 | } 183 | 184 | // Add headers to request. 185 | req.Header = s.headers 186 | 187 | // Perform the request. 188 | response, err := client.Do(req) 189 | if err != nil { 190 | return nil, err 191 | } 192 | if response.StatusCode != http.StatusOK { 193 | response.Body.Close() 194 | return nil, fmt.Errorf("HTTP request failed with status code %d", response.StatusCode) 195 | } 196 | 197 | return response.Body, nil 198 | } 199 | 200 | // Verify interface 201 | var _ Source = &HttpSource{} 202 | -------------------------------------------------------------------------------- /http_source_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Mr. Gecko's Media (James Coleman). http://mrgeckosmedia.com/ 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | package selfupdate 22 | 23 | import ( 24 | "context" 25 | "crypto/sha256" 26 | "encoding/hex" 27 | "io" 28 | "net/http" 29 | "net/http/httptest" 30 | "testing" 31 | 32 | "github.com/stretchr/testify/assert" 33 | "github.com/stretchr/testify/require" 34 | ) 35 | 36 | const httpTestBaseURL = "http://localhost" 37 | 38 | // Test server for testing http repos. 39 | type HttpRepoTestServer struct { 40 | server *httptest.Server 41 | repoURL string 42 | } 43 | 44 | // Setup test server with test data. 45 | func NewHttpRepoTestServer() *HttpRepoTestServer { 46 | s := new(HttpRepoTestServer) 47 | 48 | // Setup handlers. 49 | mux := http.NewServeMux() 50 | fs := http.FileServer(http.Dir("./testdata/http_repo")) 51 | mux.Handle("/repo/creativeprojects/resticprofile/", http.StripPrefix("/repo/creativeprojects/resticprofile", fs)) 52 | 53 | // Setup server config. 54 | s.server = httptest.NewServer(mux) 55 | s.repoURL = s.server.URL + "/repo/" 56 | return s 57 | } 58 | 59 | // Stop the HTTP server. 60 | func (s *HttpRepoTestServer) Stop() { 61 | s.server.Close() 62 | } 63 | 64 | // Verify the client ignores invalid URLs. 65 | func TestHttpClientInvalidURL(t *testing.T) { 66 | _, err := NewHttpSource(HttpConfig{BaseURL: ":this is not a URL"}) 67 | assert.NotNil(t, err, "Invalid URL should raise an error") 68 | } 69 | 70 | // Verify the client accepts valid URLs. 71 | func TestHttpClientValidURL(t *testing.T) { 72 | _, err := NewHttpSource(HttpConfig{BaseURL: httpTestBaseURL}) 73 | require.NoError(t, err) 74 | } 75 | 76 | // Verify cancelled contexts actually cancels a request. 77 | func TestHttpListReleasesContextCancelled(t *testing.T) { 78 | // Make a valid HTTP source. 79 | source, err := NewHttpSource(HttpConfig{BaseURL: httpTestBaseURL}) 80 | require.NoError(t, err) 81 | 82 | // Create a cancelled context. 83 | ctx, cancelFn := context.WithCancel(context.Background()) 84 | cancelFn() 85 | 86 | // Attempt to list releases and verify result. 87 | _, err = source.ListReleases(ctx, ParseSlug("creativeprojects/resticprofile")) 88 | assert.ErrorIs(t, err, context.Canceled) 89 | } 90 | 91 | // Verify cancelled contexts actually cancels a download. 92 | func TestHttpDownloadReleaseAssetContextCancelled(t *testing.T) { 93 | // Make a valid HTTP source. 94 | source, err := NewHttpSource(HttpConfig{BaseURL: httpTestBaseURL}) 95 | require.NoError(t, err) 96 | 97 | // Create a cancelled context. 98 | ctx, cancelFn := context.WithCancel(context.Background()) 99 | cancelFn() 100 | 101 | // Attempt to download release and verify result. 102 | _, err = source.DownloadReleaseAsset(ctx, &Release{ 103 | AssetID: 11, 104 | AssetURL: httpTestBaseURL, 105 | }, 11) 106 | assert.ErrorIs(t, err, context.Canceled) 107 | } 108 | 109 | // Verify no release actually returns an error. 110 | func TestHttpDownloadReleaseAssetWithNilRelease(t *testing.T) { 111 | // Create valid HTTP source. 112 | source, err := NewHttpSource(HttpConfig{BaseURL: httpTestBaseURL}) 113 | require.NoError(t, err) 114 | 115 | // Attempt to download release without specifying the release and verify result. 116 | _, err = source.DownloadReleaseAsset(context.Background(), nil, 11) 117 | assert.ErrorIs(t, err, ErrInvalidRelease) 118 | } 119 | 120 | // Verify we're able to list releases and download an asset. 121 | func TestHttpListAndDownloadReleaseAsset(t *testing.T) { 122 | // Create test HTTP server and start it. 123 | server := NewHttpRepoTestServer() 124 | 125 | // Make HTTP source with our test server. 126 | source, err := NewHttpSource(HttpConfig{BaseURL: server.repoURL}) 127 | require.NoError(t, err) 128 | 129 | // List releases 130 | releases, err := source.ListReleases(context.Background(), ParseSlug("creativeprojects/resticprofile")) 131 | require.NoError(t, err) 132 | 133 | // Confirm the manifest parsed the correct number of releases. 134 | assert.Equal(t, len(releases), 2, "releases count is not valid") 135 | 136 | // Confirm the manifest parsed by the first release is valid. 137 | assert.Equal(t, releases[0].GetTagName(), "v0.1.1", "release is not as expected") 138 | 139 | // Confirm the release assets are parsed correctly. 140 | assets := releases[1].GetAssets() 141 | assert.Equal(t, assets[1].GetName(), "example_linux_amd64.tar.gz", "the release asset is not valid") 142 | 143 | // Get updater with source. 144 | updater, err := NewUpdater(Config{ 145 | Source: source, 146 | OS: "linux", 147 | Arch: "amd64", 148 | }) 149 | require.NoError(t, err) 150 | 151 | // Find the latest release. 152 | release, found, err := updater.DetectLatest(context.Background(), NewRepositorySlug("creativeprojects", "resticprofile")) 153 | require.NoError(t, err) 154 | assert.Equal(t, found, true, "no release found") 155 | 156 | // Download asset. 157 | body, err := source.DownloadReleaseAsset(context.Background(), release, 5) 158 | require.NoError(t, err) 159 | 160 | // Read data. 161 | data, err := io.ReadAll(body) 162 | require.NoError(t, err) 163 | 164 | // Verify data. 165 | hfun := sha256.New() 166 | hfun.Write(data) 167 | sum := hfun.Sum(nil) 168 | hash := hex.EncodeToString(sum) 169 | assert.Equal(t, hash, "9208c58af1265438c6894499847355bd5e77f93d04b201393baf41297d4680a3", "hash isn't valid for test file") 170 | 171 | // Stop as we're done. 172 | server.Stop() 173 | } 174 | -------------------------------------------------------------------------------- /internal/path.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | // GetExecutablePath returns the path of the executable file with all symlinks resolved. 8 | func GetExecutablePath() (string, error) { 9 | exe, err := os.Executable() 10 | if err != nil { 11 | return "", err 12 | } 13 | 14 | exe, err = ResolvePath(exe) 15 | if err != nil { 16 | return "", err 17 | } 18 | 19 | return exe, nil 20 | } 21 | -------------------------------------------------------------------------------- /internal/path_test.go: -------------------------------------------------------------------------------- 1 | package internal_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/creativeprojects/go-selfupdate/internal" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestGetExecutablePath(t *testing.T) { 11 | t.Parallel() 12 | 13 | exe, err := internal.GetExecutablePath() 14 | assert.NoError(t, err) 15 | assert.NotEmpty(t, exe) 16 | } 17 | -------------------------------------------------------------------------------- /internal/resolve_path_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package internal 4 | 5 | import ( 6 | "path/filepath" 7 | ) 8 | 9 | // ResolvePath returns the path of a given filename with all symlinks resolved. 10 | func ResolvePath(filename string) (string, error) { 11 | finalPath, err := filepath.EvalSymlinks(filename) 12 | if err != nil { 13 | return "", err 14 | } 15 | 16 | return finalPath, nil 17 | } 18 | -------------------------------------------------------------------------------- /internal/resolve_path_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package internal 4 | 5 | import ( 6 | "golang.org/x/sys/windows" 7 | "os" 8 | "strings" 9 | "syscall" 10 | ) 11 | 12 | // ResolvePath returns the path of a given filename with all symlinks resolved. 13 | func ResolvePath(filename string) (string, error) { 14 | f, err := os.Open(filename) 15 | if err != nil { 16 | return "", err 17 | } 18 | defer f.Close() 19 | 20 | // Get the Windows handle 21 | handle := windows.Handle(f.Fd()) 22 | 23 | // Probe call to determine the needed buffer size 24 | bufSize, err := windows.GetFinalPathNameByHandle(handle, nil, 0, 0) 25 | if err != nil { 26 | return "", err 27 | } 28 | 29 | buf := make([]uint16, bufSize) 30 | n, err := windows.GetFinalPathNameByHandle(handle, &buf[0], uint32(len(buf)), 0) 31 | if err != nil { 32 | return "", err 33 | } 34 | 35 | // Convert the buffer to a string 36 | final := syscall.UTF16ToString(buf[:n]) 37 | 38 | // Strip possible "\\?\" prefix 39 | final = strings.TrimPrefix(final, `\\?\`) 40 | 41 | return final, nil 42 | } 43 | -------------------------------------------------------------------------------- /log.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | var log Logger = &emptyLogger{} 4 | 5 | // SetLogger redirects all logs to the logger defined in parameter. 6 | // By default logs are not sent anywhere. 7 | func SetLogger(logger Logger) { 8 | log = logger 9 | } 10 | 11 | // Logger interface. Compatible with standard log.Logger 12 | type Logger interface { 13 | // Print calls Output to print to the standard logger. Arguments are handled in the manner of fmt.Print. 14 | Print(v ...interface{}) 15 | // Printf calls Output to print to the standard logger. Arguments are handled in the manner of fmt.Printf. 16 | Printf(format string, v ...interface{}) 17 | } 18 | 19 | // emptyLogger to discard all logs by default 20 | type emptyLogger struct{} 21 | 22 | func (l *emptyLogger) Print(v ...interface{}) {} 23 | func (l *emptyLogger) Printf(format string, v ...interface{}) {} 24 | -------------------------------------------------------------------------------- /log_test.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | stdlog "log" 5 | "os" 6 | ) 7 | 8 | func ExampleSetLogger() { 9 | // you can plug-in any logger providing the 2 methods Print and Printf 10 | // the default log.Logger satisfies the interface 11 | logger := stdlog.New(os.Stdout, "selfupdate ", 0) 12 | SetLogger(logger) 13 | } 14 | -------------------------------------------------------------------------------- /mockdata_test.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "bytes" 5 | "crypto/sha256" 6 | "fmt" 7 | "os" 8 | "testing" 9 | "time" 10 | 11 | "github.com/stretchr/testify/require" 12 | "golang.org/x/crypto/openpgp" 13 | ) 14 | 15 | // mockSourceRepository creates a new *MockSource pre-populated with different versions and assets 16 | func mockSourceRepository(t *testing.T) *MockSource { 17 | 18 | gzData, err := os.ReadFile("testdata/new_version.tar.gz") 19 | require.NoError(t, err) 20 | 21 | zipData, err := os.ReadFile("testdata/new_version.zip") 22 | require.NoError(t, err) 23 | 24 | releases := []SourceRelease{ 25 | &GitHubRelease{ 26 | name: "v0.1.0", 27 | tagName: "v0.1.0", 28 | url: "v0.1.0", 29 | prerelease: true, 30 | publishedAt: time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC), 31 | releaseNotes: "first stable", 32 | assets: []SourceAsset{ 33 | &GitHubAsset{ 34 | id: 1, 35 | name: "resticprofile_0.1.0_linux_amd64.tar.gz", 36 | url: "resticprofile_0.1.0_linux_amd64.tar.gz", 37 | size: len(gzData), 38 | }, 39 | &GitHubAsset{ 40 | id: 2, 41 | name: "resticprofile_0.1.0_darwin_amd64.tar.gz", 42 | url: "resticprofile_0.1.0_darwin_amd64.tar.gz", 43 | size: len(gzData), 44 | }, 45 | &GitHubAsset{ 46 | id: 3, 47 | name: "resticprofile_0.1.0_windows_amd64.zip", 48 | url: "resticprofile_0.1.0_windows_amd64.zip", 49 | size: len(zipData), 50 | }, 51 | &GitHubAsset{ 52 | id: 31, 53 | name: "resticprofile_0.1.0_darwin_arm64.tar.gz", 54 | url: "resticprofile_0.1.0_darwin_arm64.tar.gz", 55 | size: len(gzData), 56 | }, 57 | }, 58 | }, 59 | &GitHubRelease{ 60 | name: "v0.14.0", 61 | tagName: "v0.14.0", 62 | url: "v0.14.0", 63 | prerelease: false, 64 | publishedAt: time.Date(2010, 1, 1, 0, 0, 0, 0, time.UTC), 65 | releaseNotes: "latest stable", 66 | assets: []SourceAsset{ 67 | &GitHubAsset{ 68 | id: 4, 69 | name: "resticprofile_0.14.0_linux_amd64.tar.gz", 70 | url: "resticprofile_0.14.0_linux_amd64.tar.gz", 71 | size: len(gzData), 72 | }, 73 | &GitHubAsset{ 74 | id: 5, 75 | name: "resticprofile_0.14.0_darwin_amd64.tar.gz", 76 | url: "resticprofile_0.14.0_darwin_amd64.tar.gz", 77 | size: len(gzData), 78 | }, 79 | &GitHubAsset{ 80 | id: 6, 81 | name: "resticprofile_0.14.0_windows_amd64.zip", 82 | url: "resticprofile_0.14.0_windows_amd64.zip", 83 | size: len(zipData), 84 | }, 85 | &GitHubAsset{ 86 | id: 32, 87 | name: "resticprofile_0.14.0_darwin_arm64.tar.gz", 88 | url: "resticprofile_0.14.0_darwin_arm64.tar.gz", 89 | size: len(gzData), 90 | }, 91 | }, 92 | }, 93 | &GitHubRelease{ 94 | name: "v1.0.0-rc", 95 | tagName: "v1.0.0-rc", 96 | url: "v1.0.0-rc", 97 | prerelease: false, 98 | publishedAt: time.Date(2011, 1, 1, 0, 0, 0, 0, time.UTC), 99 | releaseNotes: "release candidate", 100 | assets: []SourceAsset{ 101 | &GitHubAsset{ 102 | id: 11, 103 | name: "resticprofile_1.0.0-rc_linux_amd64.tar.gz", 104 | url: "resticprofile_1.0.0-rc_linux_amd64.tar.gz", 105 | size: len(gzData), 106 | }, 107 | &GitHubAsset{ 108 | id: 12, 109 | name: "resticprofile_1.0.0-rc_darwin_amd64.tar.gz", 110 | url: "resticprofile_1.0.0-rc_darwin_amd64.tar.gz", 111 | size: len(gzData), 112 | }, 113 | &GitHubAsset{ 114 | id: 13, 115 | name: "resticprofile_1.0.0-rc_windows_amd64.zip", 116 | url: "resticprofile_1.0.0-rc_windows_amd64.zip", 117 | size: len(zipData), 118 | }, 119 | &GitHubAsset{ 120 | id: 33, 121 | name: "resticprofile_1.0.0-rc_darwin_arm64.tar.gz", 122 | url: "resticprofile_1.0.0-rc_darwin_arm64.tar.gz", 123 | size: len(gzData), 124 | }, 125 | }, 126 | }, 127 | &GitHubRelease{ 128 | name: "v1.0.0", 129 | tagName: "v1.0.0", 130 | url: "v1.0.0", 131 | prerelease: false, 132 | publishedAt: time.Date(2011, 2, 1, 0, 0, 0, 0, time.UTC), 133 | releaseNotes: "final v1", 134 | assets: []SourceAsset{ 135 | &GitHubAsset{ 136 | id: 14, 137 | name: "resticprofile_1.0.0_linux_amd64.tar.gz", 138 | url: "resticprofile_1.0.0_linux_amd64.tar.gz", 139 | size: len(gzData), 140 | }, 141 | &GitHubAsset{ 142 | id: 15, 143 | name: "resticprofile_1.0.0_darwin_amd64.tar.gz", 144 | url: "resticprofile_1.0.0_darwin_amd64.tar.gz", 145 | size: len(gzData), 146 | }, 147 | &GitHubAsset{ 148 | id: 16, 149 | name: "resticprofile_1.0.0_windows_amd64.zip", 150 | url: "resticprofile_1.0.0_windows_amd64.zip", 151 | size: len(zipData), 152 | }, 153 | &GitHubAsset{ 154 | id: 34, 155 | name: "resticprofile_1.0.0_darwin_arm64.tar.gz", 156 | url: "resticprofile_1.0.0_darwin_arm64.tar.gz", 157 | size: len(gzData), 158 | }, 159 | }, 160 | }, 161 | &GitHubRelease{ 162 | name: "v2.0.0-beta", 163 | tagName: "v2.0.0-beta", 164 | url: "v2.0.0-beta", 165 | prerelease: true, 166 | publishedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), 167 | releaseNotes: "beta", 168 | assets: []SourceAsset{ 169 | &GitHubAsset{ 170 | id: 21, 171 | name: "resticprofile_2.0.0-beta_linux_amd64.tar.gz", 172 | url: "resticprofile_2.0.0-beta_linux_amd64.tar.gz", 173 | size: len(gzData), 174 | }, 175 | &GitHubAsset{ 176 | id: 22, 177 | name: "resticprofile_2.0.0-beta_darwin_amd64.tar.gz", 178 | url: "resticprofile_2.0.0-beta_darwin_amd64.tar.gz", 179 | size: len(gzData), 180 | }, 181 | &GitHubAsset{ 182 | id: 23, 183 | name: "resticprofile_2.0.0-beta_windows_amd64.zip", 184 | url: "resticprofile_2.0.0-beta_windows_amd64.zip", 185 | size: len(zipData), 186 | }, 187 | &GitHubAsset{ 188 | id: 35, 189 | name: "resticprofile_2.0.0-beta_darwin_arm64.tar.gz", 190 | url: "resticprofile_2.0.0-beta_darwin_arm64.tar.gz", 191 | size: len(gzData), 192 | }, 193 | }, 194 | }, 195 | &GitHubRelease{ 196 | name: "v2.0.0", 197 | tagName: "v2.0.0", 198 | url: "v2.0.0", 199 | draft: true, 200 | publishedAt: time.Date(2020, 2, 1, 0, 0, 0, 0, time.UTC), 201 | releaseNotes: "almost there", 202 | assets: []SourceAsset{ 203 | &GitHubAsset{ 204 | id: 24, 205 | name: "resticprofile_2.0.0_linux_amd64.tar.gz", 206 | url: "resticprofile_2.0.0_linux_amd64.tar.gz", 207 | size: len(gzData), 208 | }, 209 | &GitHubAsset{ 210 | id: 25, 211 | name: "resticprofile_2.0.0_darwin_amd64.tar.gz", 212 | url: "resticprofile_2.0.0_darwin_amd64.tar.gz", 213 | size: len(gzData), 214 | }, 215 | &GitHubAsset{ 216 | id: 26, 217 | name: "resticprofile_2.0.0_windows_amd64.zip", 218 | url: "resticprofile_2.0.0_windows_amd64.zip", 219 | size: len(zipData), 220 | }, 221 | &GitHubAsset{ 222 | id: 36, 223 | name: "resticprofile_2.0.0_darwin_arm64.tar.gz", 224 | url: "resticprofile_2.0.0_darwin_arm64.tar.gz", 225 | size: len(gzData), 226 | }, 227 | }, 228 | }, 229 | } 230 | 231 | files := map[int64][]byte{ 232 | 1: gzData, 233 | 2: gzData, 234 | 3: zipData, 235 | 4: gzData, 236 | 5: gzData, 237 | 6: zipData, 238 | 11: gzData, 239 | 12: gzData, 240 | 13: zipData, 241 | 14: gzData, 242 | 15: gzData, 243 | 16: zipData, 244 | 21: gzData, 245 | 22: gzData, 246 | 23: zipData, 247 | 24: gzData, 248 | 25: gzData, 249 | 26: zipData, 250 | 31: gzData, 251 | 32: gzData, 252 | 33: gzData, 253 | 34: gzData, 254 | 35: gzData, 255 | 36: gzData, 256 | } 257 | 258 | // generates checksum files automatically 259 | for i, release := range releases { 260 | rel := release.(*GitHubRelease) 261 | checksums := &bytes.Buffer{} 262 | for _, asset := range rel.assets { 263 | file, ok := files[asset.GetID()] 264 | if !ok { 265 | t.Errorf("file ID %d not found", asset.GetID()) 266 | } 267 | hash := sha256.Sum256(file) 268 | checksums.WriteString(fmt.Sprintf("%x %s\n", hash, asset.GetName())) 269 | } 270 | id := int64(i*10 + 101) 271 | rel.assets = append(rel.assets, &GitHubAsset{ 272 | id: id, 273 | name: "checksums.txt", 274 | }) 275 | files[id] = checksums.Bytes() 276 | // t.Logf("file id %d contains checksums:\n%s\n", id, string(files[id])) 277 | } 278 | 279 | return NewMockSource(releases, files) 280 | } 281 | 282 | // mockPGPSourceRepository creates a variant of mockSourceRepository where "checksums.txt" is signed with PGP 283 | func mockPGPSourceRepository(t *testing.T) (source *MockSource, PGPKeyRing []byte) { 284 | 285 | source = mockSourceRepository(t) 286 | 287 | var err error 288 | 289 | var entity *openpgp.Entity 290 | PGPKeyRing, entity = getTestPGPKeyRing(t) 291 | 292 | for i, release := range source.releases { 293 | rel := release.(*GitHubRelease) 294 | 295 | id := int64(i*10 + 101) 296 | signatureId := id + 1 297 | shaSums := source.files[id] 298 | 299 | // Create SHA256SUMS.asc (by signing SHA256SUMS) 300 | signature := &bytes.Buffer{} 301 | err = openpgp.ArmoredDetachSign(signature, entity, bytes.NewReader(shaSums), nil) 302 | require.NoError(t, err) 303 | 304 | rel.assets = append(rel.assets, &GitHubAsset{ 305 | id: signatureId, 306 | name: "checksums.txt.asc", 307 | }) 308 | source.files[signatureId] = signature.Bytes() 309 | 310 | t.Logf("file id %d contains PGP signature:\n%s\n", signatureId, string(source.files[signatureId])) 311 | } 312 | 313 | return 314 | } 315 | -------------------------------------------------------------------------------- /package.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | ) 9 | 10 | // DetectLatest detects the latest release from the repository. 11 | // This function is a shortcut version of updater.DetectLatest with the DefaultUpdater. 12 | func DetectLatest(ctx context.Context, repository Repository) (*Release, bool, error) { 13 | //nolint:contextcheck 14 | return DefaultUpdater().DetectLatest(ctx, repository) 15 | } 16 | 17 | // DetectVersion detects the given release from the repository. 18 | func DetectVersion(ctx context.Context, repository Repository, version string) (*Release, bool, error) { 19 | //nolint:contextcheck 20 | return DefaultUpdater().DetectVersion(ctx, repository, version) 21 | } 22 | 23 | // UpdateTo downloads an executable from assetURL and replaces the current binary with the downloaded one. 24 | // This function is low-level API to update the binary. Because it does not use a source provider and downloads asset directly from the URL via HTTP, 25 | // this function is not available to update a release for private repositories. 26 | // cmdPath is a file path to command executable. 27 | func UpdateTo(ctx context.Context, assetURL, assetFileName, cmdPath string) error { 28 | //nolint:contextcheck 29 | up := DefaultUpdater() 30 | src, err := downloadReleaseAssetFromURL(ctx, assetURL) 31 | if err != nil { 32 | return err 33 | } 34 | defer src.Close() 35 | return up.decompressAndUpdate(src, assetFileName, assetURL, cmdPath) 36 | } 37 | 38 | // UpdateCommand updates a given command binary to the latest version. 39 | // This function is a shortcut version of updater.UpdateCommand using a DefaultUpdater() 40 | func UpdateCommand(ctx context.Context, cmdPath string, current string, repository Repository) (*Release, error) { 41 | //nolint:contextcheck 42 | return DefaultUpdater().UpdateCommand(ctx, cmdPath, current, repository) 43 | } 44 | 45 | // UpdateSelf updates the running executable itself to the latest version. 46 | // This function is a shortcut version of updater.UpdateSelf using a DefaultUpdater() 47 | func UpdateSelf(ctx context.Context, current string, repository Repository) (*Release, error) { 48 | //nolint:contextcheck 49 | return DefaultUpdater().UpdateSelf(ctx, current, repository) 50 | } 51 | 52 | func downloadReleaseAssetFromURL(ctx context.Context, url string) (rc io.ReadCloser, err error) { 53 | client := http.DefaultClient 54 | req, err := http.NewRequest(http.MethodGet, url, nil) 55 | if err != nil { 56 | return nil, err 57 | } 58 | req = req.WithContext(ctx) 59 | req.Header.Set("Accept", "*/*") 60 | resp, err := client.Do(req) 61 | if err != nil { 62 | return nil, fmt.Errorf("failed to download a release file from %s: %w", url, err) 63 | } 64 | if resp.StatusCode >= 300 { 65 | resp.Body.Close() 66 | return nil, fmt.Errorf("failed to download a release file from %s: HTTP %d", url, resp.StatusCode) 67 | } 68 | return resp.Body, nil 69 | } 70 | -------------------------------------------------------------------------------- /path.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import "github.com/creativeprojects/go-selfupdate/internal" 4 | 5 | func ExecutablePath() (string, error) { 6 | return internal.GetExecutablePath() 7 | } 8 | -------------------------------------------------------------------------------- /path_test.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestExecutablePath(t *testing.T) { 10 | t.Parallel() 11 | 12 | exe, err := ExecutablePath() 13 | assert.NoError(t, err) 14 | assert.NotEmpty(t, exe) 15 | } 16 | -------------------------------------------------------------------------------- /reader_test.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | ) 7 | 8 | var ( 9 | errTestRead = errors.New("read error") 10 | ) 11 | 12 | // errorReader is a reader that will throw an error after reading n characters 13 | type errorReader struct { 14 | r io.Reader 15 | failAfter int 16 | } 17 | 18 | // newErrorReader creates a new reader that will thrown an error after reading n characters 19 | func newErrorReader(r io.Reader, failAfterBytes int) *errorReader { 20 | return &errorReader{ 21 | r: r, 22 | failAfter: failAfterBytes, 23 | } 24 | } 25 | 26 | // Read will throw an error after reading n characters 27 | func (r *errorReader) Read(p []byte) (int, error) { 28 | if len(p) <= r.failAfter { 29 | return r.Read(p) 30 | } 31 | read, _ := r.r.Read(p[0 : r.failAfter-1]) 32 | return read, errTestRead 33 | } 34 | 35 | // Verify interface 36 | var _ io.Reader = &errorReader{} 37 | -------------------------------------------------------------------------------- /release.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/Masterminds/semver/v3" 7 | ) 8 | 9 | // Release represents a release asset for current OS and arch. 10 | type Release struct { 11 | // AssetURL is a URL to the uploaded file for the release 12 | AssetURL string 13 | // AssetSize represents the size of asset in bytes 14 | AssetByteSize int 15 | // AssetID is the ID of the asset on the source platform 16 | AssetID int64 17 | // ReleaseID is the ID of the release on the source platform 18 | ReleaseID int64 19 | // AssetName is the filename of the asset 20 | AssetName string 21 | // ValidationAssetID is the ID of additional validation asset on the source platform 22 | ValidationAssetID int64 23 | // ValidationAssetURL is the URL of additional validation asset on the source platform 24 | ValidationAssetURL string 25 | // ValidationChain is the list of validation assets being used (first record is ValidationAssetID). 26 | ValidationChain []struct { 27 | // ValidationAssetID is the ID of additional validation asset on the source platform 28 | ValidationAssetID int64 29 | // ValidationAssetURL is the filename of additional validation asset on the source platform 30 | ValidationAssetName string 31 | // ValidationAssetURL is the URL of additional validation asset on the source platform 32 | ValidationAssetURL string 33 | } 34 | // URL is a URL to release page for browsing 35 | URL string 36 | // ReleaseNotes is a release notes of the release 37 | ReleaseNotes string 38 | // Name represents a name of the release 39 | Name string 40 | // PublishedAt is the time when the release was published 41 | PublishedAt time.Time 42 | // OS this release is for 43 | OS string 44 | // Arch this release is for 45 | Arch string 46 | // Arm 32bits version (if any). Valid values are 0 (unknown), 5, 6 or 7 47 | Arm uint8 48 | // Prerelease is set to true for alpha, beta or release candidates 49 | Prerelease bool 50 | // version is the parsed *semver.Version 51 | version *semver.Version 52 | repository Repository 53 | } 54 | 55 | // Version is the version string of the release 56 | func (r Release) Version() string { 57 | return r.version.String() 58 | } 59 | 60 | // Give access to some of the method of the internal semver 61 | // so we can change the version without breaking compatibility 62 | 63 | // Equal tests if two versions are equal to each other. 64 | func (r Release) Equal(other string) bool { 65 | return r.version.Equal(semver.MustParse(other)) 66 | } 67 | 68 | // LessThan tests if one version is less than another one. 69 | func (r Release) LessThan(other string) bool { 70 | return r.version.LessThan(semver.MustParse(other)) 71 | } 72 | 73 | // GreaterThan tests if one version is greater than another one. 74 | func (r Release) GreaterThan(other string) bool { 75 | return r.version.GreaterThan(semver.MustParse(other)) 76 | } 77 | 78 | // LessOrEqual tests if one version is less than or equal to another one. 79 | func (r Release) LessOrEqual(other string) bool { 80 | return r.version.Compare(semver.MustParse(other)) <= 0 81 | } 82 | 83 | // GreaterOrEqual tests if one version is greater than or equal to another one. 84 | func (r Release) GreaterOrEqual(other string) bool { 85 | return r.version.Compare(semver.MustParse(other)) >= 0 86 | } 87 | -------------------------------------------------------------------------------- /release_test.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/Masterminds/semver/v3" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestReleaseLessThan(t *testing.T) { 11 | testData := []struct { 12 | current string 13 | other string 14 | lessThan bool 15 | }{ 16 | {"1.0", "1.0.0", false}, 17 | {"1.0", "1.0.1", true}, 18 | } 19 | for _, testItem := range testData { 20 | release := Release{ 21 | version: semver.MustParse(testItem.current), 22 | } 23 | assert.Equal(t, testItem.lessThan, release.LessThan(testItem.other)) 24 | } 25 | } 26 | 27 | func TestReleaseGreaterThan(t *testing.T) { 28 | testData := []struct { 29 | current string 30 | other string 31 | greaterThan bool 32 | }{ 33 | {"1.0", "1.0.0", false}, 34 | {"1.0", "0.9", true}, 35 | } 36 | for _, testItem := range testData { 37 | release := Release{ 38 | version: semver.MustParse(testItem.current), 39 | } 40 | assert.Equal(t, testItem.greaterThan, release.GreaterThan(testItem.other)) 41 | } 42 | } 43 | 44 | func TestReleaseLessOrEqual(t *testing.T) { 45 | testData := []struct { 46 | current string 47 | other string 48 | lessOrEqual bool 49 | }{ 50 | {"1.0", "1.0.0", true}, 51 | {"1.0", "1.0.1", true}, 52 | {"1.0", "0.9", false}, 53 | {"1.0", "1.0.0-beta", false}, 54 | } 55 | for _, testItem := range testData { 56 | release := Release{ 57 | version: semver.MustParse(testItem.current), 58 | } 59 | assert.Equal(t, testItem.lessOrEqual, release.LessOrEqual(testItem.other)) 60 | } 61 | } 62 | 63 | func TestReleasePointerGreaterOrEqual(t *testing.T) { 64 | testData := []struct { 65 | current string 66 | other string 67 | greaterOrEqual bool 68 | }{ 69 | {"1.0", "1.0.0", true}, 70 | {"1.0", "0.9", true}, 71 | {"1.0", "1.0.0-beta", true}, 72 | } 73 | for _, testItem := range testData { 74 | release := &Release{ 75 | version: semver.MustParse(testItem.current), 76 | } 77 | assert.Equal(t, testItem.greaterOrEqual, release.GreaterOrEqual(testItem.other)) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /repository.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | type Repository interface { 4 | GetSlug() (string, string, error) 5 | Get() (interface{}, error) 6 | } 7 | -------------------------------------------------------------------------------- /repository_id.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | type RepositoryID int 4 | 5 | // Repository interface 6 | var _ Repository = RepositoryID(0) 7 | 8 | // NewRepositoryID creates a repository ID from an integer 9 | func NewRepositoryID(id int) RepositoryID { 10 | return RepositoryID(id) 11 | } 12 | 13 | func (r RepositoryID) GetSlug() (string, string, error) { 14 | return "", "", ErrInvalidID 15 | } 16 | 17 | func (r RepositoryID) Get() (interface{}, error) { 18 | return int(r), nil 19 | } 20 | -------------------------------------------------------------------------------- /repository_id_test.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestRepositoryID(t *testing.T) { 10 | id := NewRepositoryID(11) 11 | 12 | repo, err := id.Get() 13 | assert.NoError(t, err) 14 | assert.Equal(t, 11, repo) 15 | 16 | _, _, err = id.GetSlug() 17 | assert.Error(t, err) 18 | } 19 | -------------------------------------------------------------------------------- /repository_slug.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | type RepositorySlug struct { 8 | owner string 9 | repo string 10 | } 11 | 12 | // Repository interface 13 | var _ Repository = RepositorySlug{} 14 | 15 | // ParseSlug is used to take a string "owner/repo" to make a RepositorySlug 16 | func ParseSlug(slug string) RepositorySlug { 17 | var owner, repo string 18 | couple := strings.Split(slug, "/") 19 | if len(couple) != 2 { 20 | // give it another try 21 | couple = strings.Split(slug, "%2F") 22 | } 23 | if len(couple) == 2 { 24 | owner = couple[0] 25 | repo = couple[1] 26 | } 27 | return RepositorySlug{ 28 | owner: owner, 29 | repo: repo, 30 | } 31 | } 32 | 33 | // NewRepositorySlug creates a RepositorySlug from owner and repo parameters 34 | func NewRepositorySlug(owner, repo string) RepositorySlug { 35 | return RepositorySlug{ 36 | owner: owner, 37 | repo: repo, 38 | } 39 | } 40 | 41 | func (r RepositorySlug) GetSlug() (string, string, error) { 42 | if r.owner == "" && r.repo == "" { 43 | return "", "", ErrInvalidSlug 44 | } 45 | if r.owner == "" { 46 | return r.owner, r.repo, ErrIncorrectParameterOwner 47 | } 48 | if r.repo == "" { 49 | return r.owner, r.repo, ErrIncorrectParameterRepo 50 | } 51 | return r.owner, r.repo, nil 52 | } 53 | 54 | func (r RepositorySlug) Get() (interface{}, error) { 55 | _, _, err := r.GetSlug() 56 | if err != nil { 57 | return "", err 58 | } 59 | return r.owner + "/" + r.repo, nil 60 | } 61 | -------------------------------------------------------------------------------- /repository_slug_test.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestInvalidSlug(t *testing.T) { 10 | for _, slug := range []string{ 11 | "foo", 12 | "/", 13 | "foo/", 14 | "/bar", 15 | "foo/bar/piyo", 16 | } { 17 | t.Run(slug, func(t *testing.T) { 18 | repo := ParseSlug(slug) 19 | 20 | _, _, err := repo.GetSlug() 21 | assert.Error(t, err) 22 | 23 | _, err = repo.Get() 24 | assert.Error(t, err) 25 | }) 26 | } 27 | } 28 | 29 | func TestParseSlug(t *testing.T) { 30 | slug := ParseSlug("foo/bar") 31 | 32 | owner, repo, err := slug.GetSlug() 33 | assert.NoError(t, err) 34 | assert.Equal(t, "foo", owner) 35 | assert.Equal(t, "bar", repo) 36 | 37 | name, err := slug.Get() 38 | assert.NoError(t, err) 39 | assert.Equal(t, "foo/bar", name) 40 | } 41 | 42 | func TestNewRepositorySlug(t *testing.T) { 43 | slug := NewRepositorySlug("foo", "bar") 44 | 45 | owner, repo, err := slug.GetSlug() 46 | assert.NoError(t, err) 47 | assert.Equal(t, "foo", owner) 48 | assert.Equal(t, "bar", repo) 49 | 50 | name, err := slug.Get() 51 | assert.NoError(t, err) 52 | assert.Equal(t, "foo/bar", name) 53 | } 54 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.organization=creativeprojects 2 | sonar.projectKey=creativeprojects_go-selfupdate 3 | sonar.projectName=go-selfupdate 4 | sonar.projectVersion=1.4.1 5 | 6 | sonar.sources=. 7 | sonar.exclusions=**/*_test.go,/docs/** 8 | 9 | sonar.tests=. 10 | sonar.test.inclusions=**/*_test.go 11 | sonar.go.coverage.reportPaths=**/coverage.txt 12 | -------------------------------------------------------------------------------- /source.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "time" 7 | ) 8 | 9 | // Source interface to load the releases from (GitHubSource for example) 10 | type Source interface { 11 | ListReleases(ctx context.Context, repository Repository) ([]SourceRelease, error) 12 | DownloadReleaseAsset(ctx context.Context, rel *Release, assetID int64) (io.ReadCloser, error) 13 | } 14 | 15 | type SourceRelease interface { 16 | GetID() int64 17 | GetTagName() string 18 | GetDraft() bool 19 | GetPrerelease() bool 20 | GetPublishedAt() time.Time 21 | GetReleaseNotes() string 22 | GetName() string 23 | GetURL() string 24 | 25 | GetAssets() []SourceAsset 26 | } 27 | 28 | type SourceAsset interface { 29 | GetID() int64 30 | GetName() string 31 | GetSize() int 32 | GetBrowserDownloadURL() string 33 | } 34 | -------------------------------------------------------------------------------- /source_test.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | ) 8 | 9 | // MockSource is a Source in memory used for unit tests 10 | type MockSource struct { 11 | releases []SourceRelease 12 | files map[int64][]byte 13 | readError bool 14 | } 15 | 16 | // NewMockSource instantiates a new MockSource 17 | func NewMockSource(releases []SourceRelease, files map[int64][]byte) *MockSource { 18 | return &MockSource{ 19 | releases: releases, 20 | files: files, 21 | } 22 | } 23 | 24 | // ListReleases returns a list of releases. repository parameter is not used. 25 | func (s *MockSource) ListReleases(ctx context.Context, repository Repository) ([]SourceRelease, error) { 26 | if _, _, err := repository.GetSlug(); err != nil { 27 | return nil, err 28 | } 29 | return s.releases, nil 30 | } 31 | 32 | // DownloadReleaseAsset returns a file from its ID. repository parameter is not used. 33 | func (s *MockSource) DownloadReleaseAsset(ctx context.Context, rel *Release, assetID int64) (io.ReadCloser, error) { 34 | if rel == nil { 35 | return nil, ErrInvalidRelease 36 | } 37 | if _, _, err := rel.repository.GetSlug(); err != nil { 38 | return nil, err 39 | } 40 | content, ok := s.files[assetID] 41 | if !ok { 42 | return nil, ErrAssetNotFound 43 | } 44 | var buffer io.Reader = bytes.NewBuffer(content) 45 | if s.readError { 46 | // will return a read error after reading 4 characters 47 | buffer = newErrorReader(buffer, 4) 48 | } 49 | return io.NopCloser(buffer), nil 50 | } 51 | 52 | // Verify interface 53 | var _ Source = &MockSource{} 54 | -------------------------------------------------------------------------------- /testdata/SHA256SUM: -------------------------------------------------------------------------------- 1 | 7375728ea1c09872945ab8647c3415bfdcc2eb30f6d912e2c318a99926680dde foo.tar.gz 2 | cb6a9d6e485b09daa424749021528e90f2713c8ed2b3dd1249c88bb9879cdf08 foo.tar.xz 3 | 7375728ea1c09872945ab8647c3415bfdcc2eb30f6d912e2c318a99926680dde foo.tgz 4 | e412095724426c984940efde02ea000251a12b37506c977341e0a07600dbfcb6 foo.zip 5 | -------------------------------------------------------------------------------- /testdata/Test.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIBLzCB1qADAgECAgglJIDaK1tbjjAKBggqhkjOPQQDAjAPMQ0wCwYDVQQDEwRU 3 | ZXN0MCAXDTE4MTEwNjE1MTcwMFoYDzIxMTgxMTA2MTUxNzAwWjAPMQ0wCwYDVQQD 4 | EwRUZXN0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEs8fjo/Mi5A3c2v2YxV6A 5 | QPJnr70qYMEpsmqn0BTcI8RhZUgB46tWqeDYdO15yQKbZjfI/dr0fvS21jyW0GSX 6 | rKMaMBgwCwYDVR0PBAQDAgXgMAkGA1UdEwQCMAAwCgYIKoZIzj0EAwIDSAAwRQIh 7 | AI1pUr0nrw3m++sR8HEBoejM5Qh1QJA7gF9y1jY6rc/aAiALFPJXckSLAQuq5IvQ 8 | 7cugOPws7/OoUo1124LKPugISg== 9 | -----END CERTIFICATE----- 10 | -------------------------------------------------------------------------------- /testdata/Test.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN EC PRIVATE KEY----- 2 | MHcCAQEEIJvTkRedVrQDNjCb9/RfVjzRwz8S059Y1J6w2N8gy8jVoAoGCCqGSM49 3 | AwEHoUQDQgAEs8fjo/Mi5A3c2v2YxV6AQPJnr70qYMEpsmqn0BTcI8RhZUgB46tW 4 | qeDYdO15yQKbZjfI/dr0fvS21jyW0GSXrA== 5 | -----END EC PRIVATE KEY----- 6 | -----BEGIN CERTIFICATE----- 7 | MIIBLzCB1qADAgECAgglJIDaK1tbjjAKBggqhkjOPQQDAjAPMQ0wCwYDVQQDEwRU 8 | ZXN0MCAXDTE4MTEwNjE1MTcwMFoYDzIxMTgxMTA2MTUxNzAwWjAPMQ0wCwYDVQQD 9 | EwRUZXN0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEs8fjo/Mi5A3c2v2YxV6A 10 | QPJnr70qYMEpsmqn0BTcI8RhZUgB46tWqeDYdO15yQKbZjfI/dr0fvS21jyW0GSX 11 | rKMaMBgwCwYDVR0PBAQDAgXgMAkGA1UdEwQCMAAwCgYIKoZIzj0EAwIDSAAwRQIh 12 | AI1pUr0nrw3m++sR8HEBoejM5Qh1QJA7gF9y1jY6rc/aAiALFPJXckSLAQuq5IvQ 13 | 7cugOPws7/OoUo1124LKPugISg== 14 | -----END CERTIFICATE----- 15 | -------------------------------------------------------------------------------- /testdata/bar-not-found.gzip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/creativeprojects/go-selfupdate/e96958700db7caa350b5481d3374ca8bf6fb7bd6/testdata/bar-not-found.gzip -------------------------------------------------------------------------------- /testdata/bar-not-found.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/creativeprojects/go-selfupdate/e96958700db7caa350b5481d3374ca8bf6fb7bd6/testdata/bar-not-found.tar.gz -------------------------------------------------------------------------------- /testdata/bar-not-found.tar.xz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/creativeprojects/go-selfupdate/e96958700db7caa350b5481d3374ca8bf6fb7bd6/testdata/bar-not-found.tar.xz -------------------------------------------------------------------------------- /testdata/bar-not-found.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/creativeprojects/go-selfupdate/e96958700db7caa350b5481d3374ca8bf6fb7bd6/testdata/bar-not-found.zip -------------------------------------------------------------------------------- /testdata/empty.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/creativeprojects/go-selfupdate/e96958700db7caa350b5481d3374ca8bf6fb7bd6/testdata/empty.tar.gz -------------------------------------------------------------------------------- /testdata/empty.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/creativeprojects/go-selfupdate/e96958700db7caa350b5481d3374ca8bf6fb7bd6/testdata/empty.zip -------------------------------------------------------------------------------- /testdata/fake-executable: -------------------------------------------------------------------------------- 1 | this file is used for passing check of file existence in update tests. 2 | -------------------------------------------------------------------------------- /testdata/fake-executable.exe: -------------------------------------------------------------------------------- 1 | this file is used for passing check of file existence in update tests. 2 | -------------------------------------------------------------------------------- /testdata/foo.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/creativeprojects/go-selfupdate/e96958700db7caa350b5481d3374ca8bf6fb7bd6/testdata/foo.tar.gz -------------------------------------------------------------------------------- /testdata/foo.tar.xz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/creativeprojects/go-selfupdate/e96958700db7caa350b5481d3374ca8bf6fb7bd6/testdata/foo.tar.xz -------------------------------------------------------------------------------- /testdata/foo.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/creativeprojects/go-selfupdate/e96958700db7caa350b5481d3374ca8bf6fb7bd6/testdata/foo.tgz -------------------------------------------------------------------------------- /testdata/foo.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/creativeprojects/go-selfupdate/e96958700db7caa350b5481d3374ca8bf6fb7bd6/testdata/foo.zip -------------------------------------------------------------------------------- /testdata/foo.zip.sha256: -------------------------------------------------------------------------------- 1 | e412095724426c984940efde02ea000251a12b37506c977341e0a07600dbfcb6 foo.zip 2 | -------------------------------------------------------------------------------- /testdata/foo.zip.sig: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/creativeprojects/go-selfupdate/e96958700db7caa350b5481d3374ca8bf6fb7bd6/testdata/foo.zip.sig -------------------------------------------------------------------------------- /testdata/hello/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func main() { 4 | // This is a dummy main function 5 | } 6 | -------------------------------------------------------------------------------- /testdata/http_repo/manifest.yaml: -------------------------------------------------------------------------------- 1 | last_release_id: 2 2 | last_asset_id: 6 3 | releases: 4 | - id: 1 5 | name: example 6 | tag_name: v0.1.1 7 | url: v0.1.1 8 | draft: false 9 | prerelease: false 10 | published_at: 2024-09-30T09:26:01.612178185-05:00 11 | release_notes: "" 12 | assets: 13 | - id: 1 14 | name: metadata.json 15 | size: 288 16 | url: v0.1.1/metadata.json 17 | - id: 2 18 | name: example_linux_amd64.tar.gz 19 | size: 52 20 | url: v0.1.1/example_linux_amd64.tar.gz 21 | - id: 3 22 | name: checksums.txt 23 | size: 93 24 | url: v0.1.1/checksums.txt 25 | - id: 2 26 | name: example 27 | tag_name: v0.1.2 28 | url: v0.1.2 29 | draft: false 30 | prerelease: false 31 | published_at: 2024-10-07T22:15:21.731224367-05:00 32 | release_notes: "" 33 | assets: 34 | - id: 4 35 | name: metadata.json 36 | size: 288 37 | url: v0.1.2/metadata.json 38 | - id: 5 39 | name: example_linux_amd64.tar.gz 40 | size: 52 41 | url: v0.1.2/example_linux_amd64.tar.gz 42 | - id: 6 43 | name: checksums.txt 44 | size: 93 45 | url: v0.1.2/checksums.txt 46 | -------------------------------------------------------------------------------- /testdata/http_repo/v0.1.2/example_linux_amd64.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/creativeprojects/go-selfupdate/e96958700db7caa350b5481d3374ca8bf6fb7bd6/testdata/http_repo/v0.1.2/example_linux_amd64.tar.gz -------------------------------------------------------------------------------- /testdata/invalid-gzip.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/creativeprojects/go-selfupdate/e96958700db7caa350b5481d3374ca8bf6fb7bd6/testdata/invalid-gzip.tar.gz -------------------------------------------------------------------------------- /testdata/invalid-tar.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/creativeprojects/go-selfupdate/e96958700db7caa350b5481d3374ca8bf6fb7bd6/testdata/invalid-tar.tar.gz -------------------------------------------------------------------------------- /testdata/invalid-tar.tar.xz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/creativeprojects/go-selfupdate/e96958700db7caa350b5481d3374ca8bf6fb7bd6/testdata/invalid-tar.tar.xz -------------------------------------------------------------------------------- /testdata/invalid-xz.tar.xz: -------------------------------------------------------------------------------- 1 | hello 2 | -------------------------------------------------------------------------------- /testdata/invalid.bz2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/creativeprojects/go-selfupdate/e96958700db7caa350b5481d3374ca8bf6fb7bd6/testdata/invalid.bz2 -------------------------------------------------------------------------------- /testdata/invalid.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/creativeprojects/go-selfupdate/e96958700db7caa350b5481d3374ca8bf6fb7bd6/testdata/invalid.gz -------------------------------------------------------------------------------- /testdata/invalid.xz: -------------------------------------------------------------------------------- 1 | hello 2 | -------------------------------------------------------------------------------- /testdata/invalid.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/creativeprojects/go-selfupdate/e96958700db7caa350b5481d3374ca8bf6fb7bd6/testdata/invalid.zip -------------------------------------------------------------------------------- /testdata/new_version.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/creativeprojects/go-selfupdate/e96958700db7caa350b5481d3374ca8bf6fb7bd6/testdata/new_version.tar.gz -------------------------------------------------------------------------------- /testdata/new_version.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/creativeprojects/go-selfupdate/e96958700db7caa350b5481d3374ca8bf6fb7bd6/testdata/new_version.zip -------------------------------------------------------------------------------- /testdata/single-file.bz2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/creativeprojects/go-selfupdate/e96958700db7caa350b5481d3374ca8bf6fb7bd6/testdata/single-file.bz2 -------------------------------------------------------------------------------- /testdata/single-file.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/creativeprojects/go-selfupdate/e96958700db7caa350b5481d3374ca8bf6fb7bd6/testdata/single-file.gz -------------------------------------------------------------------------------- /testdata/single-file.gzip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/creativeprojects/go-selfupdate/e96958700db7caa350b5481d3374ca8bf6fb7bd6/testdata/single-file.gzip -------------------------------------------------------------------------------- /testdata/single-file.xz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/creativeprojects/go-selfupdate/e96958700db7caa350b5481d3374ca8bf6fb7bd6/testdata/single-file.xz -------------------------------------------------------------------------------- /testdata/single-file.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/creativeprojects/go-selfupdate/e96958700db7caa350b5481d3374ca8bf6fb7bd6/testdata/single-file.zip -------------------------------------------------------------------------------- /token.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "net/url" 5 | "strings" 6 | ) 7 | 8 | // canUseTokenForDomain returns true if other URL is in the same domain as origin URL 9 | func canUseTokenForDomain(origin, other string) (bool, error) { 10 | originURL, err := url.Parse(origin) 11 | if err != nil { 12 | return false, err 13 | } 14 | otherURL, err := url.Parse(other) 15 | if err != nil { 16 | return false, err 17 | } 18 | return originURL.Hostname() != "" && strings.HasSuffix(otherURL.Hostname(), originURL.Hostname()), nil 19 | } 20 | -------------------------------------------------------------------------------- /token_test.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestCanUseTokenForDomain(t *testing.T) { 10 | fixtures := []struct { 11 | origin, other string 12 | valid bool 13 | }{ 14 | {"http://gitlab.com", "http://gitlab.com", true}, 15 | {"http://gitlab.com/owner/repo", "http://gitlab.com/file", true}, 16 | {"http://gitlab.com/owner/repo", "http://download.gitlab.com/file", true}, 17 | {"http://api.gitlab.com", "http://gitlab.com", false}, 18 | {"http://api.gitlab.com/owner/repo", "http://gitlab.com/file", false}, 19 | {"", "http://gitlab.com/file", false}, 20 | } 21 | 22 | for _, fixture := range fixtures { 23 | t.Run("", func(t *testing.T) { 24 | ok, err := canUseTokenForDomain(fixture.origin, fixture.other) 25 | assert.NoError(t, err) 26 | assert.Equal(t, fixture.valid, ok) 27 | }) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /universal_binary.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import "debug/macho" 4 | 5 | // IsDarwinUniversalBinary checks if the file is a universal binary (also called a fat binary). 6 | func IsDarwinUniversalBinary(filename string) bool { 7 | file, err := macho.OpenFat(filename) 8 | if err == nil { 9 | file.Close() 10 | return true 11 | } 12 | return false 13 | } 14 | -------------------------------------------------------------------------------- /update.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | 12 | "github.com/Masterminds/semver/v3" 13 | "github.com/creativeprojects/go-selfupdate/internal" 14 | "github.com/creativeprojects/go-selfupdate/update" 15 | ) 16 | 17 | // UpdateTo downloads an executable from the source provider and replace current binary with the downloaded one. 18 | // It downloads a release asset via the source provider so this function is available for update releases on private repository. 19 | func (up *Updater) UpdateTo(ctx context.Context, rel *Release, cmdPath string) error { 20 | if rel == nil { 21 | return ErrInvalidRelease 22 | } 23 | 24 | data, err := up.download(ctx, rel, rel.AssetID) 25 | if err != nil { 26 | return fmt.Errorf("failed to read asset %q: %w", rel.AssetName, err) 27 | } 28 | 29 | if up.validator != nil { 30 | err = up.validate(ctx, rel, data) 31 | if err != nil { 32 | return err 33 | } 34 | } 35 | 36 | return up.decompressAndUpdate(bytes.NewReader(data), rel.AssetName, rel.AssetURL, cmdPath) 37 | } 38 | 39 | // UpdateCommand updates a given command binary to the latest version. 40 | // 'current' is used to check the latest version against the current version. 41 | func (up *Updater) UpdateCommand(ctx context.Context, cmdPath string, current string, repository Repository) (*Release, error) { 42 | version, err := semver.NewVersion(current) 43 | if err != nil { 44 | return nil, fmt.Errorf("incorrect version %q: %w", current, err) 45 | } 46 | 47 | if up.os == "windows" && !strings.HasSuffix(cmdPath, ".exe") { 48 | // Ensure to add '.exe' to given path on Windows 49 | cmdPath = cmdPath + ".exe" 50 | } 51 | 52 | stat, err := os.Lstat(cmdPath) 53 | if err != nil { 54 | return nil, fmt.Errorf("failed to stat '%s'. file may not exist: %s", cmdPath, err) 55 | } 56 | if stat.Mode()&os.ModeSymlink != 0 { 57 | p, err := internal.ResolvePath(cmdPath) 58 | if err != nil { 59 | return nil, fmt.Errorf("failed to resolve symlink '%s' for executable: %s", cmdPath, err) 60 | } 61 | cmdPath = p 62 | } 63 | 64 | rel, ok, err := up.DetectLatest(ctx, repository) 65 | if err != nil { 66 | return nil, err 67 | } 68 | if !ok { 69 | log.Print("No release detected. Current version is considered up-to-date") 70 | return &Release{version: version}, nil 71 | } 72 | if version.Equal(rel.version) { 73 | log.Printf("Current version %s is the latest. Update is not needed", version.String()) 74 | return rel, nil 75 | } 76 | log.Printf("Will update %s to the latest version %s", cmdPath, rel.Version()) 77 | if err := up.UpdateTo(ctx, rel, cmdPath); err != nil { 78 | return nil, err 79 | } 80 | return rel, nil 81 | } 82 | 83 | // UpdateSelf updates the running executable itself to the latest version. 84 | // 'current' is used to check the latest version against the current version. 85 | func (up *Updater) UpdateSelf(ctx context.Context, current string, repository Repository) (*Release, error) { 86 | cmdPath, err := internal.GetExecutablePath() 87 | if err != nil { 88 | return nil, err 89 | } 90 | return up.UpdateCommand(ctx, cmdPath, current, repository) 91 | } 92 | 93 | func (up *Updater) decompressAndUpdate(src io.Reader, assetName, assetURL, cmdPath string) error { 94 | _, cmd := filepath.Split(cmdPath) 95 | asset, err := DecompressCommand(src, assetName, cmd, up.os, up.arch) 96 | if err != nil { 97 | return err 98 | } 99 | 100 | log.Printf("Will update %s to the latest downloaded from %s", cmdPath, assetURL) 101 | return update.Apply(asset, update.Options{ 102 | TargetPath: cmdPath, 103 | OldSavePath: up.oldSavePath, 104 | }) 105 | } 106 | 107 | // validate loads the validation file and passes it to the validator. 108 | // The validation is successful if no error was returned 109 | func (up *Updater) validate(ctx context.Context, rel *Release, data []byte) error { 110 | if rel == nil { 111 | return ErrInvalidRelease 112 | } 113 | 114 | // compatibility with setting rel.ValidationAssetID directly 115 | if len(rel.ValidationChain) == 0 { 116 | rel.ValidationChain = append(rel.ValidationChain, struct { 117 | ValidationAssetID int64 118 | ValidationAssetName, ValidationAssetURL string 119 | }{ 120 | ValidationAssetID: rel.ValidationAssetID, 121 | ValidationAssetName: "", 122 | ValidationAssetURL: rel.ValidationAssetURL, 123 | }) 124 | } 125 | 126 | validationName := rel.AssetName 127 | 128 | for _, va := range rel.ValidationChain { 129 | validationData, err := up.download(ctx, rel, va.ValidationAssetID) 130 | if err != nil { 131 | return fmt.Errorf("failed reading validation data %q: %w", va.ValidationAssetName, err) 132 | } 133 | 134 | if err = up.validator.Validate(validationName, data, validationData); err != nil { 135 | return fmt.Errorf("failed validating asset content %q: %w", validationName, err) 136 | } 137 | 138 | // Select what next to validate 139 | validationName = va.ValidationAssetName 140 | data = validationData 141 | } 142 | return nil 143 | } 144 | 145 | func (up *Updater) download(ctx context.Context, rel *Release, assetId int64) (data []byte, err error) { 146 | var reader io.ReadCloser 147 | if reader, err = up.source.DownloadReleaseAsset(ctx, rel, assetId); err == nil { 148 | defer func() { _ = reader.Close() }() 149 | data, err = io.ReadAll(reader) 150 | } 151 | return 152 | } 153 | -------------------------------------------------------------------------------- /update/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2015 Alan Shreve 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /update/apply.go: -------------------------------------------------------------------------------- 1 | package update 2 | 3 | import ( 4 | "bytes" 5 | "crypto" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "os" 10 | "path/filepath" 11 | 12 | "github.com/creativeprojects/go-selfupdate/internal" 13 | ) 14 | 15 | var ( 16 | openFile = os.OpenFile 17 | ) 18 | 19 | // Apply performs an update of the current executable (or opts.TargetFile, if set) with the contents of the given io.Reader. 20 | // 21 | // Apply performs the following actions to ensure a safe cross-platform update: 22 | // 23 | // 1. If configured, computes the checksum of the new executable and verifies it matches. 24 | // 25 | // 2. If configured, verifies the signature with a public key. 26 | // 27 | // 3. Creates a new file, /path/to/.target.new with the TargetMode with the contents of the updated file 28 | // 29 | // 4. Renames /path/to/target to /path/to/.target.old 30 | // 31 | // 5. Renames /path/to/.target.new to /path/to/target 32 | // 33 | // 6. If the final rename is successful, deletes /path/to/.target.old, returns no error. On Windows, 34 | // the removal of /path/to/target.old always fails, so instead Apply hides the old file instead. 35 | // 36 | // 7. If the final rename fails, attempts to roll back by renaming /path/to/.target.old 37 | // back to /path/to/target. 38 | // 39 | // If the roll back operation fails, the file system is left in an inconsistent state (between steps 5 and 6) where 40 | // there is no new executable file and the old executable file could not be moved to its original location. In this 41 | // case you should notify the user of the bad news and ask them to recover manually. Applications can determine whether 42 | // the rollback failed by calling RollbackError, see the documentation on that function for additional detail. 43 | func Apply(update io.Reader, opts Options) error { 44 | // validate 45 | verify := false 46 | switch { 47 | case opts.Signature != nil && opts.PublicKey != nil: 48 | // okay 49 | verify = true 50 | case opts.Signature != nil: 51 | return errors.New("no public key to verify signature with") 52 | case opts.PublicKey != nil: 53 | return errors.New("no signature to verify with") 54 | } 55 | 56 | // set defaults 57 | if opts.Hash == 0 { 58 | opts.Hash = crypto.SHA256 59 | } 60 | if opts.Verifier == nil { 61 | opts.Verifier = NewECDSAVerifier() 62 | } 63 | if opts.TargetMode == 0 { 64 | opts.TargetMode = 0o755 65 | } 66 | 67 | // get target path 68 | var err error 69 | if opts.TargetPath == "" { 70 | opts.TargetPath, err = internal.GetExecutablePath() 71 | if err != nil { 72 | return err 73 | } 74 | } 75 | 76 | var newBytes []byte 77 | if newBytes, err = io.ReadAll(update); err != nil { 78 | return err 79 | } 80 | 81 | // verify checksum if requested 82 | if opts.Checksum != nil { 83 | if err = opts.verifyChecksum(newBytes); err != nil { 84 | return err 85 | } 86 | } 87 | 88 | if verify { 89 | if err = opts.verifySignature(newBytes); err != nil { 90 | return err 91 | } 92 | } 93 | 94 | // get the directory the executable exists in 95 | updateDir := filepath.Dir(opts.TargetPath) 96 | filename := filepath.Base(opts.TargetPath) 97 | 98 | // Copy the contents of newbinary to a new executable file 99 | newPath := filepath.Join(updateDir, fmt.Sprintf(".%s.new", filename)) 100 | fp, err := openFile(newPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, opts.TargetMode) 101 | if err != nil { 102 | return err 103 | } 104 | defer fp.Close() 105 | 106 | _, err = io.Copy(fp, bytes.NewReader(newBytes)) 107 | if err != nil { 108 | return err 109 | } 110 | 111 | // if we don't call fp.Close(), windows won't let us move the new executable 112 | // because the file will still be "in use" 113 | fp.Close() 114 | 115 | // this is where we'll move the executable to so that we can swap in the updated replacement 116 | oldPath := opts.OldSavePath 117 | removeOld := opts.OldSavePath == "" 118 | if removeOld { 119 | oldPath = filepath.Join(updateDir, fmt.Sprintf(".%s.old", filename)) 120 | } 121 | 122 | // delete any existing old exec file - this is necessary on Windows for two reasons: 123 | // 1. after a successful update, Windows can't remove the .old file because the process is still running 124 | // 2. windows rename operations fail if the destination file already exists 125 | _ = os.Remove(oldPath) 126 | 127 | // move the existing executable to a new file in the same directory 128 | err = os.Rename(opts.TargetPath, oldPath) 129 | if err != nil { 130 | return err 131 | } 132 | 133 | // move the new executable in to become the new program 134 | err = os.Rename(newPath, opts.TargetPath) 135 | 136 | if err != nil { 137 | // move unsuccessful 138 | // 139 | // The filesystem is now in a bad state. We have successfully 140 | // moved the existing binary to a new location, but we couldn't move the new 141 | // binary to take its place. That means there is no file where the current executable binary 142 | // used to be! 143 | // Try to rollback by restoring the old binary to its original path. 144 | rerr := os.Rename(oldPath, opts.TargetPath) 145 | if rerr != nil { 146 | return &rollbackError{err, rerr} 147 | } 148 | 149 | return err 150 | } 151 | 152 | // move successful, remove the old binary if needed 153 | if removeOld { 154 | errRemove := os.Remove(oldPath) 155 | 156 | // windows has trouble with removing old binaries, so hide it instead 157 | if errRemove != nil { 158 | _ = hideFile(oldPath) 159 | } 160 | } 161 | 162 | return nil 163 | } 164 | 165 | // RollbackError takes an error value returned by Apply and returns the error, if any, 166 | // that occurred when attempting to roll back from a failed update. Applications should 167 | // always call this function on any non-nil errors returned by Apply. 168 | // 169 | // If no rollback was needed or if the rollback was successful, RollbackError returns nil, 170 | // otherwise it returns the error encountered when trying to roll back. 171 | func RollbackError(err error) error { 172 | if err == nil { 173 | return nil 174 | } 175 | if rerr, ok := err.(*rollbackError); ok { 176 | return rerr.rollbackErr 177 | } 178 | return nil 179 | } 180 | 181 | type rollbackError struct { 182 | error // original error 183 | rollbackErr error // error encountered while rolling back 184 | } 185 | -------------------------------------------------------------------------------- /update/apply_test.go: -------------------------------------------------------------------------------- 1 | package update 2 | 3 | import ( 4 | "bytes" 5 | "crypto" 6 | "crypto/rand" 7 | "crypto/sha256" 8 | "crypto/x509" 9 | "encoding/pem" 10 | "fmt" 11 | "os" 12 | "testing" 13 | 14 | "github.com/stretchr/testify/assert" 15 | ) 16 | 17 | var ( 18 | oldFile = []byte{0xDE, 0xAD, 0xBE, 0xEF} 19 | newFile = []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06} 20 | newFileChecksum = sha256.Sum256(newFile) 21 | ) 22 | 23 | func cleanup(path string) { 24 | os.Remove(path) 25 | os.Remove(fmt.Sprintf(".%s.new", path)) 26 | } 27 | 28 | // we write with a separate name for each test so that we can run them in parallel 29 | func writeOldFile(t *testing.T, path string) { 30 | t.Helper() 31 | 32 | if err := os.WriteFile(path, oldFile, 0o600); err != nil { 33 | t.Fatalf("Failed to write file for testing preparation: %v", err) 34 | } 35 | } 36 | 37 | func validateUpdate(t *testing.T, path string, err error) { 38 | t.Helper() 39 | 40 | if err != nil { 41 | t.Fatalf("Failed to update: %v", err) 42 | } 43 | 44 | buf, err := os.ReadFile(path) 45 | if err != nil { 46 | t.Fatalf("Failed to read file post-update: %v", err) 47 | } 48 | 49 | if !bytes.Equal(buf, newFile) { 50 | t.Fatalf("File was not updated! Bytes read: %v, Bytes expected: %v", buf, newFile) 51 | } 52 | } 53 | 54 | func TestApplySimple(t *testing.T) { 55 | t.Parallel() 56 | 57 | fName := t.Name() 58 | defer cleanup(fName) 59 | writeOldFile(t, fName) 60 | 61 | err := Apply(bytes.NewReader(newFile), Options{ 62 | TargetPath: fName, 63 | }) 64 | validateUpdate(t, fName, err) 65 | } 66 | 67 | func TestApplyOldSavePath(t *testing.T) { 68 | t.Parallel() 69 | 70 | fName := t.Name() 71 | defer cleanup(fName) 72 | writeOldFile(t, fName) 73 | 74 | oldfName := "OldSavePath" 75 | 76 | err := Apply(bytes.NewReader(newFile), Options{ 77 | TargetPath: fName, 78 | OldSavePath: oldfName, 79 | }) 80 | validateUpdate(t, fName, err) 81 | 82 | if _, err := os.Stat(oldfName); os.IsNotExist(err) { 83 | t.Fatalf("Failed to find the old file: %v", err) 84 | } 85 | 86 | cleanup(oldfName) 87 | } 88 | 89 | func TestVerifyChecksum(t *testing.T) { 90 | t.Parallel() 91 | 92 | fName := t.Name() 93 | defer cleanup(fName) 94 | writeOldFile(t, fName) 95 | 96 | err := Apply(bytes.NewReader(newFile), Options{ 97 | TargetPath: fName, 98 | Checksum: newFileChecksum[:], 99 | }) 100 | validateUpdate(t, fName, err) 101 | } 102 | 103 | func TestVerifyChecksumNegative(t *testing.T) { 104 | t.Parallel() 105 | 106 | fName := t.Name() 107 | defer cleanup(fName) 108 | writeOldFile(t, fName) 109 | 110 | badChecksum := []byte{0x0A, 0x0B, 0x0C, 0xFF} 111 | err := Apply(bytes.NewReader(newFile), Options{ 112 | TargetPath: fName, 113 | Checksum: badChecksum, 114 | }) 115 | if err == nil { 116 | t.Fatalf("Failed to detect bad checksum!") 117 | } 118 | } 119 | 120 | const ecdsaPublicKey = ` 121 | -----BEGIN PUBLIC KEY----- 122 | MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEL8ThbSyEucsCxnd4dCZR2hIy5nea54ko 123 | O+jUUfIjkvwhCWzASm0lpCVdVpXKZXIe+NZ+44RQRv3+OqJkCCGzUgJkPNI3lxdG 124 | 9zu8rbrnxISV06VQ8No7Ei9wiTpqmTBB 125 | -----END PUBLIC KEY----- 126 | ` 127 | 128 | const ecdsaPrivateKey = ` 129 | -----BEGIN EC PRIVATE KEY----- 130 | MIGkAgEBBDBttCB/1NOY4T+WrG4FSV49Ayn3gK1DNzfGaJ01JUXeiNFCWQM2pqpU 131 | om8ATPP/dkegBwYFK4EEACKhZANiAAQvxOFtLIS5ywLGd3h0JlHaEjLmd5rniSg7 132 | 6NRR8iOS/CEJbMBKbSWkJV1Wlcplch741n7jhFBG/f46omQIIbNSAmQ80jeXF0b3 133 | O7ytuufEhJXTpVDw2jsSL3CJOmqZMEE= 134 | -----END EC PRIVATE KEY----- 135 | ` 136 | 137 | const rsaPublicKey = ` 138 | -----BEGIN PUBLIC KEY----- 139 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxSWmu7trWKAwDFjiCN2D 140 | Tk2jj2sgcr/CMlI4cSSiIOHrXCFxP1I8i9PvQkd4hasXQrLbT5WXKrRGv1HKUKab 141 | b9ead+kD0kxk7i2bFYvKX43oq66IW0mOLTQBO7I9UyT4L7svcMD+HUQ2BqHoaQe4 142 | y20C59dPr9Dpcz8DZkdLsBV6YKF6Ieb3iGk8oRLMWNaUqPa8f1BGgxAkvPHcqDjT 143 | x4xRnjgTRRRlZvRtALHMUkIChgxDOhoEzKpGiqnX7HtMJfrhV6h0PAXNA4h9Kjv5 144 | 5fhJ08Rz7mmZmtH5JxTK5XTquo59sihSajR4bSjZbbkQ1uLkeFlY3eli3xdQ7Nrf 145 | fQIDAQAB 146 | -----END PUBLIC KEY-----` 147 | 148 | const rsaPrivateKey = ` 149 | -----BEGIN RSA PRIVATE KEY----- 150 | MIIEogIBAAKCAQEAxSWmu7trWKAwDFjiCN2DTk2jj2sgcr/CMlI4cSSiIOHrXCFx 151 | P1I8i9PvQkd4hasXQrLbT5WXKrRGv1HKUKabb9ead+kD0kxk7i2bFYvKX43oq66I 152 | W0mOLTQBO7I9UyT4L7svcMD+HUQ2BqHoaQe4y20C59dPr9Dpcz8DZkdLsBV6YKF6 153 | Ieb3iGk8oRLMWNaUqPa8f1BGgxAkvPHcqDjTx4xRnjgTRRRlZvRtALHMUkIChgxD 154 | OhoEzKpGiqnX7HtMJfrhV6h0PAXNA4h9Kjv55fhJ08Rz7mmZmtH5JxTK5XTquo59 155 | sihSajR4bSjZbbkQ1uLkeFlY3eli3xdQ7NrffQIDAQABAoIBAAkN+6RvrTR61voa 156 | Mvd5RQiZpEN4Bht/Fyo8gH8h0Zh1B9xJZOwlmMZLS5fdtHlfLEhR8qSrGDBL61vq 157 | I8KkhEsUufF78EL+YzxVN+Q7cWYGHIOWFokqza7hzpSxUQO6lPOMQ1eIZaNueJTB 158 | Zu07/47ISPPg/bXzgGVcpYlTCPTjUwKjtfyMqvX9AD7fIyYRm6zfE7EHj1J2sBFt 159 | Yz1OGELg6HfJwXfpnPfBvftD0hWGzJ78Bp71fPJe6n5gnqmSqRvrcXNWFnH/yqkN 160 | d6vPIxD6Z3LjvyZpkA7JillLva2L/zcIFhg4HZvQnWd8/PpDnUDonu36hcj4SC5j 161 | W4aVPLkCgYEA4XzNKWxqYcajzFGZeSxlRHupSAl2MT7Cc5085MmE7dd31wK2T8O4 162 | n7N4bkm/rjTbX85NsfWdKtWb6mpp8W3VlLP0rp4a/12OicVOkg4pv9LZDmY0sRlE 163 | YuDJk1FeCZ50UrwTZI3rZ9IhZHhkgVA6uWAs7tYndONkxNHG0pjqs4sCgYEA39MZ 164 | JwMqo3qsPntpgP940cCLflEsjS9hYNO3+Sv8Dq3P0HLVhBYajJnotf8VuU0fsQZG 165 | grmtVn1yThFbMq7X1oY4F0XBA+paSiU18c4YyUnwax2u4sw9U/Q9tmQUZad5+ueT 166 | qriMBwGv+ewO+nQxqvAsMUmemrVzrfwA5Oct+hcCgYAfiyXoNZJsOy2O15twqBVC 167 | j0oPGcO+/9iT89sg5lACNbI+EdMPNYIOVTzzsL1v0VUfAe08h++Enn1BPcG0VHkc 168 | ZFBGXTfJoXzfKQrkw7ZzbzuOGB4m6DH44xlP0oIlNlVvfX/5ASF9VJf3RiBJNsAA 169 | TsP6ZVr/rw/ZuL7nlxy+IQKBgDhL/HOXlE3yOQiuOec8WsNHTs7C1BXe6PtVxVxi 170 | 988pYK/pclL6zEq5G5NLSceF4obAMVQIJ9UtUGbabrncyGUo9UrFPLsjYvprSZo8 171 | YHegpVwL50UcYgCP2kXZ/ldjPIcjYDz8lhvdDMor2cidGTEJn9P11HLNWP9V91Ob 172 | 4jCZAoGAPNRSC5cC8iP/9j+s2/kdkfWJiNaolPYAUrmrkL6H39PYYZM5tnhaIYJV 173 | Oh9AgABamU0eb3p3vXTISClVgV7ifq1HyZ7BSUhMfaY2Jk/s3sUHCWFxPZe9sgEG 174 | KinIY/373KIkIV/5g4h2v1w330IWcfptxKcY/Er3DJr38f695GE= 175 | -----END RSA PRIVATE KEY-----` 176 | 177 | func signec(privatePEM string, source []byte, t *testing.T) []byte { 178 | parseFn := func(p []byte) (crypto.Signer, error) { return x509.ParseECPrivateKey(p) } 179 | return sign(parseFn, privatePEM, source, t) 180 | } 181 | 182 | func signrsa(privatePEM string, source []byte, t *testing.T) []byte { 183 | parseFn := func(p []byte) (crypto.Signer, error) { return x509.ParsePKCS1PrivateKey(p) } 184 | return sign(parseFn, privatePEM, source, t) 185 | } 186 | 187 | func sign(parsePrivKey func([]byte) (crypto.Signer, error), privatePEM string, source []byte, t *testing.T) []byte { 188 | block, _ := pem.Decode([]byte(privatePEM)) 189 | if block == nil { 190 | t.Fatalf("Failed to parse private key PEM") 191 | } 192 | 193 | priv, err := parsePrivKey(block.Bytes) 194 | if err != nil { 195 | t.Fatalf("Failed to parse private key DER: %v", err) 196 | } 197 | 198 | checksum := sha256.Sum256(source) 199 | sig, err := priv.Sign(rand.Reader, checksum[:], crypto.SHA256) 200 | if err != nil { 201 | t.Fatalf("Failed to sign: %v", sig) 202 | } 203 | 204 | return sig 205 | } 206 | 207 | func TestSetInvalidPublicKeyPEM(t *testing.T) { 208 | t.Parallel() 209 | 210 | const wrongPublicKey = ` 211 | -----BEGIN PUBLIC KEY----- 212 | == not valid base64 == 213 | -----END PUBLIC KEY----- 214 | ` 215 | 216 | fName := t.Name() 217 | defer cleanup(fName) 218 | writeOldFile(t, fName) 219 | 220 | opts := Options{TargetPath: fName} 221 | err := opts.SetPublicKeyPEM([]byte(wrongPublicKey)) 222 | assert.Error(t, err, "Did not fail with invalid public key") 223 | } 224 | 225 | func TestVerifyECSignature(t *testing.T) { 226 | t.Parallel() 227 | 228 | fName := t.Name() 229 | defer cleanup(fName) 230 | writeOldFile(t, fName) 231 | 232 | opts := Options{TargetPath: fName} 233 | err := opts.SetPublicKeyPEM([]byte(ecdsaPublicKey)) 234 | if err != nil { 235 | t.Fatalf("Could not parse public key: %v", err) 236 | } 237 | 238 | opts.Signature = signec(ecdsaPrivateKey, newFile, t) 239 | err = Apply(bytes.NewReader(newFile), opts) 240 | validateUpdate(t, fName, err) 241 | } 242 | 243 | func TestVerifyRSASignature(t *testing.T) { 244 | t.Parallel() 245 | 246 | fName := t.Name() 247 | defer cleanup(fName) 248 | writeOldFile(t, fName) 249 | 250 | opts := Options{ 251 | TargetPath: fName, 252 | Verifier: NewRSAVerifier(), 253 | } 254 | err := opts.SetPublicKeyPEM([]byte(rsaPublicKey)) 255 | if err != nil { 256 | t.Fatalf("Could not parse public key: %v", err) 257 | } 258 | 259 | opts.Signature = signrsa(rsaPrivateKey, newFile, t) 260 | err = Apply(bytes.NewReader(newFile), opts) 261 | validateUpdate(t, fName, err) 262 | } 263 | 264 | func TestVerifyFailBadSignature(t *testing.T) { 265 | t.Parallel() 266 | 267 | fName := t.Name() 268 | defer cleanup(fName) 269 | writeOldFile(t, fName) 270 | 271 | opts := Options{ 272 | TargetPath: fName, 273 | Signature: []byte{0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA}, 274 | } 275 | err := opts.SetPublicKeyPEM([]byte(ecdsaPublicKey)) 276 | if err != nil { 277 | t.Fatalf("Could not parse public key: %v", err) 278 | } 279 | 280 | err = Apply(bytes.NewReader(newFile), opts) 281 | if err == nil { 282 | t.Fatalf("Did not fail with bad signature") 283 | } 284 | } 285 | 286 | func TestVerifyFailNoSignature(t *testing.T) { 287 | t.Parallel() 288 | 289 | fName := t.Name() 290 | defer cleanup(fName) 291 | writeOldFile(t, fName) 292 | 293 | opts := Options{TargetPath: fName} 294 | err := opts.SetPublicKeyPEM([]byte(ecdsaPublicKey)) 295 | if err != nil { 296 | t.Fatalf("Could not parse public key: %v", err) 297 | } 298 | 299 | err = Apply(bytes.NewReader(newFile), opts) 300 | if err == nil { 301 | t.Fatalf("Did not fail with empty signature") 302 | } 303 | } 304 | 305 | func TestVerifyFailWrongSignature(t *testing.T) { 306 | t.Parallel() 307 | 308 | const wrongKey = ` 309 | -----BEGIN EC PRIVATE KEY----- 310 | MIGkAgEBBDBzqYp6N2s8YWYifBjS03/fFfmGeIPcxQEi+bbFeekIYt8NIKIkhD+r 311 | hpaIwSmot+qgBwYFK4EEACKhZANiAAR0EC8Usbkc4k30frfEB2ECmsIghu9DJSqE 312 | RbH7jfq2ULNv8tN/clRjxf2YXgp+iP3SQF1R1EYERKpWr8I57pgfIZtoZXjwpbQC 313 | VBbP/Ff+05HOqwPC7rJMy1VAJLKg7Cw= 314 | -----END EC PRIVATE KEY----- 315 | ` 316 | 317 | fName := t.Name() 318 | defer cleanup(fName) 319 | writeOldFile(t, fName) 320 | 321 | opts := Options{TargetPath: fName} 322 | err := opts.SetPublicKeyPEM([]byte(ecdsaPublicKey)) 323 | if err != nil { 324 | t.Fatalf("Could not parse public key: %v", err) 325 | } 326 | 327 | opts.Signature = signec(wrongKey, newFile, t) 328 | err = Apply(bytes.NewReader(newFile), opts) 329 | if err == nil { 330 | t.Fatalf("Verified an update that was signed by an untrusted key!") 331 | } 332 | } 333 | 334 | func TestSignatureButNoPublicKey(t *testing.T) { 335 | t.Parallel() 336 | 337 | fName := t.Name() 338 | defer cleanup(fName) 339 | writeOldFile(t, fName) 340 | 341 | err := Apply(bytes.NewReader(newFile), Options{ 342 | TargetPath: fName, 343 | Signature: signec(ecdsaPrivateKey, newFile, t), 344 | }) 345 | if err == nil { 346 | t.Fatalf("Allowed an update with a signature verification when no public key was specified!") 347 | } 348 | } 349 | 350 | func TestPublicKeyButNoSignature(t *testing.T) { 351 | t.Parallel() 352 | 353 | fName := t.Name() 354 | defer cleanup(fName) 355 | writeOldFile(t, fName) 356 | 357 | opts := Options{TargetPath: fName} 358 | if err := opts.SetPublicKeyPEM([]byte(ecdsaPublicKey)); err != nil { 359 | t.Fatalf("Could not parse public key: %v", err) 360 | } 361 | err := Apply(bytes.NewReader(newFile), opts) 362 | if err == nil { 363 | t.Fatalf("Allowed an update with no signature when a public key was specified!") 364 | } 365 | } 366 | 367 | func TestWriteError(t *testing.T) { 368 | // fix this test patching the global openFile variable 369 | // t.Parallel() 370 | 371 | fName := t.Name() 372 | defer cleanup(fName) 373 | writeOldFile(t, fName) 374 | 375 | openFile = func(name string, flags int, perm os.FileMode) (*os.File, error) { 376 | f, err := os.OpenFile(name, flags, perm) 377 | 378 | // simulate Write() error by closing the file prematurely 379 | f.Close() 380 | 381 | return f, err 382 | } 383 | defer func() { 384 | openFile = os.OpenFile 385 | }() 386 | 387 | err := Apply(bytes.NewReader(newFile), Options{TargetPath: fName}) 388 | if err == nil { 389 | t.Fatalf("Allowed an update to an empty file") 390 | } 391 | } 392 | -------------------------------------------------------------------------------- /update/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package update provides functionality to implement secure, self-updating Go programs (or other single-file targets). 3 | 4 | Basic Example 5 | 6 | This example shows how to update a program remotely from a URL. 7 | 8 | import ( 9 | "fmt" 10 | "net/http" 11 | 12 | "github.com/creativeprojects/go-selfupdate/update" 13 | ) 14 | 15 | func doUpdate(url string) error { 16 | // request the new file 17 | resp, err := http.Get(url) 18 | if err != nil { 19 | return err 20 | } 21 | defer resp.Body.Close() 22 | err := update.Apply(resp.Body, update.Options{}) 23 | if err != nil { 24 | if rerr := update.RollbackError(err); rerr != nil { 25 | fmt.Println("Failed to rollback from bad update: %v", rerr) 26 | } 27 | } 28 | return err 29 | } 30 | 31 | 32 | Checksum Verification 33 | 34 | Updating executable code on a computer can be a dangerous operation unless you 35 | take the appropriate steps to guarantee the authenticity of the new code. While 36 | checksum verification is important, it should always be combined with signature 37 | verification (next section) to guarantee that the code came from a trusted party. 38 | 39 | go-update validates SHA256 checksums by default, but this is pluggable via the Hash 40 | property on the Options struct. 41 | 42 | This example shows how to guarantee that the newly-updated binary is verified to 43 | have an appropriate checksum (that was otherwise retrieved via a secure channel) 44 | specified as a hex string. 45 | 46 | import ( 47 | "crypto" 48 | _ "crypto/sha256" 49 | "encoding/hex" 50 | "io" 51 | 52 | "github.com/creativeprojects/go-selfupdate/update" 53 | ) 54 | 55 | func updateWithChecksum(binary io.Reader, hexChecksum string) error { 56 | checksum, err := hex.DecodeString(hexChecksum) 57 | if err != nil { 58 | return err 59 | } 60 | err = update.Apply(binary, update.Options{ 61 | Hash: crypto.SHA256, // this is the default, you don't need to specify it 62 | Checksum: checksum, 63 | }) 64 | if err != nil { 65 | // error handling 66 | } 67 | return err 68 | } 69 | 70 | Cryptographic Signature Verification 71 | 72 | Cryptographic verification of new code from an update is an extremely important way to guarantee the 73 | security and integrity of your updates. 74 | 75 | Verification is performed by validating the signature of a hash of the new file. 76 | 77 | This example shows how to add signature verification to your updates. To make all of this work 78 | an application distributor must first create a public/private key pair and embed the public key 79 | into their application. When they issue a new release, the issuer must sign the new executable file 80 | with the private key and distribute the signature along with the update. 81 | 82 | import ( 83 | "crypto" 84 | _ "crypto/sha256" 85 | "encoding/hex" 86 | "io" 87 | 88 | "github.com/creativeprojects/go-selfupdate/update" 89 | ) 90 | 91 | var publicKey = []byte(` 92 | -----BEGIN PUBLIC KEY----- 93 | MFYwEAYHKoZIzj0CAQYFK4EEAAoDQgAEtrVmBxQvheRArXjg2vG1xIprWGuCyESx 94 | MMY8pjmjepSy2kuz+nl9aFLqmr+rDNdYvEBqQaZrYMc6k29gjvoQnQ== 95 | -----END PUBLIC KEY----- 96 | `) 97 | 98 | func verifiedUpdate(binary io.Reader, hexChecksum, hexSignature string) { 99 | checksum, err := hex.DecodeString(hexChecksum) 100 | if err != nil { 101 | return err 102 | } 103 | signature, err := hex.DecodeString(hexSignature) 104 | if err != nil { 105 | return err 106 | } 107 | opts := update.Options{ 108 | Checksum: checksum, 109 | Signature: signature, 110 | Hash: crypto.SHA256, // this is the default, you don't need to specify it 111 | Verifier: update.NewECDSAVerifier(), // this is the default, you don't need to specify it 112 | } 113 | err = opts.SetPublicKeyPEM(publicKey) 114 | if err != nil { 115 | return err 116 | } 117 | err = update.Apply(binary, opts) 118 | if err != nil { 119 | // error handling 120 | } 121 | return err 122 | } 123 | 124 | 125 | */ 126 | package update 127 | -------------------------------------------------------------------------------- /update/hide_noop.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package update 4 | 5 | func hideFile(path string) error { 6 | return nil 7 | } 8 | -------------------------------------------------------------------------------- /update/hide_test.go: -------------------------------------------------------------------------------- 1 | package update 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestHideFile(t *testing.T) { 12 | t.Parallel() 13 | 14 | tempFile := filepath.Join(t.TempDir(), t.Name()) 15 | err := os.WriteFile(tempFile, []byte("test"), 0o600) 16 | assert.NoError(t, err) 17 | 18 | err = hideFile(tempFile) 19 | assert.NoError(t, err) 20 | } 21 | -------------------------------------------------------------------------------- /update/hide_windows.go: -------------------------------------------------------------------------------- 1 | package update 2 | 3 | import ( 4 | "syscall" 5 | "unsafe" 6 | ) 7 | 8 | func hideFile(path string) error { 9 | kernel32 := syscall.NewLazyDLL("kernel32.dll") 10 | setFileAttributes := kernel32.NewProc("SetFileAttributesW") 11 | 12 | utf16Str, err := syscall.UTF16PtrFromString(path) 13 | if err != nil { 14 | return err 15 | } 16 | r1, _, err := setFileAttributes.Call(uintptr(unsafe.Pointer(utf16Str)), 2) 17 | 18 | if r1 == 0 { 19 | return err 20 | } else { 21 | return nil 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /update/options.go: -------------------------------------------------------------------------------- 1 | package update 2 | 3 | import ( 4 | "bytes" 5 | "crypto" 6 | "crypto/x509" 7 | "encoding/pem" 8 | "errors" 9 | "fmt" 10 | "os" 11 | ) 12 | 13 | // Options for Apply update 14 | type Options struct { 15 | // TargetPath defines the path to the file to update. 16 | // The empty string means 'the executable file of the running program'. 17 | TargetPath string 18 | 19 | // Create TargetPath replacement with this file mode. If zero, defaults to 0755. 20 | TargetMode os.FileMode 21 | 22 | // Checksum of the new binary to verify against. If nil, no checksum or signature verification is done. 23 | Checksum []byte 24 | 25 | // Public key to use for signature verification. If nil, no signature verification is done. 26 | PublicKey crypto.PublicKey 27 | 28 | // Signature to verify the updated file. If nil, no signature verification is done. 29 | Signature []byte 30 | 31 | // Pluggable signature verification algorithm. If nil, ECDSA is used. 32 | Verifier Verifier 33 | 34 | // Use this hash function to generate the checksum. If not set, SHA256 is used. 35 | Hash crypto.Hash 36 | 37 | // Store the old executable file at this path after a successful update. 38 | // The empty string means the old executable file will be removed after the update. 39 | OldSavePath string 40 | } 41 | 42 | // SetPublicKeyPEM is a convenience method to set the PublicKey property 43 | // used for checking a completed update's signature by parsing a 44 | // Public Key formatted as PEM data. 45 | func (o *Options) SetPublicKeyPEM(pembytes []byte) error { 46 | block, _ := pem.Decode(pembytes) 47 | if block == nil { 48 | return errors.New("couldn't parse PEM data") 49 | } 50 | 51 | pub, err := x509.ParsePKIXPublicKey(block.Bytes) 52 | if err != nil { 53 | return err 54 | } 55 | o.PublicKey = pub 56 | return nil 57 | } 58 | 59 | func (o *Options) verifyChecksum(updated []byte) error { 60 | checksum, err := checksumFor(o.Hash, updated) 61 | if err != nil { 62 | return err 63 | } 64 | 65 | if !bytes.Equal(o.Checksum, checksum) { 66 | return fmt.Errorf("updated file has wrong checksum. Expected: %x, got: %x", o.Checksum, checksum) 67 | } 68 | return nil 69 | } 70 | 71 | func (o *Options) verifySignature(updated []byte) error { 72 | checksum, err := checksumFor(o.Hash, updated) 73 | if err != nil { 74 | return err 75 | } 76 | return o.Verifier.VerifySignature(checksum, o.Signature, o.Hash, o.PublicKey) 77 | } 78 | 79 | func checksumFor(h crypto.Hash, payload []byte) ([]byte, error) { 80 | if !h.Available() { 81 | return nil, errors.New("requested hash function not available") 82 | } 83 | hash := h.New() 84 | _, _ = hash.Write(payload) 85 | return hash.Sum([]byte{}), nil 86 | } 87 | -------------------------------------------------------------------------------- /update/verifier.go: -------------------------------------------------------------------------------- 1 | package update 2 | 3 | import ( 4 | "crypto" 5 | "crypto/ecdsa" 6 | "crypto/rsa" 7 | "encoding/asn1" 8 | "errors" 9 | "math/big" 10 | ) 11 | 12 | // Verifier defines an interface for verifying an update's signature with a public key. 13 | type Verifier interface { 14 | VerifySignature(checksum, signature []byte, h crypto.Hash, publicKey crypto.PublicKey) error 15 | } 16 | 17 | type verifyFn func([]byte, []byte, crypto.Hash, crypto.PublicKey) error 18 | 19 | func (fn verifyFn) VerifySignature(checksum []byte, signature []byte, hash crypto.Hash, publicKey crypto.PublicKey) error { 20 | return fn(checksum, signature, hash, publicKey) 21 | } 22 | 23 | // NewRSAVerifier returns a Verifier that uses the RSA algorithm to verify updates. 24 | func NewRSAVerifier() Verifier { 25 | return verifyFn(func(checksum, signature []byte, hash crypto.Hash, publicKey crypto.PublicKey) error { 26 | key, ok := publicKey.(*rsa.PublicKey) 27 | if !ok { 28 | return errors.New("not a valid RSA public key") 29 | } 30 | return rsa.VerifyPKCS1v15(key, hash, checksum, signature) 31 | }) 32 | } 33 | 34 | type rsDER struct { 35 | R *big.Int 36 | S *big.Int 37 | } 38 | 39 | // NewECDSAVerifier returns a Verifier that uses the ECDSA algorithm to verify updates. 40 | func NewECDSAVerifier() Verifier { 41 | return verifyFn(func(checksum, signature []byte, hash crypto.Hash, publicKey crypto.PublicKey) error { 42 | key, ok := publicKey.(*ecdsa.PublicKey) 43 | if !ok { 44 | return errors.New("not a valid ECDSA public key") 45 | } 46 | var rs rsDER 47 | if _, err := asn1.Unmarshal(signature, &rs); err != nil { 48 | return err 49 | } 50 | if !ecdsa.Verify(key, checksum, rs.R, rs.S) { 51 | return errors.New("failed to verify ECDSA signature") 52 | } 53 | return nil 54 | }) 55 | } 56 | -------------------------------------------------------------------------------- /updater.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "runtime" 7 | 8 | "github.com/creativeprojects/go-selfupdate/internal" 9 | ) 10 | 11 | // Updater is responsible for managing the context of self-update. 12 | type Updater struct { 13 | source Source 14 | validator Validator 15 | filters []*regexp.Regexp 16 | os string 17 | arch string 18 | arm uint8 19 | universalArch string // only filled in when needed 20 | prerelease bool 21 | draft bool 22 | oldSavePath string 23 | } 24 | 25 | // keep the default updater instance in cache 26 | var defaultUpdater *Updater 27 | 28 | // NewUpdater creates a new updater instance. 29 | // If you don't specify a source in the config object, GitHub will be used 30 | func NewUpdater(config Config) (*Updater, error) { 31 | source := config.Source 32 | if source == nil { 33 | // default source is GitHub 34 | // an error can only be returned when using GitHub Enterprise URLs 35 | source, _ = NewGitHubSource(GitHubConfig{}) 36 | } 37 | 38 | filtersRe := make([]*regexp.Regexp, 0, len(config.Filters)) 39 | for _, filter := range config.Filters { 40 | re, err := regexp.Compile(filter) 41 | if err != nil { 42 | return nil, fmt.Errorf("could not compile regular expression %q for filtering releases: %w", filter, err) 43 | } 44 | filtersRe = append(filtersRe, re) 45 | } 46 | 47 | os := config.OS 48 | if os == "" { 49 | os = runtime.GOOS 50 | } 51 | arch := config.Arch 52 | if arch == "" { 53 | arch = runtime.GOARCH 54 | } 55 | arm := config.Arm 56 | if arm == 0 && arch == "arm" { 57 | exe, _ := internal.GetExecutablePath() 58 | arm = getGOARM(exe) 59 | } 60 | universalArch := "" 61 | if os == "darwin" && config.UniversalArch != "" { 62 | universalArch = config.UniversalArch 63 | } 64 | 65 | return &Updater{ 66 | source: source, 67 | validator: config.Validator, 68 | filters: filtersRe, 69 | os: os, 70 | arch: arch, 71 | arm: arm, 72 | universalArch: universalArch, 73 | prerelease: config.Prerelease, 74 | draft: config.Draft, 75 | oldSavePath: config.OldSavePath, 76 | }, nil 77 | } 78 | 79 | // DefaultUpdater creates a new updater instance with default configuration. 80 | // It initializes GitHub API client with default API base URL. 81 | // If you set your API token to $GITHUB_TOKEN, the client will use it. 82 | // Every call to this function will always return the same instance, it's only created once 83 | func DefaultUpdater() *Updater { 84 | // instantiate it only once 85 | if defaultUpdater != nil { 86 | return defaultUpdater 87 | } 88 | defaultUpdater, _ = NewUpdater(Config{}) 89 | return defaultUpdater 90 | } 91 | -------------------------------------------------------------------------------- /updater_test.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestCompileRegexForFiltering(t *testing.T) { 9 | filters := []string{ 10 | "^hello$", 11 | "^(\\d\\.)+\\d$", 12 | } 13 | up, err := NewUpdater(Config{ 14 | Filters: filters, 15 | }) 16 | if err != nil { 17 | t.Fatal(err) 18 | } 19 | if len(up.filters) != 2 { 20 | t.Fatalf("Wanted 2 regexes but got %d", len(up.filters)) 21 | } 22 | for i, r := range up.filters { 23 | want := filters[i] 24 | got := r.String() 25 | if want != got { 26 | t.Errorf("Compiled regex is %q but specified was %q", got, want) 27 | } 28 | } 29 | } 30 | 31 | func TestFilterRegexIsBroken(t *testing.T) { 32 | _, err := NewUpdater(Config{ 33 | Filters: []string{"(foo"}, 34 | }) 35 | if err == nil { 36 | t.Fatal("Error unexpectedly did not occur") 37 | } 38 | msg := err.Error() 39 | if !strings.Contains(msg, "could not compile regular expression \"(foo\" for filtering releases") { 40 | t.Fatalf("Error message is unexpected: %q", msg) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /validate.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "bytes" 5 | "crypto/ecdsa" 6 | "crypto/sha256" 7 | "crypto/x509" 8 | "encoding/asn1" 9 | "encoding/hex" 10 | "encoding/pem" 11 | "errors" 12 | "fmt" 13 | "io" 14 | "math/big" 15 | "path" 16 | 17 | "golang.org/x/crypto/openpgp" 18 | ) 19 | 20 | // Validator represents an interface which enables additional validation of releases. 21 | type Validator interface { 22 | // Validate validates release bytes against an additional asset bytes. 23 | // See SHAValidator or ECDSAValidator for more information. 24 | Validate(filename string, release, asset []byte) error 25 | // GetValidationAssetName returns the additional asset name containing the validation checksum. 26 | // The asset containing the checksum can be based on the release asset name 27 | // Please note if the validation file cannot be found, the DetectLatest and DetectVersion methods 28 | // will fail with a wrapped ErrValidationAssetNotFound error 29 | GetValidationAssetName(releaseFilename string) string 30 | } 31 | 32 | // RecursiveValidator may be implemented by validators that can continue validation on 33 | // validation assets (multistep validation). 34 | type RecursiveValidator interface { 35 | // MustContinueValidation returns true if validation must continue on the provided filename 36 | MustContinueValidation(filename string) bool 37 | } 38 | 39 | //===================================================================================================================== 40 | 41 | // PatternValidator specifies a validator for additional file validation 42 | // that redirects to other validators depending on glob file patterns. 43 | // 44 | // Unlike others, PatternValidator is a recursive validator that also checks 45 | // validation assets (e.g. SHA256SUMS file checks assets and SHA256SUMS.asc 46 | // checks the SHA256SUMS file). 47 | // Depending on the used validators, a validation loop might be created, 48 | // causing validation errors. In order to prevent this, use SkipValidation 49 | // for validation assets that should not be checked (e.g. signature files). 50 | // Note that glob pattern are matched in the order of addition. Add general 51 | // patterns like "*" at last. 52 | // 53 | // Usage Example (validate assets by SHA256SUMS and SHA256SUMS.asc): 54 | // 55 | // new(PatternValidator). 56 | // // "SHA256SUMS" file is checked by PGP signature (from "SHA256SUMS.asc") 57 | // Add("SHA256SUMS", new(PGPValidator).WithArmoredKeyRing(key)). 58 | // // "SHA256SUMS.asc" file is not checked (is the signature for "SHA256SUMS") 59 | // SkipValidation("*.asc"). 60 | // // All other files are checked by the "SHA256SUMS" file 61 | // Add("*", &ChecksumValidator{UniqueFilename:"SHA256SUMS"}) 62 | type PatternValidator struct { 63 | validators []struct { 64 | pattern string 65 | validator Validator 66 | } 67 | } 68 | 69 | // Add maps a new validator to the given glob pattern. 70 | func (m *PatternValidator) Add(glob string, validator Validator) *PatternValidator { 71 | m.validators = append(m.validators, struct { 72 | pattern string 73 | validator Validator 74 | }{glob, validator}) 75 | 76 | if _, err := path.Match(glob, ""); err != nil { 77 | panic(fmt.Errorf("failed adding %q: %w", glob, err)) 78 | } 79 | return m 80 | } 81 | 82 | // SkipValidation skips validation for the given glob pattern. 83 | func (m *PatternValidator) SkipValidation(glob string) *PatternValidator { 84 | _ = m.Add(glob, nil) 85 | // move skip rule to the beginning of the list to ensure it is matched 86 | // before the validation rules 87 | if size := len(m.validators); size > 0 { 88 | m.validators = append(m.validators[size-1:], m.validators[0:size-1]...) 89 | } 90 | return m 91 | } 92 | 93 | func (m *PatternValidator) findValidator(filename string) (Validator, error) { 94 | for _, item := range m.validators { 95 | if match, err := path.Match(item.pattern, filename); match { 96 | return item.validator, nil 97 | } else if err != nil { 98 | return nil, err 99 | } 100 | } 101 | return nil, ErrValidatorNotFound 102 | } 103 | 104 | // Validate delegates to the first matching Validator that was configured with Add. 105 | // It fails with ErrValidatorNotFound if no matching validator is configured. 106 | func (m *PatternValidator) Validate(filename string, release, asset []byte) error { 107 | if validator, err := m.findValidator(filename); err == nil { 108 | if validator == nil { 109 | return nil // OK, this file does not need to be validated 110 | } 111 | return validator.Validate(filename, release, asset) 112 | } else { 113 | return err 114 | } 115 | } 116 | 117 | // GetValidationAssetName returns the asset name for validation. 118 | func (m *PatternValidator) GetValidationAssetName(releaseFilename string) string { 119 | if validator, err := m.findValidator(releaseFilename); err == nil { 120 | if validator == nil { 121 | return releaseFilename // Return a file that we know will exist 122 | } 123 | return validator.GetValidationAssetName(releaseFilename) 124 | } else { 125 | return releaseFilename // do not produce an error here to ensure err will be logged. 126 | } 127 | } 128 | 129 | // MustContinueValidation returns true if validation must continue on the specified filename 130 | func (m *PatternValidator) MustContinueValidation(filename string) bool { 131 | if validator, err := m.findValidator(filename); err == nil && validator != nil { 132 | if rv, ok := validator.(RecursiveValidator); ok && rv != m { 133 | return rv.MustContinueValidation(filename) 134 | } 135 | return true 136 | } 137 | return false 138 | } 139 | 140 | //===================================================================================================================== 141 | 142 | // SHAValidator specifies a SHA256 validator for additional file validation 143 | // before updating. 144 | type SHAValidator struct { 145 | } 146 | 147 | // Validate checks the SHA256 sum of the release against the contents of an 148 | // additional asset file. 149 | func (v *SHAValidator) Validate(filename string, release, asset []byte) error { 150 | // we'd better check the size of the file otherwise it's going to panic 151 | if len(asset) < sha256.BlockSize { 152 | return ErrIncorrectChecksumFile 153 | } 154 | 155 | hash := string(asset[:sha256.BlockSize]) 156 | calculatedHash := fmt.Sprintf("%x", sha256.Sum256(release)) 157 | 158 | if equal, err := hexStringEquals(sha256.Size, calculatedHash, hash); !equal { 159 | if err == nil { 160 | return fmt.Errorf("expected %q, found %q: %w", hash, calculatedHash, ErrChecksumValidationFailed) 161 | } else { 162 | return fmt.Errorf("%s: %w", err.Error(), ErrChecksumValidationFailed) 163 | } 164 | } 165 | return nil 166 | } 167 | 168 | // GetValidationAssetName returns the asset name for SHA256 validation. 169 | func (v *SHAValidator) GetValidationAssetName(releaseFilename string) string { 170 | return releaseFilename + ".sha256" 171 | } 172 | 173 | //===================================================================================================================== 174 | 175 | // ChecksumValidator is a SHA256 checksum validator where all the validation hash are in a single file (one per line) 176 | type ChecksumValidator struct { 177 | // UniqueFilename is the name of the global file containing all the checksums 178 | // Usually "checksums.txt", "SHA256SUMS", etc. 179 | UniqueFilename string 180 | } 181 | 182 | // Validate the SHA256 sum of the release against the contents of an 183 | // additional asset file containing all the checksums (one file per line). 184 | func (v *ChecksumValidator) Validate(filename string, release, asset []byte) error { 185 | hash, err := findChecksum(filename, asset) 186 | if err != nil { 187 | return err 188 | } 189 | return new(SHAValidator).Validate(filename, release, []byte(hash)) 190 | } 191 | 192 | func findChecksum(filename string, content []byte) (string, error) { 193 | // check if the file has windows line ending (probably better than just testing the platform) 194 | crlf := []byte("\r\n") 195 | lf := []byte("\n") 196 | eol := lf 197 | if bytes.Contains(content, crlf) { 198 | log.Print("Checksum file is using windows line ending") 199 | eol = crlf 200 | } 201 | lines := bytes.Split(content, eol) 202 | log.Printf("Checksum validator: %d checksums available, searching for %q", len(lines), filename) 203 | for _, line := range lines { 204 | // skip empty line 205 | if len(line) == 0 { 206 | continue 207 | } 208 | parts := bytes.Split(line, []byte(" ")) 209 | if len(parts) != 2 { 210 | return "", ErrIncorrectChecksumFile 211 | } 212 | if string(parts[1]) == filename { 213 | return string(parts[0]), nil 214 | } 215 | } 216 | return "", ErrHashNotFound 217 | } 218 | 219 | // GetValidationAssetName returns the unique asset name for SHA256 validation. 220 | func (v *ChecksumValidator) GetValidationAssetName(releaseFilename string) string { 221 | return v.UniqueFilename 222 | } 223 | 224 | //===================================================================================================================== 225 | 226 | // ECDSAValidator specifies a ECDSA validator for additional file validation 227 | // before updating. 228 | type ECDSAValidator struct { 229 | PublicKey *ecdsa.PublicKey 230 | } 231 | 232 | // WithPublicKey is a convenience method to set PublicKey from a PEM encoded 233 | // ECDSA certificate 234 | func (v *ECDSAValidator) WithPublicKey(pemData []byte) *ECDSAValidator { 235 | block, _ := pem.Decode(pemData) 236 | if block == nil || block.Type != "CERTIFICATE" { 237 | panic(fmt.Errorf("failed to decode PEM block")) 238 | } 239 | 240 | cert, err := x509.ParseCertificate(block.Bytes) 241 | if err == nil { 242 | var ok bool 243 | if v.PublicKey, ok = cert.PublicKey.(*ecdsa.PublicKey); !ok { 244 | err = fmt.Errorf("not an ECDSA public key") 245 | } 246 | } 247 | if err != nil { 248 | panic(fmt.Errorf("failed to parse certificate in PEM block: %w", err)) 249 | } 250 | 251 | return v 252 | } 253 | 254 | // Validate checks the ECDSA signature of the release against the signature 255 | // contained in an additional asset file. 256 | func (v *ECDSAValidator) Validate(filename string, input, signature []byte) error { 257 | h := sha256.New() 258 | h.Write(input) 259 | 260 | log.Printf("Verifying ECDSA signature on %q", filename) 261 | var rs struct { 262 | R *big.Int 263 | S *big.Int 264 | } 265 | if _, err := asn1.Unmarshal(signature, &rs); err != nil { 266 | return ErrInvalidECDSASignature 267 | } 268 | 269 | if v.PublicKey == nil || !ecdsa.Verify(v.PublicKey, h.Sum([]byte{}), rs.R, rs.S) { 270 | return ErrECDSAValidationFailed 271 | } 272 | 273 | return nil 274 | } 275 | 276 | // GetValidationAssetName returns the asset name for ECDSA validation. 277 | func (v *ECDSAValidator) GetValidationAssetName(releaseFilename string) string { 278 | return releaseFilename + ".sig" 279 | } 280 | 281 | //===================================================================================================================== 282 | 283 | // PGPValidator specifies a PGP validator for additional file validation 284 | // before updating. 285 | type PGPValidator struct { 286 | // KeyRing is usually filled by openpgp.ReadArmoredKeyRing(bytes.NewReader(key)) with key being the PGP pub key. 287 | KeyRing openpgp.EntityList 288 | // Binary toggles whether to validate detached *.sig (binary) or *.asc (ascii) signature files 289 | Binary bool 290 | } 291 | 292 | // WithArmoredKeyRing is a convenience method to set KeyRing 293 | func (g *PGPValidator) WithArmoredKeyRing(key []byte) *PGPValidator { 294 | if ring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(key)); err == nil { 295 | g.KeyRing = ring 296 | } else { 297 | panic(fmt.Errorf("failed setting armored public key ring: %w", err)) 298 | } 299 | return g 300 | } 301 | 302 | // Validate checks the PGP signature of the release against the signature 303 | // contained in an additional asset file. 304 | func (g *PGPValidator) Validate(filename string, release, signature []byte) (err error) { 305 | if g.KeyRing == nil { 306 | return ErrPGPKeyRingNotSet 307 | } 308 | log.Printf("Verifying PGP signature on %q", filename) 309 | 310 | data, sig := bytes.NewReader(release), bytes.NewReader(signature) 311 | if g.Binary { 312 | _, err = openpgp.CheckDetachedSignature(g.KeyRing, data, sig) 313 | } else { 314 | _, err = openpgp.CheckArmoredDetachedSignature(g.KeyRing, data, sig) 315 | } 316 | 317 | if errors.Is(err, io.EOF) { 318 | err = ErrInvalidPGPSignature 319 | } 320 | 321 | return err 322 | } 323 | 324 | // GetValidationAssetName returns the asset name for PGP validation. 325 | func (g *PGPValidator) GetValidationAssetName(releaseFilename string) string { 326 | if g.Binary { 327 | return releaseFilename + ".sig" 328 | } 329 | return releaseFilename + ".asc" 330 | } 331 | 332 | //===================================================================================================================== 333 | 334 | func hexStringEquals(size int, a, b string) (equal bool, err error) { 335 | size *= 2 336 | if len(a) == size && len(b) == size { 337 | var bytesA, bytesB []byte 338 | if bytesA, err = hex.DecodeString(a); err == nil { 339 | if bytesB, err = hex.DecodeString(b); err == nil { 340 | equal = bytes.Equal(bytesA, bytesB) 341 | } 342 | } 343 | } 344 | return 345 | } 346 | 347 | // NewChecksumWithECDSAValidator returns a validator that checks assets with a checksums file 348 | // (e.g. SHA256SUMS) and the checksums file with an ECDSA signature (e.g. SHA256SUMS.sig). 349 | func NewChecksumWithECDSAValidator(checksumsFilename string, pemECDSACertificate []byte) Validator { 350 | return new(PatternValidator). 351 | Add(checksumsFilename, new(ECDSAValidator).WithPublicKey(pemECDSACertificate)). 352 | Add("*", &ChecksumValidator{UniqueFilename: checksumsFilename}). 353 | SkipValidation("*.sig") 354 | } 355 | 356 | // NewChecksumWithPGPValidator returns a validator that checks assets with a checksums file 357 | // (e.g. SHA256SUMS) and the checksums file with an armored PGP signature (e.g. SHA256SUMS.asc). 358 | func NewChecksumWithPGPValidator(checksumsFilename string, armoredPGPKeyRing []byte) Validator { 359 | return new(PatternValidator). 360 | Add(checksumsFilename, new(PGPValidator).WithArmoredKeyRing(armoredPGPKeyRing)). 361 | Add("*", &ChecksumValidator{UniqueFilename: checksumsFilename}). 362 | SkipValidation("*.asc") 363 | } 364 | 365 | //===================================================================================================================== 366 | 367 | // Verify interface 368 | var ( 369 | _ Validator = &SHAValidator{} 370 | _ Validator = &ChecksumValidator{} 371 | _ Validator = &ECDSAValidator{} 372 | _ Validator = &PGPValidator{} 373 | _ Validator = &PatternValidator{} 374 | _ RecursiveValidator = &PatternValidator{} 375 | ) 376 | -------------------------------------------------------------------------------- /validate_test.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "bytes" 5 | "crypto/ecdsa" 6 | "crypto/x509" 7 | "encoding/hex" 8 | "encoding/pem" 9 | "fmt" 10 | "io" 11 | "os" 12 | "testing" 13 | 14 | "github.com/stretchr/testify/assert" 15 | "github.com/stretchr/testify/require" 16 | "golang.org/x/crypto/openpgp" 17 | "golang.org/x/crypto/openpgp/armor" 18 | ) 19 | 20 | func TestValidatorAssetNames(t *testing.T) { 21 | filename := "asset" 22 | for _, test := range []struct { 23 | validator Validator 24 | validationName string 25 | }{ 26 | { 27 | validator: &SHAValidator{}, 28 | validationName: filename + ".sha256", 29 | }, 30 | { 31 | validator: &ECDSAValidator{}, 32 | validationName: filename + ".sig", 33 | }, 34 | { 35 | validator: &PGPValidator{}, 36 | validationName: filename + ".asc", 37 | }, 38 | { 39 | validator: &PGPValidator{Binary: true}, 40 | validationName: filename + ".sig", 41 | }, 42 | { 43 | validator: &ChecksumValidator{"funny_sha256"}, 44 | validationName: "funny_sha256", 45 | }, 46 | } { 47 | want := test.validationName 48 | got := test.validator.GetValidationAssetName(filename) 49 | if want != got { 50 | t.Errorf("Wanted %q but got %q", want, got) 51 | } 52 | } 53 | } 54 | 55 | // ======= PatternValidator ================================================ 56 | 57 | func TestPatternValidator(t *testing.T) { 58 | data, err := os.ReadFile("testdata/foo.zip") 59 | require.NoError(t, err) 60 | 61 | hashData, err := os.ReadFile("testdata/foo.zip.sha256") 62 | require.NoError(t, err) 63 | 64 | t.Run("Mapping", func(t *testing.T) { 65 | validator := new(PatternValidator).Add("foo.*", new(SHAValidator)) 66 | { 67 | v, _ := validator.findValidator("foo.ext") 68 | assert.IsType(t, &SHAValidator{}, v) 69 | } 70 | 71 | assert.True(t, validator.MustContinueValidation("foo.zip")) 72 | assert.NoError(t, validator.Validate("foo.zip", data, hashData)) 73 | assert.Equal(t, "foo.zip.sha256", validator.GetValidationAssetName("foo.zip")) 74 | 75 | assert.Error(t, validator.Validate("foo.zip", data, data)) 76 | assert.Error(t, validator.Validate("unmapped", data, hashData)) 77 | }) 78 | 79 | t.Run("MappingInvalidPanics", func(t *testing.T) { 80 | assert.PanicsWithError(t, "failed adding \"\\\\\": syntax error in pattern", func() { 81 | new(PatternValidator).Add("\\", new(SHAValidator)) 82 | }) 83 | }) 84 | 85 | t.Run("Skip", func(t *testing.T) { 86 | validator := new(PatternValidator).SkipValidation("*.skipped") 87 | 88 | assert.False(t, validator.MustContinueValidation("foo.skipped")) 89 | assert.NoError(t, validator.Validate("foo.skipped", nil, nil)) 90 | assert.Equal(t, "foo.skipped", validator.GetValidationAssetName("foo.skipped")) 91 | }) 92 | 93 | t.Run("Unmapped", func(t *testing.T) { 94 | validator := new(PatternValidator) 95 | 96 | assert.False(t, validator.MustContinueValidation("foo.zip")) 97 | assert.ErrorIs(t, ErrValidatorNotFound, validator.Validate("foo.zip", data, hashData)) 98 | assert.Equal(t, "foo.zip", validator.GetValidationAssetName("foo.zip")) 99 | }) 100 | 101 | t.Run("SupportsNesting", func(t *testing.T) { 102 | nested := new(PatternValidator).Add("**/*.zip", new(SHAValidator)) 103 | validator := new(PatternValidator).Add("path/**", nested) 104 | { 105 | v, _ := validator.findValidator("path/foo") 106 | assert.Equal(t, nested, v) 107 | } 108 | 109 | assert.True(t, validator.MustContinueValidation("path/foo.zip")) 110 | assert.False(t, validator.MustContinueValidation("path/other")) 111 | assert.NoError(t, validator.Validate("path/foo.zip", data, hashData)) 112 | assert.Error(t, validator.Validate("foo.zip", data, hashData)) 113 | }) 114 | } 115 | 116 | // ======= SHAValidator ==================================================== 117 | 118 | func TestSHAValidatorEmptyFile(t *testing.T) { 119 | validator := &SHAValidator{} 120 | data, err := os.ReadFile("testdata/foo.zip") 121 | require.NoError(t, err) 122 | err = validator.Validate("foo.zip", data, nil) 123 | assert.EqualError(t, err, ErrIncorrectChecksumFile.Error()) 124 | } 125 | 126 | func TestSHAValidatorInvalidFile(t *testing.T) { 127 | validator := &SHAValidator{} 128 | data, err := os.ReadFile("testdata/foo.zip") 129 | require.NoError(t, err) 130 | err = validator.Validate("foo.zip", data, []byte("blahblahblah\n")) 131 | assert.EqualError(t, err, ErrIncorrectChecksumFile.Error()) 132 | } 133 | 134 | func TestSHAValidator(t *testing.T) { 135 | validator := &SHAValidator{} 136 | data, err := os.ReadFile("testdata/foo.zip") 137 | require.NoError(t, err) 138 | 139 | hashData, err := os.ReadFile("testdata/foo.zip.sha256") 140 | require.NoError(t, err) 141 | 142 | err = validator.Validate("foo.zip", data, hashData) 143 | assert.NoError(t, err) 144 | } 145 | 146 | func TestSHAValidatorFail(t *testing.T) { 147 | validator := &SHAValidator{} 148 | data, err := os.ReadFile("testdata/foo.zip") 149 | require.NoError(t, err) 150 | 151 | hashData, err := os.ReadFile("testdata/foo.zip.sha256") 152 | require.NoError(t, err) 153 | 154 | hashData[0] = '0' 155 | err = validator.Validate("foo.zip", data, hashData) 156 | assert.ErrorIs(t, err, ErrChecksumValidationFailed) 157 | } 158 | 159 | // ======= ECDSAValidator ==================================================== 160 | 161 | func TestECDSAValidatorNoPublicKey(t *testing.T) { 162 | validator := &ECDSAValidator{ 163 | PublicKey: nil, 164 | } 165 | data, err := os.ReadFile("testdata/foo.zip") 166 | require.NoError(t, err) 167 | 168 | signatureData, err := os.ReadFile("testdata/foo.zip.sig") 169 | require.NoError(t, err) 170 | 171 | err = validator.Validate("foo.zip", data, signatureData) 172 | assert.EqualError(t, err, ErrECDSAValidationFailed.Error()) 173 | } 174 | 175 | func TestECDSAValidatorEmptySignature(t *testing.T) { 176 | validator := &ECDSAValidator{ 177 | PublicKey: getTestPublicKey(t), 178 | } 179 | data, err := os.ReadFile("testdata/foo.zip") 180 | require.NoError(t, err) 181 | 182 | err = validator.Validate("foo.zip", data, nil) 183 | assert.EqualError(t, err, ErrInvalidECDSASignature.Error()) 184 | } 185 | 186 | func TestECDSAValidator(t *testing.T) { 187 | validator := &ECDSAValidator{ 188 | PublicKey: getTestPublicKey(t), 189 | } 190 | data, err := os.ReadFile("testdata/foo.zip") 191 | require.NoError(t, err) 192 | 193 | signatureData, err := os.ReadFile("testdata/foo.zip.sig") 194 | require.NoError(t, err) 195 | 196 | err = validator.Validate("foo.zip", data, signatureData) 197 | assert.NoError(t, err) 198 | } 199 | 200 | func TestECDSAValidatorWithKeyFromPem(t *testing.T) { 201 | pemData, err := os.ReadFile("testdata/Test.crt") 202 | require.NoError(t, err) 203 | 204 | validator := new(ECDSAValidator).WithPublicKey(pemData) 205 | assert.True(t, getTestPublicKey(t).Equal(validator.PublicKey)) 206 | 207 | assert.PanicsWithError(t, "failed to decode PEM block", func() { 208 | new(ECDSAValidator).WithPublicKey([]byte{}) 209 | }) 210 | 211 | assert.PanicsWithError(t, "failed to parse certificate in PEM block: x509: malformed certificate", func() { 212 | new(ECDSAValidator).WithPublicKey([]byte(` 213 | -----BEGIN CERTIFICATE----- 214 | 215 | -----END CERTIFICATE----- 216 | `)) 217 | }) 218 | } 219 | 220 | func TestECDSAValidatorFail(t *testing.T) { 221 | validator := &ECDSAValidator{ 222 | PublicKey: getTestPublicKey(t), 223 | } 224 | data, err := os.ReadFile("testdata/foo.tar.xz") 225 | require.NoError(t, err) 226 | 227 | signatureData, err := os.ReadFile("testdata/foo.zip.sig") 228 | require.NoError(t, err) 229 | 230 | err = validator.Validate("foo.tar.xz", data, signatureData) 231 | assert.EqualError(t, err, ErrECDSAValidationFailed.Error()) 232 | } 233 | 234 | func getTestPublicKey(t *testing.T) *ecdsa.PublicKey { 235 | pemData, err := os.ReadFile("testdata/Test.crt") 236 | require.NoError(t, err) 237 | 238 | block, _ := pem.Decode(pemData) 239 | if block == nil || block.Type != "CERTIFICATE" { 240 | t.Fatalf("failed to decode PEM block") 241 | } 242 | 243 | cert, err := x509.ParseCertificate(block.Bytes) 244 | require.NoError(t, err) 245 | 246 | pubKey, ok := cert.PublicKey.(*ecdsa.PublicKey) 247 | if !ok { 248 | t.Errorf("PublicKey is not ECDSA") 249 | } 250 | return pubKey 251 | } 252 | 253 | // ======= PGPValidator ====================================================== 254 | 255 | func TestPGPValidator(t *testing.T) { 256 | data, err := os.ReadFile("testdata/foo.zip") 257 | require.NoError(t, err) 258 | 259 | otherData, err := os.ReadFile("testdata/foo.tar.xz") 260 | require.NoError(t, err) 261 | 262 | keyRing, entity := getTestPGPKeyRing(t) 263 | require.NotNil(t, keyRing) 264 | require.NotNil(t, entity) 265 | 266 | var signatureData []byte 267 | { 268 | signature := &bytes.Buffer{} 269 | err = openpgp.ArmoredDetachSign(signature, entity, bytes.NewReader(data), nil) 270 | require.NoError(t, err) 271 | signatureData = signature.Bytes() 272 | } 273 | 274 | t.Run("NoPublicKey", func(t *testing.T) { 275 | validator := new(PGPValidator) 276 | err = validator.Validate("foo.zip", data, signatureData) 277 | assert.ErrorIs(t, err, ErrPGPKeyRingNotSet) 278 | err = validator.Validate("foo.zip", data, nil) 279 | assert.ErrorIs(t, err, ErrPGPKeyRingNotSet) 280 | err = validator.Validate("foo.zip", data, []byte{}) 281 | assert.ErrorIs(t, err, ErrPGPKeyRingNotSet) 282 | }) 283 | 284 | t.Run("EmptySignature", func(t *testing.T) { 285 | validator := new(PGPValidator).WithArmoredKeyRing(keyRing) 286 | err = validator.Validate("foo.zip", data, nil) 287 | assert.ErrorIs(t, err, ErrInvalidPGPSignature) 288 | err = validator.Validate("foo.zip", data, []byte{}) 289 | assert.ErrorIs(t, err, ErrInvalidPGPSignature) 290 | }) 291 | 292 | t.Run("InvalidSignature", func(t *testing.T) { 293 | validator := new(PGPValidator).WithArmoredKeyRing(keyRing) 294 | err = validator.Validate("foo.zip", data, []byte{0, 1, 2}) 295 | assert.ErrorIs(t, err, ErrInvalidPGPSignature) 296 | err = validator.Validate("foo.zip", data, data) 297 | assert.ErrorIs(t, err, ErrInvalidPGPSignature) 298 | }) 299 | 300 | t.Run("ValidSignature", func(t *testing.T) { 301 | validator := new(PGPValidator).WithArmoredKeyRing(keyRing) 302 | err = validator.Validate("foo.zip", data, signatureData) 303 | assert.NoError(t, err) 304 | }) 305 | 306 | t.Run("Fail", func(t *testing.T) { 307 | validator := new(PGPValidator).WithArmoredKeyRing(keyRing) 308 | err = validator.Validate("foo.tar.xz", otherData, signatureData) 309 | assert.EqualError(t, err, "openpgp: invalid signature: hash tag doesn't match") 310 | }) 311 | } 312 | 313 | func TestPGPValidatorWithArmoredKeyRing(t *testing.T) { 314 | keyRing, entity := getTestPGPKeyRing(t) 315 | validator := new(PGPValidator).WithArmoredKeyRing(keyRing) 316 | assert.Equal(t, entity.PrimaryKey.KeyIdString(), validator.KeyRing[0].PrimaryKey.KeyIdString()) 317 | 318 | assert.PanicsWithError(t, "failed setting armored public key ring: openpgp: invalid argument: no armored data found", func() { 319 | new(PGPValidator).WithArmoredKeyRing([]byte{}) 320 | }) 321 | } 322 | 323 | func getTestPGPKeyRing(t *testing.T) (PGPKeyRing []byte, entity *openpgp.Entity) { 324 | var err error 325 | var armoredWriter io.WriteCloser 326 | entity, err = openpgp.NewEntity("go-selfupdate", "", "info@go-selfupdate.local", nil) 327 | require.NoError(t, err) 328 | 329 | buffer := &bytes.Buffer{} 330 | if armoredWriter, err = armor.Encode(buffer, openpgp.PublicKeyType, nil); err == nil { 331 | if err = entity.Serialize(armoredWriter); err == nil { 332 | err = armoredWriter.Close() 333 | } 334 | } 335 | require.NoError(t, err) 336 | PGPKeyRing = buffer.Bytes() 337 | return 338 | } 339 | 340 | // ======= ChecksumValidator ==================================================== 341 | 342 | func TestChecksumValidatorEmptyFile(t *testing.T) { 343 | data, err := os.ReadFile("testdata/foo.zip") 344 | require.NoError(t, err) 345 | 346 | validator := &ChecksumValidator{} 347 | err = validator.Validate("foo.zip", data, nil) 348 | assert.EqualError(t, err, ErrHashNotFound.Error()) 349 | } 350 | 351 | func TestChecksumValidatorInvalidChecksumFile(t *testing.T) { 352 | data, err := os.ReadFile("testdata/foo.zip") 353 | require.NoError(t, err) 354 | 355 | validator := &ChecksumValidator{} 356 | err = validator.Validate("foo.zip", data, []byte("blahblahblah")) 357 | assert.EqualError(t, err, ErrIncorrectChecksumFile.Error()) 358 | } 359 | 360 | func TestChecksumValidatorWithUniqueLine(t *testing.T) { 361 | data, err := os.ReadFile("testdata/foo.zip") 362 | require.NoError(t, err) 363 | 364 | hashData, err := os.ReadFile("testdata/foo.zip.sha256") 365 | require.NoError(t, err) 366 | 367 | validator := &ChecksumValidator{} 368 | err = validator.Validate("foo.zip", data, hashData) 369 | require.NoError(t, err) 370 | } 371 | 372 | func TestChecksumValidatorWillFailWithWrongHash(t *testing.T) { 373 | data, err := os.ReadFile("testdata/foo.tar.xz") 374 | require.NoError(t, err) 375 | 376 | hashData, err := os.ReadFile("testdata/foo.zip.sha256") 377 | require.NoError(t, err) 378 | 379 | validator := &ChecksumValidator{} 380 | err = validator.Validate("foo.zip", data, hashData) 381 | assert.ErrorIs(t, err, ErrChecksumValidationFailed) 382 | } 383 | 384 | func TestChecksumNotFound(t *testing.T) { 385 | data, err := os.ReadFile("testdata/bar-not-found.zip") 386 | require.NoError(t, err) 387 | 388 | hashData, err := os.ReadFile("testdata/SHA256SUM") 389 | require.NoError(t, err) 390 | 391 | validator := &ChecksumValidator{} 392 | err = validator.Validate("bar-not-found.zip", data, hashData) 393 | assert.EqualError(t, err, ErrHashNotFound.Error()) 394 | } 395 | 396 | func TestChecksumValidatorSuccess(t *testing.T) { 397 | data, err := os.ReadFile("testdata/foo.tar.xz") 398 | require.NoError(t, err) 399 | 400 | hashData, err := os.ReadFile("testdata/SHA256SUM") 401 | require.NoError(t, err) 402 | 403 | validator := &ChecksumValidator{"SHA256SUM"} 404 | err = validator.Validate("foo.tar.xz", data, hashData) 405 | assert.NoError(t, err) 406 | } 407 | 408 | // ======= Utilities ========================================================= 409 | 410 | func TestHexStringEquals(t *testing.T) { 411 | tests := []struct { 412 | equal bool 413 | size int 414 | a, b string 415 | err error 416 | }{ 417 | {true, 0, "", "", nil}, 418 | {true, 1, "b1", "b1", nil}, 419 | {true, 1, "b1", "B1", nil}, 420 | {true, 2, "b1AA", "B1aa", nil}, 421 | {false, 1, "", "", nil}, 422 | {false, 0, "b1", "b1", nil}, 423 | {false, 0, "b", "b", nil}, 424 | {false, 1, "b", "b", nil}, 425 | {false, 1, "b2", "b1", nil}, 426 | {false, 2, "b1", "b1", nil}, 427 | {false, 2, "b1AA", "aab1", nil}, 428 | {false, 3, "b1", "b1", nil}, 429 | {false, 2, "aaXX", "aaXX", hex.InvalidByteError('X')}, 430 | } 431 | for i, test := range tests { 432 | t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { 433 | equal, err := hexStringEquals(test.size, test.a, test.b) 434 | assert.Equal(t, test.equal, equal) 435 | assert.ErrorIs(t, err, test.err) 436 | }) 437 | } 438 | } 439 | 440 | func TestNewChecksumWithECDSAValidator(t *testing.T) { 441 | pemData, err := os.ReadFile("testdata/Test.crt") 442 | require.NoError(t, err) 443 | 444 | validator := NewChecksumWithECDSAValidator("checksums", pemData) 445 | assert.Implements(t, (*RecursiveValidator)(nil), validator) 446 | assert.Equal(t, "checksums", validator.GetValidationAssetName("anything")) 447 | assert.Equal(t, "checksums.sig", validator.GetValidationAssetName("checksums")) 448 | } 449 | 450 | func TestNewChecksumWithPGPValidator(t *testing.T) { 451 | keyRing, _ := getTestPGPKeyRing(t) 452 | 453 | validator := NewChecksumWithPGPValidator("checksums", keyRing) 454 | assert.Implements(t, (*RecursiveValidator)(nil), validator) 455 | assert.Equal(t, "checksums", validator.GetValidationAssetName("anything")) 456 | assert.Equal(t, "checksums.asc", validator.GetValidationAssetName("checksums")) 457 | } 458 | --------------------------------------------------------------------------------