diff --git a/cmd/root/root.go b/cmd/root/root.go index 24e9b95c8..969c3b5ac 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -19,6 +19,7 @@ import ( "github.com/docker/docker-agent/pkg/feedback" "github.com/docker/docker-agent/pkg/logging" "github.com/docker/docker-agent/pkg/paths" + "github.com/docker/docker-agent/pkg/selfupdate" "github.com/docker/docker-agent/pkg/telemetry" "github.com/docker/docker-agent/pkg/version" ) @@ -168,6 +169,16 @@ We collect anonymous usage data to help improve docker agent. To disable: } func Execute(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args ...string) error { + selfupdate.Cleanup(ctx) + + // Opt-in self-update: when enabled, replace this binary with the latest + // release and re-exec before doing any real work. Skipped for invocations + // where restarting would be wrong (plugin metadata handshake, shell + // completion) and always falls back to the current binary on any failure. + if selfupdate.Enabled() && !isManagementInvocation(args) { + selfupdate.Run(ctx, stdin, stderr) + } + rootCmd := NewRootCmd() rootCmd.SetIn(stdin) rootCmd.SetOut(stdout) @@ -221,6 +232,32 @@ func visitAll(cmd *cobra.Command, fn func(*cobra.Command)) { } } +// isManagementInvocation reports whether args correspond to an invocation that +// must not trigger a self-update + restart: the docker CLI plugin metadata +// handshake, shell-completion script generation, and the version/help queries. +// Updating mid-handshake would corrupt the plugin protocol, and restarting a +// completion call would be surprising. +// +// Help and version are detected anywhere in args, not just at args[0], so that +// per-subcommand help (e.g. "run --help") is also skipped. +func isManagementInvocation(args []string) bool { + if len(args) == 0 { + return false + } + switch args[0] { + case metadata.MetadataSubcommandName, cobra.ShellCompRequestCmd, cobra.ShellCompNoDescRequestCmd, "completion", "version", "help", "--version": + return true + } + // A help request can appear after a subcommand ("run --help"); never update + // just to print help text. + for _, arg := range args { + if arg == "--help" || arg == "-h" { + return true + } + } + return false +} + // setupLogging configures slog logging behavior. // When --debug is enabled, logs are written to a rotating file /cagent.debug.log, // or to the file specified by --log-file. Log files are rotated when they exceed 10MB, diff --git a/cmd/root/selfupdate_test.go b/cmd/root/selfupdate_test.go new file mode 100644 index 000000000..a12b70c10 --- /dev/null +++ b/cmd/root/selfupdate_test.go @@ -0,0 +1,39 @@ +package root + +import ( + "testing" + + "github.com/docker/cli/cli-plugins/metadata" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +func TestIsManagementInvocation(t *testing.T) { + t.Parallel() + + for _, args := range [][]string{ + {metadata.MetadataSubcommandName}, + {cobra.ShellCompRequestCmd}, + {cobra.ShellCompNoDescRequestCmd}, + {"completion", "bash"}, + {"version"}, + {"help"}, + {"--help"}, + {"-h"}, + {"--version"}, + {"run", "--help"}, + {"run", "agent.yaml", "-h"}, + {"share", "push", "--help"}, + } { + assert.True(t, isManagementInvocation(args), "args %v", args) + } + + for _, args := range [][]string{ + nil, + {}, + {"run", "agent.yaml"}, + {"new"}, + } { + assert.False(t, isManagementInvocation(args), "args %v", args) + } +} diff --git a/docs/configuration/overview/index.md b/docs/configuration/overview/index.md index 2edf4b9b8..d48b55e8f 100644 --- a/docs/configuration/overview/index.md +++ b/docs/configuration/overview/index.md @@ -197,6 +197,7 @@ API keys and secrets are read from environment variables — never stored in con | `DOCKER_AGENT_DEFAULT_MODEL` | Default model used when none is specified, in `provider/model` form (e.g. `openai/gpt-5-mini`). | | `DOCKER_AGENT_MODELS_GATEWAY` | Route model traffic through a gateway. Equivalent to the `--models-gateway` flag. | | `DOCKER_AGENT_HIDE_TELEMETRY_BANNER`| Set to `1` to suppress the first-run telemetry notice. | +| `DOCKER_AGENT_AUTO_UPDATE` | Set to a truthy value (`1`, `true`, `yes`, `on`) to let standalone release binaries self-update before running. See [Optional Self-Updates]({{ '/getting-started/installation/#optional-self-updates' | relative_url }}). |
Legacy CAGENT_* aliases diff --git a/docs/getting-started/installation/index.md b/docs/getting-started/installation/index.md index 39f82ac05..fd960de8a 100644 --- a/docs/getting-started/installation/index.md +++ b/docs/getting-started/installation/index.md @@ -67,6 +67,30 @@ docker agent version Download `docker-agent-windows-amd64.exe` from the [releases page](https://github.com/docker/docker-agent/releases), rename it to `docker-agent.exe` and add it to your PATH. Alternatively you can move it to `~/.docker/cli-plugins` +## Optional Self-Updates + +When docker-agent is installed from a standalone GitHub release binary, you can opt in to automatic self-updates by setting `DOCKER_AGENT_AUTO_UPDATE` to a truthy value (`1`, `true`, `yes`, or `on`): + +```bash +# Enable for one command +DOCKER_AGENT_AUTO_UPDATE=1 docker agent run + +# Or enable for the current shell session +export DOCKER_AGENT_AUTO_UPDATE=1 +docker agent run +``` + +With self-updates enabled, docker-agent checks the latest GitHub release before normal commands run. If a newer release exists and your session is interactive, docker-agent asks whether you want to install it or keep running your current version. When the answer is yes (or the session is non-interactive, such as CI or piped input, in which case the update proceeds automatically), it downloads the asset for your OS and architecture, verifies the release-provided SHA-256 digest/checksum, replaces the current binary, and restarts the command with the same arguments. + +Self-updates are fail-safe: if checking, downloading, verifying, installing, or restarting fails, docker-agent keeps running the current binary. Version/help/completion commands and Docker CLI plugin metadata handshakes do not trigger self-updates. + +
+
Package-manager installs +
+

Docker Desktop and Homebrew already manage docker-agent updates. Prefer those update mechanisms when you installed docker-agent that way. Self-updates are mainly intended for standalone release binaries.

+ +
+ ## Build from Source For the latest features, or to contribute, build from source: diff --git a/go.mod b/go.mod index ad058086b..7c4ab68cd 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( charm.land/lipgloss/v2 v2.0.3 github.com/99designs/keyring v1.2.2 github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.1 + github.com/Masterminds/semver/v3 v3.2.1 github.com/Microsoft/go-winio v0.6.2 github.com/a2aproject/a2a-go v0.3.15 github.com/alecthomas/chroma/v2 v2.26.1 diff --git a/pkg/selfupdate/exec_unix.go b/pkg/selfupdate/exec_unix.go new file mode 100644 index 000000000..9d754b80d --- /dev/null +++ b/pkg/selfupdate/exec_unix.go @@ -0,0 +1,44 @@ +//go:build !windows + +package selfupdate + +import ( + "errors" + "fmt" + "os" + "syscall" +) + +// swapBinary replaces dst with src on Unix. A running executable's inode can be +// replaced while it is in use, so a rename within the same filesystem is both +// safe and atomic. If src and dst live on different filesystems (the temp-dir +// fallback), os.Rename fails with EXDEV and we copy-then-replace instead. +func swapBinary(dst, src string) error { + if err := os.Rename(src, dst); err == nil { + return nil + } else if !errors.Is(err, syscall.EXDEV) { + return err + } + // Cross-filesystem fallback: copy the contents atomically. + if err := atomicWriteFromFile(dst, src); err != nil { + return err + } + _ = os.Remove(src) + return nil +} + +// reExecProcess replaces the current process image with path using execve. +// On success it never returns: the new binary inherits our PID, file +// descriptors and terminal, so the user sees a seamless restart. +func reExecProcess(path string, args, env []string) error { + if len(args) == 0 { + args = []string{path} + } else { + // argv[0] should be the new binary's path. + args = append([]string{path}, args[1:]...) + } + if err := syscall.Exec(path, args, env); err != nil { + return fmt.Errorf("exec %s: %w", path, err) + } + return nil // unreachable on success +} diff --git a/pkg/selfupdate/exec_windows.go b/pkg/selfupdate/exec_windows.go new file mode 100644 index 000000000..6517efcc9 --- /dev/null +++ b/pkg/selfupdate/exec_windows.go @@ -0,0 +1,77 @@ +//go:build windows + +package selfupdate + +import ( + "fmt" + "os" + "os/exec" +) + +// swapBinary replaces dst with src on Windows. +// +// Windows locks a running executable's file, so we cannot overwrite dst +// directly. Instead we rename the running binary out of the way (to a sidecar +// ".old" path, which Windows allows even while the file is mapped) and move the +// new binary into its place. The stale ".old" file is best-effort cleaned up; +// if it is still locked it lingers harmlessly until the next run. +func swapBinary(dst, src string) error { + old := dst + ".old" + _ = os.Remove(old) + + if err := os.Rename(dst, old); err != nil { + return fmt.Errorf("moving current binary aside: %w", err) + } + + if err := os.Rename(src, dst); err != nil { + if cpErr := atomicWriteFromFile(dst, src); cpErr != nil { + // Roll back so we never leave the install without a binary. + if rbErr := os.Rename(old, dst); rbErr != nil { + return fmt.Errorf("installing new binary: %w (copy fallback failed: %v; rollback also failed: %v)", err, cpErr, rbErr) + } + return fmt.Errorf("installing new binary: %w (copy fallback failed: %v)", err, cpErr) + } + _ = os.Remove(src) + } + + _ = os.Remove(old) + return nil +} + +// reExecProcess relaunches path as a child process inheriting our stdio, waits +// for it, and exits with its status. Windows has no execve, so the parent +// process must stay alive until the child finishes to preserve exit-code +// semantics for the caller (e.g. a shell or the docker CLI). +func reExecProcess(path string, args, env []string) error { + var childArgs []string + if len(args) > 1 { + childArgs = args[1:] + } + + cmd := exec.Command(path, childArgs...) //nolint:gosec // path is our own freshly installed binary + cmd.Env = env + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + var exitErr *exec.ExitError + if ok := asExitError(err, &exitErr); ok { + os.Exit(exitErr.ExitCode()) + } + return fmt.Errorf("running updated binary: %w", err) + } + + os.Exit(0) + return nil +} + +// asExitError is a tiny helper kept separate so exec_unix.go does not need to +// import errors solely for this Windows branch. +func asExitError(err error, target **exec.ExitError) bool { + if e, ok := err.(*exec.ExitError); ok { //nolint:errorlint // direct type assertion is intentional here + *target = e + return true + } + return false +} diff --git a/pkg/selfupdate/selfupdate.go b/pkg/selfupdate/selfupdate.go new file mode 100644 index 000000000..60d52b967 --- /dev/null +++ b/pkg/selfupdate/selfupdate.go @@ -0,0 +1,681 @@ +// Package selfupdate implements an opt-in, fail-safe self-update mechanism. +// +// When enabled via the [EnvAutoUpdate] environment variable, the running +// binary checks GitHub for a newer release, downloads the asset matching the +// current OS/arch, verifies it, atomically replaces itself on disk, and +// re-executes the new binary with the original arguments. +// +// The whole mechanism is best-effort: any failure (network, disk, permissions, +// version parsing, a corrupt download, ...) is logged and swallowed so the +// caller always falls back to running the current binary. The only observable +// effect of a failed update is a short delay and a log line. +package selfupdate + +import ( + "bufio" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/Masterminds/semver/v3" + "github.com/mattn/go-isatty" + + "github.com/docker/docker-agent/pkg/atomicfile" +) + +const ( + // EnvAutoUpdate enables the self-update mechanism when set to a truthy + // value ("1", "true", "yes", "on", case-insensitive). + EnvAutoUpdate = "DOCKER_AGENT_AUTO_UPDATE" + + // envReExecMarker is set on the re-executed child process to prevent an + // infinite update→re-exec loop. Its presence means "an update has already + // been attempted in this process tree, do not try again". + envReExecMarker = "DOCKER_AGENT_SELF_UPDATE_REEXEC" + + // envBackupMarker points the re-executed child at the previous binary backup + // so it can clean up after a successful restart. + envBackupMarker = "DOCKER_AGENT_SELF_UPDATE_BACKUP" + + defaultRepoOwner = "docker" + defaultRepoName = "docker-agent" + + defaultAPIBaseURL = "https://api.github.com" + defaultDownloadBaseURL = "https://github.com" + + // devVersion marks a local/unreleased build that must never be updated. + devVersion = "dev" + + // httpTimeout bounds every network operation. The full update budget is + // roughly downloadTimeout for the asset plus this for metadata. + httpTimeout = 30 * time.Second + downloadTimeout = 5 * time.Minute + + // maxBinarySize caps the downloaded asset to defeat a malicious or broken + // release from filling the disk. docker-agent binaries are tens of MiB. + maxBinarySize = 512 << 20 // 512 MiB +) + +// Updater performs a single best-effort self-update. The zero value is not +// usable; call [New] to get one wired with sensible defaults. +type Updater struct { + CurrentVersion string + Owner string + Repo string + + APIBaseURL string + DownloadBaseURL string + + GOOS string + GOARCH string + + HTTPClient *http.Client + + // resolveExecutable returns the absolute, symlink-resolved path of the + // binary to replace. Overridable in tests. + resolveExecutable func() (string, error) + + // install replaces the current executable with the staged binary and returns + // the previous binary backup plus a restore function that puts it back on + // disk if the restart step fails. Overridable in tests. + install func(dst, src string) (func() error, string, error) + + // reExec replaces (Unix) or relaunches (Windows) the current process with + // path, args and env. On success it does not return on Unix; on Windows it + // exits the process with the child's status. Overridable in tests. + reExec func(path string, args, env []string) error + + // confirm asks the user whether to install the available update. It returns + // true to proceed. In non-interactive sessions it must auto-confirm. + // Overridable in tests. + confirm func(stdin io.Reader, stderr io.Writer, current, latest string) bool +} + +// New returns an Updater configured for the docker-agent GitHub repository, +// targeting the current binary and platform. +func New(currentVersion string) *Updater { + return &Updater{ + CurrentVersion: currentVersion, + Owner: defaultRepoOwner, + Repo: defaultRepoName, + APIBaseURL: defaultAPIBaseURL, + DownloadBaseURL: defaultDownloadBaseURL, + GOOS: runtime.GOOS, + GOARCH: runtime.GOARCH, + HTTPClient: &http.Client{Timeout: downloadTimeout}, + resolveExecutable: resolveExecutable, + install: installExecutable, + reExec: reExecProcess, + confirm: confirmUpdate, + } +} + +// Enabled reports whether the self-update mechanism should run for this +// process. It is false when disabled by env, or when this process is already +// the re-executed child of a prior update (loop guard). +func Enabled() bool { + if os.Getenv(envReExecMarker) != "" { + return false + } + return isTruthy(os.Getenv(EnvAutoUpdate)) +} + +// Run attempts a self-update. It never returns an error: on success it +// re-executes the new binary (and does not return on Unix); on any failure or +// when already up to date it returns so the caller can continue with the +// current binary. Progress and failures are reported to stderr and slog. +// +// When a newer release is available and the session is interactive, the user +// is prompted to confirm the upgrade; non-interactive sessions auto-confirm. +func Run(ctx context.Context, stdin io.Reader, stderr io.Writer) { + if !Enabled() { + return + } + New(currentVersion()).run(ctx, stdin, stderr) +} + +// run is the testable core. It logs and swallows every error. +func (u *Updater) run(ctx context.Context, stdin io.Reader, stderr io.Writer) { + if err := u.tryUpdate(ctx, stdin, stderr); err != nil { + slog.WarnContext(ctx, "Self-update skipped; continuing with current binary", "error", err) + fmt.Fprintf(stderr, "docker-agent: self-update failed (%v); continuing with current version\n", err) + } +} + +func (u *Updater) tryUpdate(ctx context.Context, stdin io.Reader, stderr io.Writer) error { + current, err := semver.NewVersion(u.CurrentVersion) + if err != nil { + // "dev" and other non-release versions land here. Never clobber a + // local build the developer compiled themselves. + return fmt.Errorf("current version %q is not a release version: %w", u.CurrentVersion, err) + } + + assetName := u.assetName() + release, err := u.latestRelease(ctx, assetName) + if err != nil { + return fmt.Errorf("resolving latest release: %w", err) + } + + latest, err := semver.NewVersion(release.Tag) + if err != nil { + return fmt.Errorf("parsing latest tag %q: %w", release.Tag, err) + } + + if !latest.GreaterThan(current) { + slog.DebugContext(ctx, "Already on the latest version", "current", u.CurrentVersion, "latest", release.Tag) + return nil + } + + if !u.confirm(stdin, stderr, u.CurrentVersion, release.Tag) { + slog.DebugContext(ctx, "User declined self-update", "current", u.CurrentVersion, "latest", release.Tag) + return nil + } + + exePath, err := u.resolveExecutable() + if err != nil { + return fmt.Errorf("locating current executable: %w", err) + } + + fmt.Fprintf(stderr, "docker-agent: updating from %s to %s...\n", u.CurrentVersion, release.Tag) + + newPath, err := u.downloadAndStage(ctx, release, exePath) + if err != nil { + return err + } + + if err := u.verifyBinary(ctx, newPath); err != nil { + _ = os.Remove(newPath) + return fmt.Errorf("verifying downloaded binary: %w", err) + } + + restore, backup, err := u.install(exePath, newPath) + if err != nil { + _ = os.Remove(newPath) + return fmt.Errorf("replacing executable: %w", err) + } + + fmt.Fprintf(stderr, "docker-agent: updated to %s, restarting...\n", release.Tag) + + // Re-execute the freshly installed binary with the original arguments, + // marking the child so it will not attempt to update again. Strip any + // inherited self-update markers first so a stale backup path from a prior + // cycle cannot shadow the one we set (getenv returns the first match). + env := append(selfUpdateEnv(os.Environ()), envReExecMarker+"=1", envBackupMarker+"="+backup) + if err := u.reExec(exePath, os.Args, env); err != nil { + if restoreErr := restore(); restoreErr != nil { + return fmt.Errorf("re-executing updated binary and restoring previous binary: %w", errors.Join(err, restoreErr)) + } + return fmt.Errorf("re-executing updated binary: %w", err) + } + return nil +} + +// assetName returns the release asset filename for the target platform, e.g. +// "docker-agent-darwin-arm64" or "docker-agent-windows-amd64.exe". +func (u *Updater) assetName() string { + name := fmt.Sprintf("%s-%s-%s", u.Repo, u.GOOS, u.GOARCH) + if u.GOOS == "windows" { + name += ".exe" + } + return name +} + +type releaseInfo struct { + Tag string + Asset string + DownloadURL string +} + +// latestRelease fetches the latest GitHub release metadata and locates the +// asset matching the current platform. +func (u *Updater) latestRelease(ctx context.Context, assetName string) (releaseInfo, error) { + endpoint := fmt.Sprintf("%s/repos/%s/%s/releases/latest", u.APIBaseURL, u.Owner, u.Repo) + + ctx, cancel := context.WithTimeout(ctx, httpTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody) + if err != nil { + return releaseInfo{}, err + } + req.Header.Set("Accept", "application/vnd.github+json") + setGitHubAuth(req) + + resp, err := u.HTTPClient.Do(req) + if err != nil { + return releaseInfo{}, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return releaseInfo{}, fmt.Errorf("GitHub API returned HTTP %d", resp.StatusCode) + } + + var release struct { + TagName string `json:"tag_name"` + Assets []struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + } `json:"assets"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&release); err != nil { + return releaseInfo{}, fmt.Errorf("decoding release metadata: %w", err) + } + if release.TagName == "" { + return releaseInfo{}, errors.New("latest release has no tag_name") + } + + for _, asset := range release.Assets { + if asset.Name == assetName { + if asset.BrowserDownloadURL == "" { + return releaseInfo{}, fmt.Errorf("release asset %s has no download URL", assetName) + } + if err := u.validateDownloadURL(asset.BrowserDownloadURL); err != nil { + return releaseInfo{}, err + } + return releaseInfo{ + Tag: release.TagName, + Asset: asset.Name, + DownloadURL: asset.BrowserDownloadURL, + }, nil + } + } + + return releaseInfo{}, fmt.Errorf("latest release %s does not contain asset %s", release.TagName, assetName) +} + +// validateDownloadURL rejects asset URLs that do not point at the trusted +// download host. The asset URL comes from the GitHub API response, so a +// tampered or compromised response could otherwise redirect the binary +// download to an attacker-controlled host. The trusted host is derived from +// the hardcoded DownloadBaseURL (github.com in production), and the scheme of +// that base URL is enforced too. +func (u *Updater) validateDownloadURL(rawURL string) error { + parsed, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("parsing asset download URL: %w", err) + } + base, err := url.Parse(u.DownloadBaseURL) + if err != nil { + return fmt.Errorf("parsing download base URL: %w", err) + } + if parsed.Scheme != base.Scheme { + return fmt.Errorf("asset download URL %q scheme is not %q", rawURL, base.Scheme) + } + if !strings.EqualFold(parsed.Hostname(), base.Hostname()) { + return fmt.Errorf("asset download URL host %q is not the trusted host %q", parsed.Hostname(), base.Hostname()) + } + return nil +} + +// downloadAndStage downloads the release asset into a temp file next to exePath +// (same filesystem, so the later rename is atomic) and returns its path. +func (u *Updater) downloadAndStage(ctx context.Context, release releaseInfo, exePath string) (string, error) { + dlURL := release.DownloadURL + + dlCtx, cancel := context.WithTimeout(ctx, downloadTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(dlCtx, http.MethodGet, dlURL, http.NoBody) + if err != nil { + return "", err + } + setGitHubAuth(req) + + resp, err := u.HTTPClient.Do(req) + if err != nil { + return "", fmt.Errorf("downloading %s: %w", dlURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("downloading %s: HTTP %d", dlURL, resp.StatusCode) + } + + dir := filepath.Dir(exePath) + tmp, err := os.CreateTemp(dir, ".docker-agent-update-*") + if err != nil { + // Fall back to the system temp dir; replaceExecutable copies across + // filesystems when an atomic rename is not possible. + tmp, err = os.CreateTemp("", ".docker-agent-update-*") + if err != nil { + return "", fmt.Errorf("creating temp file: %w", err) + } + } + tmpPath := tmp.Name() + + hasher := sha256.New() + n, copyErr := io.Copy(io.MultiWriter(tmp, hasher), io.LimitReader(resp.Body, maxBinarySize+1)) + closeErr := tmp.Close() + if copyErr != nil { + _ = os.Remove(tmpPath) + return "", fmt.Errorf("writing downloaded binary: %w", copyErr) + } + if closeErr != nil { + _ = os.Remove(tmpPath) + return "", fmt.Errorf("closing downloaded binary: %w", closeErr) + } + if n == 0 { + _ = os.Remove(tmpPath) + return "", errors.New("downloaded binary is empty") + } + if n > maxBinarySize { + _ = os.Remove(tmpPath) + return "", fmt.Errorf("downloaded binary exceeds %d bytes", int64(maxBinarySize)) + } + + if err := os.Chmod(tmpPath, 0o755); err != nil { //nolint:gosec // replacement binary must be executable + _ = os.Remove(tmpPath) + return "", fmt.Errorf("setting executable permissions: %w", err) + } + + // Integrity check is mandatory for self-update: do not execute or install a + // downloaded binary unless GitHub provides a digest or the release publishes + // a matching SHA-256 entry in checksums.txt. + if err := u.verifyChecksum(ctx, release, hex.EncodeToString(hasher.Sum(nil))); err != nil { + _ = os.Remove(tmpPath) + return "", err + } + + return tmpPath, nil +} + +// verifyChecksum verifies gotHex against the SHA-256 listed for the asset in +// the release's checksums.txt, fetched from the hardcoded DownloadBaseURL +// rather than any API-supplied value. It fails closed when checksums.txt is +// missing or does not list the asset, so a tampered API response cannot +// substitute its own digest for a malicious binary. +func (u *Updater) verifyChecksum(ctx context.Context, release releaseInfo, gotHex string) error { + endpoint := fmt.Sprintf("%s/%s/%s/releases/download/%s/checksums.txt", u.DownloadBaseURL, u.Owner, u.Repo, release.Tag) + + ctx, cancel := context.WithTimeout(ctx, httpTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody) + if err != nil { + return err + } + setGitHubAuth(req) + + resp, err := u.HTTPClient.Do(req) + if err != nil { + return fmt.Errorf("fetching checksums.txt: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("fetching checksums.txt: HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return fmt.Errorf("reading checksums.txt: %w", err) + } + + want, ok := checksumFor(string(body), release.Asset) + if !ok { + return fmt.Errorf("checksums.txt does not list %s", release.Asset) + } + + if !strings.EqualFold(want, gotHex) { + return fmt.Errorf("checksum mismatch for %s: expected %s, got %s", release.Asset, want, gotHex) + } + return nil +} + +// checksumFor parses a "sha256 filename" formatted checksums file and returns +// the hex digest for the given asset. +func checksumFor(contents, asset string) (string, bool) { + for line := range strings.Lines(contents) { + fields := strings.Fields(line) + if len(fields) != 2 { + continue + } + // The filename column may carry a leading "*" (binary mode marker). + name := strings.TrimPrefix(fields[1], "*") + if name == asset { + return fields[0], true + } + } + return "", false +} + +// verifyBinary sanity-checks the staged binary by executing it with the +// "version" subcommand. A binary that cannot even print its version (wrong +// platform, truncated download, ...) must not replace the working one. +func (u *Updater) verifyBinary(ctx context.Context, path string) error { + // Skip when staged for another platform (tests): we cannot run it here. + if u.GOOS != runtime.GOOS || u.GOARCH != runtime.GOARCH { + return nil + } + + ctx, cancel := context.WithTimeout(ctx, httpTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, path, "version") + // Mark the probe so the freshly downloaded binary does not recursively + // attempt its own self-update while we are validating it. Keep the + // environment minimal so the probe cannot read model/provider secrets. + cmd.Env = []string{envReExecMarker + "=1"} + if runtime.GOOS == "windows" { + cmd.Env = append(cmd.Env, "SYSTEMROOT="+os.Getenv("SYSTEMROOT"), "PATH="+os.Getenv("PATH")) + } + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("staged binary failed to run (%w): %s", err, strings.TrimSpace(string(out))) + } + return nil +} + +// resolveExecutable returns the absolute, symlink-resolved path of the running +// binary. Resolving symlinks ensures we replace the real file (e.g. the +// Homebrew/cli-plugins target) rather than a link to it. +func resolveExecutable() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", err + } + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + exe = resolved + } + return filepath.Abs(exe) +} + +// backupFilePrefix is the basename prefix of the temp backup created by +// backupExecutable. Cleanup only removes paths matching it so a hostile or +// accidental DOCKER_AGENT_SELF_UPDATE_BACKUP value cannot delete arbitrary +// files on startup. +const backupFilePrefix = ".docker-agent-backup-" + +// Cleanup removes the previous-binary backup after a successful re-exec. It is +// deliberately best-effort: cleanup failure must never block normal execution. +// +// The backup path comes from an environment variable, so it is validated to +// look like a backup file produced by this package before being removed. This +// prevents an attacker-controlled environment from turning startup into an +// arbitrary file deletion. +func Cleanup(ctx context.Context) { + backup := os.Getenv(envBackupMarker) + if backup == "" || !isOwnedBackupPath(backup) { + return + } + if err := os.Remove(backup); err != nil && !errors.Is(err, os.ErrNotExist) { + slog.DebugContext(ctx, "Could not remove self-update backup", "path", backup, "error", err) + } +} + +// isOwnedBackupPath reports whether p looks like a backup file created by +// backupExecutable, i.e. its basename carries the expected prefix. +func isOwnedBackupPath(p string) bool { + return strings.HasPrefix(filepath.Base(p), backupFilePrefix) +} + +// installExecutable swaps the binary at dst with the staged file at src and +// returns a restore function that can put the previous binary back if restart +// fails. The platform-specific details (a running binary cannot be overwritten +// on Windows) live in swapBinary's platform implementations. +func installExecutable(dst, src string) (func() error, string, error) { + backup, err := backupExecutable(dst) + if err != nil { + return nil, "", fmt.Errorf("backing up current executable: %w", err) + } + + if err := swapBinary(dst, src); err != nil { + _ = os.Remove(backup) + return nil, "", err + } + + restored := false + return func() error { + if restored { + return nil + } + restored = true + defer os.Remove(backup) + return swapBinary(dst, backup) + }, backup, nil +} + +func backupExecutable(path string) (string, error) { + in, err := os.Open(path) + if err != nil { + return "", err + } + defer in.Close() + + tmp, err := os.CreateTemp(filepath.Dir(path), backupFilePrefix+"*") + if err != nil { + return "", err + } + backup := tmp.Name() + + _, copyErr := io.Copy(tmp, in) + syncErr := tmp.Sync() + closeErr := tmp.Close() + if copyErr != nil || syncErr != nil || closeErr != nil { + _ = os.Remove(backup) + switch { + case copyErr != nil: + return "", copyErr + case syncErr != nil: + return "", syncErr + default: + return "", closeErr + } + } + + if err := os.Chmod(backup, 0o755); err != nil { //nolint:gosec // backup must be executable if restored + _ = os.Remove(backup) + return "", err + } + return backup, nil +} + +// atomicWriteFromFile copies src into dst atomically (used by the Windows path +// and by the cross-filesystem fallback). Kept here so both platform files can +// share it. +func atomicWriteFromFile(dst, src string) error { + f, err := os.Open(src) + if err != nil { + return err + } + defer f.Close() + return atomicfile.Write(dst, f, 0o755) +} + +// confirmUpdate asks the user whether to install the available update. On a +// non-interactive session (stdin is not a terminal, e.g. CI or piped input) it +// auto-confirms so automation keeps working. On an interactive session it +// prompts and treats anything other than an explicit "yes" as a decline. +func confirmUpdate(stdin io.Reader, stderr io.Writer, current, latest string) bool { + if !isInteractive(stdin) { + return true + } + + fmt.Fprintf(stderr, "An update is available (%s). Do you want to install it or continue with version %s? [Y/n] ", latest, current) + + line, err := bufio.NewReader(stdin).ReadString('\n') + if err != nil && line == "" { + // Could not read an answer (EOF on an empty line): be conservative and + // keep running the current version rather than upgrading unprompted. + return false + } + + return answerIsYes(line) +} + +// answerIsYes reports whether a prompt answer means "proceed". The prompt +// defaults to yes, so an empty answer confirms; anything other than y/yes +// declines. +func answerIsYes(answer string) bool { + switch strings.ToLower(strings.TrimSpace(answer)) { + case "", "y", "yes": + return true + default: + return false + } +} + +// isInteractive reports whether stdin is a terminal we can prompt on. It probes +// for an Fd() accessor (satisfied by *os.File and by common wrappers) rather +// than asserting a concrete *os.File, so a wrapped real terminal is still +// detected. A reader without a file descriptor (e.g. a bytes buffer in tests) +// or a non-terminal descriptor (pipe, redirect, CI) is non-interactive. +func isInteractive(stdin io.Reader) bool { + f, ok := stdin.(interface{ Fd() uintptr }) + if !ok { + return false + } + return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd()) +} + +// selfUpdateEnv returns environ with every DOCKER_AGENT_SELF_UPDATE_* entry +// removed so the caller can append fresh markers without leaving duplicates. +// Duplicates matter because getenv returns the first match, so a stale backup +// path inherited from a prior update cycle would otherwise shadow the new one +// and leak the temp backup file. +func selfUpdateEnv(environ []string) []string { + filtered := make([]string, 0, len(environ)) + for _, kv := range environ { + key, _, _ := strings.Cut(kv, "=") + if key == envReExecMarker || key == envBackupMarker { + continue + } + filtered = append(filtered, kv) + } + return filtered +} + +// isTruthy reports whether s represents an enabled boolean flag. +func isTruthy(s string) bool { + switch strings.ToLower(strings.TrimSpace(s)) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + +// setGitHubAuth adds a bearer token from the environment when available, +// raising the GitHub rate limit and enabling access to private assets. +func setGitHubAuth(req *http.Request) { + token := os.Getenv("GITHUB_TOKEN") + if token == "" { + token = os.Getenv("GH_TOKEN") + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } +} diff --git a/pkg/selfupdate/selfupdate_test.go b/pkg/selfupdate/selfupdate_test.go new file mode 100644 index 000000000..5dde3b2e7 --- /dev/null +++ b/pkg/selfupdate/selfupdate_test.go @@ -0,0 +1,529 @@ +package selfupdate + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIsTruthy(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + in string + want bool + }{ + {"1", true}, + {"true", true}, + {"TRUE", true}, + {"yes", true}, + {"on", true}, + {" true ", true}, + {"0", false}, + {"false", false}, + {"", false}, + {"nope", false}, + } { + assert.Equal(t, tc.want, isTruthy(tc.in), "input %q", tc.in) + } +} + +func TestEnabled(t *testing.T) { + t.Setenv(EnvAutoUpdate, "") + t.Setenv(envReExecMarker, "") + assert.False(t, Enabled()) + + t.Setenv(EnvAutoUpdate, "true") + assert.True(t, Enabled()) + + // The re-exec marker disables updates even when explicitly enabled. + t.Setenv(envReExecMarker, "1") + assert.False(t, Enabled()) +} + +func TestAssetName(t *testing.T) { + t.Parallel() + + u := &Updater{Repo: "docker-agent", GOOS: "linux", GOARCH: "amd64"} + assert.Equal(t, "docker-agent-linux-amd64", u.assetName()) + + u = &Updater{Repo: "docker-agent", GOOS: "windows", GOARCH: "arm64"} + assert.Equal(t, "docker-agent-windows-arm64.exe", u.assetName()) +} + +func TestChecksumFor(t *testing.T) { + t.Parallel() + + contents := "abc123 docker-agent-linux-amd64\n" + + "def456 *docker-agent-darwin-arm64\n" + + "bad999 nested/docker-agent-windows-amd64.exe\n" + + got, ok := checksumFor(contents, "docker-agent-linux-amd64") + assert.True(t, ok) + assert.Equal(t, "abc123", got) + + got, ok = checksumFor(contents, "docker-agent-darwin-arm64") + assert.True(t, ok) + assert.Equal(t, "def456", got) + + _, ok = checksumFor(contents, "docker-agent-windows-amd64.exe") + assert.False(t, ok, "path-bearing entries should not match") +} + +// newFakeRelease returns an httptest server emulating the GitHub release API +// and download endpoints for the given tag and asset payload. +const testAssetName = "docker-agent-plan9-mips" + +func newFakeRelease(t *testing.T, tag string, payload []byte, withChecksums bool) *httptest.Server { + t.Helper() + + assetName := testAssetName + sum := sha256.Sum256(payload) + checksums := fmt.Sprintf("%s %s\n", hex.EncodeToString(sum[:]), assetName) + + var baseURL string + mux := http.NewServeMux() + mux.HandleFunc("/repos/docker/docker-agent/releases/latest", func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprintf(w, `{"tag_name":%q,"assets":[{"name":%q,"browser_download_url":%q}]}`, tag, assetName, baseURL+"/docker/docker-agent/releases/download/"+tag+"/"+assetName) + }) + mux.HandleFunc("/docker/docker-agent/releases/download/"+tag+"/"+assetName, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(payload) + }) + mux.HandleFunc("/docker/docker-agent/releases/download/"+tag+"/checksums.txt", func(w http.ResponseWriter, _ *http.Request) { + if !withChecksums { + http.NotFound(w, nil) + return + } + _, _ = io.WriteString(w, checksums) + }) + + srv := httptest.NewServer(mux) + baseURL = srv.URL + t.Cleanup(srv.Close) + return srv +} + +// newTestUpdater wires an Updater against srv, targeting a non-host platform so +// verifyBinary is skipped, and capturing the re-exec call instead of execing. +func newTestUpdater(t *testing.T, srv *httptest.Server, currentVer, exePath string) (*Updater, *reExecCapture) { + t.Helper() + + capt := &reExecCapture{} + return &Updater{ + CurrentVersion: currentVer, + Owner: "docker", + Repo: "docker-agent", + APIBaseURL: srv.URL, + DownloadBaseURL: srv.URL, + // Deliberately not the host platform: verifyBinary returns early so we + // don't try to exec a fake binary. + GOOS: "plan9", + GOARCH: "mips", + HTTPClient: srv.Client(), + resolveExecutable: func() (string, error) { + return exePath, nil + }, + reExec: capt.fn, + install: installExecutable, + confirm: func(io.Reader, io.Writer, string, string) bool { return true }, + }, capt +} + +type reExecCapture struct { + mu sync.Mutex + called bool + path string + args []string + env []string +} + +func (c *reExecCapture) fn(path string, args, env []string) error { + c.mu.Lock() + defer c.mu.Unlock() + c.called = true + c.path = path + c.args = args + c.env = env + return nil +} + +func TestTryUpdateSuccess(t *testing.T) { + payload := []byte("#!/bin/sh\necho new binary\n") + srv := newFakeRelease(t, "v2.0.0", payload, true) + + dir := t.TempDir() + exePath := filepath.Join(dir, "docker-agent") + require.NoError(t, os.WriteFile(exePath, []byte("old binary"), 0o755)) + + u, capt := newTestUpdater(t, srv, "v1.0.0", exePath) + + var stderr strings.Builder + require.NoError(t, u.tryUpdate(t.Context(), nil, &stderr)) + + // The on-disk binary was replaced with the downloaded payload. + got, err := os.ReadFile(exePath) + require.NoError(t, err) + assert.Equal(t, payload, got) + + // And the new binary was re-executed with the loop-guard env marker. + assert.True(t, capt.called) + assert.Equal(t, exePath, capt.path) + assert.Contains(t, capt.env, envReExecMarker+"=1") +} + +func TestTryUpdateDeclinedDoesNotUpdate(t *testing.T) { + payload := []byte("#!/bin/sh\necho new binary\n") + srv := newFakeRelease(t, "v2.0.0", payload, true) + + dir := t.TempDir() + exePath := filepath.Join(dir, "docker-agent") + require.NoError(t, os.WriteFile(exePath, []byte("old binary"), 0o755)) + + u, capt := newTestUpdater(t, srv, "v1.0.0", exePath) + u.confirm = func(io.Reader, io.Writer, string, string) bool { return false } + + var stderr strings.Builder + require.NoError(t, u.tryUpdate(t.Context(), nil, &stderr)) + + assert.False(t, capt.called, "declining must not re-exec") + got, _ := os.ReadFile(exePath) + assert.Equal(t, "old binary", string(got), "binary must be untouched when declined") +} + +func TestConfirmUpdateNonInteractiveAutoConfirms(t *testing.T) { + t.Parallel() + + // A non-*os.File reader (e.g. a pipe in CI) is non-interactive: auto-confirm. + var stderr strings.Builder + assert.True(t, confirmUpdate(strings.NewReader(""), &stderr, "v1.0.0", "v2.0.0")) + assert.Empty(t, stderr.String(), "must not prompt in a non-interactive session") +} + +func TestAnswerIsYes(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + in string + want bool + }{ + {"", true}, // default is yes + {"\n", true}, + {"y", true}, + {"Y", true}, + {"yes", true}, + {" YES ", true}, + {"n", false}, + {"no", false}, + {"nope", false}, + {"x", false}, + } { + assert.Equal(t, tc.want, answerIsYes(tc.in), "input %q", tc.in) + } +} + +func TestTryUpdateAlreadyLatest(t *testing.T) { + srv := newFakeRelease(t, "v1.0.0", []byte("x"), true) + + dir := t.TempDir() + exePath := filepath.Join(dir, "docker-agent") + require.NoError(t, os.WriteFile(exePath, []byte("old"), 0o755)) + + u, capt := newTestUpdater(t, srv, "v1.0.0", exePath) + + var stderr strings.Builder + require.NoError(t, u.tryUpdate(t.Context(), nil, &stderr)) + + assert.False(t, capt.called, "should not re-exec when already up to date") + got, _ := os.ReadFile(exePath) + assert.Equal(t, "old", string(got), "binary must be untouched") +} + +func TestTryUpdateDevVersionNeverUpdates(t *testing.T) { + srv := newFakeRelease(t, "v1.0.0", []byte("x"), true) + + dir := t.TempDir() + exePath := filepath.Join(dir, "docker-agent") + require.NoError(t, os.WriteFile(exePath, []byte("old"), 0o755)) + + u, capt := newTestUpdater(t, srv, devVersion, exePath) + + var stderr strings.Builder + err := u.tryUpdate(t.Context(), nil, &stderr) + require.Error(t, err, "dev builds must not be replaced") + assert.False(t, capt.called) +} + +func TestTryUpdateChecksumMismatch(t *testing.T) { + payload := []byte("real payload") + + dir := t.TempDir() + exePath := filepath.Join(dir, "docker-agent") + require.NoError(t, os.WriteFile(exePath, []byte("old"), 0o755)) + + // Server advertises a checksum that does not match the payload. + bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/releases/latest"): + fmt.Fprintf(w, `{"tag_name":"v2.0.0","assets":[{"name":"docker-agent-plan9-mips","browser_download_url":%q}]}`, "http://"+r.Host+"/download/docker-agent-plan9-mips") + case strings.HasSuffix(r.URL.Path, "checksums.txt"): + fmt.Fprint(w, "deadbeef docker-agent-plan9-mips\n") + default: + _, _ = w.Write(payload) + } + })) + t.Cleanup(bad.Close) + + u, capt := newTestUpdater(t, bad, "v1.0.0", exePath) + u.APIBaseURL = bad.URL + u.DownloadBaseURL = bad.URL + + var stderr strings.Builder + err := u.tryUpdate(t.Context(), nil, &stderr) + require.Error(t, err) + assert.Contains(t, err.Error(), "checksum mismatch") + assert.False(t, capt.called) + + // The original binary must be intact on failure. + got, _ := os.ReadFile(exePath) + assert.Equal(t, "old", string(got)) +} + +func TestTryUpdateMissingChecksumFailsClosed(t *testing.T) { + payload := []byte("real payload") + srv := newFakeRelease(t, "v2.0.0", payload, false) + + dir := t.TempDir() + exePath := filepath.Join(dir, "docker-agent") + require.NoError(t, os.WriteFile(exePath, []byte("old"), 0o755)) + + u, capt := newTestUpdater(t, srv, "v1.0.0", exePath) + + var stderr strings.Builder + err := u.tryUpdate(t.Context(), nil, &stderr) + require.Error(t, err) + assert.Contains(t, err.Error(), "checksums.txt") + assert.False(t, capt.called) + + got, err := os.ReadFile(exePath) + require.NoError(t, err) + assert.Equal(t, "old", string(got)) +} + +func TestTryUpdateReExecFailureRestoresPreviousBinary(t *testing.T) { + payload := []byte("new binary") + srv := newFakeRelease(t, "v2.0.0", payload, true) + + dir := t.TempDir() + exePath := filepath.Join(dir, "docker-agent") + require.NoError(t, os.WriteFile(exePath, []byte("old binary"), 0o755)) + + u, _ := newTestUpdater(t, srv, "v1.0.0", exePath) + u.reExec = func(string, []string, []string) error { + return errors.New("boom") + } + + var stderr strings.Builder + err := u.tryUpdate(t.Context(), nil, &stderr) + require.Error(t, err) + assert.Contains(t, err.Error(), "re-executing updated binary") + + got, err := os.ReadFile(exePath) + require.NoError(t, err) + assert.Equal(t, "old binary", string(got)) +} + +func TestTryUpdateDownloadNotFound(t *testing.T) { + // Latest resolves but the asset 404s: must fail and leave binary intact. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/releases/latest") { + fmt.Fprintf(w, `{"tag_name":"v2.0.0","assets":[{"name":"docker-agent-plan9-mips","browser_download_url":%q}]}`, "http://"+r.Host+"/missing/docker-agent-plan9-mips") + return + } + http.NotFound(w, r) + })) + t.Cleanup(srv.Close) + + dir := t.TempDir() + exePath := filepath.Join(dir, "docker-agent") + require.NoError(t, os.WriteFile(exePath, []byte("old"), 0o755)) + + u, capt := newTestUpdater(t, srv, "v1.0.0", exePath) + + var stderr strings.Builder + err := u.tryUpdate(t.Context(), nil, &stderr) + require.Error(t, err) + assert.False(t, capt.called) + got, _ := os.ReadFile(exePath) + assert.Equal(t, "old", string(got)) +} + +func TestRunSwallowsErrors(t *testing.T) { + // A totally unreachable server must not panic or propagate: Run is + // best-effort and only logs. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + t.Cleanup(srv.Close) + + dir := t.TempDir() + exePath := filepath.Join(dir, "docker-agent") + require.NoError(t, os.WriteFile(exePath, []byte("old"), 0o755)) + + u, capt := newTestUpdater(t, srv, "v1.0.0", exePath) + + var stderr strings.Builder + u.run(t.Context(), nil, &stderr) // must not panic + assert.False(t, capt.called) + assert.Contains(t, stderr.String(), "self-update failed") +} + +func TestLatestReleaseAuthHeader(t *testing.T) { + t.Setenv("GITHUB_TOKEN", "secret-token") + + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + fmt.Fprintf(w, `{"tag_name":"v9.9.9","assets":[{"name":"docker-agent-plan9-mips","browser_download_url":%q}]}`, "http://"+r.Host+"/download") + })) + t.Cleanup(srv.Close) + + u := &Updater{ + Owner: "docker", + Repo: "docker-agent", + APIBaseURL: srv.URL, + DownloadBaseURL: srv.URL, + HTTPClient: srv.Client(), + } + + release, err := u.latestRelease(t.Context(), "docker-agent-plan9-mips") + require.NoError(t, err) + assert.Equal(t, "v9.9.9", release.Tag) + assert.Equal(t, "Bearer secret-token", gotAuth) +} + +func TestLatestReleaseRejectsUntrustedDownloadHost(t *testing.T) { + // The asset download URL points at an attacker-controlled host while the + // trusted DownloadBaseURL is the test server: resolution must fail rather + // than follow the foreign URL. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprintf(w, `{"tag_name":"v9.9.9","assets":[{"name":"docker-agent-plan9-mips","browser_download_url":%q}]}`, "http://evil.example.com/docker-agent-plan9-mips") + })) + t.Cleanup(srv.Close) + + u := &Updater{ + Owner: "docker", + Repo: "docker-agent", + APIBaseURL: srv.URL, + DownloadBaseURL: srv.URL, + HTTPClient: srv.Client(), + } + + _, err := u.latestRelease(t.Context(), "docker-agent-plan9-mips") + require.Error(t, err) + assert.Contains(t, err.Error(), "not the trusted host") +} + +func TestValidateDownloadURL(t *testing.T) { + t.Parallel() + + u := &Updater{DownloadBaseURL: "https://github.com"} + + require.NoError(t, u.validateDownloadURL("https://github.com/docker/docker-agent/releases/download/v1/asset")) + require.NoError(t, u.validateDownloadURL("https://GitHub.com/docker/docker-agent/releases/download/v1/asset")) + + require.Error(t, u.validateDownloadURL("https://objects.githubusercontent.com/asset"), "foreign host must be rejected") + require.Error(t, u.validateDownloadURL("http://github.com/asset"), "non-HTTPS must be rejected") + require.Error(t, u.validateDownloadURL("https://evil.example.com/asset")) + require.Error(t, u.validateDownloadURL("://bad")) +} + +func TestSelfUpdateEnvStripsMarkers(t *testing.T) { + t.Parallel() + + in := []string{ + "PATH=/usr/bin", + envBackupMarker + "=/tmp/stale", + "HOME=/home/me", + envReExecMarker + "=1", + } + + got := selfUpdateEnv(in) + assert.Equal(t, []string{"PATH=/usr/bin", "HOME=/home/me"}, got) + + // Appending fresh markers must yield exactly one entry for each key. + full := append(selfUpdateEnv(in), envReExecMarker+"=1", envBackupMarker+"=/tmp/new") + assert.Equal(t, 1, countKey(full, envReExecMarker)) + assert.Equal(t, 1, countKey(full, envBackupMarker)) + assert.Contains(t, full, envBackupMarker+"=/tmp/new") +} + +func countKey(env []string, key string) int { + n := 0 + for _, kv := range env { + if k, _, _ := strings.Cut(kv, "="); k == key { + n++ + } + } + return n +} + +func TestCleanupRemovesBackup(t *testing.T) { + backup := filepath.Join(t.TempDir(), backupFilePrefix+"123") + require.NoError(t, os.WriteFile(backup, []byte("old"), 0o755)) + t.Setenv(envBackupMarker, backup) + + Cleanup(t.Context()) + + _, err := os.Stat(backup) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestCleanupIgnoresForeignBackupPath(t *testing.T) { + // A path that does not look like one of our backups must never be removed, + // even if pointed at by the environment variable. + victim := filepath.Join(t.TempDir(), "important.txt") + require.NoError(t, os.WriteFile(victim, []byte("keep"), 0o644)) + t.Setenv(envBackupMarker, victim) + + Cleanup(t.Context()) + + got, err := os.ReadFile(victim) + require.NoError(t, err, "foreign path must not be deleted") + assert.Equal(t, "keep", string(got)) +} + +func TestIsOwnedBackupPath(t *testing.T) { + t.Parallel() + + assert.True(t, isOwnedBackupPath("/tmp/"+backupFilePrefix+"abc")) + assert.True(t, isOwnedBackupPath(backupFilePrefix+"abc")) + assert.False(t, isOwnedBackupPath("/tmp/important.txt")) + assert.False(t, isOwnedBackupPath("/etc/passwd")) + assert.False(t, isOwnedBackupPath("")) +} + +func TestSwapBinary(t *testing.T) { + dir := t.TempDir() + dst := filepath.Join(dir, "docker-agent") + src := filepath.Join(dir, "staged") + require.NoError(t, os.WriteFile(dst, []byte("old"), 0o755)) + require.NoError(t, os.WriteFile(src, []byte("new"), 0o755)) + + require.NoError(t, swapBinary(dst, src)) + + got, err := os.ReadFile(dst) + require.NoError(t, err) + assert.Equal(t, "new", string(got)) +} diff --git a/pkg/selfupdate/version.go b/pkg/selfupdate/version.go new file mode 100644 index 000000000..d108091f4 --- /dev/null +++ b/pkg/selfupdate/version.go @@ -0,0 +1,9 @@ +package selfupdate + +import "github.com/docker/docker-agent/pkg/version" + +// currentVersion returns the compiled-in release version. Wrapped so the rest +// of the package depends only on a string and stays trivially testable. +func currentVersion() string { + return version.Version +}