docs: document packages, exported API and non-obvious logic
This commit is contained in:
@@ -16,6 +16,11 @@ import (
|
||||
"github.com/mathismqn/godeez/internal/fsutil"
|
||||
)
|
||||
|
||||
// managedPrefixes are install roots owned by a package manager. Overwriting a
|
||||
// binary there would leave the package manager's database describing a file
|
||||
// that no longer matches, and its next upgrade would silently revert the
|
||||
// self-update. Users on these installs are pointed back at their package
|
||||
// manager instead.
|
||||
var managedPrefixes = []string{
|
||||
"/nix/store",
|
||||
"/opt/homebrew",
|
||||
@@ -25,6 +30,14 @@ var managedPrefixes = []string{
|
||||
"/var/lib/flatpak",
|
||||
}
|
||||
|
||||
// resolveTarget returns the binary that should be replaced, or an error
|
||||
// explaining why self-updating is not appropriate here.
|
||||
//
|
||||
// Symlinks are resolved first so the real file is replaced rather than the
|
||||
// link: package managers commonly expose a binary through a symlink, and
|
||||
// following it is what makes the managed prefix check meaningful. A build
|
||||
// that was not produced by a release is refused outright, since there is no
|
||||
// version to compare against.
|
||||
func resolveTarget() (string, error) {
|
||||
if buildinfo.IsDev() {
|
||||
return "", fmt.Errorf("development build cannot self-update; install a release from https://github.com/%s/%s/releases",
|
||||
@@ -56,6 +69,10 @@ func CheckUpdatable() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// checkWritable proves the install directory is writable by actually creating
|
||||
// and removing a file there. Inspecting permission bits would not account for
|
||||
// read-only mounts or the platform's own rules, and finding out only after
|
||||
// the download has finished wastes the user's time.
|
||||
func checkWritable(dir string) error {
|
||||
f, err := os.CreateTemp(dir, tmpPattern)
|
||||
if err != nil {
|
||||
@@ -74,6 +91,14 @@ func checkWritable(dir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Apply downloads release and replaces the running binary with it.
|
||||
//
|
||||
// The order of these steps is the safety property. The expected checksum is
|
||||
// fetched before the asset, so a release that does not publish one fails
|
||||
// before anything is downloaded. The download lands in a temporary file in
|
||||
// the install directory, which keeps the final rename on the same filesystem
|
||||
// and therefore atomic. The binary is only replaced after the checksum
|
||||
// matches, so a corrupted or tampered download can never be executed.
|
||||
func (u *Updater) Apply(ctx context.Context, release *Release) error {
|
||||
target, err := resolveTarget()
|
||||
if err != nil {
|
||||
@@ -134,6 +159,10 @@ func (u *Updater) fetchChecksum(ctx context.Context, release *Release, assetName
|
||||
return parseChecksums(io.LimitReader(body, maxResponseSize), assetName)
|
||||
}
|
||||
|
||||
// parseChecksums finds the digest for name in a sha256sum style file.
|
||||
//
|
||||
// The optional "*" before the filename is the marker sha256sum uses for
|
||||
// binary mode and is not part of the name.
|
||||
func parseChecksums(r io.Reader, name string) (string, error) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
@@ -152,6 +181,9 @@ func parseChecksums(r io.Reader, name string) (string, error) {
|
||||
return "", fmt.Errorf("no checksum listed for %s", name)
|
||||
}
|
||||
|
||||
// download writes asset to a temporary file in dir and returns its path and
|
||||
// sha256. The hash is computed while streaming, so the file is never read a
|
||||
// second time and never has to be held in memory.
|
||||
func (u *Updater) download(ctx context.Context, dir string, asset Asset) (string, string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, downloadTimeout)
|
||||
defer cancel()
|
||||
@@ -184,6 +216,14 @@ func (u *Updater) download(ctx context.Context, dir string, asset Asset) (string
|
||||
return tmp, hex.EncodeToString(hash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// replaceBinary swaps the new binary into place.
|
||||
//
|
||||
// Unix lets a running executable be renamed over, so a single atomic rename
|
||||
// is enough. Windows locks the file of a running process, so the current
|
||||
// binary has to be moved aside first, which leaves a window where the target
|
||||
// does not exist; if installing the replacement then fails, the old one is
|
||||
// moved back. The .old file is removed on the next update rather than
|
||||
// immediately, since it is still locked while this process runs.
|
||||
func (u *Updater) replaceBinary(target, tmp string) error {
|
||||
if runtime.GOOS != "windows" {
|
||||
return os.Rename(tmp, target)
|
||||
|
||||
@@ -14,7 +14,13 @@ import (
|
||||
const noCheckEnv = "GODEEZ_NO_UPDATE_CHECK"
|
||||
|
||||
const (
|
||||
cacheTTL = 24 * time.Hour
|
||||
// cacheTTL keeps the check to roughly once a day, which is often enough
|
||||
// to notice a release without hitting the GitHub API on every command.
|
||||
cacheTTL = 24 * time.Hour
|
||||
|
||||
// checkTimeout is deliberately short. The check is a courtesy running
|
||||
// alongside a download, so it gives up quickly rather than delaying
|
||||
// anything the user actually asked for.
|
||||
checkTimeout = 3 * time.Second
|
||||
)
|
||||
|
||||
@@ -32,6 +38,9 @@ func cachePath() (string, error) {
|
||||
return filepath.Join(dir, "godeez", "update.json"), nil
|
||||
}
|
||||
|
||||
// readCache returns the cached result, or false if there is nothing usable.
|
||||
// Every failure, including a corrupt or unreadable file, is reported the same
|
||||
// way: the caller simply checks again, so there is nothing to distinguish.
|
||||
func readCache() (cacheEntry, bool) {
|
||||
path, err := cachePath()
|
||||
if err != nil {
|
||||
@@ -71,6 +80,11 @@ func writeCache(version string) error {
|
||||
return os.WriteFile(path, data, 0644)
|
||||
}
|
||||
|
||||
// check returns the latest version if it is newer than the running one, or
|
||||
// "" if it is not. The cache is written even when the release turns out not
|
||||
// to be newer, since the point is to record that GitHub was asked recently,
|
||||
// and a failure to write it is ignored: an uncacheable check still works, it
|
||||
// just repeats.
|
||||
func check(ctx context.Context) (string, error) {
|
||||
if entry, ok := readCache(); ok {
|
||||
return latestIfNewer(entry.LatestVersion), nil
|
||||
@@ -95,6 +109,17 @@ func latestIfNewer(latest string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// StartCheck begins a background update check and returns a channel that
|
||||
// yields the newer version, if there is one, and is closed either way.
|
||||
//
|
||||
// It runs concurrently so the check never delays the command the user ran,
|
||||
// and the channel is buffered so the goroutine exits even if nobody reads the
|
||||
// result. Errors are swallowed: a failed check is not something to report.
|
||||
//
|
||||
// The check is skipped entirely for development builds, which have no version
|
||||
// to compare, and whenever GODEEZ_NO_UPDATE_CHECK is set, which is the escape
|
||||
// hatch for packagers and offline use. Both cases close the channel
|
||||
// immediately so callers need no special handling.
|
||||
func StartCheck(ctx context.Context) <-chan string {
|
||||
ch := make(chan string, 1)
|
||||
|
||||
|
||||
@@ -14,7 +14,11 @@ const (
|
||||
repoName = "godeez"
|
||||
latestReleaseURL = "https://api.github.com/repos/" + repoOwner + "/" + repoName + "/releases/latest"
|
||||
checksumsAsset = "checksums.txt"
|
||||
maxResponseSize = 1 << 20
|
||||
|
||||
// maxResponseSize caps what is read from GitHub, so a malformed or
|
||||
// hostile response cannot exhaust memory. Release JSON and the checksums
|
||||
// file are both a few kilobytes.
|
||||
maxResponseSize = 1 << 20
|
||||
)
|
||||
|
||||
var githubAPIHeaders = map[string]string{
|
||||
@@ -67,6 +71,12 @@ func (r *Release) asset(name string) (Asset, bool) {
|
||||
return Asset{}, false
|
||||
}
|
||||
|
||||
// assetForRuntime finds the release asset for the current platform.
|
||||
//
|
||||
// The name is reconstructed from the goreleaser naming template rather than
|
||||
// discovered, so this has to stay in step with the name_template in
|
||||
// .goreleaser.yaml: a change there breaks self-update for everyone already
|
||||
// running an older build.
|
||||
func (r *Release) assetForRuntime() (Asset, error) {
|
||||
name := fmt.Sprintf("%s_%s_%s_%s", repoName, r.Version(), runtime.GOOS, runtime.GOARCH)
|
||||
if runtime.GOOS == "windows" {
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
// Package update handles both halves of keeping godeez current: the passive
|
||||
// background check that tells the user a newer release exists, and the
|
||||
// `godeez update` command that installs it.
|
||||
//
|
||||
// Releases come from the GitHub releases API. Downloads are verified against
|
||||
// the published checksums file before anything replaces the running binary,
|
||||
// and installs owned by a package manager are refused rather than overwritten.
|
||||
package update
|
||||
|
||||
import (
|
||||
@@ -11,9 +18,13 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// Timeouts are per request rather than for the whole operation, so a slow
|
||||
// but progressing download is not killed part way. The generous download
|
||||
// timeout covers a binary of a few tens of megabytes on a poor connection.
|
||||
apiTimeout = 30 * time.Second
|
||||
downloadTimeout = 5 * time.Minute
|
||||
tmpPattern = ".godeez-update-*"
|
||||
|
||||
tmpPattern = ".godeez-update-*"
|
||||
)
|
||||
|
||||
type Updater struct {
|
||||
@@ -21,6 +32,10 @@ type Updater struct {
|
||||
Out io.Writer
|
||||
}
|
||||
|
||||
// New returns an Updater that reports nothing. Callers that want the step by
|
||||
// step progress, such as the update command, set Out themselves; the
|
||||
// background check leaves it discarding so it cannot write over the download
|
||||
// output.
|
||||
func New() *Updater {
|
||||
return &Updater{
|
||||
client: &http.Client{},
|
||||
|
||||
@@ -10,6 +10,10 @@ func trimV(v string) string {
|
||||
return strings.TrimPrefix(strings.TrimSpace(v), "v")
|
||||
}
|
||||
|
||||
// canonical normalises a version for comparison, returning "" if it is not
|
||||
// valid semver. Tags carry a leading "v" and buildinfo reports versions
|
||||
// without one, so the prefix is added back before validating rather than
|
||||
// requiring callers to agree on a spelling.
|
||||
func canonical(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
@@ -25,6 +29,11 @@ func canonical(v string) string {
|
||||
return v
|
||||
}
|
||||
|
||||
// IsNewer reports whether latest is a strictly newer release than current.
|
||||
//
|
||||
// An unparseable version on either side yields false rather than an error or
|
||||
// a guess: this decides whether to nag the user about an update, and staying
|
||||
// quiet is the right failure mode when the comparison is meaningless.
|
||||
func IsNewer(current, latest string) bool {
|
||||
c, l := canonical(current), canonical(latest)
|
||||
if c == "" || l == "" {
|
||||
|
||||
Reference in New Issue
Block a user