From f935d85f3b6a5860232dc0e3855e74a06b2ab500 Mon Sep 17 00:00:00 2001 From: deveshctl Date: Thu, 9 Jul 2026 06:40:12 +0530 Subject: [PATCH 1/4] fix: resolve eight confirmed bugs from production bug scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes BUG-5, BUG-6, BUG-7, BUG-8, BUG-9, BUG-10, BUG-13, BUG-14 from internal-docs/bug_scan.md. BUG-4 was re-verified against current code and found correct — no change needed. Error classification (BUG-5, BUG-6): remove overly broad substrings from isDaemonUnreachable ("no such file or directory", "file does not exist") and isImageNotFoundMessage ("not found"). Both were firing on unrelated errors such as missing credential helpers, routing errors, and certificate failures, misdirecting users toward the wrong root cause. Daemon check now requires daemon-specific phrasing; image-not-found check uses OCI spec terms ("name unknown", "name not known") instead of the bare "not found" substring. Engine env var parity (BUG-7, BUG-8): DockerResolver now honours DOCKER_CONFIG when resolving config.json and contexts/meta/. PodmanResolver now honours PODMAN_CONNECTIONS_CONF and CONTAINERS_CONF before falling back to XDG defaults. Both match the behaviour of the respective engine's own CLI. Config discovery (BUG-9): config.Load() now walks up from CWD (max 50 levels) looking for .layerx.yaml, then falls back to $XDG_CONFIG_HOME/layerx/config.yaml. Adds --config PATH persistent flag to bypass the walk and load from an explicit path. Fixes silent threshold substitution when layerx ci is invoked from a monorepo subdirectory. Archive auto-detection (BUG-10): isRegularFilePath switches from os.Stat to os.Lstat. A symlink named like an image reference (e.g. "nginx:latest") is no longer mistaken for an archive path on Linux/macOS. Cache prune safety (BUG-13): normalizeDigest now requires exactly 64 lowercase hex characters after the sha256: prefix. Arbitrary directory names in a shared LAYERX_CACHE_DIR are no longer accepted as evictable cache entries. File extraction symlink safety (BUG-14): production statFile changed from os.Stat to os.Lstat so uniquePath treats a dangling symlink as present and bumps to a .N suffix. atomicWriteFile now detects dangling symlinks via Lstat when EvalSymlinks returns ErrNotExist and returns an error rather than silently replacing the link with a regular file. --- CHANGELOG.md | 37 +++++++++++++++++++++++++++++++++++++ cmd/config_load.go | 14 +++++++++++++- cmd/resolver.go | 6 +++++- cmd/root.go | 2 ++ config/config.go | 34 +++++++++++++++++++++++++++++++++- image/cache.go | 21 ++++++++++++--------- image/docker.go | 23 ++++++++++++++++++++--- image/engine/docker.go | 21 +++++++++++++++++---- image/engine/podman.go | 16 ++++++++++++++-- tui/model.go | 18 ++++++++++++++++-- 10 files changed, 169 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cfcbfa..fbb0982 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,43 @@ 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 OS errors such as "no such + file or directory" from a missing credential helper or certificate. The check + now requires daemon-specific phrasing, reducing false "Is Docker running?" + messages when the daemon is fine. +- `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/cache.go b/image/cache.go index 82af0d1..342520f 100644 --- a/image/cache.go +++ b/image/cache.go @@ -239,19 +239,22 @@ 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 validates that +// the remainder is exactly 64 lowercase hex characters — the canonical form +// of a Docker/OCI content-addressable layer digest. +// +// Accepting only the strict hex form prevents pruneCache from treating +// arbitrary directory names (e.g. a mispointed LAYERX_CACHE_DIR that shares +// a parent with unrelated data) as evictable cache entries. 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 _, c := range rest { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + return "", errBadDigest + } } return rest, nil } diff --git a/image/docker.go b/image/docker.go index 0db57af..0ba0c9f 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. +// +// "not found" is intentionally absent: it is too broad and matches unrelated +// errors such as "credential store not found" or "route not found", which +// would misdirect the user to check the image name when the real cause is +// elsewhere. Use "manifest unknown" / "manifest for " / "repository does not +// exist" instead — all of which reference the image 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,13 @@ 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 intentionally +// absent: they are generic OS strings that also appear in unrelated errors +// (missing credential helper, missing certificate, missing bind-mount source) +// and would produce a false "daemon not running" diagnosis. The daemon-specific +// phrases below all require the word "daemon", a socket/pipe reference, or the +// connection endpoint itself, so they cannot fire on unrelated filesystem errors. func isDaemonUnreachable(err error) bool { if err == nil { return false @@ -814,10 +828,13 @@ 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", + // Windows named-pipe connect failure includes the pipe path + "error during connect", + // moby SDK wraps dial errors with the endpoint URL; matching on + // "/var/run/docker.sock" or "pipe/docker_engine" would be too + // specific, so we key on the SDK's "Cannot connect" prefix instead. } { if strings.Contains(s, needle) { return true 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) From 9190298aed0eebce6e7fde7c92be24511750cbbe Mon Sep 17 00:00:00 2001 From: deveshctl Date: Thu, 9 Jul 2026 08:26:43 +0530 Subject: [PATCH 2/4] fix(bug-scan): repair test-side regressions from the eight-bug commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The initial fix commit (f935d85) tightened three classifiers but shipped with test-side regressions that surfaced on CI: isDaemonUnreachable — the substring guard removed 'no such file or directory' and 'file does not exist' entirely, breaking classifier tests that assert 'dial unix /var/run/docker.sock: connect: no such file or directory' and 'open //./pipe/docker_engine: file does not exist' are still recognised as daemon-unreachable (both are the real moby SDK dial-error format for a missing daemon socket). Restore both substrings but require a co-occurring socket or named-pipe token (.sock / /pipe/ / podman.sock) before firing. Unrelated filesystem errors (credential helpers, certificates) don't mention a socket path and remain excluded, so BUG-5's false-positive fix is preserved. isImageNotFoundMessage — the pushed commit dropped the bare 'not found' needle (BUG-6) but forgot to remove the pre-existing test case 'plain_not_found' that expected 'Error response from daemon: not found' to match. Delete that case and replace it with a regression guard that asserts a credential-helper error MUST NOT be classified as image-not-found. normalizeDigest — the pushed commit's 64-hex check tripped staticcheck QF1001 (De Morgan's law) on '!((c >= 0-9) || (c >= a-f))'. Rewrite the predicate using two named booleans (isDigit / isHex) so no reducible negation-of-disjunction remains for the linter to flag. The behaviour is identical. Test fixture migration — cache_test.go and analysis_test.go used short synthetic digests like 'sha256:abcdef' and 'sha256:driftguard' that fail the newly-strict normalizeDigest. Every short fixture is now 'sha256:' + strings.Repeat('', 64), preserving each test's intent while satisfying the canonical-form requirement. TestNormalizeDigest_RejectsUnsafe extended with length-not-64, uppercase-hex, and non-hex-ASCII cases so a future loosening of the validator fails a test rather than silently regressing BUG-13. CHANGELOG — 'isDaemonUnreachable' bullet reworded to reflect the guarded behaviour ('.sock/pipe context required') rather than the pushed commit's 'requires daemon-specific phrasing' phrasing that is no longer accurate. --- CHANGELOG.md | 9 +++++---- image/analysis_test.go | 11 ++++++----- image/cache.go | 21 +++++++++++++-------- image/cache_test.go | 38 +++++++++++++++++++++++++------------- image/docker.go | 33 ++++++++++++++++++--------------- image/docker_test.go | 3 ++- 6 files changed, 69 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbb0982..a9517e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,10 +36,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 OS errors such as "no such - file or directory" from a missing credential helper or certificate. The check - now requires daemon-specific phrasing, reducing false "Is Docker running?" - messages when the daemon is fine. +- `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. 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 342520f..d4b20f2 100644 --- a/image/cache.go +++ b/image/cache.go @@ -239,20 +239,25 @@ func cachePath(root, digest string) (string, error) { return filepath.Join(root, norm, "layers.gob"), nil } -// normalizeDigest strips the "sha256:" prefix when present and validates that -// the remainder is exactly 64 lowercase hex characters — the canonical form -// of a Docker/OCI content-addressable layer digest. +// 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. // -// Accepting only the strict hex form prevents pruneCache from treating -// arbitrary directory names (e.g. a mispointed LAYERX_CACHE_DIR that shares -// a parent with unrelated data) as evictable cache entries. +// 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 len(rest) != 64 { return "", errBadDigest } - for _, c := range rest { - if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + 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 } } 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 0ba0c9f..da5dd1d 100644 --- a/image/docker.go +++ b/image/docker.go @@ -787,11 +787,11 @@ func extractShortID(layerPath string) string { // exist" (vs network / auth / 5xx). Conservative substring match against the // phrases emitted by Docker Hub, GHCR, ECR, and GCR pull paths. // -// "not found" is intentionally absent: it is too broad and matches unrelated -// errors such as "credential store not found" or "route not found", which -// would misdirect the user to check the image name when the real cause is -// elsewhere. Use "manifest unknown" / "manifest for " / "repository does not -// exist" instead — all of which reference the image unambiguously. +// 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{ @@ -813,12 +813,10 @@ func isImageNotFoundMessage(s string) bool { // 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 intentionally -// absent: they are generic OS strings that also appear in unrelated errors -// (missing credential helper, missing certificate, missing bind-mount source) -// and would produce a false "daemon not running" diagnosis. The daemon-specific -// phrases below all require the word "daemon", a socket/pipe reference, or the -// connection endpoint itself, so they cannot fire on unrelated filesystem errors. +// "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 @@ -830,16 +828,21 @@ func isDaemonUnreachable(err error) bool { "docker daemon is not running", "connection refused", "connect: permission denied", - // Windows named-pipe connect failure includes the pipe path "error during connect", - // moby SDK wraps dial errors with the endpoint URL; matching on - // "/var/run/docker.sock" or "pipe/docker_engine" would be too - // specific, so we key on the SDK's "Cannot connect" prefix instead. } { 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}, From 44b3c92cfed5cb06d8e7c1510f1ca54d9e9ae70b Mon Sep 17 00:00:00 2001 From: Devesh Pharswan <87819719+deveshctl@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:32:15 +0530 Subject: [PATCH 3/4] Update ci.yml ci: pin Go to 1.26.5 for GO-2026-5856 (CVE-2026-42505) --- .github/workflows/ci.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4b06a1..09c0aaf 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.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.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.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.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.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.5' - name: Install Podman run: | sudo apt-get update From 3a2e56655224e16088c0e7ea0cd3c7ddc0eb7a19 Mon Sep 17 00:00:00 2001 From: Devesh Pharswan <87819719+deveshctl@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:35:14 +0530 Subject: [PATCH 4/4] pin Go to 1.26.5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit govulncheck flagged the stdlib TLS vulnerability GO-2026-5856 when setup-go with spec '1.26' resolved to go1.26.4 on the CI runner. Pinning to 1.26.5 lands the patched toolchain. go.mod minimum stays at 1.26.2 so users building from source with an older 1.26.x are not forced to upgrade — only CI is. --- .github/workflows/ci.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09c0aaf..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.5.5' + 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.5.5' + 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.5.5' + 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.5.5' + 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.5.5' + 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.5.5' + go-version: '1.26.5' - name: Install Podman run: | sudo apt-get update