Documentation
¶
Overview ¶
Package update implements the local self-update lifecycle: discover a published release, download and verify its signed artifacts, install the binary atomically under an install lock, and coordinate explicitly requested process restarts. It does not own CLI rendering or daemon runtime state.
Index ¶
- Constants
- Variables
- func AcquireLock(cacheDir string) (*xdgcache.Lock, error)
- func CacheDir() (string, error)
- func CleanupOnSignal(paths ...string) (cancel func())
- func DownloadAsset(ctx context.Context, url, dest string) error
- func EmbeddedKeyring() (openpgp.EntityList, error)
- func ExtractTarball(tarballPath, destDir, archiveRoot string) (string, error)
- func InstallCanonical(srcBinary, installDir string) error
- func IsDaemonRunning() (int, bool)
- func KillDaemon(pid int, timeout time.Duration) error
- func ResolveInstallDir() (string, error)
- func RestartDaemon(pid int) error
- func RunInstall(ctx context.Context, plan *Plan) error
- func StopDaemon(pid int, timeout time.Duration) error
- func StripQuarantine(path string) error
- func VerifyChecksum(tarballPath, sumsPath, assetName string) error
- func VerifyDetachedSignature(signed, sig io.Reader) error
- func VerifySignature(sumsPath, sumsSigPath string) error
- type Asset
- type DaemonProcess
- type Plan
- type Release
Constants ¶
const GitHubReleasesURL = "https://api.github.com/repos/osauer/canary/releases?per_page=100"
GitHubReleasesURL lists published releases so an installed major can remain on its own stable line after a newer major is published.
Variables ¶
var ( ErrDaemonNotRunning = errors.New("daemon not running") ErrDaemonUnverified = errors.New("daemon process could not be verified") ErrStopTimeout = errors.New("daemon stop timed out") )
Errors reported while locating or stopping the daemon process.
var ErrInstallInProgress = errors.New("another Canary update is already running")
ErrInstallInProgress signals that another `canary update` already holds the install-time flock. Re-exported as a typed sentinel so the CLI command can detect it without string-matching the error message.
var ErrSignatureInvalid = errors.New("SHA256SUMS signature did not verify against the embedded release-signing key")
ErrSignatureInvalid means the detached PGP signature did not verify against the embedded public key over the supplied SHA256SUMS bytes. Exposed as a typed sentinel so the install flow can fail with a clear "release signature did not match the maintainer's key" message rather than leaking an internal openpgp error.
var ErrSignatureMissing = errors.New("release SHA256SUMS.asc was not present alongside SHA256SUMS")
ErrSignatureMissing means the .asc file was not delivered alongside the SHA256SUMS asset. Distinct from ErrSignatureInvalid so the CLI can hint "this release was published before signing was required (pre-v1.0.0)" vs "this release was tampered with."
var ReleaseSigningKeyFingerprint = "D98426D48FED85EFA33904694D922A4F922B7D7D"
ReleaseSigningKeyFingerprint is the SHA-1 fingerprint of the embedded PGP public key, as printed by `gpg --fingerprint` with spaces stripped and upper-cased. Cross-checked against the parsed key's fingerprint at startup so a swapped .asc file at build time fails loud rather than silently accepting whatever key happens to be embedded.
Rotation: when the signing key changes, update both the .asc file and this fingerprint in the same commit; TestEmbeddedKeyMatchesFingerprint (run by `make check`) catches any mismatch.
Declared `var` rather than `const` only so tests in this package can swap it alongside embeddedPublicKey when exercising the wrong-key path. Treat as immutable in production code.
Functions ¶
func AcquireLock ¶
AcquireLock takes the install-time flock at <cacheDir>/update.lock. Returns ErrInstallInProgress on contention so the CLI can print the friendly message without unwrapping a wrapped syscall error.
The lock covers the full flow (download + verify + extract + rename + transaction staging cleanup). Two parallel `canary update` invocations queue rather than race on publication — the loser exits immediately with the friendly "another update is running" message.
func CacheDir ¶
CacheDir returns the update cache directory: where the tarball, SHA256SUMS, extracted binary, and lock file live for the duration of an install. Thin wrapper around xdgcache.CacheDir so callers in this package can reference one constant ("update").
func CleanupOnSignal ¶
func CleanupOnSignal(paths ...string) (cancel func())
CleanupOnSignal installs a SIGTERM/SIGINT handler that removes the given tempfiles when the signal fires. Returns a cancel function the caller should defer — calling cancel removes the signal handler AND removes the tempfiles, so the same cleanup path runs on both successful exit (defer cancel) and signal interruption.
The handler exits the process after cleanup so a Ctrl-C during download doesn't leave the user dropped back at a half-installed state. Cleanup is best-effort; remove errors are swallowed because the user already wants out.
func DownloadAsset ¶
DownloadAsset streams an HTTP GET to dest via xdgcache.WriteAtomic — the bytes land in the destination's directory under a temp name and are renamed into place only on a clean read. A failed read leaves no partial file at dest.
60-second timeout. The default http.Client follows redirects (GitHub release downloads redirect through objects.githubusercontent.com).
func EmbeddedKeyring ¶
func EmbeddedKeyring() (openpgp.EntityList, error)
EmbeddedKeyring parses the //go:embedded public key into a usable openpgp keyring, AND verifies the parsed key's fingerprint matches the pinned constant. The fingerprint check is defence-in-depth: it catches the build-time swap case where someone modifies release-signing-key.asc without updating the constant (or vice versa) — both would compile, but the runtime check fails closed.
func ExtractTarball ¶
ExtractTarball untars+ungzips tarballPath into destDir, expecting a single regular `canary` binary either at the archive root (older fixtures) or directly under archiveRoot (the current release layout). It returns the absolute path of the extracted binary on success, or an error if the archive is malformed, the binary entry is missing, or the magic-byte smoke check rejects the extracted file as non-executable.
Hardening:
- Reject any entry whose resolved path escapes destDir (path-traversal defence — the tarball isn't fully trusted even after SHA verification because verification only proves the bytes match what we asked for, not that they're benign).
- Cap per-entry read at 200MiB so a malformed tar header (size: math.MaxInt64) can't OOM the CLI.
- File mode is forced to 0o755 — the archive's stored mode is taken as informational only.
func InstallCanonical ¶
InstallCanonical installs srcBinary only as the canonical canary executable. Existing canonical or pre-upgrade executables are moved to transaction-local hidden paths and restored only if canonical publication fails. After success, those bytes are made non-executable and removed. No durable rollback binary is retained because daemon state migrations are forward-only.
func IsDaemonRunning ¶
IsDaemonRunning reads the daemon's PID file (co-located with the socket, written by internal/daemon/lock.go) and reports whether the PID is currently a live process.
Returns (0, false) on any missing/malformed/stale case so the caller can treat the absence the same way regardless of cause — "no daemon to restart, skip the step."
func KillDaemon ¶
KillDaemon sends SIGKILL to pid and waits until it exits. It is intended only as an explicit --force fallback after StopDaemon timed out.
func ResolveInstallDir ¶
ResolveInstallDir returns the directory where the updated `canary` binary should land. CANARY_INSTALL_DIR overrides — used by the release pipeline to sandbox dog-food installs into a tmp dir, and by tests to avoid touching the host's real ~/.local/bin. Falls back to $HOME/.local/bin otherwise.
func RestartDaemon ¶
RestartDaemon stops pid with the standard graceful-shutdown timeout. The next daemon-backed command may autospawn the installed binary; this function does not start a replacement process. An already-exited process is success.
func RunInstall ¶
RunInstall executes the install flow end-to-end against a planned release: download → verify → extract → quarantine-strip → atomic install. Holds an exclusive flock for the duration. Cleans up tempfiles on success, error, and SIGINT/SIGTERM. Returns nil on success; the prior binary is intact on every error path.
The CLI wrapper layers version comparison and TTY-aware restart on top; this function is the pure transport+install primitive so the install_test exercises the whole flow with a synthetic tarball.
func StopDaemon ¶
StopDaemon sends SIGTERM to pid and waits until it exits. It does not verify the PID; callers that read a pidfile should call FindDaemonProcess first. On timeout it returns an error wrapping ErrStopTimeout so callers can decide whether to escalate to SIGKILL.
func StripQuarantine ¶
StripQuarantine removes the com.apple.quarantine extended attribute from path on macOS. On other platforms the call is a no-op.
Critically this MUST run on the staging binary BEFORE the os.Rename into place — strip-after-rename leaves a quarantined live binary with no rollback signal if the strip fails. Strip-before-rename gives us a single point of failure with the prior binary intact.
The xattr command exits non-zero with "No such xattr" when the attribute isn't present (e.g. binary was built locally and never downloaded through Gatekeeper). That's the steady state we expect in tests and on Linux — tolerated.
func VerifyChecksum ¶
VerifyChecksum reads SHA256SUMS (one `<sha> <filename>` line per asset), looks up assetName, and compares against the SHA256 of the file at tarballPath. Returns nil on match.
The two-space-separator format is what shasum / sha256sum / GNU coreutils produce by default and what the release pipeline emits. Lines for other assets are ignored — the same SHA256SUMS file may list every published artefact for the release.
func VerifyDetachedSignature ¶
VerifyDetachedSignature reports whether sig is a valid PGP detached signature over signed, produced by the embedded release-signing key. Both readers are fully consumed.
Returns ErrSignatureInvalid on any verification failure (bad sig, wrong key, corrupted SHA256SUMS) — the underlying openpgp error is wrapped for diagnostics but the typed sentinel is what callers should check.
The verifier accepts only signatures from the embedded key. A signature from any other key — even a perfectly valid one — fails with ErrSignatureInvalid because that key isn't in the keyring we pass.
func VerifySignature ¶
VerifySignature verifies that sumsSigPath is a valid PGP detached signature over sumsPath, produced by the embedded release-signing key. Returns nil on success; ErrSignatureInvalid (wrapped with the openpgp failure reason) on any mismatch.
MUST run BEFORE VerifyChecksum: without this, a same-release attacker could swap both SHA256SUMS and the tarball and the SHA check alone would still pass. Signing SHA256SUMS is what binds the published hash list to a key the attacker doesn't have.
Types ¶
type DaemonProcess ¶
type DaemonProcess struct {
PID int
Command string
SocketPath string
LockPath string
Foreground bool
}
DaemonProcess is the verified process currently holding the daemon pidfile.
func FindDaemonProcess ¶
func FindDaemonProcess(ctx context.Context, socketPath string) (DaemonProcess, error)
FindDaemonProcess returns the live Canary daemon process for socketPath.
It is intentionally stricter than IsDaemonRunning: commands that send signals must not trust a stale or forged pidfile. A live pidfile holder is accepted only when its command line uses the canonical `canary daemon` or a pre-upgrade `ibkr daemon` process that must be quiesced. A responding socket without a verifiable pidfile is treated as unverified rather than killed.
type Plan ¶
type Plan struct {
CacheDir string // ~/.cache/ibkr/update/ (durable namespace compatibility pin)
TarballPath string // CacheDir/<asset>.tar.gz
SumsPath string // CacheDir/SHA256SUMS
SumsSigPath string // CacheDir/SHA256SUMS.asc (PGP detached signature)
ExtractDir string // CacheDir/extract/
InstallDir string // $CANARY_INSTALL_DIR or ~/.local/bin
DestPath string // InstallDir/canary
// ArchiveRoot is the exact top-level directory derived from AssetName
// (for example canary-vX-darwin-arm64).
ArchiveRoot string
AssetName string // <asset>.tar.gz (used for SHA lookup)
AssetURL string // GitHub asset URL
SumsURL string // SHA256SUMS asset URL
SumsSigURL string // SHA256SUMS.asc asset URL
}
Plan is the full sequence of artefacts an install touches. Exposed so tests can construct partial state and exercise per-step branches without re-running the network layer.
func PlanFor ¶
PlanFor builds a Plan for the given release on the current host. The caller has already confirmed the release has an asset for this host; this is structure-only, no I/O.
SHA256SUMS.asc is required: a release without a PGP signature is refused, full stop. The trust model is "every shipped binary verifies the next release via the embedded maintainer key" — relaxing this to "skip when missing" would silently downgrade trust on every install that happened to land between a signing breakage and its fix.
type Release ¶
type Release struct {
TagName string `json:"tag_name"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
Assets []Asset `json:"assets"`
}
Release is the subset of the GitHub release JSON we consume. Only the fields we read are unmarshalled — drift on unrelated fields (author, body, etc.) doesn't surface as a parse error.
func FetchLatestRelease ¶
FetchLatestRelease returns the newest stable release on the installed binary's major line. Development builds have no installed major and follow the newest stable release. It never silently crosses a released major.
The HTTP client uses a 60-second timeout. Redirects are followed by the default client behaviour (GitHub serves the JSON directly with no redirect, but a future API edge change is harmless).
func (*Release) AssetForHost ¶
AssetForHost returns the (name, URL) of the tarball matching the current GOOS/GOARCH, or (_, _, false) if no asset matches (e.g. the caller is running on windows/amd64 and no tarball was published for that platform). The caller surfaces the false return as "no binary for your platform" — we don't synthesise an error here to keep the branching at the call site explicit.
The match is exact. Trading variants and pre-rename asset names can never broaden the installed binary's authority or act as an implicit fallback.
func (*Release) SHA256SUMSAsset ¶
SHA256SUMSAsset returns the SHA256SUMS file the release pipeline publishes alongside the tarballs. Single source of truth for per-asset SHA hashes; format is `<sha> <filename>` per line.
func (*Release) SHA256SUMSSigAsset ¶
SHA256SUMSSigAsset returns the ASCII-armored PGP detached signature (`SHA256SUMS.asc`) the release pipeline publishes alongside SHA256SUMS. Required from v1.0.0 forward — a release without it is refused by the install path (see PlanFor's doc).