From 9311600de85f33a7ad85ed891c00833fe848fa9f Mon Sep 17 00:00:00 2001 From: Paul Johnston Date: Mon, 20 Jul 2026 18:02:30 -0600 Subject: [PATCH] feat: add OCI image support and release automation - load OCI layout directories and tar archives - improve image loading errors and temporary file cleanup - honor whiteouts and harden extraction path handling - report malformed layer and digest lookup errors - upgrade Go dependencies - expand PR checks and add GoReleaser-based releases - document supported image formats --- .github/workflows/pr.yaml | 40 ++- .github/workflows/release.yaml | 43 ++++ .goreleaser.yaml | 36 +++ README.md | 6 +- go.mod | 39 ++- go.sum | 90 ++++--- main.go | 431 ++++++++++++++++++++++++++++----- main_test.go | 175 +++++++++++++ 8 files changed, 715 insertions(+), 145 deletions(-) create mode 100644 .github/workflows/release.yaml create mode 100644 .goreleaser.yaml diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 088890a..2665eda 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -4,27 +4,53 @@ on: pull_request: branches: - master + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pr-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: check: - name: Vet, Build, Test + name: Check runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: - go-version: '1.25' - id: go + go-version-file: go.mod + cache-dependency-path: go.sum + + - name: Verify modules + run: go mod verify + + - name: Check module files + run: | + go mod tidy + git diff --exit-code -- go.mod go.sum + + - name: Check formatting + run: test -z "$(gofmt -l .)" - name: Vet run: go vet ./... + - name: Test + run: go test -race ./... + - name: Build run: go build ./... - - name: Test - run: go test ./... + - name: Check release configuration + uses: goreleaser/goreleaser-action@v7 + with: + version: "~> v2" + args: check diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..c097916 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,43 @@ +name: Release + +on: + push: + tags: + - "*" + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + +jobs: + release: + name: Release + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: write + + steps: + - name: Check out code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + + - name: Test + run: go test ./... + + - name: Release with GoReleaser + uses: goreleaser/goreleaser-action@v7 + with: + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..e2beb59 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,36 @@ +version: 2 + +project_name: layer + +builds: + - main: . + binary: layer + env: + - CGO_ENABLED=0 + flags: + - -trimpath + ldflags: + - -s -w + goos: + - darwin + - linux + - windows + goarch: + - amd64 + - arm64 + mod_timestamp: "{{ .CommitTimestamp }}" + +archives: + - formats: + - tar.gz + format_overrides: + - goos: windows + formats: + - zip + +checksum: + name_template: checksums.txt + +changelog: + use: git + sort: asc diff --git a/README.md b/README.md index 5b6f3ef..ad6ee3b 100644 --- a/README.md +++ b/README.md @@ -31,10 +31,12 @@ go install github.com/stackb/layer@latest ## Usage -Show layers in an image (tarball filename): +Show layers in an image (Docker archive, OCI archive, or OCI layout directory): ```sh layer inspect image.tar +layer inspect image_oci_layout.tar +layer inspect path/to/oci/layout ``` Show layers in an image (ref): @@ -50,7 +52,7 @@ N Layer Size 6 sha256:b6812e8d56d65d296e21a639b786e7e793e8b969bd2b109fd172646ce5ebe951 1.4 kB ``` -List files in an image: +List files in a Docker archive, single-image OCI archive, or OCI layout directory: ```sh layer ls image.tar # all layers diff --git a/go.mod b/go.mod index cdad720..092355a 100644 --- a/go.mod +++ b/go.mod @@ -4,41 +4,38 @@ go 1.25.7 require ( github.com/dustin/go-humanize v1.0.1 - github.com/google/go-containerregistry v0.21.3 + github.com/google/go-containerregistry v0.21.7 github.com/urfave/cli/v2 v2.27.7 ) require ( github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect - github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/cli v29.3.0+incompatible // indirect - github.com/docker/distribution v2.8.3+incompatible // indirect - github.com/docker/docker-credential-helpers v0.9.3 // indirect - github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/cli v29.6.2+incompatible // indirect + github.com/docker/docker-credential-helpers v0.9.8 // indirect + github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/go-logr/logr v1.4.3 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect + github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/klauspost/compress v1.18.4 // indirect - github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/klauspost/compress v1.19.1 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect - github.com/moby/moby/api v1.54.0 // indirect - github.com/moby/moby/client v0.3.0 // indirect + github.com/moby/moby/api v1.55.0 // indirect + github.com/moby/moby/client v0.5.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sirupsen/logrus v1.9.4 // indirect - github.com/vbatts/tar-split v0.12.2 // indirect - github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect - go.opentelemetry.io/otel v1.36.0 // indirect - go.opentelemetry.io/otel/metric v1.36.0 // indirect - go.opentelemetry.io/otel/trace v1.36.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.42.0 // indirect + github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect ) diff --git a/go.sum b/go.sum index 331269d..a33a6e9 100644 --- a/go.sum +++ b/go.sum @@ -1,52 +1,48 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= -github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw= -github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/cli v29.3.0+incompatible h1:z3iWveU7h19Pqx7alZES8j+IeFQZ1lhTwb2F+V9SVvk= -github.com/docker/cli v29.3.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= -github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= -github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= -github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= -github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= -github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= -github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/cli v29.6.2+incompatible h1:/bjePvcbbFTnRrMfWJBY7AjfICdsiLVgHn6LwTVOcqw= +github.com/docker/cli v29.6.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/docker-credential-helpers v0.9.8 h1:bIREROb7So6PRlq6KTtdS9MPEjC29OQRkFNlvK2OX8Q= +github.com/docker/docker-credential-helpers v0.9.8/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-containerregistry v0.21.3 h1:Xr+yt3VvwOOn/5nJzd7UoOhwPGiPkYW0zWDLLUXqAi4= -github.com/google/go-containerregistry v0.21.3/go.mod h1:D5ZrJF1e6dMzvInpBPuMCX0FxURz7GLq2rV3Us9aPkc= +github.com/google/go-containerregistry v0.21.7 h1:/vPFuVXDjtFREsVArW+0h1CIl5urnOhzei4X2DMW9IU= +github.com/google/go-containerregistry v0.21.7/go.mod h1:kjSbt7/zMsKLWfnHrIvKvhXHUw91jbe9DNjPPJ32gXE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= -github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= -github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= -github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/moby/api v1.54.0 h1:7kbUgyiKcoBhm0UrWbdrMs7RX8dnwzURKVbZGy2GnL0= -github.com/moby/moby/api v1.54.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc= -github.com/moby/moby/client v0.3.0 h1:UUGL5okry+Aomj3WhGt9Aigl3ZOxZGqR7XPo+RLPlKs= -github.com/moby/moby/client v0.3.0/go.mod h1:HJgFbJRvogDQjbM8fqc1MCEm4mIAGMLjXbgwoZp6jCQ= +github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= +github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc= +github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -57,32 +53,30 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= -github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4= -github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= -github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= -github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= -go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= -go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= -go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= -go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= -go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= -go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= -go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= -go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= -go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= +github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= diff --git a/main.go b/main.go index bc9f1ce..931d530 100644 --- a/main.go +++ b/main.go @@ -2,9 +2,11 @@ package main import ( "archive/tar" + "errors" "fmt" "io" "os" + "path" "path/filepath" "sort" "strconv" @@ -17,8 +19,10 @@ import ( "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/daemon" + "github.com/google/go-containerregistry/pkg/v1/layout" "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/google/go-containerregistry/pkg/v1/tarball" + "github.com/google/go-containerregistry/pkg/v1/types" "github.com/urfave/cli/v2" ) @@ -122,28 +126,213 @@ func makeOptions(opts ...crane.Option) crane.Options { return opt } -// getImage returns the image for the given ref. -func getImage(r string) (v1.Image, error) { +// loadedImage owns any temporary files needed by an image. +type loadedImage struct { + image v1.Image + cleanup func() +} + +func (i *loadedImage) close() { + if i.cleanup != nil { + i.cleanup() + } +} + +// getImage returns an image from a Docker archive, OCI layout or archive, +// local daemon, or remote registry. Existing filesystem paths are always +// treated as local inputs so useful format and I/O errors are not hidden by +// unrelated daemon and registry failures. +func getImage(r string) (*loadedImage, error) { if r == "" { return nil, fmt.Errorf("no image ref provided") } - image, err := tarball.ImageFromPath(r, nil) - if err == nil { - return image, nil + info, statErr := os.Stat(r) + if statErr == nil { + if info.IsDir() { + image, err := imageFromOCILayout(r) + if err != nil { + return nil, fmt.Errorf("reading OCI image layout %q: %w", r, err) + } + return &loadedImage{image: image}, nil + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("image path %q is not a regular file or directory", r) + } + + image, dockerErr := tarball.ImageFromPath(r, nil) + if dockerErr == nil { + return &loadedImage{image: image}, nil + } + + image, cleanup, ociErr := imageFromOCIArchive(r) + if ociErr == nil { + return &loadedImage{image: image, cleanup: cleanup}, nil + } + return nil, fmt.Errorf("reading local image %q: %w", r, errors.Join( + fmt.Errorf("Docker archive: %w", dockerErr), + fmt.Errorf("OCI archive: %w", ociErr), + )) + } + if !os.IsNotExist(statErr) { + return nil, fmt.Errorf("checking image path %q: %w", r, statErr) + } + + image, _, daemonErr := getDaemonImage(r) + if daemonErr == nil { + return &loadedImage{image: image}, nil + } + + image, _, remoteErr := getRemoteImage(r) + if remoteErr == nil { + return &loadedImage{image: image}, nil + } + + return nil, fmt.Errorf("unable to load image %q: %w", r, errors.Join( + fmt.Errorf("local daemon: %w", daemonErr), + fmt.Errorf("remote registry: %w", remoteErr), + )) +} + +func imageFromOCILayout(dir string) (v1.Image, error) { + lp, err := layout.FromPath(dir) + if err != nil { + return nil, err + } + index, err := lp.ImageIndex() + if err != nil { + return nil, err } + return imageFromIndex(index) +} - image, _, err = getDaemonImage(r) - if err == nil { - return image, nil +// imageFromIndex follows single-child indexes. Choosing among a multi-platform +// index requires a platform option, which the CLI does not currently expose. +func imageFromIndex(index v1.ImageIndex) (v1.Image, error) { + manifest, err := index.IndexManifest() + if err != nil { + return nil, fmt.Errorf("reading index manifest: %w", err) + } + if len(manifest.Manifests) != 1 { + return nil, fmt.Errorf("OCI index contains %d images; exactly one is required", len(manifest.Manifests)) + } + desc := manifest.Manifests[0] + switch desc.MediaType { + case types.OCIManifestSchema1, types.DockerManifestSchema2: + return index.Image(desc.Digest) + case types.OCIImageIndex, types.DockerManifestList: + child, err := index.ImageIndex(desc.Digest) + if err != nil { + return nil, fmt.Errorf("reading nested image index: %w", err) + } + return imageFromIndex(child) + default: + return nil, fmt.Errorf("unsupported OCI descriptor media type %q", desc.MediaType) } +} - image, _, err = getRemoteImage(r) - if err == nil { - return image, nil +func imageFromOCIArchive(filename string) (v1.Image, func(), error) { + dir, err := os.MkdirTemp("", "layer-oci-layout-*") + if err != nil { + return nil, nil, fmt.Errorf("creating temporary directory: %w", err) } + cleanup := func() { _ = os.RemoveAll(dir) } - return nil, fmt.Errorf("unable to find image %q", r) + if err := extractOCIArchive(filename, dir); err != nil { + cleanup() + return nil, nil, err + } + root, err := findOCILayoutRoot(dir) + if err != nil { + cleanup() + return nil, nil, err + } + image, err := imageFromOCILayout(root) + if err != nil { + cleanup() + return nil, nil, err + } + return image, cleanup, nil +} + +func extractOCIArchive(filename, dir string) error { + f, err := os.Open(filename) + if err != nil { + return err + } + defer f.Close() + + tr := tar.NewReader(f) + for { + h, err := tr.Next() + if err == io.EOF { + return nil + } + if err != nil { + return fmt.Errorf("reading tar: %w", err) + } + + name, err := cleanArchivePath(h.Name) + if err != nil { + return err + } + if name == "" { + continue + } + dest := filepath.Join(dir, filepath.FromSlash(name)) + switch h.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(dest, 0o755); err != nil { + return fmt.Errorf("creating directory %q: %w", name, err) + } + case tar.TypeReg, tar.TypeRegA: + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return fmt.Errorf("creating directory for %q: %w", name, err) + } + out, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return fmt.Errorf("creating %q: %w", name, err) + } + _, copyErr := io.Copy(out, tr) + closeErr := out.Close() + if copyErr != nil { + return fmt.Errorf("extracting %q: %w", name, copyErr) + } + if closeErr != nil { + return fmt.Errorf("closing %q: %w", name, closeErr) + } + default: + return fmt.Errorf("unsupported tar entry %q with type %d", h.Name, h.Typeflag) + } + } +} + +func findOCILayoutRoot(dir string) (string, error) { + if _, err := os.Stat(filepath.Join(dir, "index.json")); err == nil { + return dir, nil + } + entries, err := os.ReadDir(dir) + if err != nil { + return "", err + } + if len(entries) == 1 && entries[0].IsDir() { + candidate := filepath.Join(dir, entries[0].Name()) + if _, err := os.Stat(filepath.Join(candidate, "index.json")); err == nil { + return candidate, nil + } + } + return "", fmt.Errorf("archive does not contain an OCI index.json at its root") +} + +func cleanArchivePath(name string) (string, error) { + clean := path.Clean(strings.TrimLeft(name, "/")) + if clean == "." { + return "", nil + } + if clean == ".." || strings.HasPrefix(clean, "../") { + return "", fmt.Errorf("unsafe archive path %q", name) + } + return clean, nil } // getRemoteImage returns the image for the given ref. @@ -176,11 +365,12 @@ func getDaemonImage(r string, opt ...crane.Option) (v1.Image, name.Reference, er // inspect prints info about layers. func inspect(cfg *config) error { - image, err := getImage(cfg.ref) + loaded, err := getImage(cfg.ref) if err != nil { return err } - return inspectImage(cfg, image) + defer loaded.close() + return inspectImage(cfg, loaded.image) } // inspectImage prints info about the layers of an image. @@ -210,11 +400,12 @@ func inspectImage(cfg *config, image v1.Image) error { // ls prints layers. func ls(cfg *config) error { - image, err := getImage(cfg.ref) + loaded, err := getImage(cfg.ref) if err != nil { return err } - return lsImage(cfg, image) + defer loaded.close() + return lsImage(cfg, loaded.image) } // lsImage lists files in the layers of an image. @@ -247,12 +438,16 @@ func lsImage(cfg *config, image v1.Image) error { if err != nil { return fmt.Errorf("invalid layer id %s: %w", id, err) } - layer, err := image.LayerByDigest(hash) - if layer == nil { - layer, err = image.LayerByDiffID(hash) - } - if err != nil { - return fmt.Errorf("layer %s not found: %w", id, err) + layer, digestErr := image.LayerByDigest(hash) + if digestErr != nil || layer == nil { + var diffErr error + layer, diffErr = image.LayerByDiffID(hash) + if diffErr != nil || layer == nil { + if lookupErr := errors.Join(digestErr, diffErr); lookupErr != nil { + return fmt.Errorf("layer %s not found: %w", id, lookupErr) + } + return fmt.Errorf("layer %s not found", id) + } } if err := files(cfg, layer); err != nil { return err @@ -287,6 +482,9 @@ func files(cfg *config, layer v1.Layer) error { if err == io.EOF { break } + if err != nil { + return fmt.Errorf("reading layer tar: %w", err) + } headers = append(headers, header) } @@ -318,12 +516,23 @@ func extract(cfg *config) error { return fmt.Errorf("no files specified") } - image, err := getImage(cfg.ref) + for _, name := range cfg.files { + clean, err := cleanArchivePath(name) + if err != nil { + return fmt.Errorf("invalid file path: %w", err) + } + if clean == "" { + return fmt.Errorf("invalid empty file path") + } + } + + loaded, err := getImage(cfg.ref) if err != nil { return err } + defer loaded.close() - return extractFromImage(cfg, image) + return extractFromImage(cfg, loaded.image) } // extractFromImage extracts files from the given image. @@ -334,60 +543,49 @@ func extractFromImage(cfg *config, image v1.Image) error { } // Build a set of wanted files for quick lookup. - // Normalize by stripping leading slash. wanted := make(map[string]bool, len(cfg.files)) for _, f := range cfg.files { - wanted[strings.TrimPrefix(f, "/")] = true + name, err := cleanArchivePath(f) + if err != nil || name == "" { + return fmt.Errorf("invalid file path %q", f) + } + wanted[name] = true } found := make(map[string]bool, len(cfg.files)) + resolved := make(map[string]bool, len(cfg.files)) // Search layers in reverse order (last wins) to match container runtime behavior. for i := len(layers) - 1; i >= 0; i-- { - layer := layers[i] - - uncompressed, err := layer.Uncompressed() + states, err := scanLayerForFiles(layers[i], wanted, resolved) if err != nil { - return fmt.Errorf("getting layer: %w", err) + return err } - tarReader := tar.NewReader(uncompressed) - for { - header, err := tarReader.Next() - if err == io.EOF { - break - } - if err != nil { - uncompressed.Close() - return fmt.Errorf("reading tar: %w", err) - } - - name := strings.TrimPrefix(header.Name, "./") - name = strings.TrimPrefix(name, "/") - - if !wanted[name] || found[name] { - continue + selections := make(map[int]string) + for name, state := range states { + resolved[name] = true + if state.sequence >= 0 { + selections[state.sequence] = name } - - if err := extractFile(cfg, name, tarReader); err != nil { - uncompressed.Close() + } + if len(selections) > 0 { + if err := extractLayerFiles(cfg, layers[i], selections); err != nil { return err } - found[name] = true - - // Stop early if all files found. - if len(found) == len(wanted) { - uncompressed.Close() - return nil + for _, name := range selections { + found[name] = true } } - uncompressed.Close() + if len(resolved) == len(wanted) { + break + } } // Report any files not found. var missing []string for _, f := range cfg.files { - name := strings.TrimPrefix(f, "/") + name, _ := cleanArchivePath(f) if !found[name] { missing = append(missing, f) } @@ -399,27 +597,126 @@ func extractFromImage(cfg *config, image v1.Image) error { return nil } +// layerFileState records the last regular-file entry for a requested path. +// A negative sequence means a whiteout hides the path from lower layers. +type layerFileState struct { + sequence int +} + +func scanLayerForFiles(layer v1.Layer, wanted, resolved map[string]bool) (map[string]layerFileState, error) { + uncompressed, err := layer.Uncompressed() + if err != nil { + return nil, fmt.Errorf("getting layer: %w", err) + } + defer uncompressed.Close() + + states := make(map[string]layerFileState) + tr := tar.NewReader(uncompressed) + for sequence := 0; ; sequence++ { + header, err := tr.Next() + if err == io.EOF { + return states, nil + } + if err != nil { + return nil, fmt.Errorf("reading layer tar: %w", err) + } + name, err := cleanArchivePath(header.Name) + if err != nil { + return nil, err + } + if name == "" { + continue + } + + dir, base := path.Split(name) + dir = strings.TrimSuffix(dir, "/") + if base == ".wh..wh..opq" { + for candidate := range wanted { + _, alreadyInLayer := states[candidate] + if !resolved[candidate] && !alreadyInLayer && pathWithin(candidate, dir) { + states[candidate] = layerFileState{sequence: -1} + } + } + continue + } + if strings.HasPrefix(base, ".wh.") { + target := path.Join(dir, strings.TrimPrefix(base, ".wh.")) + _, alreadyInLayer := states[target] + if wanted[target] && !resolved[target] && !alreadyInLayer { + states[target] = layerFileState{sequence: -1} + } + continue + } + if !wanted[name] || resolved[name] { + continue + } + if header.Typeflag != tar.TypeReg && header.Typeflag != tar.TypeRegA { + return nil, fmt.Errorf("requested path %q is not a regular file", name) + } + states[name] = layerFileState{sequence: sequence} + } +} + +func pathWithin(name, dir string) bool { + return dir == "" || strings.HasPrefix(name, dir+"/") +} + +func extractLayerFiles(cfg *config, layer v1.Layer, selections map[int]string) error { + uncompressed, err := layer.Uncompressed() + if err != nil { + return fmt.Errorf("getting layer: %w", err) + } + defer uncompressed.Close() + + tr := tar.NewReader(uncompressed) + for sequence := 0; ; sequence++ { + _, err := tr.Next() + if err == io.EOF { + return nil + } + if err != nil { + return fmt.Errorf("reading layer tar: %w", err) + } + if name, ok := selections[sequence]; ok { + if err := extractFile(cfg, name, tr); err != nil { + return err + } + } + } +} + // extractFile writes the contents of a tar entry to stdout or to a file under outputDir. func extractFile(cfg *config, name string, r io.Reader) error { if cfg.outputDir == "" { - _, err := io.Copy(cfg.out, r) - return err + if _, err := io.Copy(cfg.out, r); err != nil { + return fmt.Errorf("writing %s: %w", name, err) + } + return nil + } + + if err := os.MkdirAll(cfg.outputDir, 0o755); err != nil { + return fmt.Errorf("creating output directory: %w", err) + } + root, err := os.OpenRoot(cfg.outputDir) + if err != nil { + return fmt.Errorf("opening output directory: %w", err) } + defer root.Close() - outPath := filepath.Join(cfg.outputDir, name) - if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil { + if err := root.MkdirAll(path.Dir(name), 0o755); err != nil { return fmt.Errorf("creating directory for %s: %w", name, err) } - f, err := os.Create(outPath) + f, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) if err != nil { - return fmt.Errorf("creating file %s: %w", outPath, err) + return fmt.Errorf("creating file %s: %w", name, err) } - defer f.Close() - if _, err := io.Copy(f, r); err != nil { - return fmt.Errorf("writing file %s: %w", outPath, err) + _ = f.Close() + return fmt.Errorf("writing file %s: %w", name, err) + } + if err := f.Close(); err != nil { + return fmt.Errorf("closing file %s: %w", name, err) } - return nil } diff --git a/main_test.go b/main_test.go index 3d2e261..ad55ee5 100644 --- a/main_test.go +++ b/main_test.go @@ -3,6 +3,7 @@ package main import ( "archive/tar" "bytes" + "io" "os" "path/filepath" "strings" @@ -10,6 +11,7 @@ import ( v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/layout" "github.com/google/go-containerregistry/pkg/v1/mutate" "github.com/google/go-containerregistry/pkg/v1/tarball" ) @@ -52,6 +54,69 @@ func createTestImage(t *testing.T, layers ...v1.Layer) v1.Image { return img } +func createOCILayout(t *testing.T, img v1.Image) string { + t.Helper() + dir := t.TempDir() + index := mutate.AppendManifests(empty.Index, mutate.IndexAddendum{Add: img}) + if _, err := layout.Write(dir, index); err != nil { + t.Fatal(err) + } + return dir +} + +func archiveDirectory(t *testing.T, dir string) string { + t.Helper() + filename := filepath.Join(t.TempDir(), "image_oci_layout.tar") + f, err := os.Create(filename) + if err != nil { + t.Fatal(err) + } + tw := tar.NewWriter(f) + err = filepath.Walk(dir, func(filename string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + if filename == dir { + return nil + } + name, err := filepath.Rel(dir, filename) + if err != nil { + return err + } + header, err := tar.FileInfoHeader(info, "") + if err != nil { + return err + } + header.Name = filepath.ToSlash(name) + if err := tw.WriteHeader(header); err != nil { + return err + } + if !info.Mode().IsRegular() { + return nil + } + in, err := os.Open(filename) + if err != nil { + return err + } + _, copyErr := io.Copy(tw, in) + closeErr := in.Close() + if copyErr != nil { + return copyErr + } + return closeErr + }) + if err != nil { + t.Fatal(err) + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + return filename +} + func TestInspectImage(t *testing.T) { layer1 := createTestLayer(t, map[string]string{"a.txt": "hello"}) layer2 := createTestLayer(t, map[string]string{"b.txt": "world"}) @@ -270,3 +335,113 @@ func TestLsImageWithLayerID(t *testing.T) { t.Errorf("did not expect layer2.txt for layer 1 only:\n%s", output) } } + +func TestGetImageFromOCILayoutDirectory(t *testing.T) { + img := createTestImage(t, createTestLayer(t, map[string]string{"oci.txt": "content"})) + loaded, err := getImage(createOCILayout(t, img)) + if err != nil { + t.Fatal(err) + } + defer loaded.close() + + var buf bytes.Buffer + if err := lsImage(&config{out: &buf}, loaded.image); err != nil { + t.Fatal(err) + } + if !strings.Contains(buf.String(), "oci.txt") { + t.Fatalf("expected OCI layer contents, got:\n%s", buf.String()) + } +} + +func TestGetImageFromOCIArchive(t *testing.T) { + img := createTestImage(t, createTestLayer(t, map[string]string{"oci.txt": "content"})) + archive := archiveDirectory(t, createOCILayout(t, img)) + loaded, err := getImage(archive) + if err != nil { + t.Fatal(err) + } + defer loaded.close() + + var buf bytes.Buffer + if err := lsImage(&config{out: &buf}, loaded.image); err != nil { + t.Fatal(err) + } + if !strings.Contains(buf.String(), "oci.txt") { + t.Fatalf("expected OCI layer contents, got:\n%s", buf.String()) + } +} + +func TestGetImageReportsLocalFormatErrors(t *testing.T) { + filename := filepath.Join(t.TempDir(), "not-an-image.tar") + if err := os.WriteFile(filename, []byte("not a tar archive"), 0o600); err != nil { + t.Fatal(err) + } + _, err := getImage(filename) + if err == nil { + t.Fatal("expected an error") + } + for _, want := range []string{"reading local image", "Docker archive", "OCI archive"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not contain %q", err, want) + } + } +} + +func TestExtractHonorsWhiteout(t *testing.T) { + lower := createTestLayer(t, map[string]string{"secret.txt": "old"}) + upper := createTestLayer(t, map[string]string{".wh.secret.txt": ""}) + img := createTestImage(t, lower, upper) + cfg := &config{files: []string{"secret.txt"}, out: &bytes.Buffer{}} + + err := extractFromImage(cfg, img) + if err == nil || !strings.Contains(err.Error(), "files not found") { + t.Fatalf("expected whiteouted file to be absent, got %v", err) + } +} + +func TestExtractHonorsOpaqueWhiteout(t *testing.T) { + lower := createTestLayer(t, map[string]string{"private/secret.txt": "old"}) + upper := createTestLayer(t, map[string]string{"private/.wh..wh..opq": ""}) + img := createTestImage(t, lower, upper) + cfg := &config{files: []string{"private/secret.txt"}, out: &bytes.Buffer{}} + + err := extractFromImage(cfg, img) + if err == nil || !strings.Contains(err.Error(), "files not found") { + t.Fatalf("expected file below opaque directory to be absent, got %v", err) + } +} + +func TestExtractRejectsTraversal(t *testing.T) { + img := createTestImage(t, createTestLayer(t, map[string]string{"../escape": "bad"})) + cfg := &config{files: []string{"../escape"}, outputDir: t.TempDir(), out: &bytes.Buffer{}} + if err := extractFromImage(cfg, img); err == nil || !strings.Contains(err.Error(), "invalid file path") { + t.Fatalf("expected invalid path error, got %v", err) + } +} + +func TestExtractDoesNotFollowOutputSymlink(t *testing.T) { + img := createTestImage(t, createTestLayer(t, map[string]string{"link/escape": "bad"})) + outputDir := t.TempDir() + outside := t.TempDir() + if err := os.Symlink(outside, filepath.Join(outputDir, "link")); err != nil { + t.Fatal(err) + } + cfg := &config{files: []string{"link/escape"}, outputDir: outputDir, out: &bytes.Buffer{}} + if err := extractFromImage(cfg, img); err == nil { + t.Fatal("expected extraction through a symlink to fail") + } + if _, err := os.Stat(filepath.Join(outside, "escape")); !os.IsNotExist(err) { + t.Fatalf("file escaped the output directory: %v", err) + } +} + +func TestFilesReportsMalformedLayerTar(t *testing.T) { + layer, err := tarball.LayerFromReader(strings.NewReader("not a tar archive")) + if err != nil { + t.Fatal(err) + } + err = files(&config{out: &bytes.Buffer{}}, layer) + if err == nil || !strings.Contains(err.Error(), "reading layer tar") { + t.Fatalf("expected layer tar error, got %v", err) + } +}