├── .dockerignore ├── .github ├── dependabot.yml └── workflows │ ├── main.yml │ └── release.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── cmd └── diffoci │ ├── backend │ ├── backend.go │ ├── backendmanager │ │ └── backendmanager.go │ ├── containerdbackend │ │ └── containerdbackend.go │ └── localbackend │ │ └── localbackend.go │ ├── commands │ ├── diff │ │ └── diff.go │ ├── images │ │ └── images.go │ ├── info │ │ └── info.go │ ├── load │ │ └── load.go │ ├── pull │ │ └── pull.go │ └── remove │ │ └── remove.go │ ├── flagutil │ └── flagutil.go │ ├── imagegetter │ └── imagegetter.go │ ├── main.go │ └── version │ └── version.go ├── go.mod ├── go.sum └── pkg ├── diff └── diff.go ├── dockercred └── dockercred.go ├── envutil └── envutil.go ├── localpathutil └── localpathutil.go ├── platformutil └── platformutil.go └── untar └── tar.go /.dockerignore: -------------------------------------------------------------------------------- 1 | /diffoci 2 | /vendor 3 | /_artifacts 4 | /_output 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | open-pull-requests-limit: 10 8 | reviewers: 9 | - AkihiroSuda 10 | - package-ecosystem: github-actions 11 | directory: "/" 12 | schedule: 13 | interval: weekly 14 | open-pull-requests-limit: 10 15 | reviewers: 16 | - AkihiroSuda 17 | - package-ecosystem: docker 18 | directory: "/" 19 | schedule: 20 | interval: weekly 21 | open-pull-requests-limit: 10 22 | reviewers: 23 | - AkihiroSuda 24 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - master 6 | - 'release/**' 7 | pull_request: 8 | jobs: 9 | main: 10 | runs-on: ubuntu-24.04 11 | steps: 12 | - uses: actions/checkout@v5 13 | - uses: actions/setup-go@v6 14 | with: 15 | go-version: "oldstable" 16 | - run: go test -covermode=atomic -race -v ./... 17 | - name: golangci-lint 18 | uses: golangci/golangci-lint-action@v6.5.2 19 | with: 20 | args: --verbose 21 | - run: go install ./cmd/diffoci 22 | - name: smoke test 23 | run: | 24 | set -x pipefail 25 | diffoci diff --semantic --report-dir=~/tmp/diff alpine:3.18.2 alpine:3.18.3 | tee stdout 26 | set +o pipefail 27 | grep "File etc/os-release fb844374742438cf1b4e675dcd7d87c2fd6fbdb7cc7be30c62d4027240474aaf e08e943282c5d38f99bfde311c7d5759a4578f92fca5943e5b1351e8cd472892" stdout 28 | find ~/tmp/diff 29 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # Forked from https://github.com/containerd/nerdctl/blob/v0.8.1/.github/workflows/release.yml 2 | # Apache License 2.0 3 | 4 | name: Release 5 | on: 6 | push: 7 | branches: 8 | - 'master' 9 | tags: 10 | - 'v*' 11 | pull_request: 12 | branches: 13 | - 'master' 14 | jobs: 15 | release: 16 | runs-on: ubuntu-24.04 17 | timeout-minutes: 20 18 | steps: 19 | - uses: actions/checkout@v5 20 | with: 21 | # https://github.com/reproducible-containers/repro-get/issues/3 22 | fetch-depth: 0 23 | ref: ${{ github.event.pull_request.head.sha }} 24 | - name: "Make artifacts" 25 | run: make artifacts.docker 26 | - name: "SHA256SUMS" 27 | run: | 28 | cat _artifacts/SHA256SUMS 29 | - name: "The sha256sum of the SHA256SUMS file" 30 | run: | 31 | (cd _artifacts; sha256sum SHA256SUMS) 32 | - name: "Prepare the release note" 33 | run: | 34 | shasha=$(sha256sum _artifacts/SHA256SUMS | awk '{print $1}') 35 | cat <<-EOF | tee /tmp/release-note.txt 36 | (Changes to be documented) 37 | 38 | ## Usage 39 | \`\`\` 40 | # Basic 41 | diffoci diff --semantic alpine:3.18.2 alpine:3.18.3 42 | 43 | ## Dump conflicting files to ~/diff 44 | diffoci diff --semantic --report-dir=~/diff alpine:3.18.2 alpine:3.18.3 45 | 46 | ## Compare local Docker images 47 | diffoci diff --semantic docker://foo docker://bar 48 | \`\`\` 49 | - - - 50 | The binaries were built automatically on GitHub Actions. 51 | The build log is available for 90 days: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} 52 | 53 | The sha256sum of the SHA256SUMS file itself is \`${shasha}\` . 54 | EOF 55 | - name: "Create release" 56 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') 57 | env: 58 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 59 | run: | 60 | tag="${GITHUB_REF##*/}" 61 | gh release create -F /tmp/release-note.txt --draft --title "${tag}" "${tag}" _artifacts/* 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /diffoci 2 | /vendor 3 | /_artifacts 4 | /_output 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.25.2-bookworm@sha256:42d8e9dea06f23d0bfc908826455213ee7f3ed48c43e287a422064220c501be9 AS build-artifacts 2 | 3 | RUN --mount=type=cache,target=/root/.cache \ 4 | --mount=type=cache,target=/go \ 5 | --mount=type=bind,src=.,target=/src,rw=true \ 6 | cd /src && \ 7 | make artifacts && \ 8 | cp -a _artifacts / 9 | 10 | FROM scratch AS artifacts 11 | COPY --from=build-artifacts /_artifacts/ / 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Files are installed under $(DESTDIR)/$(PREFIX) 2 | PREFIX ?= /usr/local 3 | DEST := $(shell echo "$(DESTDIR)/$(PREFIX)" | sed 's:///*:/:g; s://*$$::') 4 | 5 | VERSION ?=$(shell git describe --match 'v[0-9]*' --dirty='.m' --always --tags) 6 | VERSION_SYMBOL := github.com/reproducible-containers/diffoci/cmd/diffoci/version.Version 7 | 8 | export CGO_ENABLED ?= 0 9 | export DOCKER_BUILDKIT := 1 10 | export SOURCE_DATE_EPOCH ?= $(shell git log -1 --pretty=%ct) 11 | 12 | GO ?= go 13 | GO_LDFLAGS ?= -s -w -X $(VERSION_SYMBOL)=$(VERSION) 14 | GO_BUILD ?= $(GO) build -trimpath -ldflags="$(GO_LDFLAGS)" 15 | DOCKER ?= docker 16 | DOCKER_BUILD ?= $(DOCKER) build --build-arg SOURCE_DATE_EPOCH=$(SOURCE_DATE_EPOCH) 17 | 18 | .PHONY: all 19 | all: binaries 20 | 21 | .PHONY: binaries 22 | binaries: _output/bin/diffoci 23 | 24 | .PHONY: _output/bin/diffoci 25 | _output/bin/diffoci: 26 | $(GO_BUILD) -o $@ ./cmd/diffoci 27 | 28 | .PHONY: install 29 | install: uninstall 30 | mkdir -p "$(DEST)" 31 | install _output/bin/diffoci "$(DEST)/bin/diffoci" 32 | 33 | .PHONY: uninstall 34 | uninstall: 35 | rm -rf "$(DEST)/bin/diffoci" 36 | 37 | .PHONY: clean 38 | clean: 39 | rm -rf _output _artifacts 40 | 41 | .PHONY: artifacts 42 | artifacts: 43 | rm -rf _artifacts 44 | mkdir -p _artifacts 45 | GOOS=linux GOARCH=amd64 $(GO_BUILD) -o _artifacts/diffoci-$(VERSION).linux-amd64 ./cmd/diffoci 46 | GOOS=linux GOARCH=arm GOARM=7 $(GO_BUILD) -o _artifacts/diffoci-$(VERSION).linux-arm-v7 ./cmd/diffoci 47 | GOOS=linux GOARCH=arm64 $(GO_BUILD) -o _artifacts/diffoci-$(VERSION).linux-arm64 ./cmd/diffoci 48 | GOOS=linux GOARCH=ppc64le $(GO_BUILD) -o _artifacts/diffoci-$(VERSION).linux-ppc64le ./cmd/diffoci 49 | GOOS=linux GOARCH=riscv64 $(GO_BUILD) -o _artifacts/diffoci-$(VERSION).linux-riscv64 ./cmd/diffoci 50 | GOOS=linux GOARCH=s390x $(GO_BUILD) -o _artifacts/diffoci-$(VERSION).linux-s390x ./cmd/diffoci 51 | GOOS=darwin GOARCH=amd64 $(GO_BUILD) -o _artifacts/diffoci-$(VERSION).darwin-amd64 ./cmd/diffoci 52 | GOOS=darwin GOARCH=arm64 $(GO_BUILD) -o _artifacts/diffoci-$(VERSION).darwin-arm64 ./cmd/diffoci 53 | (cd _artifacts ; sha256sum *) > SHA256SUMS 54 | mv SHA256SUMS _artifacts/SHA256SUMS 55 | touch -d @$(SOURCE_DATE_EPOCH) _artifacts/* 56 | 57 | .PHONY: artifacts.docker 58 | artifacts.docker: 59 | $(DOCKER_BUILD) --output=./_artifacts --target=artifacts . 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # diffoci: diff for Docker and OCI container images 2 | 3 | `diffoci` compares Docker and OCI container images for helping [reproducible builds](https://reproducible-builds.org/). 4 | 5 | > [!NOTE] 6 | > "OCI" here refers to the "[Open Container Initiative](https://opencontainers.org/)", not to the "Oracle Cloud Infrastructure". 7 | 8 | ```console 9 | $ diffoci diff --semantic alpine:3.18.2 alpine:3.18.3 10 | TYPE NAME INPUT-0 INPUT-1 11 | File lib/libcrypto.so.3 4518b7d5f6563f81ca1b3d857bbd7008d686545d8211283240506449618b2858 d21ccbb32c1e98340027504e4d951ab4c38450f1f211a4157fa9e962216ac625 12 | File usr/bin/iconv 8c54b8962ef07047032facf57cacb2f19eecfbefe03ad001e8c6c2b332e0d334 07115db0a4bd082f746ddbc6433b96397adcb523956417cb8376cd1ab4faf3d6 13 | File usr/lib/engines-3/capi.so 1b0508fa6be2efe1412f59da46b3963c7611696563b6b2b699b1aa9dba447b5a cc03f5d58b206389f9560e8e7f15495d2bd2f384793549f63f3cb3cb9c71b466 14 | File bin/busybox 6bf2a4358d5c26033d2d2e30ef9ce0ba9e3a9b34d81f0964f1d127123ae3e827 89f85a8b92bdb5ada51bd8ee0d9d687da1a759a0d80f8d012e7f8cbb3933a3a0 15 | File etc/os-release fb844374742438cf1b4e675dcd7d87c2fd6fbdb7cc7be30c62d4027240474aaf e08e943282c5d38f99bfde311c7d5759a4578f92fca5943e5b1351e8cd472892 16 | File usr/lib/engines-3/padlock.so 670f9a084f97974b12c81cc7a88fce5ec9f47dcee00da3192a17577a5298e62f 15fb67a501ad1293034dd16106edbdee0c07803ebcbbeb5a8123b4e784c1491f 17 | File usr/bin/getconf d314880d6288d48e8223b3da02d00605f6a993b669593c1c1a0fa5fd95339d48 040fc0ddb83193809919381bb23d89e4efefdc207e18ef0f3d0e0d9977bf2b58 18 | File lib/ld-musl-x86_64.so.1 c6b3288ba48945a21ede7ccf6f7a257d41bacf2c061236726ff9b5def383a766 35002c47957674f588389c0f3ca23b01adab091ea5773326e1c7782e5ece1207 19 | File usr/lib/engines-3/afalg.so 2aaa6a94f0e07f556e77faa8bec55321d0fe138f27193fdcd6ef8ccef051c7ba 9ef6555b3594798ce5a05dfd79b996397642b367c75b5cab0c752050d28e6138 20 | File lib/apk/db/installed d8b49a733ba6590033ae75f27a52f83281b693ba46751a9e488bf12dbdb06c61 c5cd4d9ed0a78abe602cc1e5fb29c84bef033c99d7c7f5e15fdb76b6c6cf8ad0 21 | File lib/apk/db/scripts.tar fbc51430c9f8b6a1a547281858fb36dff7947a2660a8bb6937b48f12361e3bb1 7d28a05788bda8fc5ec835257d5204d6d45c002458b04ad4981bce3be733c80f 22 | File etc/alpine-release 8fc1c65f6ef13f8ef573d59100eb183cf5a66aa7e94a6e6cba1dd22e7a0af51b 7cc26f207ef55a1422daecc84d155e9fa50fd170574807e73e55823dd81407d9 23 | File etc/ssl/misc/tsget.pl 559f94eb47d2d6f81c361e334fd882596858c7934a8b0111177f535a910f2990 3feab60fb8d102da8746aa2a422ba77c60dda6b4ae9ac33056fe82bfc071969d 24 | File lib/apk/db/triggers f78b6968b2c85c453a9dd89368a9fc78788d7c34aeb202544a41714e90544a37 ff37f223b335fc42d5a563dd268cbf476411d8fdd2b88addade5b5f715f0425a 25 | File lib/libssl.so.3 c22f9f45c1266ebdcc5f89c2f3471e89ffc5b2a4cd299f672156723270994133 91ad7b1cc8cf5575afeeb202c0fd1fefb63ceea1492491218f3132813eb04e49 26 | File usr/lib/engines-3/loader_attic.so 4f419a09b9f608e753045d83c255818c793913c274f724eb10d8ca8f474ae457 959301d5c88fab262a0631f1c87d02e1ef52215de35e0316af4513014a449b7f 27 | File usr/bin/getent 1c6aa066998ddd019b6df0142ffcf8b1f4307593dc42b92e7fc82cd601905f75 c22523bc4d2208c8df51b9c7b87ad5596aaf171a4e69d169f007ada8241c5d09 28 | File usr/lib/ossl-modules/legacy.so 5b677eca0c3a3ac53c1a49fbac49534ea2862c62416d14ec6053f2080d5bae50 65b55bc81cbab89e7d2d7e5636455ef86642ae8c377dec3924103d3513ede888 29 | ``` 30 | 31 | ## Installation 32 | ### Binary 33 | Binaries are available for Linux and macOS: https://github.com/reproducible-containers/diffoci/releases 34 | 35 | ### Source 36 | Needs Go 1.21 or later. 37 | ```bash 38 | go install github.com/reproducible-containers/diffoci/cmd/diffoci@latest 39 | ``` 40 | 41 | ## Basic usage 42 | ### Strict mode 43 | ```bash 44 | diffoci diff IMAGE0 IMAGE1 45 | ``` 46 | 47 | > [!TIP] 48 | > Non-Linux users typically have to specify `--platform` explicitly. 49 | > e.g., `diffoci diff --platform=linux/amd64 IMAGE0 IMAGE1`. 50 | 51 | The strict mode is often too strict. 52 | Consider using the non-strict mode (see below). 53 | 54 | ### Non-strict (aka "semantic") mode 55 | ```bash 56 | diffoci diff --semantic IMAGE0 IMAGE1 57 | ``` 58 | 59 | Non-strict mode ignores: 60 | - Timestamps 61 | - Build histories 62 | - File ordering in tar layers 63 | - Image name annotations 64 | 65 | ## Advanced usage 66 | ### Dumping conflicting files 67 | Set `--report-dir=DIR` to dump conflicting files in the specified dir. 68 | 69 | ```console 70 | $ diffoci diff --semantic --report-dir=/tmp/diff alpine:3.18.2 alpine:3.18.3 71 | TYPE NAME INPUT-0 INPUT-1 72 | ... 73 | File etc/alpine-release 8fc1c65f6ef13f8ef573d59100eb183cf5a66aa7e94a6e6cba1dd22e7a0af51b 7cc26f207ef55a1422daecc84d155e9fa50fd170574807e73e55823dd81407d9 74 | ... 75 | 76 | $ diff -ur /tmp/diff/input-0/ /tmp/diff/input-1/ 77 | Binary files /tmp/diff/input-0/manifests-0/layers-0/bin/busybox and /tmp/diff/input-1/manifests-0/layers-0/bin/busybox differ 78 | diff -ur /tmp/diff/input-0/manifests-0/layers-0/etc/alpine-release /tmp/diff/input-1/manifests-0/layers-0/etc/alpine-release 79 | --- /tmp/diff/input-0/manifests-0/layers-0/etc/alpine-release 2023-06-15 00:03:14.000000000 +0900 80 | +++ /tmp/diff/input-1/manifests-0/layers-0/etc/alpine-release 2023-08-07 22:09:12.000000000 +0900 81 | @@ -1 +1 @@ 82 | -3.18.2 83 | +3.18.3 84 | ... 85 | ``` 86 | 87 | ### Accessing containerd images 88 | `diffoci` uses the containerd image store by default when containerd v1.7 or later is running. 89 | The default namespace is `default`. 90 | 91 | To explicitly enable containerd: 92 | ```bash 93 | diffoci --backend=containerd --containerd-socket=SOCKET --containerd-namespace=NAMESPACE 94 | ``` 95 | 96 | To explicitly disable containerd: 97 | ```bash 98 | diffoci --backend=local 99 | ``` 100 | 101 | ### Accessing Docker images 102 | To access Docker images that are not pushed to a registry, prepend `docker://` to the image name: 103 | ```bash 104 | docker build -t foo ~/foo 105 | docker build -t bar ~/bar 106 | diffoci diff docker://foo docker://bar 107 | ``` 108 | 109 | You do NOT need to specify a custom `--backend` to access Docker images. 110 | 111 | ### Accessing Podman images 112 | To access Podman images that are not pushed to a registry, prepend `podman://` to the image name. 113 | See the `docker://` example above, and read `docker` as `podman`. 114 | 115 | ### Accessing private images 116 | To access private images, create a credential file as `~/.docker/config.json` using `docker login`. 117 | 118 | 119 | - - - 120 | # Examples 121 | ## Non-reproducible Docker Hub images 122 | 123 | ### `golang:1.21-alpine3.18` 124 | The sources of the official Docker Hub images are available at . 125 | 126 | For example, the source of [`golang:1.21-alpine3.18`](https://hub.docker.com/layers/library/golang/1.21-alpine3.18/images/sha256-dd8888bb7f1b0b05e1e625aa29483f50f38a9b64073a4db00b04076cec52b71c?context=explore) 127 | can be found at . 128 | 129 | The source can be built as follows: 130 | 131 | ```console 132 | $ DOCKER_BUILDKIT=0 docker build -t my-golang-1.21-alpine3.18 'https://github.com/docker-library/golang.git#d1ff31b86b23fe721dc65806cd2bd79a4c71b039:1.21/alpine3.18' 133 | ... 134 | Successfully tagged my-golang-1.21-alpine3.18:latest 135 | ``` 136 | 137 | > [!NOTE] 138 | > `DOCKER_BUILDKIT=0` is specified here because the official `golang:1.21-alpine3.18` image is currently built with the legacy builder. 139 | > A future revision of the official image may be built with BuildKit, and in such a case, `DOCKER_BUILDKIT=1` will rather need to be specified here. 140 | 141 | The resulting image binary (`my-golang-1.21-alpine3.18`) can be compared with the official image binary (`golang:1.21-alpine3.18`) as follows: 142 | 143 | ```console 144 | $ diffoci diff docker://golang:1.21-alpine3.18 docker://my-golang-1.21-alpine3.18 --semantic --report-dir=~/diff 145 | INFO[0000] Loading image "docker.io/library/golang:1.21-alpine3.18" from "docker" 146 | docker.io/library/golang:1.21 alpine3.18 saved 147 | Importing elapsed: 2.6 s total: 0.0 B (0.0 B/s) 148 | INFO[0004] Loading image "docker.io/library/my-golang-1.21-alpine3.18:latest" from "docker" 149 | docker.io/library/my golang 1.21 alpine3 saved 150 | Importing elapsed: 2.6 s total: 0.0 B (0.0 B/s) 151 | TYPE NAME INPUT-0 INPUT-1 152 | Layer ctx:/layers-1/layer length mismatch (457 vs 454) 153 | File lib/apk/db/scripts.tar eef110e559acb7aa00ea23ee7b8bddb52c4526cd394749261aa244ef9c6024a4 342eaa013375398497bfc21dff7dd017a647032ec5c486011142c576b7ccc989 154 | Layer ctx:/layers-1/layer name "usr/local/share/ca-certificates/.wh..wh..opq" only appears in input 0 155 | Layer ctx:/layers-1/layer name "usr/share/ca-certificates/.wh..wh..opq" only appears in input 0 156 | Layer ctx:/layers-1/layer name "etc/ca-certificates/.wh..wh..opq" only appears in input 0 157 | Layer ctx:/layers-2/layer length mismatch (13927 vs 13926) 158 | Layer ctx:/layers-2/layer name "usr/local/go/.wh..wh..opq" only appears in input 0 159 | File lib/apk/db/scripts.tar 073bb5094fc5bba800f06661dc7f1325c5cb4250b13209fb9e3eaf4e60e4bfc4 1369581b62bd60304c59556ea85f585bd498040c8fa223243622bb7990833063 160 | Layer ctx:/layers-3/layer length mismatch (4 vs 3) 161 | Layer ctx:/layers-3/layer name "go/.wh..wh..opq" only appears in input 0 162 | ``` 163 | 164 | > [!NOTE] 165 | > The `--semantic` flag is specified to ignore differences of timestamps, image names, and other "boring" attributes. 166 | > Without this flag, the `diffoci` command may print an enourmous amount of output. 167 | 168 | In the `my-golang-1.21-alpine3.18` image, special files called ["Opaque whiteouts"](https://github.com/opencontainers/image-spec/blob/v1.0.2/layer.md#whiteouts) (`.wh..wh..opq`) 169 | are missing due to filesystem difference between Docker Hub's build machine and the local machine. 170 | 171 | Also, the `lib/apk/db/scripts.tar` file in the layer 1 is not reproducible due to the timestamps of the tar entries inside it. 172 | The differences can be inspected by running the [`diffoscope`](https://diffoscope.org/) command for `~/diff/input-{0,1}/layers-1/lib/apk/db/scripts.tar`: 173 | ```console 174 | $ sudo apt-get install -y diffoscope 175 | 176 | $ diffoscope ~/diff/input-0/layers-1/lib/apk/db/scripts.tar ~/diff/input-1/layers-1/lib/apk/db/scripts.tar 177 | --- /home/suda/diff/input-0/layers-1/lib/apk/db/scripts.tar 178 | +++ /home/suda/diff/input-1/layers-1/lib/apk/db/scripts.tar 179 | ├── file list 180 | │ @@ -1,9 +1,9 @@ 181 | │ --rwxr-xr-x 0 root (0) root (0) 56 2023-08-09 03:36:47.000000 alpine-baselayout-3.4.3-r1.Q1zwvKMnYs1b6ZdPTBJ0Z7D5P3jyA=.pre-install 182 | │ --rwxr-xr-x 0 root (0) root (0) 983 2023-08-09 03:36:47.000000 alpine-baselayout-3.4.3-r1.Q1zwvKMnYs1b6ZdPTBJ0Z7D5P3jyA=.post-install 183 | │ --rwxr-xr-x 0 root (0) root (0) 755 2023-08-09 03:36:47.000000 alpine-baselayout-3.4.3-r1.Q1zwvKMnYs1b6ZdPTBJ0Z7D5P3jyA=.pre-upgrade 184 | │ --rwxr-xr-x 0 root (0) root (0) 983 2023-08-09 03:36:47.000000 alpine-baselayout-3.4.3-r1.Q1zwvKMnYs1b6ZdPTBJ0Z7D5P3jyA=.post-upgrade 185 | │ --rwxr-xr-x 0 root (0) root (0) 139 2023-08-09 03:36:47.000000 busybox-1.36.1-r2.Q1gQ/L3UBnSjgkFWEHQaUkUDubqdI=.post-install 186 | │ --rwxr-xr-x 0 root (0) root (0) 1239 2023-08-09 03:36:47.000000 busybox-1.36.1-r2.Q1gQ/L3UBnSjgkFWEHQaUkUDubqdI=.post-upgrade 187 | │ --rwxr-xr-x 0 root (0) root (0) 546 2023-08-09 03:36:47.000000 busybox-1.36.1-r2.Q1gQ/L3UBnSjgkFWEHQaUkUDubqdI=.trigger 188 | │ --rwxr-xr-x 0 root (0) root (0) 137 2023-08-09 03:36:47.000000 ca-certificates-20230506-r0.Q1FG8M+7w+dkjV9Vy0mGFWW2t4+Do=.post-deinstall 189 | │ --rwxr-xr-x 0 root (0) root (0) 63 2023-08-09 03:36:47.000000 ca-certificates-20230506-r0.Q1FG8M+7w+dkjV9Vy0mGFWW2t4+Do=.trigger 190 | │ +-rwxr-xr-x 0 root (0) root (0) 56 2023-08-24 07:50:41.000000 alpine-baselayout-3.4.3-r1.Q1zwvKMnYs1b6ZdPTBJ0Z7D5P3jyA=.pre-install 191 | │ +-rwxr-xr-x 0 root (0) root (0) 983 2023-08-24 07:50:41.000000 alpine-baselayout-3.4.3-r1.Q1zwvKMnYs1b6ZdPTBJ0Z7D5P3jyA=.post-install 192 | │ +-rwxr-xr-x 0 root (0) root (0) 755 2023-08-24 07:50:41.000000 alpine-baselayout-3.4.3-r1.Q1zwvKMnYs1b6ZdPTBJ0Z7D5P3jyA=.pre-upgrade 193 | │ +-rwxr-xr-x 0 root (0) root (0) 983 2023-08-24 07:50:41.000000 alpine-baselayout-3.4.3-r1.Q1zwvKMnYs1b6ZdPTBJ0Z7D5P3jyA=.post-upgrade 194 | │ +-rwxr-xr-x 0 root (0) root (0) 139 2023-08-24 07:50:41.000000 busybox-1.36.1-r2.Q1gQ/L3UBnSjgkFWEHQaUkUDubqdI=.post-install 195 | │ +-rwxr-xr-x 0 root (0) root (0) 1239 2023-08-24 07:50:41.000000 busybox-1.36.1-r2.Q1gQ/L3UBnSjgkFWEHQaUkUDubqdI=.post-upgrade 196 | │ +-rwxr-xr-x 0 root (0) root (0) 546 2023-08-24 07:50:41.000000 busybox-1.36.1-r2.Q1gQ/L3UBnSjgkFWEHQaUkUDubqdI=.trigger 197 | │ +-rwxr-xr-x 0 root (0) root (0) 137 2023-08-24 07:50:41.000000 ca-certificates-20230506-r0.Q1FG8M+7w+dkjV9Vy0mGFWW2t4+Do=.post-deinstall 198 | │ +-rwxr-xr-x 0 root (0) root (0) 63 2023-08-24 07:50:41.000000 ca-certificates-20230506-r0.Q1FG8M+7w+dkjV9Vy0mGFWW2t4+Do=.trigger 199 | ``` 200 | 201 | These differences are boring, but not filtered out by the `--semantic` flag of the `diffoci` command, because `diffoci` is not aware of the formats of the files inside the image layers. 202 | 203 | The `lib/apk/db/scripts.tar` file in the layer 2 has the same issue: 204 |
205 |

206 | 207 | ```console 208 | $ diffoscope ~/diff/input-0/layers-2/lib/apk/db/scripts.tar ~/diff/input-1/layers-2/lib/apk/db/scripts.tar 209 | --- /home/suda/diff/input-0/layers-2/lib/apk/db/scripts.tar 210 | +++ /home/suda/diff/input-1/layers-2/lib/apk/db/scripts.tar 211 | ├── file list 212 | │ @@ -1,9 +1,9 @@ 213 | │ --rwxr-xr-x 0 root (0) root (0) 56 2023-08-09 04:41:27.000000 alpine-baselayout-3.4.3-r1.Q1zwvKMnYs1b6ZdPTBJ0Z7D5P3jyA=.pre-install 214 | │ --rwxr-xr-x 0 root (0) root (0) 983 2023-08-09 04:41:27.000000 alpine-baselayout-3.4.3-r1.Q1zwvKMnYs1b6ZdPTBJ0Z7D5P3jyA=.post-install 215 | │ --rwxr-xr-x 0 root (0) root (0) 755 2023-08-09 04:41:27.000000 alpine-baselayout-3.4.3-r1.Q1zwvKMnYs1b6ZdPTBJ0Z7D5P3jyA=.pre-upgrade 216 | │ --rwxr-xr-x 0 root (0) root (0) 983 2023-08-09 04:41:27.000000 alpine-baselayout-3.4.3-r1.Q1zwvKMnYs1b6ZdPTBJ0Z7D5P3jyA=.post-upgrade 217 | │ --rwxr-xr-x 0 root (0) root (0) 139 2023-08-09 04:41:27.000000 busybox-1.36.1-r2.Q1gQ/L3UBnSjgkFWEHQaUkUDubqdI=.post-install 218 | │ --rwxr-xr-x 0 root (0) root (0) 1239 2023-08-09 04:41:27.000000 busybox-1.36.1-r2.Q1gQ/L3UBnSjgkFWEHQaUkUDubqdI=.post-upgrade 219 | │ --rwxr-xr-x 0 root (0) root (0) 546 2023-08-09 04:41:27.000000 busybox-1.36.1-r2.Q1gQ/L3UBnSjgkFWEHQaUkUDubqdI=.trigger 220 | │ --rwxr-xr-x 0 root (0) root (0) 137 2023-08-09 04:41:27.000000 ca-certificates-20230506-r0.Q1FG8M+7w+dkjV9Vy0mGFWW2t4+Do=.post-deinstall 221 | │ --rwxr-xr-x 0 root (0) root (0) 63 2023-08-09 04:41:27.000000 ca-certificates-20230506-r0.Q1FG8M+7w+dkjV9Vy0mGFWW2t4+Do=.trigger 222 | │ +-rwxr-xr-x 0 root (0) root (0) 56 2023-08-24 07:50:52.000000 alpine-baselayout-3.4.3-r1.Q1zwvKMnYs1b6ZdPTBJ0Z7D5P3jyA=.pre-install 223 | │ +-rwxr-xr-x 0 root (0) root (0) 983 2023-08-24 07:50:52.000000 alpine-baselayout-3.4.3-r1.Q1zwvKMnYs1b6ZdPTBJ0Z7D5P3jyA=.post-install 224 | │ +-rwxr-xr-x 0 root (0) root (0) 755 2023-08-24 07:50:52.000000 alpine-baselayout-3.4.3-r1.Q1zwvKMnYs1b6ZdPTBJ0Z7D5P3jyA=.pre-upgrade 225 | │ +-rwxr-xr-x 0 root (0) root (0) 983 2023-08-24 07:50:52.000000 alpine-baselayout-3.4.3-r1.Q1zwvKMnYs1b6ZdPTBJ0Z7D5P3jyA=.post-upgrade 226 | │ +-rwxr-xr-x 0 root (0) root (0) 139 2023-08-24 07:50:52.000000 busybox-1.36.1-r2.Q1gQ/L3UBnSjgkFWEHQaUkUDubqdI=.post-install 227 | │ +-rwxr-xr-x 0 root (0) root (0) 1239 2023-08-24 07:50:52.000000 busybox-1.36.1-r2.Q1gQ/L3UBnSjgkFWEHQaUkUDubqdI=.post-upgrade 228 | │ +-rwxr-xr-x 0 root (0) root (0) 546 2023-08-24 07:50:52.000000 busybox-1.36.1-r2.Q1gQ/L3UBnSjgkFWEHQaUkUDubqdI=.trigger 229 | │ +-rwxr-xr-x 0 root (0) root (0) 137 2023-08-24 07:50:52.000000 ca-certificates-20230506-r0.Q1FG8M+7w+dkjV9Vy0mGFWW2t4+Do=.post-deinstall 230 | │ +-rwxr-xr-x 0 root (0) root (0) 63 2023-08-24 07:50:52.000000 ca-certificates-20230506-r0.Q1FG8M+7w+dkjV9Vy0mGFWW2t4+Do=.trigger 231 | ``` 232 | 233 |

234 |
235 | 236 | Depending on the time to build the image, more differences may happen, especially when the Alpine packages on the internet are bumped up. 237 | 238 | #### Conclusion 239 | This example indicates that although the official `golang:1.21-alpine3.18` image binary is not fully reproducible, its non-reproducibility is practically negligible, and 240 | this image binary can be assured to be certainly buildable from with the [published source](https://github.com/docker-library/golang/blob/d1ff31b86b23fe721dc65806cd2bd79a4c71b039/1.21/alpine3.18/Dockerfile). 241 | **If the published source is trustable**, this image binary can be trusted too. 242 | -------------------------------------------------------------------------------- /cmd/diffoci/backend/backend.go: -------------------------------------------------------------------------------- 1 | package backend 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/containerd/containerd/content" 7 | "github.com/containerd/containerd/images" 8 | "github.com/containerd/containerd/pkg/transfer" 9 | ) 10 | 11 | type Backend interface { 12 | Info() Info 13 | Context(context.Context) context.Context 14 | ImageService() images.Store 15 | ContentStore() content.Store 16 | transfer.Transferrer 17 | MaybeGC(ctx context.Context) error 18 | } 19 | 20 | type Info struct { 21 | Name string `json:"Name"` 22 | } 23 | -------------------------------------------------------------------------------- /cmd/diffoci/backend/backendmanager/backendmanager.go: -------------------------------------------------------------------------------- 1 | package backendmanager 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/containerd/log" 7 | "github.com/reproducible-containers/diffoci/cmd/diffoci/backend" 8 | "github.com/reproducible-containers/diffoci/cmd/diffoci/backend/containerdbackend" 9 | "github.com/reproducible-containers/diffoci/cmd/diffoci/backend/localbackend" 10 | "github.com/reproducible-containers/diffoci/pkg/envutil" 11 | "github.com/spf13/cobra" 12 | "github.com/spf13/pflag" 13 | ) 14 | 15 | func AddFlags(flags *pflag.FlagSet) { 16 | containerdbackend.AddFlags(flags) 17 | localbackend.AddFlags(flags) 18 | flags.String("backend", envutil.String("DIFFOCI_BACKEND", "auto"), 19 | "backend (auto|containerd|local) [$DIFFOCI_BACKEND]") 20 | } 21 | 22 | func NewBackend(cmd *cobra.Command) (backend.Backend, error) { 23 | ctx := cmd.Context() 24 | flags := cmd.Flags() 25 | b, err := flags.GetString("backend") 26 | if err != nil { 27 | return nil, err 28 | } 29 | switch b { 30 | case "auto": 31 | cb, err := containerdbackend.New(cmd) 32 | if err == nil { 33 | log.G(ctx).Debug("auto backend: choosing \"containerd\"") 34 | return cb, nil 35 | } 36 | log.G(ctx).WithError(err).Debug("auto backend: failed to choose \"containerd\", falling back to \"local\"") 37 | return localbackend.New(cmd) 38 | case "containerd": 39 | return containerdbackend.New(cmd) 40 | case "local": 41 | return localbackend.New(cmd) 42 | default: 43 | return nil, fmt.Errorf("unknown backend %q (valid values are \"auto\", \"containerd\", and \"local\")", b) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /cmd/diffoci/backend/containerdbackend/containerdbackend.go: -------------------------------------------------------------------------------- 1 | package containerdbackend 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "runtime" 9 | "strconv" 10 | "strings" 11 | 12 | "github.com/containerd/containerd" 13 | "github.com/containerd/containerd/defaults" 14 | "github.com/containerd/containerd/namespaces" 15 | "github.com/containerd/log" 16 | "github.com/reproducible-containers/diffoci/cmd/diffoci/backend" 17 | "github.com/reproducible-containers/diffoci/pkg/envutil" 18 | "github.com/spf13/cobra" 19 | "github.com/spf13/pflag" 20 | "golang.org/x/sys/unix" 21 | ) 22 | 23 | const Name = "containerd" 24 | 25 | func AddFlags(flags *pflag.FlagSet) { 26 | flags.String("containerd-address", envutil.String("CONTAINERD_ADDRESS", defaultContainerdAddress()), 27 | "containerd address [$CONTAINERD_ADDRESS]") 28 | flags.String("containerd-namespace", envutil.String("CONTAINERD_NAMESPACE", namespaces.Default), 29 | "containerd namespace [$CONTAINERD_NAMESPACE]") 30 | } 31 | 32 | func defaultContainerdAddress() string { 33 | if runtime.GOOS == "linux" && os.Geteuid() != 0 { 34 | addr, err := rootlessContainerdAddress() 35 | if err != nil { 36 | log.L.WithError(err).Debug("Failed to get the address of the rootless containerd (not running?)") 37 | } else if addr != "" { 38 | return addr 39 | } 40 | } 41 | return defaults.DefaultAddress 42 | } 43 | 44 | func rootlessContainerdAddress() (string, error) { 45 | xdr := os.Getenv("XDG_RUNTIME_DIR") 46 | if xdr == "" { 47 | xdr = fmt.Sprintf("/run/user/%d", os.Geteuid()) 48 | } 49 | childPidPath := filepath.Join(xdr, "containerd-rootless/child_pid") 50 | childPidB, err := os.ReadFile(childPidPath) 51 | if err != nil { 52 | return "", err 53 | } 54 | childPid, err := strconv.Atoi(strings.TrimSpace(string(childPidB))) 55 | if err != nil { 56 | return "", fmt.Errorf("failed to parse the content of %q (%q): %w", childPidPath, string(childPidB), err) 57 | } 58 | childRoot := fmt.Sprintf("/proc/%d/root", childPid) 59 | return filepath.Join(childRoot, defaults.DefaultAddress), nil 60 | } 61 | 62 | func New(cmd *cobra.Command) (backend.Backend, error) { 63 | flags := cmd.Flags() 64 | addr, err := flags.GetString("containerd-address") 65 | if err != nil { 66 | return nil, err 67 | } 68 | ns, err := flags.GetString("containerd-namespace") 69 | if err != nil { 70 | return nil, err 71 | } 72 | return newBackend(cmd.Context(), addr, ns) 73 | } 74 | 75 | func newBackend(ctx context.Context, addr, ns string) (backend.Backend, error) { 76 | if err := unix.Access(addr, unix.R_OK); err != nil { 77 | return nil, fmt.Errorf("failed to access containerd socket %q: %w", addr, err) 78 | } 79 | opts := []containerd.ClientOpt{containerd.WithDefaultNamespace(ns)} 80 | client, err := containerd.New(addr, opts...) 81 | if err != nil { 82 | return nil, fmt.Errorf("failed to create containerd client: %w", err) 83 | } 84 | const ( 85 | pluginType = "io.containerd.grpc.v1" 86 | pluginID = "transfer" 87 | ) 88 | plugins, err := client.IntrospectionService().Plugins(ctx, []string{fmt.Sprintf("type==%q,id==%q", pluginType, pluginID)}) 89 | if err != nil { 90 | return nil, fmt.Errorf("failed to introspect containerd plugins: %w", err) 91 | } 92 | if len(plugins.Plugins) == 0 { 93 | return nil, fmt.Errorf("containerd plugin \"%s.%s\" seems missing (Hint: upgrade containerd to v1.7 or later)", pluginType, pluginID) 94 | } 95 | return &containerdBackend{Client: client}, nil 96 | } 97 | 98 | type containerdBackend struct { 99 | *containerd.Client 100 | } 101 | 102 | func (b *containerdBackend) Info() backend.Info { 103 | return backend.Info{ 104 | Name: Name, 105 | } 106 | } 107 | 108 | func (b *containerdBackend) Context(ctx context.Context) context.Context { 109 | return ctx 110 | } 111 | 112 | func (b *containerdBackend) MaybeGC(ctx context.Context) error { 113 | // NOP 114 | return nil 115 | } 116 | -------------------------------------------------------------------------------- /cmd/diffoci/backend/localbackend/localbackend.go: -------------------------------------------------------------------------------- 1 | package localbackend 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "os" 8 | "path/filepath" 9 | "sync" 10 | 11 | "github.com/containerd/containerd/content" 12 | contentlocal "github.com/containerd/containerd/content/local" 13 | "github.com/containerd/containerd/images" 14 | "github.com/containerd/containerd/metadata" 15 | "github.com/containerd/containerd/namespaces" 16 | "github.com/containerd/containerd/pkg/transfer" 17 | transferlocal "github.com/containerd/containerd/pkg/transfer/local" 18 | "github.com/containerd/log" 19 | "github.com/opencontainers/go-digest" 20 | "github.com/reproducible-containers/diffoci/cmd/diffoci/backend" 21 | "github.com/reproducible-containers/diffoci/pkg/envutil" 22 | "github.com/spf13/cobra" 23 | "github.com/spf13/pflag" 24 | "go.etcd.io/bbolt" 25 | ) 26 | 27 | const Name = "local" 28 | 29 | func AddFlags(flags *pflag.FlagSet) { 30 | flags.String("local-cache", envutil.String("DIFFOCI_LOCAL_CACHE", defaultLocalCache()), 31 | "local cache [$DIFFOCI_LOCAL_CACHE]") 32 | } 33 | 34 | func defaultLocalCache() string { 35 | if os.Geteuid() != 0 { 36 | ucd, err := os.UserCacheDir() 37 | if err != nil || ucd == "" { 38 | log.L.WithError(err).Warn("failed to get user cache dir") 39 | } else { 40 | return filepath.Join(ucd, "diffoci") 41 | } 42 | } 43 | return "/var/cache/diffoci" 44 | } 45 | 46 | func New(cmd *cobra.Command) (backend.Backend, error) { 47 | flags := cmd.Flags() 48 | dir, err := flags.GetString("local-cache") 49 | if err != nil { 50 | return nil, err 51 | } 52 | labelsDir := filepath.Join(dir, "labels") 53 | for _, f := range []string{dir, labelsDir} { 54 | if err := os.MkdirAll(f, 0700); err != nil { 55 | return nil, err 56 | } 57 | } 58 | b := &localBackend{ 59 | ns: "diffoci", 60 | } 61 | labelStore := &labelStore{ 62 | dir: labelsDir, 63 | } 64 | b.contentStore, err = contentlocal.NewLabeledStore(dir, labelStore) 65 | if err != nil { 66 | return nil, err 67 | } 68 | dbRaw, err := bbolt.Open(filepath.Join(dir, "diffoci.db"), 0644, nil) 69 | if err != nil { 70 | return nil, err 71 | } 72 | b.db = metadata.NewDB(dbRaw, b.contentStore, nil) 73 | b.imageStore = metadata.NewImageStore(b.db) 74 | lm := metadata.NewLeaseManager(b.db) 75 | b.transferrer = transferlocal.NewTransferService(lm, 76 | b.contentStore, 77 | b.imageStore, 78 | &transferlocal.TransferConfig{}, 79 | ) 80 | return b, nil 81 | } 82 | 83 | type localBackend struct { 84 | ns string 85 | db *metadata.DB 86 | contentStore content.Store 87 | imageStore images.Store 88 | transferrer transfer.Transferrer 89 | } 90 | 91 | func (b *localBackend) Info() backend.Info { 92 | return backend.Info{ 93 | Name: Name, 94 | } 95 | } 96 | 97 | func (b *localBackend) Context(ctx context.Context) context.Context { 98 | return namespaces.WithNamespace(ctx, b.ns) 99 | } 100 | 101 | func (b *localBackend) ContentStore() content.Store { 102 | return b.contentStore 103 | } 104 | 105 | func (b *localBackend) ImageService() images.Store { 106 | return b.imageStore 107 | } 108 | 109 | func (b *localBackend) Transfer(ctx context.Context, source interface{}, destination interface{}, opts ...transfer.Opt) error { 110 | return b.transferrer.Transfer(ctx, source, destination, opts...) 111 | } 112 | 113 | func (b *localBackend) MaybeGC(ctx context.Context) error { 114 | _, err := b.db.GarbageCollect(ctx) 115 | return err 116 | } 117 | 118 | type labelStore struct { 119 | dir string 120 | mu sync.RWMutex 121 | } 122 | 123 | func (ls *labelStore) filepath(d digest.Digest) string { 124 | return filepath.Join(ls.dir, filepath.Clean(d.Algorithm().String()), filepath.Clean(d.Encoded())) 125 | } 126 | 127 | // TODO: flock 128 | func (ls *labelStore) Get(d digest.Digest) (map[string]string, error) { 129 | ls.mu.RLock() 130 | defer ls.mu.RUnlock() 131 | return ls.getUnlocked(d) 132 | } 133 | 134 | func (ls *labelStore) getUnlocked(d digest.Digest) (map[string]string, error) { 135 | f := ls.filepath(d) 136 | b, err := os.ReadFile(f) 137 | if err != nil { 138 | if errors.Is(err, os.ErrNotExist) { 139 | return nil, nil 140 | } 141 | return nil, err 142 | } 143 | var m map[string]string 144 | if err := json.Unmarshal(b, &m); err != nil { 145 | return nil, err 146 | } 147 | return m, nil 148 | } 149 | 150 | // TODO: flock 151 | func (ls *labelStore) Set(d digest.Digest, m map[string]string) error { 152 | ls.mu.Lock() 153 | defer ls.mu.Unlock() 154 | return ls.setUnlocked(d, m) 155 | } 156 | 157 | func (ls *labelStore) setUnlocked(d digest.Digest, m map[string]string) error { 158 | f := ls.filepath(d) 159 | if len(m) == 0 { 160 | return os.RemoveAll(f) 161 | } 162 | dir := filepath.Dir(f) 163 | if err := os.MkdirAll(dir, 0700); err != nil { 164 | return err 165 | } 166 | b, err := json.Marshal(m) 167 | if err != nil { 168 | return err 169 | } 170 | return os.WriteFile(f, b, 0600) 171 | } 172 | 173 | // TODO: flock 174 | func (ls *labelStore) Update(d digest.Digest, m map[string]string) (map[string]string, error) { 175 | ls.mu.Lock() 176 | defer ls.mu.Unlock() 177 | mm, err := ls.getUnlocked(d) 178 | if err != nil { 179 | return nil, err 180 | } 181 | if mm == nil { 182 | mm = make(map[string]string) 183 | } 184 | for k, v := range m { 185 | if k == "" { 186 | delete(mm, k) 187 | } else { 188 | mm[k] = v 189 | } 190 | } 191 | return mm, ls.setUnlocked(d, mm) 192 | } 193 | -------------------------------------------------------------------------------- /cmd/diffoci/commands/diff/diff.go: -------------------------------------------------------------------------------- 1 | package diff 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/containerd/errdefs" 9 | "github.com/containerd/log" 10 | "github.com/containerd/platforms" 11 | ocispec "github.com/opencontainers/image-spec/specs-go/v1" 12 | "github.com/reproducible-containers/diffoci/cmd/diffoci/backend/backendmanager" 13 | "github.com/reproducible-containers/diffoci/cmd/diffoci/flagutil" 14 | "github.com/reproducible-containers/diffoci/cmd/diffoci/imagegetter" 15 | "github.com/reproducible-containers/diffoci/pkg/diff" 16 | "github.com/reproducible-containers/diffoci/pkg/localpathutil" 17 | "github.com/reproducible-containers/diffoci/pkg/platformutil" 18 | "github.com/spf13/cobra" 19 | ) 20 | 21 | const Example = ` # Basic 22 | diffoci diff --semantic alpine:3.18.2 alpine:3.18.3 23 | 24 | # Dump conflicting files to ~/diff 25 | diffoci diff --semantic --report-dir=~/diff alpine:3.18.2 alpine:3.18.3 26 | 27 | # Compare local Docker images 28 | diffoci diff --semantic docker://foo docker://bar 29 | ` 30 | 31 | func NewCommand() *cobra.Command { 32 | cmd := &cobra.Command{ 33 | Use: "diff IMAGE0 IMAGE1", 34 | Short: "Diff images", 35 | Example: Example, 36 | Args: cobra.ExactArgs(2), 37 | 38 | PreRunE: func(cmd *cobra.Command, args []string) error { 39 | flags := cmd.Flags() 40 | if semantic, _ := cmd.Flags().GetBool("semantic"); semantic { 41 | flagNames := []string{ 42 | "ignore-history", 43 | "ignore-file-order", 44 | "ignore-file-mode-redundant-bits", 45 | "ignore-file-timestamps", 46 | "ignore-image-timestamps", 47 | "ignore-image-name", 48 | "ignore-tar-format", 49 | "treat-canonical-paths-equal", 50 | } 51 | for _, f := range flagNames { 52 | if err := flags.Set(f, "true"); err != nil { 53 | return err 54 | } 55 | } 56 | } 57 | if ignoreTimestamps, _ := cmd.Flags().GetBool("ignore-timestamps"); ignoreTimestamps { 58 | flagNames := []string{ 59 | "ignore-file-timestamps", 60 | "ignore-image-timestamps", 61 | } 62 | for _, f := range flagNames { 63 | if err := flags.Set(f, "true"); err != nil { 64 | return err 65 | } 66 | } 67 | } 68 | return nil 69 | }, 70 | RunE: action, 71 | 72 | DisableFlagsInUseLine: true, 73 | } 74 | 75 | flags := cmd.Flags() 76 | flagutil.AddPlatformFlags(flags) 77 | flags.Bool("ignore-timestamps", false, "Ignore timestamps - Alias for --ignore-*-timestamps=true") 78 | flags.Bool("ignore-history", false, "Ignore history") 79 | flags.Bool("ignore-file-order", false, "Ignore file order in tar layers") 80 | flags.Bool("ignore-file-mode-redundant-bits", false, "Ignore redundant bits of file mode") 81 | flags.Bool("ignore-file-timestamps", false, "Ignore timestamps on files") 82 | flags.Bool("ignore-image-timestamps", false, "Ignore timestamps in image metadata") 83 | flags.Bool("ignore-image-name", false, "Ignore image name annotation") 84 | flags.Bool("ignore-tar-format", false, "Ignore tar format") 85 | flags.Bool("treat-canonical-paths-equal", false, "Treat leading `./` `/` `` in file paths as canonical") 86 | flags.Bool("semantic", false, "[Recommended] Alias for --ignore-*=true --treat-canonical-paths-equal") 87 | 88 | flags.Bool("verbose", false, "Verbose output") 89 | flags.String("report-file", "", "Create a report file to the specified path (EXPERIMENTAL)") 90 | flags.String("report-dir", "", "Create a detailed report in the specified directory") 91 | flags.String("pull", imagegetter.PullMissing, "Pull mode (always|missing|never)") 92 | flags.Float64("max-scale", 1.0, "Scale factor for maximum values (e.g., maxTarBlobSize = 4GiB)") 93 | return cmd 94 | } 95 | 96 | func action(cmd *cobra.Command, args []string) error { 97 | backend, err := backendmanager.NewBackend(cmd) 98 | if err != nil { 99 | return err 100 | } 101 | ctx := backend.Context(cmd.Context()) 102 | flags := cmd.Flags() 103 | plats, err := flagutil.ParsePlatformFlags(flags) 104 | if err != nil { 105 | return err 106 | } 107 | log.G(ctx).Infof("Target platforms: %v", platformutil.FormatSlice(plats)) 108 | platMC := platforms.Any(plats...) 109 | 110 | var options diff.Options 111 | options.IgnoreHistory, err = flags.GetBool("ignore-history") 112 | if err != nil { 113 | return err 114 | } 115 | options.IgnoreFileOrder, err = flags.GetBool("ignore-file-order") 116 | if err != nil { 117 | return err 118 | } 119 | options.IgnoreFileModeRedundantBits, err = flags.GetBool("ignore-file-mode-redundant-bits") 120 | if err != nil { 121 | return err 122 | } 123 | options.IgnoreFileTimestamps, err = flags.GetBool("ignore-file-timestamps") 124 | if err != nil { 125 | return err 126 | } 127 | options.IgnoreImageTimestamps, err = flags.GetBool("ignore-image-timestamps") 128 | if err != nil { 129 | return err 130 | } 131 | options.IgnoreImageName, err = flags.GetBool("ignore-image-name") 132 | if err != nil { 133 | return err 134 | } 135 | options.IgnoreTarFormat, err = flags.GetBool("ignore-tar-format") 136 | if err != nil { 137 | return err 138 | } 139 | options.CanonicalPaths, err = flags.GetBool("treat-canonical-paths-equal") 140 | if err != nil { 141 | return err 142 | } 143 | options.ReportFile, err = flags.GetString("report-file") 144 | if err != nil { 145 | return err 146 | } 147 | if options.ReportFile != "" { 148 | log.G(ctx).Warn("report-file is experimental. The file format is subject to change.") 149 | options.ReportFile, err = localpathutil.Expand(options.ReportFile) 150 | if err != nil { 151 | return fmt.Errorf("invalid report-file path %q: %w", options.ReportFile, err) 152 | } 153 | } 154 | options.ReportDir, err = flags.GetString("report-dir") 155 | if err != nil { 156 | return err 157 | } 158 | if options.ReportDir != "" { 159 | options.ReportDir, err = localpathutil.Expand(options.ReportDir) 160 | if err != nil { 161 | return fmt.Errorf("invalid report-dir path %q: %w", options.ReportDir, err) 162 | } 163 | } 164 | 165 | options.EventHandler = diff.DefaultEventHandler 166 | verbose, err := flags.GetBool("verbose") 167 | if err != nil { 168 | return err 169 | } 170 | if verbose { 171 | options.EventHandler = diff.VerboseEventHandler 172 | } 173 | 174 | options.MaxScale, err = flags.GetFloat64("max-scale") 175 | if err != nil { 176 | return err 177 | } 178 | 179 | pullMode, err := flags.GetString("pull") 180 | if err != nil { 181 | return err 182 | } 183 | 184 | ig, err := imagegetter.New(cmd.ErrOrStderr(), backend) 185 | if err != nil { 186 | return err 187 | } 188 | 189 | var imageDescs [2]ocispec.Descriptor 190 | for i := 0; i < 2; i++ { 191 | img, err := ig.Get(ctx, args[i], plats, imagegetter.PullMode(pullMode)) 192 | if err != nil { 193 | return err 194 | } 195 | log.G(ctx).Debugf("Input %d: Image %q (%s)", i, img.Name, img.Target.Digest) 196 | imageDescs[i] = img.Target 197 | } 198 | 199 | contentStore := backend.ContentStore() 200 | 201 | var exitCode int 202 | report, err := diff.Diff(ctx, contentStore, imageDescs, platMC, &options) 203 | if report != nil && len(report.Children) > 0 { 204 | exitCode = 1 205 | } 206 | if err != nil { 207 | if errors.Is(err, errdefs.ErrUnavailable) { 208 | err = fmt.Errorf("%w (Hint: specify `--platform` explicitly, e.g., `--platform=linux/amd64`)", err) 209 | } 210 | log.G(ctx).Error(err) 211 | exitCode = 2 212 | } 213 | if exitCode != 0 { 214 | log.G(ctx).Debugf("exiting with code %d", exitCode) 215 | } 216 | os.Exit(exitCode) 217 | /* NOTREACHED */ 218 | return nil 219 | } 220 | -------------------------------------------------------------------------------- /cmd/diffoci/commands/images/images.go: -------------------------------------------------------------------------------- 1 | package images 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "text/tabwriter" 7 | 8 | "github.com/containerd/containerd/images" 9 | "github.com/containerd/log" 10 | "github.com/containerd/platforms" 11 | refdocker "github.com/distribution/reference" 12 | "github.com/reproducible-containers/diffoci/cmd/diffoci/backend/backendmanager" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | func NewCommand() *cobra.Command { 17 | cmd := &cobra.Command{ 18 | Use: "images", 19 | Short: "List images", 20 | Aliases: []string{"list", "ls"}, 21 | Args: cobra.NoArgs, 22 | RunE: action, 23 | DisableFlagsInUseLine: true, 24 | } 25 | return cmd 26 | } 27 | 28 | func action(cmd *cobra.Command, args []string) error { 29 | backend, err := backendmanager.NewBackend(cmd) 30 | if err != nil { 31 | return err 32 | } 33 | ctx := backend.Context(cmd.Context()) 34 | var o printImageOptions 35 | imgStore := backend.ImageService() 36 | imgs, err := imgStore.List(ctx) 37 | if err != nil { 38 | return err 39 | } 40 | contentStore := backend.ContentStore() 41 | w := cmd.OutOrStdout() 42 | tw := tabwriter.NewWriter(w, 4, 8, 4, ' ', 0) 43 | defer tw.Flush() 44 | fmt.Fprintln(tw, "REPOSITORY\tTAG\tIMAGE ID\tPLATFORM") 45 | for _, img := range imgs { 46 | plats, err := images.Platforms(ctx, contentStore, img.Target) 47 | if err != nil { 48 | log.G(ctx).WithError(err).Debugf("failed to get platforms for %q", img.Name) 49 | if err2 := printImage(tw, img, "", o); err2 != nil { 50 | log.G(ctx).WithError(err2).Warnf("Failed to print image %q", img.Name) 51 | } 52 | } else { 53 | for _, plat := range plats { 54 | if avail, _, _, _, _ := images.Check(ctx, contentStore, img.Target, platforms.OnlyStrict(plat)); avail { 55 | platStr := platforms.Format(plat) 56 | if err := printImage(tw, img, platStr, o); err != nil { 57 | log.G(ctx).WithError(err).Warnf("Failed to print image %q for %q", img.Name, platStr) 58 | } 59 | } 60 | } 61 | } 62 | } 63 | return nil 64 | } 65 | 66 | type printImageOptions struct { 67 | // reserved 68 | } 69 | 70 | func printImage(w io.Writer, img images.Image, plat string, o printImageOptions) error { 71 | ref, err := refdocker.ParseDockerRef(img.Name) 72 | if err != nil { 73 | return err 74 | } 75 | repo := refdocker.FamiliarName(ref) 76 | var tag string 77 | if tagged, ok := ref.(refdocker.Tagged); ok { 78 | tag = tagged.Tag() 79 | } 80 | imgId := img.Target.Digest 81 | _, err = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", repo, tag, imgId, plat) 82 | return err 83 | } 84 | -------------------------------------------------------------------------------- /cmd/diffoci/commands/info/info.go: -------------------------------------------------------------------------------- 1 | package info 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/reproducible-containers/diffoci/cmd/diffoci/backend" 8 | "github.com/reproducible-containers/diffoci/cmd/diffoci/backend/backendmanager" 9 | "github.com/reproducible-containers/diffoci/cmd/diffoci/version" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | func NewCommand() *cobra.Command { 14 | cmd := &cobra.Command{ 15 | Use: "info", 16 | Short: "Display diagnostic information", 17 | Args: cobra.NoArgs, 18 | RunE: action, 19 | DisableFlagsInUseLine: true, 20 | } 21 | flags := cmd.Flags() 22 | flags.Bool("json", false, "Display the result as JSON") 23 | return cmd 24 | } 25 | 26 | type Info struct { 27 | Backend backend.Info `json:"Backend"` 28 | Version string `json:"Version"` 29 | } 30 | 31 | func action(cmd *cobra.Command, args []string) error { 32 | flags := cmd.Flags() 33 | flagJSON, err := flags.GetBool("json") 34 | if err != nil { 35 | return err 36 | } 37 | b, err := backendmanager.NewBackend(cmd) 38 | if err != nil { 39 | return err 40 | } 41 | info := Info{ 42 | Backend: b.Info(), 43 | Version: version.GetVersion(), 44 | } 45 | 46 | w := cmd.OutOrStdout() 47 | if flagJSON { 48 | b, err := json.MarshalIndent(info, "", " ") 49 | if err != nil { 50 | return err 51 | } 52 | fmt.Fprintln(w, string(b)) 53 | } else { 54 | fmt.Fprintf(w, "Backend: %s\n", info.Backend.Name) 55 | fmt.Fprintf(w, "Version: %s\n", info.Version) 56 | } 57 | return nil 58 | } 59 | -------------------------------------------------------------------------------- /cmd/diffoci/commands/load/load.go: -------------------------------------------------------------------------------- 1 | package load 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/reproducible-containers/diffoci/cmd/diffoci/backend/backendmanager" 8 | "github.com/reproducible-containers/diffoci/cmd/diffoci/flagutil" 9 | "github.com/reproducible-containers/diffoci/cmd/diffoci/imagegetter" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | func NewCommand() *cobra.Command { 14 | cmd := &cobra.Command{ 15 | Use: "load < a.tar", 16 | Short: "Load an image archive (Docker or OCI) from STDIN", 17 | Args: cobra.ExactArgs(0), 18 | RunE: action, 19 | DisableFlagsInUseLine: true, 20 | } 21 | 22 | flags := cmd.Flags() 23 | flagutil.AddPlatformFlags(flags) 24 | flags.String("input", "", "Read from tar archive file, instead of STDIN") 25 | return cmd 26 | } 27 | 28 | func action(cmd *cobra.Command, args []string) error { 29 | backend, err := backendmanager.NewBackend(cmd) 30 | if err != nil { 31 | return err 32 | } 33 | ctx := backend.Context(cmd.Context()) 34 | flags := cmd.Flags() 35 | plats, err := flagutil.ParsePlatformFlags(flags) 36 | if err != nil { 37 | return err 38 | } 39 | r := cmd.InOrStdin() 40 | input, err := flags.GetString("input") 41 | if err != nil { 42 | return err 43 | } 44 | if input != "" { 45 | f, err := os.Open(input) 46 | if err != nil { 47 | return err 48 | } 49 | defer f.Close() 50 | r = f 51 | } 52 | stdout := cmd.OutOrStdout() 53 | if err := imagegetter.Load(ctx, stdout, backend, r, plats, ""); err != nil { 54 | return fmt.Errorf("failed to load: %w", err) 55 | } 56 | return nil 57 | } 58 | -------------------------------------------------------------------------------- /cmd/diffoci/commands/pull/pull.go: -------------------------------------------------------------------------------- 1 | package pull 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/reproducible-containers/diffoci/cmd/diffoci/backend/backendmanager" 7 | "github.com/reproducible-containers/diffoci/cmd/diffoci/flagutil" 8 | "github.com/reproducible-containers/diffoci/cmd/diffoci/imagegetter" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | func NewCommand() *cobra.Command { 13 | cmd := &cobra.Command{ 14 | Use: "pull IMAGE", 15 | Short: "Pull an image", 16 | Args: cobra.ExactArgs(1), 17 | RunE: action, 18 | DisableFlagsInUseLine: true, 19 | } 20 | 21 | flags := cmd.Flags() 22 | flagutil.AddPlatformFlags(flags) 23 | return cmd 24 | } 25 | 26 | func action(cmd *cobra.Command, args []string) error { 27 | backend, err := backendmanager.NewBackend(cmd) 28 | if err != nil { 29 | return err 30 | } 31 | ctx := backend.Context(cmd.Context()) 32 | flags := cmd.Flags() 33 | plats, err := flagutil.ParsePlatformFlags(flags) 34 | if err != nil { 35 | return err 36 | } 37 | ig, err := imagegetter.New(cmd.ErrOrStderr(), backend) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | img, err := ig.Get(ctx, args[0], plats, imagegetter.PullAlways) 43 | if err != nil { 44 | return err 45 | } 46 | stdout := cmd.OutOrStdout() 47 | fmt.Fprintln(stdout, "Digest: "+img.Target.Digest) 48 | fmt.Fprintln(stdout, img.Name) 49 | return nil 50 | } 51 | -------------------------------------------------------------------------------- /cmd/diffoci/commands/remove/remove.go: -------------------------------------------------------------------------------- 1 | package remove 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/containerd/containerd/images" 8 | "github.com/containerd/log" 9 | refdocker "github.com/distribution/reference" 10 | "github.com/reproducible-containers/diffoci/cmd/diffoci/backend/backendmanager" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | func NewCommand() *cobra.Command { 15 | cmd := &cobra.Command{ 16 | Use: "remove IMAGE...", 17 | Aliases: []string{"rm", "rmi"}, 18 | Short: "Remove images", 19 | Long: `Remove images 20 | 21 | BUG: when using the "local" backend, the remove command 22 | does not remove blobs associated with the image. 23 | (Workaround: remove the cache directory periodically by yourself.) 24 | `, 25 | Args: cobra.MinimumNArgs(1), 26 | RunE: action, 27 | DisableFlagsInUseLine: true, 28 | } 29 | return cmd 30 | } 31 | 32 | func action(cmd *cobra.Command, args []string) error { 33 | backend, err := backendmanager.NewBackend(cmd) 34 | if err != nil { 35 | return err 36 | } 37 | ctx := backend.Context(cmd.Context()) 38 | imageStore := backend.ImageService() 39 | stdout := cmd.OutOrStdout() 40 | var errs []error 41 | for _, rawRef := range args { 42 | ref, err := refdocker.ParseDockerRef(rawRef) 43 | if err != nil { 44 | return fmt.Errorf("failed to parse %q: %w", rawRef, err) 45 | } 46 | img, err := imageStore.Get(ctx, ref.String()) 47 | if err != nil { 48 | return err 49 | } 50 | if err := imageStore.Delete(ctx, img.Name, images.SynchronousDelete()); err != nil { 51 | errs = append(errs, fmt.Errorf("failed to remove %q: %w", img.Name, err)) 52 | } 53 | fmt.Fprintln(stdout, img.Name) 54 | } 55 | if gcErr := backend.MaybeGC(ctx); gcErr != nil { 56 | log.G(ctx).WithError(gcErr).Warn("Failed to do GC") 57 | } 58 | return errors.Join(errs...) 59 | } 60 | -------------------------------------------------------------------------------- /cmd/diffoci/flagutil/flagutil.go: -------------------------------------------------------------------------------- 1 | package flagutil 2 | 3 | import ( 4 | "github.com/containerd/platforms" 5 | ocispec "github.com/opencontainers/image-spec/specs-go/v1" 6 | "github.com/spf13/pflag" 7 | ) 8 | 9 | func AddPlatformFlags(flags *pflag.FlagSet) { 10 | flags.Bool("all-platforms", false, "Specify all the image platforms") 11 | flags.StringSlice("platform", []string{platforms.DefaultString()}, "Specify the image platform") 12 | } 13 | 14 | func ParsePlatformFlags(flags *pflag.FlagSet) ([]ocispec.Platform, error) { 15 | allPlatforms, err := flags.GetBool("all-platforms") 16 | if err != nil { 17 | return nil, err 18 | } 19 | if allPlatforms { 20 | return nil, nil 21 | } 22 | platformSS, err := flags.GetStringSlice("platform") 23 | if err != nil { 24 | return nil, err 25 | } 26 | ps := make([]ocispec.Platform, len(platformSS)) 27 | for i := range platformSS { 28 | var err error 29 | ps[i], err = platforms.Parse(platformSS[i]) 30 | if err != nil { 31 | return nil, err 32 | } 33 | } 34 | return ps, nil 35 | } 36 | -------------------------------------------------------------------------------- /cmd/diffoci/imagegetter/imagegetter.go: -------------------------------------------------------------------------------- 1 | package imagegetter 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "os" 9 | "os/exec" 10 | "strings" 11 | 12 | "github.com/containerd/containerd/archive/compression" 13 | ctrimages "github.com/containerd/containerd/cmd/ctr/commands/images" 14 | "github.com/containerd/containerd/content" 15 | "github.com/containerd/containerd/images" 16 | "github.com/containerd/containerd/pkg/transfer" 17 | "github.com/containerd/containerd/pkg/transfer/archive" 18 | "github.com/containerd/containerd/pkg/transfer/image" 19 | transimage "github.com/containerd/containerd/pkg/transfer/image" 20 | "github.com/containerd/containerd/pkg/transfer/registry" 21 | "github.com/containerd/errdefs" 22 | "github.com/containerd/log" 23 | "github.com/containerd/platforms" 24 | refdocker "github.com/distribution/reference" 25 | ocispec "github.com/opencontainers/image-spec/specs-go/v1" 26 | "github.com/reproducible-containers/diffoci/cmd/diffoci/backend" 27 | "github.com/reproducible-containers/diffoci/pkg/dockercred" 28 | "github.com/reproducible-containers/diffoci/pkg/platformutil" 29 | ) 30 | 31 | func wrapTransferProgressFunc(ctx context.Context, pf transfer.ProgressFunc) transfer.ProgressFunc { 32 | return func(p transfer.Progress) { 33 | log.G(ctx).Debugf("transfer progress %+v", p) 34 | pf(p) 35 | } 36 | } 37 | 38 | func Load(ctx context.Context, stdout io.Writer, transferrer transfer.Transferrer, tarR io.Reader, plats []ocispec.Platform, foreknownRef string) error { 39 | decompressed, err := compression.DecompressStream(tarR) 40 | if err != nil { 41 | return err 42 | } 43 | iis := archive.NewImageImportStream(decompressed, "") 44 | 45 | sOpts := []transimage.StoreOpt{ 46 | transimage.WithPlatforms(plats...), 47 | image.WithPlatforms(plats...), 48 | image.WithAllMetadata, 49 | image.WithNamedPrefix("unused", true), 50 | } 51 | is := transimage.NewStore(foreknownRef, sOpts...) 52 | 53 | pf, done := ctrimages.ProgressHandler(ctx, stdout) 54 | defer done() 55 | 56 | if err := transferrer.Transfer(ctx, iis, is, transfer.WithProgress(wrapTransferProgressFunc(ctx, pf))); err != nil { 57 | return fmt.Errorf("failed to load: %w", err) 58 | } 59 | return nil 60 | } 61 | 62 | func Pull(ctx context.Context, stdout io.Writer, transferrer transfer.Transferrer, credHelper registry.CredentialHelper, ref string, plats []ocispec.Platform) error { 63 | reg := registry.NewOCIRegistry(ref, nil, credHelper) 64 | 65 | sOpts := []transimage.StoreOpt{ 66 | transimage.WithPlatforms(plats...), 67 | } 68 | is := transimage.NewStore(ref, sOpts...) 69 | 70 | pf, done := ctrimages.ProgressHandler(ctx, stdout) 71 | defer done() 72 | 73 | if err := transferrer.Transfer(ctx, reg, is, transfer.WithProgress(wrapTransferProgressFunc(ctx, pf))); err != nil { 74 | return fmt.Errorf("failed to pull %q: %w", ref, err) 75 | } 76 | return nil 77 | } 78 | 79 | type ImageGetter struct { 80 | progressWriter io.Writer // stderr 81 | imageStore images.Store 82 | contentStore content.Store 83 | transferrer transfer.Transferrer 84 | credHelper registry.CredentialHelper 85 | } 86 | 87 | func New(progressWriter io.Writer, backend backend.Backend) (*ImageGetter, error) { 88 | credHelper, err := dockercred.NewCredentialHelper() 89 | if err != nil { 90 | return nil, err 91 | } 92 | return &ImageGetter{ 93 | progressWriter: progressWriter, 94 | imageStore: backend.ImageService(), 95 | contentStore: backend.ContentStore(), 96 | transferrer: backend, 97 | credHelper: credHelper, 98 | }, nil 99 | } 100 | 101 | type PullMode string 102 | 103 | const ( 104 | PullAlways = "always" 105 | PullMissing = "missing" 106 | PullNever = "never" 107 | 108 | dockerImagePrefix = "docker://" 109 | podmanImagePrefix = "podman://" 110 | ) 111 | 112 | func (g *ImageGetter) isDocker(rawRef string) bool { 113 | return strings.HasPrefix(rawRef, dockerImagePrefix) 114 | } 115 | 116 | func (g *ImageGetter) isPodman(rawRef string) bool { 117 | return strings.HasPrefix(rawRef, podmanImagePrefix) 118 | } 119 | 120 | func (g *ImageGetter) getDocker(ctx context.Context, rawRef string, plats []ocispec.Platform) (*images.Image, error) { 121 | rawRefTrimmed := strings.TrimPrefix(rawRef, dockerImagePrefix) 122 | ref, err := refdocker.ParseDockerRef(rawRefTrimmed) 123 | if err != nil { 124 | return nil, fmt.Errorf("failed to parse %q: %w", rawRefTrimmed, err) 125 | } 126 | name := ref.String() 127 | docker := os.Getenv("DOCKER") 128 | if docker == "" { 129 | docker = "docker" 130 | } 131 | return g.loadDocker(ctx, docker, name, plats) 132 | } 133 | 134 | func (g *ImageGetter) getPodman(ctx context.Context, rawRef string, plats []ocispec.Platform) (*images.Image, error) { 135 | rawRefTrimmed := strings.TrimPrefix(rawRef, podmanImagePrefix) 136 | ref, err := refdocker.ParseDockerRef(rawRefTrimmed) 137 | if err != nil { 138 | return nil, fmt.Errorf("failed to parse %q: %w", rawRefTrimmed, err) 139 | } 140 | name := ref.String() 141 | podman := os.Getenv("PODMAN") 142 | if podman == "" { 143 | podman = "podman" 144 | } 145 | return g.loadDocker(ctx, podman, name, plats) 146 | } 147 | 148 | type readerWithEOF struct { 149 | io.Reader 150 | } 151 | 152 | func (r *readerWithEOF) Read(p []byte) (int, error) { 153 | n, err := r.Reader.Read(p) 154 | if errors.Is(err, os.ErrClosed) { 155 | err = io.EOF 156 | } 157 | return n, err 158 | } 159 | 160 | // loadDocker runs `docker save` and loads the result 161 | func (g *ImageGetter) loadDocker(ctx context.Context, docker, name string, plats []ocispec.Platform) (*images.Image, error) { 162 | log.G(ctx).Infof("Loading image %q from %q", name, docker) 163 | dockerCmd := exec.Command(docker, "save", name) 164 | dockerCmd.Stderr = os.Stderr 165 | r, err := dockerCmd.StdoutPipe() 166 | if err != nil { 167 | return nil, err 168 | } 169 | defer r.Close() 170 | log.G(ctx).Debugf("Running %v", dockerCmd.Args) 171 | if err = dockerCmd.Start(); err != nil { 172 | return nil, fmt.Errorf("failed to run %v: %w", dockerCmd.Args, err) 173 | } 174 | if err = Load(ctx, g.progressWriter, g.transferrer, &readerWithEOF{r}, plats, name); err != nil { 175 | return nil, fmt.Errorf("failed to load an archive (from %v): %w", dockerCmd.Args, err) 176 | } 177 | if err = r.Close(); err != nil { 178 | return nil, err 179 | } 180 | img, err := g.imageStore.Get(ctx, name) 181 | if err != nil { 182 | return nil, fmt.Errorf("should have loaded an archive (from %v), but the loaded image is not accessible: %w", dockerCmd.Args, err) 183 | } 184 | 185 | // Check platforms 186 | platMC := platforms.Any(plats...) 187 | available, _, _, _, err := images.Check(ctx, g.contentStore, img.Target, platMC) 188 | if err != nil { 189 | return nil, err 190 | } 191 | if !available { 192 | return nil, fmt.Errorf("image %q lacks blobs for additional platforms (%v): %w", 193 | name, platformutil.FormatSlice(plats), errdefs.ErrUnavailable) 194 | } 195 | return &img, nil 196 | } 197 | 198 | func (g *ImageGetter) Get(ctx context.Context, rawRef string, plats []ocispec.Platform, pullMode PullMode) (*images.Image, error) { 199 | if g.isDocker(rawRef) { 200 | return g.getDocker(ctx, rawRef, plats) 201 | } 202 | if g.isPodman(rawRef) { 203 | return g.getPodman(ctx, rawRef, plats) 204 | } 205 | ref, err := refdocker.ParseDockerRef(rawRef) 206 | if err != nil { 207 | return nil, fmt.Errorf("failed to parse %q: %w", rawRef, err) 208 | } 209 | name := ref.String() 210 | 211 | switch pullMode { 212 | case PullAlways: 213 | log.G(ctx).Infof("Pulling %q", name) 214 | if err := Pull(ctx, g.progressWriter, g.transferrer, g.credHelper, name, plats); err != nil { 215 | return nil, fmt.Errorf("failed to pull %q: %w", name, err) 216 | } 217 | case PullMissing, PullNever: 218 | // NOP 219 | default: 220 | return nil, fmt.Errorf("unknown pull mode %q", pullMode) 221 | } 222 | 223 | // Get the image object 224 | img, err := g.imageStore.Get(ctx, name) 225 | if err != nil { 226 | if errors.Is(err, errdefs.ErrNotFound) && pullMode != PullNever { 227 | log.G(ctx).Infof("Pulling %q", name) 228 | if pullErr := Pull(ctx, g.progressWriter, g.transferrer, g.credHelper, name, plats); pullErr != nil { 229 | return nil, fmt.Errorf("failed to pull %q: %w", name, pullErr) 230 | } 231 | var retryErr error 232 | img, retryErr = g.imageStore.Get(ctx, name) 233 | if retryErr != nil { 234 | return nil, fmt.Errorf("should have pulled %q, but still not accessible in the local store: %w", name, retryErr) 235 | } 236 | err = nil 237 | } 238 | } 239 | if err != nil { 240 | return nil, fmt.Errorf("failed to get image %q: %w", name, err) 241 | } 242 | 243 | // Check platforms 244 | platMC := platforms.Any(plats...) 245 | available, _, _, _, err := images.Check(ctx, g.contentStore, img.Target, platMC) 246 | if err != nil { 247 | return nil, err 248 | } 249 | if !available { 250 | if pullMode == PullNever { 251 | return nil, fmt.Errorf("image %q lacks blobs for additional platforms (%s): %w", 252 | name, platformutil.FormatSlice(plats), errdefs.ErrUnavailable) 253 | } else { 254 | log.G(ctx).Infof("Pulling %q for additional platforms (%s)", name, platformutil.FormatSlice(plats)) 255 | if err := Pull(ctx, g.progressWriter, g.transferrer, g.credHelper, name, plats); err != nil { 256 | return nil, fmt.Errorf("failed to pull %q: %w", name, err) 257 | } 258 | } 259 | } 260 | return &img, nil 261 | } 262 | -------------------------------------------------------------------------------- /cmd/diffoci/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | _ "crypto/sha256" 5 | 6 | "github.com/containerd/log" 7 | "github.com/reproducible-containers/diffoci/cmd/diffoci/backend/backendmanager" 8 | "github.com/reproducible-containers/diffoci/cmd/diffoci/commands/diff" 9 | "github.com/reproducible-containers/diffoci/cmd/diffoci/commands/images" 10 | "github.com/reproducible-containers/diffoci/cmd/diffoci/commands/info" 11 | "github.com/reproducible-containers/diffoci/cmd/diffoci/commands/load" 12 | "github.com/reproducible-containers/diffoci/cmd/diffoci/commands/pull" 13 | "github.com/reproducible-containers/diffoci/cmd/diffoci/commands/remove" 14 | "github.com/reproducible-containers/diffoci/cmd/diffoci/version" 15 | "github.com/reproducible-containers/diffoci/pkg/envutil" 16 | "github.com/spf13/cobra" 17 | ) 18 | 19 | func main() { 20 | if err := newRootCommand().Execute(); err != nil { 21 | log.L.Fatal(err) 22 | } 23 | } 24 | 25 | func newRootCommand() *cobra.Command { 26 | cmd := &cobra.Command{ 27 | Use: "diffoci", 28 | Short: "diff for container images", 29 | Example: diff.Example, 30 | Version: version.GetVersion(), 31 | Args: cobra.NoArgs, 32 | SilenceUsage: true, 33 | SilenceErrors: true, 34 | } 35 | flags := cmd.PersistentFlags() 36 | flags.Bool("debug", envutil.Bool("DEBUG", false), "debug mode [$DEBUG]") 37 | backendmanager.AddFlags(flags) 38 | 39 | cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { 40 | if debug, _ := cmd.Flags().GetBool("debug"); debug { 41 | if err := log.SetLevel(log.DebugLevel.String()); err != nil { 42 | log.L.WithError(err).Warn("Failed to enable debug logs") 43 | } 44 | } 45 | return nil 46 | } 47 | 48 | cmd.AddCommand( 49 | diff.NewCommand(), 50 | images.NewCommand(), 51 | pull.NewCommand(), 52 | load.NewCommand(), 53 | remove.NewCommand(), 54 | info.NewCommand(), 55 | ) 56 | return cmd 57 | } 58 | -------------------------------------------------------------------------------- /cmd/diffoci/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "runtime/debug" 5 | "strconv" 6 | ) 7 | 8 | // Version can be fulfilled on compilation time: -ldflags="-X github.com/reproducible-containers/diffoci/cmd/diffoci/version.Version=v0.1.2" 9 | var Version string 10 | 11 | func GetVersion() string { 12 | if Version != "" { 13 | return Version 14 | } 15 | const unknown = "(unknown)" 16 | bi, ok := debug.ReadBuildInfo() 17 | if !ok { 18 | return unknown 19 | } 20 | 21 | /* 22 | * go install example.com/cmd/foo@vX.Y.Z: bi.Main.Version="vX.Y.Z", vcs.revision is unset 23 | * go install example.com/cmd/foo@latest: bi.Main.Version="vX.Y.Z", vcs.revision is unset 24 | * go install example.com/cmd/foo@master: bi.Main.Version="vX.Y.Z-N.yyyyMMddhhmmss-gggggggggggg", vcs.revision is unset 25 | * go install ./cmd/foo: bi.Main.Version="(devel)", vcs.revision="gggggggggggggggggggggggggggggggggggggggg" 26 | * vcs.time="yyyy-MM-ddThh:mm:ssZ", vcs.modified=("false"|"true") 27 | */ 28 | if bi.Main.Version != "" && bi.Main.Version != "(devel)" { 29 | return bi.Main.Version 30 | } 31 | var ( 32 | vcsRevision string 33 | vcsModified bool 34 | ) 35 | for _, f := range bi.Settings { 36 | switch f.Key { 37 | case "vcs.revision": 38 | vcsRevision = f.Value 39 | case "vcs.modified": 40 | vcsModified, _ = strconv.ParseBool(f.Value) 41 | } 42 | } 43 | if vcsRevision == "" { 44 | return unknown 45 | } 46 | v := vcsRevision 47 | if vcsModified { 48 | v += ".m" 49 | } 50 | return v 51 | } 52 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/reproducible-containers/diffoci 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/containerd/containerd v1.7.28 7 | github.com/containerd/continuity v0.4.5 8 | github.com/containerd/errdefs v1.0.0 9 | github.com/containerd/log v0.1.0 10 | github.com/containerd/platforms v0.2.1 11 | github.com/distribution/reference v0.6.0 12 | github.com/docker/cli v28.5.1+incompatible 13 | github.com/google/go-cmp v0.7.0 14 | github.com/opencontainers/go-digest v1.0.0 15 | github.com/opencontainers/image-spec v1.1.1 16 | github.com/spf13/cobra v1.10.1 17 | github.com/spf13/pflag v1.0.10 18 | go.etcd.io/bbolt v1.4.3 19 | golang.org/x/sys v0.35.0 20 | ) 21 | 22 | require ( 23 | github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect 24 | github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20230306123547-8075edf89bb0 // indirect 25 | github.com/Microsoft/go-winio v0.6.2 // indirect 26 | github.com/Microsoft/hcsshim v0.11.7 // indirect 27 | github.com/containerd/cgroups v1.1.0 // indirect 28 | github.com/containerd/console v1.0.3 // indirect 29 | github.com/containerd/containerd/api v1.8.0 // indirect 30 | github.com/containerd/fifo v1.1.0 // indirect 31 | github.com/containerd/ttrpc v1.2.7 // indirect 32 | github.com/containerd/typeurl/v2 v2.1.1 // indirect 33 | github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect 34 | github.com/docker/docker-credential-helpers v0.7.0 // indirect 35 | github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect 36 | github.com/docker/go-units v0.5.0 // indirect 37 | github.com/felixge/httpsnoop v1.0.3 // indirect 38 | github.com/go-logr/logr v1.4.2 // indirect 39 | github.com/go-logr/stdr v1.2.2 // indirect 40 | github.com/gogo/protobuf v1.3.2 // indirect 41 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 42 | github.com/golang/protobuf v1.5.4 // indirect 43 | github.com/google/uuid v1.4.0 // indirect 44 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 45 | github.com/klauspost/compress v1.16.7 // indirect 46 | github.com/moby/locker v1.0.1 // indirect 47 | github.com/moby/sys/mountinfo v0.6.2 // indirect 48 | github.com/moby/sys/sequential v0.5.0 // indirect 49 | github.com/moby/sys/signal v0.7.0 // indirect 50 | github.com/moby/sys/user v0.3.0 // indirect 51 | github.com/moby/sys/userns v0.1.0 // indirect 52 | github.com/opencontainers/runtime-spec v1.1.0 // indirect 53 | github.com/opencontainers/selinux v1.11.0 // indirect 54 | github.com/pelletier/go-toml v1.9.5 // indirect 55 | github.com/pkg/errors v0.9.1 // indirect 56 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 57 | github.com/sirupsen/logrus v1.9.3 // indirect 58 | github.com/urfave/cli v1.22.14 // indirect 59 | go.opencensus.io v0.24.0 // indirect 60 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.45.0 // indirect 61 | go.opentelemetry.io/otel v1.21.0 // indirect 62 | go.opentelemetry.io/otel/metric v1.21.0 // indirect 63 | go.opentelemetry.io/otel/trace v1.21.0 // indirect 64 | golang.org/x/net v0.42.0 // indirect 65 | golang.org/x/sync v0.16.0 // indirect 66 | golang.org/x/text v0.27.0 // indirect 67 | google.golang.org/genproto v0.0.0-20231211222908-989df2bf70f3 // indirect 68 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda // indirect 69 | google.golang.org/grpc v1.59.0 // indirect 70 | google.golang.org/protobuf v1.35.2 // indirect 71 | gotest.tools/v3 v3.5.0 // indirect 72 | ) 73 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= 3 | github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= 4 | github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20230306123547-8075edf89bb0 h1:59MxjQVfjXsBpLy+dbd2/ELV5ofnUkUZBvWSC85sheA= 5 | github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20230306123547-8075edf89bb0/go.mod h1:OahwfttHWG6eJ0clwcfBAHoDI6X/LV/15hx/wlMZSrU= 6 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 7 | github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 8 | github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 9 | github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 10 | github.com/Microsoft/hcsshim v0.11.7 h1:vl/nj3Bar/CvJSYo7gIQPyRWc9f3c6IeSNavBTSZNZQ= 11 | github.com/Microsoft/hcsshim v0.11.7/go.mod h1:MV8xMfmECjl5HdO7U/3/hFVnkmSBjAjmA09d4bExKcU= 12 | github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= 13 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 14 | github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= 15 | github.com/containerd/cgroups v1.1.0 h1:v8rEWFl6EoqHB+swVNjVoCJE8o3jX7e8nqBGPLaDFBM= 16 | github.com/containerd/cgroups v1.1.0/go.mod h1:6ppBcbh/NOOUU+dMKrykgaBnK9lCIBxHqJDGwsa1mIw= 17 | github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= 18 | github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= 19 | github.com/containerd/containerd v1.7.28 h1:Nsgm1AtcmEh4AHAJ4gGlNSaKgXiNccU270Dnf81FQ3c= 20 | github.com/containerd/containerd v1.7.28/go.mod h1:azUkWcOvHrWvaiUjSQH0fjzuHIwSPg1WL5PshGP4Szs= 21 | github.com/containerd/containerd/api v1.8.0 h1:hVTNJKR8fMc/2Tiw60ZRijntNMd1U+JVMyTRdsD2bS0= 22 | github.com/containerd/containerd/api v1.8.0/go.mod h1:dFv4lt6S20wTu/hMcP4350RL87qPWLVa/OHOwmmdnYc= 23 | github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4= 24 | github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= 25 | github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= 26 | github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= 27 | github.com/containerd/fifo v1.1.0 h1:4I2mbh5stb1u6ycIABlBw9zgtlK8viPI9QkQNRQEEmY= 28 | github.com/containerd/fifo v1.1.0/go.mod h1:bmC4NWMbXlt2EZ0Hc7Fx7QzTFxgPID13eH0Qu+MAb2o= 29 | github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= 30 | github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= 31 | github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= 32 | github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= 33 | github.com/containerd/ttrpc v1.2.7 h1:qIrroQvuOL9HQ1X6KHe2ohc7p+HP/0VE6XPU7elJRqQ= 34 | github.com/containerd/ttrpc v1.2.7/go.mod h1:YCXHsb32f+Sq5/72xHubdiJRQY9inL4a4ZQrAbN1q9o= 35 | github.com/containerd/typeurl/v2 v2.1.1 h1:3Q4Pt7i8nYwy2KmQWIw2+1hTvwTE/6w9FqcttATPO/4= 36 | github.com/containerd/typeurl/v2 v2.1.1/go.mod h1:IDp2JFvbwZ31H8dQbEIY7sDl2L3o3HZj1hsSQlywkQ0= 37 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 38 | github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= 39 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 40 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 41 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 42 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 43 | github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= 44 | github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= 45 | github.com/docker/cli v28.5.1+incompatible h1:ESutzBALAD6qyCLqbQSEf1a/U8Ybms5agw59yGVc+yY= 46 | github.com/docker/cli v28.5.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= 47 | github.com/docker/docker-credential-helpers v0.7.0 h1:xtCHsjxogADNZcdv1pKUHXryefjlVRqWqIhk/uXJp0A= 48 | github.com/docker/docker-credential-helpers v0.7.0/go.mod h1:rETQfLdHNT3foU5kuNkFR1R1V12OJRRO5lzt2D1b5X0= 49 | github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ+oDZB4KHQFypsfjYlq/C4rfL7D3g8= 50 | github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= 51 | github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= 52 | github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 53 | github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 54 | github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 55 | github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= 56 | github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= 57 | github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= 58 | github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 59 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 60 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 61 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 62 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 63 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 64 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 65 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 66 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 67 | github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 68 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= 69 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 70 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 71 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 72 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 73 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 74 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 75 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 76 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 77 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 78 | github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= 79 | github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 80 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 81 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 82 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 83 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 84 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 85 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 86 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 87 | github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 88 | github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 89 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 90 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 91 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 92 | github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 93 | github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= 94 | github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 95 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 96 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 97 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 98 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 99 | github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= 100 | github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= 101 | github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg= 102 | github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc= 103 | github.com/moby/sys/mountinfo v0.6.2 h1:BzJjoreD5BMFNmD9Rus6gdd1pLuecOFPt8wC+Vygl78= 104 | github.com/moby/sys/mountinfo v0.6.2/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI= 105 | github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= 106 | github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= 107 | github.com/moby/sys/signal v0.7.0 h1:25RW3d5TnQEoKvRbEKUGay6DCQ46IxAVTT9CUMgmsSI= 108 | github.com/moby/sys/signal v0.7.0/go.mod h1:GQ6ObYZfqacOwTtlXvcmh9A26dVRul/hbOZn88Kg8Tg= 109 | github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= 110 | github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= 111 | github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= 112 | github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= 113 | github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 114 | github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 115 | github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= 116 | github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= 117 | github.com/opencontainers/runtime-spec v1.1.0 h1:HHUyrt9mwHUjtasSbXSMvs4cyFxh+Bll4AjJ9odEGpg= 118 | github.com/opencontainers/runtime-spec v1.1.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= 119 | github.com/opencontainers/selinux v1.11.0 h1:+5Zbo97w3Lbmb3PeqQtpmTkMwsW5nRI3YaLpt7tQ7oU= 120 | github.com/opencontainers/selinux v1.11.0/go.mod h1:E5dMC3VPuVvVHDYmi78qvhJp8+M586T4DlDRYpFkyec= 121 | github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= 122 | github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= 123 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 124 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 125 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 126 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 127 | github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 128 | github.com/prometheus/procfs v0.10.1 h1:kYK1Va/YMlutzCGazswoHKo//tZVlFpKYh+PymziUAg= 129 | github.com/prometheus/procfs v0.10.1/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM= 130 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 131 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 132 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 133 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 134 | github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= 135 | github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= 136 | github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 137 | github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= 138 | github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 139 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 140 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 141 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 142 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 143 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 144 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 145 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 146 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 147 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 148 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 149 | github.com/urfave/cli v1.22.14 h1:ebbhrRiGK2i4naQJr+1Xj92HXZCrK7MsyTS/ob3HnAk= 150 | github.com/urfave/cli v1.22.14/go.mod h1:X0eDS6pD6Exaclxm99NJ3FiCDRED7vIHpx2mDOHLvkA= 151 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 152 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 153 | go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= 154 | go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= 155 | go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= 156 | go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= 157 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.45.0 h1:x8Z78aZx8cOF0+Kkazoc7lwUNMGy0LrzEMxTm4BbTxg= 158 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.45.0/go.mod h1:62CPTSry9QZtOaSsE3tOzhx6LzDhHnXJ6xHeMNNiM6Q= 159 | go.opentelemetry.io/otel v1.21.0 h1:hzLeKBZEL7Okw2mGzZ0cc4k/A7Fta0uoPgaJCr8fsFc= 160 | go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo= 161 | go.opentelemetry.io/otel/metric v1.21.0 h1:tlYWfeo+Bocx5kLEloTjbcDwBuELRrIFxwdQ36PlJu4= 162 | go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM= 163 | go.opentelemetry.io/otel/trace v1.21.0 h1:WD9i5gzvoUPuXIXH24ZNBudiarZDKuekPqi/E8fpfLc= 164 | go.opentelemetry.io/otel/trace v1.21.0/go.mod h1:LGbsEB0f9LGjN+OZaQQ26sohbOmiMR+BaslueVtS/qQ= 165 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 166 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 167 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 168 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 169 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 170 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 171 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 172 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 173 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 174 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 175 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 176 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 177 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 178 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 179 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 180 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 181 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 182 | golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 183 | golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= 184 | golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= 185 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 186 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 187 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 188 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 189 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 190 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 191 | golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= 192 | golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 193 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 194 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 195 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 196 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 197 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 198 | golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 199 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 200 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 201 | golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= 202 | golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 203 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 204 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 205 | golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= 206 | golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= 207 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 208 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 209 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 210 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 211 | golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 212 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 213 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 214 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 215 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 216 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 217 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 218 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 219 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 220 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 221 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 222 | google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 223 | google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= 224 | google.golang.org/genproto v0.0.0-20231211222908-989df2bf70f3 h1:1hfbdAfFbkmpg41000wDVqr7jUpK/Yo+LPnIxxGzmkg= 225 | google.golang.org/genproto v0.0.0-20231211222908-989df2bf70f3/go.mod h1:5RBcpGRxr25RbDzY5w+dmaqpSEvl8Gwl1x2CICf60ic= 226 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda h1:LI5DOvAxUPMv/50agcLLoo+AdWc1irS9Rzz4vPuD1V4= 227 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= 228 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 229 | google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= 230 | google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= 231 | google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 232 | google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= 233 | google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= 234 | google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= 235 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 236 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 237 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 238 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 239 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 240 | google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 241 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 242 | google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 243 | google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= 244 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 245 | google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 246 | google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= 247 | google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 248 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 249 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 250 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 251 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 252 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 253 | gotest.tools/v3 v3.5.0 h1:Ljk6PdHdOhAb5aDMWXjDLMMhph+BpztA4v1QdqEW2eY= 254 | gotest.tools/v3 v3.5.0/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= 255 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 256 | honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 257 | -------------------------------------------------------------------------------- /pkg/diff/diff.go: -------------------------------------------------------------------------------- 1 | package diff 2 | 3 | import ( 4 | "archive/tar" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "os" 11 | "path" 12 | "path/filepath" 13 | "runtime" 14 | "slices" 15 | "sort" 16 | "strconv" 17 | "strings" 18 | "sync" 19 | "text/tabwriter" 20 | 21 | "github.com/containerd/containerd/archive/compression" 22 | "github.com/containerd/containerd/content" 23 | "github.com/containerd/containerd/images" 24 | "github.com/containerd/errdefs" 25 | "github.com/containerd/log" 26 | "github.com/containerd/platforms" 27 | "github.com/google/go-cmp/cmp" 28 | "github.com/google/go-cmp/cmp/cmpopts" 29 | "github.com/opencontainers/go-digest" 30 | ocispec "github.com/opencontainers/image-spec/specs-go/v1" 31 | "github.com/reproducible-containers/diffoci/pkg/untar" 32 | ) 33 | 34 | type IgnoranceOptions struct { 35 | IgnoreHistory bool 36 | IgnoreFileOrder bool 37 | IgnoreFileModeRedundantBits bool 38 | IgnoreFileTimestamps bool 39 | IgnoreImageTimestamps bool 40 | IgnoreImageName bool 41 | IgnoreTarFormat bool 42 | CanonicalPaths bool 43 | } 44 | 45 | type Options struct { 46 | IgnoranceOptions 47 | EventHandler 48 | ReportFile string 49 | ReportDir string 50 | MaxScale float64 51 | } 52 | 53 | func (o *Options) digestMayChange() bool { 54 | return o.IgnoranceOptions != IgnoranceOptions{} 55 | } 56 | 57 | func (o *Options) sizeMayChange() bool { 58 | // over-estimated 59 | return o.digestMayChange() 60 | } 61 | 62 | func Diff(ctx context.Context, cs content.Provider, descs [2]ocispec.Descriptor, 63 | platMC platforms.MatchComparer, opts *Options) (*EventTreeNode, error) { 64 | for i, desc := range descs { 65 | available, _, _, missing, err := images.Check(ctx, cs, desc, platMC) 66 | if err == nil && !available { 67 | err = errdefs.ErrUnavailable 68 | } 69 | if err != nil { 70 | log.G(ctx).Debugf("missing=%+v", missing) 71 | for _, f := range missing { 72 | if f.Platform != nil { 73 | p := platforms.Format(*f.Platform) 74 | return nil, fmt.Errorf("image %d is not available for platform %q: %w", i, p, err) 75 | } 76 | } 77 | return nil, fmt.Errorf("image %d is not available for the requested platform: %w", i, err) 78 | } 79 | } 80 | var o Options 81 | if opts != nil { 82 | o = *opts 83 | } 84 | if o.EventHandler == nil { 85 | o.EventHandler = DefaultEventHandler 86 | } 87 | var reportFiles []string 88 | if o.ReportFile != "" { 89 | reportFiles = append(reportFiles, o.ReportFile) 90 | } 91 | if o.ReportDir != "" { 92 | if err := os.MkdirAll(o.ReportDir, 0755); err != nil { 93 | return nil, err 94 | } 95 | for _, f := range ReportDirRootFilenames { 96 | p := filepath.Join(o.ReportDir, filepath.Clean(f)) 97 | log.G(ctx).Debugf("Removing %q (if exists)", p) 98 | if err := os.RemoveAll(p); err != nil { 99 | return nil, err 100 | } 101 | } 102 | if err := os.WriteFile(filepath.Join(o.ReportDir, ReportDirReadmeMD), []byte(ReportDirReadmeMDContent), 0444); err != nil { 103 | return nil, err 104 | } 105 | reportFiles = append(reportFiles, filepath.Join(o.ReportDir, ReportDirReportJSON)) 106 | } 107 | if o.MaxScale == 0.0 { 108 | o.MaxScale = 1.0 109 | } 110 | d := differ{ 111 | cs: cs, 112 | platMC: platMC, 113 | o: o, 114 | } 115 | eventTreeRootNode := &EventTreeNode{ 116 | Context: "/", 117 | } 118 | inputs := [2]EventInput{ 119 | { 120 | Descriptor: &descs[0], 121 | }, { 122 | Descriptor: &descs[1], 123 | }, 124 | } 125 | var errs []error 126 | if err := d.diff(ctx, eventTreeRootNode, inputs); err != nil { 127 | errs = append(errs, err) 128 | } 129 | if flusher, ok := o.EventHandler.(Flusher); ok { 130 | if err := flusher.Flush(); err != nil { 131 | errs = append(errs, err) 132 | } 133 | } 134 | for _, reportFile := range reportFiles { 135 | if err := writeReportFile(reportFile, eventTreeRootNode); err != nil { 136 | errs = append(errs, err) 137 | } 138 | } 139 | return eventTreeRootNode, errors.Join(errs...) 140 | } 141 | 142 | func writeReportFile(p string, node *EventTreeNode) error { 143 | f, err := os.Create(p) 144 | if err != nil { 145 | return err 146 | } 147 | defer f.Close() 148 | enc := json.NewEncoder(f) 149 | enc.SetEscapeHTML(false) 150 | return enc.Encode(node) 151 | } 152 | 153 | type differ struct { 154 | cs content.Provider 155 | platMC platforms.MatchComparer 156 | o Options 157 | } 158 | 159 | func (d *differ) raiseEvent(ctx context.Context, node *EventTreeNode, ev Event, evContextName string) error { 160 | return d.raiseEventWithEventTreeNode(ctx, node, &EventTreeNode{Context: path.Join(node.Context, path.Clean(evContextName)), Event: ev}) 161 | } 162 | 163 | func (d *differ) raiseEventWithEventTreeNode(ctx context.Context, node, newNode *EventTreeNode) error { 164 | eventErr := d.o.EventHandler.HandleEventTreeNode(ctx, newNode) 165 | node.Append(newNode) 166 | return eventErr 167 | } 168 | 169 | func manifestDesc(ctx context.Context, cs content.Provider, indexDesc ocispec.Descriptor, platMC platforms.MatchComparer) (*ocispec.Descriptor, error) { 170 | p, err := content.ReadBlob(ctx, cs, indexDesc) 171 | if err != nil { 172 | return nil, err 173 | } 174 | var idx ocispec.Index 175 | if err := json.Unmarshal(p, &idx); err != nil { 176 | return nil, err 177 | } 178 | for _, mani := range idx.Manifests { 179 | if mani.Platform == nil || platMC.Match(*mani.Platform) { 180 | return &mani, nil 181 | } 182 | } 183 | return nil, errdefs.ErrNotFound 184 | } 185 | 186 | func (d *differ) diff(ctx context.Context, node *EventTreeNode, in [2]EventInput) error { 187 | var errs []error 188 | negligibleFields := []string{"Annotations"} 189 | if d.o.digestMayChange() { 190 | negligibleFields = append(negligibleFields, "Digest", "Data") 191 | } 192 | if d.o.sizeMayChange() { 193 | negligibleFields = append(negligibleFields, "Size") 194 | } 195 | if diff := cmp.Diff(in[0].Descriptor, in[1].Descriptor, 196 | cmpopts.IgnoreFields(ocispec.Descriptor{}, negligibleFields...)); diff != "" { 197 | ev := Event{ 198 | Type: EventTypeDescriptorMismatch, 199 | Inputs: in, 200 | Diff: diff, 201 | } 202 | if err := d.raiseEvent(ctx, node, ev, "desc"); err != nil { 203 | errs = append(errs, err) 204 | } 205 | } 206 | if err := d.diffAnnotationsField(ctx, node, in, EventTypeDescriptorMismatch, 207 | [2]map[string]string{ 208 | in[0].Descriptor.Annotations, 209 | in[1].Descriptor.Annotations, 210 | }, "Annotations"); err != nil { 211 | errs = append(errs, err) 212 | } 213 | switch mt := in[0].Descriptor.MediaType; { 214 | case images.IsIndexType(mt): 215 | if images.IsManifestType(in[1].Descriptor.MediaType) { 216 | log.G(ctx).Warn("Comparing multi-platform image vs single-platform image (EXPERIMENTAL)") 217 | mani0Desc, err := manifestDesc(ctx, d.cs, *in[0].Descriptor, d.platMC) 218 | if err != nil { 219 | return err 220 | } 221 | newNode := EventTreeNode{ 222 | Context: path.Join(node.Context, "manifest"), 223 | Event: Event{ 224 | Type: EventTypeManifestBlobMismatch, 225 | Inputs: in, 226 | Diff: cmp.Diff(*in[0].Descriptor, *in[1].Descriptor), 227 | Note: "index vs manifest", 228 | }, 229 | } 230 | childInputs := [2]EventInput{ 231 | { 232 | Descriptor: mani0Desc, 233 | }, { 234 | Descriptor: in[1].Descriptor, 235 | }, 236 | } 237 | if diffErr := d.diff(ctx, &newNode, childInputs); diffErr != nil { 238 | errs = append(errs, err) 239 | } 240 | if len(newNode.Children) > 0 { 241 | if err := d.raiseEventWithEventTreeNode(ctx, node, &newNode); err != nil { 242 | errs = append(errs, err) 243 | } 244 | } // else no event happens 245 | } else { 246 | if err := d.diffIndex(ctx, node, in); err != nil { 247 | errs = append(errs, err) 248 | } 249 | } 250 | case images.IsManifestType(mt): 251 | if images.IsIndexType(in[1].Descriptor.MediaType) { 252 | errs = append(errs, errors.New("comparing single-platform image vs multi-platform image is not supported (Hint: swap input 0 and 1)")) 253 | } else { 254 | if err := d.diffManifest(ctx, node, in); err != nil { 255 | errs = append(errs, err) 256 | } 257 | } 258 | case images.IsConfigType(mt): 259 | if err := d.diffConfig(ctx, node, in); err != nil { 260 | errs = append(errs, err) 261 | } 262 | case images.IsLayerType(mt): 263 | if err := d.diffLayer(ctx, node, in); err != nil { 264 | errs = append(errs, err) 265 | } 266 | default: 267 | log.G(ctx).Warnf("Unknown media type %q", mt) 268 | if diff := cmp.Diff(in[0].Descriptor, in[1].Descriptor); diff != "" { 269 | ev := Event{ 270 | Type: EventTypeDescriptorMismatch, 271 | Inputs: in, 272 | Diff: diff, 273 | } 274 | if err := d.raiseEvent(ctx, node, ev, "desc"); err != nil { 275 | errs = append(errs, err) 276 | } 277 | } 278 | } 279 | return errors.Join(errs...) 280 | } 281 | 282 | func (d *differ) diffDescriptorPtrField(ctx context.Context, node *EventTreeNode, in [2]EventInput, evType EventType, descs [2]*ocispec.Descriptor, fieldName string) error { 283 | if (descs[0] != nil && descs[1] == nil) || (descs[0] == nil && descs[1] != nil) { 284 | ev := Event{ 285 | Type: evType, 286 | Inputs: in, 287 | Diff: cmp.Diff(descs[0], descs[1]), 288 | Note: fmt.Sprintf("field %q: only present in a single input", fieldName), 289 | } 290 | return d.raiseEvent(ctx, node, ev, strings.ToLower(fieldName)) 291 | } 292 | if descs[0] == nil { 293 | return nil 294 | } 295 | newNode := EventTreeNode{ 296 | Context: path.Join(node.Context, path.Clean(strings.ToLower(fieldName))), 297 | Event: Event{ 298 | Type: evType, 299 | Inputs: in, 300 | Diff: cmp.Diff(*descs[0], *descs[1]), 301 | Note: fmt.Sprintf("field %q", fieldName), 302 | }, 303 | } 304 | childInputs := [2]EventInput{ 305 | { 306 | Descriptor: descs[0], 307 | }, { 308 | Descriptor: descs[1], 309 | }, 310 | } 311 | var err error 312 | if diffErr := d.diff(ctx, &newNode, childInputs); diffErr != nil { 313 | err = fmt.Errorf("field %q: %w", fieldName, diffErr) 314 | } 315 | if len(newNode.Children) > 0 { 316 | if err2 := d.raiseEventWithEventTreeNode(ctx, node, &newNode); err2 != nil { 317 | err = errors.Join(err, err2) 318 | } 319 | } // else no event happens 320 | return err 321 | } 322 | 323 | func (d *differ) diffDescriptorSliceField(ctx context.Context, node *EventTreeNode, in [2]EventInput, evType EventType, descSlices [2][]ocispec.Descriptor, fieldName string, 324 | maxEnts int, validateDesc func(ocispec.Descriptor) (tolerable bool, vErr error)) error { 325 | if len(descSlices[0]) != len(descSlices[1]) { 326 | ev := Event{ 327 | Type: evType, 328 | Inputs: in, 329 | Diff: cmp.Diff(descSlices[0], descSlices[1]), 330 | Note: fmt.Sprintf("field %q: length mismatch (%d vs %d)", fieldName, len(descSlices[0]), len(descSlices[1])), 331 | } 332 | return d.raiseEvent(ctx, node, ev, strings.ToLower(fieldName)) 333 | } 334 | if len(descSlices[0]) > maxEnts { 335 | return fmt.Errorf("field %q: too many manifests (> %d)", fieldName, maxEnts) 336 | } 337 | var errs []error 338 | // TODO: paralellize the loop 339 | for i := range descSlices[0] { 340 | i := i 341 | fieldNameI := fmt.Sprintf("%s[%d]", fieldName, i) 342 | newNode := EventTreeNode{ 343 | Context: path.Join(node.Context, path.Clean(strings.ToLower(fieldName)+"-"+strconv.Itoa(i))), 344 | Event: Event{ 345 | Type: evType, 346 | Inputs: in, 347 | Diff: cmp.Diff(descSlices[0][i], descSlices[1][i]), 348 | Note: fmt.Sprintf("field %q", fieldNameI), 349 | }, 350 | } 351 | if tolerable, err := validateDesc(descSlices[0][i]); err != nil { 352 | if !tolerable { 353 | errs = append(errs, fmt.Errorf("field %q: invalid: %w", fieldNameI, err)) 354 | } 355 | continue 356 | } 357 | childInputs := [2]EventInput{ 358 | { 359 | Descriptor: &descSlices[0][i], 360 | }, { 361 | Descriptor: &descSlices[1][i], 362 | }, 363 | } 364 | if err := d.diff(ctx, &newNode, childInputs); err != nil { 365 | errs = append(errs, fmt.Errorf("field %q: %w", fieldNameI, err)) 366 | } 367 | if len(newNode.Children) > 0 { 368 | if err2 := d.raiseEventWithEventTreeNode(ctx, node, &newNode); err2 != nil { 369 | errs = append(errs, err2) 370 | } 371 | } // else no event happens 372 | } 373 | return errors.Join(errs...) 374 | } 375 | 376 | func (d *differ) diffAnnotationsField(ctx context.Context, node *EventTreeNode, in [2]EventInput, evType EventType, maps [2]map[string]string, fieldName string) error { 377 | negligible := map[string]struct{}{} 378 | if d.o.IgnoreImageTimestamps { 379 | negligible[ocispec.AnnotationCreated] = struct{}{} 380 | } 381 | if d.o.IgnoreImageName { 382 | negligible[images.AnnotationImageName] = struct{}{} // "io.containerd.image.name": "docker.io/library/alpine:3.18" 383 | negligible[ocispec.AnnotationRefName] = struct{}{} // "org.opencontainers.image.ref.name": "3.18" 384 | } 385 | if len(negligible) > 0 { 386 | for i := 0; i < 2; i++ { 387 | if maps[i] == nil { 388 | maps[i] = make(map[string]string) 389 | } 390 | } 391 | } 392 | discardFunc := func(k, _ string) bool { 393 | _, ok := negligible[k] 394 | return ok 395 | } 396 | if diff := cmp.Diff(maps[0], maps[1], cmpopts.IgnoreMapEntries(discardFunc)); diff != "" { 397 | ev := Event{ 398 | Type: evType, 399 | Inputs: in, 400 | Diff: diff, 401 | } 402 | if fieldName != "" { 403 | ev.Note = fmt.Sprintf("field %q", fieldName) 404 | } 405 | return d.raiseEvent(ctx, node, ev, strings.ToLower(fieldName)) 406 | } 407 | return nil 408 | } 409 | 410 | func (d *differ) diffIndex(ctx context.Context, node *EventTreeNode, in [2]EventInput) error { 411 | for i := 0; i < 2; i++ { 412 | var err error 413 | in[i].Index, err = readBlobWithType[ocispec.Index](ctx, d.cs, *in[i].Descriptor, d.o.MaxScale) 414 | if err != nil { 415 | return fmt.Errorf("failed to read index (%v): %w", in[i].Descriptor, err) // critical, not joined 416 | } 417 | } 418 | 419 | var negligibleFields []string 420 | if d.o.digestMayChange() { 421 | negligibleFields = append(negligibleFields, "Manifests", "Subject", "Annotations") 422 | } 423 | var errs []error 424 | if diff := cmp.Diff(*in[0].Index, *in[1].Index, cmpopts.IgnoreFields(ocispec.Index{}, negligibleFields...)); diff != "" { 425 | ev := Event{ 426 | Type: EventTypeIndexBlobMismatch, 427 | Inputs: in, 428 | Diff: diff, 429 | } 430 | if err := d.raiseEvent(ctx, node, ev, "index"); err != nil { 431 | errs = append(errs, err) 432 | } 433 | } 434 | 435 | // Compare Manifests 436 | // TODO: allow comparing multi-platform image vs single-platform image 437 | if err := d.diffDescriptorSliceField(ctx, node, in, EventTypeIndexBlobMismatch, [2][]ocispec.Descriptor{ 438 | in[0].Index.Manifests, 439 | in[1].Index.Manifests, 440 | }, "Manifests", int(maxManifests*d.o.MaxScale), 441 | func(desc ocispec.Descriptor) (tolerable bool, vErr error) { 442 | if !images.IsManifestType(desc.MediaType) { 443 | return false, fmt.Errorf("expected a manifest type, got %q", desc.MediaType) 444 | } 445 | if desc.Platform != nil && !d.platMC.Match(*desc.Platform) { 446 | return true, fmt.Errorf("unexpected platform %q", platforms.Format(*desc.Platform)) 447 | } 448 | return true, nil 449 | }); err != nil { 450 | errs = append(errs, err) 451 | } 452 | 453 | // Compare Subject 454 | if err := d.diffDescriptorPtrField(ctx, node, in, EventTypeIndexBlobMismatch, [2]*ocispec.Descriptor{ 455 | in[0].Index.Subject, 456 | in[1].Index.Subject, 457 | }, "Subject"); err != nil { 458 | errs = append(errs, err) 459 | } 460 | 461 | // Compare Annotations 462 | if err := d.diffAnnotationsField(ctx, node, in, EventTypeIndexBlobMismatch, [2]map[string]string{ 463 | in[0].Index.Annotations, 464 | in[1].Index.Annotations, 465 | }, "Annotations"); err != nil { 466 | errs = append(errs, err) 467 | } 468 | 469 | return errors.Join(errs...) 470 | } 471 | 472 | func (d *differ) diffManifest(ctx context.Context, node *EventTreeNode, in [2]EventInput) error { 473 | if in[0].Descriptor.Platform != nil && !d.platMC.Match(*in[0].Descriptor.Platform) { 474 | return nil 475 | } 476 | for i := 0; i < 2; i++ { 477 | var err error 478 | in[i].Manifest, err = readBlobWithType[ocispec.Manifest](ctx, d.cs, *in[i].Descriptor, d.o.MaxScale) 479 | if err != nil { 480 | return fmt.Errorf("failed to read manifest (%v): %w", in[i].Descriptor, err) 481 | } 482 | } 483 | var negligibleFields []string 484 | if d.o.digestMayChange() { 485 | negligibleFields = append(negligibleFields, "Config", "Layers", "Subject", "Annotations") 486 | } 487 | var errs []error 488 | if diff := cmp.Diff(*in[0].Manifest, *in[1].Manifest, cmpopts.IgnoreFields(ocispec.Manifest{}, negligibleFields...)); diff != "" { 489 | ev := Event{ 490 | Type: EventTypeManifestBlobMismatch, 491 | Inputs: in, 492 | Diff: diff, 493 | } 494 | if err := d.raiseEvent(ctx, node, ev, "manifest"); err != nil { 495 | errs = append(errs, err) 496 | } 497 | } 498 | 499 | // Compare Config 500 | if err := d.diffDescriptorPtrField(ctx, node, in, EventTypeManifestBlobMismatch, [2]*ocispec.Descriptor{ 501 | &in[0].Manifest.Config, 502 | &in[1].Manifest.Config, 503 | }, "Config"); err != nil { 504 | errs = append(errs, err) 505 | } 506 | 507 | // Compare Layers 508 | if len(in[0].Manifest.Layers) == len(in[1].Manifest.Layers) { 509 | if err := d.diffDescriptorSliceField(ctx, node, in, EventTypeManifestBlobMismatch, [2][]ocispec.Descriptor{ 510 | in[0].Manifest.Layers, 511 | in[1].Manifest.Layers, 512 | }, "Layers", int(maxLayers*d.o.MaxScale), 513 | func(desc ocispec.Descriptor) (tolerable bool, vErr error) { 514 | if !images.IsLayerType(desc.MediaType) { 515 | return false, fmt.Errorf("expected a layer type, got %q", desc.MediaType) 516 | } 517 | return true, nil 518 | }); err != nil { 519 | errs = append(errs, err) 520 | } 521 | } else { 522 | log.G(ctx).Warningf("Layer length mismatch (%d vs %d), squashing for comparison (EXPERIMENTAL)", len(in[0].Manifest.Layers), len(in[1].Manifest.Layers)) 523 | if err := d.diffLayersWithSquashing(ctx, node, in); err != nil { 524 | errs = append(errs, err) 525 | } 526 | } 527 | 528 | // Compare Subject 529 | if err := d.diffDescriptorPtrField(ctx, node, in, EventTypeManifestBlobMismatch, [2]*ocispec.Descriptor{ 530 | in[0].Manifest.Subject, 531 | in[1].Manifest.Subject, 532 | }, "Subject"); err != nil { 533 | errs = append(errs, err) 534 | } 535 | 536 | // Compare Annotations 537 | if err := d.diffAnnotationsField(ctx, node, in, EventTypeManifestBlobMismatch, [2]map[string]string{ 538 | in[0].Manifest.Annotations, 539 | in[1].Manifest.Annotations, 540 | }, "Annotations"); err != nil { 541 | errs = append(errs, err) 542 | } 543 | 544 | return errors.Join(errs...) 545 | } 546 | 547 | func (d *differ) diffConfig(ctx context.Context, node *EventTreeNode, in [2]EventInput) error { 548 | for i := 0; i < 2; i++ { 549 | var err error 550 | in[i].Config, err = readBlobWithType[ocispec.Image](ctx, d.cs, *in[i].Descriptor, d.o.MaxScale) 551 | if err != nil { 552 | return fmt.Errorf("failed to read config (%v): %w", in[i].Descriptor, err) 553 | } 554 | } 555 | var negligibleFields []string 556 | if d.o.digestMayChange() { 557 | negligibleFields = append(negligibleFields, "RootFS") 558 | } 559 | if d.o.IgnoreImageTimestamps { 560 | // history contains timestamps 561 | negligibleFields = append(negligibleFields, "Created", "History") 562 | } 563 | if d.o.IgnoreHistory { 564 | negligibleFields = append(negligibleFields, "History") 565 | } 566 | var errs []error 567 | if diff := cmp.Diff(*in[0].Config, *in[1].Config, cmpopts.IgnoreFields(ocispec.Image{}, negligibleFields...)); diff != "" { 568 | ev := Event{ 569 | Type: EventTypeConfigBlobMismatch, 570 | Inputs: in, 571 | Diff: diff, 572 | } 573 | if err := d.raiseEvent(ctx, node, ev, "config"); err != nil { 574 | errs = append(errs, err) 575 | } 576 | } 577 | 578 | // Compare partial RootFS 579 | if slices.Contains(negligibleFields, "RootFS") { 580 | if diff := cmp.Diff(in[0].Config.RootFS, in[1].Config.RootFS, cmpopts.IgnoreFields(ocispec.RootFS{}, "DiffIDs")); diff != "" { 581 | ev := Event{ 582 | Type: EventTypeConfigBlobMismatch, 583 | Inputs: in, 584 | Diff: diff, 585 | Note: "field \"RootFS\"", 586 | } 587 | if err := d.raiseEvent(ctx, node, ev, "config/rootfs"); err != nil { 588 | errs = append(errs, err) 589 | } 590 | } 591 | } 592 | 593 | // Compare partial History 594 | if slices.Contains(negligibleFields, "History") && !d.o.IgnoreHistory { 595 | if len(in[0].Config.History) != len(in[1].Config.History) { 596 | ev := Event{ 597 | Type: EventTypeConfigBlobMismatch, 598 | Inputs: in, 599 | Diff: cmp.Diff(in[0].Config.History, in[1].Config.History), 600 | Note: "field \"History\": length mismatch", 601 | } 602 | if err := d.raiseEvent(ctx, node, ev, "config/history"); err != nil { 603 | errs = append(errs, err) 604 | } 605 | } else { 606 | var negligibleHistoryFields []string 607 | if d.o.IgnoreImageTimestamps { 608 | negligibleHistoryFields = append(negligibleHistoryFields, "Created") 609 | } 610 | for i := range in[0].Config.History { 611 | if diff := cmp.Diff(in[0].Config.History[i], in[1].Config.History[i], 612 | cmpopts.IgnoreFields(ocispec.History{}, negligibleHistoryFields...)); diff != "" { 613 | ev := Event{ 614 | Type: EventTypeConfigBlobMismatch, 615 | Inputs: in, 616 | Diff: diff, 617 | Note: fmt.Sprintf("field \"History[%d]\"", i), 618 | } 619 | if err := d.raiseEvent(ctx, node, ev, fmt.Sprintf("config/history-%d", i)); err != nil { 620 | errs = append(errs, err) 621 | } 622 | } 623 | } 624 | } 625 | } 626 | 627 | return errors.Join(errs...) 628 | } 629 | 630 | func (d *differ) diffLayersWithSquashing(ctx context.Context, node *EventTreeNode, in [2]EventInput) error { 631 | tr0, trCloser0, err := openTarReaderWithSquashing(ctx, d.cs, in[0].Manifest.Layers, d.o.MaxScale) 632 | if err != nil { 633 | return err 634 | } 635 | defer func() { 636 | if trCloserErr0 := trCloser0(); trCloserErr0 != nil { 637 | log.G(ctx).WithError(trCloserErr0).Warn("failed to close tar reader 0") 638 | } 639 | }() 640 | 641 | tr1, trCloser1, err := openTarReaderWithSquashing(ctx, d.cs, in[1].Manifest.Layers, d.o.MaxScale) 642 | if err != nil { 643 | return err 644 | } 645 | defer func() { 646 | if trCloserErr1 := trCloser1(); trCloserErr1 != nil { 647 | log.G(ctx).WithError(trCloserErr1).Warn("failed to close tar reader 1") 648 | } 649 | }() 650 | return d.diffLayerWithTarReader(ctx, node, in, tr0, tr1) 651 | } 652 | 653 | func (d *differ) diffLayer(ctx context.Context, node *EventTreeNode, in [2]EventInput) error { 654 | tr0, trCloser0, err := openTarReader(ctx, d.cs, *in[0].Descriptor, d.o.MaxScale) 655 | if err != nil { 656 | return err 657 | } 658 | defer func() { 659 | if trCloserErr0 := trCloser0(); trCloserErr0 != nil { 660 | log.G(ctx).WithError(trCloserErr0).Warn("failed to close tar reader 0") 661 | } 662 | }() 663 | 664 | tr1, trCloser1, err := openTarReader(ctx, d.cs, *in[1].Descriptor, d.o.MaxScale) 665 | if err != nil { 666 | return err 667 | } 668 | defer func() { 669 | if trCloserErr1 := trCloser1(); trCloserErr1 != nil { 670 | log.G(ctx).WithError(trCloserErr1).Warn("failed to close tar reader 1") 671 | } 672 | }() 673 | return d.diffLayerWithTarReader(ctx, node, in, tr0, tr1) 674 | } 675 | 676 | // tarReader is implemented by *tar.Reader . 677 | type tarReader interface { 678 | io.Reader 679 | Next() (*tar.Header, error) 680 | } 681 | 682 | type loadLayerResult struct { 683 | entries int 684 | entriesByName map[string][]*TarEntry 685 | finalizers []func() error 686 | } 687 | 688 | func (d *differ) loadLayer(ctx context.Context, node *EventTreeNode, inputIdx int, tr tarReader) (*loadLayerResult, error) { 689 | res := &loadLayerResult{ 690 | entriesByName: make(map[string][]*TarEntry), 691 | finalizers: nil, 692 | } 693 | for i := 0; ; i++ { 694 | hdr, err := tr.Next() 695 | if errors.Is(err, io.EOF) { 696 | break 697 | } 698 | if d.o.IgnoreTarFormat { 699 | hdr.Format = tar.FormatUnknown 700 | } 701 | if d.o.CanonicalPaths { 702 | hdr.Name = strings.TrimPrefix(hdr.Name, "/") 703 | hdr.Name = strings.TrimPrefix(hdr.Name, "./") 704 | hdr.Linkname = strings.TrimPrefix(hdr.Linkname, "/") 705 | hdr.Linkname = strings.TrimPrefix(hdr.Linkname, "./") 706 | if path, ok := hdr.PAXRecords["path"]; ok { 707 | path = strings.TrimPrefix(path, "/") 708 | hdr.PAXRecords["path"] = strings.TrimPrefix(path, "./") 709 | } 710 | if path, ok := hdr.PAXRecords["linkpath"]; ok { 711 | path = strings.TrimPrefix(path, "/") 712 | hdr.PAXRecords["linkpath"] = strings.TrimPrefix(path, "./") 713 | } 714 | } 715 | if os.Geteuid() != 0 && runtime.GOOS == "linux" { 716 | //nolint:staticcheck // SA1019: hdr.Xattrs has been deprecated since Go 1.10: Use PAXRecords instead. 717 | for k := range hdr.Xattrs { 718 | if strings.HasPrefix(k, "security.") { 719 | log.G(ctx).Debugf("Ignoring xattr %q", k) 720 | delete(hdr.Xattrs, k) 721 | } 722 | } 723 | for k := range hdr.PAXRecords { 724 | if strings.HasPrefix(k, "SCHILY.xattr.security.") { 725 | log.G(ctx).Debugf("Ignoring PAX record %q", k) 726 | delete(hdr.PAXRecords, k) 727 | } 728 | } 729 | } 730 | res.entries++ 731 | ent := &TarEntry{ 732 | Index: i, 733 | Header: hdr, 734 | } 735 | if repDir := d.o.ReportDir; repDir != "" { 736 | dirx := filepath.Clean(node.Context) // "/manifests-0/layers-0" 737 | dir := filepath.Join(repDir, ReportDirInput0, dirx) 738 | switch inputIdx { 739 | case 0: // NOP 740 | case 1: 741 | dir = filepath.Join(repDir, ReportDirInput1, dirx) 742 | default: 743 | return res, fmt.Errorf("invalid input index %d", inputIdx) 744 | } 745 | ut, err := untar.Entry(ctx, dir, hdr, tr) 746 | if err != nil { 747 | return res, err 748 | } 749 | ent.Digest = ut.Digest 750 | ent.extractedPath = ut.Path 751 | if ut.Finalizer != nil { 752 | res.finalizers = append(res.finalizers, ut.Finalizer) 753 | } 754 | } else { 755 | ent.Digest, err = digest.SHA256.FromReader(tr) 756 | if err != nil { 757 | return res, err 758 | } 759 | } 760 | res.entriesByName[hdr.Name] = append(res.entriesByName[hdr.Name], ent) 761 | } 762 | 763 | return res, nil 764 | } 765 | 766 | func (d *differ) diffLayerWithTarReader(ctx context.Context, node *EventTreeNode, in [2]EventInput, tr0, tr1 tarReader) error { 767 | l0, err := d.loadLayer(ctx, node, 0, tr0) 768 | if err != nil { 769 | return fmt.Errorf("failed to load layer (input-0): %w", err) 770 | } 771 | l1, err := d.loadLayer(ctx, node, 1, tr1) 772 | if err != nil { 773 | return fmt.Errorf("failed to load layer (input-1): %w", err) 774 | } 775 | defer func() { 776 | for _, finalizer := range append(l0.finalizers, l1.finalizers...) { 777 | if finalizerErr := finalizer(); finalizerErr != nil { 778 | log.G(ctx).WithError(finalizerErr).Debug("Failed to execute a layer finalizer") 779 | } 780 | } 781 | }() 782 | var errs []error 783 | if l0.entries != l1.entries { 784 | ev := Event{ 785 | Type: EventTypeLayerBlobMismatch, 786 | Inputs: in, 787 | Note: fmt.Sprintf("length mismatch (%d vs %d)", l0.entries, l1.entries), 788 | } 789 | if err := d.raiseEvent(ctx, node, ev, "layer"); err != nil { 790 | errs = append(errs, err) 791 | } 792 | } 793 | newNode := EventTreeNode{ 794 | Context: path.Join(node.Context, "layer"), 795 | Event: Event{ 796 | Type: EventTypeLayerBlobMismatch, 797 | Inputs: in, 798 | }, 799 | } 800 | var dirsToBeRemovedIfEmpty []string 801 | for name, ents0 := range l0.entriesByName { 802 | ents1 := l1.entriesByName[name] 803 | if len(ents0) != len(ents1) { 804 | ev := Event{ 805 | Type: EventTypeLayerBlobMismatch, 806 | Inputs: in, 807 | Note: eventNoteNameAppearanceMismatch(name, len(ents0), len(ents1)), 808 | } 809 | if err := d.raiseEvent(ctx, node /* not NewNode */, ev, "layer"); err != nil { 810 | errs = append(errs, err) 811 | } 812 | continue 813 | } 814 | dd, err := d.diffTarEntries(ctx, &newNode, in, [2][]*TarEntry{ents0, ents1}) 815 | dirsToBeRemovedIfEmpty = append(dirsToBeRemovedIfEmpty, dd...) 816 | if err != nil { 817 | errs = append(errs, err) 818 | } 819 | } 820 | // Iterate again to find entries that only appear in input 1 821 | for name, ents1 := range l1.entriesByName { 822 | ents0 := l0.entriesByName[name] 823 | if len(ents0) != len(ents1) { 824 | ev := Event{ 825 | Type: EventTypeLayerBlobMismatch, 826 | Inputs: in, 827 | Note: eventNoteNameAppearanceMismatch(name, len(ents0), len(ents1)), 828 | } 829 | if err := d.raiseEvent(ctx, node /* not newNode */, ev, "layer"); err != nil { 830 | errs = append(errs, err) 831 | } 832 | } 833 | } 834 | sort.Sort(sort.Reverse(sort.StringSlice(dirsToBeRemovedIfEmpty))) 835 | for _, d := range dirsToBeRemovedIfEmpty { 836 | _ = os.Remove(d) // Not RemoveAll 837 | } 838 | 839 | if len(newNode.Children) > 0 { 840 | if err2 := d.raiseEventWithEventTreeNode(ctx, node, &newNode); err2 != nil { 841 | errs = append(errs, err2) 842 | } 843 | } // else no event happens 844 | return errors.Join(errs...) 845 | } 846 | 847 | func eventNoteNameAppearanceMismatch(name string, len0, len1 int) string { 848 | if len0 != 0 && len1 == 0 { 849 | return fmt.Sprintf("name %q only appears in input 0", name) 850 | } 851 | if len0 == 0 && len1 != 0 { 852 | return fmt.Sprintf("name %q only appears in input 1", name) 853 | } 854 | return fmt.Sprintf("name %q appears %d times in input 0, %d times in input 1", 855 | name, len0, len1) 856 | } 857 | 858 | func (d *differ) diffTarEntries(ctx context.Context, node *EventTreeNode, in [2]EventInput, ents [2][]*TarEntry) (dirsToBeRemoved []string, retErr error) { 859 | var ( 860 | dirsToBeRemovedIfEmpty []string 861 | errs []error 862 | ) 863 | for i, ent0 := range ents[0] { 864 | ent1 := ents[1][i] 865 | childInputs := in 866 | childInputs[0].TarEntry = ent0 867 | childInputs[1].TarEntry = ent1 868 | dd, err := d.diffTarEntry(ctx, node, childInputs) 869 | dirsToBeRemovedIfEmpty = append(dirsToBeRemovedIfEmpty, dd...) 870 | if err != nil { 871 | errs = append(errs, err) 872 | } 873 | } 874 | return dirsToBeRemovedIfEmpty, errors.Join(errs...) 875 | } 876 | 877 | func (d *differ) diffTarEntry(ctx context.Context, node *EventTreeNode, in [2]EventInput) (dirsToBeRemovedIfEmpty []string, retErr error) { 878 | var negligibleTarFields []string 879 | negligiblePAXFields := map[string]struct{}{} 880 | if d.o.IgnoreFileTimestamps { 881 | negligibleTarFields = append(negligibleTarFields, "ModTime", "AccessTime", "ChangeTime", "PAXRecords") 882 | negligiblePAXFields["mtime"] = struct{}{} 883 | negligiblePAXFields["atime"] = struct{}{} 884 | negligiblePAXFields["ctime"] = struct{}{} 885 | } 886 | discardFunc := func(k, _ string) bool { 887 | _, ok := negligiblePAXFields[k] 888 | return ok 889 | } 890 | cmpOpts := []cmp.Option{cmpopts.IgnoreUnexported(TarEntry{}), cmpopts.IgnoreFields(tar.Header{}, negligibleTarFields...)} 891 | paxOpts := []cmp.Option{cmpopts.IgnoreMapEntries(discardFunc)} 892 | ent0, ent1 := *in[0].TarEntry, *in[1].TarEntry 893 | if d.o.IgnoreFileOrder { 894 | // cmpopts.IgnoreFields cannot be used for int 895 | ent0.Index = -1 896 | ent1.Index = -1 897 | } 898 | if d.o.IgnoreFileModeRedundantBits { 899 | // Ignore 0x4000 (directory), 0x8000 (regular), etc. 900 | // BuildKit sets these redundant bits. The legacy builder does not. 901 | ent0.Header.Mode &= 0x0FFF 902 | ent1.Header.Mode &= 0x0FFF 903 | } 904 | pax0 := ent0.Header.PAXRecords 905 | if pax0 == nil { 906 | pax0 = map[string]string{} 907 | } 908 | pax1 := ent1.Header.PAXRecords 909 | if pax1 == nil { 910 | pax1 = map[string]string{} 911 | } 912 | var errs []error 913 | if diff := cmp.Diff(ent0, ent1, cmpOpts...); diff != "" { 914 | ev := Event{ 915 | Type: EventTypeTarEntryMismatch, 916 | Inputs: in, 917 | Diff: diff, 918 | Note: fmt.Sprintf("name %q", ent0.Header.Name), 919 | } 920 | if err := d.raiseEvent(ctx, node, ev, "tarentry"); err != nil { 921 | errs = append(errs, err) 922 | } 923 | } else if diff := cmp.Diff(pax0, pax1, paxOpts...); diff != "" { 924 | ev := Event{ 925 | Type: EventTypeTarEntryMismatch, 926 | Inputs: in, 927 | Diff: diff, 928 | Note: fmt.Sprintf("name %q", ent0.Header.Name), 929 | } 930 | if err := d.raiseEvent(ctx, node, ev, "tarentry"); err != nil { 931 | errs = append(errs, err) 932 | } 933 | } else { 934 | // entry matches, so no need to retain the extracted files and dirs 935 | // (but dirs cannot be removed until processing all the tar entries in the layer) 936 | if ent0.Header.Typeflag == tar.TypeDir { 937 | if ent0.extractedPath != "" { 938 | dirsToBeRemovedIfEmpty = append(dirsToBeRemovedIfEmpty, ent0.extractedPath) 939 | } 940 | if ent1.extractedPath != "" { 941 | dirsToBeRemovedIfEmpty = append(dirsToBeRemovedIfEmpty, ent1.extractedPath) 942 | } 943 | } else { 944 | if ent0.extractedPath != "" { 945 | _ = os.Remove(ent0.extractedPath) 946 | } 947 | if ent1.extractedPath != "" { 948 | _ = os.Remove(ent1.extractedPath) 949 | } 950 | } 951 | } 952 | return dirsToBeRemovedIfEmpty, errors.Join(errs...) 953 | } 954 | 955 | func openTarReader(ctx context.Context, cs content.Provider, desc ocispec.Descriptor, maxScale float64) (tr tarReader, closer func() error, err error) { 956 | if desc.Size > int64(maxTarBlobSize*maxScale) { 957 | return nil, nil, fmt.Errorf("too large tar blob (%d > %d bytes)", desc.Size, int64(maxTarBlobSize*maxScale)) 958 | } 959 | ra, err := cs.ReaderAt(ctx, desc) 960 | if err != nil { 961 | return nil, nil, err 962 | } 963 | cr := content.NewReader(ra) 964 | dr, err := compression.DecompressStream(cr) 965 | if err != nil { 966 | ra.Close() 967 | return nil, nil, err 968 | } 969 | lr := io.LimitReader(dr, int64(maxTarStreamSize*maxScale)) 970 | return tar.NewReader(lr), ra.Close, nil 971 | } 972 | 973 | func openTarReaderWithSquashing(ctx context.Context, cs content.Provider, descs []ocispec.Descriptor, maxScale float64) (tr tarReader, closer func() error, err error) { 974 | tarReaders := make([]tarReader, len(descs)) 975 | closers := make([]func() error, len(descs)) 976 | for i := 0; i < len(descs); i++ { 977 | var err error 978 | tarReaders[i], closers[i], err = openTarReader(ctx, cs, descs[i], maxScale) 979 | if err != nil { 980 | return nil, nil, err 981 | } 982 | } 983 | tr = newSquashedTarReader(tarReaders) 984 | closer = func() error { 985 | var errs []error 986 | for _, f := range closers { 987 | if err := f(); err != nil { 988 | errs = append(errs, err) 989 | } 990 | } 991 | return errors.Join(errs...) 992 | } 993 | return tr, closer, nil 994 | } 995 | 996 | func newSquashedTarReader(tarReaders []tarReader) tarReader { 997 | return &squashedTarReader{ 998 | tarReaders: tarReaders, 999 | current: 0, 1000 | } 1001 | } 1002 | 1003 | type squashedTarReader struct { 1004 | tarReaders []tarReader 1005 | current int 1006 | } 1007 | 1008 | func (r *squashedTarReader) Read(p []byte) (int, error) { 1009 | return r.tarReaders[r.current].Read(p) 1010 | } 1011 | 1012 | func (r *squashedTarReader) Next() (*tar.Header, error) { 1013 | begin: 1014 | hdr, err := r.tarReaders[r.current].Next() 1015 | if errors.Is(err, io.EOF) && r.current < len(r.tarReaders)-1 { 1016 | r.current++ 1017 | goto begin 1018 | } 1019 | return hdr, err 1020 | } 1021 | 1022 | func readBlobWithType[T interface { 1023 | ocispec.Index | ocispec.Manifest | ocispec.Image 1024 | }](ctx context.Context, cs content.Provider, desc ocispec.Descriptor, maxScale float64) (*T, error) { 1025 | if desc.Size > int64(maxJSONBlobSize*maxScale) { 1026 | return nil, fmt.Errorf("too large JSON blob (%d > %d bytes)", desc.Size, int64(maxJSONBlobSize*maxScale)) 1027 | } 1028 | b, err := content.ReadBlob(ctx, cs, desc) 1029 | if err != nil { 1030 | return nil, err 1031 | } 1032 | var t T 1033 | if err = json.Unmarshal(b, &t); err != nil { 1034 | return nil, err 1035 | } 1036 | return &t, nil 1037 | } 1038 | 1039 | type EventTreeNode struct { 1040 | Context string `json:"context"` // Not unique 1041 | Event `json:"event"` 1042 | Children []*EventTreeNode `json:"children,omitempty"` 1043 | sync.RWMutex `json:"-"` 1044 | } 1045 | 1046 | func (n *EventTreeNode) Append(newNode *EventTreeNode) { 1047 | n.Lock() 1048 | n.Children = append(n.Children, newNode) 1049 | n.Unlock() 1050 | } 1051 | 1052 | type Event struct { 1053 | Type EventType `json:"type,omitempty"` 1054 | Inputs [2]EventInput `json:"inputs,omitempty"` 1055 | Diff string `json:"diff,omitempty"` // Not machine-parsable 1056 | Note string `json:"note,omitempty"` // Not machine-parsable 1057 | } 1058 | 1059 | // String implements [fmt.Stringer]. 1060 | // The returned string is not machine-parsable. 1061 | func (ev *Event) String() string { 1062 | s := fmt.Sprintf("%q", ev.Type) 1063 | if ev.Note != "" { 1064 | s += " (" + ev.Note + ")" 1065 | } 1066 | if ev.Diff != "" { 1067 | s += "\n" + ev.Diff 1068 | } 1069 | return s 1070 | } 1071 | 1072 | type TarEntry struct { 1073 | Index int `json:"index"` 1074 | Header *tar.Header `json:"header,omitempty"` 1075 | Digest digest.Digest `json:"digest,omitempty"` 1076 | 1077 | extractedPath string `json:"-"` // path on local filesystem 1078 | } 1079 | 1080 | type EventInput struct { 1081 | Descriptor *ocispec.Descriptor `json:"descriptor,omitempty"` 1082 | Index *ocispec.Index `json:"index,omitempty"` 1083 | Manifest *ocispec.Manifest `json:"manifest,omitempty"` 1084 | Config *ocispec.Image `json:"config,omitempty"` 1085 | TarEntry *TarEntry `json:"tarEntry,omitempty"` 1086 | } 1087 | 1088 | type EventType string 1089 | 1090 | const ( 1091 | EventTypeNone = EventType("") 1092 | EventTypeDescriptorMismatch = EventType("DescriptorMismatch") 1093 | EventTypeIndexBlobMismatch = EventType("IndexBlobMismatch") 1094 | EventTypeManifestBlobMismatch = EventType("ManifestBlobMismatch") 1095 | EventTypeConfigBlobMismatch = EventType("ConfigBlobMismatch") 1096 | EventTypeLayerBlobMismatch = EventType("LayerBlobMismatch") 1097 | EventTypeTarEntryMismatch = EventType("TarEntryMismatch") 1098 | ) 1099 | 1100 | // MaxScale option is multiplied to these constants 1101 | const ( 1102 | maxManifests = 4096 1103 | maxLayers = 4096 1104 | maxJSONBlobSize = 1024 * 1024 1105 | maxTarBlobSize = 1024 * 1024 * 1024 * 4 1106 | maxTarStreamSize = 1024 * 1024 * 1024 * 32 1107 | ) 1108 | 1109 | // EventHandler handles an event. 1110 | // EventHandler blocks. 1111 | type EventHandler interface { 1112 | HandleEventTreeNode(context.Context, *EventTreeNode) error 1113 | } 1114 | 1115 | type Flusher interface { 1116 | Flush() error 1117 | } 1118 | 1119 | var DefaultEventHandler = NewDefaultEventHandler(os.Stdout) 1120 | 1121 | func NewDefaultEventHandler(w io.Writer) EventHandler { 1122 | tw := tabwriter.NewWriter(w, 4, 8, 4, ' ', 0) 1123 | return &defaultEventHandler{tw: tw} 1124 | } 1125 | 1126 | type defaultEventHandler struct { 1127 | twHeaderOnce sync.Once 1128 | tw *tabwriter.Writer 1129 | } 1130 | 1131 | func (h *defaultEventHandler) HandleEventTreeNode(ctx context.Context, node *EventTreeNode) error { 1132 | ev := node.Event 1133 | log.G(ctx).Debugf("Event: " + ev.String()) 1134 | // Only print leaf events to stdout 1135 | if len(node.Children) > 0 { 1136 | return nil 1137 | } 1138 | h.twHeaderOnce.Do(func() { 1139 | fmt.Fprintln(h.tw, "TYPE\tNAME\tINPUT-0\tINPUT-1") 1140 | }) 1141 | in0, in1 := ev.Inputs[0], ev.Inputs[1] 1142 | d0, d1 := "?", "?" 1143 | if ev.Note != "" { 1144 | d0, d1 = ev.Note, "" 1145 | } 1146 | name := "-" 1147 | if node.Context != "" { 1148 | name = "ctx:" + node.Context 1149 | } 1150 | // TODO: colorize 1151 | switch ev.Type { 1152 | case EventTypeDescriptorMismatch: 1153 | desc0, desc1 := in0.Descriptor, in1.Descriptor 1154 | name = desc0.MediaType 1155 | if desc0.MediaType != desc1.MediaType { 1156 | d0, d1 = desc0.MediaType, desc1.MediaType 1157 | } else if desc0.Digest != desc1.Digest { 1158 | d0, d1 = desc0.Digest.String(), desc1.Digest.String() 1159 | d0, d1 = strings.TrimPrefix(d0, "sha256:"), strings.TrimPrefix(d1, "sha256:") 1160 | } 1161 | fmt.Fprintln(h.tw, "Desc\t"+name+"\t"+d0+"\t"+d1) 1162 | case EventTypeIndexBlobMismatch: 1163 | fmt.Fprintln(h.tw, "Idx\t"+name+"\t"+d0+"\t"+d1) 1164 | case EventTypeManifestBlobMismatch: 1165 | fmt.Fprintln(h.tw, "Mani\t"+name+"\t"+d0+"\t"+d1) 1166 | case EventTypeConfigBlobMismatch: 1167 | fmt.Fprintln(h.tw, "Cfg\t"+name+"\t"+d0+"\t"+d1) 1168 | case EventTypeLayerBlobMismatch: 1169 | fmt.Fprintln(h.tw, "Layer\t"+name+"\t"+d0+"\t"+d1) 1170 | case EventTypeTarEntryMismatch: 1171 | name := "?" 1172 | d0, d1 := "?", "?" 1173 | ent0, ent1 := in0.TarEntry, in1.TarEntry 1174 | if ent0 == nil { 1175 | d0 = "missing" 1176 | } else { 1177 | name = ent0.Header.Name 1178 | } 1179 | if ent1 == nil { 1180 | d1 = "missing" 1181 | } else if ent0 == nil { 1182 | name = ent1.Header.Name 1183 | } 1184 | if ent0 != nil && ent1 != nil { 1185 | hdr0, hdr1 := ent0.Header, ent1.Header 1186 | if hdr0.Name != hdr1.Name { 1187 | d0, d1 = hdr0.Name, hdr1.Name 1188 | } else if hdr0.Linkname != hdr1.Linkname { 1189 | d0, d1 = "Linkname "+hdr0.Linkname, "Linkname "+hdr1.Linkname 1190 | } else if hdr0.Mode != hdr1.Mode { 1191 | d0, d1 = fmt.Sprintf("Mode 0x%0x", hdr0.Mode), fmt.Sprintf("Mode 0x%0x", hdr1.Mode) 1192 | } else if hdr0.Uid != hdr1.Uid { 1193 | d0, d1 = fmt.Sprintf("Uid %d", hdr0.Uid), fmt.Sprintf("Uid %d", hdr1.Uid) 1194 | } else if hdr0.Gid != hdr1.Gid { 1195 | d0, d1 = fmt.Sprintf("Gid %d", hdr0.Gid), fmt.Sprintf("Gid %d", hdr1.Gid) 1196 | } else if hdr0.Uname != hdr1.Uname { 1197 | d0, d1 = "Uname "+hdr0.Uname, "Uname "+hdr1.Uname 1198 | } else if hdr0.Gname != hdr1.Gname { 1199 | d0, d1 = "Gname "+hdr0.Gname, "Gname "+hdr1.Gname 1200 | } else if hdr0.Devmajor != hdr1.Devmajor || hdr0.Devminor != hdr1.Devminor { 1201 | d0, d1 = fmt.Sprintf("Dev %d:%d", hdr0.Devmajor, hdr0.Devminor), fmt.Sprintf("Dev %d:%d", hdr1.Devmajor, hdr1.Devminor) 1202 | } else if ent0.Digest != ent1.Digest { 1203 | d0, d1 = ent0.Digest.String(), ent1.Digest.String() 1204 | d0, d1 = strings.TrimPrefix(d0, "sha256:"), strings.TrimPrefix(d1, "sha256:") 1205 | } else if !hdr0.ModTime.Equal(hdr1.ModTime) { 1206 | d0, d1 = hdr0.ModTime.String(), hdr1.ModTime.String() 1207 | } else if !hdr0.AccessTime.Equal(hdr1.AccessTime) { 1208 | d0, d1 = "Atime "+hdr0.AccessTime.String(), "Atime "+hdr1.AccessTime.String() 1209 | } else if !hdr0.ChangeTime.Equal(hdr1.ChangeTime) { 1210 | d0, d1 = "Ctime "+hdr0.ChangeTime.String(), "Ctime "+hdr1.ChangeTime.String() 1211 | } else if ent0.Index != ent1.Index { 1212 | d0, d1 = fmt.Sprintf("Index %d", ent0.Index), fmt.Sprintf("Index %d", ent1.Index) 1213 | } else if ent0.Header.Format != ent1.Header.Format { 1214 | d0 = fmt.Sprintf("Format %s (%d)", ent0.Header.Format, ent0.Header.Format) 1215 | d1 = fmt.Sprintf("Format %s (%d)", ent1.Header.Format, ent1.Header.Format) 1216 | } 1217 | // TODO: Xattrs 1218 | } 1219 | fmt.Fprintln(h.tw, "File\t"+name+"\t"+d0+"\t"+d1) 1220 | default: 1221 | log.G(ctx).Warnf("Unknown event: " + node.Event.String()) 1222 | } 1223 | return nil 1224 | } 1225 | 1226 | func (h *defaultEventHandler) Flush() error { 1227 | return h.tw.Flush() 1228 | } 1229 | 1230 | var VerboseEventHandler = newVerboseEventHandler() 1231 | 1232 | func newVerboseEventHandler() EventHandler { 1233 | return &verboseEventHandler{} 1234 | } 1235 | 1236 | type verboseEventHandler struct { 1237 | } 1238 | 1239 | func (h *verboseEventHandler) HandleEventTreeNode(ctx context.Context, node *EventTreeNode) error { 1240 | fmt.Println("Event: " + node.Event.String()) 1241 | return nil 1242 | } 1243 | 1244 | const ( 1245 | ReportDirReadmeMD = "README.md" 1246 | ReportDirReportJSON = "report.json" 1247 | ReportDirInput0 = "input-0" 1248 | ReportDirInput1 = "input-1" 1249 | ) 1250 | 1251 | var ReportDirRootFilenames = []string{ 1252 | ReportDirReadmeMD, 1253 | ReportDirReportJSON, 1254 | ReportDirInput0, 1255 | ReportDirInput1, 1256 | } 1257 | 1258 | const ReportDirReadmeMDContent = `# diffoci report directory 1259 | - input-0: Input 0 1260 | - input-1: Input 1 1261 | - report.json: report file (EXPERIMENTAL; the file format is subject to change) 1262 | ` 1263 | -------------------------------------------------------------------------------- /pkg/dockercred/dockercred.go: -------------------------------------------------------------------------------- 1 | package dockercred 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/containerd/containerd/pkg/transfer/registry" 9 | dockerconfig "github.com/docker/cli/cli/config" 10 | dockerconfigfile "github.com/docker/cli/cli/config/configfile" 11 | dockerconfigtypes "github.com/docker/cli/cli/config/types" 12 | ) 13 | 14 | func NewCredentialHelper() (registry.CredentialHelper, error) { 15 | // Load does not raise an error on ENOENT 16 | dockerConfigFile, err := dockerconfig.Load("") 17 | if err != nil { 18 | return nil, err 19 | } 20 | h := &helper{ 21 | dockerConfigFile: dockerConfigFile, 22 | } 23 | return h, nil 24 | } 25 | 26 | type helper struct { 27 | dockerConfigFile *dockerconfigfile.ConfigFile 28 | } 29 | 30 | func (h *helper) GetCredentials(ctx context.Context, ref, origHost string) (registry.Credentials, error) { 31 | hosts := []string{origHost} 32 | if origHost == "registry-1.docker.io" { 33 | hosts = append(hosts, "https://index.docker.io/v1/") 34 | } else if !strings.Contains(origHost, "://") { 35 | hosts = append(hosts, "https://"+origHost, "http://"+origHost) 36 | } 37 | 38 | var emptyAC dockerconfigtypes.AuthConfig 39 | for _, host := range hosts { 40 | ac, err := h.dockerConfigFile.GetAuthConfig(host) 41 | if err != nil { 42 | return registry.Credentials{}, fmt.Errorf("failed to call GetAutoConfig(%q): %w", host, err) 43 | } 44 | if ac == emptyAC { 45 | continue 46 | } 47 | cred := registry.Credentials{ 48 | Host: ac.ServerAddress, 49 | Username: ac.Username, 50 | Secret: ac.Password, 51 | } 52 | if ac.IdentityToken != "" { 53 | cred.Username = "" 54 | cred.Secret = ac.IdentityToken 55 | } 56 | return cred, nil 57 | } 58 | return registry.Credentials{}, nil 59 | } 60 | -------------------------------------------------------------------------------- /pkg/envutil/envutil.go: -------------------------------------------------------------------------------- 1 | // From https://github.com/reproducible-containers/repro-get/blob/v0.4.0/pkg/envutil/envutil.go 2 | 3 | package envutil 4 | 5 | import ( 6 | "os" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/containerd/log" 11 | ) 12 | 13 | func String(envName, defaultValue string) string { 14 | v, ok := os.LookupEnv(envName) 15 | if !ok { 16 | return defaultValue 17 | } 18 | return v 19 | } 20 | 21 | func StringSlice(envName string, defaultValue []string) []string { 22 | v, ok := os.LookupEnv(envName) 23 | if !ok { 24 | return defaultValue 25 | } 26 | ss := strings.Split(v, ",") 27 | l := len(ss) 28 | for i := 0; i < l; i++ { 29 | ss[i] = strings.TrimSpace(ss[i]) 30 | } 31 | return ss 32 | } 33 | 34 | func Bool(envName string, defaultValue bool) bool { 35 | v, ok := os.LookupEnv(envName) 36 | if !ok { 37 | return defaultValue 38 | } 39 | b, err := strconv.ParseBool(v) 40 | if err != nil { 41 | log.L.WithError(err).Warnf("Failed to parse %q ($%s) as a boolean", v, envName) 42 | return defaultValue 43 | } 44 | return b 45 | } 46 | -------------------------------------------------------------------------------- /pkg/localpathutil/localpathutil.go: -------------------------------------------------------------------------------- 1 | // From https://github.com/lima-vm/lima/blob/v0.17.2/pkg/localpathutil/localpathutil.go . 2 | /* 3 | Copyright The Lima Authors. 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | */ 17 | 18 | package localpathutil 19 | 20 | import ( 21 | "errors" 22 | "fmt" 23 | "os" 24 | "path/filepath" 25 | "strings" 26 | ) 27 | 28 | // Expand is from https://github.com/lima-vm/lima/blob/v0.17.2/pkg/localpathutil/localpathutil.go#L11-L34 . 29 | // 30 | // Expand expands a path like "~", "~/", "~/foo". 31 | // Paths like "~foo/bar" are unsupported. 32 | // 33 | // FIXME: is there an existing library for this? 34 | func Expand(orig string) (string, error) { 35 | s := orig 36 | if s == "" { 37 | return "", errors.New("empty path") 38 | } 39 | homeDir, err := os.UserHomeDir() 40 | if err != nil { 41 | return "", err 42 | } 43 | 44 | if strings.HasPrefix(s, "~") { 45 | if s == "~" || strings.HasPrefix(s, "~/") { 46 | s = strings.Replace(s, "~", homeDir, 1) 47 | } else { 48 | // Paths like "~foo/bar" are unsupported. 49 | return "", fmt.Errorf("unexpandable path %q", orig) 50 | } 51 | } 52 | return filepath.Abs(s) 53 | } 54 | -------------------------------------------------------------------------------- /pkg/platformutil/platformutil.go: -------------------------------------------------------------------------------- 1 | package platformutil 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/containerd/platforms" 7 | ocispec "github.com/opencontainers/image-spec/specs-go/v1" 8 | ) 9 | 10 | func FormatSlice(ps []ocispec.Platform) string { 11 | ss := make([]string, len(ps)) 12 | for i := range ps { 13 | ss[i] = platforms.Format(ps[i]) 14 | } 15 | return fmt.Sprintf("%v", ss) 16 | } 17 | -------------------------------------------------------------------------------- /pkg/untar/tar.go: -------------------------------------------------------------------------------- 1 | // Forked from https://github.com/containerd/containerd/blob/v1.7.3/archive/tar.go . 2 | // This fork ignores permission errors. 3 | /* 4 | Copyright The containerd Authors. 5 | 6 | Licensed under the Apache License, Version 2.0 (the "License"); 7 | you may not use this file except in compliance with the License. 8 | You may obtain a copy of the License at 9 | 10 | http://www.apache.org/licenses/LICENSE-2.0 11 | 12 | Unless required by applicable law or agreed to in writing, software 13 | distributed under the License is distributed on an "AS IS" BASIS, 14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | See the License for the specific language governing permissions and 16 | limitations under the License. 17 | */ 18 | 19 | package untar 20 | 21 | import ( 22 | "archive/tar" 23 | "context" 24 | "fmt" 25 | "io" 26 | "os" 27 | "path/filepath" 28 | "strings" 29 | "time" 30 | _ "unsafe" 31 | 32 | "github.com/containerd/continuity/fs" 33 | "github.com/containerd/log" 34 | "github.com/opencontainers/go-digest" 35 | ) 36 | 37 | type EntryResult struct { 38 | Path string // path on filesystem 39 | Digest digest.Digest 40 | Finalizer func() error 41 | } 42 | 43 | // Entry untars a tar entry. 44 | // 45 | // Entry contains a portion from https://github.com/containerd/containerd/blob/v1.7.3/archive/tar.go#L159-L327 . 46 | func Entry(ctx context.Context, root string, hdr *tar.Header, r io.Reader) (*EntryResult, error) { 47 | if err := os.MkdirAll(root, 0755); err != nil { 48 | return nil, err 49 | } 50 | 51 | // Normalize name, for safety and for a simple is-root check 52 | hdr.Name = filepath.Clean(hdr.Name) 53 | 54 | // Split name and resolve symlinks for root directory. 55 | ppath, base := filepath.Split(hdr.Name) 56 | var err error 57 | ppath, err = fs.RootPath(root, ppath) 58 | if err != nil { 59 | return nil, fmt.Errorf("failed to get root path: %w", err) 60 | } 61 | 62 | // Join to root before joining to parent path to ensure relative links are 63 | // already resolved based on the root before adding to parent. 64 | path := filepath.Join(ppath, filepath.Join("/", base)) 65 | if path == root { 66 | log.G(ctx).Debugf("file %q ignored: resolved to root", hdr.Name) 67 | return &EntryResult{ 68 | Path: path, 69 | }, nil 70 | } 71 | 72 | // If file is not directly under root, ensure parent directory 73 | // exists or is created. 74 | if ppath != root { 75 | parentPath := ppath 76 | if base == "" { 77 | parentPath = filepath.Dir(path) 78 | } 79 | if err := mkparent(ctx, parentPath, root, nil); err != nil { 80 | return nil, err 81 | } 82 | } 83 | 84 | // If path exits we almost always just want to remove and replace it. 85 | // The only exception is when it is a directory *and* the file from 86 | // the layer is also a directory. Then we want to merge them (i.e. 87 | // just apply the metadata from the layer). 88 | if fi, err := os.Lstat(path); err == nil { 89 | if !(fi.IsDir() && hdr.Typeflag == tar.TypeDir) { 90 | if err := removeAllUnderRoot(path, root); err != nil { 91 | return nil, err 92 | } 93 | } 94 | } 95 | 96 | digester := digest.SHA256.Digester() 97 | hasher := digester.Hash() 98 | teeR := io.TeeReader(r, hasher) 99 | if err := createTarFile(ctx, path, root, hdr, teeR, true); err != nil { 100 | return nil, err 101 | } 102 | 103 | res := &EntryResult{ 104 | Path: path, 105 | Digest: digester.Digest(), 106 | } 107 | // Directory mtimes must be handled at the end to avoid further 108 | // file creation in them to modify the directory mtime 109 | if hdr.Typeflag == tar.TypeDir { 110 | res.Finalizer = func() error { 111 | return chtimes(path, boundTime(latestTime(hdr.AccessTime, hdr.ModTime)), boundTime(hdr.ModTime)) 112 | } 113 | } 114 | return res, nil 115 | } 116 | 117 | // removeAllUnderRoot is a 'safe' version of os.RemoveAll 118 | // it makes sure that we don't accidentally delete files on the host system. 119 | // it is not strictly necessary as all paths should be safe already 120 | // but serves as an insurance against programming mistakes upstream. 121 | func removeAllUnderRoot(path, root string) error { 122 | absPath, err := filepath.Abs(path) 123 | if err != nil { 124 | return fmt.Errorf("failed to get absolute path: %w", err) 125 | } 126 | absRoot, err := filepath.Abs(root) 127 | if err != nil { 128 | return fmt.Errorf("failed to get absolute root: %w", err) 129 | } 130 | 131 | rel, err := filepath.Rel(absRoot, absPath) 132 | if err != nil { 133 | return fmt.Errorf("failed to compute relative path: %w", err) 134 | } 135 | 136 | if absPath == "/" { 137 | return fmt.Errorf("refusing to delete root directory '/'") 138 | } 139 | if strings.HasPrefix(rel, "../") || rel == ".." || filepath.IsAbs(rel) { 140 | return fmt.Errorf("refusing to delete path outside root: %q", absPath) 141 | } 142 | 143 | return os.RemoveAll(path) 144 | } 145 | 146 | //go:linkname createTarFile github.com/containerd/containerd/archive.createTarFile 147 | func createTarFile(ctx context.Context, path, extractDir string, hdr *tar.Header, reader io.Reader, noSameOwner bool) error 148 | 149 | //go:linkname chtimes github.com/containerd/containerd/archive.chtimes 150 | func chtimes(path string, atime, mtime time.Time) error 151 | 152 | //go:linkname mkparent github.com/containerd/containerd/archive.mkparent 153 | func mkparent(ctx context.Context, path, root string, parents []string) error 154 | 155 | //go:linkname boundTime github.com/containerd/containerd/archive.boundTime 156 | func boundTime(t time.Time) time.Time 157 | 158 | //go:linkname latestTime github.com/containerd/containerd/archive.latestTime 159 | func latestTime(t1, t2 time.Time) time.Time 160 | --------------------------------------------------------------------------------