Files
Evan JarrettandClaude Opus 5 c615d7253b credhelper: make config writes non-destructive
Follow-up to 8a556a5. v3 keys accounts by DID and, unlike v2, rewrites the
config during ordinary `docker pull` traffic (migration on first read, DID
backfill after a successful get) rather than only on explicit login. That moved
three latent ways to lose a device.json onto the hot path. A user cannot recover
from any of them: once the file is gone they no longer know which accounts they
had.

save() is now atomic. It writes a temp file in the same directory, fsyncs, and
renames, so the config always holds either the previous contents or the complete
new contents. The truncate-then-write it replaced left a zero-length window that
Ctrl-C, Docker reaping the helper, or a suspend could land in. The fsync matters
on its own: on ext4/XFS a rename can become durable while the new file's data
blocks are not, which resurrects the empty-file case. The rename also replaces
the destination's mode, which incidentally repairs a config restored from a
backup as world-readable (os.WriteFile's perm argument only applies at creation).

An unreadable config no longer degrades into an empty one that the next write
commits. loadConfig returns a usable empty config so read paths can still print
something helpful, but it now marks the unrecoverable cases with
errConfigUnusable, and loadConfigForWrite refuses on that sentinel. This is the
downgrade path: the previous binary hard-gates on Version == 2, so without the
guard a reinstall of an older helper would wipe a v3 file on the first
`docker login`. loadConfig also stops explicitly on a version newer than it
understands instead of falling through the legacy probes to the same empty
config. All six write paths take the guard — get, store, erase, login, logout,
switch. login had the same warn-and-continue-then-save shape as store.

The guard keys on the sentinel rather than on any error, deliberately. The
v2 -> v3 migration returns a fully populated config alongside a "saving migrated
config" error when the directory is not writable, and every pre-v3 user passes
through that path on their next invocation; refusing there would hard-break
`docker pull` for exactly the population that is migrating.

migrateV2toV3 merges DID collisions deterministically. Two v2 entries collapse
onto one v3 key when a handle was renamed and the old entry was never cleaned up,
which is the case v3 exists to fix. The previous loop wrote both into the same
map slot in randomized iteration order, so which account survived varied run to
run, and when the stale one won, get would fail validation and remove it — the
user ended up with no account at all. Iteration is now sorted and collisions
resolve through v2EntryBeats (active entry, then the one holding a secret, then
the smaller key), with the loser donating its secret if the winner has none. The
"No account is dropped" comment was false and is now accurate.

Also deterministic: find() and upsert() resolve their scans through a shared
scanFor helper that prefers DID matches and breaks ties on the smallest key, so
two entries sharing a handle can no longer hand Docker a different secret on each
invocation. upsert additionally refuses to match a handle that already belongs to
a different DID, which previously let it overwrite an unrelated account's DID and
destroy that account's credentials. Nil map entries are tolerated throughout
rather than panicking on Docker's credential path.

resolveHandleDNS is bounded at 2s. It runs synchronously on every store and on
every get for a DID-less account, and net.LookupTXT applies no deadline of its
own, so a blackholing resolver (captive portal, split-horizon VPN) stalled
docker login and docker pull on each invocation.

Tests cover the paths that can lose credentials. Each was checked against the
pre-fix code: the collision, null-entry, DID-theft, find-determinism,
permissions, and out-of-place-write tests all fail or panic without their fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 19:19:40 -05:00

633 lines
20 KiB
Go

package credhelper
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"time"
)
// errConfigUnusable marks a config file that exists on disk but could not be
// understood — a newer on-disk version, malformed JSON, a truncated file, or an
// unreadable one. Callers that intend to write MUST refuse when they see it
// (see [loadConfigForWrite]): loadConfig hands back an empty config so read
// paths can still print a useful message, and saving that empty config over a
// real one would destroy every stored account.
var errConfigUnusable = errors.New("credential config is unusable")
// configVersion is the current on-disk format version. v3 keys the accounts
// map (and Active) by DID, the stable identity; v2 keyed them by handle, which
// broke on handle renames. The struct shape is identical between v2 and v3 —
// only the map keys and version number differ — so [loadConfig] migrates v2
// files in place on first read.
const configVersion = 3
// StoredConfig is the on-disk credential helper state (v3). Distinct from
// the per-binary [Config] passed to [Run].
type StoredConfig struct {
Version int `json:"version"`
Registries map[string]*RegistryConfig `json:"registries"`
}
// RegistryConfig holds accounts for a single registry. Accounts is keyed by
// DID when known, else by handle as a provisional key that is re-keyed to the
// DID once resolved (see [RegistryConfig.rekey]). Active holds the key of the
// active account.
type RegistryConfig struct {
Active string `json:"active"`
Accounts map[string]*Account `json:"accounts"`
}
// Account holds credentials for a single identity on a registry. DID is the
// stable identity; Handle is a mutable display label.
type Account struct {
Handle string `json:"handle"`
DID string `json:"did,omitempty"`
DeviceSecret string `json:"device_secret"`
}
// key returns the map key for this account: the DID when known, else the
// handle as a provisional key.
func (a *Account) key() string {
if a.DID != "" {
return a.DID
}
return a.Handle
}
// UpdateCheckCache stores the last update check result.
type UpdateCheckCache struct {
CheckedAt time.Time `json:"checked_at"`
Latest string `json:"latest"`
Current string `json:"current"`
}
// loadConfig loads the config from disk, auto-migrating old formats.
// Returns a valid StoredConfig (possibly empty) even on error.
func loadConfig() (*StoredConfig, error) {
path := getConfigPath()
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return newStoredConfig(), nil
}
return newStoredConfig(), fmt.Errorf("%w: %v", errConfigUnusable, err)
}
// v3 (current) and v2 share the same struct shape — v2 keyed accounts by
// handle, v3 by DID. Unmarshal once, then branch on the version.
var sc StoredConfig
if err := json.Unmarshal(data, &sc); err == nil && sc.Registries != nil {
switch {
case sc.Version == configVersion:
return &sc, nil
case sc.Version == 2:
migrated := migrateV2toV3(&sc)
if err := migrated.save(); err != nil {
return migrated, fmt.Errorf("saving migrated config: %w", err)
}
return migrated, nil
case sc.Version > configVersion:
// A newer build wrote this file. Stop here rather than falling
// through to the legacy probes: they would all miss, loadConfig
// would return an empty config, and the next save would overwrite
// every account. This is the downgrade/reinstall path, so it has to
// be non-destructive.
return newStoredConfig(), fmt.Errorf(
"%w: on-disk config is version %d but this build of %s understands version %d; upgrade %s to use it",
errConfigUnusable, sc.Version, cfg.BinaryName, configVersion, cfg.BinaryName)
}
}
// Try current multi-registry format: {"credentials": {"url": {...}}}
var multiCreds struct {
Credentials map[string]struct {
Handle string `json:"handle"`
DID string `json:"did"`
DeviceSecret string `json:"device_secret"`
AppViewURL string `json:"appview_url"`
} `json:"credentials"`
}
if err := json.Unmarshal(data, &multiCreds); err == nil && multiCreds.Credentials != nil {
migrated := newStoredConfig()
for appViewURL, cred := range multiCreds.Credentials {
handle := cred.Handle
if handle == "" {
continue
}
reg := migrated.getOrCreateRegistry(appViewURL)
stored := reg.upsert(&Account{
Handle: handle,
DID: cred.DID,
DeviceSecret: cred.DeviceSecret,
})
if reg.Active == "" {
reg.setActive(stored)
}
}
if err := migrated.save(); err != nil {
return migrated, fmt.Errorf("saving migrated config: %w", err)
}
return migrated, nil
}
// Try legacy single-device format: {"handle": "...", "device_secret": "...", "appview_url": "..."}
var legacy struct {
Handle string `json:"handle"`
DeviceSecret string `json:"device_secret"`
AppViewURL string `json:"appview_url"`
}
if err := json.Unmarshal(data, &legacy); err == nil && legacy.DeviceSecret != "" {
migrated := newStoredConfig()
handle := legacy.Handle
registryURL := legacy.AppViewURL
if registryURL == "" {
registryURL = "https://" + cfg.DefaultRegistry
}
reg := migrated.getOrCreateRegistry(registryURL)
stored := reg.upsert(&Account{
Handle: handle,
DeviceSecret: legacy.DeviceSecret,
})
reg.setActive(stored)
if err := migrated.save(); err != nil {
return migrated, fmt.Errorf("saving migrated config: %w", err)
}
return migrated, nil
}
return newStoredConfig(), fmt.Errorf("%w: unrecognized config format", errConfigUnusable)
}
// loadConfigForWrite loads the config for a caller that intends to save it back.
// It refuses only on errConfigUnusable — the paths where loadConfig could not
// understand the file and therefore handed back an *empty* config, which a save
// would write over the user's real accounts.
//
// Other errors carry a fully populated config and must not block the caller. The
// v2→v3 migration in particular returns its migrated config alongside a "saving
// migrated config" error when the config directory is not writable (read-only
// $HOME in a container, ENOSPC, restrictive mode). Every pre-v3 user passes
// through that path on their next invocation, so failing it would hard-break
// `docker pull` and `docker login` for exactly the population that is migrating.
// The in-memory config is correct; the caller's own save will surface any
// persistence failure on its own terms.
func loadConfigForWrite() (*StoredConfig, error) {
sc, err := loadConfig()
if err != nil && errors.Is(err, errConfigUnusable) {
return nil, fmt.Errorf("refusing to modify stored credentials: %w", err)
}
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: %v\n", err)
}
return sc, nil
}
func newStoredConfig() *StoredConfig {
return &StoredConfig{
Version: configVersion,
Registries: make(map[string]*RegistryConfig),
}
}
// save writes the config to disk atomically.
//
// This file is the user's only copy of their device secrets, and since v3 it is
// written during ordinary `docker pull`/`docker login` traffic (migration and
// DID backfill), not just on explicit login. A truncate-then-write would leave a
// zero-length or half-written file if the helper is interrupted mid-write —
// Ctrl-C, Docker reaping the process, a laptop suspending — and the user cannot
// recover from that because they no longer know which accounts they had.
func (c *StoredConfig) save() error {
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
return writeFileAtomic(getConfigPath(), data)
}
// writeFileAtomic writes data to path via a temp file in the same directory
// followed by a rename, so path always holds either the previous contents or the
// complete new contents. The rename also replaces the destination's mode with
// the temp file's 0600, which repairs a config that was left group- or
// world-readable by a restore from backup.
func writeFileAtomic(path string, data []byte) error {
f, err := os.CreateTemp(filepath.Dir(path), ".device-*.json.tmp")
if err != nil {
return err
}
tmp := f.Name()
// No-op once the rename below succeeds; on any earlier return it removes
// the partial file rather than leaving litter next to the real config.
defer os.Remove(tmp) //nolint:errcheck
if _, err := f.Write(data); err != nil {
f.Close() //nolint:errcheck
return err
}
// Flush before renaming. On ext4/XFS a rename can become durable while the
// new file's data blocks have not, which would resurrect the very
// empty-file case this function exists to prevent.
if err := f.Sync(); err != nil {
f.Close() //nolint:errcheck
return err
}
if err := f.Close(); err != nil {
return err
}
return os.Rename(tmp, path)
}
// getOrCreateRegistry returns (or creates) a RegistryConfig for the given URL.
// A present-but-null entry (possible in a hand-edited or corrupted file) is
// replaced rather than returned, so callers never dereference nil.
func (c *StoredConfig) getOrCreateRegistry(registryURL string) *RegistryConfig {
reg, ok := c.Registries[registryURL]
if !ok || reg == nil {
reg = &RegistryConfig{
Accounts: make(map[string]*Account),
}
c.Registries[registryURL] = reg
}
return reg
}
// findRegistry looks up a RegistryConfig by registry URL.
func (c *StoredConfig) findRegistry(registryURL string) *RegistryConfig {
return c.Registries[registryURL]
}
// scanFor returns the matching account with the lexically smallest map key,
// along with that key. Go randomizes map iteration order, so any scan that can
// match more than one entry has to impose its own order — otherwise the helper
// hands Docker a different secret from one invocation to the next. Nil entries
// (hand-edited or corrupted files) are skipped.
func (r *RegistryConfig) scanFor(match func(*Account) bool) (*Account, string) {
var best *Account
var bestKey string
for k, acct := range r.Accounts {
if acct == nil || !match(acct) {
continue
}
if best == nil || k < bestKey {
best, bestKey = acct, k
}
}
return best, bestKey
}
// find returns the account matching identity, which may be a DID or a handle.
// It checks the map key first (DID or provisional handle key), then scans by
// DID, then by handle, so an image ref by handle resolves even when the map is
// DID-keyed. Returns nil if no account matches.
//
// DID matches are tried before handle matches, and each scan is deterministic,
// because two entries can legitimately share a handle: renaming a handle and
// logging in again leaves the old DID-keyed entry behind with the same label.
func (r *RegistryConfig) find(identity string) *Account {
if identity == "" {
return nil
}
if acct, ok := r.Accounts[identity]; ok && acct != nil {
return acct
}
if acct, _ := r.scanFor(func(a *Account) bool { return a.DID == identity }); acct != nil {
return acct
}
acct, _ := r.scanFor(func(a *Account) bool { return a.Handle == identity })
return acct
}
// activeAccount returns the active account, falling back to the sole account
// when Active is unset or dangling and exactly one account exists.
func (r *RegistryConfig) activeAccount() *Account {
if r == nil {
return nil
}
if acct := r.find(r.Active); acct != nil {
return acct
}
if len(r.Accounts) == 1 {
for _, acct := range r.Accounts {
return acct
}
}
return nil
}
// setActive points Active at acct's key (DID when known, else handle).
func (r *RegistryConfig) setActive(acct *Account) {
if acct != nil {
r.Active = acct.key()
}
}
// upsert inserts or updates an account, keyed by DID when known. It locates any
// existing entry by DID first, then by handle, and updates it in place —
// never blanking a known DID with an empty one. When a provisional
// handle-keyed entry gains a DID, it is re-keyed to the DID (and Active
// repointed). Returns the stored account.
func (r *RegistryConfig) upsert(acct *Account) *Account {
if r.Accounts == nil {
r.Accounts = make(map[string]*Account)
}
var existing *Account
var existingKey string
// 1. Exact DID key.
if acct.DID != "" {
if e, ok := r.Accounts[acct.DID]; ok && e != nil {
existing, existingKey = e, acct.DID
}
}
// 2. Same DID stored under some other key (a provisional handle key that
// has since been backfilled).
if existing == nil && acct.DID != "" {
existing, existingKey = r.scanFor(func(e *Account) bool { return e.DID == acct.DID })
}
// 3. Handle match — but never one that already belongs to a *different*
// DID. Two entries can share a handle after a rename, and matching one
// of those would let the DID assignment below overwrite another
// identity's DID, destroying that account's credentials.
if existing == nil && acct.Handle != "" {
existing, existingKey = r.scanFor(func(e *Account) bool {
if e.Handle != acct.Handle {
return false
}
return acct.DID == "" || e.DID == "" || e.DID == acct.DID
})
}
if existing == nil {
r.Accounts[acct.key()] = acct
return acct
}
if acct.DID != "" {
existing.DID = acct.DID
}
if acct.Handle != "" {
existing.Handle = acct.Handle
}
if acct.DeviceSecret != "" {
existing.DeviceSecret = acct.DeviceSecret
}
if newKey := existing.key(); newKey != existingKey {
delete(r.Accounts, existingKey)
r.Accounts[newKey] = existing
if r.Active == existingKey {
r.Active = newKey
}
}
return existing
}
// rekey assigns did to a provisional (DID-less) account and moves it under its
// DID key, merging into any existing DID-keyed entry and repointing Active. No
// op if the DID is already set or empty.
func (r *RegistryConfig) rekey(acct *Account, did string) {
if acct == nil || did == "" || acct.DID != "" {
return
}
oldKey := acct.key() // handle
acct.DID = did
newKey := acct.key() // did
if newKey == oldKey {
return
}
delete(r.Accounts, oldKey)
if existing, ok := r.Accounts[newKey]; ok {
// A DID-keyed entry already exists (e.g. a prior login) — merge into it.
if acct.DeviceSecret != "" {
existing.DeviceSecret = acct.DeviceSecret
}
if acct.Handle != "" {
existing.Handle = acct.Handle
}
} else {
r.Accounts[newKey] = acct
}
if r.Active == oldKey {
r.Active = newKey
}
}
// resolveAccount determines which account to use for a given registry.
// Priority:
// 1. Identity detected from parent process command line (handle or DID)
// 2. Active account (set by `switch`)
// 3. Sole account (if only one exists)
// 4. Error
func (c *StoredConfig) resolveAccount(registryURL, serverURL string) (*Account, error) {
reg := c.findRegistry(registryURL)
if reg == nil || len(reg.Accounts) == 0 {
return nil, fmt.Errorf("no accounts configured for %s\nRun: %s login", serverURL, cfg.BinaryName)
}
// 1. Try to detect identity from parent process
if ref := detectImageRef(serverURL); ref != nil && ref.Identity != "" {
if acct := reg.find(ref.Identity); acct != nil {
return acct, nil
}
// Identity detected but no matching account — fall through to active
}
// 2. Active account
if acct := reg.find(reg.Active); acct != nil {
return acct, nil
}
// 3. Sole account
if len(reg.Accounts) == 1 {
for _, acct := range reg.Accounts {
return acct, nil
}
}
// 4. Ambiguous
return nil, fmt.Errorf("multiple accounts configured for %s\nRun: %s switch", serverURL, cfg.BinaryName)
}
// addAccount adds or updates an account in a registry and sets it active.
func (c *StoredConfig) addAccount(registryURL string, acct *Account) {
reg := c.getOrCreateRegistry(registryURL)
stored := reg.upsert(acct)
reg.setActive(stored)
}
// removeAccount removes an account (identity may be a DID or handle) from a
// registry. If it was the active account, clears active (or sets to the
// remaining account if exactly one left).
func (c *StoredConfig) removeAccount(registryURL, identity string) {
reg := c.findRegistry(registryURL)
if reg == nil {
return
}
acct := reg.find(identity)
if acct == nil {
return
}
k := acct.key()
delete(reg.Accounts, k)
if reg.Active == k || reg.Active == identity {
reg.Active = ""
if len(reg.Accounts) == 1 {
for _, a := range reg.Accounts {
reg.Active = a.key()
}
}
}
// Clean up empty registries
if len(reg.Accounts) == 0 {
delete(c.Registries, registryURL)
}
}
// migrateV2toV3 re-keys a v2 (handle-keyed) config to v3 (DID-keyed) in place.
// Accounts with a stored DID move under their DID key; DID-less accounts keep
// the handle as a provisional key and self-heal on the next successful get.
//
// Two v2 entries can collapse onto one v3 key: renaming a handle and logging in
// again leaves the old entry behind under the same DID, which is precisely the
// case v3 exists to fix. That is a merge, not a drop — see [v2EntryBeats] for
// the tie-break, and note the loser still donates its secret if the winner has
// none. Both the merge and the Active re-point iterate in sorted key order, so
// the outcome never depends on Go's randomized map iteration.
func migrateV2toV3(sc *StoredConfig) *StoredConfig {
for _, reg := range sc.Registries {
if reg == nil {
continue
}
oldActive := reg.Active
newAccounts := make(map[string]*Account, len(reg.Accounts))
wonWith := make(map[string]string, len(reg.Accounts)) // new key -> winning v2 key
for _, oldKey := range sortedKeys(reg.Accounts) {
acct := reg.Accounts[oldKey]
if acct == nil {
continue // tolerate a hand-edited or corrupted file
}
if acct.Handle == "" {
acct.Handle = oldKey // v2 map key was the handle
}
newKey := acct.key()
incumbent, collision := newAccounts[newKey]
if !collision {
newAccounts[newKey], wonWith[newKey] = acct, oldKey
continue
}
if v2EntryBeats(acct, oldKey, incumbent, wonWith[newKey], oldActive) {
if acct.DeviceSecret == "" {
acct.DeviceSecret = incumbent.DeviceSecret
}
newAccounts[newKey], wonWith[newKey] = acct, oldKey
} else if incumbent.DeviceSecret == "" {
incumbent.DeviceSecret = acct.DeviceSecret
}
}
reg.Accounts = newAccounts
reg.Active = ""
// Prefer the entry whose winning v2 key was the active one; only fall
// back to a handle match if none did. Two entries can share a handle
// under different DIDs, and a handle match alone would then activate an
// arbitrary one of them.
for _, newKey := range sortedKeys(newAccounts) {
if wonWith[newKey] == oldActive {
reg.setActive(newAccounts[newKey])
break
}
}
if reg.Active == "" {
for _, newKey := range sortedKeys(newAccounts) {
if newAccounts[newKey].Handle == oldActive {
reg.setActive(newAccounts[newKey])
break
}
}
}
}
sc.Version = configVersion
return sc
}
// v2EntryBeats reports whether candidate should displace incumbent when both v2
// entries re-key onto the same v3 key. Tie-breaks, in order: the entry v2 had
// marked active, then the entry that actually carries a device secret, then the
// lexically smaller v2 key. Every tier is a total order over the inputs, so the
// winner is the same on every run.
func v2EntryBeats(cand *Account, candKey string, inc *Account, incKey, oldActive string) bool {
candActive := oldActive != "" && (candKey == oldActive || cand.Handle == oldActive)
incActive := oldActive != "" && (incKey == oldActive || inc.Handle == oldActive)
if candActive != incActive {
return candActive
}
if candHasSecret, incHasSecret := cand.DeviceSecret != "", inc.DeviceSecret != ""; candHasSecret != incHasSecret {
return candHasSecret
}
return candKey < incKey
}
// sortedKeys returns m's keys in lexical order, for iteration that must be
// reproducible across runs.
func sortedKeys(m map[string]*Account) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// getUpdateCheckCachePath returns the path to the update check cache file
func getUpdateCheckCachePath() string {
homeDir, err := os.UserHomeDir()
if err != nil {
return ""
}
return fmt.Sprintf("%s/%s/update-check.json", homeDir, cfg.ConfigDirName)
}
// loadUpdateCheckCache loads the update check cache from disk
func loadUpdateCheckCache() *UpdateCheckCache {
path := getUpdateCheckCachePath()
if path == "" {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil
}
var cache UpdateCheckCache
if err := json.Unmarshal(data, &cache); err != nil {
return nil
}
return &cache
}
// saveUpdateCheckCache saves the update check cache to disk
func saveUpdateCheckCache(cache *UpdateCheckCache) {
path := getUpdateCheckCachePath()
if path == "" {
return
}
data, err := json.MarshalIndent(cache, "", " ")
if err != nil {
return
}
os.WriteFile(path, data, 0600) //nolint:errcheck
}