Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ./...
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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 ./...

Expand All @@ -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.
Expand All @@ -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
Expand Down
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 13 additions & 1 deletion cmd/config_load.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion cmd/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 2 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
var (
flagJSON string
flagNoCacheFl bool
flagConfig string
)

func noCacheRequested() bool {
Expand Down Expand Up @@ -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", "",
Expand Down
34 changes: 33 additions & 1 deletion config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"io/fs"
"math"
"os"
"path/filepath"
"strings"

"github.com/goccy/go-yaml"
Expand Down Expand Up @@ -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.
Expand Down
11 changes: 6 additions & 5 deletions image/analysis_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package image

import (
"context"
"strings"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -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{})
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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{})
Expand Down
26 changes: 17 additions & 9 deletions image/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading
Loading