diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4b06a1..c351c23 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: - go-version: '1.26' + go-version: '1.26.5' - run: go build ./... - run: go vet ./... - run: go test -race -count=1 -coverprofile=coverage.out ./... @@ -32,7 +32,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: - go-version: '1.26' + go-version: '1.26.5' - run: GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} go build -o /dev/null ./... lint: @@ -41,7 +41,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: - go-version: '1.26' + go-version: '1.26.5' - uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9 with: version: v2.12 @@ -52,7 +52,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: - go-version: '1.26' + go-version: '1.26.5' - run: go install golang.org/x/vuln/cmd/govulncheck@latest - run: govulncheck ./... @@ -62,7 +62,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: - go-version: '1.26' + go-version: '1.26.5' # Short PR-time fuzz smoke. Each fuzz target gets 60s; the nightly # workflow runs longer budgets. The matrix expands as new Fuzz* targets # are added — keep this list in sync with image/*_fuzz_test.go. @@ -79,7 +79,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: - go-version: '1.26' + go-version: '1.26.5' - name: Install Podman run: | sudo apt-get update diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cfcbfa..a9517e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `--config PATH` persistent flag lets you point any subcommand at a specific + `.layerx.yaml` regardless of the working directory. Useful for monorepos and + scripts that invoke `layerx ci` from a subdirectory. + +### Changed +- `layerx` now walks up the directory tree from the current working directory + when searching for `.layerx.yaml`, stopping at the filesystem root. A config + file at the repo root is found even when the command is invoked from a nested + subdirectory. Falls back to `$XDG_CONFIG_HOME/layerx/config.yaml` (Linux) or + `%AppData%\layerx\config.yaml` (Windows) when no file is found in the walk. +- The Docker engine resolver now honours the `DOCKER_CONFIG` environment + variable when locating `config.json` and the context metadata directory, + matching the behaviour of the Docker CLI. +- The Podman engine resolver now honours `PODMAN_CONNECTIONS_CONF` and + `CONTAINERS_CONF` environment variables before falling back to the default + XDG config paths. +- `isRegularFilePath` (archive auto-detection in the resolver) now uses + `os.Lstat` instead of `os.Stat`, so a symlink whose name looks like an + image reference (e.g. `nginx:latest`) is not mistaken for an archive. +- The cache-entry validator (`normalizeDigest`) now requires exactly 64 + lowercase hexadecimal characters after the `sha256:` prefix. This prevents + `layerx cache prune` from deleting unrelated directories when + `LAYERX_CACHE_DIR` points at a shared parent directory. + +### Fixed +- File extraction (`x` key) no longer silently overwrites a dangling symlink + at the destination path. If a broken symlink exists at the target, the + filename is bumped (e.g. `foo.env.1`) rather than replacing the link. +- `isDaemonUnreachable` no longer fires on generic filesystem errors such as + "no such file or directory" from a missing credential helper or certificate. + Those strings now only trigger the daemon-unreachable path when they appear + alongside a socket or named-pipe path (`.sock`, `/pipe/`), matching the + actual moby SDK error format for a missing daemon socket. +- `isImageNotFoundMessage` no longer matches the bare substring "not found", + which previously misclassified credential-helper and route errors as + image-not-found failures. + ## [v1.5.2] - 2026-07-08 Multi-arch container image on GHCR, fuzz hardening, and CLI help-text polish. diff --git a/cmd/config_load.go b/cmd/config_load.go index 3f046a2..49973a2 100644 --- a/cmd/config_load.go +++ b/cmd/config_load.go @@ -13,8 +13,20 @@ import ( // cobra's full usage block. On error the one-line message is printed to // stderr along with a section-specific hint when the failure is a // *config.LoadError. +// +// If --config was passed it is used directly. Otherwise config.Load() walks +// up from CWD looking for .layerx.yaml, then falls back to the OS user-config +// directory. func loadConfig(cmd *cobra.Command) (*config.Config, error) { - cfg, err := config.Load() + var ( + cfg *config.Config + err error + ) + if flagConfig != "" { + cfg, err = config.LoadFrom(flagConfig) + } else { + cfg, err = config.Load() + } if err != nil { wrapped := fmt.Errorf("loading config: %w", err) presentConfigLoadFailure(cmd, wrapped) diff --git a/cmd/resolver.go b/cmd/resolver.go index a885224..fadd03d 100644 --- a/cmd/resolver.go +++ b/cmd/resolver.go @@ -272,7 +272,11 @@ func podmanRootlessSocketPath() string { } func isRegularFilePath(path string) bool { - info, err := os.Stat(path) + // os.Lstat rather than os.Stat: a symlink named like an image reference + // (e.g. "nginx:latest -> /tmp/attacker.tar") must not silently reroute + // archive detection. Only a genuine regular file (not a link to one) is + // accepted as an archive path. + info, err := os.Lstat(path) if err != nil { return false } diff --git a/cmd/root.go b/cmd/root.go index faad132..0477465 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -14,6 +14,7 @@ import ( var ( flagJSON string flagNoCacheFl bool + flagConfig string ) func noCacheRequested() bool { @@ -93,6 +94,7 @@ variant. Without --platform, the daemon's default platform is used.`, func init() { rootCmd.Flags().StringVar(&flagJSON, "json", "", "write analysis to PATH as JSON (skips TUI; composes with the ci subcommand)") rootCmd.PersistentFlags().BoolVar(&flagNoCacheFl, "no-cache", false, "bypass the analysis cache for this run; the run still writes the cache on success") + rootCmd.PersistentFlags().StringVar(&flagConfig, "config", "", "path to .layerx.yaml config file (default: walk up from CWD, then $XDG_CONFIG_HOME/layerx/config.yaml)") rootCmd.PersistentFlags().Var(&engineValue{v: &engineFlag}, "engine", `container engine to use: "docker", "podman", or "auto". Each engine honours its own active context/connection ("docker context use", "podman system connection default"); DOCKER_HOST / CONTAINER_HOST env vars still override`) rootCmd.PersistentFlags().StringVar(&platformFlag, "platform", "", diff --git a/config/config.go b/config/config.go index 5516aa0..dc56b1f 100644 --- a/config/config.go +++ b/config/config.go @@ -7,6 +7,7 @@ import ( "io/fs" "math" "os" + "path/filepath" "strings" "github.com/goccy/go-yaml" @@ -65,8 +66,39 @@ func Default() *Config { } } +// Load searches for .layerx.yaml starting from the current working directory +// and walking up to the filesystem root (bounded at 50 levels). The first +// file found is loaded. If no file is found in the walk, the XDG user config +// directory (~/.config/layerx/config.yaml on Linux, %AppData%\layerx\config.yaml +// on Windows) is checked as a final fallback. Returns Default() when no file +// is found in any location. func Load() (*Config, error) { - return LoadFrom(defaultConfigFile) + // Walk up from CWD. + dir, err := os.Getwd() + if err != nil { + return Default(), nil + } + for i := 0; i < 50; i++ { + candidate := filepath.Join(dir, defaultConfigFile) + if _, err := os.Stat(candidate); err == nil { + return LoadFrom(candidate) + } + parent := filepath.Dir(dir) + if parent == dir { + break // reached root + } + dir = parent + } + + // XDG / OS user-config fallback. + if cfgDir, err := os.UserConfigDir(); err == nil && cfgDir != "" { + candidate := filepath.Join(cfgDir, "layerx", "config.yaml") + if _, err := os.Stat(candidate); err == nil { + return LoadFrom(candidate) + } + } + + return Default(), nil } // LoadFrom reads config from the specified path. diff --git a/image/analysis_test.go b/image/analysis_test.go index 17283f2..57224e4 100644 --- a/image/analysis_test.go +++ b/image/analysis_test.go @@ -2,6 +2,7 @@ package image import ( "context" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -235,7 +236,7 @@ func TestAnalyze_CacheHit_SkipsResolve(t *testing.T) { Tree: makeTree(makeFile("a", "/a", 50)), }, } - resolver := &mockResolver{layers: layers, imageID: "sha256:cafebabe"} + resolver := &mockResolver{layers: layers, imageID: "sha256:cafebabe" + strings.Repeat("0", 56)} // First call: miss -> resolve -> save. r1, err := AnalyzeWithOptions(context.Background(), resolver, "img:1", AnalyzeOptions{}) @@ -258,7 +259,7 @@ func TestAnalyze_NoCache_AlwaysResolves(t *testing.T) { t.Setenv("LAYERX_CACHE_DIR", cacheRoot) layers := []Layer{{Index: 0, ID: "aa", Size: 1, Tree: makeTree(makeFile("x", "/x", 1))}} - resolver := &mockResolver{layers: layers, imageID: "sha256:1111"} + resolver := &mockResolver{layers: layers, imageID: "sha256:" + strings.Repeat("1", 64)} _, err := AnalyzeWithOptions(context.Background(), resolver, "img", AnalyzeOptions{NoCache: true}) require.NoError(t, err) @@ -267,7 +268,7 @@ func TestAnalyze_NoCache_AlwaysResolves(t *testing.T) { assert.Equal(t, 2, resolver.resolveCalls, "NoCache must always resolve") // But the cache MUST still have been written. - cached, ok, err := loadCache(cacheRoot, "sha256:1111") + cached, ok, err := loadCache(cacheRoot, "sha256:"+strings.Repeat("1", 64)) require.NoError(t, err) require.True(t, ok, "NoCache still writes cache after a successful resolve") require.Len(t, cached, 1) @@ -278,7 +279,7 @@ func TestAnalyze_DifferentTagSameDigest_HitsCache(t *testing.T) { t.Setenv("LAYERX_CACHE_DIR", cacheRoot) layers := []Layer{{Index: 0, ID: "aa", Size: 1, Tree: makeTree(makeFile("x", "/x", 1))}} - resolver := &mockResolver{layers: layers, imageID: "sha256:samedigest"} + resolver := &mockResolver{layers: layers, imageID: "sha256:" + strings.Repeat("d", 64)} r1, err := AnalyzeWithOptions(context.Background(), resolver, "myapp:latest", AnalyzeOptions{}) require.NoError(t, err) @@ -311,7 +312,7 @@ func TestAnalyze_CacheHit_EmitsPhaseCacheLoad(t *testing.T) { t.Setenv("LAYERX_CACHE_DIR", cacheRoot) layers := []Layer{{Index: 0, ID: "aa", Size: 1, Tree: makeTree(makeFile("x", "/x", 1))}} - resolver := &mockResolver{layers: layers, imageID: "sha256:probe"} + resolver := &mockResolver{layers: layers, imageID: "sha256:" + strings.Repeat("3", 64)} // Prime the cache. _, err := AnalyzeWithOptions(context.Background(), resolver, "img", AnalyzeOptions{}) diff --git a/image/cache.go b/image/cache.go index 82af0d1..d4b20f2 100644 --- a/image/cache.go +++ b/image/cache.go @@ -239,19 +239,27 @@ func cachePath(root, digest string) (string, error) { return filepath.Join(root, norm, "layers.gob"), nil } -// normalizeDigest strips the "sha256:" prefix when present and rejects -// anything that could escape a single directory component (empty, path -// separators, "..", control chars). +// normalizeDigest strips the "sha256:" prefix when present and enforces the +// canonical form of a Docker/OCI content digest: exactly 64 lowercase +// hexadecimal characters. Anything else is rejected. +// +// The strict form is a defense against `LAYERX_CACHE_DIR` misconfiguration. +// PruneCache uses this validator to decide which subdirectories are cache +// entries eligible for eviction; a loose validator that accepts arbitrary +// names (e.g. "scratch-work", "README") would let prune delete unrelated +// user data if the cache root points at a shared parent directory. func normalizeDigest(digest string) (string, error) { rest, _ := strings.CutPrefix(digest, "sha256:") - if rest == "" { - return "", errBadDigest - } - if rest == "." || rest == ".." { + if len(rest) != 64 { return "", errBadDigest } - if strings.ContainsAny(rest, `/\`) || strings.Contains(rest, "..") { - return "", errBadDigest + for i := 0; i < len(rest); i++ { + c := rest[i] + isDigit := c >= '0' && c <= '9' + isHex := c >= 'a' && c <= 'f' + if !isDigit && !isHex { + return "", errBadDigest + } } return rest, nil } diff --git a/image/cache_test.go b/image/cache_test.go index 7a0e0e5..60ea8c6 100644 --- a/image/cache_test.go +++ b/image/cache_test.go @@ -140,7 +140,7 @@ func TestCacheDTO_RoundTrip_AllPersistableFields(t *testing.T) { }} cacheRoot := t.TempDir() - digest := "sha256:driftguard" + digest := "sha256:" + strings.Repeat("d", 64) require.NoError(t, saveCache(cacheRoot, digest, "", layers, nil)) rehydrated, ok, err := loadCache(cacheRoot, digest) require.NoError(t, err) @@ -238,7 +238,7 @@ func TestCacheDir_RejectsUnusableOverride(t *testing.T) { func TestSaveLoadCache_RoundTrip(t *testing.T) { root := t.TempDir() - digest := "sha256:abcdef" + digest := "sha256:" + strings.Repeat("a", 64) layers := []Layer{ { @@ -259,7 +259,7 @@ func TestSaveLoadCache_RoundTrip(t *testing.T) { func TestLoadCache_Miss_NoFile(t *testing.T) { root := t.TempDir() - got, ok, err := loadCache(root, "sha256:nope") + got, ok, err := loadCache(root, "sha256:"+strings.Repeat("e", 64)) require.NoError(t, err) assert.False(t, ok) assert.Nil(t, got) @@ -267,7 +267,7 @@ func TestLoadCache_Miss_NoFile(t *testing.T) { func TestLoadCache_SchemaMismatch_DeletesAndMisses(t *testing.T) { root := t.TempDir() - digest := "sha256:badschema" + digest := "sha256:" + strings.Repeat("b", 64) path, err := cachePath(root, digest) require.NoError(t, err) require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) @@ -294,14 +294,14 @@ func TestLoadCache_SchemaMismatch_DeletesAndMisses(t *testing.T) { func TestLoadCache_DigestMismatch_DeletesAndMisses(t *testing.T) { root := t.TempDir() - dirDigest := "sha256:aaaaaa" + dirDigest := "sha256:" + strings.Repeat("a", 64) path, err := cachePath(root, dirDigest) require.NoError(t, err) require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) f, err := os.Create(path) require.NoError(t, err) - other, err := normalizeDigest("sha256:bbbbbb") + other, err := normalizeDigest("sha256:" + strings.Repeat("c", 64)) require.NoError(t, err) env := cacheEnvelope{ Digest: other, @@ -321,7 +321,7 @@ func TestLoadCache_DigestMismatch_DeletesAndMisses(t *testing.T) { func TestLoadCache_CorruptFile_DeletesAndMisses(t *testing.T) { root := t.TempDir() - digest := "sha256:corrupt" + digest := "sha256:" + strings.Repeat("f", 64) path, err := cachePath(root, digest) require.NoError(t, err) require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) @@ -336,7 +336,7 @@ func TestLoadCache_CorruptFile_DeletesAndMisses(t *testing.T) { func TestSaveCache_NoTempFileLingers(t *testing.T) { root := t.TempDir() - digest := "sha256:keep" + digest := "sha256:" + strings.Repeat("1", 64) require.NoError(t, saveCache(root, digest, "", nil, nil)) path, err := cachePath(root, digest) @@ -351,16 +351,18 @@ func TestSaveCache_NoTempFileLingers(t *testing.T) { } func TestNormalizeDigest_StripsSha256Prefix(t *testing.T) { - got, err := normalizeDigest("sha256:abcdef") + hex := strings.Repeat("a", 64) + got, err := normalizeDigest("sha256:" + hex) require.NoError(t, err) - assert.Equal(t, "abcdef", got) + assert.Equal(t, hex, got) - got, err = normalizeDigest("abcdef") + got, err = normalizeDigest(hex) require.NoError(t, err) - assert.Equal(t, "abcdef", got) + assert.Equal(t, hex, got) } func TestNormalizeDigest_RejectsUnsafe(t *testing.T) { + hex64 := strings.Repeat("a", 64) cases := []string{ "", "sha256:", @@ -372,6 +374,16 @@ func TestNormalizeDigest_RejectsUnsafe(t *testing.T) { "foo/bar", `foo\bar`, "prefix..suffix", + // Length-outside-64 must reject: BUG-13 defense against arbitrary + // directory names being treated as cache entries. + "abcdef", + strings.Repeat("a", 63), + strings.Repeat("a", 65), + // Uppercase hex is not canonical form (Docker/OCI digests are lowercase). + strings.Repeat("A", 64), + // Non-hex ASCII inside a 64-char string. + strings.Repeat("g", 64), + hex64[:63] + "!", } for _, c := range cases { _, err := normalizeDigest(c) @@ -395,7 +407,7 @@ func TestLoadCache_TransientIOError_KeepsFile(t *testing.T) { t.Skip("running as root; chmod 0o000 does not block open") } root := t.TempDir() - digest := "sha256:transient" + digest := "sha256:" + strings.Repeat("2", 64) path, err := cachePath(root, digest) require.NoError(t, err) require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) diff --git a/image/docker.go b/image/docker.go index 0db57af..da5dd1d 100644 --- a/image/docker.go +++ b/image/docker.go @@ -786,14 +786,21 @@ func extractShortID(layerPath string) string { // isImageNotFoundMessage classifies a registry pull error as "ref does not // exist" (vs network / auth / 5xx). Conservative substring match against the // phrases emitted by Docker Hub, GHCR, ECR, and GCR pull paths. +// +// The bare substring "not found" is intentionally absent: it collides with +// unrelated errors such as `credential store "osxkeychain" not found`, +// `route not found`, and header-missing errors, which would misdirect the +// user to check the image name when the real cause is elsewhere. The needles +// below all reference the image, manifest, or repository unambiguously. func isImageNotFoundMessage(s string) bool { s = strings.ToLower(s) for _, needle := range []string{ "manifest unknown", "manifest for ", - "not found", "repository does not exist", "pull access denied", + "name unknown", + "name not known", } { if strings.Contains(s, needle) { return true @@ -805,6 +812,11 @@ func isImageNotFoundMessage(s string) bool { // isDaemonUnreachable substring-matches moby connection-failure messages. // Substring match works on every supported transport (unix socket, Windows // named pipe, TCP) without depending on internal SDK types. +// +// "no such file or directory" and "file does not exist" are only treated as +// daemon-unreachable when the error also references a socket or named-pipe +// path. This prevents generic filesystem errors (missing credential helper, +// missing certificate) from being misclassified as connectivity problems. func isDaemonUnreachable(err error) bool { if err == nil { return false @@ -814,15 +826,23 @@ func isDaemonUnreachable(err error) bool { "cannot connect to the docker daemon", "is the docker daemon running", "docker daemon is not running", - "no such file or directory", "connection refused", "connect: permission denied", - "file does not exist", + "error during connect", } { if strings.Contains(s, needle) { return true } } + // "no such file or directory" / "file does not exist" are genuine + // daemon-unreachable signals only when they appear in the context of a + // Unix socket or Windows named-pipe path. Without that guard they fire + // on unrelated filesystem errors (missing credential helpers, certs, etc.). + socketMissing := strings.Contains(s, "no such file or directory") || strings.Contains(s, "file does not exist") + hasPipePath := strings.Contains(s, ".sock") || strings.Contains(s, "/pipe/") || strings.Contains(s, "podman.sock") + if socketMissing && hasPipePath { + return true + } return false } diff --git a/image/docker_test.go b/image/docker_test.go index 78864de..fa017b8 100644 --- a/image/docker_test.go +++ b/image/docker_test.go @@ -502,10 +502,11 @@ func TestIsImageNotFoundMessage(t *testing.T) { }{ {"docker hub manifest unknown", "manifest unknown", true}, {"manifest for ref not found", "manifest for nginx:bogus not found", true}, - {"plain not found", "Error response from daemon: not found", true}, {"repository does not exist", "repository does not exist or may require 'docker login'", true}, {"pull access denied", "pull access denied for private/foo", true}, {"mixed case", "Manifest Unknown", true}, + {"credential-helper not found is not image-not-found", + `credential store "osxkeychain" not found`, false}, {"network error", "dial tcp: lookup registry: no such host", false}, {"5xx", "received unexpected HTTP status: 500 Internal Server Error", false}, {"empty", "", false}, diff --git a/image/engine/docker.go b/image/engine/docker.go index 61072f3..adb8a9c 100644 --- a/image/engine/docker.go +++ b/image/engine/docker.go @@ -89,14 +89,26 @@ type dockerConfig struct { CurrentContext string `json:"currentContext"` } +// dockerConfigDir returns the Docker configuration directory, honouring the +// DOCKER_CONFIG env var before falling back to ~/.docker. Mirrors what the +// Docker CLI does: if DOCKER_CONFIG is set, all config files (config.json, +// contexts/meta/…) live under it rather than under ~/.docker. +func (r *DockerResolver) dockerConfigDir() string { + if v := r.env("DOCKER_CONFIG"); v != "" { + return v + } + return homePath(r.files, ".docker") +} + func (r *DockerResolver) activeContext() (string, string, *dockerConfig, error) { if v := r.env("DOCKER_CONTEXT"); v != "" { return v, "env:DOCKER_CONTEXT", nil, nil } - cfgPath := homePath(r.files, ".docker", "config.json") - if cfgPath == "" { + configDir := r.dockerConfigDir() + if configDir == "" { return "", "", nil, nil } + cfgPath := filepath.Join(configDir, "config.json") data, err := r.files.readFile(cfgPath) if err != nil { if isNotExist(err) { @@ -141,10 +153,11 @@ type contextEndpointEntry struct { // when name is not found on disk, or ("", nil, err) on a real I/O / // parse failure. func (r *DockerResolver) readContextHost(name string) (string, []string, error) { - metaRoot := homePath(r.files, ".docker", "contexts", "meta") - if metaRoot == "" { + configDir := r.dockerConfigDir() + if configDir == "" { return "", nil, errNoActiveEndpoint } + metaRoot := filepath.Join(configDir, "contexts", "meta") // Docker CLI hashes context names to directory identifiers with // SHA-256(name)-hex, matching the reference implementation in diff --git a/image/engine/podman.go b/image/engine/podman.go index 4622666..33a1068 100644 --- a/image/engine/podman.go +++ b/image/engine/podman.go @@ -91,8 +91,16 @@ func (r *PodmanResolver) Resolve() (Endpoint, error) { // Podman 4.4+ writes podman-connections.json; older versions (and admin- // installed system configs) use containers.conf. When both are present the // JSON file wins, mirroring Podman's own read order. +// +// PODMAN_CONNECTIONS_CONF and CONTAINERS_CONF env vars override the default +// config file paths, matching Podman's own behaviour. Each is checked before +// falling back to the XDG/OS default location. func (r *PodmanResolver) loadConnections() (map[string]string, string, string, error) { - jsonPath := configPath(r.files, "containers", "podman-connections.json") + // Resolve the connections JSON path: env override first, then XDG default. + jsonPath := r.env("PODMAN_CONNECTIONS_CONF") + if jsonPath == "" { + jsonPath = configPath(r.files, "containers", "podman-connections.json") + } if jsonPath != "" { data, err := r.files.readFile(jsonPath) switch { @@ -107,7 +115,11 @@ func (r *PodmanResolver) loadConnections() (map[string]string, string, string, e } } - confPath := configPath(r.files, "containers", "containers.conf") + // Resolve the containers.conf path: env override first, then XDG default. + confPath := r.env("CONTAINERS_CONF") + if confPath == "" { + confPath = configPath(r.files, "containers", "containers.conf") + } if confPath != "" { data, err := r.files.readFile(confPath) switch { diff --git a/tui/model.go b/tui/model.go index 59f7be9..948f0b7 100644 --- a/tui/model.go +++ b/tui/model.go @@ -243,7 +243,7 @@ func NewModel(cfg Config) model { resolver: cfg.Resolver, progressCh: ch, writeFile: atomicWriteFile, - statFile: os.Stat, + statFile: os.Lstat, keys: defaultKeys(), noCache: cfg.NoCache, fetchCtx: ctx, @@ -1935,6 +1935,10 @@ func saveFileCmd(ctx context.Context, stat func(string) (os.FileInfo, error), // up to 1000 candidates and returns an error if all are taken — never // silently clobbers a pre-existing file by reusing the .999 candidate. // The probe loop honours ctx so a quit can abort a slow filesystem. +// +// Uses lstat (does not follow symlinks) so that a dangling symlink at +// filename is treated as "present" and bumped to filename.1 rather than +// silently clobbered. func uniquePath(ctx context.Context, stat func(string) (os.FileInfo, error), filename string) (string, error) { if stat == nil { return filename, nil @@ -2004,7 +2008,17 @@ func atomicWriteFile(name string, data []byte, perm os.FileMode) error { target := name if resolved, err := filepath.EvalSymlinks(name); err == nil { target = resolved - } else if !os.IsNotExist(err) { + } else if os.IsNotExist(err) { + // EvalSymlinks returns ErrNotExist for both "nothing at this path" and + // "dangling symlink" — distinguish them with Lstat. If a symlink entry + // exists at name (even a broken one), refuse to replace it: uniquePath + // should have already bumped the filename, so arriving here with a + // symlink at the destination means something changed under us. + if info, lstatErr := os.Lstat(name); lstatErr == nil && info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("refusing to overwrite symlink at %s", name) + } + // Nothing at this path — write directly. + } else { return err } dir := filepath.Dir(target)