selfupdate

package
v0.58.21 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 31, 2026 License: MIT Imports: 31 Imported by: 0

README

selfupdate

import "github.com/kitsunium/sdk/framework/internal/service/selfupdate"

Package selfupdate replaces the running binary with a newer signed release.

Package selfupdate replaces the running binary with a newer signed release. This file implements SHA-256 integrity verification of downloaded release archives against the checksums.txt manifest published by the release workflow (CWE-494 mitigation: download of code without integrity check).

INTEGRITY ONLY. Nothing here establishes who published the manifest; that is signature.go's job, and matchArchiveDigest must never be reached with a manifest verifyArchive has not authenticated first.

Package selfupdate — range 0.3.66.* (ADR 0005 §Registry, allocated in codeRangeOwners per ADR 0035).

The domain's own range is 0.2.34.* and it holds every refusal the CONTRACT names: an unsigned release, a digest that does not match, a redirect this build will not follow. Those are the outcomes a caller of framework/selfupdate branches on, and none of them is redeclared here.

What lives in this range is the other half — the failures an IMPLEMENTATION has and a contract does not: a JSON body that will not decode, a tar that will not open, a temp file that cannot be created next to the running binary. The core has no vocabulary for them because a different implementation of the same port would fail in different places.

Package selfupdate — consent: whether a caller that did not ask to upgrade may nonetheless replace this binary, and the advice printed when it may not. Package updater — CONSENT for an upgrade nobody typed.

An explicit `upgrade` command is a deliberate act and needs no permission: a it IS the permission. The licence gate is the opposite case. It fires from the root command's PersistentPreRun on every invocation, so a plain routine command that could reach a code path downloading an archive, chmods it 0755, moves it over the running executable and, where sudoers allows it, does that last step as root — none of which the person who typed `run` asked for. The gate's own comment already conceded the point: "this is the one moment a user is staring at a pause they did not ask for".

A mandatory update that simply refuses is useless, so refusing is not what this does. It asks when someone is there to answer, takes an explicit out-of-band authorisation when nobody is (CI, devcontainers, hooks), and when it does decline it prints every way forward rather than leaving a dead end.

Package selfupdate — The consent a product declares, and the escalation it forbids (ADR 0150). Both are properties of the Service a product builds rather than fields of SourceValue: a source says where releases come from, a Service how this binary treats them.

Package selfupdate — the half of an error that never goes on a wire, made available to the one reader who is entitled to all of it.

errs.Error.Error() renders "[<code> <REASON>] <public>" and deliberately nothing else: no private detail, no fields, and not one word from the cause. That is right for anything crossing a boundary, and it is exactly wrong for the person who just typed `upgrade` and is owed "no space left on device".

So the split is not "throw the detail away", it is "send it somewhere else". This file is that somewhere else.

Package selfupdate — privilege escalation: the second, separate opt-in that a replacement into a directory this user cannot write requires. Package updater — privilege escalation, and the explicit consent it now requires.

finalizeReplacement falls back to `sudo -n mv` when the plain rename over the running executable is refused for permissions. That fallback used to fire unconditionally, which meant an ordinary gated command — a command nobody asked to install anything — could end up executing a privileged move of a file it had just downloaded. Where sudoers grants NOPASSWD (devcontainers, CI images, plenty of laptops) that completes silently and writes an attacker-chosen file into a root-owned directory.

Escalation is now opt-in. Not asking is the default, and the refusal names the two ways forward so it is never a dead end.

Package selfupdate — the sentinels this IMPLEMENTATION emits, as opposed to the ones the domain contract names.

Each var's name equals its errs.Define Reason in SCREAMING_SNAKE form.

Every Public here is wire-safe, so none of them names a path, a URL, a host, a tag or an asset. That is not squeamishness: the public half is the one documented safe to put in a response body, and this package's inputs are a release host and a filesystem — every particular it could name came from one of the two. What it says instead is the one thing an operator has to take away, which is whether the binary they are running was replaced.

Where it happened and what the filesystem said live in Private and Fields. They are not lost to the operator: diagnose renders them and ExplainUpgradeFailure prints them under the sentence, because a terminal the operator owns is not a wire.

Package selfupdate — the ports, aliased from core.

They are ALIASES rather than a second declaration: a service-local copy of a contract the core layer owns compiles fine and drifts silently, and a caller holding one of each would find them interchangeable right up until a method is added to one of them.

Package selfupdate — the vendor key list and the signature domain of ADR 0150: several keys any of which verifies, and a manifest that names its tag and its expiry.

Package selfupdate replaces the running binary with a newer signed release.

Package selfupdate — the compile-time proof that osFileSystem still satisfies the core FileSystem port. Package updater — compile-time assertion for osFileSystem.

Hoisted from osfilesystem.go per KTN-IFACE-ASSERT-PLACEMENT so the production source carries no purely verificational declarations.

Package selfupdate — the probe of ADR 0150: the previous binary kept as <binary>.prev, the new one asked to answer, and put back when it does not.

Package selfupdate replaces the running binary with a newer signed release.

Package selfupdate — the one platform the replacement step refuses, and why the refusal comes before anything is downloaded.

Package selfupdate — authenticity: the detached ed25519 signature over the checksum manifest, and the vendor key it is verified against. Package updater — release AUTHENTICITY, which is a different property from the integrity checksum.go already proves.

checksums.txt establishes that the archive arrived intact. It cannot establish who published it: the manifest is fetched from the SAME release, on the SAME origin, with the same write credentials as the archive beside it (see fetchChecksums — it reuses `downloadURL` verbatim). Stable releases are additionally served from a public MIRROR repository, a second publishing origin entirely. Anyone able to write assets to that release replaces the archive and regenerates the manifest in the same breath, and every SHA-256 still matches. The digest defends against a corrupted download, never against a hostile publisher.

This file supplies the missing half: a DETACHED ed25519 signature over checksums.txt, verified against the vendor public key linked into the binary at build time. It is deliberately the same primitive, the same algorithm and the same key that pkg/license already uses to authenticate the roster (pkg/license/bundle.go ParseBundle, anchored on linked in at build time by the consuming binary) — a second scheme would be a second thing to get wrong. Once the manifest is authenticated the origin stops mattering: a substituted mirror can serve any bytes it likes and cannot produce a signature over them.

ABSENT SIGNATURE: FAIL CLOSED, NO TRANSITION WINDOW.

A client that installs an unsigned release when the .sig asset is missing has no authenticity check at all — an attacker who can publish assets can also decline to publish one, so "accept it when it is absent" is exactly equivalent to "never require it". There is no grandfathering window and no environment variable that reopens one. The cost is bounded and known: an upgrade always moves FORWARD to a release published by the workflow that ships with this code, so the only refusals are `upgrade --candidate <tag>` against a pre-signing release candidate, which is a developer channel with a source checkout one command away.

Package selfupdate — the release source: which project's releases this binary updates itself from, and what its artefacts are called.

Package selfupdate replaces the running binary with a newer signed release.

Package selfupdate — the compile-time proof that stdIOCopier still satisfies the core Copier port. Package updater — compile-time assertion for stdIOCopier.

Hoisted from stdiocopier.go per KTN-IFACE-ASSERT-PLACEMENT so the production source carries no purely verificational declarations.

Package selfupdate — the HTTP policy every real Service uses: bounded, https-only redirects and a cap on how much of a response is read. Package updater — the transport the release endpoints are reached over, and the bound on what they are allowed to make this process do.

Everything here treats the GitHub API and the release CDN as untrusted input, because they are: stable releases resolve through a PUBLIC mirror repository (see repoForTag), which is a second publishing origin, and a redirect chain or a response body is chosen entirely by whatever answers.

Package selfupdate replaces the running binary with a newer signed release.

Package selfupdate replaces the running binary with a newer signed release. It checks GitHub releases for newer versions and downloads/replaces the binary.

Package selfupdate — the two shapes every error in this package is built with, so the choice at a call site is which sentinel rather than which spelling.

The split between them is whether this package DECIDED the failure or was TOLD about one. A decision has no cause to carry — nothing failed, a rule was applied — and the sentinel itself is the whole of it. A report from outside has a cause that must survive, because `errors.Is(err, os.ErrPermission)` and the text the operating system wrote are the two things a wrapper most often destroys.

Index

Constants

const CandidateListSentinel string = "__list__"

CandidateListSentinel is the sentinel value for --candidate without a tag.

const CodeArchiveUnreadable errs.Code = 213507

CodeArchiveUnreadable identifies a release archive that could not be buffered or unpacked.

It is deliberately NOT the domain's ChecksumMismatch: by the time anything here runs the archive has already been authenticated and its digest matched, so a container that will not open is a packaging or transfer fault and never a supply-chain signal. Reporting it as one would tell an operator not to install a release that is provably the vendor's.

Code 0.3.66.3

const CodeCandidateTagRequired errs.Code = 213505

CodeCandidateTagRequired identifies a candidate install asked for without naming which candidate.

Code 0.3.66.1

const CodeElevationFailed errs.Code = 213511

CodeElevationFailed identifies an escalation the operator DID authorise and that the system refused anyway.

It is not the domain's ElevationNotAuthorised, and the difference is the whole remedy: that one is fixed by setting one environment variable, this one is not fixed by setting anything.

Code 0.3.66.7

const CodeExecutablePathUnresolved errs.Code = 213508

CodeExecutablePathUnresolved identifies an update that cannot name the file it would replace.

Code 0.3.66.4

const CodeProbeFailed errs.Code = 213512

CodeProbeFailed identifies a replacement whose new binary did not answer the probe the product declared (ADR 0150). The fields say whether the previous binary, kept as <binary>.prev, was put back.

Code 0.3.66.8

const CodeReleaseMetadataUnreadable errs.Code = 213506

CodeReleaseMetadataUnreadable identifies a release-API answer that arrived whole and did not decode as release metadata.

Code 0.3.66.2

const CodeReplacementFailed errs.Code = 213510

CodeReplacementFailed identifies a rename over the running executable that did not land.

Code 0.3.66.6

const CodeStagingFailed errs.Code = 213509

CodeStagingFailed identifies a replacement that failed BEFORE the rename, so the running binary was never touched.

The boundary between this and CodeReplacementFailed is the only thing an operator needs from these two, and it is the rename: everything under this code leaves the installed binary byte-for-byte as it was.

Code 0.3.66.5

Variables

var ArchiveUnreadable *errs.Error

ArchiveUnreadable is returned when the release archive cannot be buffered or unpacked.

Everything under this sentinel happens AFTER the archive's signature and digest have both been verified, which is what makes it a packaging or transfer fault rather than a supply-chain one. Saying so matters: the documented response to an authenticity refusal is "do not install this by any means", and that is the wrong advice for a gzip stream that was truncated on the way in.

Code 0.3.66.3 · reason ARCHIVE_UNREADABLE · public "the release archive could not be read"

var CandidateTagRequired *errs.Error

CandidateTagRequired is returned by DownloadCandidate for an empty tag.

There is deliberately no "latest candidate" to fall back to. A prerelease channel has no ordering an updater may assume — rc.2 can be published after rc.3 is withdrawn — so resolving the empty tag would mean this package choosing which unreleased build to install over the running binary.

Code 0.3.66.1 · reason CANDIDATE_TAG_REQUIRED · public "a release candidate is installed by naming its tag, and no tag was named"

var ElevationFailed *errs.Error

ElevationFailed is returned when an escalation the operator DID authorise is refused by the system.

It is deliberately not ElevationNotAuthorised, and the gap between them is the remedy: that one is repaired by exporting one variable, and this one is not repaired by exporting anything. Merging them would send somebody who already set the variable to set it again.

Code 0.3.66.7 · reason ELEVATION_FAILED · public "the elevated replacement was authorised and the system refused it"

var ExecutablePathUnresolved *errs.Error

ExecutablePathUnresolved is returned when the process cannot work out which file on disk it is running from.

Nothing is attempted after it. An updater that guesses its own path writes a downloaded binary somewhere nobody asked for.

Code 0.3.66.4 · reason EXECUTABLE_PATH_UNRESOLVED · public "the path of the running binary could not be resolved, so nothing was replaced"

var ProbeFailed *errs.Error

ProbeFailed is returned when the replacement landed and the new binary did not answer the product's probe in time. The previous binary is put back from <binary>.prev when it was kept; rolled_back says whether it was.

Code 0.3.66.8 · reason PROBE_FAILED · public "the new binary did not answer its probe and was not kept"

var ReleaseMetadataUnreadable *errs.Error

ReleaseMetadataUnreadable is returned when the release API answers within its size cap and the body does not decode as release metadata.

It is separate from the download sentinels on purpose: the bytes arrived, so no retry policy keyed on a transport failure applies, and the thing to look at is what the host actually sent.

Code 0.3.66.2 · reason RELEASE_METADATA_UNREADABLE · public "the release host's answer could not be read as release metadata"

var ReplacementFailed *errs.Error

ReplacementFailed is returned when the rename of the staged file over the running executable does not land.

The rename is atomic, so this means the old binary is still in place — but unlike StagingFailed it is the step that would have changed it, and the remedy is about the install directory rather than about disk space.

Code 0.3.66.6 · reason REPLACEMENT_FAILED · public "the running binary could not be replaced"

var StagingFailed *errs.Error

StagingFailed is returned when the replacement fails BEFORE the rename.

The installed binary is byte-for-byte as it was, and the partial staging file has been removed. That is the whole content of this sentinel, and it is why it is not merged with ReplacementFailed: the two differ in exactly the fact an operator needs, which is whether the binary they are about to run is the old one or something in between.

Code 0.3.66.5 · reason STAGING_FAILED · public "the update could not be staged, and the running binary was left untouched"

func NewService

func NewService(version string, src SourceValue) *Service

NewService creates a new updater instance.

The returned Service carries NO vendor key and therefore installs nothing: callers chain `.WithVendorKey(...)` with the build's linked-in anchor. That is deliberate — see WithVendorKey in signature.go.

func NewUpdaterWithDeps

func NewUpdaterWithDeps(version string, src SourceValue, client Getter, fs FileSystem, copier Copier) *Service

NewUpdaterWithDeps creates an updater with custom dependencies for testing.

A nil fs or copier is LEGAL and several callers rely on it: a test that only exercises the network path has nothing to inject for the filesystem, and the Service never reaches those fields on that path.

This used to call validateDeps and discard its result — a validation whose answer nobody read, which is worse than none because the call site reads as a guarantee. The function is deleted rather than promoted to a panic or an error return: the nil callers are correct, so there is nothing to reject.

func StdinIsTerminal

func StdinIsTerminal() bool

StdinIsTerminal reports whether a human could answer a prompt on this process's standard input.

Callers pass the result to AuthoriseUnattendedUpgrade rather than letting it detect its own terminal, so the decision stays testable without a pty.

What this can and cannot tell apart

A pipe, a regular file and a closed descriptor are excluded by the mode: none is a character device. The null device IS one, and is excluded by identity — os.SameFile against /dev/null, which is stdlib and portable, rather than a path comparison a redirect would defeat.

What remains indistinguishable, without a cgo or x/sys ioctl this module cannot take (x/sys is banned SDK-wide), is one character device from another: stdin redirected from /dev/zero or /dev/random reads as a terminal here. Both answer a prompt with bytes nobody typed, so a caller in that position should set the unattended opt-in rather than rely on detection. Said out loud because the alternative is a comment claiming a precision this does not have.

type CandidateValue

CandidateValue is one release candidate. It ALIASES the core value rather than redeclaring it, for the same reason UpdateValue does.

type CandidateValue = coreupd.CandidateValue

CandidateValue is an alias of github.com/kitsunium/sdk/framework/internal/core/selfupdate.CandidateValue, declared as:

type CandidateValue struct {
	// Tag is the release tag, e.g. "v1.9.0-rc.3".
	Tag       string
	// Name is the release's human title, which a host may leave empty.
	Name      string
	// CreatedAt is the RFC 3339 publication timestamp, as text.
	CreatedAt string
}

type Copier

Copier streams the verified archive to its destination. It aliases the core port.

type Copier = coreupd.Copier

Copier is an alias of github.com/kitsunium/sdk/framework/internal/core/selfupdate.Copier, declared as:

type Copier interface {
	// Copy copies from src to dst, returning the number of bytes written.
	Copy(dst io.Writer, src io.Reader) (written int64, err error)
}

func (Copier) Copy
func (Copier) Copy(dst io.Writer, src io.Reader) (written int64, err error)

Copy copies from src to dst, returning the number of bytes written.

type FileSystem

FileSystem is the disk half of replacing a running binary. It aliases the core port.

type FileSystem = coreupd.FileSystem

FileSystem is an alias of github.com/kitsunium/sdk/framework/internal/core/selfupdate.FileSystem, declared as:

type FileSystem interface {
	// Chmod changes file permissions.
	Chmod(name string, mode os.FileMode) error
	// CreateTemp creates a temporary file.
	CreateTemp(dir string, pattern string) (file *os.File, err error)
	// EvalSymlinks resolves symlinks in the given path.
	EvalSymlinks(path string) (resolved string, err error)
	// Executable returns the path of the current executable.
	Executable() (path string, err error)
	// Remove removes a file.
	Remove(name string) error
	// Rename renames a file. It is the atomic half of the replacement and must
	// happen on the same filesystem as the target.
	Rename(oldpath string, newpath string) error
}

func (FileSystem) Chmod
func (FileSystem) Chmod(name string, mode os.FileMode) error

Chmod changes file permissions.

func (FileSystem) CreateTemp
func (FileSystem) CreateTemp(dir string, pattern string) (file *os.File, err error)

CreateTemp creates a temporary file.

func (FileSystem) EvalSymlinks(path string) (resolved string, err error)

EvalSymlinks resolves symlinks in the given path.

func (FileSystem) Executable
func (FileSystem) Executable() (path string, err error)

Executable returns the path of the current executable.

func (FileSystem) Remove
func (FileSystem) Remove(name string) error

Remove removes a file.

func (FileSystem) Rename
func (FileSystem) Rename(oldpath string, newpath string) error

Rename renames a file. It is the atomic half of the replacement and must happen on the same filesystem as the target.

type Getter

Getter performs the HTTP GETs a self-update needs. It aliases the core port.

type Getter = coreupd.Getter

Getter is an alias of github.com/kitsunium/sdk/framework/internal/core/selfupdate.Getter, declared as:

type Getter interface {
	// Get performs an HTTP GET request.
	Get(url string) (resp *http.Response, err error)
}

func (Getter) Get
func (Getter) Get(url string) (resp *http.Response, err error)

Get performs an HTTP GET request.

type Service

Service replaces the running binary with a newer signed release of the product its SourceValue names. It manages version checking via GitHub API and binary replacement. goos/goarch are captured at construction time so the per-platform asset resolution (`getBinaryName`, `getArchiveSuffix`, `innerBinaryName`) is stable per-instance — tests can construct a literal with explicit values instead of mutating package-level `runtimeGOOS`/`runtimeGOARCH` (which would race under t.Parallel).

type Service struct{}

func (Service) AuthoriseUnattendedUpgrade
func (*Service) AuthoriseUnattendedUpgrade(out io.Writer, in io.Reader, interactive bool) bool

AuthoriseUnattendedUpgrade decides whether an upgrade nobody asked for may proceed for this Service: an explicit environment answer first, then the product's own consent (WithAutomaticConsent), then the source's prompt rule.

func (Service) CheckForUpdate
func (*Service) CheckForUpdate() (info UpdateValue, checkErr error)

CheckForUpdate checks if an update is available.

func (Service) DownloadCandidate
func (*Service) DownloadCandidate(tag string) (info UpdateValue, downloadErr error)

DownloadCandidate downloads and installs a specific release candidate.

func (Service) ListCandidates
func (*Service) ListCandidates() (candidates []CandidateValue, listErr error)

ListCandidates returns all available release candidates.

func (Service) Upgrade
func (*Service) Upgrade() (info UpdateValue, upgradeErr error)

Upgrade downloads and applies the latest version.

func (Service) WithAutomaticConsent
func (*Service) WithAutomaticConsent() *Service

WithAutomaticConsent declares that the PRODUCT consents to unrequested upgrades — a status line that updates itself silently, its decision D11 —, so no environment variable has to grant it. An operator still refuses with <PREFIX>_AUTO_UPGRADE=0: an explicit answer always wins. It grants nothing else: escalation keeps its own, separate opt-in.

func (Service) WithCompanions
func (*Service) WithCompanions(names ...string) *Service

WithCompanions makes an upgrade also replace each named file the release archive carries beside the product binary — a second executable shipped in the same archive — from that SAME archive, after the one signature and checksum check that covers it: nothing is downloaded twice. A companion is replaced where it is installed, beside the running binary, its symlinks resolved; one that is not installed there is not created. A companion that resolves to the binary itself, or to a file another name already reached, is that file and is replaced once.

Every file is staged in its own directory before any is renamed, and each installed one is kept by a hard link as <file>.prev. A rename, or the probe WithProbe declared, that fails puts back every file already replaced, so the product and its companions are never left at two versions; the kept files are dropped once all stand, unless a probe keeps them as it keeps the binary's. A companion the archive lacks refuses the upgrade (BinaryNotInArchive) with nothing replaced. The replacement of a set is never elevated: a directory the user cannot write, or a file system that cannot link, refuses it (StagingFailed) with nothing replaced.

func (Service) WithProbe
func (*Service) WithProbe(args []string, timeout time.Duration) *Service

WithProbe makes a replacement stand only once the NEW binary, run with args, exits 0 within timeout (zero: five seconds). Before the rename the previous binary is kept as <binary>.prev — a hard link, so the binary's own name is never absent —, and a probe that fails puts it back and reports ProbeFailed. A daemon that updates itself declares the argument its binary answers cheaply ("version", "--probe").

func (Service) WithSignatureDomain
func (*Service) WithSignatureDomain(domain string) *Service

WithSignatureDomain makes every signature cover domain, a NUL byte and the manifest — never the manifest alone — so a key the vendor also signs other documents with (a roster, another product's releases) cannot have one of those signatures read as a release of this product. It also requires the manifest to say which release it is and until when it may be installed, in two header lines the signature therefore covers:

# tag v1.4.0
# expires 2026-12-31T00:00:00Z

A manifest naming another tag is refused — the replay of an older, signed release under a newer name —, and so is one past its expiry — the freeze an attacker who can only replay can otherwise hold an installation in. Without a domain the historical form is verified unchanged.

func (Service) WithVendorKey
func (*Service) WithVendorKey(key []byte) *Service

WithVendorKey pins the ed25519 public half every release must be signed with, and returns the Service so construction reads as one expression — the same chaining shape license.NewService(...).WithVersion(...) uses.

A Service built WITHOUT this call authenticates nothing and therefore installs nothing: verifyArchive refuses with coreupd.NoVendorKey before a single byte is fetched. That default is deliberate. The alternative — treat an absent key as "verification not required" — would mean any construction site that forgot the call silently reverted to the unauthenticated behaviour this file exists to end, and nothing would ever fail to point it out.

func (Service) WithVendorKeys
func (*Service) WithVendorKeys(keys ...[]byte) *Service

WithVendorKeys links the keys a release may be signed with, in order: a release verifies when ANY of them verifies its manifest (ADR 0150). A key rotation publishes under the new key while builds carrying both accept it and builds carrying only the old one keep reading the old — neither side has to be updated first. Several keys are still ONE signer: no quorum is taken. A key of the wrong length is kept as it is and refused at verification, naming its length, exactly as a single wrong key always was.

func (Service) WithoutElevation
func (*Service) WithoutElevation() *Service

WithoutElevation forbids the privileged replacement outright, whatever <PREFIX>_ALLOW_SUDO says: a binary installed where its user cannot write is reported (ElevationNotAuthorised), never replaced through sudo.

type SourceValue

SourceValue says where releases come from and what they are called.

It is the whole of what the source implementation hard-coded. Every field is a property of one product's distribution, not of the update mechanism, so keeping them as constants is what made that implementation un-reusable.

StableRepo and DevRepo can differ, and the reason is worth stating: a project whose source repository goes private still has to serve already-installed binaries their update path, which means a public mirror for stable releases while candidates stay in the source repository behind credentials the public binary does not carry. When DevRepo is empty, StableRepo serves both.

type SourceValue struct {
	// Owner is the release host account, e.g. "kodflow".
	Owner      string
	// StableRepo is the repository serving stable releases.
	StableRepo string
	// DevRepo is the repository serving release candidates. Empty means
	// candidates come from StableRepo too.
	DevRepo    string
	// Product is the binary's name. It is both the archive-asset stem
	// ("<product>_<goos>_<goarch>.tar.gz") and, uppercased, the environment
	// variable prefix for the two opt-ins.
	Product    string
}

func (SourceValue) AuthoriseUnattendedUpgrade
func (SourceValue) AuthoriseUnattendedUpgrade(out io.Writer, in io.Reader, interactive bool) bool

AuthoriseUnattendedUpgrade reports whether a caller that did not ask to upgrade may nonetheless replace this binary.

out receives the prompt; in supplies the answer; interactive says whether there is a human on the other end (the caller decides — see StdinIsTerminal — so a test does not need a pty). The environment is read here rather than passed so every call site honours the same variable.

The order is: an explicit authorisation decides, in EITHER direction; otherwise a human is asked; otherwise the answer is no.

func (SourceValue) AutoUpgradeEnv
func (SourceValue) AutoUpgradeEnv() string

AutoUpgradeEnv is the variable authorising an upgrade the user did not ask for, e.g. MY_TOOL_AUTO_UPGRADE.

func (SourceValue) ExplainUpgradeFailure
func (SourceValue) ExplainUpgradeFailure(out io.Writer, action string, err error)

ExplainUpgradeFailure prints what a refused upgrade means and what, if anything, resolves it. `action` names the operation that failed, so the same advice serves the gate ("cannot apply the required update") and the deliberate command ("upgrade failed").

It exists because exit.Code's message parameter only reaches the structured --debug log (pkg/exit's logEvent is a no-op unless --debug is set): without this, an upgrade against a forged or unsigned release printed "Checking for updates..." and then nothing, exiting non-zero with no reason given. For a network blip that is merely poor; for an authenticity refusal it hides the one signal that must not be missed, and invites the user to go and install the bad release by hand.

It prints THREE things, in descending order of how far they may travel: the error's public sentence, which is wire-safe and says what happened; its diagnostic half — fields and the cause's own words — which says where and why and is for this terminal and the log, never for a response body; and the advice, which is what to do about it.

func (SourceValue) ExplainUpgradeRefusal
func (SourceValue) ExplainUpgradeRefusal(out io.Writer)

ExplainUpgradeRefusal prints every way to get the required update installed after consent was declined or could not be obtained.

It is deliberately exhaustive, including the sudo opt-in: the two authorisations are separate on purpose (one permits replacing the binary, the other permits doing it as root) and discovering the second one only after acting on the first is a bad afternoon.

func (SourceValue) SudoOptInEnv
func (SourceValue) SudoOptInEnv() string

SudoOptInEnv is the variable authorising privilege escalation when the install directory is not writable, e.g. MY_TOOL_ALLOW_SUDO.

type UpdateValue

UpdateValue is the outcome of a version check or an install. It ALIASES the core value rather than redeclaring it: a service-local copy of a value core owns compiles fine and drifts silently.

type UpdateValue = coreupd.UpdateValue

UpdateValue is an alias of github.com/kitsunium/sdk/framework/internal/core/selfupdate.UpdateValue, declared as:

type UpdateValue struct {
	// CurrentVersion is the version of the running binary.
	CurrentVersion string
	// LatestVersion is the version that was found, or the empty string when
	// none was.
	LatestVersion  string
	// Available reports whether LatestVersion is newer than CurrentVersion.
	Available      bool
}

Generated from docs/api by tools/genindex.

Documentation

Overview

Package selfupdate replaces the running binary with a newer signed release.

Package selfupdate replaces the running binary with a newer signed release. This file implements SHA-256 integrity verification of downloaded release archives against the checksums.txt manifest published by the release workflow (CWE-494 mitigation: download of code without integrity check).

INTEGRITY ONLY. Nothing here establishes who published the manifest; that is signature.go's job, and matchArchiveDigest must never be reached with a manifest verifyArchive has not authenticated first.

Package selfupdate — range 0.3.66.* (ADR 0005 §Registry, allocated in codeRangeOwners per ADR 0035).

The domain's own range is 0.2.34.* and it holds every refusal the CONTRACT names: an unsigned release, a digest that does not match, a redirect this build will not follow. Those are the outcomes a caller of framework/selfupdate branches on, and none of them is redeclared here.

What lives in this range is the other half — the failures an IMPLEMENTATION has and a contract does not: a JSON body that will not decode, a tar that will not open, a temp file that cannot be created next to the running binary. The core has no vocabulary for them because a different implementation of the same port would fail in different places.

Package selfupdate — consent: whether a caller that did not ask to upgrade may nonetheless replace this binary, and the advice printed when it may not. Package updater — CONSENT for an upgrade nobody typed.

An explicit `upgrade` command is a deliberate act and needs no permission: a it IS the permission. The licence gate is the opposite case. It fires from the root command's PersistentPreRun on every invocation, so a plain routine command that could reach a code path downloading an archive, chmods it 0755, moves it over the running executable and, where sudoers allows it, does that last step as root — none of which the person who typed `run` asked for. The gate's own comment already conceded the point: "this is the one moment a user is staring at a pause they did not ask for".

A mandatory update that simply refuses is useless, so refusing is not what this does. It asks when someone is there to answer, takes an explicit out-of-band authorisation when nobody is (CI, devcontainers, hooks), and when it does decline it prints every way forward rather than leaving a dead end.

Package selfupdate — The consent a product declares, and the escalation it forbids (ADR 0150). Both are properties of the Service a product builds rather than fields of SourceValue: a source says where releases come from, a Service how this binary treats them.

Package selfupdate — the half of an error that never goes on a wire, made available to the one reader who is entitled to all of it.

errs.Error.Error() renders "[<code> <REASON>] <public>" and deliberately nothing else: no private detail, no fields, and not one word from the cause. That is right for anything crossing a boundary, and it is exactly wrong for the person who just typed `upgrade` and is owed "no space left on device".

So the split is not "throw the detail away", it is "send it somewhere else". This file is that somewhere else.

Package selfupdate — privilege escalation: the second, separate opt-in that a replacement into a directory this user cannot write requires. Package updater — privilege escalation, and the explicit consent it now requires.

finalizeReplacement falls back to `sudo -n mv` when the plain rename over the running executable is refused for permissions. That fallback used to fire unconditionally, which meant an ordinary gated command — a command nobody asked to install anything — could end up executing a privileged move of a file it had just downloaded. Where sudoers grants NOPASSWD (devcontainers, CI images, plenty of laptops) that completes silently and writes an attacker-chosen file into a root-owned directory.

Escalation is now opt-in. Not asking is the default, and the refusal names the two ways forward so it is never a dead end.

Package selfupdate — the sentinels this IMPLEMENTATION emits, as opposed to the ones the domain contract names.

Each var's name equals its errs.Define Reason in SCREAMING_SNAKE form.

Every Public here is wire-safe, so none of them names a path, a URL, a host, a tag or an asset. That is not squeamishness: the public half is the one documented safe to put in a response body, and this package's inputs are a release host and a filesystem — every particular it could name came from one of the two. What it says instead is the one thing an operator has to take away, which is whether the binary they are running was replaced.

Where it happened and what the filesystem said live in Private and Fields. They are not lost to the operator: diagnose renders them and ExplainUpgradeFailure prints them under the sentence, because a terminal the operator owns is not a wire.

Package selfupdate — the ports, aliased from core.

They are ALIASES rather than a second declaration: a service-local copy of a contract the core layer owns compiles fine and drifts silently, and a caller holding one of each would find them interchangeable right up until a method is added to one of them.

Package selfupdate — the vendor key list and the signature domain of ADR 0150: several keys any of which verifies, and a manifest that names its tag and its expiry.

Package selfupdate replaces the running binary with a newer signed release.

Package selfupdate — the compile-time proof that osFileSystem still satisfies the core FileSystem port. Package updater — compile-time assertion for osFileSystem.

Hoisted from osfilesystem.go per KTN-IFACE-ASSERT-PLACEMENT so the production source carries no purely verificational declarations.

Package selfupdate — the probe of ADR 0150: the previous binary kept as <binary>.prev, the new one asked to answer, and put back when it does not.

Package selfupdate replaces the running binary with a newer signed release.

Package selfupdate — the one platform the replacement step refuses, and why the refusal comes before anything is downloaded.

Package selfupdate — authenticity: the detached ed25519 signature over the checksum manifest, and the vendor key it is verified against. Package updater — release AUTHENTICITY, which is a different property from the integrity checksum.go already proves.

checksums.txt establishes that the archive arrived intact. It cannot establish who published it: the manifest is fetched from the SAME release, on the SAME origin, with the same write credentials as the archive beside it (see fetchChecksums — it reuses `downloadURL` verbatim). Stable releases are additionally served from a public MIRROR repository, a second publishing origin entirely. Anyone able to write assets to that release replaces the archive and regenerates the manifest in the same breath, and every SHA-256 still matches. The digest defends against a corrupted download, never against a hostile publisher.

This file supplies the missing half: a DETACHED ed25519 signature over checksums.txt, verified against the vendor public key linked into the binary at build time. It is deliberately the same primitive, the same algorithm and the same key that pkg/license already uses to authenticate the roster (pkg/license/bundle.go ParseBundle, anchored on linked in at build time by the consuming binary) — a second scheme would be a second thing to get wrong. Once the manifest is authenticated the origin stops mattering: a substituted mirror can serve any bytes it likes and cannot produce a signature over them.

ABSENT SIGNATURE: FAIL CLOSED, NO TRANSITION WINDOW.

A client that installs an unsigned release when the .sig asset is missing has no authenticity check at all — an attacker who can publish assets can also decline to publish one, so "accept it when it is absent" is exactly equivalent to "never require it". There is no grandfathering window and no environment variable that reopens one. The cost is bounded and known: an upgrade always moves FORWARD to a release published by the workflow that ships with this code, so the only refusals are `upgrade --candidate <tag>` against a pre-signing release candidate, which is a developer channel with a source checkout one command away.

Package selfupdate — the release source: which project's releases this binary updates itself from, and what its artefacts are called.

Package selfupdate replaces the running binary with a newer signed release.

Package selfupdate — the compile-time proof that stdIOCopier still satisfies the core Copier port. Package updater — compile-time assertion for stdIOCopier.

Hoisted from stdiocopier.go per KTN-IFACE-ASSERT-PLACEMENT so the production source carries no purely verificational declarations.

Package selfupdate — the HTTP policy every real Service uses: bounded, https-only redirects and a cap on how much of a response is read. Package updater — the transport the release endpoints are reached over, and the bound on what they are allowed to make this process do.

Everything here treats the GitHub API and the release CDN as untrusted input, because they are: stable releases resolve through a PUBLIC mirror repository (see repoForTag), which is a second publishing origin, and a redirect chain or a response body is chosen entirely by whatever answers.

Package selfupdate replaces the running binary with a newer signed release.

Package selfupdate replaces the running binary with a newer signed release. It checks GitHub releases for newer versions and downloads/replaces the binary.

Package selfupdate — the two shapes every error in this package is built with, so the choice at a call site is which sentinel rather than which spelling.

The split between them is whether this package DECIDED the failure or was TOLD about one. A decision has no cause to carry — nothing failed, a rule was applied — and the sentinel itself is the whole of it. A report from outside has a cause that must survive, because `errors.Is(err, os.ErrPermission)` and the text the operating system wrote are the two things a wrapper most often destroys.

Index

Constants

View Source
const (

	// CandidateListSentinel is the sentinel value for --candidate without a tag.
	CandidateListSentinel string = "__list__"
)

Service constants for GitHub API, versioning, and file operations.

View Source
const CodeArchiveUnreadable errs.Code = 0x00_03_42_03 // 0.3.66.3

CodeArchiveUnreadable identifies a release archive that could not be buffered or unpacked.

It is deliberately NOT the domain's ChecksumMismatch: by the time anything here runs the archive has already been authenticated and its digest matched, so a container that will not open is a packaging or transfer fault and never a supply-chain signal. Reporting it as one would tell an operator not to install a release that is provably the vendor's.

View Source
const CodeCandidateTagRequired errs.Code = 0x00_03_42_01 // 0.3.66.1

CodeCandidateTagRequired identifies a candidate install asked for without naming which candidate.

View Source
const CodeElevationFailed errs.Code = 0x00_03_42_07 // 0.3.66.7

CodeElevationFailed identifies an escalation the operator DID authorise and that the system refused anyway.

It is not the domain's ElevationNotAuthorised, and the difference is the whole remedy: that one is fixed by setting one environment variable, this one is not fixed by setting anything.

View Source
const CodeExecutablePathUnresolved errs.Code = 0x00_03_42_04 // 0.3.66.4

CodeExecutablePathUnresolved identifies an update that cannot name the file it would replace.

View Source
const CodeProbeFailed errs.Code = 0x00_03_42_08 // 0.3.66.8

CodeProbeFailed identifies a replacement whose new binary did not answer the probe the product declared (ADR 0150). The fields say whether the previous binary, kept as <binary>.prev, was put back.

View Source
const CodeReleaseMetadataUnreadable errs.Code = 0x00_03_42_02 // 0.3.66.2

CodeReleaseMetadataUnreadable identifies a release-API answer that arrived whole and did not decode as release metadata.

View Source
const CodeReplacementFailed errs.Code = 0x00_03_42_06 // 0.3.66.6

CodeReplacementFailed identifies a rename over the running executable that did not land.

View Source
const CodeStagingFailed errs.Code = 0x00_03_42_05 // 0.3.66.5

CodeStagingFailed identifies a replacement that failed BEFORE the rename, so the running binary was never touched.

The boundary between this and CodeReplacementFailed is the only thing an operator needs from these two, and it is the rename: everything under this code leaves the installed binary byte-for-byte as it was.

Variables

View Source
var (
	// CandidateTagRequired is returned by DownloadCandidate for an empty tag.
	//
	// There is deliberately no "latest candidate" to fall back to. A
	// prerelease channel has no ordering an updater may assume — rc.2 can be
	// published after rc.3 is withdrawn — so resolving the empty tag would
	// mean this package choosing which unreleased build to install over the
	// running binary.
	CandidateTagRequired = errs.Static(CodeCandidateTagRequired, "CANDIDATE_TAG_REQUIRED",
		"a release candidate is installed by naming its tag, and no tag was named",
		"service/selfupdate: DownloadCandidate received an empty tag; errors.ErrUnsupported is kept as the cause, so anything that matched it before this sentinel existed still matches",
		0,
		64)

	// ReleaseMetadataUnreadable is returned when the release API answers
	// within its size cap and the body does not decode as release metadata.
	//
	// It is separate from the download sentinels on purpose: the bytes
	// arrived, so no retry policy keyed on a transport failure applies, and
	// the thing to look at is what the host actually sent.
	ReleaseMetadataUnreadable = errs.Static(CodeReleaseMetadataUnreadable, "RELEASE_METADATA_UNREADABLE",
		"the release host's answer could not be read as release metadata",
		"service/selfupdate: a release API response decoded neither as a release nor as a list of them; the fields name the query and, where the query had one, the tag",
		0,
		0)

	// ArchiveUnreadable is returned when the release archive cannot be
	// buffered or unpacked.
	//
	// Everything under this sentinel happens AFTER the archive's signature
	// and digest have both been verified, which is what makes it a packaging
	// or transfer fault rather than a supply-chain one. Saying so matters:
	// the documented response to an authenticity refusal is "do not install
	// this by any means", and that is the wrong advice for a gzip stream that
	// was truncated on the way in.
	ArchiveUnreadable = errs.Static(CodeArchiveUnreadable, "ARCHIVE_UNREADABLE",
		"the release archive could not be read",
		"service/selfupdate: buffering or unpacking the verified archive failed; the fields name the stage and, for a zip entry, which entry",
		0,
		0)

	// ExecutablePathUnresolved is returned when the process cannot work out
	// which file on disk it is running from.
	//
	// Nothing is attempted after it. An updater that guesses its own path
	// writes a downloaded binary somewhere nobody asked for.
	ExecutablePathUnresolved = errs.Static(CodeExecutablePathUnresolved, "EXECUTABLE_PATH_UNRESOLVED",
		"the path of the running binary could not be resolved, so nothing was replaced",
		"service/selfupdate: os.Executable or the symlink resolution behind it failed; the fields name which of the two",
		0,
		0)

	// StagingFailed is returned when the replacement fails BEFORE the rename.
	//
	// The installed binary is byte-for-byte as it was, and the partial
	// staging file has been removed. That is the whole content of this
	// sentinel, and it is why it is not merged with ReplacementFailed: the
	// two differ in exactly the fact an operator needs, which is whether the
	// binary they are about to run is the old one or something in between.
	StagingFailed = errs.Static(CodeStagingFailed, "STAGING_FAILED",
		"the update could not be staged, and the running binary was left untouched",
		"service/selfupdate: creating, writing, closing or chmod-ing the staging file failed; the fields name the step",
		0,
		73)

	// ReplacementFailed is returned when the rename of the staged file over
	// the running executable does not land.
	//
	// The rename is atomic, so this means the old binary is still in place —
	// but unlike StagingFailed it is the step that would have changed it, and
	// the remedy is about the install directory rather than about disk space.
	ReplacementFailed = errs.Static(CodeReplacementFailed, "REPLACEMENT_FAILED",
		"the running binary could not be replaced",
		"service/selfupdate: the rename of the staged file over the running executable failed; the fields name the step",
		0,
		73)

	// ElevationFailed is returned when an escalation the operator DID
	// authorise is refused by the system.
	//
	// It is deliberately not ElevationNotAuthorised, and the gap between them
	// is the remedy: that one is repaired by exporting one variable, and this
	// one is not repaired by exporting anything. Merging them would send
	// somebody who already set the variable to set it again.
	ElevationFailed = errs.Static(CodeElevationFailed, "ELEVATION_FAILED",
		"the elevated replacement was authorised and the system refused it",
		"service/selfupdate: `sudo -n mv` exited non-zero; the fields carry sudo's own output, which names the real blocker far better than the exec error does",
		0,
		73)

	// ProbeFailed is returned when the replacement landed and the new binary
	// did not answer the product's probe in time. The previous binary is put
	// back from <binary>.prev when it was kept; rolled_back says whether it
	// was.
	ProbeFailed = errs.Static(CodeProbeFailed, "PROBE_FAILED",
		"the new binary did not answer its probe and was not kept",
		"service/selfupdate: the probe of the replaced binary failed or timed out; rolled_back says whether <binary>.prev was put back",
		0,
		73)
)

Functions

func StdinIsTerminal

func StdinIsTerminal() bool

StdinIsTerminal reports whether a human could answer a prompt on this process's standard input.

Callers pass the result to AuthoriseUnattendedUpgrade rather than letting it detect its own terminal, so the decision stays testable without a pty.

What this can and cannot tell apart

A pipe, a regular file and a closed descriptor are excluded by the mode: none is a character device. The null device IS one, and is excluded by identity — os.SameFile against /dev/null, which is stdlib and portable, rather than a path comparison a redirect would defeat.

What remains indistinguishable, without a cgo or x/sys ioctl this module cannot take (x/sys is banned SDK-wide), is one character device from another: stdin redirected from /dev/zero or /dev/random reads as a terminal here. Both answer a prompt with bytes nobody typed, so a caller in that position should set the unattended opt-in rather than rely on detection. Said out loud because the alternative is a comment claiming a precision this does not have.

Types

type CandidateValue

type CandidateValue = coreupd.CandidateValue

CandidateValue is one release candidate. It ALIASES the core value rather than redeclaring it, for the same reason UpdateValue does.

type Copier

type Copier = coreupd.Copier

Copier streams the verified archive to its destination. It aliases the core port.

type FileSystem

type FileSystem = coreupd.FileSystem

FileSystem is the disk half of replacing a running binary. It aliases the core port.

type Getter

type Getter = coreupd.Getter

Getter performs the HTTP GETs a self-update needs. It aliases the core port.

type Service

type Service struct {
	// contains filtered or unexported fields
}

Service replaces the running binary with a newer signed release of the product its SourceValue names. It manages version checking via GitHub API and binary replacement. goos/goarch are captured at construction time so the per-platform asset resolution (`getBinaryName`, `getArchiveSuffix`, `innerBinaryName`) is stable per-instance — tests can construct a literal with explicit values instead of mutating package-level `runtimeGOOS`/`runtimeGOARCH` (which would race under t.Parallel).

func NewService

func NewService(version string, src SourceValue) *Service

NewService creates a new updater instance.

The returned Service carries NO vendor key and therefore installs nothing: callers chain `.WithVendorKey(...)` with the build's linked-in anchor. That is deliberate — see WithVendorKey in signature.go.

func NewUpdaterWithDeps

func NewUpdaterWithDeps(version string, src SourceValue, client Getter, fs FileSystem, copier Copier) *Service

NewUpdaterWithDeps creates an updater with custom dependencies for testing.

A nil fs or copier is LEGAL and several callers rely on it: a test that only exercises the network path has nothing to inject for the filesystem, and the Service never reaches those fields on that path.

This used to call validateDeps and discard its result — a validation whose answer nobody read, which is worse than none because the call site reads as a guarantee. The function is deleted rather than promoted to a panic or an error return: the nil callers are correct, so there is nothing to reject.

func (*Service) AuthoriseUnattendedUpgrade

func (u *Service) AuthoriseUnattendedUpgrade(out io.Writer, in io.Reader, interactive bool) bool

AuthoriseUnattendedUpgrade decides whether an upgrade nobody asked for may proceed for this Service: an explicit environment answer first, then the product's own consent (WithAutomaticConsent), then the source's prompt rule.

func (*Service) CheckForUpdate

func (u *Service) CheckForUpdate() (info UpdateValue, checkErr error)

CheckForUpdate checks if an update is available.

func (*Service) DownloadCandidate

func (u *Service) DownloadCandidate(tag string) (info UpdateValue, downloadErr error)

DownloadCandidate downloads and installs a specific release candidate.

func (*Service) ListCandidates

func (u *Service) ListCandidates() (candidates []CandidateValue, listErr error)

ListCandidates returns all available release candidates.

func (*Service) Upgrade

func (u *Service) Upgrade() (info UpdateValue, upgradeErr error)

Upgrade downloads and applies the latest version.

func (*Service) WithAutomaticConsent

func (u *Service) WithAutomaticConsent() *Service

WithAutomaticConsent declares that the PRODUCT consents to unrequested upgrades — a status line that updates itself silently, its decision D11 —, so no environment variable has to grant it. An operator still refuses with <PREFIX>_AUTO_UPGRADE=0: an explicit answer always wins. It grants nothing else: escalation keeps its own, separate opt-in.

func (*Service) WithCompanions added in v0.29.0

func (u *Service) WithCompanions(names ...string) *Service

WithCompanions makes an upgrade also replace each named file the release archive carries beside the product binary — a second executable shipped in the same archive — from that SAME archive, after the one signature and checksum check that covers it: nothing is downloaded twice. A companion is replaced where it is installed, beside the running binary, its symlinks resolved; one that is not installed there is not created. A companion that resolves to the binary itself, or to a file another name already reached, is that file and is replaced once.

Every file is staged in its own directory before any is renamed, and each installed one is kept by a hard link as <file>.prev. A rename, or the probe WithProbe declared, that fails puts back every file already replaced, so the product and its companions are never left at two versions; the kept files are dropped once all stand, unless a probe keeps them as it keeps the binary's. A companion the archive lacks refuses the upgrade (BinaryNotInArchive) with nothing replaced. The replacement of a set is never elevated: a directory the user cannot write, or a file system that cannot link, refuses it (StagingFailed) with nothing replaced.

func (*Service) WithProbe

func (u *Service) WithProbe(args []string, timeout time.Duration) *Service

WithProbe makes a replacement stand only once the NEW binary, run with args, exits 0 within timeout (zero: five seconds). Before the rename the previous binary is kept as <binary>.prev — a hard link, so the binary's own name is never absent —, and a probe that fails puts it back and reports ProbeFailed. A daemon that updates itself declares the argument its binary answers cheaply ("version", "--probe").

func (*Service) WithSignatureDomain

func (u *Service) WithSignatureDomain(domain string) *Service

WithSignatureDomain makes every signature cover domain, a NUL byte and the manifest — never the manifest alone — so a key the vendor also signs other documents with (a roster, another product's releases) cannot have one of those signatures read as a release of this product. It also requires the manifest to say which release it is and until when it may be installed, in two header lines the signature therefore covers:

# tag v1.4.0
# expires 2026-12-31T00:00:00Z

A manifest naming another tag is refused — the replay of an older, signed release under a newer name —, and so is one past its expiry — the freeze an attacker who can only replay can otherwise hold an installation in. Without a domain the historical form is verified unchanged.

func (*Service) WithVendorKey

func (u *Service) WithVendorKey(key []byte) *Service

WithVendorKey pins the ed25519 public half every release must be signed with, and returns the Service so construction reads as one expression — the same chaining shape license.NewService(...).WithVersion(...) uses.

A Service built WITHOUT this call authenticates nothing and therefore installs nothing: verifyArchive refuses with coreupd.NoVendorKey before a single byte is fetched. That default is deliberate. The alternative — treat an absent key as "verification not required" — would mean any construction site that forgot the call silently reverted to the unauthenticated behaviour this file exists to end, and nothing would ever fail to point it out.

func (*Service) WithVendorKeys

func (u *Service) WithVendorKeys(keys ...[]byte) *Service

WithVendorKeys links the keys a release may be signed with, in order: a release verifies when ANY of them verifies its manifest (ADR 0150). A key rotation publishes under the new key while builds carrying both accept it and builds carrying only the old one keep reading the old — neither side has to be updated first. Several keys are still ONE signer: no quorum is taken. A key of the wrong length is kept as it is and refused at verification, naming its length, exactly as a single wrong key always was.

func (*Service) WithoutElevation

func (u *Service) WithoutElevation() *Service

WithoutElevation forbids the privileged replacement outright, whatever <PREFIX>_ALLOW_SUDO says: a binary installed where its user cannot write is reported (ElevationNotAuthorised), never replaced through sudo.

type SourceValue

type SourceValue struct {
	// Owner is the release host account, e.g. "kodflow".
	Owner string
	// StableRepo is the repository serving stable releases.
	StableRepo string
	// DevRepo is the repository serving release candidates. Empty means
	// candidates come from StableRepo too.
	DevRepo string
	// Product is the binary's name. It is both the archive-asset stem
	// ("<product>_<goos>_<goarch>.tar.gz") and, uppercased, the environment
	// variable prefix for the two opt-ins.
	Product string
}

SourceValue says where releases come from and what they are called.

It is the whole of what the source implementation hard-coded. Every field is a property of one product's distribution, not of the update mechanism, so keeping them as constants is what made that implementation un-reusable.

StableRepo and DevRepo can differ, and the reason is worth stating: a project whose source repository goes private still has to serve already-installed binaries their update path, which means a public mirror for stable releases while candidates stay in the source repository behind credentials the public binary does not carry. When DevRepo is empty, StableRepo serves both.

func (SourceValue) AuthoriseUnattendedUpgrade

func (s SourceValue) AuthoriseUnattendedUpgrade(out io.Writer, in io.Reader, interactive bool) bool

AuthoriseUnattendedUpgrade reports whether a caller that did not ask to upgrade may nonetheless replace this binary.

out receives the prompt; in supplies the answer; interactive says whether there is a human on the other end (the caller decides — see StdinIsTerminal — so a test does not need a pty). The environment is read here rather than passed so every call site honours the same variable.

The order is: an explicit authorisation decides, in EITHER direction; otherwise a human is asked; otherwise the answer is no.

func (SourceValue) AutoUpgradeEnv

func (s SourceValue) AutoUpgradeEnv() string

AutoUpgradeEnv is the variable authorising an upgrade the user did not ask for, e.g. MY_TOOL_AUTO_UPGRADE.

func (SourceValue) ExplainUpgradeFailure

func (s SourceValue) ExplainUpgradeFailure(out io.Writer, action string, err error)

ExplainUpgradeFailure prints what a refused upgrade means and what, if anything, resolves it. `action` names the operation that failed, so the same advice serves the gate ("cannot apply the required update") and the deliberate command ("upgrade failed").

It exists because exit.Code's message parameter only reaches the structured --debug log (pkg/exit's logEvent is a no-op unless --debug is set): without this, an upgrade against a forged or unsigned release printed "Checking for updates..." and then nothing, exiting non-zero with no reason given. For a network blip that is merely poor; for an authenticity refusal it hides the one signal that must not be missed, and invites the user to go and install the bad release by hand.

It prints THREE things, in descending order of how far they may travel: the error's public sentence, which is wire-safe and says what happened; its diagnostic half — fields and the cause's own words — which says where and why and is for this terminal and the log, never for a response body; and the advice, which is what to do about it.

func (SourceValue) ExplainUpgradeRefusal

func (s SourceValue) ExplainUpgradeRefusal(out io.Writer)

ExplainUpgradeRefusal prints every way to get the required update installed after consent was declined or could not be obtained.

It is deliberately exhaustive, including the sudo opt-in: the two authorisations are separate on purpose (one permits replacing the binary, the other permits doing it as root) and discovering the second one only after acting on the first is a bad afternoon.

func (SourceValue) SudoOptInEnv

func (s SourceValue) SudoOptInEnv() string

SudoOptInEnv is the variable authorising privilege escalation when the install directory is not writable, e.g. MY_TOOL_ALLOW_SUDO.

type UpdateValue

type UpdateValue = coreupd.UpdateValue

UpdateValue is the outcome of a version check or an install. It ALIASES the core value rather than redeclaring it: a service-local copy of a value core owns compiles fine and drifts silently.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL