diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 45855c2..47c4f5e 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -43,6 +43,7 @@ archives: - CONTRIBUTING.md - SECURITY.md - docs/telemetry.md + - docs/updates.md checksum: name_template: checksums.txt diff --git a/README.md b/README.md index 73caf6d..23871c8 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,9 @@ Install Terraform only when you are ready to validate and import the generated f To build from source, see [Contributing](CONTRIBUTING.md). +Interactive commands check GitHub Releases for updates. A notice links to the newer release. +Set `DATATF_NO_UPDATE_NOTIFIER=1` to disable checks. See [upgrade instructions](docs/updates.md). + ## Quick start Use the [Databricks CLI](https://learn.microsoft.com/en-us/azure/databricks/dev-tools/cli/install) diff --git a/docs/telemetry.md b/docs/telemetry.md index 9bedd87..6174817 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -1,6 +1,7 @@ # Optional telemetry Telemetry is off by default. DataTF exports work without telemetry. +Automatic [update checks](updates.md) are separate from telemetry and have their own control. 536 Technologies uses optional command metrics to choose platform support, resource coverage, and reliability work. diff --git a/docs/updates.md b/docs/updates.md new file mode 100644 index 0000000..200bb15 --- /dev/null +++ b/docs/updates.md @@ -0,0 +1,59 @@ +# Upgrade DataTF + +DataTF shows an update notice when a newer stable release is available. +It does not install updates or change your Terraform files. + +1. Open the release link in the notice. +2. Download the archive for your system and processor. +3. Compare its SHA-256 checksum with the value in `checksums.txt` from the same release. +4. Extract the archive. +5. Replace your installed `datatf` binary, or `datatf.exe` on Windows. +6. Run `datatf version` to check the installed version. + +Use `command -v datatf` on macOS or Linux to find the installed binary. +Use `where.exe datatf` in PowerShell on Windows. +If your company manages the installation, ask its administrator to approve the update. + +## Check behavior + +DataTF checks after a successful interactive command, help output, or a launch without a command. +It writes the notice to stderr. It does not change stdout, the exit code, or export files. +Only stable builds check for updates. Development builds and prereleases do not check. + +Checks require terminal output on both stdout and stderr. +JSON, plain, quiet, completion, and telemetry commands do not check. +Detected CI and agent sessions do not check. +DataTF uses the same CI and agent indicators listed in the [telemetry notice](telemetry.md). + +Results and failed attempts stay in a local cache for 24 hours. +The cache contains only a check time and a release version. +DataTF stores it in `datatf/update.json` under the operating system's user cache directory. +If that directory is unavailable, DataTF skips the check. + +Each check has a 500-millisecond network limit. Network failures produce no notice. +There are no retries or redirects. Normal HTTPS certificate checks and proxy settings apply. + +## Network access and controls + +Update checks are separate from optional telemetry. +They request public release metadata from `api.github.com` without authentication. +They send no installed version, workspace data, profile, command arguments, or credentials. +GitHub receives the request's network metadata, including an IP address. + +To disable checks in the current shell: + +```sh +export DATATF_NO_UPDATE_NOTIFIER=1 +``` + +In PowerShell: + +```powershell +$env:DATATF_NO_UPDATE_NOTIFIER = '1' +``` + +Any nonempty `DATATF_NO_UPDATE_NOTIFIER` value disables the check and the notice. +`DO_NOT_TRACK=1` or `DO_NOT_TRACK=true` also disables both update checks and telemetry. +`DATATF_TELEMETRY=0` disables usage events only. It does not control update checks. + +You can always check [GitHub Releases](https://github.com/536tech/datatf/releases/latest) yourself. diff --git a/go.mod b/go.mod index 8c97e7a..2b98ccb 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,9 @@ require ( github.com/hashicorp/hcl/v2 v2.24.0 github.com/spf13/cobra v1.10.2 github.com/zclconf/go-cty v1.19.0 + golang.org/x/mod v0.30.0 golang.org/x/sync v0.22.0 + golang.org/x/term v0.45.0 ) require ( @@ -40,10 +42,9 @@ require ( go.opentelemetry.io/otel/trace v1.39.0 // indirect golang.org/x/crypto v0.46.0 // indirect golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect - golang.org/x/mod v0.30.0 // indirect golang.org/x/net v0.48.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect - golang.org/x/sys v0.39.0 // indirect + golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.32.0 // indirect golang.org/x/time v0.5.0 // indirect golang.org/x/tools v0.39.0 // indirect diff --git a/go.sum b/go.sum index bfe6592..f3d850f 100644 --- a/go.sum +++ b/go.sum @@ -152,8 +152,10 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= diff --git a/internal/cli/root.go b/internal/cli/root.go index 9d63464..81ec104 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -66,6 +66,9 @@ func Execute(ctx context.Context, args []string, stdin io.Reader, stdout, stderr code = rc.writeError(executed, err) } rc.finishUsage(err != nil) + if err == nil || len(args) == 0 { + rc.updateNotice(executed) + } return code } @@ -116,7 +119,7 @@ visible resources without local files. Neither proves full resource visibility.` flags.BoolVar(&rc.g.asJSON, "json", false, "emit JSON results to stdout and errors to stderr; no progress") flags.BoolVar(&rc.g.plain, "plain", false, "emit stable plain text where available") - flags.BoolVarP(&rc.g.quiet, "quiet", "q", false, "suppress progress output on stderr") + flags.BoolVarP(&rc.g.quiet, "quiet", "q", false, "suppress progress and update notices on stderr") flags.BoolVar(&rc.g.noColor, "no-color", false, "disable color") flags.BoolVar(&rc.g.showVersion, "version", false, "print version and exit") diff --git a/internal/cli/update.go b/internal/cli/update.go new file mode 100644 index 0000000..f104ef6 --- /dev/null +++ b/internal/cli/update.go @@ -0,0 +1,79 @@ +package cli + +import ( + "fmt" + "io" + "os" + "path/filepath" + goruntime "runtime" + "strings" + + "github.com/spf13/cobra" + "golang.org/x/term" + + "github.com/536tech/datatf/internal/update" +) + +var checkForUpdate = update.Check +var updateCacheDir = os.UserCacheDir +var terminalOutput = isTerminalOutput + +func (rc *runtime) updateNotice(cmd *cobra.Command) { + if !rc.updateOutputAllowed() || updateDisabled() { + return + } + for current := cmd; current != nil; current = current.Parent() { + switch current.Name() { + case "telemetry", "completion", "__complete", "__completeNoDesc", "help": + return + } + } + rc.writeUpdateNotice() +} + +func (rc *runtime) updateOutputAllowed() bool { + return !rc.g.asJSON && !rc.g.plain && !rc.g.quiet && + terminalOutput(rc.stdout) && terminalOutput(rc.stderr) +} + +func updateDisabled() bool { + if os.Getenv("DATATF_NO_UPDATE_NOTIFIER") != "" { + return true + } + if value := os.Getenv("DO_NOT_TRACK"); value == "1" || strings.EqualFold(value, "true") { + return true + } + for _, key := range []string{ + "CI", "GITHUB_ACTIONS", "TF_BUILD", "GITLAB_CI", "JENKINS_URL", + "CODEX_THREAD_ID", "CODEX_CI", "CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", + } { + if value := os.Getenv(key); value != "" && value != "0" && value != "false" { + return true + } + } + return false +} + +func (rc *runtime) writeUpdateNotice() { + dir, err := updateCacheDir() + if err != nil { + return + } + latest := checkForUpdate(rc.ctx, version, filepath.Join(dir, "datatf", "update.json"), now()) + if latest == "" { + return + } + binary := "datatf" + if goruntime.GOOS == "windows" { + binary = "datatf.exe" + } + _, _ = fmt.Fprintf(rc.stderr, + "\nDataTF %s is available (installed: %s).\nUpgrade: %s%s\n"+ + "Download the archive for your system. Verify its checksum. Replace %s on PATH.\n", + latest, version, update.Releases, latest, binary) +} + +func isTerminalOutput(writer io.Writer) bool { + file, ok := writer.(*os.File) + return ok && term.IsTerminal(int(file.Fd())) +} diff --git a/internal/cli/update_test.go b/internal/cli/update_test.go new file mode 100644 index 0000000..3c308ae --- /dev/null +++ b/internal/cli/update_test.go @@ -0,0 +1,153 @@ +package cli + +import ( + "context" + "io" + "os" + "reflect" + "strings" + "testing" + "time" + + "github.com/536tech/datatf/internal/fakews" +) + +func captureUpdateCheck(t *testing.T) *int { + t.Helper() + oldCheck, oldDir := checkForUpdate, updateCacheDir + oldTerminal, oldVersion := terminalOutput, version + t.Cleanup(func() { + checkForUpdate, updateCacheDir = oldCheck, oldDir + terminalOutput, version = oldTerminal, oldVersion + }) + for _, key := range []string{ + "DATATF_NO_UPDATE_NOTIFIER", "DO_NOT_TRACK", "CI", "GITHUB_ACTIONS", "TF_BUILD", + "GITLAB_CI", "JENKINS_URL", "CODEX_THREAD_ID", "CODEX_CI", "CLAUDECODE", + "CLAUDE_CODE_ENTRYPOINT", + } { + t.Setenv(key, "") + } + dir := t.TempDir() + updateCacheDir = func() (string, error) { return dir, nil } + terminalOutput = func(io.Writer) bool { return true } + version = "0.2.0" + calls := 0 + checkForUpdate = func(context.Context, string, string, time.Time) string { + calls++ + return "v0.3.0" + } + return &calls +} + +func TestUpdateNoticeForInteractiveLaunch(t *testing.T) { + calls := captureUpdateCheck(t) + for _, args := range [][]string{{}, {"--help"}, {"version"}, {"--version"}} { + code, stdout, stderr := run(t, args...) + want := exitOK + if len(args) == 0 { + want = exitUsage + } + if code != want || !strings.Contains(stderr, "DataTF v0.3.0 is available") { + t.Fatalf("%v: %d %s", args, code, stderr) + } + if strings.Contains(stdout, "Upgrade:") { + t.Fatal("notice changed stdout") + } + if !strings.Contains(stderr, "https://github.com/536tech/datatf/releases/tag/v0.3.0") { + t.Fatal("missing release link") + } + } + if *calls != 4 { + t.Fatalf("checks: %d", *calls) + } +} + +func TestUpdateNoticeExclusions(t *testing.T) { + calls := captureUpdateCheck(t) + for _, args := range [][]string{ + {"version", "--json"}, {"version", "--plain"}, {"version", "--quiet"}, + {"telemetry", "status"}, {"telemetry", "preview"}, {"help", "telemetry"}, + {"completion", "bash"}, {"__complete", "export", ""}, + {"invalid-command"}, {"export", "--scope", "invalid"}, + } { + _, _, stderr := run(t, args...) + if strings.Contains(stderr, "Upgrade:") { + t.Fatalf("notice for %v", args) + } + } + if *calls != 0 { + t.Fatalf("excluded commands made %d checks", *calls) + } +} + +func TestUpdateNoticeEnvironmentOptOut(t *testing.T) { + calls := captureUpdateCheck(t) + for _, key := range []string{ + "DATATF_NO_UPDATE_NOTIFIER", "DO_NOT_TRACK", "CI", "GITHUB_ACTIONS", "TF_BUILD", + "GITLAB_CI", "JENKINS_URL", "CODEX_THREAD_ID", "CODEX_CI", "CLAUDECODE", + "CLAUDE_CODE_ENTRYPOINT", + } { + t.Run(key, func(t *testing.T) { + t.Setenv(key, "1") + _, _, stderr := run(t, "version") + if strings.Contains(stderr, "Upgrade:") { + t.Fatal(stderr) + } + }) + } + if *calls != 0 { + t.Fatalf("opt-out made %d checks", *calls) + } +} + +func TestUpdateNoticeRequiresBothTerminals(t *testing.T) { + calls := captureUpdateCheck(t) + for _, failed := range []int{1, 2} { + checks := 0 + terminalOutput = func(io.Writer) bool { checks++; return checks != failed } + run(t, "version") + } + if *calls != 0 { + t.Fatal("redirected output made a check") + } + file, err := os.Open(os.DevNull) + if err != nil { + t.Fatal(err) + } + defer file.Close() + if isTerminalOutput(file) || isTerminalOutput(io.Discard) { + t.Fatal("not a terminal") + } +} + +func TestUpdateFailureDoesNotChangeExport(t *testing.T) { + calls := captureUpdateCheck(t) + isolateAuth(t, fakews.New(t)) + oldNow := now + now = func() time.Time { return time.Unix(0, 0) } + t.Cleanup(func() { now = oldNow }) + checkForUpdate = func(context.Context, string, string, time.Time) string { + (*calls)++ + return "" + } + var baselineFiles [][]byte + var baselineOutput string + for _, disabled := range []string{"1", ""} { + t.Setenv("DATATF_NO_UPDATE_NOTIFIER", disabled) + dir := t.TempDir() + code, stdout, stderr := run(t, "export", "--resources", "warehouses", "--out", dir) + if code != exitOK { + t.Fatalf("%d %s", code, stderr) + } + files := readExportFiles(t, dir) + output := strings.ReplaceAll(stdout+stderr, dir, "") + if disabled == "1" { + baselineFiles, baselineOutput = files, output + } else if !reflect.DeepEqual(files, baselineFiles) || output != baselineOutput { + t.Fatal("failed update check changed export files or output") + } + } + if *calls != 1 { + t.Fatalf("checks: %d", *calls) + } +} diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 0000000..d135a4f --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,158 @@ +// Package update checks public release metadata without credentials or usage events. +package update + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "golang.org/x/mod/semver" +) + +const ( + Timeout = 500 * time.Millisecond + Releases = "https://github.com/536tech/datatf/releases/tag/" + releaseAPI = "https://api.github.com/repos/536tech/datatf/releases/latest" + maxResponse = 1024 * 1024 +) + +type cache struct { + CheckedAt time.Time `json:"checked_at"` + Latest string `json:"latest"` +} + +var fetchLatest = func(ctx context.Context) (string, error) { return fetchRelease(ctx, releaseAPI) } + +// Check returns a newer stable version, or an empty string when no notice is available. +// A cache failure disables the check. Network failures are cached for 24 hours. +func Check(ctx context.Context, current, path string, now time.Time) string { + current = stableVersion(current) + if current == "" || ctx.Err() != nil { + return "" + } + saved := readCache(path) + age := now.Sub(saved.CheckedAt) + if age < 0 || age >= 24*time.Hour { + saved.CheckedAt = now + saved = refreshCache(ctx, path, saved) + } + latest := stableVersion(saved.Latest) + if semver.Compare(latest, current) > 0 { + return latest + } + return "" +} + +func refreshCache(ctx context.Context, path string, saved cache) cache { + // Record the attempt first so blocked networks do not delay every command. + if err := writeCache(path, saved); err != nil { + return cache{} + } + latest, err := fetchLatest(ctx) + if err != nil { + return cache{} + } + saved.Latest = stableVersion(latest) + if err := writeCache(path, saved); err != nil { + return cache{} + } + return saved +} + +func stableVersion(value string) string { + version := "v" + strings.TrimPrefix(value, "v") + if len(version) > 64 || semver.Canonical(version) != version || semver.Prerelease(version) != "" { + return "" + } + return version +} + +func fetchRelease(ctx context.Context, endpoint string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, Timeout) + defer cancel() + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return "", err + } + request.Header.Set("Accept", "application/vnd.github+json") + request.Header.Set("User-Agent", "datatf") + transport := &http.Transport{Proxy: http.ProxyFromEnvironment, DisableKeepAlives: true} + defer transport.CloseIdleConnections() + client := &http.Client{ + Transport: transport, Timeout: Timeout, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } + response, err := client.Do(request) + if err != nil { + return "", err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return "", fmt.Errorf("release check returned HTTP %d", response.StatusCode) + } + return decodeRelease(response.Body) +} + +func decodeRelease(body io.Reader) (string, error) { + data, err := io.ReadAll(io.LimitReader(body, maxResponse+1)) + if err != nil { + return "", err + } + if len(data) > maxResponse { + return "", fmt.Errorf("release response exceeds size limit") + } + var release struct { + Tag string `json:"tag_name"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` + } + if err := json.Unmarshal(data, &release); err != nil { + return "", err + } + if release.Draft || release.Prerelease { + return "", nil + } + return stableVersion(release.Tag), nil +} + +func readCache(path string) cache { + info, err := os.Lstat(path) + if err != nil || !info.Mode().IsRegular() || info.Size() > 4096 { + return cache{} + } + data, err := os.ReadFile(path) + if err != nil { + return cache{} + } + var saved cache + if json.Unmarshal(data, &saved) != nil { + return cache{} + } + return saved +} + +func writeCache(path string, saved cache) error { + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + file, err := os.CreateTemp(filepath.Dir(path), ".update-*") + if err != nil { + return err + } + defer os.Remove(file.Name()) + writeErr := json.NewEncoder(file).Encode(saved) + closeErr := file.Close() + if writeErr != nil { + return writeErr + } + if closeErr != nil { + return closeErr + } + return os.Rename(file.Name(), path) +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 0000000..bc57970 --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,187 @@ +package update + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestCheckVersionsAndDailyCache(t *testing.T) { + for _, test := range []struct{ current, latest, want string }{ + {"0.2.0", "v0.3.0", "v0.3.0"}, {"v0.9.0", "v0.10.0", "v0.10.0"}, + {"0.3.0", "v0.3.0", ""}, {"0.4.0", "v0.3.0", ""}, + {"dev", "v0.3.0", ""}, {"0.4.0-next", "v0.3.0", ""}, + {"0.3.0", "v0.4.0-rc.1", ""}, {"0.3.0", "v0.4.0\ncanary", ""}, + } { + t.Run(test.current+"/"+test.latest, func(t *testing.T) { + calls := 0 + old := fetchLatest + fetchLatest = func(context.Context) (string, error) { calls++; return test.latest, nil } + t.Cleanup(func() { fetchLatest = old }) + path := filepath.Join(t.TempDir(), "update.json") + now := time.Now() + for _, at := range []time.Time{now, now.Add(time.Hour)} { + if got := Check(context.Background(), test.current, path, at); got != test.want { + t.Fatalf("got %q, want %q", got, test.want) + } + } + wantCalls := 1 + if stableVersion(test.current) == "" { + wantCalls = 0 + } + if calls != wantCalls { + t.Fatalf("requests: %d, want %d", calls, wantCalls) + } + }) + } +} + +func TestCheckFailureAndCacheRecovery(t *testing.T) { + old := fetchLatest + t.Cleanup(func() { fetchLatest = old }) + calls := 0 + fetchLatest = func(context.Context) (string, error) { + calls++ + return "", fmt.Errorf("offline") + } + path := filepath.Join(t.TempDir(), "update.json") + now := time.Now() + if err := os.WriteFile(path, []byte("invalid cache"), 0600); err != nil { + t.Fatal(err) + } + for _, at := range []time.Time{now, now.Add(time.Hour)} { + if got := Check(context.Background(), "0.2.0", path, at); got != "" { + t.Fatal(got) + } + } + if calls != 1 { + t.Fatalf("offline requests: %d", calls) + } + fetchLatest = func(context.Context) (string, error) { return "v0.3.0", nil } + if got := Check(context.Background(), "0.2.0", path, now.Add(24*time.Hour)); got != "v0.3.0" { + t.Fatalf("expired cache: %q", got) + } + if got := Check(context.Background(), "0.3.0", path, now.Add(25*time.Hour)); got != "" { + t.Fatalf("notice after upgrade: %q", got) + } +} + +func TestFetchReleaseBoundary(t *testing.T) { + for _, test := range []struct { + name, body string + status int + want string + }{ + {"stable", `{"tag_name":"v0.3.0","html_url":"https://untrusted.invalid"}`, 200, "v0.3.0"}, + {"draft", `{"tag_name":"v0.3.0","draft":true}`, 200, ""}, + {"prerelease", `{"tag_name":"v0.3.0-rc.1","prerelease":true}`, 200, ""}, + {"invalid tag", `{"tag_name":"v0.3.0\u001b[31m"}`, 200, ""}, + {"truncated", `{"tag_name":`, 200, ""}, + {"trailing JSON", `{"tag_name":"v0.3.0"} {}`, 200, ""}, + {"rate limited", `{}`, 403, ""}, {"not found", `{}`, 404, ""}, + {"oversized", strings.Repeat(" ", maxResponse+1), 200, ""}, + } { + t.Run(test.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.RawQuery != "" || r.ContentLength > 0 { + t.Errorf("unexpected request: %s %s", r.Method, r.URL) + } + for _, header := range []string{"Authorization", "Cookie"} { + assertEmptyHeader(t, r, header) + } + if r.UserAgent() != "datatf" { + t.Errorf("user agent: %q", r.UserAgent()) + } + w.WriteHeader(test.status) + _, _ = fmt.Fprint(w, test.body) + })) + defer srv.Close() + got, _ := fetchRelease(context.Background(), srv.URL) + if got != test.want { + t.Fatalf("got %q, want %q", got, test.want) + } + }) + } +} + +func assertEmptyHeader(t *testing.T, r *http.Request, header string) { + t.Helper() + if r.Header.Get(header) != "" { + t.Errorf("request contains %s", header) + } +} + +func TestCheckSkipsNetworkWithoutCacheOrAfterCancellation(t *testing.T) { + old := fetchLatest + t.Cleanup(func() { fetchLatest = old }) + fetchLatest = func(context.Context) (string, error) { + t.Fatal("unexpected network request") + return "", nil + } + parent := filepath.Join(t.TempDir(), "not-a-directory") + if err := os.WriteFile(parent, nil, 0600); err != nil { + t.Fatal(err) + } + path := filepath.Join(parent, "cache") + if got := Check(context.Background(), "0.2.0", path, time.Now()); got != "" { + t.Fatal(got) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if got := Check(ctx, "0.2.0", filepath.Join(t.TempDir(), "cache"), time.Now()); got != "" { + t.Fatal(got) + } +} + +func TestCacheDoesNotFollowSymlinks(t *testing.T) { + dir := t.TempDir() + target, link := filepath.Join(dir, "target"), filepath.Join(dir, "update.json") + if err := os.WriteFile(target, []byte("preserve me"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, link); err != nil { + t.Skipf("symlinks are unavailable: %v", err) + } + if got := readCache(link); got.Latest != "" { + t.Fatal(got) + } + if err := writeCache(link, cache{CheckedAt: time.Now(), Latest: "v0.3.0"}); err != nil { + t.Fatal(err) + } + contents, err := os.ReadFile(target) + if err != nil || string(contents) != "preserve me" { + t.Fatal("cache write changed the symlink target") + } +} + +func TestFetchDoesNotRetryRedirectOrWaitPastTimeout(t *testing.T) { + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + http.Redirect(w, r, "/next", http.StatusFound) + })) + defer srv.Close() + if got, err := fetchRelease(context.Background(), srv.URL); got != "" || err == nil { + t.Fatalf("redirect: %q %v", got, err) + } + if calls != 1 { + t.Fatalf("requests: %d", calls) + } + slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-r.Context().Done() + })) + defer slow.Close() + start := time.Now() + if _, err := fetchRelease(context.Background(), slow.URL); err == nil { + t.Fatal("no timeout") + } + if time.Since(start) > 2*Timeout { + t.Fatal("exceeded timeout budget") + } +}