Repository: wagoodman/dive Branch: main Commit: d6c691947f8f Files: 170 Total size: 591.2 KB Directory structure: gitextract_iuihkmt4/ ├── .binny.yaml ├── .bouncer.yaml ├── .data/ │ ├── .dive-ci │ ├── Dockerfile.example │ ├── Dockerfile.minimal │ └── Dockerfile.test-image ├── .dockerignore ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ ├── actions/ │ │ └── bootstrap/ │ │ └── action.yaml │ ├── dependabot.yaml │ ├── scripts/ │ │ ├── coverage.py │ │ └── trigger-release.sh │ └── workflows/ │ ├── release.yaml │ └── validations.yaml ├── .gitignore ├── .golangci.yaml ├── .goreleaser.yaml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── RELEASE.md ├── Taskfile.yaml ├── cmd/ │ └── dive/ │ ├── cli/ │ │ ├── cli.go │ │ ├── cli_build_test.go │ │ ├── cli_ci_test.go │ │ ├── cli_config_test.go │ │ ├── cli_json_test.go │ │ ├── cli_load_test.go │ │ ├── cli_test.go │ │ ├── internal/ │ │ │ ├── command/ │ │ │ │ ├── adapter/ │ │ │ │ │ ├── analyzer.go │ │ │ │ │ ├── evaluator.go │ │ │ │ │ ├── exporter.go │ │ │ │ │ └── resolver.go │ │ │ │ ├── build.go │ │ │ │ ├── ci/ │ │ │ │ │ ├── evaluator.go │ │ │ │ │ ├── evaluator_test.go │ │ │ │ │ ├── rule.go │ │ │ │ │ └── rules.go │ │ │ │ ├── export/ │ │ │ │ │ ├── export.go │ │ │ │ │ ├── export_test.go │ │ │ │ │ ├── main_test.go │ │ │ │ │ └── testdata/ │ │ │ │ │ └── snapshots/ │ │ │ │ │ └── export_test.snap │ │ │ │ └── root.go │ │ │ ├── options/ │ │ │ │ ├── analysis.go │ │ │ │ ├── application.go │ │ │ │ ├── ci.go │ │ │ │ ├── ci_rules.go │ │ │ │ ├── export.go │ │ │ │ ├── ui.go │ │ │ │ ├── ui_diff.go │ │ │ │ ├── ui_filetree.go │ │ │ │ ├── ui_keybindings.go │ │ │ │ └── ui_layers.go │ │ │ └── ui/ │ │ │ ├── no_ui.go │ │ │ ├── v1/ │ │ │ │ ├── app/ │ │ │ │ │ ├── app.go │ │ │ │ │ ├── controller.go │ │ │ │ │ ├── job_control_other.go │ │ │ │ │ └── job_control_unix.go │ │ │ │ ├── config.go │ │ │ │ ├── format/ │ │ │ │ │ └── format.go │ │ │ │ ├── key/ │ │ │ │ │ ├── binding.go │ │ │ │ │ └── config.go │ │ │ │ ├── layout/ │ │ │ │ │ ├── area.go │ │ │ │ │ ├── compound/ │ │ │ │ │ │ └── layer_details_column.go │ │ │ │ │ ├── layout.go │ │ │ │ │ ├── location.go │ │ │ │ │ ├── manager.go │ │ │ │ │ └── manager_test.go │ │ │ │ ├── view/ │ │ │ │ │ ├── cursor.go │ │ │ │ │ ├── debug.go │ │ │ │ │ ├── filetree.go │ │ │ │ │ ├── filter.go │ │ │ │ │ ├── image_details.go │ │ │ │ │ ├── layer.go │ │ │ │ │ ├── layer_change_listener.go │ │ │ │ │ ├── layer_details.go │ │ │ │ │ ├── renderer.go │ │ │ │ │ ├── status.go │ │ │ │ │ └── views.go │ │ │ │ └── viewmodel/ │ │ │ │ ├── config.go │ │ │ │ ├── filetree.go │ │ │ │ ├── filetree_test.go │ │ │ │ ├── layer_compare.go │ │ │ │ ├── layer_selection.go │ │ │ │ ├── layer_set_state.go │ │ │ │ ├── layer_set_state_test.go │ │ │ │ └── testdata/ │ │ │ │ ├── TestFileShowAggregateChanges.txt │ │ │ │ ├── TestFileTreeDirCollapse.txt │ │ │ │ ├── TestFileTreeDirCollapseAll.txt │ │ │ │ ├── TestFileTreeDirCursorRight.txt │ │ │ │ ├── TestFileTreeFilterTree.txt │ │ │ │ ├── TestFileTreeGoCase.txt │ │ │ │ ├── TestFileTreeHideAddedRemovedModified.txt │ │ │ │ ├── TestFileTreeHideTypeWithFilter.txt │ │ │ │ ├── TestFileTreeHideUnmodified.txt │ │ │ │ ├── TestFileTreeNoAttributes.txt │ │ │ │ ├── TestFileTreePageDown.txt │ │ │ │ ├── TestFileTreePageUp.txt │ │ │ │ ├── TestFileTreeRestrictedHeight.txt │ │ │ │ └── TestFileTreeSelectLayer.txt │ │ │ └── v1.go │ │ └── testdata/ │ │ ├── config/ │ │ │ └── dive-ci-legacy.yaml │ │ ├── default-ci-config/ │ │ │ └── .dive-ci │ │ ├── dive-enable-ci.yaml │ │ ├── image-multi-layer-containerfile/ │ │ │ ├── Containerfile │ │ │ ├── dive-pass.yaml │ │ │ ├── example.md │ │ │ └── overwrite.md │ │ ├── image-multi-layer-dockerfile/ │ │ │ ├── Dockerfile │ │ │ ├── dive-fail.yaml │ │ │ ├── dive-pass.yaml │ │ │ ├── example.md │ │ │ └── overwrite.md │ │ ├── invalid/ │ │ │ └── Dockerfile │ │ └── snapshots/ │ │ ├── cli_build_test.snap │ │ ├── cli_ci_test.snap │ │ ├── cli_config_test.snap │ │ ├── cli_json_test.snap │ │ └── cli_load_test.snap │ └── main.go ├── dive/ │ ├── filetree/ │ │ ├── comparer.go │ │ ├── diff.go │ │ ├── efficiency.go │ │ ├── efficiency_test.go │ │ ├── file_info.go │ │ ├── file_node.go │ │ ├── file_node_test.go │ │ ├── file_tree.go │ │ ├── file_tree_test.go │ │ ├── node_data.go │ │ ├── node_data_test.go │ │ ├── order_strategy.go │ │ ├── path_error.go │ │ └── view_info.go │ ├── get_image_resolver.go │ └── image/ │ ├── analysis.go │ ├── docker/ │ │ ├── archive_resolver.go │ │ ├── build.go │ │ ├── build_test.go │ │ ├── cli.go │ │ ├── config.go │ │ ├── docker_host_unix.go │ │ ├── docker_host_windows.go │ │ ├── engine_resolver.go │ │ ├── image_archive.go │ │ ├── image_archive_analysis_test.go │ │ ├── layer.go │ │ ├── manifest.go │ │ └── testing.go │ ├── image.go │ ├── layer.go │ ├── podman/ │ │ ├── build.go │ │ ├── cli.go │ │ ├── resolver.go │ │ └── resolver_unsupported.go │ └── resolver.go ├── go.mod ├── go.sum └── internal/ ├── bus/ │ ├── bus.go │ ├── event/ │ │ ├── event.go │ │ ├── parser/ │ │ │ └── parsers.go │ │ └── payload/ │ │ ├── explore.go │ │ └── generic.go │ └── helpers.go ├── log/ │ └── log.go └── utils/ ├── format.go └── view.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .binny.yaml ================================================ tools: # we want to use a pinned version of binny to manage the toolchain (so binny manages itself!) - name: binny version: want: v0.9.0 method: github-release with: repo: anchore/binny # used for linting - name: golangci-lint version: want: v1.64.8 method: github-release with: repo: golangci/golangci-lint # used for showing the changelog at release - name: glow version: want: v2.1.0 method: github-release with: repo: charmbracelet/glow # used to release all artifacts - name: goreleaser version: want: v2.8.1 method: github-release with: repo: goreleaser/goreleaser # used at release to generate the changelog - name: chronicle version: want: v0.8.0 method: github-release with: repo: anchore/chronicle # used during static analysis for license compliance - name: bouncer version: want: v0.4.0 method: github-release with: repo: wagoodman/go-bouncer # used for running all local and CI tasks - name: task version: want: v3.42.1 method: github-release with: repo: go-task/task # used for triggering a release - name: gh version: want: v2.69.0 method: github-release with: repo: cli/cli ================================================ FILE: .bouncer.yaml ================================================ permit: - BSD.* - MIT.* - Apache.* - MPL.* - ISC - WTFPL - Unlicense ignore-packages: # crypto/internal/boring is released under the openSSL license as a part of the Golang Standard Library - crypto/internal/boring ================================================ FILE: .data/.dive-ci ================================================ --- plugins: - plugin1 rules: # If the efficiency is measured below X%, mark as failed. (expressed as a percentage between 0-1) lowestEfficiency: 0.95 # If the amount of wasted space is at least X or larger than X, mark as failed. (expressed in B, KB, MB, and GB) highestWastedBytes: 20Mb # If the amount of wasted space makes up for X% of the image, mark as failed. (fail if the threshold is met or crossed; expressed as a percentage between 0-1) highestUserWastedPercent: 0.5 plugin1/rule1: error ================================================ FILE: .data/Dockerfile.example ================================================ FROM busybox:latest ADD README.md /somefile.txt RUN mkdir -p /root/example/really/nested RUN cp /somefile.txt /root/example/somefile1.txt RUN chmod 444 /root/example/somefile1.txt RUN cp /somefile.txt /root/example/somefile2.txt RUN cp /somefile.txt /root/example/somefile3.txt RUN mv /root/example/somefile3.txt /root/saved.txt RUN cp /root/saved.txt /root/.saved.txt RUN rm -rf /root/example/ ADD .scripts/ /root/.data/ RUN cp /root/saved.txt /tmp/saved.again1.txt RUN cp /root/saved.txt /root/.data/saved.again2.txt RUN chmod +x /root/saved.txt RUN chmod 421 /root ================================================ FILE: .data/Dockerfile.minimal ================================================ FROM scratch COPY README.md /README.md ================================================ FILE: .data/Dockerfile.test-image ================================================ FROM busybox:latest ADD README.md /somefile.txt RUN mkdir -p /root/example/really/nested RUN cp /somefile.txt /root/example/somefile1.txt RUN chmod 444 /root/example/somefile1.txt RUN cp /somefile.txt /root/example/somefile2.txt RUN cp /somefile.txt /root/example/somefile3.txt RUN mv /root/example/somefile3.txt /root/saved.txt RUN cp /root/saved.txt /root/.saved.txt RUN rm -rf /root/example/ ADD .scripts/ /root/.data/ RUN cp /root/saved.txt /tmp/saved.again1.txt RUN cp /root/saved.txt /root/.data/saved.again2.txt RUN chmod +x /root/saved.txt ================================================ FILE: .dockerignore ================================================ /.git /.data /.cover /dist !/dist/dive_linux_amd64 /ui /internal/utils /image /cmd /build coverage.txt ================================================ FILE: .github/FUNDING.yml ================================================ github: ['wagoodman'] ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Something isn't working as expected title: '' labels: bug assignees: '' --- **What happened**: **What you expected to happen**: **How to reproduce it (as minimally and precisely as possible)**: **Anything else we need to know?**: **Environment**: - OS version - Docker version (if applicable) ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Got an idea for a new feature? Let us know! title: '' labels: enhancement assignees: '' --- **What would you like to be added**: **Why is this needed**: **Additional context**: ================================================ FILE: .github/actions/bootstrap/action.yaml ================================================ name: "Bootstrap" description: "Bootstrap all tools and dependencies" inputs: go-version: description: "Go version to install" required: true default: "1.24.x" cache-key-prefix: description: "Prefix all cache keys with this value" required: true default: "efa04b89c1b1" bootstrap-apt-packages: description: "Space delimited list of tools to install via apt" default: "" runs: using: "composite" steps: - uses: actions/setup-go@v5 with: go-version: ${{ inputs.go-version }} - name: Restore tool cache id: tool-cache uses: actions/cache@v4 with: path: ${{ github.workspace }}/.tmp key: ${{ inputs.cache-key-prefix }}-${{ runner.os }}-tool-${{ hashFiles('Makefile') }} - name: (cache-miss) Bootstrap project tools shell: bash if: steps.tool-cache.outputs.cache-hit != 'true' run: make tools - name: (cache-miss) Bootstrap go dependencies shell: bash if: steps.go-mod-cache.outputs.cache-hit != 'true' run: go mod download -x - name: Install apt packages if: inputs.bootstrap-apt-packages != '' shell: bash run: | DEBIAN_FRONTEND=noninteractive sudo apt update && sudo -E apt install -y ${{ inputs.bootstrap-apt-packages }} ================================================ FILE: .github/dependabot.yaml ================================================ version: 2 updates: - package-ecosystem: "gomod" directory: "/" schedule: interval: "weekly" - package-ecosystem: "docker" directory: "/" schedule: interval: "weekly" - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" ================================================ FILE: .github/scripts/coverage.py ================================================ #!/usr/bin/env python3 import subprocess import sys import shlex class bcolors: HEADER = '\033[95m' OKBLUE = '\033[94m' OKCYAN = '\033[96m' OKGREEN = '\033[92m' WARNING = '\033[93m' FAIL = '\033[91m' ENDC = '\033[0m' BOLD = '\033[1m' UNDERLINE = '\033[4m' if len(sys.argv) < 3: print("Usage: coverage.py [threshold] [go-coverage-report]") sys.exit(1) threshold = float(sys.argv[1]) report = sys.argv[2] args = shlex.split(f"go tool cover -func {report}") p = subprocess.run(args, capture_output=True, text=True) percent_coverage = float(p.stdout.splitlines()[-1].split()[-1].replace("%", "")) print(f"{bcolors.BOLD}Coverage: {percent_coverage}%{bcolors.ENDC}") if percent_coverage < threshold: print(f"{bcolors.BOLD}{bcolors.FAIL}Coverage below threshold of {threshold}%{bcolors.ENDC}") sys.exit(1) ================================================ FILE: .github/scripts/trigger-release.sh ================================================ #!/usr/bin/env bash set -eu bold=$(tput bold) normal=$(tput sgr0) if ! [ -x "$(command -v gh)" ]; then echo "The GitHub CLI could not be found. To continue follow the instructions at https://github.com/cli/cli#installation" exit 1 fi gh auth status # we need all of the git state to determine the next version. Since tagging is done by # the release pipeline it is possible to not have all of the tags from previous releases. git fetch --tags # populates the CHANGELOG.md and VERSION files echo "${bold}Generating changelog...${normal}" make changelog 2> /dev/null NEXT_VERSION=$(cat VERSION) if [[ "$NEXT_VERSION" == "" || "${NEXT_VERSION}" == "(Unreleased)" ]]; then echo "Could not determine the next version to release. Exiting..." exit 1 fi while true; do read -p "${bold}Do you want to trigger a release for version '${NEXT_VERSION}'?${normal} [y/n] " yn case $yn in [Yy]* ) echo; break;; [Nn]* ) echo; echo "Cancelling release..."; exit;; * ) echo "Please answer yes or no.";; esac done echo "${bold}Kicking off release for ${NEXT_VERSION}${normal}..." echo gh workflow run release.yaml -f version=${NEXT_VERSION} echo echo "${bold}Waiting for release to start...${normal}" sleep 10 set +e echo "${bold}Head to the release workflow to monitor the release:${normal} $(gh run list --workflow=release.yaml --limit=1 --json url --jq '.[].url')" id=$(gh run list --workflow=release.yaml --limit=1 --json databaseId --jq '.[].databaseId') gh run watch $id --exit-status || (echo ; echo "${bold}Logs of failed step:${normal}" && GH_PAGER="" gh run view $id --log-failed) ================================================ FILE: .github/workflows/release.yaml ================================================ name: "Release" on: workflow_dispatch: inputs: version: description: tag the latest commit on main with the given version (prefixed with v) required: true jobs: quality-gate: environment: release runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Check if tag already exists # note: this will fail if the tag already exists run: | [[ "${{ github.event.inputs.version }}" == v* ]] || (echo "version '${{ github.event.inputs.version }}' does not have a 'v' prefix" && exit 1) git tag ${{ github.event.inputs.version }} - name: Check static analysis results uses: fountainhead/action-wait-for-check@v1.2.0 id: static-analysis with: token: ${{ secrets.GITHUB_TOKEN }} # This check name is defined as the github action job name (in .github/workflows/validations.yaml) checkName: "Static analysis" ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Check unit test results uses: fountainhead/action-wait-for-check@v1.2.0 id: unit with: token: ${{ secrets.GITHUB_TOKEN }} # This check name is defined as the github action job name (in .github/workflows/validations.yaml) checkName: "Unit tests (ubuntu-latest)" ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Check acceptance test results (linux) uses: fountainhead/action-wait-for-check@v1.2.0 id: acceptance-linux with: token: ${{ secrets.GITHUB_TOKEN }} # This check name is defined as the github action job name (in .github/workflows/validations.yaml) checkName: "Acceptance tests (Linux)" ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Check acceptance test results (mac) uses: fountainhead/action-wait-for-check@v1.2.0 id: acceptance-mac with: token: ${{ secrets.GITHUB_TOKEN }} # This check name is defined as the github action job name (in .github/workflows/validations.yaml) checkName: "Acceptance tests (Mac)" ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Check acceptance test results (windows) uses: fountainhead/action-wait-for-check@v1.2.0 id: acceptance-windows with: token: ${{ secrets.GITHUB_TOKEN }} # This check name is defined as the github action job name (in .github/workflows/validations.yaml) checkName: "Acceptance tests (Windows)" ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Quality gate if: steps.static-analysis.outputs.conclusion != 'success' || steps.unit.outputs.conclusion != 'success' || steps.acceptance-linux.outputs.conclusion != 'success' || steps.acceptance-mac.outputs.conclusion != 'success' || steps.acceptance-windows.outputs.conclusion != 'success' run: | echo "Static Analysis Status: ${{ steps.static-analysis.conclusion }}" echo "Unit Test Status: ${{ steps.unit.outputs.conclusion }}" echo "Acceptance Test (Linux) Status: ${{ steps.acceptance-linux.outputs.conclusion }}" echo "Acceptance Test (Mac) Status: ${{ steps.acceptance-mac.outputs.conclusion }}" echo "Acceptance Test (Windows) Status: ${{ steps.acceptance-windows.outputs.conclusion }}" false release: needs: [quality-gate] runs-on: ubuntu-latest permissions: # for tagging contents: write # for pushing container images packages: write steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Bootstrap environment uses: ./.github/actions/bootstrap - name: Login to Docker Hub uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 #v3.4.0 with: registry: docker.io username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Login to GitHub Container Registry uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 #v3.4.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Tag release run: | git tag ${{ github.event.inputs.version }} git push origin --tags env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Build & publish release artifacts run: make ci-release env: # for creating the release (requires write access to content) GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # for updating brew formula in wagoodman/homebrew-dive TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} - name: Smoke test published image run: make ci-test-docker-image ================================================ FILE: .github/workflows/validations.yaml ================================================ name: "Validations" on: workflow_dispatch: push: branches: - main pull_request: jobs: Static-Analysis: # Note: changing this job name requires making the same update in the .github/workflows/release.yaml pipeline name: "Static analysis" runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Bootstrap environment uses: ./.github/actions/bootstrap - name: Run static analysis run: make static-analysis Unit-Test: # Note: changing this job name requires making the same update in the .github/workflows/release.yaml pipeline name: "Unit tests" strategy: matrix: platform: - ubuntu-latest # - macos-latest # todo: mac runners are expensive minute-wise # - windows-latest # todo: support windows runs-on: ${{ matrix.platform }} steps: - uses: actions/checkout@v4 - name: Bootstrap environment uses: ./.github/actions/bootstrap - name: Run unit tests run: make unit Build-Snapshot-Artifacts: name: "Build snapshot artifacts" runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Bootstrap environment uses: ./.github/actions/bootstrap - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build snapshot artifacts run: make snapshot - run: docker images wagoodman/dive # todo: compare against known json output in shared volume - name: Test production image run: make ci-test-docker-image # why not use actions/upload-artifact? It is very slow (3 minutes to upload ~600MB of data, vs 10 seconds with this approach). # see https://github.com/actions/upload-artifact/issues/199 for more info - name: Upload snapshot artifacts uses: actions/cache/save@v4 with: path: snapshot key: snapshot-build-${{ github.run_id }} # ... however the cache trick doesn't work on windows :( - uses: actions/upload-artifact@v4 with: name: windows-artifacts path: snapshot/dive_windows_amd64_v1/dive.exe Acceptance-Linux: name: "Acceptance tests (Linux)" needs: [Build-Snapshot-Artifacts] runs-on: ubuntu-latest steps: - uses: actions/checkout@master - name: Download snapshot build uses: actions/cache/restore@v4 with: path: snapshot key: snapshot-build-${{ github.run_id }} - name: Test linux run run: make ci-test-linux-run - name: Test DEB package installation run: make ci-test-deb-package-install - name: Test RPM package installation run: make ci-test-rpm-package-install Acceptance-Mac: name: "Acceptance tests (Mac)" needs: [Build-Snapshot-Artifacts] runs-on: macos-latest steps: - uses: actions/checkout@master - name: Download snapshot build uses: actions/cache/restore@v4 with: path: snapshot key: snapshot-build-${{ github.run_id }} - name: Test darwin run run: make ci-test-mac-run Acceptance-Windows: name: "Acceptance tests (Windows)" needs: [Build-Snapshot-Artifacts] runs-on: windows-latest steps: - uses: actions/checkout@master - uses: actions/download-artifact@v4 with: name: windows-artifacts - name: Test windows run run: make ci-test-windows-run ================================================ FILE: .gitignore ================================================ # app configs .dive.yaml # misc /.image *.log CHANGELOG.md VERSION # IDEs /.idea /.vscode # tooling /bin /.tool-versions /.tmp /.tool /.mise.toml /.task /go.work /go.work.sum # builds /dist /snapshot # testing .cover coverage.txt # Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib # Test binary, build with `go test -c` *.test # Output of the go coverage tool, specifically when used with LiteIDE *.out /tmp /build /_vendor* /vendor ================================================ FILE: .golangci.yaml ================================================ # TODO: enable this when we have coverage on docstring comments #issues: # # The list of ids of default excludes to include or disable. # include: # - EXC0002 # disable excluding of issues about comments from golint linters-settings: funlen: # Checks the number of lines in a function. # If lower than 0, disable the check. # Default: 60 # TODO: drop this down over time... lines: 110 # Checks the number of statements in a function. # If lower than 0, disable the check. # Default: 40 statements: 60 # TODO: use the default linters for now, but include these over time #linters: # # inverted configuration with `enable-all` and `disable` is not scalable during updates of golangci-lint # disable-all: true # enable: # - asciicheck # - bodyclose # - depguard # - dogsled # - dupl # - errcheck # - exportloopref # - funlen # - gocognit # - goconst # - gocritic # - gocyclo # - gofmt # - goimports # - goprintffuncname # - gosec # - gosimple # - govet # - ineffassign # - misspell # - nakedret # - nolintlint # - revive # - staticcheck # - stylecheck # - typecheck # - unconvert # - unparam # - unused # - whitespace # do not enable... # - gochecknoglobals # - gochecknoinits # this is too aggressive # - godot # - godox # - goerr113 # - golint # deprecated # - gomnd # this is too aggressive # - interfacer # this is a good idea, but is no longer supported and is prone to false positives # - lll # without a way to specify per-line exception cases, this is not usable # - maligned # this is an excellent linter, but tricky to optimize and we are not sensitive to memory layout optimizations # - nestif # - prealloc # following this rule isn't consistently a good idea, as it sometimes forces unnecessary allocations that result in less idiomatic code # - scopelint # deprecated # - testpackage # - wsl # this doesn't have an auto-fixer yet and is pretty noisy (https://github.com/bombsimon/wsl/issues/90) # - varcheck # deprecated (since v1.49.0) due to: The owner seems to have abandoned the linter. Replaced by unused. # - deadcode # deprecated (since v1.49.0) due to: The owner seems to have abandoned the linter. Replaced by unused. # - structcheck # deprecated (since v1.49.0) due to: The owner seems to have abandoned the linter. Replaced by unused. # - rowserrcheck # we're not using sql.Rows at all in the codebase ================================================ FILE: .goreleaser.yaml ================================================ version: 2 release: # If set to auto, will mark the release as not ready for production in case there is an indicator for this in the # tag e.g. v1.0.0-rc1 .If set to true, will mark the release as not ready for production. prerelease: auto # If set to true, will not auto-publish the release. This is done to allow us to review the changelog before publishing. draft: false env: # required to support multi architecture docker builds - DOCKER_CLI_EXPERIMENTAL=enabled - CGO_ENABLED=0 builds: - binary: dive dir: ./cmd/dive env: - CGO_ENABLED=0 goos: - windows - darwin - linux goarch: - amd64 - arm64 - ppc64le ldflags: -w -s -extldflags '-static' -X main.version={{.Version}} -X main.gitCommit={{.Commit}} -X main.buildDate={{.Date}} -X main.gitDescription={{.Summary}} brews: - repository: owner: wagoodman name: homebrew-dive token: "{{.Env.TAP_GITHUB_TOKEN}}" homepage: &project_url "https://github.com/wagoodman/dive/" description: &description "A tool for exploring layers in a docker image" archives: - format: tar.gz format_overrides: - goos: windows format: zip nfpms: - license: MIT maintainer: Alex Goodman homepage: *project_url description: *description formats: - rpm - deb dockers: # docker.io amd64 - &dockerhub_amd64 id: docker-amd64 ids: - dive use: buildx goarch: amd64 image_templates: - docker.io/wagoodman/dive:latest - docker.io/wagoodman/dive:v{{.Version}}-amd64 build_flag_templates: - "--build-arg=DOCKER_CLI_VERSION={{.Env.DOCKER_CLI_VERSION}}" - "--platform=linux/amd64" - "--label=org.opencontainers.image.created={{.Date}}" - "--label=org.opencontainers.image.title={{.ProjectName}}" - "--label=org.opencontainers.image.description=A tool for exploring layers in a docker image" - "--label=org.opencontainers.image.url={{.GitURL}}" - "--label=org.opencontainers.image.source={{.GitURL}}" - "--label=org.opencontainers.image.version={{.Version}}" - "--label=org.opencontainers.image.revision={{.FullCommit}}" - "--label=org.opencontainers.image.licenses=MIT" - "--label=org.opencontainers.image.authors=Alex Goodman <@wagoodman>" # docker.io arm64 - &dockerhub_arm64 id: docker-arm64 ids: - dive use: buildx goarch: arm64 image_templates: - docker.io/wagoodman/dive:v{{.Version}}-arm64 build_flag_templates: - "--build-arg=DOCKER_CLI_VERSION={{.Env.DOCKER_CLI_VERSION}}" - "--platform=linux/arm64/v8" - "--label=org.opencontainers.image.created={{.Date}}" - "--label=org.opencontainers.image.title={{.ProjectName}}" - "--label=org.opencontainers.image.description=A tool for exploring layers in a docker image" - "--label=org.opencontainers.image.url={{.GitURL}}" - "--label=org.opencontainers.image.source={{.GitURL}}" - "--label=org.opencontainers.image.version={{.Version}}" - "--label=org.opencontainers.image.revision={{.FullCommit}}" - "--label=org.opencontainers.image.licenses=MIT" - "--label=org.opencontainers.image.authors=Alex Goodman <@wagoodman>" # ghcr.io amd64 - id: ghcr-amd64 <<: *dockerhub_amd64 image_templates: - ghcr.io/wagoodman/dive:v{{.Version}}-amd64 # ghcr.io arm64 - id: ghcr-arm64 <<: *dockerhub_arm64 image_templates: - ghcr.io/wagoodman/dive:v{{.Version}}-arm64 docker_manifests: # docker.io manifests - name_template: docker.io/wagoodman/dive:latest image_templates: &dockerhub_images - docker.io/wagoodman/dive:v{{.Version}}-amd64 - docker.io/wagoodman/dive:v{{.Version}}-arm64 - name_template: docker.io/wagoodman/dive:v{{.Major}} image_templates: *dockerhub_images - name_template: docker.io/wagoodman/dive:v{{.Major}}.{{.Minor}} image_templates: *dockerhub_images - name_template: docker.io/wagoodman/dive:v{{.Version}} image_templates: *dockerhub_images # ghcr.io manifests - name_template: ghcr.io/wagoodman/dive:latest image_templates: &ghcr_images - ghcr.io/wagoodman/dive:v{{.Version}}-amd64 - ghcr.io/wagoodman/dive:v{{.Version}}-arm64 - name_template: ghcr.io/wagoodman/dive:v{{.Major}} image_templates: *ghcr_images - name_template: ghcr.io/wagoodman/dive:v{{.Major}}.{{.Minor}} image_templates: *ghcr_images - name_template: ghcr.io/wagoodman/dive:v{{.Version}} image_templates: *ghcr_images ================================================ FILE: Dockerfile ================================================ FROM alpine:3.21 AS base ARG DOCKER_CLI_VERSION=${DOCKER_CLI_VERSION} RUN wget -O- https://download.docker.com/linux/static/stable/$(uname -m)/docker-${DOCKER_CLI_VERSION}.tgz | \ tar -xzf - docker/docker --strip-component=1 -C /usr/local/bin COPY dive /usr/local/bin/ # though we could make this a multi-stage image and copy the binary to scratch, this image is small enough # and users are expecting to be able to exec into it ENTRYPOINT ["/usr/local/bin/dive"] ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2018 Alex Goodman Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ OWNER = wagoodman PROJECT = dive TOOL_DIR = .tool BINNY = $(TOOL_DIR)/binny TASK = $(TOOL_DIR)/task .DEFAULT_GOAL := make-default ## Bootstrapping targets ################################# # note: we need to assume that binny and task have not already been installed $(BINNY): @mkdir -p $(TOOL_DIR) @curl -sSfL https://raw.githubusercontent.com/anchore/binny/main/install.sh | sh -s -- -b $(TOOL_DIR) # note: we need to assume that binny and task have not already been installed .PHONY: task $(TASK) task: $(BINNY) @$(BINNY) install task -q # this is a bootstrapping catch-all, where if the target doesn't exist, we'll ensure the tools are installed and then try again %: @make --silent $(TASK) @$(TASK) $@ ## Shim targets ################################# .PHONY: make-default make-default: $(TASK) @# run the default task in the taskfile @$(TASK) # for those of us that can't seem to kick the habit of typing `make ...` lets wrap the superior `task` tool TASKS := $(shell bash -c "test -f $(TASK) && $(TASK) -l | grep '^\* ' | cut -d' ' -f2 | tr -d ':' | tr '\n' ' '" ) $(shell bash -c "test -f $(TASK) && $(TASK) -l | grep 'aliases:' | cut -d ':' -f 3 | tr '\n' ' ' | tr -d ','") .PHONY: $(TASKS) $(TASKS): $(TASK) @$(TASK) $@ ## actual targets ci-test-windows-run: dive.exe --source docker-archive .data/test-docker-image.tar --ci --ci-config .data/.dive-ci help: $(TASK) @$(TASK) -l ================================================ FILE: README.md ================================================ # dive [![GitHub release](https://img.shields.io/github/release/wagoodman/dive.svg)](https://github.com/wagoodman/dive/releases/latest) [![Validations](https://github.com/wagoodman/dive/actions/workflows/validations.yaml/badge.svg)](https://github.com/wagoodman/dive/actions/workflows/validations.yaml) [![Go Report Card](https://goreportcard.com/badge/github.com/wagoodman/dive)](https://goreportcard.com/report/github.com/wagoodman/dive) [![License: MIT](https://img.shields.io/badge/License-MIT%202.0-blue.svg)](https://github.com/wagoodman/dive/blob/main/LICENSE) [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg?style=flat)](https://www.paypal.me/wagoodman) **A tool for exploring a Docker image, layer contents, and discovering ways to shrink the size of your Docker/OCI image.** ![Image](.data/demo.gif) To analyze a Docker image simply run dive with an image tag/id/digest: ```bash dive ``` or you can dive with Docker directly: ``` alias dive="docker run -ti --rm -v /var/run/docker.sock:/var/run/docker.sock docker.io/wagoodman/dive" dive # for example dive nginx:latest ``` or if you want to build your image then jump straight into analyzing it: ```bash dive build -t . ``` Building on macOS (supporting only the Docker container engine): ```bash docker run --rm -it \ -v /var/run/docker.sock:/var/run/docker.sock \ -v "$(pwd)":"$(pwd)" \ -w "$(pwd)" \ -v "$HOME/.dive.yaml":"$HOME/.dive.yaml" \ docker.io/wagoodman/dive:latest build -t . ``` Additionally you can run this in your CI pipeline to ensure you're keeping wasted space to a minimum (this skips the UI): ``` CI=true dive ``` ![Image](.data/demo-ci.png) **This is beta quality!** *Feel free to submit an issue if you want a new feature or find a bug :)* ## Basic Features **Show Docker image contents broken down by layer** As you select a layer on the left, you are shown the contents of that layer combined with all previous layers on the right. Also, you can fully explore the file tree with the arrow keys. **Indicate what's changed in each layer** Files that have changed, been modified, added, or removed are indicated in the file tree. This can be adjusted to show changes for a specific layer, or aggregated changes up to this layer. **Estimate "image efficiency"** The lower left pane shows basic layer info and an experimental metric that will guess how much wasted space your image contains. This might be from duplicating files across layers, moving files across layers, or not fully removing files. Both a percentage "score" and total wasted file space is provided. **Quick build/analysis cycles** You can build a Docker image and do an immediate analysis with one command: `dive build -t some-tag .` You only need to replace your `docker build` command with the same `dive build` command. **CI Integration** Analyze an image and get a pass/fail result based on the image efficiency and wasted space. Simply set `CI=true` in the environment when invoking any valid dive command. **Multiple Image Sources and Container Engines Supported** With the `--source` option, you can select where to fetch the container image from: ```bash dive --source ``` or ```bash dive :// ``` With valid `source` options as such: - `docker`: Docker engine (the default option) - `docker-archive`: A Docker Tar Archive from disk - `podman`: Podman engine (linux only) ## Installation **Ubuntu/Debian** Using debs: ```bash DIVE_VERSION=$(curl -sL "https://api.github.com/repos/wagoodman/dive/releases/latest" | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\1/') curl -fOL "https://github.com/wagoodman/dive/releases/download/v${DIVE_VERSION}/dive_${DIVE_VERSION}_linux_amd64.deb" sudo apt install ./dive_${DIVE_VERSION}_linux_amd64.deb ``` Using snap: ```bash sudo snap install docker sudo snap install dive sudo snap connect dive:docker-executables docker:docker-executables sudo snap connect dive:docker-daemon docker:docker-daemon ``` > [!CAUTION] > The Snap method is not recommended if you installed Docker via `apt-get`, since it might break your existing Docker daemon. > > See also: https://github.com/wagoodman/dive/issues/546 **RHEL/Centos** ```bash DIVE_VERSION=$(curl -sL "https://api.github.com/repos/wagoodman/dive/releases/latest" | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\1/') curl -fOL "https://github.com/wagoodman/dive/releases/download/v${DIVE_VERSION}/dive_${DIVE_VERSION}_linux_amd64.rpm" rpm -i dive_${DIVE_VERSION}_linux_amd64.rpm ``` **Arch Linux** Available in the [extra repository](https://archlinux.org/packages/extra/x86_64/dive/) and can be installed via [pacman](https://wiki.archlinux.org/title/Pacman): ```bash pacman -S dive ``` **Mac** If you use [Homebrew](https://brew.sh): ```bash brew install dive ``` If you use [MacPorts](https://www.macports.org): ```bash sudo port install dive ``` Or download the latest Darwin build from the [releases page](https://github.com/wagoodman/dive/releases/latest). **Windows** If you use [Chocolatey](https://chocolatey.org) ```powershell choco install dive ``` If you use [scoop](https://scoop.sh/) ```powershell scoop install main/dive ``` If you use [winget](https://learn.microsoft.com/en-gb/windows/package-manager/): ```powershell winget install --id wagoodman.dive ``` Or download the latest Windows build from the [releases page](https://github.com/wagoodman/dive/releases/latest). **Go tools** Requires Go version 1.10 or higher. ```bash go install github.com/wagoodman/dive@latest ``` *Note*: installing in this way you will not see a proper version when running `dive -v`. **Nix/NixOS** On NixOS: ```bash nix-env -iA nixos.dive ``` On non-NixOS (Linux, Mac) ```bash nix-env -iA nixpkgs.dive ``` **X-CMD** [x-cmd](https://www.x-cmd.com/) is a **toolbox for Posix Shell**, offering a lightweight package manager built using shell and awk. ```sh x env use dive ``` **Docker** ```bash docker pull docker.io/wagoodman/dive # or alternatively docker pull ghcr.io/wagoodman/dive ``` When running you'll need to include the Docker socket file: ```bash docker run --rm -it \ -v /var/run/docker.sock:/var/run/docker.sock \ docker.io/wagoodman/dive:latest ``` Docker for Windows (showing PowerShell compatible line breaks; collapse to a single line for Command Prompt compatibility) ```bash docker run --rm -it ` -v /var/run/docker.sock:/var/run/docker.sock ` docker.io/wagoodman/dive:latest ``` **Note:** depending on the version of docker you are running locally you may need to specify the docker API version as an environment variable: ```bash DOCKER_API_VERSION=1.37 dive ... ``` or if you are running with a docker image: ```bash docker run --rm -it \ -v /var/run/docker.sock:/var/run/docker.sock \ -e DOCKER_API_VERSION=1.37 \ docker.io/wagoodman/dive:latest ``` if you are using an alternative runtime (Colima etc) then you may need to specify the docker host as an environment variable in order to pull local images: ```bash export DOCKER_HOST=$(docker context inspect -f '{{ .Endpoints.docker.Host }}') ``` ## CI Integration When running dive with the environment variable `CI=true` then the dive UI will be bypassed and will instead analyze your docker image, giving it a pass/fail indication via return code. Currently there are three metrics supported via a `.dive-ci` file that you can put at the root of your repo: ``` rules: # If the efficiency is measured below X%, mark as failed. # Expressed as a ratio between 0-1. lowestEfficiency: 0.95 # If the amount of wasted space is at least X or larger than X, mark as failed. # Expressed in B, KB, MB, and GB. highestWastedBytes: 20MB # If the amount of wasted space makes up for X% or more of the image, mark as failed. # Note: the base image layer is NOT included in the total image size. # Expressed as a ratio between 0-1; fails if the threshold is met or crossed. highestUserWastedPercent: 0.20 ``` You can override the CI config path with the `--ci-config` option. ## KeyBindings Key Binding | Description -------------------------------------------|--------------------------------------------------------- Ctrl + C or Q | Exit Tab | Switch between the layer and filetree views Ctrl + F | Filter files ESC | Close filter files PageUp or U | Scroll up a page PageDown or D | Scroll down a page Up or K | Move up one line within a page Down or J | Move down one line within a page Ctrl + A | Layer view: see aggregated image modifications Ctrl + L | Layer view: see current layer modifications Space | Filetree view: collapse/uncollapse a directory Ctrl + Space | Filetree view: collapse/uncollapse all directories Ctrl + A | Filetree view: show/hide added files Ctrl + R | Filetree view: show/hide removed files Ctrl + M | Filetree view: show/hide modified files Ctrl + U | Filetree view: show/hide unmodified files Ctrl + B | Filetree view: show/hide file attributes PageUp or U | Filetree view: scroll up a page PageDown or D | Filetree view: scroll down a page ## UI Configuration No configuration is necessary, however, you can create a config file and override values: ```yaml # supported options are "docker" and "podman" container-engine: docker # continue with analysis even if there are errors parsing the image archive ignore-errors: false log: enabled: true path: ./dive.log level: info # Note: you can specify multiple bindings by separating values with a comma. # Note: UI hinting is derived from the first binding keybinding: # Global bindings quit: ctrl+c toggle-view: tab filter-files: ctrl+f, ctrl+slash close-filter-files: esc up: up,k down: down,j left: left,h right: right,l # Layer view specific bindings compare-all: ctrl+a compare-layer: ctrl+l # File view specific bindings toggle-collapse-dir: space toggle-collapse-all-dir: ctrl+space toggle-added-files: ctrl+a toggle-removed-files: ctrl+r toggle-modified-files: ctrl+m toggle-unmodified-files: ctrl+u toggle-filetree-attributes: ctrl+b page-up: pgup,u page-down: pgdn,d diff: # You can change the default files shown in the filetree (right pane). All diff types are shown by default. hide: - added - removed - modified - unmodified filetree: # The default directory-collapse state collapse-dir: false # The percentage of screen width the filetree should take on the screen (must be >0 and <1) pane-width: 0.5 # Show the file attributes next to the filetree show-attributes: true layer: # Enable showing all changes from this layer and every previous layer show-aggregated-changes: false ``` dive will search for configs in the following locations: - `$XDG_CONFIG_HOME/dive/*.yaml` - `$XDG_CONFIG_DIRS/dive/*.yaml` - `~/.config/dive/*.yaml` - `~/.dive.yaml` `.yml` can be used instead of `.yaml` if desired. ================================================ FILE: RELEASE.md ================================================ # Release process ## Creating a release **Trigger a new release with `make release`**. At this point you'll see a preview changelog in the terminal. If you're happy with the changelog, press `y` to continue, otherwise you can abort and adjust the labels on the PRs and issues to be included in the release and re-run the release trigger command. ## Retracting a release If a release is found to be problematic, it can be retracted with the following steps: - Deleting the GitHub Release - Untag the docker images in the `docker.io` registry - Revert the brew formula in [`wagoodman/homebrew-dive`](https://github.com/wagoodman/homebrew-dive) to point to the previous release - Add a new `retract` entry in the go.mod for the versioned release **Note**: do not delete release tags from the git repository since there may already be references to the release in the go proxy, which will cause confusion when trying to reuse the tag later (the H1 hash will not match and there will be a warning when users try to pull the new release). ================================================ FILE: Taskfile.yaml ================================================ version: "3" vars: OWNER: wagoodman PROJECT: dive # static file dirs TOOL_DIR: .tool TMP_DIR: .tmp # TOOLS BINNY: "{{ .TOOL_DIR }}/binny" CHRONICLE: "{{ .TOOL_DIR }}/chronicle" GORELEASER: "{{ .TOOL_DIR }}/goreleaser" GOLANGCI_LINT: "{{ .TOOL_DIR }}/golangci-lint" TASK: "{{ .TOOL_DIR }}/task" BOUNCER: "{{ .TOOL_DIR }}/bouncer" GLOW: "{{ .TOOL_DIR }}/glow" # used for changelog generation CHANGELOG: CHANGELOG.md NEXT_VERSION: VERSION # note: the snapshot dir must be a relative path starting with ./ SNAPSHOT_DIR: ./snapshot SNAPSHOT_CMD: "{{ .GORELEASER }} release --config {{ .TMP_DIR }}/goreleaser.yaml --clean --snapshot --skip=publish --skip=sign" BUILD_CMD: "{{ .GORELEASER }} build --config {{ .TMP_DIR }}/goreleaser.yaml --clean --snapshot --single-target" RELEASE_CMD: "{{ .GORELEASER }} release --clean --release-notes {{ .CHANGELOG }}" VERSION: sh: git describe --dirty --always --tags # used for acceptance tests TEST_IMAGE: busybox:latest DOCKER_CLI_VERSION: 28.0.0 env: GNUMAKEFLAGS: '--no-print-directory' DOCKER_CLI_VERSION: "{{ .DOCKER_CLI_VERSION }}" tasks: ## High-level tasks ################################# default: desc: Run all validation tasks aliases: - pr-validations - validations cmds: - task: static-analysis - task: test static-analysis: desc: Run all static analysis tasks cmds: - task: check-go-mod-tidy - task: check-licenses - task: lint test: desc: Run all levels of test cmds: - task: unit - task: cli ## Bootstrap tasks ################################# binny: internal: true # desc: Get the binny tool generates: - "{{ .BINNY }}" status: - "test -f {{ .BINNY }}" cmd: "curl -sSfL https://raw.githubusercontent.com/anchore/binny/main/install.sh | sh -s -- -b .tool" silent: true tools: desc: Install all tools needed for CI and local development deps: [binny] aliases: - bootstrap generates: - ".binny.yaml" - "{{ .TOOL_DIR }}/*" status: - "{{ .BINNY }} check -v" cmd: "{{ .BINNY }} install -v" silent: true update-tools: desc: Update pinned versions of all tools to their latest available versions deps: [binny] generates: - ".binny.yaml" - "{{ .TOOL_DIR }}/*" cmd: "{{ .BINNY }} update -v" silent: true list-tools: desc: List all tools needed for CI and local development deps: [binny] cmd: "{{ .BINNY }} list" silent: true list-tool-updates: desc: List all tools that are not up to date relative to the binny config deps: [binny] cmd: "{{ .BINNY }} list --updates" silent: true tmpdir: silent: true generates: - "{{ .TMP_DIR }}" cmd: "mkdir -p {{ .TMP_DIR }}" ## Static analysis tasks ################################# format: desc: Auto-format all source code deps: [tools] cmds: - gofmt -w -s . - go mod tidy lint-fix: desc: Auto-format all source code + run golangci lint fixers deps: [tools] cmds: - task: format - "{{ .GOLANGCI_LINT }} run --tests=false --fix" lint: desc: Run gofmt + golangci lint checks vars: BAD_FMT_FILES: sh: gofmt -l -s . BAD_FILE_NAMES: sh: "find . | grep -e ':' || true" deps: [tools] cmds: # ensure there are no go fmt differences - cmd: 'test -z "{{ .BAD_FMT_FILES }}" || (echo "files with gofmt issues: [{{ .BAD_FMT_FILES }}]"; exit 1)' silent: true # ensure there are no files with ":" in it (a known back case in the go ecosystem) - cmd: 'test -z "{{ .BAD_FILE_NAMES }}" || (echo "files with bad names: [{{ .BAD_FILE_NAMES }}]"; exit 1)' silent: true # run linting - "{{ .GOLANGCI_LINT }} run --tests=false" check-licenses: # desc: Ensure transitive dependencies are compliant with the current license policy deps: [tools] cmd: "{{ .BOUNCER }} check ./..." check-go-mod-tidy: # desc: Ensure go.mod and go.sum are up to date cmds: - cmd: | if ! go mod tidy -diff; then echo "go.mod and/or go.sum need updates. Please run 'go mod tidy'" exit 1 fi silent: true ## Testing tasks ################################# unit: desc: Run unit tests deps: - tmpdir vars: TEST_PKGS: sh: "go list ./... | grep -v '^github.com/wagoodman/dive/cmd/dive/cli$' | tr '\n' ' '" # unit test coverage threshold (in % coverage) COVERAGE_THRESHOLD: 25 cmds: - "go test -coverprofile {{ .TMP_DIR }}/unit-coverage-details.txt {{ .TEST_PKGS }}" - cmd: ".github/scripts/coverage.py {{ .COVERAGE_THRESHOLD }} {{ .TMP_DIR }}/unit-coverage-details.txt" silent: true cli: desc: Run CLI tests cmds: - "go test github.com/wagoodman/dive/cmd/dive/cli -v" ## Acceptance tests ################################# ci-test-linux: cmds: - task: ci-test-linux-run - task: ci-test-docker-image - task: ci-test-deb-package-install - task: ci-test-rpm-package-install ci-test-docker-image: desc: Test using the docker image cmds: - | docker run \ --rm \ -t \ --env CLICOLOR_FORCE=true \ -v /var/run/docker.sock:/var/run/docker.sock \ 'docker.io/wagoodman/dive:latest' \ '{{ .TEST_IMAGE }}' \ --ci ci-test-deb-package-install: desc: Test debian package installation cmds: - | docker run \ --platform linux/amd64 \ -v /var/run/docker.sock:/var/run/docker.sock \ -v /${PWD}:/src \ -w /src \ --env CLICOLOR_FORCE=true \ ubuntu:latest \ /bin/bash -x -c "\ apt update && \ apt install -y curl && \ curl -L 'https://download.docker.com/linux/static/stable/x86_64/docker-{{ .DOCKER_CLI_VERSION }}.tgz' | \ tar -vxzf - docker/docker --strip-component=1 && \ mv docker /usr/local/bin/ &&\ docker version && \ apt install ./snapshot/dive_*_linux_amd64.deb -y && \ dive --version && \ dive '{{ .TEST_IMAGE }}' --ci \ " ci-test-rpm-package-install: desc: Test RPM package installation cmds: - | docker run \ --platform linux/amd64 \ -v /var/run/docker.sock:/var/run/docker.sock \ -v /${PWD}:/src \ -w /src \ --env CLICOLOR_FORCE=true \ fedora:latest \ /bin/bash -x -c "\ curl -L 'https://download.docker.com/linux/static/stable/x86_64/docker-{{ .DOCKER_CLI_VERSION }}.tgz' | \ tar -vxzf - docker/docker --strip-component=1 && \ mv docker /usr/local/bin/ &&\ docker version && \ dnf install ./snapshot/dive_*_linux_amd64.rpm -y && \ dive --version && \ dive '{{ .TEST_IMAGE }}' --ci \ " generate-compressed-test-images: desc: Generate compressed test images for testing cmds: - | for alg in uncompressed gzip estargz zstd; do \ for exporter in docker image; do \ docker buildx build \ -f .data/Dockerfile.minimal \ --tag test-dive-${exporter}:${alg} \ --output type=${exporter},force-compression=true,compression=${alg} . ; \ done ; \ done && \ echo 'Exported test data!' generate-compressed-test-data: desc: Generate compressed test data for testing cmds: - | for alg in uncompressed gzip estargz zstd; \ do \ docker buildx build \ -f .data/Dockerfile.minimal \ --output type=tar,dest=.data/test-${alg}-image.tar,force-compression=true,compression=${alg} . ; \ docker buildx build \ -f .data/Dockerfile.minimal \ --output type=oci,dest=.data/test-oci-${alg}-image.tar,force-compression=true,compression=${alg} . ; \ done && \ echo 'Exported test data!' ci-test-linux-run: desc: Test Linux binary execution (CI only) deps: [ci-check, generate-compressed-test-images] cmds: - | ls -la {{ .SNAPSHOT_DIR }} ls -la {{ .SNAPSHOT_DIR }}/dive_linux_amd64_v1 chmod 755 {{ .SNAPSHOT_DIR }}/dive_linux_amd64_v1/dive && \ {{ .SNAPSHOT_DIR }}/dive_linux_amd64_v1/dive '{{ .TEST_IMAGE }}' --ci && \ {{ .SNAPSHOT_DIR }}/dive_linux_amd64_v1/dive --source docker-archive .data/test-kaniko-image.tar --ci --ci-config .data/.dive-ci - | for alg in uncompressed gzip estargz zstd; do \ for exporter in docker image; do \ {{ .SNAPSHOT_DIR }}/dive_linux_amd64_v1/dive "test-dive-${exporter}:${alg}" --ci ; \ done && \ {{ .SNAPSHOT_DIR }}/dive_linux_amd64_v1/dive --source docker-archive .data/test-oci-${alg}-image.tar --ci --ci-config .data/.dive-ci; \ done ci-test-mac-run: desc: Test macOS binary execution (CI only) deps: [ci-check] cmds: - | chmod 755 {{ .SNAPSHOT_DIR }}/dive_darwin_amd64_v1/dive && \ {{ .SNAPSHOT_DIR }}/dive_darwin_amd64_v1/dive --source docker-archive .data/test-docker-image.tar --ci --ci-config .data/.dive-ci ## Build-related targets ################################# build: desc: Build the project deps: [tools, tmpdir] generates: - "{{ .PROJECT }}" cmds: - silent: true cmd: | echo "dist: {{ .SNAPSHOT_DIR }}" > {{ .TMP_DIR }}/goreleaser.yaml cat .goreleaser.yaml >> {{ .TMP_DIR }}/goreleaser.yaml - "{{ .BUILD_CMD }}" snapshot: desc: Create a snapshot release aliases: - build deps: [tools, tmpdir] sources: - "**/*.go" - ".goreleaser.yaml" method: checksum cmds: - silent: true cmd: | echo "dist: {{ .SNAPSHOT_DIR }}" > {{ .TMP_DIR }}/goreleaser.yaml cat .goreleaser.yaml >> {{ .TMP_DIR }}/goreleaser.yaml - "{{ .SNAPSHOT_CMD }}" changelog: desc: Generate a changelog deps: [tools] generates: - "{{ .CHANGELOG }}" - "{{ .NEXT_VERSION }}" cmds: - "{{ .CHRONICLE }} -vv -n --version-file {{ .NEXT_VERSION }} > {{ .CHANGELOG }}" - "{{ .GLOW }} -w 200 {{ .CHANGELOG }}" ## Release targets ################################# release: desc: Create a release interactive: true deps: [tools] cmds: - cmd: .github/scripts/trigger-release.sh silent: true ## CI-only targets ################################# ci-check: preconditions: - sh: test -n "$CI" msg: "This step should ONLY be run in CI. Exiting..." cmds: - echo "Running in CI environment" silent: true internal: true ci-release: # desc: "[CI only] Create a release" deps: [ci-check, tools] cmds: - "{{ .CHRONICLE }} -vvv > CHANGELOG.md" - cmd: "cat CHANGELOG.md" silent: true - "{{ .RELEASE_CMD }}" ## Cleanup targets ################################# clean-snapshot: desc: Remove any snapshot builds cmds: - "rm -rf {{ .SNAPSHOT_DIR }}" - "rm -rf {{ .TMP_DIR }}/goreleaser.yaml" ================================================ FILE: cmd/dive/cli/cli.go ================================================ package cli import ( "github.com/anchore/clio" "github.com/spf13/cobra" "github.com/wagoodman/dive/cmd/dive/cli/internal/command" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui" "github.com/wagoodman/dive/internal/bus" "github.com/wagoodman/dive/internal/log" ) func Application(id clio.Identification) clio.Application { app, _ := create(id) return app } func Command(id clio.Identification) *cobra.Command { _, cmd := create(id) return cmd } func create(id clio.Identification) (clio.Application, *cobra.Command) { clioCfg := clio.NewSetupConfig(id). WithGlobalConfigFlag(). // add persistent -c for reading an application config from WithGlobalLoggingFlags(). // add persistent -v and -q flags tied to the logging config WithConfigInRootHelp(). // --help on the root command renders the full application config in the help text WithUI(ui.None()). WithInitializers( func(state *clio.State) error { bus.Set(state.Bus) log.Set(state.Logger) //stereoscope.SetBus(state.Bus) //stereoscope.SetLogger(state.Logger) return nil }, ) //WithPostRuns(func(_ *clio.State, _ error) { // stereoscope.Cleanup() //}) app := clio.New(*clioCfg) rootCmd := command.Root(app) rootCmd.AddCommand( clio.VersionCommand(id), clio.ConfigCommand(app, nil), command.Build(app), ) return app, rootCmd } ================================================ FILE: cmd/dive/cli/cli_build_test.go ================================================ package cli import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "regexp" "testing" ) func Test_Build_Dockerfile(t *testing.T) { t.Setenv("DIVE_CONFIG", "./testdata/image-multi-layer-dockerfile/dive-pass.yaml") t.Run("implicit dockerfile", func(t *testing.T) { rootCmd := getTestCommand(t, "build testdata/image-multi-layer-dockerfile") stdout := Capture().WithStdout().WithSuppress().Run(t, func() { require.NoError(t, rootCmd.Execute()) }) snaps.MatchSnapshot(t, stdout) }) t.Run("explicit file flag", func(t *testing.T) { rootCmd := getTestCommand(t, "build testdata/image-multi-layer-dockerfile -f testdata/image-multi-layer-dockerfile/Dockerfile") stdout := Capture().WithStdout().WithSuppress().Run(t, func() { require.NoError(t, rootCmd.Execute()) }) snaps.MatchSnapshot(t, stdout) }) } func Test_Build_Containerfile(t *testing.T) { t.Setenv("DIVE_CONFIG", "./testdata/image-multi-layer-containerfile/dive-pass.yaml") t.Run("implicit containerfile", func(t *testing.T) { rootCmd := getTestCommand(t, "build testdata/image-multi-layer-containerfile") stdout := Capture().WithStdout().WithSuppress().Run(t, func() { require.NoError(t, rootCmd.Execute()) }) snaps.MatchSnapshot(t, stdout) }) t.Run("explicit file flag", func(t *testing.T) { rootCmd := getTestCommand(t, "build testdata/image-multi-layer-containerfile -f testdata/image-multi-layer-containerfile/Containerfile") stdout := Capture().WithStdout().WithSuppress().Run(t, func() { require.NoError(t, rootCmd.Execute()) }) snaps.MatchSnapshot(t, stdout) }) } func Test_Build_CI_gate_fail(t *testing.T) { t.Setenv("DIVE_CONFIG", "./testdata/image-multi-layer-dockerfile/dive-fail.yaml") rootCmd := getTestCommand(t, "build testdata/image-multi-layer-dockerfile") stdout := Capture().WithStdout().WithSuppress().Run(t, func() { // failing gate should result in a non-zero exit code require.Error(t, rootCmd.Execute()) }) snaps.MatchSnapshot(t, stdout) } func Test_BuildFailure(t *testing.T) { t.Run("nonexistent directory", func(t *testing.T) { rootCmd := getTestCommand(t, "build ./path/does/not/exist") combined := Capture().WithStdout().WithStderr().Run(t, func() { require.ErrorContains(t, rootCmd.Execute(), "could not find Containerfile or Dockerfile") }) assert.Contains(t, combined, "Building image") snaps.MatchSnapshot(t, combined) }) t.Run("invalid dockerfile", func(t *testing.T) { rootCmd := getTestCommand(t, "build ./testdata/invalid") combined := Capture().WithStdout().WithStderr().WithSuppress().Run(t, func() { require.ErrorContains(t, rootCmd.Execute(), "cannot build image: exit status 1") }) assert.Contains(t, combined, "Building image") // ensure we're passing through docker feedback assert.Contains(t, combined, "unknown instruction: INVALID") // replace anything starting with "docker-desktop://", like "docker-desktop://dashboard/build/desktop-linux/desktop-linux/ujdmhgkwo0sqqpopsnum3xakd" combined = regexp.MustCompile("docker-desktop://[^ ]+").ReplaceAllString(combined, "docker-desktop://") snaps.MatchSnapshot(t, combined) }) } ================================================ FILE: cmd/dive/cli/cli_ci_test.go ================================================ package cli import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "testing" ) func Test_CI_DefaultCIConfig(t *testing.T) { // this lets the test harness to unset any DIVE_CONFIG env var t.Setenv("DIVE_CONFIG", "-") rootCmd := getTestCommand(t, repoPath(t, ".data/test-docker-image.tar")+" -vv") cd(t, "testdata/default-ci-config") combined := Capture().WithStdout().WithStderr().Run(t, func() { // failing gate should result in a non-zero exit code require.Error(t, rootCmd.Execute()) }) assert.Contains(t, combined, "lowest-efficiency: \"0.96\"", "missing lowest-efficiency rule") assert.Contains(t, combined, "highest-wasted-bytes: 19Mb", "missing highest-wasted-bytes rule") assert.Contains(t, combined, "highest-user-wasted-percent: \"0.6\"", "missing highest-user-wasted-percent rule") snaps.MatchSnapshot(t, combined) } func Test_CI_Fail(t *testing.T) { t.Setenv("DIVE_CONFIG", "./testdata/image-multi-layer-dockerfile/dive-fail.yaml") rootCmd := getTestCommand(t, "build testdata/image-multi-layer-dockerfile") stdout := Capture().WithStdout().WithSuppress().Run(t, func() { // failing gate should result in a non-zero exit code require.Error(t, rootCmd.Execute()) }) snaps.MatchSnapshot(t, stdout) } func Test_CI_LegacyRules(t *testing.T) { t.Setenv("DIVE_CONFIG", "./testdata/config/dive-ci-legacy.yaml") rootCmd := getTestCommand(t, "config --load") all := Capture().All().Run(t, func() { require.NoError(t, rootCmd.Execute()) }) // this proves that we can load the legacy rules and map them to the standard rules assert.Contains(t, all, "lowest-efficiency: '0.95'", "missing lowest-efficiency legacy rule") assert.Contains(t, all, "highest-wasted-bytes: '20MB'", "missing highest-wasted-bytes legacy rule") assert.Contains(t, all, "highest-user-wasted-percent: '0.2'", "missing highest-user-wasted-percent legacy rule") } ================================================ FILE: cmd/dive/cli/cli_config_test.go ================================================ package cli import ( "github.com/stretchr/testify/require" "testing" ) func Test_Config(t *testing.T) { t.Setenv("DIVE_CONFIG", "./testdata/image-multi-layer-dockerfile/dive-pass.yaml") rootCmd := getTestCommand(t, "config --load") all := Capture().All().Run(t, func() { require.NoError(t, rootCmd.Execute()) }) snaps.MatchSnapshot(t, all) } ================================================ FILE: cmd/dive/cli/cli_json_test.go ================================================ package cli import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "os" "path/filepath" "testing" ) func Test_JsonOutput(t *testing.T) { t.Run("json output", func(t *testing.T) { dest := t.TempDir() file := filepath.Join(dest, "output.json") rootCmd := getTestCommand(t, "busybox:1.37.0@sha256:ad9fa4d07136a83e69a54ef00102f579d04eba431932de3b0f098cc5d5948f9f --json "+file) combined := Capture().WithStdout().WithStderr().Run(t, func() { require.NoError(t, rootCmd.Execute()) }) assert.Contains(t, combined, "Exporting details") assert.Contains(t, combined, "file") contents, err := os.ReadFile(file) require.NoError(t, err) snaps.MatchJSON(t, contents) }) } ================================================ FILE: cmd/dive/cli/cli_load_test.go ================================================ package cli import ( "fmt" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "os" "os/exec" "testing" ) func Test_LoadImage(t *testing.T) { image := "busybox:1.37.0@sha256:ad9fa4d07136a83e69a54ef00102f579d04eba431932de3b0f098cc5d5948f9f" archive := repoPath(t, ".data/test-docker-image.tar") t.Run("from docker engine", func(t *testing.T) { runWithCombinedOutput(t, fmt.Sprintf("docker://%s", image)) }) t.Run("from docker engine (flag)", func(t *testing.T) { runWithCombinedOutput(t, fmt.Sprintf("--source docker %s", image)) }) t.Run("from podman engine", func(t *testing.T) { if _, err := exec.LookPath("podman"); err != nil { t.Skip("podman not installed, skipping test") } // pull the image from podman first require.NoError(t, exec.Command("podman", "pull", image).Run()) runWithCombinedOutput(t, fmt.Sprintf("podman://%s", image)) }) t.Run("from podman engine (flag)", func(t *testing.T) { if _, err := exec.LookPath("podman"); err != nil { t.Skip("podman not installed, skipping test") } // pull the image from podman first require.NoError(t, exec.Command("podman", "pull", image).Run()) runWithCombinedOutput(t, fmt.Sprintf("--source podman %s", image)) }) t.Run("from archive", func(t *testing.T) { runWithCombinedOutput(t, fmt.Sprintf("docker-archive://%s", archive)) }) t.Run("from archive (flag)", func(t *testing.T) { runWithCombinedOutput(t, fmt.Sprintf("--source docker-archive %s", archive)) }) } func runWithCombinedOutput(t testing.TB, cmd string) { t.Helper() rootCmd := getTestCommand(t, cmd) combined := Capture().WithStdout().WithStderr().Run(t, func() { require.NoError(t, rootCmd.Execute()) }) assertLoadOutput(t, combined) } func assertLoadOutput(t testing.TB, combined string) { t.Helper() assert.Contains(t, combined, "Loading image") assert.Contains(t, combined, "Analyzing image") assert.Contains(t, combined, "Evaluating image") snaps.MatchSnapshot(t, combined) } func Test_FetchFailure(t *testing.T) { t.Run("nonexistent image", func(t *testing.T) { rootCmd := getTestCommand(t, "docker:wagoodman/nonexistent/image:tag") combined := Capture().WithStdout().WithStderr().Run(t, func() { require.ErrorContains(t, rootCmd.Execute(), "cannot load image: Error response from daemon: invalid reference format") }) assert.Contains(t, combined, "Loading image") snaps.MatchSnapshot(t, combined) }) t.Run("invalid image name", func(t *testing.T) { rootCmd := getTestCommand(t, "docker:///wagoodman/invalid:image:format") combined := Capture().WithStdout().WithStderr().Run(t, func() { require.ErrorContains(t, rootCmd.Execute(), "cannot load image: Error response from daemon: invalid reference format") }) assert.Contains(t, combined, "Loading image") snaps.MatchSnapshot(t, combined) }) } func cd(t testing.TB, to string) { t.Helper() from, err := os.Getwd() require.NoError(t, err) require.NoError(t, os.Chdir(to)) t.Cleanup(func() { require.NoError(t, os.Chdir(from)) }) } ================================================ FILE: cmd/dive/cli/cli_test.go ================================================ package cli import ( "bytes" "flag" "github.com/anchore/clio" "github.com/charmbracelet/lipgloss" snapsPkg "github.com/gkampitakis/go-snaps/snaps" "github.com/google/shlex" "github.com/muesli/termenv" "github.com/spf13/cobra" "github.com/stretchr/testify/require" "go.uber.org/atomic" "io" "os" "os/exec" "path/filepath" "strings" "testing" ) var ( updateSnapshot = flag.Bool("update", false, "update any test snapshots") snaps *snapsPkg.Config repoRootCache atomic.String ) func TestMain(m *testing.M) { // flags are not parsed until after test.Main is called... flag.Parse() os.Unsetenv("DIVE_CONFIG") // disable colors lipgloss.SetColorProfile(termenv.Ascii) snaps = snapsPkg.WithConfig( snapsPkg.Update(*updateSnapshot), snapsPkg.Dir("testdata/snapshots"), ) v := m.Run() snapsPkg.Clean(m) os.Exit(v) } func TestUpdateSnapshotDisabled(t *testing.T) { require.False(t, *updateSnapshot, "update snapshot flag should be disabled") } func repoPath(t testing.TB, path string) string { t.Helper() root := repoRoot(t) return filepath.Join(root, path) } func repoRoot(t testing.TB) string { val := repoRootCache.Load() if val != "" { return val } t.Helper() // use git to find the root of the repo out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output() if err != nil { t.Fatalf("failed to get repo root: %v", err) } val = strings.TrimSpace(string(out)) repoRootCache.Store(val) return val } func getTestCommand(t testing.TB, cmd string) *cobra.Command { switch os.Getenv("DIVE_CONFIG") { case "": t.Setenv("DIVE_CONFIG", "./testdata/dive-enable-ci.yaml") case "-": t.Setenv("DIVE_CONFIG", "") } // need basic output to logger for testing... //l, err := logrus.New(logrus.DefaultConfig()) //require.NoError(t, err) //log.Set(l) // get the root command c := Command(clio.Identification{ Name: "dive", Version: "testing", }) args, err := shlex.Split(cmd) require.NoError(t, err, "failed to parse command line %q", cmd) c.SetArgs(args) return c } type capturer struct { stdout bool stderr bool suppress bool } func Capture() *capturer { return &capturer{} } func (c *capturer) WithSuppress() *capturer { c.suppress = true return c } func (c *capturer) All() *capturer { c.stdout = true c.stderr = true return c } func (c *capturer) WithStdout() *capturer { c.stdout = true return c } func (c *capturer) WithStderr() *capturer { c.stderr = true return c } func (c *capturer) Run(t testing.TB, f func()) string { t.Helper() r, w, err := os.Pipe() if err != nil { panic(err) } devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) if err != nil { panic(err) } defer devNull.Close() oldStdout := os.Stdout oldStderr := os.Stderr if c.stdout { os.Stdout = w } else if c.suppress { os.Stdout = devNull } if c.stderr { os.Stderr = w } else if c.suppress { os.Stderr = devNull } defer func() { os.Stdout = oldStdout os.Stderr = oldStderr }() f() require.NoError(t, w.Close()) var buf bytes.Buffer _, err = io.Copy(&buf, r) require.NoError(t, err) return buf.String() } ================================================ FILE: cmd/dive/cli/internal/command/adapter/analyzer.go ================================================ package adapter import ( "context" "fmt" "github.com/dustin/go-humanize" "github.com/wagoodman/dive/dive/image" "github.com/wagoodman/dive/internal/bus" "github.com/wagoodman/dive/internal/bus/event/payload" "github.com/wagoodman/dive/internal/log" ) type Analyzer interface { Analyze(ctx context.Context, img *image.Image) (*image.Analysis, error) } type analysisActionObserver struct { Analyzer func(context.Context, *image.Image) (*image.Analysis, error) } func NewAnalyzer() Analyzer { return analysisActionObserver{ Analyzer: image.Analyze, } } func (a analysisActionObserver) Analyze(ctx context.Context, img *image.Image) (*image.Analysis, error) { log.WithFields("image", img.Request).Infof("analyzing") layers := len(img.Layers) var files int var fileSize uint64 for _, layer := range img.Layers { files += layer.Tree.Size fileSize += layer.Tree.FileSize } fileSizeStr := humanize.Bytes(fileSize) filesStr := humanize.Comma(int64(files)) log.Debugf("├── layers: %d", layers) log.Debugf("├── files: %s", filesStr) log.Debugf("└── file size: %s", fileSizeStr) mon := bus.StartTask(payload.GenericTask{ Title: payload.Title{ Default: "Analyzing image", WhileRunning: "Analyzing image", OnSuccess: "Analyzed image", }, HideOnSuccess: false, HideStageOnSuccess: false, ID: img.Request, Context: fmt.Sprintf("[layers:%d files:%s size:%s]", layers, filesStr, fileSizeStr), }) analysis, err := a.Analyzer(ctx, img) if err != nil { mon.SetError(err) } else { mon.SetCompleted() } if err == nil && analysis == nil { err = fmt.Errorf("no results returned") } return analysis, err } ================================================ FILE: cmd/dive/cli/internal/command/adapter/evaluator.go ================================================ package adapter import ( "context" "fmt" "github.com/wagoodman/dive/cmd/dive/cli/internal/command/ci" "github.com/wagoodman/dive/dive/image" "github.com/wagoodman/dive/internal/bus" "github.com/wagoodman/dive/internal/bus/event/payload" "github.com/wagoodman/dive/internal/log" ) type Evaluator interface { Evaluate(ctx context.Context, analysis *image.Analysis) ci.Evaluation } type evaluationActionObserver struct { ci.Evaluator } func NewEvaluator(rules []ci.Rule) Evaluator { return evaluationActionObserver{ Evaluator: ci.NewEvaluator(rules), } } func (c evaluationActionObserver) Evaluate(ctx context.Context, analysis *image.Analysis) ci.Evaluation { log.WithFields("image", analysis.Image).Infof("evaluating image") mon := bus.StartTask(payload.GenericTask{ Title: payload.Title{ Default: "Evaluating image", WhileRunning: "Evaluating image", OnSuccess: "Evaluated image", }, HideOnSuccess: false, HideStageOnSuccess: false, ID: analysis.Image, Context: fmt.Sprintf("[rules: %d]", len(c.Rules)), }) eval := c.Evaluator.Evaluate(ctx, analysis) if eval.Pass { mon.SetCompleted() } else { mon.SetError(fmt.Errorf("failed evaluation")) } bus.Report(eval.Report) return eval } ================================================ FILE: cmd/dive/cli/internal/command/adapter/exporter.go ================================================ package adapter import ( "context" "fmt" "github.com/spf13/afero" "github.com/wagoodman/dive/cmd/dive/cli/internal/command/export" "github.com/wagoodman/dive/dive/image" "github.com/wagoodman/dive/internal/bus" "github.com/wagoodman/dive/internal/bus/event/payload" "github.com/wagoodman/dive/internal/log" "os" ) type Exporter interface { ExportTo(ctx context.Context, img *image.Analysis, path string) error } type jsonExporter struct { filesystem afero.Fs } func NewExporter(fs afero.Fs) Exporter { return &jsonExporter{ filesystem: fs, } } func (e *jsonExporter) ExportTo(ctx context.Context, analysis *image.Analysis, path string) error { log.WithFields("path", path).Infof("exporting analysis") mon := bus.StartTask(payload.GenericTask{ Title: payload.Title{ Default: "Exporting details", WhileRunning: "Exporting details", OnSuccess: "Exported details", }, HideOnSuccess: false, HideStageOnSuccess: false, ID: analysis.Image, Context: fmt.Sprintf("[file: %s]", path), }) bytes, err := export.NewExport(analysis).Marshal() if err != nil { mon.SetError(err) return fmt.Errorf("cannot marshal export payload: %w", err) } else { mon.SetCompleted() } file, err := e.filesystem.OpenFile(path, os.O_RDWR|os.O_CREATE, 0644) if err != nil { return fmt.Errorf("cannot open export file: %w", err) } defer file.Close() _, err = file.Write(bytes) if err != nil { return fmt.Errorf("cannot write to export file: %w", err) } return nil } ================================================ FILE: cmd/dive/cli/internal/command/adapter/resolver.go ================================================ package adapter import ( "context" "github.com/wagoodman/dive/dive/image" "github.com/wagoodman/dive/internal/bus" "github.com/wagoodman/dive/internal/bus/event/payload" "github.com/wagoodman/dive/internal/log" "strings" "time" ) type imageActionObserver struct { image.Resolver } func ImageResolver(resolver image.Resolver) image.Resolver { return imageActionObserver{ Resolver: resolver, } } func (i imageActionObserver) Build(ctx context.Context, options []string) (*image.Image, error) { log.Info("building image") log.Debugf("└── %s", strings.Join(options, " ")) mon := bus.StartTask(payload.GenericTask{ Title: payload.Title{ Default: "Building image", WhileRunning: "Building image", OnSuccess: "Built image", }, HideOnSuccess: false, HideStageOnSuccess: false, Context: "... " + strings.Join(options, " "), }) ctx = payload.SetGenericProgressToContext(ctx, mon) img, err := i.Resolver.Build(ctx, options) if err != nil { mon.SetError(err) } else { mon.SetCompleted() } return img, err } func (i imageActionObserver) Fetch(ctx context.Context, id string) (*image.Image, error) { log.WithFields("image", id).Info("fetching") log.Debugf("└── resolver: %s", i.Resolver.Name()) ctx, cancel := context.WithCancel(ctx) defer cancel() mon := bus.StartTask(payload.GenericTask{ Title: payload.Title{ Default: "Loading image", WhileRunning: "Loading image", OnSuccess: "Fetched image", }, HideOnSuccess: false, HideStageOnSuccess: false, ID: id, Context: id, }) ctx = payload.SetGenericProgressToContext(ctx, mon) go func() { // in 5 seconds if the context is not cancelled, log the message select { // nolint:gosimple case <-time.After(3 * time.Second): if ctx.Err() == nil { bus.Notify(" • this can take a while for large images...") mon.AtomicStage.Set("(this can take a while for large images)") // TODO: default level should be error for this to work when using the UI //log.Warn("this can take a while for large images") } } }() img, err := i.Resolver.Fetch(ctx, id) if err != nil { mon.SetError(err) } else { mon.SetCompleted() } return img, err } ================================================ FILE: cmd/dive/cli/internal/command/build.go ================================================ package command import ( "fmt" "github.com/anchore/clio" "github.com/spf13/cobra" "github.com/wagoodman/dive/cmd/dive/cli/internal/command/adapter" "github.com/wagoodman/dive/cmd/dive/cli/internal/options" "github.com/wagoodman/dive/dive" ) type buildOptions struct { options.Application `yaml:",inline" mapstructure:",squash"` // reserved for future use of build-only flags } func Build(app clio.Application) *cobra.Command { opts := &buildOptions{ Application: options.DefaultApplication(), } return app.SetupCommand(&cobra.Command{ Use: "build [any valid `docker build` arguments]", Short: "Builds and analyzes a docker image from a Dockerfile (this is a thin wrapper for the `docker build` command).", DisableFlagParsing: true, RunE: func(cmd *cobra.Command, args []string) error { if err := setUI(app, opts.Application); err != nil { return fmt.Errorf("failed to set UI: %w", err) } resolver, err := dive.GetImageResolver(opts.Analysis.Source) if err != nil { return fmt.Errorf("cannot determine image provider for build: %w", err) } ctx := cmd.Context() img, err := adapter.ImageResolver(resolver).Build(ctx, args) if err != nil { return fmt.Errorf("cannot build image: %w", err) } return run(cmd.Context(), opts.Application, img, resolver) }, }, opts) } ================================================ FILE: cmd/dive/cli/internal/command/ci/evaluator.go ================================================ package ci import ( "fmt" "github.com/charmbracelet/lipgloss" "golang.org/x/net/context" "sort" "strconv" "strings" "github.com/dustin/go-humanize" "github.com/wagoodman/dive/dive/image" ) type Evaluation struct { Report string Pass bool } type Evaluator struct { Rules []Rule Results map[string]RuleResult Tally ResultTally Pass bool Misconfigured bool InefficientFiles []ReferenceFile format format } type format struct { Title lipgloss.Style Success lipgloss.Style Warning lipgloss.Style Disabled lipgloss.Style Failure lipgloss.Style TableHeader lipgloss.Style Label lipgloss.Style Aux lipgloss.Style Value lipgloss.Style } type ResultTally struct { Pass int Fail int Skip int Warn int Total int } type ReferenceFile struct { References int `json:"count"` SizeBytes uint64 `json:"sizeBytes"` Path string `json:"file"` } func NewEvaluator(rules []Rule) Evaluator { return Evaluator{ Rules: rules, Results: make(map[string]RuleResult), Pass: true, format: format{ Title: lipgloss.NewStyle().Bold(true), Success: lipgloss.NewStyle().Foreground(lipgloss.Color("2")), Warning: lipgloss.NewStyle().Foreground(lipgloss.Color("3")), Disabled: lipgloss.NewStyle().Faint(true), Failure: lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true), TableHeader: lipgloss.NewStyle().Bold(true), Label: lipgloss.NewStyle().Width(18), Aux: lipgloss.NewStyle().Faint(true), Value: lipgloss.NewStyle(), }, } } func (e Evaluator) isRuleEnabled(rule Rule) bool { return rule.Configuration() != "disabled" } func (e Evaluator) Evaluate(ctx context.Context, analysis *image.Analysis) Evaluation { for _, rule := range e.Rules { if !e.isRuleEnabled(rule) { e.Results[rule.Key()] = RuleResult{ status: RuleConfigured, message: "rule disabled", } continue } e.Results[rule.Key()] = RuleResult{ status: RuleConfigured, message: "test", } } // capture inefficient files for idx := 0; idx < len(analysis.Inefficiencies); idx++ { fileData := analysis.Inefficiencies[len(analysis.Inefficiencies)-1-idx] e.InefficientFiles = append(e.InefficientFiles, ReferenceFile{ References: len(fileData.Nodes), SizeBytes: uint64(fileData.CumulativeSize), Path: fileData.Path, }) } // evaluate results against the configured CI rules for _, rule := range e.Rules { if !e.isRuleEnabled(rule) { e.Results[rule.Key()] = RuleResult{ status: RuleDisabled, message: "disabled", } continue } status, message := rule.Evaluate(analysis) if value, exists := e.Results[rule.Key()]; exists && value.status != RuleConfigured && value.status != RuleMisconfigured { panic(fmt.Errorf("CI rule result recorded twice: %s", rule.Key())) } if status == RuleFailed { e.Pass = false } if message == "" { message = rule.Configuration() } e.Results[rule.Key()] = RuleResult{ status: status, message: message, } } e.Tally.Total = len(e.Results) for rule, result := range e.Results { switch result.status { case RulePassed: e.Tally.Pass++ case RuleFailed: e.Tally.Fail++ case RuleWarning: e.Tally.Warn++ case RuleDisabled: e.Tally.Skip++ default: panic(fmt.Errorf("unknown test status (rule='%v'): %v", rule, result.status)) } } return Evaluation{ Report: e.report(analysis), Pass: e.Pass, } } func (e Evaluator) report(analysis *image.Analysis) string { sections := []string{ e.renderAnalysisSection(analysis), e.renderInefficientFilesSection(analysis), e.renderEvaluationSection(), } return strings.Join(sections, "\n\n") } func (e Evaluator) renderAnalysisSection(analysis *image.Analysis) string { wastedByteStr := "" userWastedPercent := "0 %" if analysis.WastedBytes > 0 { wastedByteStr = fmt.Sprintf("(%s)", humanize.Bytes(analysis.WastedBytes)) userWastedPercent = fmt.Sprintf("%.2f %%", analysis.WastedUserPercent*100) } title := e.format.Title.Render("Analysis:") rows := []string{ formatKeyValue(e.format, "efficiency", fmt.Sprintf("%.2f %%", analysis.Efficiency*100)), formatKeyValue(e.format, "wastedBytes", fmt.Sprintf("%d bytes %s", analysis.WastedBytes, wastedByteStr)), formatKeyValue(e.format, "userWastedPercent", userWastedPercent), } return title + "\n" + strings.Join(rows, "\n") } func (e Evaluator) renderInefficientFilesSection(analysis *image.Analysis) string { title := e.format.Title.Render("Inefficient Files:") if len(analysis.Inefficiencies) == 0 { return title + " (None)" } header := e.format.TableHeader.Render( fmt.Sprintf(" %-5s %-12s %-s", "Count", "Wasted Space", "File Path"), ) rows := []string{header} for _, file := range e.InefficientFiles { row := fmt.Sprintf(" %-5s %-12s %-s", strconv.Itoa(file.References), humanize.Bytes(file.SizeBytes), file.Path, ) rows = append(rows, row) } return title + "\n" + strings.Join(rows, "\n") } func (e Evaluator) renderEvaluationSection() string { title := e.format.Title.Render("Evaluation:") // sort rules by name for consistent output rules := make([]string, 0, len(e.Results)) for name := range e.Results { rules = append(rules, name) } sort.Strings(rules) ruleResults := []string{} for _, rule := range rules { result := e.Results[rule] ruleResult := e.formatRuleResult(rule, result) ruleResults = append(ruleResults, ruleResult) } status := e.renderStatusSummary() return title + "\n" + strings.Join(ruleResults, "\n") + "\n\n" + status } func (e Evaluator) formatRuleResult(ruleName string, result RuleResult) string { var style lipgloss.Style textStyle := lipgloss.NewStyle() switch result.status { case RulePassed: style = e.format.Success case RuleFailed: style = e.format.Failure case RuleWarning, RuleMisconfigured: style = e.format.Warning case RuleDisabled: style = e.format.Disabled textStyle = e.format.Disabled default: style = lipgloss.NewStyle() } statusStr := style.Render(result.status.String(e.format)) if result.message != "" { return fmt.Sprintf(" %s %s", statusStr, textStyle.Render(ruleName+" ("+result.message+")")) } return fmt.Sprintf(" %s %s", statusStr, textStyle.Render(ruleName)) } func (e Evaluator) renderStatusSummary() string { if e.Misconfigured { return e.format.Failure.Render("CI Misconfigured") } status := "PASS" if e.Tally.Fail > 0 { status = "FAIL" } parts := []string{} type tallyItem struct { name string value int } items := []tallyItem{ //{"total", e.Tally.Total}, {"pass", e.Tally.Pass}, {"fail", e.Tally.Fail}, {"warn", e.Tally.Warn}, {"skip", e.Tally.Skip}, } for _, item := range items { if item.value > 0 { parts = append(parts, fmt.Sprintf("%s:%d", item.name, item.value)) } } auxSummary := e.format.Aux.Render(" [" + strings.Join(parts, " ") + "]") var style lipgloss.Style switch { case e.Pass && e.Tally.Warn == 0: style = e.format.Success case e.Pass && e.Tally.Warn > 0: style = e.format.Warning default: style = e.format.Failure } return style.Render(status) + auxSummary } func formatKeyValue(f format, key, value string) string { formattedKey := f.Label.Render(key + ":") return fmt.Sprintf(" %s %s", formattedKey, value) } ================================================ FILE: cmd/dive/cli/internal/command/ci/evaluator_test.go ================================================ package ci import ( "context" "github.com/stretchr/testify/require" "go.uber.org/atomic" "os/exec" "path/filepath" "strings" "testing" "github.com/wagoodman/dive/dive/image/docker" ) var repoRootCache atomic.String func Test_Evaluator(t *testing.T) { result := docker.TestAnalysisFromArchive(t, repoPath(t, ".data/test-docker-image.tar")) validTests := []struct { name string efficiency string wastedBytes string wastedPercent string expectedPass bool expectedResult map[string]RuleStatus }{ { name: "allFail", efficiency: "0.99", wastedBytes: "1B", wastedPercent: "0.01", expectedPass: false, expectedResult: map[string]RuleStatus{ "lowestEfficiency": RuleFailed, "highestWastedBytes": RuleFailed, "highestUserWastedPercent": RuleFailed, }, }, { name: "allPass", efficiency: "0.9", wastedBytes: "50kB", wastedPercent: "0.5", expectedPass: true, expectedResult: map[string]RuleStatus{ "lowestEfficiency": RulePassed, "highestWastedBytes": RulePassed, "highestUserWastedPercent": RulePassed, }, }, { name: "allDisabled", efficiency: "disabled", wastedBytes: "disabled", wastedPercent: "disabled", expectedPass: true, expectedResult: map[string]RuleStatus{ "lowestEfficiency": RuleDisabled, "highestWastedBytes": RuleDisabled, "highestUserWastedPercent": RuleDisabled, }, }, { name: "mixedResults", efficiency: "0.9", wastedBytes: "1B", wastedPercent: "0.5", expectedPass: false, expectedResult: map[string]RuleStatus{ "lowestEfficiency": RulePassed, "highestWastedBytes": RuleFailed, "highestUserWastedPercent": RulePassed, }, }, } for _, test := range validTests { t.Run(test.name, func(t *testing.T) { // Create rules - these should not error rules, err := Rules(test.efficiency, test.wastedBytes, test.wastedPercent) require.NoError(t, err) evaluator := NewEvaluator(rules) eval := evaluator.Evaluate(context.TODO(), result) if test.expectedPass != eval.Pass { t.Errorf("expected pass=%v, got %v", test.expectedPass, eval.Pass) } if len(test.expectedResult) != len(evaluator.Results) { t.Errorf("expected %v results, got %v", len(test.expectedResult), len(evaluator.Results)) } for rule, actualResult := range evaluator.Results { expectedStatus := test.expectedResult[rule] if expectedStatus != actualResult.status { t.Errorf("%v: expected %v rule status, got %v: %v", rule, expectedStatus, actualResult.status, actualResult) } } }) } } func Test_Evaluator_Misconfigurations(t *testing.T) { invalidTests := []struct { name string efficiency string wastedBytes string wastedPercent string expectError bool }{ { name: "invalid_efficiency_too_high", efficiency: "1.1", // fail! wastedBytes: "50kB", wastedPercent: "0.5", expectError: true, }, { name: "invalid_efficiency_too_low", efficiency: "-0.1", // fail! wastedBytes: "50kB", wastedPercent: "0.5", expectError: true, }, { name: "invalid_efficiency_format", efficiency: "not_a_number", // fail! wastedBytes: "50kB", wastedPercent: "0.5", expectError: true, }, { name: "invalid_wasted_bytes_format", efficiency: "0.9", wastedBytes: "not_a_size", // fail! wastedPercent: "0.5", expectError: true, }, { name: "invalid_wasted_percent_high", efficiency: "0.9", wastedBytes: "50kB", wastedPercent: "1.1", // fail! expectError: true, }, { name: "invalid_wasted_percent_low", efficiency: "0.9", wastedBytes: "50kB", wastedPercent: "-0.1", // fail! expectError: true, }, { name: "invalid_wasted_percent_format", efficiency: "0.9", wastedBytes: "50kB", wastedPercent: "not_a_number", // fail! expectError: true, }, } for _, test := range invalidTests { t.Run(test.name, func(t *testing.T) { _, err := Rules(test.efficiency, test.wastedBytes, test.wastedPercent) if test.expectError { require.Error(t, err, "Expected an error for invalid configuration") } else { require.NoError(t, err, "Expected no error for valid configuration") } }) } } func repoPath(t testing.TB, path string) string { t.Helper() root := repoRoot(t) return filepath.Join(root, path) } func repoRoot(t testing.TB) string { val := repoRootCache.Load() if val != "" { return val } t.Helper() // use git to find the root of the repo out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output() if err != nil { t.Fatalf("failed to get repo root: %v", err) } val = strings.TrimSpace(string(out)) repoRootCache.Store(val) return val } ================================================ FILE: cmd/dive/cli/internal/command/ci/rule.go ================================================ package ci import ( "github.com/wagoodman/dive/dive/image" ) const ( RuleUnknown = iota RulePassed RuleFailed RuleWarning RuleDisabled RuleMisconfigured RuleConfigured ) type Rule interface { Key() string Configuration() string Evaluate(result *image.Analysis) (RuleStatus, string) } type RuleStatus int type RuleResult struct { status RuleStatus message string } func (status RuleStatus) String(f format) string { switch status { case RulePassed: return f.Success.Render("PASS") case RuleFailed: return f.Failure.Render("FAIL") case RuleWarning: return f.Warning.Render("WARN") case RuleDisabled: return f.Disabled.Render("SKIP") case RuleMisconfigured: return f.Warning.Render("MISCONFIGURED") case RuleConfigured: return "CONFIGURED " default: return f.Warning.Render("Unknown") } } ================================================ FILE: cmd/dive/cli/internal/command/ci/rules.go ================================================ package ci import ( "errors" "fmt" "github.com/dustin/go-humanize" "github.com/wagoodman/dive/dive/image" "strconv" "strings" ) const ( ciKeyLowestEfficiencyThreshold = "lowestEfficiency" ciKeyHighestWastedBytes = "highestWastedBytes" ciKeyHighestUserWastedPercent = "highestUserWastedPercent" ) func Rules(lowerEfficiency, highestWastedBytes, highestUserWastedPercent string) ([]Rule, error) { var rules []Rule var errs []error lowestEfficiencyRule, err := NewLowestEfficiencyRule(lowerEfficiency) if err != nil { errs = append(errs, err) } rules = append(rules, lowestEfficiencyRule) highestWastedBytesRule, err := NewHighestWastedBytesRule(highestWastedBytes) if err != nil { errs = append(errs, err) } rules = append(rules, highestWastedBytesRule) highestUserWastedPercentRule, err := NewHighestUserWastedPercentRule(highestUserWastedPercent) if err != nil { errs = append(errs, err) } rules = append(rules, highestUserWastedPercentRule) return rules, errors.Join(errs...) } func DisabledRule(key string) Rule { return &BaseRule{ key: key, configValue: "disabled", evaluator: func(_ *image.Analysis) (RuleStatus, string) { return RuleDisabled, "rule disabled" }, } } type BaseRule struct { key string configValue string status RuleStatus evaluator func(*image.Analysis) (RuleStatus, string) } func (rule *BaseRule) Key() string { return rule.key } func (rule *BaseRule) Configuration() string { return rule.configValue } func (rule *BaseRule) Evaluate(result *image.Analysis) (RuleStatus, string) { if rule.status != RuleUnknown { return rule.status, "" } return rule.evaluator(result) } // LowestEfficiencyRule checks if image efficiency is above threshold type LowestEfficiencyRule struct { BaseRule threshold float64 } // HighestWastedBytesRule checks if wasted bytes are below threshold type HighestWastedBytesRule struct { BaseRule threshold uint64 } // HighestUserWastedPercentRule checks if percentage of wasted bytes is below threshold type HighestUserWastedPercentRule struct { BaseRule threshold float64 } func NewLowestEfficiencyRule(configValue string) (Rule, error) { if isRuleDisabled(configValue) { return DisabledRule(ciKeyLowestEfficiencyThreshold), nil } threshold, err := strconv.ParseFloat(configValue, 64) if err != nil { return nil, fmt.Errorf("invalid %s config value, given %q: %v", ciKeyLowestEfficiencyThreshold, configValue, err) } if threshold < 0 || threshold > 1 { return nil, fmt.Errorf("%s config value is outside allowed range (0-1), given '%f'", ciKeyLowestEfficiencyThreshold, threshold) } return &LowestEfficiencyRule{ BaseRule: BaseRule{ key: ciKeyLowestEfficiencyThreshold, configValue: configValue, }, threshold: threshold, }, nil } func (r *LowestEfficiencyRule) Evaluate(analysis *image.Analysis) (RuleStatus, string) { if r.threshold > analysis.Efficiency { return RuleFailed, fmt.Sprintf( "image efficiency is too low (efficiency=%2.2f < threshold=%v)", analysis.Efficiency, r.threshold) } return RulePassed, "" } // NewHighestWastedBytesRule creates a new rule to check wasted bytes func NewHighestWastedBytesRule(configValue string) (Rule, error) { if isRuleDisabled(configValue) { return DisabledRule(ciKeyHighestWastedBytes), nil } threshold, err := humanize.ParseBytes(configValue) if err != nil { return nil, fmt.Errorf("invalid highestWastedBytes config value, given %q: %v", configValue, err) } return &HighestWastedBytesRule{ BaseRule: BaseRule{ key: ciKeyHighestWastedBytes, configValue: configValue, }, threshold: threshold, }, nil } func (r *HighestWastedBytesRule) Evaluate(analysis *image.Analysis) (RuleStatus, string) { if analysis.WastedBytes > r.threshold { return RuleFailed, fmt.Sprintf( "too many bytes wasted (wasted-bytes=%d > threshold=%v)", analysis.WastedBytes, r.threshold) } return RulePassed, "" } // NewHighestUserWastedPercentRule creates a new rule to check percentage of wasted bytes func NewHighestUserWastedPercentRule(configValue string) (Rule, error) { if isRuleDisabled(configValue) { return DisabledRule(ciKeyHighestUserWastedPercent), nil } threshold, err := strconv.ParseFloat(configValue, 64) if err != nil { return nil, fmt.Errorf("invalid highestUserWastedPercent config value, given %q: %v", configValue, err) } if threshold < 0 || threshold > 1 { return nil, fmt.Errorf("highestUserWastedPercent config value is outside allowed range (0-1), given '%f'", threshold) } return &HighestUserWastedPercentRule{ BaseRule: BaseRule{ key: ciKeyHighestUserWastedPercent, configValue: configValue, }, threshold: threshold, }, nil } func (r *HighestUserWastedPercentRule) Evaluate(analysis *image.Analysis) (RuleStatus, string) { if analysis.WastedUserPercent > r.threshold { return RuleFailed, fmt.Sprintf( "too many bytes wasted, relative to the user bytes added (%%-user-wasted-bytes=%2.2f > threshold=%v)", analysis.WastedUserPercent, r.threshold) } return RulePassed, "" } func isRuleDisabled(value string) bool { value = strings.TrimSpace(strings.ToLower(value)) return value == "" || value == "disabled" || value == "off" || value == "false" } ================================================ FILE: cmd/dive/cli/internal/command/export/export.go ================================================ package export import ( "encoding/json" "github.com/wagoodman/dive/dive/filetree" diveImage "github.com/wagoodman/dive/dive/image" "github.com/wagoodman/dive/internal/log" ) type Export struct { Layer []Layer `json:"layer"` Image Image `json:"image"` } type Layer struct { Index int `json:"index"` ID string `json:"id"` DigestID string `json:"digestId"` SizeBytes uint64 `json:"sizeBytes"` Command string `json:"command"` FileList []filetree.FileInfo `json:"fileList"` } type Image struct { SizeBytes uint64 `json:"sizeBytes"` InefficientBytes uint64 `json:"inefficientBytes"` EfficiencyScore float64 `json:"efficiencyScore"` InefficientFiles []FileReference `json:"fileReference"` } type FileReference struct { References int `json:"count"` SizeBytes uint64 `json:"sizeBytes"` Path string `json:"file"` } // NewExport exports the analysis to a JSON func NewExport(analysis *diveImage.Analysis) *Export { data := Export{ Layer: make([]Layer, len(analysis.Layers)), Image: Image{ InefficientFiles: make([]FileReference, len(analysis.Inefficiencies)), SizeBytes: analysis.SizeBytes, EfficiencyScore: analysis.Efficiency, InefficientBytes: analysis.WastedBytes, }, } // export layers in order for idx, curLayer := range analysis.Layers { layerFileList := make([]filetree.FileInfo, 0) visitor := func(node *filetree.FileNode) error { layerFileList = append(layerFileList, node.Data.FileInfo) return nil } err := curLayer.Tree.VisitDepthChildFirst(visitor, nil) if err != nil { log.WithFields("layer", curLayer.Id, "error", err).Debug("unable to propagate layer tree") } data.Layer[idx] = Layer{ Index: curLayer.Index, ID: curLayer.Id, DigestID: curLayer.Digest, SizeBytes: curLayer.Size, Command: curLayer.Command, FileList: layerFileList, } } // add file references for idx := 0; idx < len(analysis.Inefficiencies); idx++ { fileData := analysis.Inefficiencies[len(analysis.Inefficiencies)-1-idx] data.Image.InefficientFiles[idx] = FileReference{ References: len(fileData.Nodes), SizeBytes: uint64(fileData.CumulativeSize), Path: fileData.Path, } } return &data } func (exp *Export) Marshal() ([]byte, error) { return json.MarshalIndent(&exp, "", " ") } ================================================ FILE: cmd/dive/cli/internal/command/export/export_test.go ================================================ package export import ( "testing" "github.com/wagoodman/dive/dive/image/docker" ) func Test_Export(t *testing.T) { result := docker.TestAnalysisFromArchive(t, repoPath(t, ".data/test-docker-image.tar")) export := NewExport(result) payload, err := export.Marshal() if err != nil { t.Errorf("Test_Export: unable to export analysis: %v", err) } snaps.MatchJSON(t, payload) } ================================================ FILE: cmd/dive/cli/internal/command/export/main_test.go ================================================ package export import ( "flag" "github.com/charmbracelet/lipgloss" snapsPkg "github.com/gkampitakis/go-snaps/snaps" "github.com/muesli/termenv" "github.com/stretchr/testify/require" "go.uber.org/atomic" "os" "os/exec" "path/filepath" "strings" "testing" ) var ( updateSnapshot = flag.Bool("update", false, "update any test snapshots") snaps *snapsPkg.Config repoRootCache atomic.String ) func TestMain(m *testing.M) { // flags are not parsed until after test.Main is called... flag.Parse() os.Unsetenv("DIVE_CONFIG") // disable colors lipgloss.SetColorProfile(termenv.Ascii) snaps = snapsPkg.WithConfig( snapsPkg.Update(*updateSnapshot), snapsPkg.Dir("testdata/snapshots"), ) v := m.Run() snapsPkg.Clean(m) os.Exit(v) } func TestUpdateSnapshotDisabled(t *testing.T) { require.False(t, *updateSnapshot, "update snapshot flag should be disabled") } func repoPath(t testing.TB, path string) string { t.Helper() root := repoRoot(t) return filepath.Join(root, path) } func repoRoot(t testing.TB) string { val := repoRootCache.Load() if val != "" { return val } t.Helper() // use git to find the root of the repo out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output() if err != nil { t.Fatalf("failed to get repo root: %v", err) } val = strings.TrimSpace(string(out)) repoRootCache.Store(val) return val } ================================================ FILE: cmd/dive/cli/internal/command/export/testdata/snapshots/export_test.snap ================================================ [Test_Export - 1] { "image": { "efficiencyScore": 0.9844212134184309, "fileReference": [ { "count": 2, "file": "/root/saved.txt", "sizeBytes": 12810 }, { "count": 2, "file": "/root/example/somefile1.txt", "sizeBytes": 12810 }, { "count": 2, "file": "/root/example/somefile3.txt", "sizeBytes": 6405 } ], "inefficientBytes": 32025, "sizeBytes": 1220598 }, "layer": [ { "command": "#(nop) ADD file:ce026b62356eec3ad1214f92be2c9dc063fe205bd5e600be3492c4dfb17148bd in / ", "digestId": "sha256:23bc2b70b2014dec0ac22f27bb93e9babd08cdd6f1115d0c955b9ff22b382f5a", "fileList": [ { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "", "path": "bin/[", "size": 1075464, "typeFlag": 48, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/[[", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/acpid", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/add-shell", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/addgroup", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/adduser", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/adjtimex", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ar", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/arch", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/arp", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/arping", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ash", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/awk", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/base64", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/basename", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/beep", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/blkdiscard", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/blkid", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/blockdev", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/bootchartd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/brctl", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/bunzip2", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/busybox", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/bzcat", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/bzip2", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/cal", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/cat", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/chat", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/chattr", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/chgrp", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/chmod", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/chown", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/chpasswd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/chpst", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/chroot", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/chrt", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/chvt", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/cksum", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/clear", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/cmp", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/comm", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/conspy", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/cp", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/cpio", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/crond", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/crontab", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/cryptpw", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/cttyhack", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/cut", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/date", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/dc", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/dd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/deallocvt", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/delgroup", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/deluser", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/depmod", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/devmem", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/df", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/dhcprelay", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/diff", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/dirname", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/dmesg", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/dnsd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/dnsdomainname", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/dos2unix", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/dpkg", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/dpkg-deb", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/du", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/dumpkmap", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/dumpleases", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/echo", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ed", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/egrep", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/eject", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/env", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/envdir", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/envuidgid", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ether-wake", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/expand", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/expr", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/factor", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/fakeidentd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/fallocate", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/false", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/fatattr", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/fbset", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/fbsplash", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/fdflush", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/fdformat", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/fdisk", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/fgconsole", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/fgrep", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/find", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/findfs", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/flock", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/fold", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/free", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/freeramdisk", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/fsck", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/fsck.minix", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/fsfreeze", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/fstrim", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/fsync", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ftpd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ftpget", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ftpput", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/fuser", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "", "path": "bin/getconf", "size": 77880, "typeFlag": 48, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/getopt", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/getty", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/grep", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/groups", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/gunzip", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/gzip", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/halt", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/hd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/hdparm", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/head", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/hexdump", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/hexedit", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/hostid", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/hostname", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/httpd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/hush", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/hwclock", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/i2cdetect", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/i2cdump", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/i2cget", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/i2cset", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/id", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ifconfig", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ifdown", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ifenslave", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ifplugd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ifup", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/inetd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/init", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/insmod", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/install", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ionice", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/iostat", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ip", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ipaddr", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ipcalc", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ipcrm", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ipcs", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/iplink", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ipneigh", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/iproute", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/iprule", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/iptunnel", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/kbd_mode", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/kill", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/killall", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/killall5", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/klogd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/last", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/less", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/link", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/linux32", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/linux64", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/linuxrc", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ln", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/loadfont", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/loadkmap", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/logger", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/login", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/logname", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/logread", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/losetup", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/lpd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/lpq", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/lpr", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ls", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/lsattr", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/lsmod", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/lsof", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/lspci", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/lsscsi", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/lsusb", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/lzcat", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/lzma", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/lzop", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/makedevs", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/makemime", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/man", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/md5sum", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/mdev", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/mesg", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/microcom", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/mkdir", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/mkdosfs", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/mke2fs", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/mkfifo", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/mkfs.ext2", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/mkfs.minix", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/mkfs.vfat", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/mknod", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/mkpasswd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/mkswap", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/mktemp", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/modinfo", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/modprobe", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/more", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/mount", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/mountpoint", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/mpstat", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/mt", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/mv", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/nameif", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/nanddump", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/nandwrite", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/nbd-client", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/nc", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/netstat", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/nice", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/nl", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/nmeter", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/nohup", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/nproc", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/nsenter", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/nslookup", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ntpd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/nuke", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/od", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/openvt", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/partprobe", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/passwd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/paste", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/patch", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/pgrep", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/pidof", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ping", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ping6", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/pipe_progress", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/pivot_root", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/pkill", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/pmap", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/popmaildir", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/poweroff", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/powertop", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/printenv", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/printf", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ps", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/pscan", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/pstree", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/pwd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/pwdx", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/raidautorun", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/rdate", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/rdev", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/readahead", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/readlink", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/readprofile", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/realpath", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/reboot", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/reformime", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/remove-shell", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/renice", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/reset", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/resize", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/resume", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/rev", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/rm", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/rmdir", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/rmmod", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/route", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/rpm", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/rpm2cpio", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/rtcwake", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/run-init", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/run-parts", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/runlevel", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/runsv", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/runsvdir", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/rx", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/script", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/scriptreplay", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/sed", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/sendmail", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/seq", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/setarch", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/setconsole", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/setfattr", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/setfont", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/setkeycodes", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/setlogcons", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/setpriv", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/setserial", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/setsid", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/setuidgid", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/sh", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/sha1sum", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/sha256sum", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/sha3sum", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/sha512sum", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/showkey", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/shred", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/shuf", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/slattach", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/sleep", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/smemcap", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/softlimit", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/sort", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/split", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ssl_client", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/start-stop-daemon", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/stat", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/strings", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/stty", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/su", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/sulogin", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/sum", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/sv", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/svc", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/svlogd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/svok", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/swapoff", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/swapon", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/switch_root", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/sync", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/sysctl", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/syslogd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/tac", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/tail", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/tar", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/taskset", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/tc", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/tcpsvd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/tee", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/telnet", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/telnetd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/test", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/tftp", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/tftpd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/time", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/timeout", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/top", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/touch", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/tr", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/traceroute", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/traceroute6", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/true", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/truncate", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/tty", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ttysize", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/tunctl", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ubiattach", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ubidetach", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ubimkvol", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ubirename", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ubirmvol", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ubirsvol", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ubiupdatevol", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/udhcpc", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/udhcpd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/udpsvd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/uevent", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/umount", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/uname", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/unexpand", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/uniq", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/unix2dos", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/unlink", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/unlzma", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/unshare", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/unxz", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/unzip", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/uptime", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/users", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/usleep", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/uudecode", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/uuencode", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/vconfig", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/vi", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/vlock", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/volname", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/w", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/wall", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/watch", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/watchdog", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/wc", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/wget", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/which", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/who", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/whoami", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/whois", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/xargs", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/xxd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/xz", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/xzcat", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/yes", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/zcat", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/zcip", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 2147484141, "gid": 0, "isDir": true, "linkName": "", "path": "bin", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 2147484141, "gid": 0, "isDir": true, "linkName": "", "path": "dev", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 436, "gid": 0, "isDir": false, "linkName": "", "path": "etc/group", "size": 307, "typeFlag": 48, "uid": 0 }, { "fileMode": 420, "gid": 0, "isDir": false, "linkName": "", "path": "etc/localtime", "size": 127, "typeFlag": 48, "uid": 0 }, { "fileMode": 2147484141, "gid": 0, "isDir": true, "linkName": "", "path": "etc/network/if-down.d", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 2147484141, "gid": 0, "isDir": true, "linkName": "", "path": "etc/network/if-post-down.d", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 2147484141, "gid": 0, "isDir": true, "linkName": "", "path": "etc/network/if-pre-up.d", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 2147484141, "gid": 0, "isDir": true, "linkName": "", "path": "etc/network/if-up.d", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 2147484141, "gid": 0, "isDir": true, "linkName": "", "path": "etc/network", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 420, "gid": 0, "isDir": false, "linkName": "", "path": "etc/passwd", "size": 340, "typeFlag": 48, "uid": 0 }, { "fileMode": 384, "gid": 0, "isDir": false, "linkName": "", "path": "etc/shadow", "size": 243, "typeFlag": 48, "uid": 0 }, { "fileMode": 2147484141, "gid": 0, "isDir": true, "linkName": "", "path": "etc", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 2147484141, "gid": 65534, "isDir": true, "linkName": "", "path": "home", "size": 0, "typeFlag": 53, "uid": 65534 }, { "fileMode": 2147484096, "gid": 0, "isDir": true, "linkName": "", "path": "root", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 2148532735, "gid": 0, "isDir": true, "linkName": "", "path": "tmp", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 2147484141, "gid": 1, "isDir": true, "linkName": "", "path": "usr/sbin", "size": 0, "typeFlag": 53, "uid": 1 }, { "fileMode": 2147484141, "gid": 0, "isDir": true, "linkName": "", "path": "usr", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 2147484141, "gid": 8, "isDir": true, "linkName": "", "path": "var/spool/mail", "size": 0, "typeFlag": 53, "uid": 8 }, { "fileMode": 2147484141, "gid": 0, "isDir": true, "linkName": "", "path": "var/spool", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 2147484141, "gid": 0, "isDir": true, "linkName": "", "path": "var/www", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 2147484141, "gid": 0, "isDir": true, "linkName": "", "path": "var", "size": 0, "typeFlag": 53, "uid": 0 } ], "id": "28cfe03618aa2e914e81fdd90345245c15f4478e35252c06ca52d238fd3cc694", "index": 0, "sizeBytes": 1154361 }, { "command": "#(nop) ADD file:139c3708fb6261126453e34483abd8bf7b26ed16d952fd976994d68e72d93be2 in /somefile.txt ", "digestId": "sha256:a65b7d7ac139a0e4337bc3c73ce511f937d6140ef61a0108f7d4b8aab8d67274", "fileList": [ { "fileMode": 436, "gid": 0, "isDir": false, "linkName": "", "path": "somefile.txt", "size": 6405, "typeFlag": 48, "uid": 0 } ], "id": "1871059774abe6914075e4a919b778fa1561f577d620ae52438a9635e6241936", "index": 1, "sizeBytes": 6405 }, { "command": "mkdir -p /root/example/really/nested", "digestId": "sha256:93e208d471756ffbac88cf9c25feb442007f221d3bd73231e27b747a0a68927c", "fileList": [ { "fileMode": 2147484141, "gid": 0, "isDir": true, "linkName": "", "path": "root/example/really/nested", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 2147484141, "gid": 0, "isDir": true, "linkName": "", "path": "root/example/really", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 2147484141, "gid": 0, "isDir": true, "linkName": "", "path": "root/example", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 2147484096, "gid": 0, "isDir": true, "linkName": "", "path": "root", "size": 0, "typeFlag": 53, "uid": 0 } ], "id": "49fe2a475548bfa4d493fc796fce41f30704e3d4cbff3e45dd3e06f463236d1d", "index": 2, "sizeBytes": 0 }, { "command": "cp /somefile.txt /root/example/somefile1.txt", "digestId": "sha256:4abad3abe3cb99ad7a492a9d9f6b3d66287c1646843c74128bbbec4f7be5aa9e", "fileList": [ { "fileMode": 420, "gid": 0, "isDir": false, "linkName": "", "path": "root/example/somefile1.txt", "size": 6405, "typeFlag": 48, "uid": 0 }, { "fileMode": 2147484141, "gid": 0, "isDir": true, "linkName": "", "path": "root/example", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 2147484096, "gid": 0, "isDir": true, "linkName": "", "path": "root", "size": 0, "typeFlag": 53, "uid": 0 } ], "id": "80cd2ca1ffc89962b9349c80280c2bc551acbd11e09b16badb0669f8e2369020", "index": 3, "sizeBytes": 6405 }, { "command": "chmod 444 /root/example/somefile1.txt", "digestId": "sha256:14c9a6ffcb6a0f32d1035f97373b19608e2d307961d8be156321c3f1c1504cbf", "fileList": [ { "fileMode": 292, "gid": 0, "isDir": false, "linkName": "", "path": "root/example/somefile1.txt", "size": 6405, "typeFlag": 48, "uid": 0 }, { "fileMode": 2147484141, "gid": 0, "isDir": true, "linkName": "", "path": "root/example", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 2147484096, "gid": 0, "isDir": true, "linkName": "", "path": "root", "size": 0, "typeFlag": 53, "uid": 0 } ], "id": "c99e2f8d3f6282668f0d30dc1db5e67a51d7a1dcd7ff6ddfa0f90760836778ec", "index": 4, "sizeBytes": 6405 }, { "command": "cp /somefile.txt /root/example/somefile2.txt", "digestId": "sha256:778fb5770ef466f314e79cc9dc418eba76bfc0a64491ce7b167b76aa52c736c4", "fileList": [ { "fileMode": 420, "gid": 0, "isDir": false, "linkName": "", "path": "root/example/somefile2.txt", "size": 6405, "typeFlag": 48, "uid": 0 }, { "fileMode": 2147484141, "gid": 0, "isDir": true, "linkName": "", "path": "root/example", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 2147484096, "gid": 0, "isDir": true, "linkName": "", "path": "root", "size": 0, "typeFlag": 53, "uid": 0 } ], "id": "5eca617bdc3bc06134fe957a30da4c57adb7c340a6d749c8edc4c15861c928d7", "index": 5, "sizeBytes": 6405 }, { "command": "cp /somefile.txt /root/example/somefile3.txt", "digestId": "sha256:f275b8a31a71deb521cc048e6021e2ff6fa52bedb25c9b7bbe129a0195ddca5f", "fileList": [ { "fileMode": 420, "gid": 0, "isDir": false, "linkName": "", "path": "root/example/somefile3.txt", "size": 6405, "typeFlag": 48, "uid": 0 }, { "fileMode": 2147484141, "gid": 0, "isDir": true, "linkName": "", "path": "root/example", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 2147484096, "gid": 0, "isDir": true, "linkName": "", "path": "root", "size": 0, "typeFlag": 53, "uid": 0 } ], "id": "f07c3eb887572395408f8e11a07af945e4da5f02b3188bb06b93fad713ca0b99", "index": 6, "sizeBytes": 6405 }, { "command": "mv /root/example/somefile3.txt /root/saved.txt", "digestId": "sha256:dd1effc5eb19894c3e9b57411c98dd1cf30fa1de4253c7fae53c9cea67267d83", "fileList": [ { "fileMode": 0, "gid": 0, "isDir": false, "linkName": "", "path": "root/example/.wh.somefile3.txt", "size": 0, "typeFlag": 48, "uid": 0 }, { "fileMode": 2147484141, "gid": 0, "isDir": true, "linkName": "", "path": "root/example", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 420, "gid": 0, "isDir": false, "linkName": "", "path": "root/saved.txt", "size": 6405, "typeFlag": 48, "uid": 0 }, { "fileMode": 2147484096, "gid": 0, "isDir": true, "linkName": "", "path": "root", "size": 0, "typeFlag": 53, "uid": 0 } ], "id": "461885fc22589158dee3c5b9f01cc41c87805439f58b4399d733b51aa305cbf9", "index": 7, "sizeBytes": 6405 }, { "command": "cp /root/saved.txt /root/.saved.txt", "digestId": "sha256:8d1869a0a066cdd12e48d648222866e77b5e2814f773bb3bd8774ab4052f0f1d", "fileList": [ { "fileMode": 420, "gid": 0, "isDir": false, "linkName": "", "path": "root/.saved.txt", "size": 6405, "typeFlag": 48, "uid": 0 }, { "fileMode": 2147484096, "gid": 0, "isDir": true, "linkName": "", "path": "root", "size": 0, "typeFlag": 53, "uid": 0 } ], "id": "a10327f68ffed4afcba78919052809a8f774978a6b87fc117d39c53c4842f72c", "index": 8, "sizeBytes": 6405 }, { "command": "rm -rf /root/example/", "digestId": "sha256:bc2e36423fa31a97223fd421f22c35466220fa160769abf697b8eb58c896b468", "fileList": [ { "fileMode": 0, "gid": 0, "isDir": false, "linkName": "", "path": "root/.wh.example", "size": 0, "typeFlag": 48, "uid": 0 }, { "fileMode": 2147484096, "gid": 0, "isDir": true, "linkName": "", "path": "root", "size": 0, "typeFlag": 53, "uid": 0 } ], "id": "f2fc54e25cb7966dc9732ec671a77a1c5c104e732bd15ad44a2dc1ac42368f84", "index": 9, "sizeBytes": 0 }, { "command": "#(nop) ADD dir:7ec14b81316baa1a31c38c97686a8f030c98cba2035c968412749e33e0c4427e in /root/.data/ ", "digestId": "sha256:7f648d45ee7b6de2292162fba498b66cbaaf181da9004fcceef824c72dbae445", "fileList": [ { "fileMode": 509, "gid": 0, "isDir": false, "linkName": "", "path": "root/.data/tag.sh", "size": 917, "typeFlag": 48, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "", "path": "root/.data/test.sh", "size": 1270, "typeFlag": 48, "uid": 0 }, { "fileMode": 2147484141, "gid": 0, "isDir": true, "linkName": "", "path": "root/.data", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 2147484096, "gid": 0, "isDir": true, "linkName": "", "path": "root", "size": 0, "typeFlag": 53, "uid": 0 } ], "id": "aad36d0b05e71c7e6d4dfe0ca9ed6be89e2e0d8995dafe83438299a314e91071", "index": 10, "sizeBytes": 2187 }, { "command": "cp /root/saved.txt /tmp/saved.again1.txt", "digestId": "sha256:a4b8f95f266d5c063c9a9473c45f2f85ddc183e37941b5e6b6b9d3c00e8e0457", "fileList": [ { "fileMode": 420, "gid": 0, "isDir": false, "linkName": "", "path": "tmp/saved.again1.txt", "size": 6405, "typeFlag": 48, "uid": 0 }, { "fileMode": 2148532735, "gid": 0, "isDir": true, "linkName": "", "path": "tmp", "size": 0, "typeFlag": 53, "uid": 0 } ], "id": "3d4ad907517a021d86a4102d2764ad2161e4818bbd144e41d019bfc955434181", "index": 11, "sizeBytes": 6405 }, { "command": "cp /root/saved.txt /root/.data/saved.again2.txt", "digestId": "sha256:22a44d45780a541e593a8862d80f3e14cb80b6bf76aa42ce68dc207a35bf3a4a", "fileList": [ { "fileMode": 420, "gid": 0, "isDir": false, "linkName": "", "path": "root/.data/saved.again2.txt", "size": 6405, "typeFlag": 48, "uid": 0 }, { "fileMode": 2147484141, "gid": 0, "isDir": true, "linkName": "", "path": "root/.data", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 2147484096, "gid": 0, "isDir": true, "linkName": "", "path": "root", "size": 0, "typeFlag": 53, "uid": 0 } ], "id": "81b1b002d4b4c1325a9cad9990b5277e7f29f79e0f24582344c0891178f95905", "index": 12, "sizeBytes": 6405 }, { "command": "chmod +x /root/saved.txt", "digestId": "sha256:ba689cac6a98c92d121fa5c9716a1bab526b8bb1fd6d43625c575b79e97300c5", "fileList": [ { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "", "path": "root/saved.txt", "size": 6405, "typeFlag": 48, "uid": 0 }, { "fileMode": 2147484096, "gid": 0, "isDir": true, "linkName": "", "path": "root", "size": 0, "typeFlag": 53, "uid": 0 } ], "id": "cfb35bb5c127d848739be5ca726057e6e2c77b2849f588e7aebb642c0d3d4b7b", "index": 13, "sizeBytes": 6405 } ] } --- ================================================ FILE: cmd/dive/cli/internal/command/root.go ================================================ package command import ( "context" "errors" "fmt" "github.com/anchore/clio" "github.com/spf13/afero" "github.com/spf13/cobra" "github.com/wagoodman/dive/cmd/dive/cli/internal/command/adapter" "github.com/wagoodman/dive/cmd/dive/cli/internal/options" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui" "github.com/wagoodman/dive/dive" "github.com/wagoodman/dive/dive/image" "github.com/wagoodman/dive/internal/bus" "os" ) type rootOptions struct { options.Application `yaml:",inline" mapstructure:",squash"` // reserved for future use of root-only flags } func Root(app clio.Application) *cobra.Command { opts := &rootOptions{ Application: options.DefaultApplication(), } return app.SetupRootCommand(&cobra.Command{ Use: "dive [IMAGE]", Short: "Docker Image Visualizer & Explorer", Long: `This tool provides a way to discover and explore the contents of a docker image. Additionally the tool estimates the amount of wasted space and identifies the offending files from the image.`, Args: func(cmd *cobra.Command, args []string) error { if len(args) != 1 { return fmt.Errorf("exactly one argument is required") } opts.Analysis.Image = args[0] return nil }, RunE: func(cmd *cobra.Command, _ []string) error { if err := setUI(app, opts.Application); err != nil { return fmt.Errorf("failed to set UI: %w", err) } resolver, err := dive.GetImageResolver(opts.Analysis.Source) if err != nil { return fmt.Errorf("cannot determine image provider to fetch from: %w", err) } ctx := cmd.Context() img, err := adapter.ImageResolver(resolver).Fetch(ctx, opts.Analysis.Image) if err != nil { return fmt.Errorf("cannot load image: %w", err) } return run(ctx, opts.Application, img, resolver) }, }, opts) } func setUI(app clio.Application, opts options.Application) error { type Stater interface { State() *clio.State } state := app.(Stater).State() ux := ui.NewV1UI(opts.V1Preferences(), os.Stdout, state.Config.Log.Quiet, state.Config.Log.Verbosity) return state.UI.Replace(ux) } func run(ctx context.Context, opts options.Application, img *image.Image, content image.ContentReader) error { analysis, err := adapter.NewAnalyzer().Analyze(ctx, img) if err != nil { return fmt.Errorf("cannot analyze image: %w", err) } if opts.Export.JsonPath != "" { if err := adapter.NewExporter(afero.NewOsFs()).ExportTo(ctx, analysis, opts.Export.JsonPath); err != nil { return fmt.Errorf("cannot export analysis: %w", err) } return nil } if opts.CI.Enabled { eval := adapter.NewEvaluator(opts.CI.Rules.List).Evaluate(ctx, analysis) if !eval.Pass { return errors.New("evaluation failed") } return nil } bus.ExploreAnalysis(*analysis, content) return nil } ================================================ FILE: cmd/dive/cli/internal/options/analysis.go ================================================ package options import ( "fmt" "github.com/anchore/clio" "github.com/scylladb/go-set/strset" "github.com/wagoodman/dive/dive" "github.com/wagoodman/dive/internal/log" "strings" ) const defaultContainerEngine = "docker" var _ interface { clio.PostLoader clio.FieldDescriber } = (*Analysis)(nil) // Analysis provides configuration for the image analysis behavior type Analysis struct { Image string `yaml:"image" mapstructure:"-"` ContainerEngine string `yaml:"container-engine" mapstructure:"container-engine"` Source dive.ImageSource `yaml:"-" mapstructure:"-"` IgnoreErrors bool `yaml:"ignore-errors" mapstructure:"ignore-errors"` AvailableContainerEngines []string `yaml:"-" mapstructure:"-"` } func DefaultAnalysis() Analysis { return Analysis{ ContainerEngine: defaultContainerEngine, IgnoreErrors: false, AvailableContainerEngines: dive.ImageSources, } } func (c *Analysis) DescribeFields(descriptions clio.FieldDescriptionSet) { descriptions.Add(&c.ContainerEngine, "container engine to use for image analysis (supported options: 'docker' and 'podman')") descriptions.Add(&c.IgnoreErrors, "continue with analysis even if there are errors parsing the image archive") } func (c *Analysis) AddFlags(flags clio.FlagSet) { flags.StringVarP(&c.ContainerEngine, "source", "", fmt.Sprintf("The container engine to fetch the image from. Allowed values: %s", strings.Join(c.AvailableContainerEngines, ", "))) flags.BoolVarP(&c.IgnoreErrors, "ignore-errors", "i", "ignore image parsing errors and run the analysis anyway") } func (c *Analysis) PostLoad() error { validEngines := strset.New(c.AvailableContainerEngines...) if !validEngines.Has(c.ContainerEngine) { log.Warnf("invalid container engine: %s (valid options: %s), using default %q", c.ContainerEngine, strings.Join(c.AvailableContainerEngines, ", "), defaultContainerEngine) c.ContainerEngine = "docker" } if c.Image != "" { sourceType, imageStr := dive.DeriveImageSource(c.Image) if sourceType == dive.SourceUnknown { sourceType = dive.ParseImageSource(c.ContainerEngine) if sourceType == dive.SourceUnknown { return fmt.Errorf("unable to determine image source from %q: %v\n", c.Image, c.ContainerEngine) } // use exactly what the user provided imageStr = c.Image } c.Image = imageStr c.Source = sourceType } else { c.Source = dive.ParseImageSource(c.ContainerEngine) } return nil } ================================================ FILE: cmd/dive/cli/internal/options/application.go ================================================ package options import ( "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1" ) type Application struct { Analysis Analysis `yaml:",inline" mapstructure:",squash"` CI CI `yaml:",inline" mapstructure:",squash"` Export Export `yaml:",inline" mapstructure:",squash"` UI UI `yaml:",inline" mapstructure:",squash"` } func DefaultApplication() Application { return Application{ Analysis: DefaultAnalysis(), CI: DefaultCI(), Export: DefaultExport(), UI: DefaultUI(), } } func (c Application) V1Preferences() v1.Preferences { return v1.Preferences{ KeyBindings: c.UI.Keybinding.Config, ShowFiletreeAttributes: c.UI.Filetree.ShowAttributes, ShowAggregatedLayerChanges: c.UI.Layer.ShowAggregatedChanges, CollapseFiletreeDirectory: c.UI.Filetree.CollapseDir, FiletreePaneWidth: c.UI.Filetree.PaneWidth, FiletreeDiffHide: nil, } } ================================================ FILE: cmd/dive/cli/internal/options/ci.go ================================================ package options import ( "fmt" "github.com/anchore/clio" "gopkg.in/yaml.v3" "os" ) var _ interface { clio.PostLoader clio.FieldDescriber clio.FlagAdder } = (*CI)(nil) const defaultCIConfigPath = ".dive-ci" type CI struct { Enabled bool `yaml:"ci" mapstructure:"ci"` ConfigPath string `yaml:"ci-config" mapstructure:"ci-config"` Rules CIRules `yaml:"rules" mapstructure:"rules"` } func DefaultCI() CI { return CI{ Enabled: false, ConfigPath: defaultCIConfigPath, Rules: DefaultCIRules(), } } func (c *CI) DescribeFields(descriptions clio.FieldDescriptionSet) { descriptions.Add(&c.Enabled, "enable CI mode") descriptions.Add(&c.ConfigPath, "path to the CI config file") } func (c *CI) AddFlags(flags clio.FlagSet) { flags.BoolVarP(&c.Enabled, "ci", "", "skip the interactive TUI and validate against CI rules (same as env var CI=true)") flags.StringVarP(&c.ConfigPath, "ci-config", "", "if CI=true in the environment, use the given yaml to drive validation rules.") } func (c *CI) PostLoad() error { enabledFromEnv := truthy(os.Getenv("CI")) if !c.Enabled && enabledFromEnv { c.Enabled = true } if c.ConfigPath != "" { if fileExists(c.ConfigPath) { // if a config file is provided, load it and override any values provided in the application config. // If we're hitting this case we should pretend that only the config file was provided and applied // on top of the default config values. yamlFile, err := os.ReadFile(c.ConfigPath) if err != nil { return fmt.Errorf("failed to read CI config file %s: %w", c.ConfigPath, err) } def := DefaultCIRules() r := legacyRuleFile{ LowestEfficiencyThresholdString: def.LowestEfficiencyThresholdString, HighestWastedBytesString: def.HighestWastedBytesString, HighestUserWastedPercentString: def.HighestUserWastedPercentString, } wrapper := struct { Rules *legacyRuleFile `yaml:"rules"` }{ Rules: &r, } if err := yaml.Unmarshal(yamlFile, &wrapper); err != nil { return fmt.Errorf("failed to unmarshal CI config file %s: %w", c.ConfigPath, err) } // TODO: should this be a deprecated use warning in the future? c.Rules = CIRules{ LowestEfficiencyThresholdString: r.LowestEfficiencyThresholdString, HighestWastedBytesString: r.HighestWastedBytesString, HighestUserWastedPercentString: r.HighestUserWastedPercentString, } } } return nil } type legacyRuleFile struct { LowestEfficiencyThresholdString string `yaml:"lowestEfficiency"` HighestWastedBytesString string `yaml:"highestWastedBytes"` HighestUserWastedPercentString string `yaml:"highestUserWastedPercent"` } func fileExists(path string) bool { if _, err := os.Stat(path); os.IsNotExist(err) { return false } return true } func truthy(value string) bool { switch value { case "true", "1", "yes": return true case "false", "0", "no": return false default: return false } } ================================================ FILE: cmd/dive/cli/internal/options/ci_rules.go ================================================ package options import ( "github.com/anchore/clio" "github.com/wagoodman/dive/cmd/dive/cli/internal/command/ci" "github.com/wagoodman/dive/internal/log" ) type CIRules struct { LowestEfficiencyThresholdString string `yaml:"lowest-efficiency" mapstructure:"lowest-efficiency"` LegacyLowestEfficiencyThresholdString string `yaml:"-" mapstructure:"lowestEfficiency"` HighestWastedBytesString string `yaml:"highest-wasted-bytes" mapstructure:"highest-wasted-bytes"` LegacyHighestWastedBytesString string `yaml:"-" mapstructure:"highestWastedBytes"` HighestUserWastedPercentString string `yaml:"highest-user-wasted-percent" mapstructure:"highest-user-wasted-percent"` LegacyHighestUserWastedPercentString string `yaml:"-" mapstructure:"highestUserWastedPercent"` List []ci.Rule `yaml:"-" mapstructure:"-"` } func DefaultCIRules() CIRules { return CIRules{ LowestEfficiencyThresholdString: "0.9", HighestWastedBytesString: "disabled", HighestUserWastedPercentString: "0.1", } } func (c *CIRules) DescribeFields(descriptions clio.FieldDescriptionSet) { descriptions.Add(&c.LowestEfficiencyThresholdString, "lowest allowable image efficiency (as a ratio between 0-1), otherwise CI validation will fail.") descriptions.Add(&c.HighestWastedBytesString, "highest allowable bytes wasted, otherwise CI validation will fail.") descriptions.Add(&c.HighestUserWastedPercentString, "highest allowable percentage of bytes wasted (as a ratio between 0-1), otherwise CI validation will fail.") } func (c *CIRules) AddFlags(flags clio.FlagSet) { flags.StringVarP(&c.LowestEfficiencyThresholdString, "lowestEfficiency", "", "(only valid with --ci given) lowest allowable image efficiency (as a ratio between 0-1), otherwise CI validation will fail.") flags.StringVarP(&c.HighestWastedBytesString, "highestWastedBytes", "", "(only valid with --ci given) highest allowable bytes wasted, otherwise CI validation will fail.") flags.StringVarP(&c.HighestUserWastedPercentString, "highestUserWastedPercent", "", "(only valid with --ci given) highest allowable percentage of bytes wasted (as a ratio between 0-1), otherwise CI validation will fail.") } func (c CIRules) hasLegacyOptionsInUse() bool { return c.LegacyLowestEfficiencyThresholdString != "" || c.LegacyHighestWastedBytesString != "" || c.LegacyHighestUserWastedPercentString != "" } func (c *CIRules) PostLoad() error { // protect against repeated calls c.List = nil if c.hasLegacyOptionsInUse() { log.Warnf("please specify ci rules in snake-case (the legacy camelCase format is deprecated)") } if c.LegacyLowestEfficiencyThresholdString != "" { c.LowestEfficiencyThresholdString = c.LegacyLowestEfficiencyThresholdString } if c.LegacyHighestWastedBytesString != "" { c.HighestWastedBytesString = c.LegacyHighestWastedBytesString } if c.LegacyHighestUserWastedPercentString != "" { c.HighestUserWastedPercentString = c.LegacyHighestUserWastedPercentString } rules, err := ci.Rules(c.LowestEfficiencyThresholdString, c.HighestWastedBytesString, c.HighestUserWastedPercentString) if err != nil { return err } c.List = append(c.List, rules...) return nil } ================================================ FILE: cmd/dive/cli/internal/options/export.go ================================================ package options import ( "fmt" "os" "path" "github.com/anchore/clio" ) var _ interface { clio.FlagAdder clio.PostLoader } = (*Export)(nil) // Export provides configuration for data export functionality type Export struct { // Path to export analysis results as JSON (empty string = disabled) JsonPath string `yaml:"json-path" json:"json-path" mapstructure:"json-path"` } func DefaultExport() Export { return Export{} } func (o *Export) AddFlags(flags clio.FlagSet) { flags.StringVarP(&o.JsonPath, "json", "j", "Skip the interactive TUI and write the layer analysis statistics to a given file.") } func (o *Export) PostLoad() error { if o.JsonPath != "" { dir := path.Dir(o.JsonPath) if _, err := os.Stat(dir); os.IsNotExist(err) { return fmt.Errorf("directory for JSON export does not exist: %s", dir) } } return nil } ================================================ FILE: cmd/dive/cli/internal/options/ui.go ================================================ package options // UI combines all UI configuration elements type UI struct { Keybinding UIKeybindings `yaml:"keybinding" mapstructure:"keybinding"` Diff UIDiff `yaml:"diff" mapstructure:"diff"` Filetree UIFiletree `yaml:"filetree" mapstructure:"filetree"` Layer UILayers `yaml:"layer" mapstructure:"layer"` } func DefaultUI() UI { return UI{ Keybinding: DefaultUIKeybinding(), Diff: DefaultUIDiff(), Filetree: DefaultUIFiletree(), Layer: DefaultUILayers(), } } ================================================ FILE: cmd/dive/cli/internal/options/ui_diff.go ================================================ package options import ( "github.com/anchore/clio" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1" "github.com/wagoodman/dive/internal/log" ) var _ interface { clio.PostLoader clio.FieldDescriber } = (*UIDiff)(nil) // UIDiff provides configuration for how differences are displayed type UIDiff struct { Hide []string `yaml:"hide" mapstructure:"hide"` } func DefaultUIDiff() UIDiff { prefs := v1.DefaultPreferences() return UIDiff{ Hide: prefs.FiletreeDiffHide, } } func (c *UIDiff) DescribeFields(descriptions clio.FieldDescriptionSet) { descriptions.Add(&c.Hide, "types of file differences to hide (added, removed, modified, unmodified)") } func (c *UIDiff) PostLoad() error { validHideValues := map[string]bool{"added": true, "removed": true, "modified": true, "unmodified": true} for _, value := range c.Hide { if _, ok := validHideValues[value]; !ok { log.Warnf("invalid diff hide value: %s (valid values: added, removed, modified, unmodified)", value) } } return nil } ================================================ FILE: cmd/dive/cli/internal/options/ui_filetree.go ================================================ package options import ( "github.com/anchore/clio" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1" "github.com/wagoodman/dive/internal/log" ) var _ interface { clio.PostLoader clio.FieldDescriber } = (*UIFiletree)(nil) // UIFiletree provides configuration for the file tree display type UIFiletree struct { CollapseDir bool `yaml:"collapse-dir" mapstructure:"collapse-dir"` PaneWidth float64 `yaml:"pane-width" mapstructure:"pane-width"` ShowAttributes bool `yaml:"show-attributes" mapstructure:"show-attributes"` } func DefaultUIFiletree() UIFiletree { prefs := v1.DefaultPreferences() return UIFiletree{ CollapseDir: prefs.CollapseFiletreeDirectory, PaneWidth: prefs.FiletreePaneWidth, ShowAttributes: prefs.ShowFiletreeAttributes, } } func (c *UIFiletree) DescribeFields(descriptions clio.FieldDescriptionSet) { descriptions.Add(&c.CollapseDir, "collapse directories by default in the filetree") descriptions.Add(&c.PaneWidth, "percentage of screen width for the filetree pane (must be >0 and <1)") descriptions.Add(&c.ShowAttributes, "show file attributes in the filetree view") } func (c *UIFiletree) PostLoad() error { // Validate pane width is between 0 and 1 if c.PaneWidth <= 0 || c.PaneWidth >= 1 { log.Warnf("filetree pane-width must be >0 and <1, got %v, resetting to default 0.5", c.PaneWidth) c.PaneWidth = 0.5 } return nil } ================================================ FILE: cmd/dive/cli/internal/options/ui_keybindings.go ================================================ package options import ( "github.com/anchore/clio" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/key" "reflect" ) var _ interface { clio.FieldDescriber } = (*UIKeybindings)(nil) // UIKeybindings provides configuration for all keyboard shortcuts type UIKeybindings struct { Global GlobalBindings `yaml:",inline" mapstructure:",squash"` Navigation NavigationBindings `yaml:",inline" mapstructure:",squash"` Layer LayerBindings `yaml:",inline" mapstructure:",squash"` Filetree FiletreeBindings `yaml:",inline" mapstructure:",squash"` Config key.Bindings `yaml:"-" mapstructure:"-"` } type GlobalBindings struct { Quit string `yaml:"quit" mapstructure:"quit"` ToggleView string `yaml:"toggle-view" mapstructure:"toggle-view"` FilterFiles string `yaml:"filter-files" mapstructure:"filter-files"` CloseFilterFiles string `yaml:"close-filter-files" mapstructure:"close-filter-files"` } type NavigationBindings struct { Up string `yaml:"up" mapstructure:"up"` Down string `yaml:"down" mapstructure:"down"` Left string `yaml:"left" mapstructure:"left"` Right string `yaml:"right" mapstructure:"right"` PageUp string `yaml:"page-up" mapstructure:"page-up"` PageDown string `yaml:"page-down" mapstructure:"page-down"` } type LayerBindings struct { CompareAll string `yaml:"compare-all" mapstructure:"compare-all"` CompareLayer string `yaml:"compare-layer" mapstructure:"compare-layer"` } type FiletreeBindings struct { ToggleCollapseDir string `yaml:"toggle-collapse-dir" mapstructure:"toggle-collapse-dir"` ToggleCollapseAllDir string `yaml:"toggle-collapse-all-dir" mapstructure:"toggle-collapse-all-dir"` ToggleAddedFiles string `yaml:"toggle-added-files" mapstructure:"toggle-added-files"` ToggleRemovedFiles string `yaml:"toggle-removed-files" mapstructure:"toggle-removed-files"` ToggleModifiedFiles string `yaml:"toggle-modified-files" mapstructure:"toggle-modified-files"` ToggleUnmodifiedFiles string `yaml:"toggle-unmodified-files" mapstructure:"toggle-unmodified-files"` ToggleTreeAttributes string `yaml:"toggle-filetree-attributes" mapstructure:"toggle-filetree-attributes"` ToggleSortOrder string `yaml:"toggle-sort-order" mapstructure:"toggle-sort-order"` ToggleWrapTree string `yaml:"toggle-wrap-tree" mapstructure:"toggle-wrap-tree"` ExtractFile string `yaml:"extract-file" mapstructure:"extract-file"` } func DefaultUIKeybinding() UIKeybindings { var result UIKeybindings defaults := key.DefaultBindings() // converts from key.Bindings to UIKeybindings getUIBindingValues(reflect.ValueOf(defaults), reflect.ValueOf(&result).Elem()) return result } func getUIBindingValues(src, dst reflect.Value) { switch src.Kind() { case reflect.Struct: for i := 0; i < src.NumField(); i++ { srcField := src.Field(i) srcType := src.Type().Field(i) if !srcField.CanInterface() { continue } dstField := dst.FieldByName(srcType.Name) if !dstField.IsValid() || !dstField.CanSet() { continue } if srcType.Type.Name() == "Config" { inputField := srcField.FieldByName("Input") if inputField.IsValid() && dstField.Kind() == reflect.String { dstField.SetString(inputField.String()) } continue } getUIBindingValues(srcField, dstField) } } } func (c *UIKeybindings) PostLoad() error { cfg := key.Bindings{} // convert UIKeybindings to key.Bindings err := createKeyBindings(reflect.ValueOf(c).Elem(), reflect.ValueOf(&cfg).Elem()) if err != nil { return err } c.Config = cfg return nil } func createKeyBindings(src, dst reflect.Value) error { switch dst.Kind() { case reflect.Struct: for i := 0; i < dst.NumField(); i++ { dstField := dst.Field(i) dstType := dst.Type().Field(i) if !dstField.CanSet() { continue } srcField := src.FieldByName(dstType.Name) if !srcField.IsValid() { continue } if dstType.Type.Name() == "Config" { inputField := dstField.FieldByName("Input") if inputField.IsValid() && inputField.CanSet() && srcField.Kind() == reflect.String { inputField.SetString(srcField.String()) // call the Setup method if it exists setupMethod := dstField.Addr().MethodByName("Setup") if setupMethod.IsValid() { result := setupMethod.Call([]reflect.Value{}) if !result[0].IsNil() { return result[0].Interface().(error) } } } continue } err := createKeyBindings(srcField, dstField) if err != nil { return err } } } return nil } func (c *UIKeybindings) DescribeFields(descriptions clio.FieldDescriptionSet) { // global keybindings descriptions.Add(&c.Global.Quit, "quit the application (global)") descriptions.Add(&c.Global.ToggleView, "toggle between different views (global)") descriptions.Add(&c.Global.FilterFiles, "filter files by name (global)") descriptions.Add(&c.Global.CloseFilterFiles, "close file filtering (global)") // navigation keybindings descriptions.Add(&c.Navigation.Up, "move cursor up (global)") descriptions.Add(&c.Navigation.Down, "move cursor down (global)") descriptions.Add(&c.Navigation.Left, "move cursor left (global)") descriptions.Add(&c.Navigation.Right, "move cursor right (global)") descriptions.Add(&c.Navigation.PageUp, "scroll page up (file view)") descriptions.Add(&c.Navigation.PageDown, "scroll page down (file view)") // layer view keybindings descriptions.Add(&c.Layer.CompareAll, "compare all layers (layer view)") descriptions.Add(&c.Layer.CompareLayer, "compare specific layer (layer view)") // file view keybindings descriptions.Add(&c.Filetree.ToggleCollapseDir, "toggle directory collapse (file view)") descriptions.Add(&c.Filetree.ToggleCollapseAllDir, "toggle collapse all directories (file view)") descriptions.Add(&c.Filetree.ToggleAddedFiles, "toggle visibility of added files (file view)") descriptions.Add(&c.Filetree.ToggleRemovedFiles, "toggle visibility of removed files (file view)") descriptions.Add(&c.Filetree.ToggleModifiedFiles, "toggle visibility of modified files (file view)") descriptions.Add(&c.Filetree.ToggleUnmodifiedFiles, "toggle visibility of unmodified files (file view)") descriptions.Add(&c.Filetree.ToggleTreeAttributes, "toggle display of file attributes (file view)") descriptions.Add(&c.Filetree.ToggleSortOrder, "toggle sort order (file view)") descriptions.Add(&c.Filetree.ExtractFile, "extract file contents (file view)") } ================================================ FILE: cmd/dive/cli/internal/options/ui_layers.go ================================================ package options import "github.com/anchore/clio" var _ clio.FieldDescriber = (*UILayers)(nil) // UILayers provides configuration for layer display behavior type UILayers struct { ShowAggregatedChanges bool `yaml:"show-aggregated-changes" mapstructure:"show-aggregated-changes"` } func DefaultUILayers() UILayers { return UILayers{ ShowAggregatedChanges: false, } } func (c *UILayers) DescribeFields(descriptions clio.FieldDescriptionSet) { descriptions.Add(&c.ShowAggregatedChanges, "show aggregated changes across all previous layers") } ================================================ FILE: cmd/dive/cli/internal/ui/no_ui.go ================================================ package ui import ( "github.com/wagoodman/go-partybus" "github.com/anchore/clio" ) var _ clio.UI = (*NoUI)(nil) type NoUI struct { subscription partybus.Unsubscribable } func None() *NoUI { return &NoUI{} } func (n *NoUI) Setup(subscription partybus.Unsubscribable) error { n.subscription = subscription return nil } func (n *NoUI) Handle(_ partybus.Event) error { return nil } func (n NoUI) Teardown(_ bool) error { return nil } ================================================ FILE: cmd/dive/cli/internal/ui/v1/app/app.go ================================================ package app import ( "errors" "github.com/awesome-gocui/gocui" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/key" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/layout" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/layout/compound" "golang.org/x/net/context" "time" ) const debug = false type app struct { gui *gocui.Gui controller *controller layout *layout.Manager } // Run is the UI entrypoint. func Run(ctx context.Context, c v1.Config) error { var err error // it appears there is a race condition where termbox.Init() will // block nearly indefinitely when running as the first process in // a Docker container when started within ~25ms of container startup. // I can't seem to determine the exact root cause, however, a large // enough sleep will prevent this behavior (todo: remove this hack) time.Sleep(100 * time.Millisecond) g, err := gocui.NewGui(gocui.OutputNormal, true) if err != nil { return err } defer g.Close() _, err = newApp(ctx, g, c) if err != nil { return err } k, mod := gocui.MustParse("Ctrl+Z") if err := g.SetKeybinding("", k, mod, handle_ctrl_z); err != nil { return err } if err := g.MainLoop(); err != nil && !errors.Is(err, gocui.ErrQuit) { return err } return nil } func newApp(ctx context.Context, gui *gocui.Gui, cfg v1.Config) (*app, error) { var err error var c *controller var globalHelpKeys []*key.Binding c, err = newController(ctx, gui, cfg) if err != nil { return nil, err } // note: order matters when adding elements to the layout lm := layout.NewManager() lm.Add(c.views.Status, layout.LocationFooter) lm.Add(c.views.Filter, layout.LocationFooter) lm.Add(compound.NewLayerDetailsCompoundLayout(c.views.Layer, c.views.LayerDetails, c.views.ImageDetails), layout.LocationColumn) lm.Add(c.views.Tree, layout.LocationColumn) // todo: access this more programmatically if debug { lm.Add(c.views.Debug, layout.LocationColumn) } gui.Cursor = false // g.Mouse = true gui.SetManagerFunc(lm.Layout) a := &app{ gui: gui, controller: c, layout: lm, } var infos = []key.BindingInfo{ { Config: cfg.Preferences.KeyBindings.Global.Quit, OnAction: a.quit, Display: "Quit", }, { Config: cfg.Preferences.KeyBindings.Global.ToggleView, OnAction: c.ToggleView, Display: "Switch view", }, { Config: cfg.Preferences.KeyBindings.Navigation.Right, OnAction: c.NextPane, }, { Config: cfg.Preferences.KeyBindings.Navigation.Left, OnAction: c.PrevPane, }, { Config: cfg.Preferences.KeyBindings.Global.FilterFiles, OnAction: c.ToggleFilterView, IsSelected: c.views.Filter.IsVisible, Display: "Filter", }, { Config: cfg.Preferences.KeyBindings.Global.CloseFilterFiles, OnAction: c.CloseFilterView, }, } globalHelpKeys, err = key.GenerateBindings(gui, "", infos) if err != nil { return nil, err } c.views.Status.AddHelpKeys(globalHelpKeys...) // perform the first update and render now that all resources have been loaded err = c.UpdateAndRender() if err != nil { return nil, err } return a, err } // quit is the gocui callback invoked when the user hits Ctrl+C func (a *app) quit() error { return gocui.ErrQuit } ================================================ FILE: cmd/dive/cli/internal/ui/v1/app/controller.go ================================================ package app import ( "fmt" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/view" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/viewmodel" "golang.org/x/net/context" "regexp" "github.com/awesome-gocui/gocui" ) type controller struct { gui *gocui.Gui views *view.Views config v1.Config ctx context.Context // TODO: storing context in the controller is not ideal } func newController(ctx context.Context, g *gocui.Gui, cfg v1.Config) (*controller, error) { views, err := view.NewViews(g, cfg) if err != nil { return nil, err } c := &controller{ gui: g, views: views, config: cfg, ctx: ctx, } // layer view cursor down event should trigger an update in the file tree c.views.Layer.AddLayerChangeListener(c.onLayerChange) // update the status pane when a filetree option is changed by the user c.views.Tree.AddViewOptionChangeListener(c.onFileTreeViewOptionChange) // update the status pane when a filetree option is changed by the user c.views.Tree.AddViewExtractListener(c.onFileTreeViewExtract) // update the tree view while the user types into the filter view c.views.Filter.AddFilterEditListener(c.onFilterEdit) // propagate initial conditions to necessary views err = c.onLayerChange(viewmodel.LayerSelection{ Layer: c.views.Layer.CurrentLayer(), BottomTreeStart: 0, BottomTreeStop: 0, TopTreeStart: 0, TopTreeStop: 0, }) if err != nil { return nil, err } return c, nil } func (c *controller) onFileTreeViewExtract(p string) error { return c.config.Content.Extract(c.ctx, c.config.Analysis.Image, c.views.LayerDetails.CurrentLayer.Id, p) } func (c *controller) onFileTreeViewOptionChange() error { err := c.views.Status.Update() if err != nil { return err } return c.views.Status.Render() } func (c *controller) onFilterEdit(filter string) error { var filterRegex *regexp.Regexp var err error if len(filter) > 0 { filterRegex, err = regexp.Compile(filter) if err != nil { return err } } c.views.Tree.SetFilterRegex(filterRegex) err = c.views.Tree.Update() if err != nil { return err } return c.views.Tree.Render() } func (c *controller) onLayerChange(selection viewmodel.LayerSelection) error { // update the details c.views.LayerDetails.CurrentLayer = selection.Layer // update the filetree err := c.views.Tree.SetTree(selection.BottomTreeStart, selection.BottomTreeStop, selection.TopTreeStart, selection.TopTreeStop) if err != nil { return err } if c.views.Layer.CompareMode() == viewmodel.CompareAllLayers { c.views.Tree.SetTitle("Aggregated Layer Contents") } else { c.views.Tree.SetTitle("Current Layer Contents") } // update details and filetree panes return c.UpdateAndRender() } func (c *controller) UpdateAndRender() error { err := c.Update() if err != nil { return fmt.Errorf("controller failed update: %w", err) } err = c.Render() if err != nil { return fmt.Errorf("controller failed render: %w", err) } return nil } // Update refreshes the state objects for future rendering. func (c *controller) Update() error { for _, v := range c.views.Renderers() { err := v.Update() if err != nil { return fmt.Errorf("controller unable to update view: %w", err) } } return nil } // Render flushes the state objects to the screen. func (c *controller) Render() error { for _, v := range c.views.Renderers() { if v.IsVisible() { err := v.Render() if err != nil { return err } } } return nil } //nolint:dupl func (c *controller) NextPane() (err error) { v := c.gui.CurrentView() if v == nil { panic("CurrentView is nil") } if v.Name() == c.views.Layer.Name() { _, err = c.gui.SetCurrentView(c.views.LayerDetails.Name()) c.views.Status.SetCurrentView(c.views.LayerDetails) } else if v.Name() == c.views.LayerDetails.Name() { _, err = c.gui.SetCurrentView(c.views.ImageDetails.Name()) c.views.Status.SetCurrentView(c.views.ImageDetails) } else if v.Name() == c.views.ImageDetails.Name() { _, err = c.gui.SetCurrentView(c.views.Layer.Name()) c.views.Status.SetCurrentView(c.views.Layer) } if err != nil { return fmt.Errorf("controller unable to switch to next pane: %w", err) } return c.UpdateAndRender() } //nolint:dupl func (c *controller) PrevPane() (err error) { v := c.gui.CurrentView() if v == nil { panic("Current view is nil") } if v.Name() == c.views.Layer.Name() { _, err = c.gui.SetCurrentView(c.views.ImageDetails.Name()) c.views.Status.SetCurrentView(c.views.ImageDetails) } else if v.Name() == c.views.LayerDetails.Name() { _, err = c.gui.SetCurrentView(c.views.Layer.Name()) c.views.Status.SetCurrentView(c.views.Layer) } else if v.Name() == c.views.ImageDetails.Name() { _, err = c.gui.SetCurrentView(c.views.LayerDetails.Name()) c.views.Status.SetCurrentView(c.views.LayerDetails) } if err != nil { return fmt.Errorf("controller unable to switch to previous pane: %w", err) } return c.UpdateAndRender() } // ToggleView switches between the file view and the layer view and re-renders the screen. func (c *controller) ToggleView() (err error) { v := c.gui.CurrentView() if v == nil || v.Name() == c.views.Layer.Name() { _, err = c.gui.SetCurrentView(c.views.Tree.Name()) c.views.Status.SetCurrentView(c.views.Tree) } else { _, err = c.gui.SetCurrentView(c.views.Layer.Name()) c.views.Status.SetCurrentView(c.views.Layer) } if err != nil { return fmt.Errorf("controller unable to toggle view: %w", err) } return c.UpdateAndRender() } func (c *controller) CloseFilterView() error { // filter view needs to be visible if c.views.Filter.IsVisible() { // toggle filter view return c.ToggleFilterView() } return nil } func (c *controller) ToggleFilterView() error { // delete all user input from the tree view err := c.views.Filter.ToggleVisible() if err != nil { return fmt.Errorf("unable to toggle filter visibility: %w", err) } // we have just hidden the filter view... if !c.views.Filter.IsVisible() { // ...remove any filter from the tree c.views.Tree.SetFilterRegex(nil) // ...adjust focus to a valid (visible) view err = c.ToggleView() if err != nil { return fmt.Errorf("unable to toggle filter view (back): %w", err) } } return c.UpdateAndRender() } ================================================ FILE: cmd/dive/cli/internal/ui/v1/app/job_control_other.go ================================================ //go:build windows // +build windows package app import ( "github.com/awesome-gocui/gocui" ) // handle ctrl+z not supported on windows func handle_ctrl_z(_ *gocui.Gui, _ *gocui.View) error { return nil } ================================================ FILE: cmd/dive/cli/internal/ui/v1/app/job_control_unix.go ================================================ //go:build !windows // +build !windows package app import ( "syscall" "github.com/awesome-gocui/gocui" ) // handle ctrl+z func handle_ctrl_z(g *gocui.Gui, v *gocui.View) error { gocui.Suspend() if err := syscall.Kill(syscall.Getpid(), syscall.SIGSTOP); err != nil { return err } return gocui.Resume() } ================================================ FILE: cmd/dive/cli/internal/ui/v1/config.go ================================================ package v1 import ( "errors" "fmt" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/key" "github.com/wagoodman/dive/dive/filetree" "github.com/wagoodman/dive/dive/image" "golang.org/x/net/context" "sync" ) type Config struct { // required input Analysis image.Analysis Content ContentReader Preferences Preferences stack filetree.Comparer stackErrs error do *sync.Once } type Preferences struct { KeyBindings key.Bindings IgnoreErrors bool ShowFiletreeAttributes bool ShowAggregatedLayerChanges bool CollapseFiletreeDirectory bool FiletreePaneWidth float64 FiletreeDiffHide []string } func DefaultPreferences() Preferences { return Preferences{ KeyBindings: key.DefaultBindings(), ShowFiletreeAttributes: true, ShowAggregatedLayerChanges: true, CollapseFiletreeDirectory: false, // don't start with collapsed directories FiletreePaneWidth: 0.5, FiletreeDiffHide: []string{}, // empty slice means show all } } func (c *Config) TreeComparer() (filetree.Comparer, error) { if c.do == nil { c.do = &sync.Once{} } c.do.Do(func() { treeStack := filetree.NewComparer(c.Analysis.RefTrees) errs := treeStack.BuildCache() if errs != nil { if !c.Preferences.IgnoreErrors { errs = append(errs, fmt.Errorf("file tree has path errors (use '--ignore-errors' to attempt to continue)")) c.stackErrs = errors.Join(errs...) return } } c.stack = treeStack }) return c.stack, c.stackErrs } type ContentReader interface { Extract(ctx context.Context, id string, layer string, path string) error } ================================================ FILE: cmd/dive/cli/internal/ui/v1/format/format.go ================================================ package format import ( "fmt" "strings" "github.com/fatih/color" "github.com/lunixbochs/vtclean" ) const ( // selectedLeftBracketStr = " " // selectedRightBracketStr = " " // selectedFillStr = " " // //leftBracketStr = "▏" //rightBracketStr = "▕" //fillStr = "─" // selectedLeftBracketStr = " " // selectedRightBracketStr = " " // selectedFillStr = "━" // //leftBracketStr = "▏" //rightBracketStr = "▕" //fillStr = "─" selectedLeftBracketStr = "┃" selectedRightBracketStr = "┣" selectedFillStr = "━" leftBracketStr = "│" rightBracketStr = "├" fillStr = "─" selectStr = " ● " // selectStr = " " ) var ( Header func(...interface{}) string Selected func(...interface{}) string StatusSelected func(...interface{}) string StatusNormal func(...interface{}) string StatusControlSelected func(...interface{}) string StatusControlNormal func(...interface{}) string CompareTop func(...interface{}) string CompareBottom func(...interface{}) string reset = color.New(color.Reset).Sprint("") ) func init() { wrapper := func(fn func(a ...any) string) func(a ...any) string { return func(a ...any) string { // for some reason not all color formatter functions are not applying RESET, we'll add it manually for now return fn(a...) + reset } } Selected = wrapper(color.New(color.ReverseVideo, color.Bold).SprintFunc()) Header = wrapper(color.New(color.Bold).SprintFunc()) StatusSelected = wrapper(color.New(color.BgMagenta, color.FgWhite).SprintFunc()) StatusNormal = wrapper(color.New(color.ReverseVideo).SprintFunc()) StatusControlSelected = wrapper(color.New(color.BgMagenta, color.FgWhite, color.Bold).SprintFunc()) StatusControlNormal = wrapper(color.New(color.ReverseVideo, color.Bold).SprintFunc()) CompareTop = wrapper(color.New(color.BgMagenta).SprintFunc()) CompareBottom = wrapper(color.New(color.BgGreen).SprintFunc()) } func RenderNoHeader(width int, selected bool) string { if selected { return strings.Repeat(selectedFillStr, width) } return strings.Repeat(fillStr, width) } func RenderHeader(title string, width int, selected bool) string { if selected { body := Header(fmt.Sprintf("%s%s ", selectStr, title)) bodyLen := len(vtclean.Clean(body, false)) repeatCount := width - bodyLen - 2 if repeatCount < 0 { repeatCount = 0 } return fmt.Sprintf("%s%s%s%s\n", selectedLeftBracketStr, body, selectedRightBracketStr, strings.Repeat(selectedFillStr, repeatCount)) // return fmt.Sprintf("%s%s%s%s\n", Selected(selectedLeftBracketStr), body, Selected(selectedRightBracketStr), Selected(strings.Repeat(selectedFillStr, width-bodyLen-2))) // return fmt.Sprintf("%s%s%s%s\n", Selected(selectedLeftBracketStr), body, Selected(selectedRightBracketStr), strings.Repeat(selectedFillStr, width-bodyLen-2)) } body := Header(fmt.Sprintf(" %s ", title)) bodyLen := len(vtclean.Clean(body, false)) repeatCount := width - bodyLen - 2 if repeatCount < 0 { repeatCount = 0 } return fmt.Sprintf("%s%s%s%s\n", leftBracketStr, body, rightBracketStr, strings.Repeat(fillStr, repeatCount)) } func RenderHelpKey(control, title string, selected bool) string { if selected { return StatusSelected("▏") + StatusControlSelected(control) + StatusSelected(" "+title+" ") } else { return StatusNormal("▏") + StatusControlNormal(control) + StatusNormal(" "+title+" ") } } ================================================ FILE: cmd/dive/cli/internal/ui/v1/key/binding.go ================================================ package key import ( "fmt" "github.com/awesome-gocui/gocui" "github.com/awesome-gocui/keybinding" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/format" ) type BindingInfo struct { Key gocui.Key Modifier gocui.Modifier Config Config OnAction func() error IsSelected func() bool Display string } type Binding struct { key []keybinding.Key displayName string selectedFn func() bool actionFn func() error } func GenerateBindings(gui *gocui.Gui, influence string, infos []BindingInfo) ([]*Binding, error) { var result = make([]*Binding, 0) for _, info := range infos { if len(info.Config.Keys) == 0 { return nil, fmt.Errorf("no keybinding configured for '%s'", info.Display) } binding, err := newBinding(gui, influence, info.Config.Keys, info.Display, info.OnAction) if err != nil { return nil, err } if info.IsSelected != nil { binding.RegisterSelectionFn(info.IsSelected) } if len(info.Display) > 0 { result = append(result, binding) } } return result, nil } func newBinding(gui *gocui.Gui, influence string, keys []keybinding.Key, displayName string, actionFn func() error) (*Binding, error) { binding := &Binding{ key: keys, displayName: displayName, actionFn: actionFn, } for _, key := range keys { if err := gui.SetKeybinding(influence, key.Value, key.Modifier, binding.onAction); err != nil { return nil, err } } return binding, nil } func (binding *Binding) RegisterSelectionFn(selectedFn func() bool) { binding.selectedFn = selectedFn } func (binding *Binding) onAction(*gocui.Gui, *gocui.View) error { if binding.actionFn == nil { return fmt.Errorf("no action configured for '%+v'", binding) } return binding.actionFn() } func (binding *Binding) isSelected() bool { if binding.selectedFn == nil { return false } return binding.selectedFn() } func (binding *Binding) RenderKeyHelp() string { return format.RenderHelpKey(binding.key[0].String(), binding.displayName, binding.isSelected()) } ================================================ FILE: cmd/dive/cli/internal/ui/v1/key/config.go ================================================ package key import ( "fmt" "github.com/awesome-gocui/keybinding" ) type Config struct { Input string Keys []keybinding.Key `yaml:"-" mapstructure:"-"` } func (c *Config) Setup() error { if len(c.Input) == 0 { return nil } parsed, err := keybinding.ParseAll(c.Input) if err != nil { return fmt.Errorf("failed to parse key %q: %w", c.Input, err) } c.Keys = parsed return nil } type Bindings struct { Global GlobalBindings `yaml:",inline" mapstructure:",squash"` Navigation NavigationBindings `yaml:",inline" mapstructure:",squash"` Layer LayerBindings `yaml:",inline" mapstructure:",squash"` Filetree FiletreeBindings `yaml:",inline" mapstructure:",squash"` } type GlobalBindings struct { Quit Config `yaml:"quit" mapstructure:"quit"` ToggleView Config `yaml:"toggle-view" mapstructure:"toggle-view"` FilterFiles Config `yaml:"filter-files" mapstructure:"filter-files"` CloseFilterFiles Config `yaml:"close-filter-files" mapstructure:"close-filter-files"` } type NavigationBindings struct { Up Config `yaml:"up" mapstructure:"up"` Down Config `yaml:"down" mapstructure:"down"` Left Config `yaml:"left" mapstructure:"left"` Right Config `yaml:"right" mapstructure:"right"` PageUp Config `yaml:"page-up" mapstructure:"page-up"` PageDown Config `yaml:"page-down" mapstructure:"page-down"` } type LayerBindings struct { CompareAll Config `yaml:"compare-all" mapstructure:"compare-all"` CompareLayer Config `yaml:"compare-layer" mapstructure:"compare-layer"` } type FiletreeBindings struct { ToggleCollapseDir Config `yaml:"toggle-collapse-dir" mapstructure:"toggle-collapse-dir"` ToggleCollapseAllDir Config `yaml:"toggle-collapse-all-dir" mapstructure:"toggle-collapse-all-dir"` ToggleAddedFiles Config `yaml:"toggle-added-files" mapstructure:"toggle-added-files"` ToggleRemovedFiles Config `yaml:"toggle-removed-files" mapstructure:"toggle-removed-files"` ToggleModifiedFiles Config `yaml:"toggle-modified-files" mapstructure:"toggle-modified-files"` ToggleUnmodifiedFiles Config `yaml:"toggle-unmodified-files" mapstructure:"toggle-unmodified-files"` ToggleTreeAttributes Config `yaml:"toggle-filetree-attributes" mapstructure:"toggle-filetree-attributes"` ToggleSortOrder Config `yaml:"toggle-sort-order" mapstructure:"toggle-sort-order"` ToggleWrapTree Config `yaml:"toggle-wrap-tree" mapstructure:"toggle-wrap-tree"` ExtractFile Config `yaml:"extract-file" mapstructure:"extract-file"` } func DefaultBindings() Bindings { return Bindings{ Global: GlobalBindings{ Quit: Config{Input: "ctrl+c"}, ToggleView: Config{Input: "tab"}, FilterFiles: Config{Input: "ctrl+f, ctrl+slash"}, CloseFilterFiles: Config{Input: "esc"}, }, Navigation: NavigationBindings{ Up: Config{Input: "up,k"}, Down: Config{Input: "down,j"}, Left: Config{Input: "left,h"}, Right: Config{Input: "right,l"}, PageUp: Config{Input: "pgup,u"}, PageDown: Config{Input: "pgdn,d"}, }, Layer: LayerBindings{ CompareAll: Config{Input: "ctrl+a"}, CompareLayer: Config{Input: "ctrl+l"}, }, Filetree: FiletreeBindings{ ToggleCollapseDir: Config{Input: "space"}, ToggleCollapseAllDir: Config{Input: "ctrl+space"}, ToggleAddedFiles: Config{Input: "ctrl+a"}, ToggleRemovedFiles: Config{Input: "ctrl+r"}, ToggleModifiedFiles: Config{Input: "ctrl+m"}, ToggleUnmodifiedFiles: Config{Input: "ctrl+u"}, ToggleTreeAttributes: Config{Input: "ctrl+b"}, ToggleWrapTree: Config{Input: "ctrl+p"}, ToggleSortOrder: Config{Input: "ctrl+o"}, ExtractFile: Config{Input: "ctrl+e"}, }, } } ================================================ FILE: cmd/dive/cli/internal/ui/v1/layout/area.go ================================================ package layout type Area struct { minX, minY, maxX, maxY int } ================================================ FILE: cmd/dive/cli/internal/ui/v1/layout/compound/layer_details_column.go ================================================ package compound import ( "fmt" "github.com/awesome-gocui/gocui" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/view" "github.com/wagoodman/dive/internal/log" "github.com/wagoodman/dive/internal/utils" ) type LayerDetailsCompoundLayout struct { layer *view.Layer layerDetails *view.LayerDetails imageDetails *view.ImageDetails constrainRealEstate bool } func NewLayerDetailsCompoundLayout(layer *view.Layer, layerDetails *view.LayerDetails, imageDetails *view.ImageDetails) *LayerDetailsCompoundLayout { return &LayerDetailsCompoundLayout{ layer: layer, layerDetails: layerDetails, imageDetails: imageDetails, } } func (cl *LayerDetailsCompoundLayout) Name() string { return "layer-details-compound-column" } // OnLayoutChange is called whenever the screen dimensions are changed func (cl *LayerDetailsCompoundLayout) OnLayoutChange() error { err := cl.layer.OnLayoutChange() if err != nil { return fmt.Errorf("unable to setup layer controller onLayoutChange: %w", err) } err = cl.layerDetails.OnLayoutChange() if err != nil { return fmt.Errorf("unable to setup layer details controller onLayoutChange: %w", err) } err = cl.imageDetails.OnLayoutChange() if err != nil { return fmt.Errorf("unable to setup image details controller onLayoutChange: %w", err) } return nil } func (cl *LayerDetailsCompoundLayout) layoutRow(g *gocui.Gui, minX, minY, maxX, maxY int, viewName string, setup func(*gocui.View, *gocui.View) error) error { log.WithFields("ui", cl.Name()).Tracef("layoutRow(g, minX: %d, minY: %d, maxX: %d, maxY: %d, viewName: %s, )", minX, minY, maxX, maxY, viewName) // header + border headerHeight := 2 // TODO: investigate overlap // note: maxY needs to account for the (invisible) border, thus a +1 headerView, headerErr := g.SetView(viewName+"Header", minX, minY, maxX, minY+headerHeight+1, 0) // we are going to overlap the view over the (invisible) border (so minY will be one less than expected) bodyView, bodyErr := g.SetView(viewName, minX, minY+headerHeight, maxX, maxY, 0) if utils.IsNewView(bodyErr, headerErr) { err := setup(bodyView, headerView) if err != nil { return fmt.Errorf("unable to setup row layout for %s: %w", viewName, err) } } return nil } func (cl *LayerDetailsCompoundLayout) Layout(g *gocui.Gui, minX, minY, maxX, maxY int) error { log.WithFields("ui", cl.Name()).Tracef("layout(minX: %d, minY: %d, maxX: %d, maxY: %d)", minX, minY, maxX, maxY) layouts := []view.View{ cl.layer, cl.layerDetails, cl.imageDetails, } rowHeight := maxY / 3 for i := 0; i < 3; i++ { if err := cl.layoutRow(g, minX, i*rowHeight, maxX, (i+1)*rowHeight, layouts[i].Name(), layouts[i].Setup); err != nil { return fmt.Errorf("unable to layout %q: %w", layouts[i].Name(), err) } } if g.CurrentView() == nil { if _, err := g.SetCurrentView(cl.layer.Name()); err != nil { return fmt.Errorf("unable to set view to layer %q: %w", cl.layer.Name(), err) } } return nil } func (cl *LayerDetailsCompoundLayout) RequestedSize(available int) *int { // "available" is the entire screen real estate, so we can guess when its a bit too small and take action. // This isn't perfect, but it gets the job done for now without complicated layout constraint solvers if available < 90 { cl.layer.ConstrainLayout() cl.constrainRealEstate = true size := 8 return &size } cl.layer.ExpandLayout() cl.constrainRealEstate = false return nil } // todo: make this variable based on the nested views func (cl *LayerDetailsCompoundLayout) IsVisible() bool { return true } ================================================ FILE: cmd/dive/cli/internal/ui/v1/layout/layout.go ================================================ package layout import "github.com/awesome-gocui/gocui" type Layout interface { Name() string Layout(g *gocui.Gui, minX, minY, maxX, maxY int) error RequestedSize(available int) *int IsVisible() bool OnLayoutChange() error } ================================================ FILE: cmd/dive/cli/internal/ui/v1/layout/location.go ================================================ package layout const ( LocationFooter Location = iota LocationHeader LocationColumn ) type Location int ================================================ FILE: cmd/dive/cli/internal/ui/v1/layout/manager.go ================================================ package layout import ( "fmt" "github.com/awesome-gocui/gocui" "github.com/wagoodman/dive/internal/log" ) type Constraint func(int) int type Manager struct { lastX, lastY int lastHeaderArea, lastFooterArea, lastColumnArea Area elements map[Location][]Layout } func NewManager() *Manager { return &Manager{ elements: make(map[Location][]Layout), } } func (lm *Manager) Add(element Layout, location Location) { if _, exists := lm.elements[location]; !exists { lm.elements[location] = make([]Layout, 0) } lm.elements[location] = append(lm.elements[location], element) } func (lm *Manager) planAndLayoutHeaders(g *gocui.Gui, area Area) (Area, error) { // layout headers top down if elements, exists := lm.elements[LocationHeader]; exists { for _, element := range elements { // a visible header cannot take up the whole screen, default to 1. // this eliminates the need to discover a default size based on all element requests height := 0 if element.IsVisible() { requestedHeight := element.RequestedSize(area.maxY) if requestedHeight != nil { height = *requestedHeight } else { height = 1 } } // layout the header within the allocated space err := element.Layout(g, area.minX, area.minY, area.maxX, area.minY+height) if err != nil { log.WithFields("element", element.Name(), "error", err).Error("failed to layout header") return area, err } // restrict the available screen real estate area.minY += height } } return area, nil } func (lm *Manager) planFooters(g *gocui.Gui, area Area) (Area, []int) { var footerHeights = make([]int, 0) // we need to layout the footers last, but account for them when drawing the columns. This block is for planning // out the real estate needed for the footers now (but not laying out yet) if elements, exists := lm.elements[LocationFooter]; exists { footerHeights = make([]int, len(elements)) for idx := range footerHeights { footerHeights[idx] = 1 } for idx, element := range elements { // a visible footer cannot take up the whole screen, default to 1. // this eliminates the need to discover a default size based on all element requests height := 0 if element.IsVisible() { requestedHeight := element.RequestedSize(area.maxY) if requestedHeight != nil { height = *requestedHeight } else { height = 1 } } footerHeights[idx] = height } // restrict the available screen real estate for _, height := range footerHeights { area.maxY -= height } } return area, footerHeights } func (lm *Manager) planAndLayoutColumns(g *gocui.Gui, area Area) (Area, error) { // layout columns left to right if elements, exists := lm.elements[LocationColumn]; exists { widths := make([]int, len(elements)) for idx := range widths { widths[idx] = -1 } variableColumns := len(elements) availableWidth := area.maxX + 1 // first pass: planout the column sizes based on the given requests for idx, element := range elements { if !element.IsVisible() { widths[idx] = 0 variableColumns-- continue } requestedWidth := element.RequestedSize(availableWidth) if requestedWidth != nil { widths[idx] = *requestedWidth variableColumns-- availableWidth -= widths[idx] } } // at least one column must have a variable width, force the last column to be variable if there are no // variable columns if variableColumns == 0 { variableColumns = 1 widths[len(widths)-1] = -1 } defaultWidth := availableWidth / variableColumns // second pass: layout columns left to right (based off predetermined widths) for idx, element := range elements { // use the requested or default width width := widths[idx] if width == -1 { width = defaultWidth } // layout the column within the allocated space err := element.Layout(g, area.minX, area.minY, area.minX+width, area.maxY) if err != nil { return area, fmt.Errorf("failed to layout '%s' column: %w", element.Name(), err) } // move left to right, scratching off real estate as it is taken area.minX += width } } return area, nil } func (lm *Manager) layoutFooters(g *gocui.Gui, area Area, footerHeights []int) error { // layout footers top down (which is why the list is reversed). Top down is needed due to border overlap. if elements, exists := lm.elements[LocationFooter]; exists { for idx := len(elements) - 1; idx >= 0; idx-- { element := elements[idx] height := footerHeights[idx] var topY, bottomY, bottomPadding int for oIdx := 0; oIdx <= idx; oIdx++ { bottomPadding += footerHeights[oIdx] } topY = area.maxY - bottomPadding - height // +1 for border bottomY = topY + height + 1 // layout the footer within the allocated space // note: since the headers and rows are inclusive counting from -1 (to account for a border) we must // do the same vertically, thus a -1 is needed for a starting Y err := element.Layout(g, area.minX, topY, area.maxX, bottomY) if err != nil { return fmt.Errorf("failed to layout %q footer: %w", element.Name(), err) } } } return nil } func (lm *Manager) notifyLayoutChange() error { for _, elements := range lm.elements { for _, element := range elements { err := element.OnLayoutChange() if err != nil { return err } } } return nil } func (lm *Manager) Layout(g *gocui.Gui) error { curMaxX, curMaxY := g.Size() return lm.layout(g, curMaxX, curMaxY) } // layout defines the definition of the window pane size and placement relations to one another. This // is invoked at application start and whenever the screen dimensions change. // A few things to note: // 1. gocui has borders around all views (even if Frame=false). This means there are a lot of +1/-1 magic numbers // needed (but there are comments!). // 2. since there are borders, in order for it to appear as if there aren't any spaces for borders, the views must // overlap. To prevent screen artifacts, all elements must be layedout from the top of the screen to the bottom. func (lm *Manager) layout(g *gocui.Gui, curMaxX, curMaxY int) error { var headerAreaChanged, footerAreaChanged, columnAreaChanged bool // compare current screen size with the last known size at time of layout area := Area{ minX: -1, minY: -1, maxX: curMaxX, maxY: curMaxY, } var hasResized bool if curMaxX != lm.lastX || curMaxY != lm.lastY { hasResized = true } lm.lastX, lm.lastY = curMaxX, curMaxY // pass 1: plan and layout elements // headers... area, err := lm.planAndLayoutHeaders(g, area) if err != nil { return err } if area != lm.lastHeaderArea { headerAreaChanged = true } lm.lastHeaderArea = area // plan footers... don't layout until all columns have been layedout. This is necessary since we must layout from // top to bottom, but we need the real estate planned for the footers to determine the bottom of the columns. var footerArea = area area, footerHeights := lm.planFooters(g, area) if area != lm.lastFooterArea { footerAreaChanged = true } lm.lastFooterArea = area // columns... area, err = lm.planAndLayoutColumns(g, area) if err != nil { return nil } if area != lm.lastColumnArea { columnAreaChanged = true } lm.lastColumnArea = area // footers... layout according to the original available area and planned heights err = lm.layoutFooters(g, footerArea, footerHeights) if err != nil { return nil } // pass 2: notify everyone of a layout change (allow to update and render) // note: this may mean that each element will update and rerender, which may cause a secondary layout call. // the conditions which we notify elements of layout changes must be very selective! if hasResized || headerAreaChanged || footerAreaChanged || columnAreaChanged { return lm.notifyLayoutChange() } return nil } ================================================ FILE: cmd/dive/cli/internal/ui/v1/layout/manager_test.go ================================================ package layout import ( "testing" "github.com/awesome-gocui/gocui" ) type testElement struct { t *testing.T size int layoutArea Area location Location } func newTestElement(t *testing.T, size int, layoutArea Area, location Location) *testElement { return &testElement{ t: t, size: size, layoutArea: layoutArea, location: location, } } func (te *testElement) Name() string { return "dont care" } func (te *testElement) Layout(g *gocui.Gui, minX, minY, maxX, maxY int) error { actualLayoutArea := Area{ minX: minX, minY: minY, maxX: maxX, maxY: maxY, } if te.layoutArea != actualLayoutArea { te.t.Errorf("expected layout area '%+v', got '%+v'", te.layoutArea, actualLayoutArea) } return nil } func (te *testElement) RequestedSize(available int) *int { if te.size == -1 { return nil } return &te.size } func (te *testElement) IsVisible() bool { return true } func (te *testElement) OnLayoutChange() error { return nil } type layoutReturn struct { area Area err error } func Test_planAndLayoutHeaders(t *testing.T) { table := map[string]struct { headers []*testElement expected layoutReturn }{ "single header": { headers: []*testElement{newTestElement(t, 1, Area{ minX: -1, minY: -1, maxX: 120, maxY: 0, }, LocationHeader)}, expected: layoutReturn{ area: Area{ minX: -1, minY: 0, maxX: 120, maxY: 80, }, err: nil, }, }, "two headers": { headers: []*testElement{ newTestElement(t, 1, Area{ minX: -1, minY: -1, maxX: 120, maxY: 0, }, LocationHeader), newTestElement(t, 1, Area{ minX: -1, minY: 0, maxX: 120, maxY: 1, }, LocationHeader), }, expected: layoutReturn{ area: Area{ minX: -1, minY: 1, maxX: 120, maxY: 80, }, err: nil, }, }, "two odd-sized headers": { headers: []*testElement{ newTestElement(t, 2, Area{ minX: -1, minY: -1, maxX: 120, maxY: 1, }, LocationHeader), newTestElement(t, 3, Area{ minX: -1, minY: 1, maxX: 120, maxY: 4, }, LocationHeader), }, expected: layoutReturn{ area: Area{ minX: -1, minY: 4, maxX: 120, maxY: 80, }, err: nil, }, }, } for name, test := range table { t.Log("case: ", name, " ---") lm := NewManager() for _, element := range test.headers { lm.Add(element, element.location) } area, err := lm.planAndLayoutHeaders(nil, Area{ minX: -1, minY: -1, maxX: 120, maxY: 80, }) if err != test.expected.err { t.Errorf("%s: expected err '%+v', got error '%+v'", name, test.expected.err, err) } if area != test.expected.area { t.Errorf("%s: expected returned area '%+v', got area '%+v'", name, test.expected.area, area) } } } func Test_planAndLayoutColumns(t *testing.T) { table := map[string]struct { columns []*testElement expected layoutReturn }{ "single column": { columns: []*testElement{newTestElement(t, -1, Area{ minX: -1, minY: -1, maxX: 120, maxY: 80, }, LocationColumn)}, expected: layoutReturn{ area: Area{ minX: 120, minY: -1, maxX: 120, maxY: 80, }, err: nil, }, }, "two equal columns": { columns: []*testElement{ newTestElement(t, -1, Area{ minX: -1, minY: -1, maxX: 59, maxY: 80, }, LocationColumn), newTestElement(t, -1, Area{ minX: 59, minY: -1, maxX: 119, maxY: 80, }, LocationColumn), }, expected: layoutReturn{ area: Area{ minX: 119, minY: -1, maxX: 120, maxY: 80, }, err: nil, }, }, "two odd-sized columns": { columns: []*testElement{ newTestElement(t, 30, Area{ minX: -1, minY: -1, maxX: 29, maxY: 80, }, LocationColumn), newTestElement(t, -1, Area{ minX: 29, minY: -1, maxX: 120, maxY: 80, }, LocationColumn), }, expected: layoutReturn{ area: Area{ minX: 120, minY: -1, maxX: 120, maxY: 80, }, err: nil, }, }, } for name, test := range table { t.Log("case: ", name, " ---") lm := NewManager() for _, element := range test.columns { lm.Add(element, element.location) } area, err := lm.planAndLayoutColumns(nil, Area{ minX: -1, minY: -1, maxX: 120, maxY: 80, }) if err != test.expected.err { t.Errorf("%s: expected err '%+v', got error '%+v'", name, test.expected.err, err) } if area != test.expected.area { t.Errorf("%s: expected returned area '%+v', got area '%+v'", name, test.expected.area, area) } } } func Test_layout(t *testing.T) { table := map[string]struct { elements []*testElement }{ "1 header + 1 footer + 1 column": { elements: []*testElement{ newTestElement(t, 1, Area{ minX: -1, minY: -1, maxX: 120, maxY: 0, }, LocationHeader), newTestElement(t, 1, Area{ minX: -1, minY: 78, maxX: 120, maxY: 80, }, LocationFooter), newTestElement(t, -1, Area{ minX: -1, minY: 0, maxX: 120, maxY: 79, }, LocationColumn), }, }, "1 header + 1 footer + 3 column": { elements: []*testElement{ newTestElement(t, 1, Area{ minX: -1, minY: -1, maxX: 120, maxY: 0, }, LocationHeader), newTestElement(t, 1, Area{ minX: -1, minY: 78, maxX: 120, maxY: 80, }, LocationFooter), newTestElement(t, -1, Area{ minX: -1, minY: 0, maxX: 39, maxY: 79, }, LocationColumn), newTestElement(t, -1, Area{ minX: 39, minY: 0, maxX: 79, maxY: 79, }, LocationColumn), newTestElement(t, -1, Area{ minX: 79, minY: 0, maxX: 119, maxY: 79, }, LocationColumn), }, }, "1 header + 1 footer + 2 equal columns + 1 sized column": { elements: []*testElement{ newTestElement(t, 1, Area{ minX: -1, minY: -1, maxX: 120, maxY: 0, }, LocationHeader), newTestElement(t, 1, Area{ minX: -1, minY: 78, maxX: 120, maxY: 80, }, LocationFooter), newTestElement(t, -1, Area{ minX: -1, minY: 0, maxX: 19, maxY: 79, }, LocationColumn), newTestElement(t, 80, Area{ minX: 19, minY: 0, maxX: 99, maxY: 79, }, LocationColumn), newTestElement(t, -1, Area{ minX: 99, minY: 0, maxX: 119, maxY: 79, }, LocationColumn), }, }, } for name, test := range table { t.Log("case: ", name, " ---") lm := NewManager() for _, element := range test.elements { lm.Add(element, element.location) } err := lm.layout(nil, 120, 80) if err != nil { t.Fatalf("%s: unexpected error: %+v", name, err) } } } ================================================ FILE: cmd/dive/cli/internal/ui/v1/view/cursor.go ================================================ package view import ( "errors" "github.com/awesome-gocui/gocui" ) // CursorDown moves the cursor down in the currently selected gocui pane, scrolling the screen as needed. func CursorDown(g *gocui.Gui, v *gocui.View) error { return CursorStep(g, v, 1) } // CursorUp moves the cursor up in the currently selected gocui pane, scrolling the screen as needed. func CursorUp(g *gocui.Gui, v *gocui.View) error { return CursorStep(g, v, -1) } // Moves the cursor the given step distance, setting the origin to the new cursor line func CursorStep(g *gocui.Gui, v *gocui.View, step int) error { cx, cy := v.Cursor() // if there isn't a next line line, err := v.Line(cy + step) if err != nil { return err } if len(line) == 0 { return errors.New("unable to move the cursor, empty line") } if err := v.SetCursor(cx, cy+step); err != nil { ox, oy := v.Origin() if err := v.SetOrigin(ox, oy+step); err != nil { return err } } return nil } ================================================ FILE: cmd/dive/cli/internal/ui/v1/view/debug.go ================================================ package view import ( "fmt" "github.com/anchore/go-logger" "github.com/awesome-gocui/gocui" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/format" "github.com/wagoodman/dive/internal/log" "github.com/wagoodman/dive/internal/utils" ) // Debug is just for me :) type Debug struct { name string gui *gocui.Gui view *gocui.View header *gocui.View logger logger.Logger selectedView Helper } // newDebugView creates a new view object attached the global [gocui] screen object. func newDebugView(gui *gocui.Gui) *Debug { c := new(Debug) // populate main fields c.name = "debug" c.gui = gui c.logger = log.Nested("ui", "debug") return c } func (v *Debug) SetCurrentView(r Helper) { v.selectedView = r } func (v *Debug) Name() string { return v.name } // Setup initializes the UI concerns within the context of a global [gocui] view object. func (v *Debug) Setup(view *gocui.View, header *gocui.View) error { v.logger.Trace("setup()") // set controller options v.view = view v.view.Editable = false v.view.Wrap = false v.view.Frame = false v.header = header v.header.Editable = false v.header.Wrap = false v.header.Frame = false return v.Render() } // IsVisible indicates if the status view pane is currently initialized. func (v *Debug) IsVisible() bool { return v != nil } // Update refreshes the state objects for future rendering (currently does nothing). func (v *Debug) Update() error { return nil } // OnLayoutChange is called whenever the screen dimensions are changed func (v *Debug) OnLayoutChange() error { err := v.Update() if err != nil { return err } return v.Render() } // Render flushes the state objects to the screen. func (v *Debug) Render() error { v.logger.Trace("render()") v.gui.Update(func(g *gocui.Gui) error { // update header... v.header.Clear() width, _ := g.Size() headerStr := format.RenderHeader("Debug", width, false) _, _ = fmt.Fprintln(v.header, headerStr) // update view... v.view.Clear() _, err := fmt.Fprintln(v.view, "blerg") if err != nil { v.logger.WithFields("error", err).Debug("unable to write to buffer") } return nil }) return nil } func (v *Debug) Layout(g *gocui.Gui, minX, minY, maxX, maxY int) error { v.logger.Tracef("layout(minX: %d, minY: %d, maxX: %d, maxY: %d)", minX, minY, maxX, maxY) // header headerSize := 1 // note: maxY needs to account for the (invisible) border, thus a +1 header, headerErr := g.SetView(v.Name()+"header", minX, minY, maxX, minY+headerSize+1, 0) // we are going to overlap the view over the (invisible) border (so minY will be one less than expected). // additionally, maxY will be bumped by one to include the border view, viewErr := g.SetView(v.Name(), minX, minY+headerSize, maxX, maxY+1, 0) if utils.IsNewView(viewErr, headerErr) { err := v.Setup(view, header) if err != nil { return fmt.Errorf("unable to setup debug controller: %w", err) } } return nil } func (v *Debug) RequestedSize(available int) *int { return nil } ================================================ FILE: cmd/dive/cli/internal/ui/v1/view/filetree.go ================================================ package view import ( "fmt" "github.com/anchore/go-logger" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/format" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/key" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/viewmodel" "github.com/wagoodman/dive/internal/log" "github.com/wagoodman/dive/internal/utils" "regexp" "github.com/awesome-gocui/gocui" "github.com/wagoodman/dive/dive/filetree" ) type ViewOptionChangeListener func() error type ViewExtractListener func(string) error // FileTree holds the UI objects and data models for populating the right pane. Specifically, the pane that // shows selected layer or aggregate file ASCII tree. type FileTree struct { name string gui *gocui.Gui view *gocui.View header *gocui.View vm *viewmodel.FileTreeViewModel title string kb key.Bindings logger logger.Logger filterRegex *regexp.Regexp listeners []ViewOptionChangeListener extractListeners []ViewExtractListener helpKeys []*key.Binding requestedWidthRatio float64 } // newFileTreeView creates a new view object attached the global [gocui] screen object. func newFileTreeView(gui *gocui.Gui, cfg v1.Config, initial int) (v *FileTree, err error) { v = new(FileTree) v.logger = log.Nested("ui", "filetree") v.listeners = make([]ViewOptionChangeListener, 0) // populate main fields v.name = "filetree" v.gui = gui v.kb = cfg.Preferences.KeyBindings v.vm, err = viewmodel.NewFileTreeViewModel(cfg, initial) if err != nil { return nil, err } requestedWidthRatio := cfg.Preferences.FiletreePaneWidth if requestedWidthRatio >= 1 || requestedWidthRatio <= 0 { v.logger.Warnf("invalid config value: 'filetree.pane-width' should be 0 < value < 1, given '%v'", requestedWidthRatio) requestedWidthRatio = 0.5 } v.requestedWidthRatio = requestedWidthRatio return v, err } func (v *FileTree) AddViewOptionChangeListener(listener ...ViewOptionChangeListener) { v.listeners = append(v.listeners, listener...) } func (v *FileTree) AddViewExtractListener(listener ...ViewExtractListener) { v.extractListeners = append(v.extractListeners, listener...) } func (v *FileTree) SetTitle(title string) { v.title = title } func (v *FileTree) SetFilterRegex(filterRegex *regexp.Regexp) { v.filterRegex = filterRegex } func (v *FileTree) Name() string { return v.name } // Setup initializes the UI concerns within the context of a global [gocui] view object. func (v *FileTree) Setup(view, header *gocui.View) error { log.Trace("setup()") // set controller options v.view = view v.view.Editable = false v.view.Wrap = false v.view.Frame = false v.header = header v.header.Editable = false v.header.Wrap = false v.header.Frame = false var infos = []key.BindingInfo{ { Config: v.kb.Filetree.ToggleCollapseDir, OnAction: v.toggleCollapse, Display: "Collapse dir", }, { Config: v.kb.Filetree.ToggleCollapseAllDir, OnAction: v.toggleCollapseAll, Display: "Collapse all dir", }, { Config: v.kb.Filetree.ToggleSortOrder, OnAction: v.toggleSortOrder, Display: "Toggle sort order", }, { Config: v.kb.Filetree.ExtractFile, OnAction: v.extractFile, Display: "Extract File", }, { Config: v.kb.Filetree.ToggleAddedFiles, OnAction: func() error { return v.toggleShowDiffType(filetree.Added) }, IsSelected: func() bool { return !v.vm.HiddenDiffTypes[filetree.Added] }, Display: "Added", }, { Config: v.kb.Filetree.ToggleRemovedFiles, OnAction: func() error { return v.toggleShowDiffType(filetree.Removed) }, IsSelected: func() bool { return !v.vm.HiddenDiffTypes[filetree.Removed] }, Display: "Removed", }, { Config: v.kb.Filetree.ToggleModifiedFiles, OnAction: func() error { return v.toggleShowDiffType(filetree.Modified) }, IsSelected: func() bool { return !v.vm.HiddenDiffTypes[filetree.Modified] }, Display: "Modified", }, { Config: v.kb.Filetree.ToggleUnmodifiedFiles, OnAction: func() error { return v.toggleShowDiffType(filetree.Unmodified) }, IsSelected: func() bool { return !v.vm.HiddenDiffTypes[filetree.Unmodified] }, Display: "Unmodified", }, { Config: v.kb.Filetree.ToggleTreeAttributes, OnAction: v.toggleAttributes, IsSelected: func() bool { return v.vm.ShowAttributes }, Display: "Attributes", }, { Config: v.kb.Filetree.ToggleWrapTree, OnAction: v.toggleWrapTree, IsSelected: func() bool { return v.view.Wrap }, Display: "Wrap", }, { Config: v.kb.Navigation.PageUp, OnAction: v.PageUp, }, { Config: v.kb.Navigation.PageDown, OnAction: v.PageDown, }, { Config: v.kb.Navigation.Down, Modifier: gocui.ModNone, OnAction: v.CursorDown, }, { Config: v.kb.Navigation.Up, Modifier: gocui.ModNone, OnAction: v.CursorUp, }, { Config: v.kb.Navigation.Left, Modifier: gocui.ModNone, OnAction: v.CursorLeft, }, { Config: v.kb.Navigation.Right, Modifier: gocui.ModNone, OnAction: v.CursorRight, }, } helpKeys, err := key.GenerateBindings(v.gui, v.name, infos) if err != nil { return err } v.helpKeys = helpKeys _, height := v.view.Size() v.vm.Setup(0, height) _ = v.Update() _ = v.Render() return nil } // IsVisible indicates if the file tree view pane is currently initialized func (v *FileTree) IsVisible() bool { return v != nil } // ResetCursor moves the cursor back to the top of the buffer and translates to the top of the buffer. func (v *FileTree) resetCursor() { _ = v.view.SetCursor(0, 0) v.vm.ResetCursor() } // SetTreeByLayer populates the view model by stacking the indicated image layer file trees. func (v *FileTree) SetTree(bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop int) error { err := v.vm.SetTreeByLayer(bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop) if err != nil { return err } _ = v.Update() return v.Render() } // CursorDown moves the cursor down and renders the view. // Note: we cannot use the gocui buffer since any state change requires writing the entire tree to the buffer. // Instead we are keeping an upper and lower bounds of the tree string to render and only flushing // this range into the view buffer. This is much faster when tree sizes are large. func (v *FileTree) CursorDown() error { if v.vm.CursorDown() { return v.Render() } return nil } // CursorUp moves the cursor up and renders the view. // Note: we cannot use the gocui buffer since any state change requires writing the entire tree to the buffer. // Instead we are keeping an upper and lower bounds of the tree string to render and only flushing // this range into the view buffer. This is much faster when tree sizes are large. func (v *FileTree) CursorUp() error { if v.vm.CursorUp() { return v.Render() } return nil } // CursorLeft moves the cursor up until we reach the Parent Node or top of the tree func (v *FileTree) CursorLeft() error { err := v.vm.CursorLeft(v.filterRegex) if err != nil { return err } _ = v.Update() return v.Render() } // CursorRight descends into directory expanding it if needed func (v *FileTree) CursorRight() error { err := v.vm.CursorRight(v.filterRegex) if err != nil { return err } _ = v.Update() return v.Render() } // PageDown moves to next page putting the cursor on top func (v *FileTree) PageDown() error { err := v.vm.PageDown() if err != nil { return err } return v.Render() } // PageUp moves to previous page putting the cursor on top func (v *FileTree) PageUp() error { err := v.vm.PageUp() if err != nil { return err } return v.Render() } // getAbsPositionNode determines the selected screen cursor's location in the file tree, returning the selected FileNode. // func (controller *FileTree) getAbsPositionNode() (node *filetree.FileNode) { // return controller.vm.getAbsPositionNode(filterRegex()) // } // ToggleCollapse will collapse/expand the selected FileNode. func (v *FileTree) toggleCollapse() error { err := v.vm.ToggleCollapse(v.filterRegex) if err != nil { return err } _ = v.Update() return v.Render() } // ToggleCollapseAll will collapse/expand the all directories. func (v *FileTree) toggleCollapseAll() error { err := v.vm.ToggleCollapseAll() if err != nil { return err } if v.vm.CollapseAll { v.resetCursor() } _ = v.Update() return v.Render() } func (v *FileTree) toggleSortOrder() error { err := v.vm.ToggleSortOrder() if err != nil { return err } v.resetCursor() _ = v.Update() return v.Render() } func (v *FileTree) extractFile() error { node := v.vm.CurrentNode(v.filterRegex) for _, listener := range v.extractListeners { err := listener(node.Path()) if err != nil { return err } } return nil } func (v *FileTree) toggleWrapTree() error { v.view.Wrap = !v.view.Wrap err := v.Update() if err != nil { return err } err = v.Render() if err != nil { return err } // we need to render the changes to the status pane as well (not just this contoller/view) return v.notifyOnViewOptionChangeListeners() } func (v *FileTree) notifyOnViewOptionChangeListeners() error { for _, listener := range v.listeners { err := listener() if err != nil { return fmt.Errorf("notifyOnViewOptionChangeListeners error: %w", err) } } return nil } // ToggleAttributes will show/hide file attributes func (v *FileTree) toggleAttributes() error { err := v.vm.ToggleAttributes() if err != nil { return err } err = v.Update() if err != nil { return err } err = v.Render() if err != nil { return err } // we need to render the changes to the status pane as well (not just this controller/view) return v.notifyOnViewOptionChangeListeners() } // ToggleShowDiffType will show/hide the selected DiffType in the filetree pane. func (v *FileTree) toggleShowDiffType(diffType filetree.DiffType) error { v.vm.ToggleShowDiffType(diffType) err := v.Update() if err != nil { return err } err = v.Render() if err != nil { return err } // we need to render the changes to the status pane as well (not just this controller/view) return v.notifyOnViewOptionChangeListeners() } // OnLayoutChange is called by the UI framework to inform the view-model of the new screen dimensions func (v *FileTree) OnLayoutChange() error { err := v.Update() if err != nil { return err } return v.Render() } // Update refreshes the state objects for future rendering. func (v *FileTree) Update() error { var width, height int if v.view != nil { width, height = v.view.Size() } else { // before the TUI is setup there may not be a controller to reference. Use the entire screen as reference. width, height = v.gui.Size() } // height should account for the header return v.vm.Update(v.filterRegex, width, height-1) } // Render flushes the state objects (file tree) to the pane. func (v *FileTree) Render() error { v.logger.Trace("render()") title := v.title isSelected := v.gui.CurrentView() == v.view v.gui.Update(func(g *gocui.Gui) error { // update the header v.header.Clear() width, _ := g.Size() headerStr := format.RenderHeader(title, width, isSelected) if v.vm.ShowAttributes { headerStr += fmt.Sprintf(filetree.AttributeFormat+" %s", "P", "ermission", "UID:GID", "Size", "Filetree") } _, _ = fmt.Fprintln(v.header, headerStr) // update the contents v.view.Clear() err := v.vm.Render() if err != nil { return err } _, err = fmt.Fprint(v.view, v.vm.Buffer.String()) return err }) return nil } // KeyHelp indicates all the possible actions a user can take while the current pane is selected. func (v *FileTree) KeyHelp() string { var help string for _, binding := range v.helpKeys { help += binding.RenderKeyHelp() } return help } func (v *FileTree) Layout(g *gocui.Gui, minX, minY, maxX, maxY int) error { v.logger.Tracef("layout(minX: %d, minY: %d, maxX: %d, maxY: %d)", minX, minY, maxX, maxY) attributeRowSize := 0 // make the layout responsive to the available realestate. Make more room for the main content by hiding auxiliary // content when there is not enough room if maxX-minX < 60 { v.vm.ConstrainLayout() } else { v.vm.ExpandLayout() } if v.vm.ShowAttributes { attributeRowSize = 1 } // header + attribute header headerSize := 1 + attributeRowSize // note: maxY needs to account for the (invisible) border, thus a +1 header, headerErr := g.SetView(v.Name()+"header", minX, minY, maxX, minY+headerSize+1, 0) // we are going to overlap the view over the (invisible) border (so minY will be one less than expected). // additionally, maxY will be bumped by one to include the border view, viewErr := g.SetView(v.Name(), minX, minY+headerSize, maxX, maxY+1, 0) if utils.IsNewView(viewErr, headerErr) { err := v.Setup(view, header) if err != nil { return fmt.Errorf("unable to setup tree controller: %w", err) } } return nil } func (v *FileTree) RequestedSize(available int) *int { // var requestedWidth = int(float64(available) * (1.0 - v.requestedWidthRatio)) // return &requestedWidth return nil } ================================================ FILE: cmd/dive/cli/internal/ui/v1/view/filter.go ================================================ package view import ( "fmt" "github.com/anchore/go-logger" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/format" "github.com/wagoodman/dive/internal/log" "github.com/wagoodman/dive/internal/utils" "strings" "github.com/awesome-gocui/gocui" ) type FilterEditListener func(string) error // Filter holds the UI objects and data models for populating the bottom row. Specifically the pane that // allows the user to filter the file tree by path. type Filter struct { gui *gocui.Gui view *gocui.View header *gocui.View logger logger.Logger labelStr string maxLength int hidden bool requestedHeight int filterEditListeners []FilterEditListener } // newFilterView creates a new view object attached the global [gocui] screen object. func newFilterView(gui *gocui.Gui) *Filter { c := new(Filter) c.logger = log.Nested("ui", "filter") c.filterEditListeners = make([]FilterEditListener, 0) // populate main fields c.gui = gui c.labelStr = "Path Filter: " c.hidden = true c.requestedHeight = 1 return c } func (v *Filter) AddFilterEditListener(listener ...FilterEditListener) { v.filterEditListeners = append(v.filterEditListeners, listener...) } func (v *Filter) Name() string { return "filter" } // Setup initializes the UI concerns within the context of a global [gocui] view object. func (v *Filter) Setup(view, header *gocui.View) error { log.Trace("Setup()") // set controller options v.view = view v.maxLength = 200 v.view.Frame = false v.view.BgColor = gocui.AttrReverse v.view.Editable = true v.view.Editor = v v.header = header v.header.BgColor = gocui.AttrReverse v.header.Editable = false v.header.Wrap = false v.header.Frame = false return v.Render() } // ToggleFilterView shows/hides the file tree filter pane. func (v *Filter) ToggleVisible() error { // delete all user input from the tree view v.view.Clear() // toggle hiding v.hidden = !v.hidden if !v.hidden { _, err := v.gui.SetCurrentView(v.Name()) if err != nil { return fmt.Errorf("unable to toggle filter view: %w", err) } return nil } // reset the cursor for the next time it is visible // Note: there is a subtle gocui behavior here where this cannot be called when the view // is newly visible. Is this a problem with dive or gocui? return v.view.SetCursor(0, 0) } // IsVisible indicates if the filter view pane is currently initialized func (v *Filter) IsVisible() bool { if v == nil { return false } return !v.hidden } // Edit intercepts the key press events in the filer view to update the file view in real time. func (v *Filter) Edit(view *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) { if !v.IsVisible() { return } cx, _ := view.Cursor() ox, _ := view.Origin() limit := ox+cx+1 > v.maxLength switch { case ch != 0 && mod == 0 && !limit: view.EditWrite(ch) case key == gocui.KeySpace && !limit: view.EditWrite(' ') case key == gocui.KeyBackspace || key == gocui.KeyBackspace2: view.EditDelete(true) } // notify listeners v.notifyFilterEditListeners() } func (v *Filter) notifyFilterEditListeners() { currentValue := strings.TrimSpace(v.view.Buffer()) for _, listener := range v.filterEditListeners { err := listener(currentValue) if err != nil { // note: cannot propagate error from here since this is from the main gogui thread v.logger.WithFields("error", err).Debug("unable to notify filter edit listeners") } } } // Update refreshes the state objects for future rendering (currently does nothing). func (v *Filter) Update() error { return nil } // Render flushes the state objects to the screen. Currently this is the users path filter input. func (v *Filter) Render() error { v.logger.Trace("render()") v.gui.Update(func(g *gocui.Gui) error { _, err := fmt.Fprintln(v.header, format.Header(v.labelStr)) return err }) return nil } // KeyHelp indicates all the possible actions a user can take while the current pane is selected. func (v *Filter) KeyHelp() string { return format.StatusControlNormal("▏Type to filter the file tree ") } // OnLayoutChange is called whenever the screen dimensions are changed func (v *Filter) OnLayoutChange() error { err := v.Update() if err != nil { return err } return v.Render() } func (v *Filter) Layout(g *gocui.Gui, minX, minY, maxX, maxY int) error { v.logger.Tracef("layout(minX: %d, minY: %d, maxX: %d, maxY: %d)", minX, minY, maxX, maxY) label, labelErr := g.SetView(v.Name()+"label", minX, minY, len(v.labelStr), maxY, 0) view, viewErr := g.SetView(v.Name(), minX+(len(v.labelStr)-1), minY, maxX, maxY, 0) if utils.IsNewView(viewErr, labelErr) { err := v.Setup(view, label) if err != nil { return fmt.Errorf("unable to setup filter controller: %w", err) } } return nil } func (v *Filter) RequestedSize(available int) *int { return &v.requestedHeight } ================================================ FILE: cmd/dive/cli/internal/ui/v1/view/image_details.go ================================================ package view import ( "fmt" "github.com/anchore/go-logger" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/format" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/key" "github.com/wagoodman/dive/internal/log" "strconv" "strings" "github.com/awesome-gocui/gocui" "github.com/dustin/go-humanize" "github.com/wagoodman/dive/dive/filetree" ) type ImageDetails struct { gui *gocui.Gui body *gocui.View header *gocui.View logger logger.Logger imageName string imageSize uint64 efficiency float64 inefficiencies filetree.EfficiencySlice kb key.Bindings } func (v *ImageDetails) Name() string { return "imageDetails" } func (v *ImageDetails) Setup(body, header *gocui.View) error { v.logger = log.Nested("ui", "imageDetails") v.logger.Trace("Setup()") v.body = body v.body.Editable = false v.body.Wrap = true v.body.Highlight = true v.body.Frame = false v.header = header v.header.Editable = false v.header.Wrap = true v.header.Highlight = false v.header.Frame = false var infos = []key.BindingInfo{ { Config: v.kb.Navigation.Down, Modifier: gocui.ModNone, OnAction: v.CursorDown, }, { Config: v.kb.Navigation.Up, Modifier: gocui.ModNone, OnAction: v.CursorUp, }, { Config: v.kb.Navigation.PageUp, OnAction: v.PageUp, }, { Config: v.kb.Navigation.PageDown, OnAction: v.PageDown, }, } _, err := key.GenerateBindings(v.gui, v.Name(), infos) if err != nil { return err } return nil } // Render flushes the state objects to the screen. The details pane reports: // 1. the image efficiency score // 2. the estimated wasted image space // 3. a list of inefficient file allocations func (v *ImageDetails) Render() error { analysisTemplate := "%5s %12s %-s\n" inefficiencyReport := fmt.Sprintf(format.Header(analysisTemplate), "Count", "Total Space", "Path") var wastedSpace int64 for idx := 0; idx < len(v.inefficiencies); idx++ { data := v.inefficiencies[len(v.inefficiencies)-1-idx] wastedSpace += data.CumulativeSize inefficiencyReport += fmt.Sprintf(analysisTemplate, strconv.Itoa(len(data.Nodes)), humanize.Bytes(uint64(data.CumulativeSize)), data.Path) } imageNameStr := fmt.Sprintf("%s %s", format.Header("Image name:"), v.imageName) imageSizeStr := fmt.Sprintf("%s %s", format.Header("Total Image size:"), humanize.Bytes(v.imageSize)) efficiencyStr := fmt.Sprintf("%s %d %%", format.Header("Image efficiency score:"), int(100.0*v.efficiency)) wastedSpaceStr := fmt.Sprintf("%s %s", format.Header("Potential wasted space:"), humanize.Bytes(uint64(wastedSpace))) v.gui.Update(func(g *gocui.Gui) error { width, _ := v.body.Size() imageHeaderStr := format.RenderHeader("Image Details", width, v.gui.CurrentView() == v.body) v.header.Clear() _, err := fmt.Fprintln(v.header, imageHeaderStr) if err != nil { log.WithFields("error", err).Debug("unable to write to buffer") } var lines = []string{ imageNameStr, imageSizeStr, wastedSpaceStr, efficiencyStr, " ", // to avoid an empty line so CursorDown can work as expected inefficiencyReport, } v.body.Clear() _, err = fmt.Fprintln(v.body, strings.Join(lines, "\n")) if err != nil { log.WithFields("error", err).Debug("unable to write to buffer") } return err }) return nil } func (v *ImageDetails) OnLayoutChange() error { if err := v.Update(); err != nil { return err } return v.Render() } // IsVisible indicates if the details view pane is currently initialized. func (v *ImageDetails) IsVisible() bool { return v.body != nil } func (v *ImageDetails) PageUp() error { _, height := v.body.Size() if err := CursorStep(v.gui, v.body, -height); err != nil { v.logger.WithFields("error", err).Debugf("couldn't move the cursor up by %d steps", height) } return nil } func (v *ImageDetails) PageDown() error { _, height := v.body.Size() if err := CursorStep(v.gui, v.body, height); err != nil { v.logger.WithFields("error", err).Debugf("couldn't move the cursor down by %d steps", height) } return nil } func (v *ImageDetails) CursorUp() error { if err := CursorUp(v.gui, v.body); err != nil { v.logger.WithFields("error", err).Debug("couldn't move the cursor up") } return nil } func (v *ImageDetails) CursorDown() error { if err := CursorDown(v.gui, v.body); err != nil { v.logger.WithFields("error", err).Debug("couldn't move the cursor down") } return nil } // KeyHelp indicates all the possible actions a user can take while the current pane is selected (currently does nothing). func (v *ImageDetails) KeyHelp() string { return "" } // Update refreshes the state objects for future rendering. func (v *ImageDetails) Update() error { return nil } ================================================ FILE: cmd/dive/cli/internal/ui/v1/view/layer.go ================================================ package view import ( "fmt" "github.com/anchore/go-logger" "github.com/awesome-gocui/gocui" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/format" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/key" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/viewmodel" "github.com/wagoodman/dive/dive/image" "github.com/wagoodman/dive/internal/log" ) // Layer holds the UI objects and data models for populating the lower-left pane. // Specifically the pane that shows the image layers and layer selector. type Layer struct { name string gui *gocui.Gui body *gocui.View header *gocui.View vm *viewmodel.LayerSetState kb key.Bindings logger logger.Logger constrainedRealEstate bool listeners []LayerChangeListener helpKeys []*key.Binding } // newLayerView creates a new view object attached the global [gocui] screen object. func newLayerView(gui *gocui.Gui, cfg v1.Config) (c *Layer, err error) { c = new(Layer) c.logger = log.Nested("ui", "layer") c.listeners = make([]LayerChangeListener, 0) // populate main fields c.name = "layer" c.gui = gui c.kb = cfg.Preferences.KeyBindings var compareMode viewmodel.LayerCompareMode switch mode := cfg.Preferences.ShowAggregatedLayerChanges; mode { case true: compareMode = viewmodel.CompareAllLayers case false: compareMode = viewmodel.CompareSingleLayer default: return nil, fmt.Errorf("unknown layer.show-aggregated-changes value: %v", mode) } c.vm = viewmodel.NewLayerSetState(cfg.Analysis.Layers, compareMode) return c, err } func (v *Layer) AddLayerChangeListener(listener ...LayerChangeListener) { v.listeners = append(v.listeners, listener...) } func (v *Layer) notifyLayerChangeListeners() error { bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop := v.vm.GetCompareIndexes() selection := viewmodel.LayerSelection{ Layer: v.CurrentLayer(), BottomTreeStart: bottomTreeStart, BottomTreeStop: bottomTreeStop, TopTreeStart: topTreeStart, TopTreeStop: topTreeStop, } for _, listener := range v.listeners { err := listener(selection) if err != nil { return fmt.Errorf("error notifying layer change listeners: %w", err) } } // this is hacky, and I do not like it if layerDetails, err := v.gui.View("layerDetails"); err == nil { if err := layerDetails.SetCursor(0, 0); err != nil { v.logger.Debug("Couldn't set cursor to 0,0 for layerDetails") } } return nil } func (v *Layer) Name() string { return v.name } // Setup initializes the UI concerns within the context of a global [gocui] view object. func (v *Layer) Setup(body *gocui.View, header *gocui.View) error { v.logger.Trace("Setup()") // set controller options v.body = body v.body.Editable = false v.body.Wrap = false v.body.Frame = false v.header = header v.header.Editable = false v.header.Wrap = false v.header.Frame = false var infos = []key.BindingInfo{ { Config: v.kb.Layer.CompareLayer, OnAction: func() error { return v.setCompareMode(viewmodel.CompareSingleLayer) }, IsSelected: func() bool { return v.vm.CompareMode == viewmodel.CompareSingleLayer }, Display: "Show layer changes", }, { Config: v.kb.Layer.CompareAll, OnAction: func() error { return v.setCompareMode(viewmodel.CompareAllLayers) }, IsSelected: func() bool { return v.vm.CompareMode == viewmodel.CompareAllLayers }, Display: "Show aggregated changes", }, { Config: v.kb.Navigation.Down, Modifier: gocui.ModNone, OnAction: v.CursorDown, }, { Config: v.kb.Navigation.Up, Modifier: gocui.ModNone, OnAction: v.CursorUp, }, { Config: v.kb.Navigation.PageUp, OnAction: v.PageUp, }, { Config: v.kb.Navigation.PageDown, OnAction: v.PageDown, }, } helpKeys, err := key.GenerateBindings(v.gui, v.name, infos) if err != nil { return err } v.helpKeys = helpKeys return v.Render() } // height obtains the height of the current pane (taking into account the lost space due to the header). func (v *Layer) height() uint { _, height := v.body.Size() return uint(height - 1) } func (v *Layer) CompareMode() viewmodel.LayerCompareMode { return v.vm.CompareMode } // IsVisible indicates if the layer view pane is currently initialized. func (v *Layer) IsVisible() bool { return v != nil } // PageDown moves to next page putting the cursor on top func (v *Layer) PageDown() error { step := int(v.height()) + 1 targetLayerIndex := v.vm.LayerIndex + step if targetLayerIndex > len(v.vm.Layers) { step -= targetLayerIndex - (len(v.vm.Layers) - 1) } if step > 0 { // err := CursorStep(v.gui, v.body, step) err := error(nil) if err == nil { return v.SetCursor(v.vm.LayerIndex + step) } } return nil } // PageUp moves to previous page putting the cursor on top func (v *Layer) PageUp() error { step := int(v.height()) + 1 targetLayerIndex := v.vm.LayerIndex - step if targetLayerIndex < 0 { step += targetLayerIndex } if step > 0 { // err := CursorStep(v.gui, v.body, -step) err := error(nil) if err == nil { return v.SetCursor(v.vm.LayerIndex - step) } } return nil } // CursorDown moves the cursor down in the layer pane (selecting a higher layer). func (v *Layer) CursorDown() error { if v.vm.LayerIndex < len(v.vm.Layers)-1 { // err := CursorDown(v.gui, v.body) err := error(nil) if err == nil { return v.SetCursor(v.vm.LayerIndex + 1) } } return nil } // CursorUp moves the cursor up in the layer pane (selecting a lower layer). func (v *Layer) CursorUp() error { if v.vm.LayerIndex > 0 { // err := CursorUp(v.gui, v.body) err := error(nil) if err == nil { return v.SetCursor(v.vm.LayerIndex - 1) } } return nil } // SetOrigin updates the origin of the layer view pane. func (v *Layer) SetOrigin(x, y int) error { if err := v.body.SetOrigin(x, y); err != nil { return err } return nil } // SetCursor resets the cursor and orients the file tree view based on the given layer index. func (v *Layer) SetCursor(layer int) error { v.vm.LayerIndex = layer err := v.notifyLayerChangeListeners() if err != nil { return err } return v.Render() } // CurrentLayer returns the Layer object currently selected. func (v *Layer) CurrentLayer() *image.Layer { return v.vm.Layers[v.vm.LayerIndex] } // setCompareMode switches the layer comparison between a single-layer comparison to an aggregated comparison. func (v *Layer) setCompareMode(compareMode viewmodel.LayerCompareMode) error { v.vm.CompareMode = compareMode return v.notifyLayerChangeListeners() } // renderCompareBar returns the formatted string for the given layer. func (v *Layer) renderCompareBar(layerIdx int) string { bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop := v.vm.GetCompareIndexes() result := " " if layerIdx >= bottomTreeStart && layerIdx <= bottomTreeStop { result = format.CompareBottom(" ") } if layerIdx >= topTreeStart && layerIdx <= topTreeStop { result = format.CompareTop(" ") } return result } func (v *Layer) ConstrainLayout() { if !v.constrainedRealEstate { v.logger.Debug("constraining layout") v.constrainedRealEstate = true } } func (v *Layer) ExpandLayout() { if v.constrainedRealEstate { v.logger.Debug("expanding layout") v.constrainedRealEstate = false } } // OnLayoutChange is called whenever the screen dimensions are changed func (v *Layer) OnLayoutChange() error { err := v.Update() if err != nil { return err } return v.Render() } // Update refreshes the state objects for future rendering (currently does nothing). func (v *Layer) Update() error { return nil } // Render flushes the state objects to the screen. The layers pane reports: // 1. the layers of the image + metadata // 2. the current selected image func (v *Layer) Render() error { v.logger.Trace("render()") // indicate when selected title := "Layers" isSelected := v.gui.CurrentView() == v.body v.gui.Update(func(g *gocui.Gui) error { var err error // update header v.header.Clear() width, _ := g.Size() if v.constrainedRealEstate { headerStr := format.RenderNoHeader(width, isSelected) headerStr += "\nLayer" _, err := fmt.Fprintln(v.header, headerStr) if err != nil { return err } } else { headerStr := format.RenderHeader(title, width, isSelected) headerStr += fmt.Sprintf("Cmp"+image.LayerFormat, "Size", "Command") _, err := fmt.Fprintln(v.header, headerStr) if err != nil { return err } } // update contents v.body.Clear() for idx, layer := range v.vm.Layers { var layerStr string if v.constrainedRealEstate { layerStr = fmt.Sprintf("%-4d", layer.Index) } else { layerStr = layer.String() } compareBar := v.renderCompareBar(idx) if idx == v.vm.LayerIndex { _, err = fmt.Fprintln(v.body, compareBar+" "+format.Selected(layerStr)) } else { _, err = fmt.Fprintln(v.body, compareBar+" "+layerStr) } if err != nil { return err } } // Adjust origin, if necessary maxBodyDisplayHeight := int(v.height()) if v.vm.LayerIndex > maxBodyDisplayHeight { if err := v.SetOrigin(0, v.vm.LayerIndex-maxBodyDisplayHeight); err != nil { return err } } return nil }) return nil } func (v *Layer) LayerCount() int { return len(v.vm.Layers) } // KeyHelp indicates all the possible actions a user can take while the current pane is selected. func (v *Layer) KeyHelp() string { var help string for _, binding := range v.helpKeys { help += binding.RenderKeyHelp() } return help } ================================================ FILE: cmd/dive/cli/internal/ui/v1/view/layer_change_listener.go ================================================ package view import ( "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/viewmodel" ) type LayerChangeListener func(viewmodel.LayerSelection) error ================================================ FILE: cmd/dive/cli/internal/ui/v1/view/layer_details.go ================================================ package view import ( "fmt" "github.com/anchore/go-logger" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/format" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/key" "github.com/wagoodman/dive/internal/log" "strings" "github.com/awesome-gocui/gocui" "github.com/dustin/go-humanize" "github.com/wagoodman/dive/dive/image" ) type LayerDetails struct { gui *gocui.Gui header *gocui.View body *gocui.View CurrentLayer *image.Layer kb key.Bindings logger logger.Logger } func (v *LayerDetails) Name() string { return "layerDetails" } func (v *LayerDetails) Setup(body, header *gocui.View) error { v.logger = log.Nested("ui", "layerDetails") v.logger.Trace("setup()") v.body = body v.body.Editable = false v.body.Wrap = true v.body.Highlight = true v.body.Frame = false v.header = header v.header.Editable = false v.header.Wrap = true v.header.Highlight = false v.header.Frame = false var infos = []key.BindingInfo{ { Config: v.kb.Navigation.Down, Modifier: gocui.ModNone, OnAction: v.CursorDown, }, { Config: v.kb.Navigation.Up, Modifier: gocui.ModNone, OnAction: v.CursorUp, }, } _, err := key.GenerateBindings(v.gui, v.Name(), infos) if err != nil { return err } return nil } // Render flushes the state objects to the screen. // The details pane reports the currently selected layer's: // 1. tags // 2. ID // 3. digest // 4. command func (v *LayerDetails) Render() error { v.gui.Update(func(g *gocui.Gui) error { v.header.Clear() width, _ := v.body.Size() layerHeaderStr := format.RenderHeader("Layer Details", width, v.gui.CurrentView() == v.body) _, err := fmt.Fprintln(v.header, layerHeaderStr) if err != nil { return err } // this is for layer details var lines = make([]string, 0) tags := "(none)" if len(v.CurrentLayer.Names) > 0 { tags = strings.Join(v.CurrentLayer.Names, ", ") } lines = append(lines, []string{ format.Header("Tags: ") + tags, format.Header("Id: ") + v.CurrentLayer.Id, format.Header("Size: ") + humanize.Bytes(v.CurrentLayer.Size), format.Header("Digest: ") + v.CurrentLayer.Digest, format.Header("Command:"), v.CurrentLayer.Command, }...) v.body.Clear() if _, err = fmt.Fprintln(v.body, strings.Join(lines, "\n")); err != nil { log.WithFields("layer", v.CurrentLayer.Id, "error", err).Debug("unable to write to buffer") } return nil }) return nil } func (v *LayerDetails) OnLayoutChange() error { if err := v.Update(); err != nil { return err } return v.Render() } // IsVisible indicates if the details view pane is currently initialized. func (v *LayerDetails) IsVisible() bool { return v.body != nil } // CursorUp moves the cursor up in the details pane func (v *LayerDetails) CursorUp() error { if err := CursorUp(v.gui, v.body); err != nil { v.logger.WithFields("error", err).Debug("couldn't move the cursor up") } return nil } // CursorDown moves the cursor up in the details pane func (v *LayerDetails) CursorDown() error { if err := CursorDown(v.gui, v.body); err != nil { v.logger.WithFields("error", err).Debug("couldn't move the cursor down") } return nil } // KeyHelp indicates all the possible actions a user can take while the current pane is selected (currently does nothing). func (v *LayerDetails) KeyHelp() string { return "" } // Update refreshes the state objects for future rendering. func (v *LayerDetails) Update() error { return nil } func (v *LayerDetails) SetCursor(x, y int) error { return v.body.SetCursor(x, y) } ================================================ FILE: cmd/dive/cli/internal/ui/v1/view/renderer.go ================================================ package view // Controller defines the a renderable terminal screen pane. type Renderer interface { Update() error Render() error IsVisible() bool } type Helper interface { KeyHelp() string } ================================================ FILE: cmd/dive/cli/internal/ui/v1/view/status.go ================================================ package view import ( "fmt" "github.com/anchore/go-logger" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/format" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/key" "github.com/wagoodman/dive/internal/log" "github.com/wagoodman/dive/internal/utils" "strings" "github.com/awesome-gocui/gocui" ) // Status holds the UI objects and data models for populating the bottom-most pane. Specifically the panel // shows the user a set of possible actions to take in the window and currently selected pane. type Status struct { name string gui *gocui.Gui view *gocui.View logger logger.Logger selectedView Helper requestedHeight int helpKeys []*key.Binding } // newStatusView creates a new view object attached the global [gocui] screen object. func newStatusView(gui *gocui.Gui) *Status { c := new(Status) // populate main fields c.name = "status" c.gui = gui c.helpKeys = make([]*key.Binding, 0) c.requestedHeight = 1 c.logger = log.Nested("ui", "status") return c } func (v *Status) SetCurrentView(r Helper) { v.selectedView = r } func (v *Status) Name() string { return v.name } func (v *Status) AddHelpKeys(keys ...*key.Binding) { v.helpKeys = append(v.helpKeys, keys...) } // Setup initializes the UI concerns within the context of a global [gocui] view object. func (v *Status) Setup(view *gocui.View) error { v.logger.Trace("setup()") // set controller options v.view = view v.view.Frame = false return v.Render() } // IsVisible indicates if the status view pane is currently initialized. func (v *Status) IsVisible() bool { return v != nil } // Update refreshes the state objects for future rendering (currently does nothing). func (v *Status) Update() error { return nil } // OnLayoutChange is called whenever the screen dimensions are changed func (v *Status) OnLayoutChange() error { err := v.Update() if err != nil { return err } return v.Render() } // Render flushes the state objects to the screen. func (v *Status) Render() error { v.logger.Trace("render()") v.gui.Update(func(g *gocui.Gui) error { v.view.Clear() var selectedHelp string if v.selectedView != nil { selectedHelp = v.selectedView.KeyHelp() } _, err := fmt.Fprintln(v.view, v.KeyHelp()+selectedHelp+format.StatusNormal("▏"+strings.Repeat(" ", 1000))) if err != nil { v.logger.WithFields("error", err).Debug("unable to write to buffer") } return err }) return nil } // KeyHelp indicates all the possible global actions a user can take when any pane is selected. func (v *Status) KeyHelp() string { var help string for _, binding := range v.helpKeys { help += binding.RenderKeyHelp() } return help } func (v *Status) Layout(g *gocui.Gui, minX, minY, maxX, maxY int) error { v.logger.Tracef("layout(minX: %d, minY: %d, maxX: %d, maxY: %d)", minX, minY, maxX, maxY) view, viewErr := g.SetView(v.Name(), minX, minY, maxX, maxY, 0) if utils.IsNewView(viewErr) { err := v.Setup(view) if err != nil { return fmt.Errorf("unable to setup status controller: %w", err) } } return nil } func (v *Status) RequestedSize(available int) *int { return &v.requestedHeight } ================================================ FILE: cmd/dive/cli/internal/ui/v1/view/views.go ================================================ package view import ( "github.com/awesome-gocui/gocui" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1" ) type View interface { Setup(*gocui.View, *gocui.View) error Name() string IsVisible() bool } type Views struct { Tree *FileTree Layer *Layer Status *Status Filter *Filter LayerDetails *LayerDetails ImageDetails *ImageDetails Debug *Debug } func NewViews(g *gocui.Gui, cfg v1.Config) (*Views, error) { layer, err := newLayerView(g, cfg) if err != nil { return nil, err } tree, err := newFileTreeView(g, cfg, 0) if err != nil { return nil, err } status := newStatusView(g) // set the layer view as the first selected view status.SetCurrentView(layer) return &Views{ Tree: tree, Layer: layer, Status: status, Filter: newFilterView(g), ImageDetails: &ImageDetails{ gui: g, imageName: cfg.Analysis.Image, imageSize: cfg.Analysis.SizeBytes, efficiency: cfg.Analysis.Efficiency, inefficiencies: cfg.Analysis.Inefficiencies, kb: cfg.Preferences.KeyBindings, }, LayerDetails: &LayerDetails{gui: g, kb: cfg.Preferences.KeyBindings}, Debug: newDebugView(g), }, nil } func (views *Views) Renderers() []Renderer { return []Renderer{ views.Tree, views.Layer, views.Status, views.Filter, views.LayerDetails, views.ImageDetails, } } ================================================ FILE: cmd/dive/cli/internal/ui/v1/viewmodel/config.go ================================================ package viewmodel ================================================ FILE: cmd/dive/cli/internal/ui/v1/viewmodel/filetree.go ================================================ package viewmodel import ( "bytes" "fmt" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/format" "github.com/wagoodman/dive/internal/log" "regexp" "strings" "github.com/lunixbochs/vtclean" "github.com/wagoodman/dive/dive/filetree" ) // FileTreeViewModel holds the UI objects and data models for populating the right pane. Specifically, the pane that // shows selected layer or aggregate file ASCII tree. type FileTreeViewModel struct { ModelTree *filetree.FileTree ViewTree *filetree.FileTree RefTrees []*filetree.FileTree comparer filetree.Comparer constrainedRealEstate bool CollapseAll bool ShowAttributes bool unconstrainedShowAttributes bool HiddenDiffTypes []bool TreeIndex int bufferIndex int bufferIndexLowerBound int refHeight int refWidth int Buffer bytes.Buffer } // NewFileTreeViewModel creates a new view object attached the global [gocui] screen object. func NewFileTreeViewModel(cfg v1.Config, initialLayer int) (treeViewModel *FileTreeViewModel, err error) { treeViewModel = new(FileTreeViewModel) comparer, err := cfg.TreeComparer() if err != nil { return nil, err } // populate main fields treeViewModel.ShowAttributes = cfg.Preferences.ShowFiletreeAttributes treeViewModel.unconstrainedShowAttributes = treeViewModel.ShowAttributes treeViewModel.CollapseAll = cfg.Preferences.CollapseFiletreeDirectory treeViewModel.ModelTree = cfg.Analysis.RefTrees[initialLayer] treeViewModel.RefTrees = cfg.Analysis.RefTrees treeViewModel.comparer = comparer treeViewModel.HiddenDiffTypes = make([]bool, 4) hiddenTypes := cfg.Preferences.FiletreeDiffHide for _, hType := range hiddenTypes { switch t := strings.ToLower(hType); t { case "added": treeViewModel.HiddenDiffTypes[filetree.Added] = true case "removed": treeViewModel.HiddenDiffTypes[filetree.Removed] = true case "modified": treeViewModel.HiddenDiffTypes[filetree.Modified] = true case "unmodified": treeViewModel.HiddenDiffTypes[filetree.Unmodified] = true default: return nil, fmt.Errorf("unknown diff.hide value: %s", t) } } return treeViewModel, treeViewModel.SetTreeByLayer(0, 0, initialLayer, initialLayer) } // Setup initializes the UI concerns within the context of a global [gocui] view object. func (vm *FileTreeViewModel) Setup(lowerBound, height int) { vm.bufferIndexLowerBound = lowerBound vm.refHeight = height } // height returns the current height and considers the header func (vm *FileTreeViewModel) height() int { if vm.ShowAttributes { return vm.refHeight - 1 } return vm.refHeight } // bufferIndexUpperBound returns the current upper bounds for the view func (vm *FileTreeViewModel) bufferIndexUpperBound() int { return vm.bufferIndexLowerBound + vm.height() } // IsVisible indicates if the file tree view pane is currently initialized func (vm *FileTreeViewModel) IsVisible() bool { return vm != nil } // ResetCursor moves the cursor back to the top of the buffer and translates to the top of the buffer. func (vm *FileTreeViewModel) ResetCursor() { vm.TreeIndex = 0 vm.bufferIndex = 0 vm.bufferIndexLowerBound = 0 } // SetTreeByLayer populates the view model by stacking the indicated image layer file trees. func (vm *FileTreeViewModel) SetTreeByLayer(bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop int) error { if topTreeStop > len(vm.RefTrees)-1 { return fmt.Errorf("invalid layer index given: %d of %d", topTreeStop, len(vm.RefTrees)-1) } newTree, err := vm.comparer.GetTree(filetree.NewTreeIndexKey(bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop)) if err != nil { return fmt.Errorf("unable to fetch layer tree from cache: %w", err) } // preserve vm state on copy visitor := func(node *filetree.FileNode) error { newNode, err := newTree.GetNode(node.Path()) if err == nil { newNode.Data.ViewInfo = node.Data.ViewInfo } return nil } err = vm.ModelTree.VisitDepthChildFirst(visitor, nil) if err != nil { return fmt.Errorf("unable to propagate layer tree: %w", err) } vm.ModelTree = newTree return nil } // CursorUp performs the internal view's buffer adjustments on cursor up. Note: this is independent of the gocui buffer. func (vm *FileTreeViewModel) CursorUp() bool { if vm.TreeIndex <= 0 { return false } vm.TreeIndex-- if vm.TreeIndex < vm.bufferIndexLowerBound { vm.bufferIndexLowerBound-- } if vm.bufferIndex > 0 { vm.bufferIndex-- } return true } // CursorDown performs the internal view's buffer adjustments on cursor down. Note: this is independent of the gocui buffer. func (vm *FileTreeViewModel) CursorDown() bool { if vm.TreeIndex >= vm.ModelTree.VisibleSize() { return false } vm.TreeIndex++ if vm.TreeIndex > vm.bufferIndexUpperBound() { vm.bufferIndexLowerBound++ } vm.bufferIndex++ if vm.bufferIndex > vm.height() { vm.bufferIndex = vm.height() } return true } func (vm *FileTreeViewModel) CurrentNode(filterRegex *regexp.Regexp) *filetree.FileNode { return vm.getAbsPositionNode(filterRegex) } // CursorLeft moves the cursor up until we reach the Parent Node or top of the tree func (vm *FileTreeViewModel) CursorLeft(filterRegex *regexp.Regexp) error { var visitor func(*filetree.FileNode) error var evaluator func(*filetree.FileNode) bool var dfsCounter, newIndex int oldIndex := vm.TreeIndex currentNode := vm.getAbsPositionNode(filterRegex) if currentNode == nil { return nil } parentPath := currentNode.Parent.Path() visitor = func(curNode *filetree.FileNode) error { if strings.Compare(parentPath, curNode.Path()) == 0 { newIndex = dfsCounter } dfsCounter++ return nil } evaluator = func(curNode *filetree.FileNode) bool { regexMatch := true if filterRegex != nil { match := filterRegex.Find([]byte(curNode.Path())) regexMatch = match != nil } return !curNode.Parent.Data.ViewInfo.Collapsed && !curNode.Data.ViewInfo.Hidden && regexMatch } err := vm.ModelTree.VisitDepthParentFirst(visitor, evaluator) if err != nil { return fmt.Errorf("unable to propagate tree on cursorLeft: %w", err) } vm.TreeIndex = newIndex moveIndex := oldIndex - newIndex if newIndex < vm.bufferIndexLowerBound { vm.bufferIndexLowerBound = vm.TreeIndex } if vm.bufferIndex > moveIndex { vm.bufferIndex -= moveIndex } else { vm.bufferIndex = 0 } return nil } // CursorRight descends into directory expanding it if needed func (vm *FileTreeViewModel) CursorRight(filterRegex *regexp.Regexp) error { node := vm.getAbsPositionNode(filterRegex) if node == nil { return nil } if !node.Data.FileInfo.IsDir { return nil } if len(node.Children) == 0 { return nil } if node.Data.ViewInfo.Collapsed { node.Data.ViewInfo.Collapsed = false } vm.TreeIndex++ if vm.TreeIndex > vm.bufferIndexUpperBound() { vm.bufferIndexLowerBound++ } vm.bufferIndex++ if vm.bufferIndex > vm.height() { vm.bufferIndex = vm.height() } return nil } // PageDown moves to next page putting the cursor on top func (vm *FileTreeViewModel) PageDown() error { nextBufferIndexLowerBound := vm.bufferIndexLowerBound + vm.height() nextBufferIndexUpperBound := nextBufferIndexLowerBound + vm.height() // todo: this work should be saved or passed to render... treeString := vm.ViewTree.StringBetween(nextBufferIndexLowerBound, nextBufferIndexUpperBound, vm.ShowAttributes) lines := strings.Split(treeString, "\n") newLines := len(lines) - 1 if vm.height() >= newLines { nextBufferIndexLowerBound = vm.bufferIndexLowerBound + newLines } vm.bufferIndexLowerBound = nextBufferIndexLowerBound if vm.TreeIndex < nextBufferIndexLowerBound { vm.bufferIndex = 0 vm.TreeIndex = nextBufferIndexLowerBound } else { vm.bufferIndex -= newLines } return nil } // PageUp moves to previous page putting the cursor on top func (vm *FileTreeViewModel) PageUp() error { nextBufferIndexLowerBound := vm.bufferIndexLowerBound - vm.height() nextBufferIndexUpperBound := nextBufferIndexLowerBound + vm.height() // todo: this work should be saved or passed to render... treeString := vm.ViewTree.StringBetween(nextBufferIndexLowerBound, nextBufferIndexUpperBound, vm.ShowAttributes) lines := strings.Split(treeString, "\n") newLines := len(lines) - 2 if vm.height() >= newLines { nextBufferIndexLowerBound = vm.bufferIndexLowerBound - newLines } vm.bufferIndexLowerBound = nextBufferIndexLowerBound if vm.TreeIndex > (nextBufferIndexUpperBound - 1) { vm.bufferIndex = 0 vm.TreeIndex = nextBufferIndexLowerBound } else { vm.bufferIndex += newLines } return nil } // getAbsPositionNode determines the selected screen cursor's location in the file tree, returning the selected FileNode. func (vm *FileTreeViewModel) getAbsPositionNode(filterRegex *regexp.Regexp) (node *filetree.FileNode) { var visitor func(*filetree.FileNode) error var evaluator func(*filetree.FileNode) bool var dfsCounter int visitor = func(curNode *filetree.FileNode) error { if dfsCounter == vm.TreeIndex { node = curNode } dfsCounter++ return nil } evaluator = func(curNode *filetree.FileNode) bool { regexMatch := true if filterRegex != nil { match := filterRegex.Find([]byte(curNode.Path())) regexMatch = match != nil } parentCollapsed := false if curNode.Parent != nil { parentCollapsed = curNode.Parent.Data.ViewInfo.Collapsed } return !parentCollapsed && !curNode.Data.ViewInfo.Hidden && regexMatch } err := vm.ModelTree.VisitDepthParentFirst(visitor, evaluator) if err != nil { log.WithFields("error", err).Debug("unable to propagate tree on getAbsPositionNode") } return node } // ToggleCollapse will collapse/expand the selected FileNode. func (vm *FileTreeViewModel) ToggleCollapse(filterRegex *regexp.Regexp) error { node := vm.getAbsPositionNode(filterRegex) if node != nil && node.Data.FileInfo.IsDir { node.Data.ViewInfo.Collapsed = !node.Data.ViewInfo.Collapsed } return nil } // ToggleCollapseAll will collapse/expand the all directories. func (vm *FileTreeViewModel) ToggleCollapseAll() error { vm.CollapseAll = !vm.CollapseAll visitor := func(curNode *filetree.FileNode) error { curNode.Data.ViewInfo.Collapsed = vm.CollapseAll return nil } evaluator := func(curNode *filetree.FileNode) bool { return curNode.Data.FileInfo.IsDir } err := vm.ModelTree.VisitDepthChildFirst(visitor, evaluator) if err != nil { log.WithFields("error", err).Debug("unable to propagate tree on ToggleCollapseAll") } return nil } // ToggleSortOrder will toggle the sort order in which files are displayed func (vm *FileTreeViewModel) ToggleSortOrder() error { vm.ModelTree.SortOrder = (vm.ModelTree.SortOrder + 1) % filetree.NumSortOrderConventions return nil } func (vm *FileTreeViewModel) ConstrainLayout() { if !vm.constrainedRealEstate { vm.constrainedRealEstate = true vm.unconstrainedShowAttributes = vm.ShowAttributes vm.ShowAttributes = false } } func (vm *FileTreeViewModel) ExpandLayout() { if vm.constrainedRealEstate { vm.ShowAttributes = vm.unconstrainedShowAttributes vm.constrainedRealEstate = false } } // ToggleAttributes will hi func (vm *FileTreeViewModel) ToggleAttributes() error { // ignore any attempt to show the attributes when the layout is constrained if vm.constrainedRealEstate { return nil } vm.ShowAttributes = !vm.ShowAttributes return nil } // ToggleShowDiffType will show/hide the selected DiffType in the filetree pane. func (vm *FileTreeViewModel) ToggleShowDiffType(diffType filetree.DiffType) { vm.HiddenDiffTypes[diffType] = !vm.HiddenDiffTypes[diffType] } // Update refreshes the state objects for future rendering. func (vm *FileTreeViewModel) Update(filterRegex *regexp.Regexp, width, height int) error { vm.refWidth = width vm.refHeight = height // keep the vm selection in parity with the current DiffType selection err := vm.ModelTree.VisitDepthChildFirst(func(node *filetree.FileNode) error { node.Data.ViewInfo.Hidden = vm.HiddenDiffTypes[node.Data.DiffType] visibleChild := false for _, child := range node.Children { if !child.Data.ViewInfo.Hidden { visibleChild = true node.Data.ViewInfo.Hidden = false } } // hide nodes that do not match the current file filter regex (also don't unhide nodes that are already hidden) if filterRegex != nil && !visibleChild && !node.Data.ViewInfo.Hidden { match := filterRegex.FindString(node.Path()) node.Data.ViewInfo.Hidden = len(match) == 0 } return nil }, nil) if err != nil { return fmt.Errorf("unable to propagate vm model tree: %w", err) } // make a new tree with only visible nodes vm.ViewTree = vm.ModelTree.Copy() err = vm.ViewTree.VisitDepthParentFirst(func(node *filetree.FileNode) error { if node.Data.ViewInfo.Hidden { err1 := vm.ViewTree.RemovePath(node.Path()) if err1 != nil { return err1 } } return nil }, nil) if err != nil { return fmt.Errorf("unable to propagate vm view tree: %w", err) } return nil } // Render flushes the state objects (file tree) to the pane. func (vm *FileTreeViewModel) Render() error { treeString := vm.ViewTree.StringBetween(vm.bufferIndexLowerBound, vm.bufferIndexUpperBound(), vm.ShowAttributes) lines := strings.Split(treeString, "\n") // update the contents vm.Buffer.Reset() for idx, line := range lines { if idx == vm.bufferIndex { _, err := fmt.Fprintln(&vm.Buffer, format.Selected(vtclean.Clean(line, false))) if err != nil { return err } } else { _, err := fmt.Fprintln(&vm.Buffer, line) if err != nil { return err } } } return nil } ================================================ FILE: cmd/dive/cli/internal/ui/v1/viewmodel/filetree_test.go ================================================ package viewmodel import ( "flag" "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1" "go.uber.org/atomic" "os" "os/exec" "path/filepath" "regexp" "strings" "testing" "github.com/wagoodman/dive/dive/filetree" "github.com/wagoodman/dive/dive/image/docker" ) var repoRootCache atomic.String var updateSnapshot = flag.Bool("update", false, "update any test snapshots") func TestUpdateSnapshotDisabled(t *testing.T) { require.False(t, *updateSnapshot, "update snapshot flag should be disabled") } func fileExists(filename string) bool { info, err := os.Stat(filename) if os.IsNotExist(err) { return false } return !info.IsDir() } func testCaseDataFilePath(name string) string { return filepath.Join("testdata", name+".txt") } func helperLoadBytes(t *testing.T) []byte { t.Helper() path := testCaseDataFilePath(t.Name()) theBytes, err := os.ReadFile(path) if err != nil { t.Fatalf("unable to load test data ('%s'): %+v", t.Name(), err) } return theBytes } func helperCaptureBytes(t *testing.T, data []byte) { // TODO: switch to https://github.com/gkampitakis/go-snaps t.Helper() if *updateSnapshot { t.Fatalf("cannot capture data in test mode: %s", t.Name()) } path := testCaseDataFilePath(t.Name()) err := os.WriteFile(path, data, 0644) if err != nil { t.Fatalf("unable to save test data ('%s'): %+v", t.Name(), err) } } func initializeTestViewModel(t *testing.T) *FileTreeViewModel { t.Helper() result := docker.TestAnalysisFromArchive(t, repoPath(t, ".data/test-docker-image.tar")) require.NotNil(t, result, "unable to load test data") vm, err := NewFileTreeViewModel(v1.Config{ Analysis: *result, Preferences: v1.DefaultPreferences(), }, 0) require.NoError(t, err, "unable to create viewmodel") return vm } func runTestCase(t *testing.T, vm *FileTreeViewModel, width, height int, filterRegex *regexp.Regexp) { t.Helper() err := vm.Update(filterRegex, width, height) if err != nil { t.Errorf("failed to update viewmodel: %v", err) } err = vm.Render() if err != nil { t.Errorf("failed to render viewmodel: %v", err) } actualBytes := vm.Buffer.Bytes() path := testCaseDataFilePath(t.Name()) if !fileExists(path) { if *updateSnapshot { helperCaptureBytes(t, actualBytes) } else { t.Fatalf("missing test data: %s", path) } } expectedBytes := helperLoadBytes(t) if d := cmp.Diff(string(expectedBytes), string(actualBytes)); d != "" { t.Errorf("bytes mismatch (-want +got):\n%s", d) } } func checkError(t *testing.T, err error, message string) { if err != nil { t.Errorf(message+": %+v", err) } } func TestFileTreeGoCase(t *testing.T) { vm := initializeTestViewModel(t) width, height := 100, 1000 vm.Setup(0, height) vm.ShowAttributes = true runTestCase(t, vm, width, height, nil) } func TestFileTreeNoAttributes(t *testing.T) { vm := initializeTestViewModel(t) width, height := 100, 1000 vm.Setup(0, height) vm.ShowAttributes = false runTestCase(t, vm, width, height, nil) } func TestFileTreeRestrictedHeight(t *testing.T) { vm := initializeTestViewModel(t) width, height := 100, 20 vm.Setup(0, height) vm.ShowAttributes = false runTestCase(t, vm, width, height, nil) } func TestFileTreeDirCollapse(t *testing.T) { vm := initializeTestViewModel(t) width, height := 100, 100 vm.Setup(0, height) vm.ShowAttributes = true assertPath(t, vm, "/bin", "before toggle of bin") // collapse /bin err := vm.ToggleCollapse(nil) checkError(t, err, "unable to collapse /bin") assertPath(t, vm, "/bin", "after toggle of bin") moved := vm.CursorDown() // select /dev require.True(t, moved, "unable to cursor down") assertPath(t, vm, "/dev", "down to dev") moved = vm.CursorDown() // select /etc require.True(t, moved, "unable to cursor down") assertPath(t, vm, "/etc", "down to etc") // collapse /etc err = vm.ToggleCollapse(nil) checkError(t, err, "unable to collapse /etc") assertPath(t, vm, "/etc", "after toggle of etc") runTestCase(t, vm, width, height, nil) } func assertPath(t *testing.T, vm *FileTreeViewModel, expected string, msg string) { t.Helper() n := vm.CurrentNode(nil) require.NotNil(t, n, "unable to get current node") assert.Equal(t, expected, n.Path(), msg) } func TestFileTreeDirCollapseAll(t *testing.T) { vm := initializeTestViewModel(t) width, height := 100, 100 vm.Setup(0, height) vm.ShowAttributes = true err := vm.ToggleCollapseAll() checkError(t, err, "unable to collapse all dir") runTestCase(t, vm, width, height, nil) } func TestFileTreeSelectLayer(t *testing.T) { vm := initializeTestViewModel(t) width, height := 100, 100 vm.Setup(0, height) vm.ShowAttributes = true // collapse /bin err := vm.ToggleCollapse(nil) checkError(t, err, "unable to collapse /bin") // select the next layer, compareMode = layer err = vm.SetTreeByLayer(0, 0, 1, 1) if err != nil { t.Errorf("unable to SetTreeByLayer: %v", err) } runTestCase(t, vm, width, height, nil) } func TestFileShowAggregateChanges(t *testing.T) { vm := initializeTestViewModel(t) width, height := 100, 100 vm.Setup(0, height) vm.ShowAttributes = true // collapse /bin err := vm.ToggleCollapse(nil) checkError(t, err, "unable to collapse /bin") // select the next layer, compareMode = layer err = vm.SetTreeByLayer(0, 0, 1, 13) checkError(t, err, "unable to SetTreeByLayer") runTestCase(t, vm, width, height, nil) } func TestFileTreePageDown(t *testing.T) { vm := initializeTestViewModel(t) width, height := 100, 10 vm.Setup(0, height) vm.ShowAttributes = true err := vm.Update(nil, width, height) checkError(t, err, "unable to update") err = vm.PageDown() checkError(t, err, "unable to page down") err = vm.PageDown() checkError(t, err, "unable to page down") err = vm.PageDown() checkError(t, err, "unable to page down") runTestCase(t, vm, width, height, nil) } func TestFileTreePageUp(t *testing.T) { vm := initializeTestViewModel(t) width, height := 100, 10 vm.Setup(0, height) vm.ShowAttributes = true // these operations have a render step for intermediate results, which require at least one update to be done first err := vm.Update(nil, width, height) checkError(t, err, "unable to update") err = vm.PageDown() checkError(t, err, "unable to page down") err = vm.PageDown() checkError(t, err, "unable to page down") err = vm.PageUp() checkError(t, err, "unable to page up") runTestCase(t, vm, width, height, nil) } func TestFileTreeDirCursorRight(t *testing.T) { vm := initializeTestViewModel(t) width, height := 100, 100 vm.Setup(0, height) vm.ShowAttributes = true // collapse /bin err := vm.ToggleCollapse(nil) checkError(t, err, "unable to collapse /bin") moved := vm.CursorDown() if !moved { t.Error("unable to cursor down") } moved = vm.CursorDown() if !moved { t.Error("unable to cursor down") } // collapse /etc err = vm.ToggleCollapse(nil) checkError(t, err, "unable to collapse /etc") // expand /etc err = vm.CursorRight(nil) checkError(t, err, "unable to cursor right") runTestCase(t, vm, width, height, nil) } func TestFileTreeFilterTree(t *testing.T) { vm := initializeTestViewModel(t) width, height := 100, 1000 vm.Setup(0, height) vm.ShowAttributes = true regex, err := regexp.Compile("network") if err != nil { t.Errorf("could not create filter regex: %+v", err) } runTestCase(t, vm, width, height, regex) } func TestFileTreeHideAddedRemovedModified(t *testing.T) { vm := initializeTestViewModel(t) width, height := 100, 100 vm.Setup(0, height) vm.ShowAttributes = true // collapse /bin err := vm.ToggleCollapse(nil) checkError(t, err, "unable to collapse /bin") // select the 7th layer, compareMode = layer err = vm.SetTreeByLayer(0, 0, 1, 7) if err != nil { t.Errorf("unable to SetTreeByLayer: %v", err) } // hide added files vm.ToggleShowDiffType(filetree.Added) // hide modified files vm.ToggleShowDiffType(filetree.Modified) // hide removed files vm.ToggleShowDiffType(filetree.Removed) runTestCase(t, vm, width, height, nil) } func TestFileTreeHideUnmodified(t *testing.T) { vm := initializeTestViewModel(t) width, height := 100, 100 vm.Setup(0, height) vm.ShowAttributes = true // collapse /bin err := vm.ToggleCollapse(nil) checkError(t, err, "unable to collapse /bin") // select the 7th layer, compareMode = layer err = vm.SetTreeByLayer(0, 0, 1, 7) if err != nil { t.Errorf("unable to SetTreeByLayer: %v", err) } // hide unmodified files vm.ToggleShowDiffType(filetree.Unmodified) runTestCase(t, vm, width, height, nil) } func TestFileTreeHideTypeWithFilter(t *testing.T) { vm := initializeTestViewModel(t) width, height := 100, 100 vm.Setup(0, height) vm.ShowAttributes = true // collapse /bin err := vm.ToggleCollapse(nil) checkError(t, err, "unable to collapse /bin") // select the 7th layer, compareMode = layer err = vm.SetTreeByLayer(0, 0, 1, 7) if err != nil { t.Errorf("unable to SetTreeByLayer: %v", err) } // hide added files vm.ToggleShowDiffType(filetree.Added) regex, err := regexp.Compile("saved") if err != nil { t.Errorf("could not create filter regex: %+v", err) } runTestCase(t, vm, width, height, regex) } func repoPath(t testing.TB, path string) string { t.Helper() root := repoRoot(t) return filepath.Join(root, path) } func repoRoot(t testing.TB) string { val := repoRootCache.Load() if val != "" { return val } t.Helper() // use git to find the root of the repo out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output() if err != nil { t.Fatalf("failed to get repo root: %v", err) } val = strings.TrimSpace(string(out)) repoRootCache.Store(val) return val } ================================================ FILE: cmd/dive/cli/internal/ui/v1/viewmodel/layer_compare.go ================================================ package viewmodel const ( CompareSingleLayer LayerCompareMode = iota CompareAllLayers ) type LayerCompareMode int ================================================ FILE: cmd/dive/cli/internal/ui/v1/viewmodel/layer_selection.go ================================================ package viewmodel import ( "github.com/wagoodman/dive/dive/image" ) type LayerSelection struct { Layer *image.Layer BottomTreeStart, BottomTreeStop, TopTreeStart, TopTreeStop int } ================================================ FILE: cmd/dive/cli/internal/ui/v1/viewmodel/layer_set_state.go ================================================ package viewmodel import "github.com/wagoodman/dive/dive/image" type LayerSetState struct { LayerIndex int Layers []*image.Layer CompareMode LayerCompareMode CompareStartIndex int } func NewLayerSetState(layers []*image.Layer, compareMode LayerCompareMode) *LayerSetState { return &LayerSetState{ Layers: layers, CompareMode: compareMode, } } // getCompareIndexes determines the layer boundaries to use for comparison (based on the current compare mode) func (state *LayerSetState) GetCompareIndexes() (bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop int) { bottomTreeStart = state.CompareStartIndex topTreeStop = state.LayerIndex if state.LayerIndex == state.CompareStartIndex { bottomTreeStop = state.LayerIndex topTreeStart = state.LayerIndex } else if state.CompareMode == CompareSingleLayer { bottomTreeStop = state.LayerIndex - 1 topTreeStart = state.LayerIndex } else { bottomTreeStop = state.CompareStartIndex topTreeStart = state.CompareStartIndex + 1 } return bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop } ================================================ FILE: cmd/dive/cli/internal/ui/v1/viewmodel/layer_set_state_test.go ================================================ package viewmodel import ( "testing" ) func TestGetCompareIndexes(t *testing.T) { tests := []struct { name string layerIndex int compareMode LayerCompareMode compareStartIndex int expected [4]int }{ { name: "LayerIndex equals CompareStartIndex", layerIndex: 2, compareMode: CompareSingleLayer, compareStartIndex: 2, expected: [4]int{2, 2, 2, 2}, }, { name: "CompareMode is CompareSingleLayer", layerIndex: 3, compareMode: CompareSingleLayer, compareStartIndex: 1, expected: [4]int{1, 2, 3, 3}, }, { name: "Default CompareMode", layerIndex: 4, compareMode: CompareAllLayers, compareStartIndex: 1, expected: [4]int{1, 1, 2, 4}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { state := &LayerSetState{ LayerIndex: tt.layerIndex, CompareMode: tt.compareMode, CompareStartIndex: tt.compareStartIndex, } bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop := state.GetCompareIndexes() actual := [4]int{bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop} if actual != tt.expected { t.Errorf("expected %v, got %v", tt.expected, actual) } }) } } ================================================ FILE: cmd/dive/cli/internal/ui/v1/viewmodel/testdata/TestFileShowAggregateChanges.txt ================================================ drwxr-xr-x 0:0 1.2 MB ├─⊕ bin drwxr-xr-x 0:0 0 B ├── dev drwxr-xr-x 0:0 1.0 kB ├── etc -rw-rw-r-- 0:0 307 B │ ├── group -rw-r--r-- 0:0 127 B │ ├── localtime drwxr-xr-x 0:0 0 B │ ├── network drwxr-xr-x 0:0 0 B │ │ ├── if-down.d drwxr-xr-x 0:0 0 B │ │ ├── if-post-down.d drwxr-xr-x 0:0 0 B │ │ ├── if-pre-up.d drwxr-xr-x 0:0 0 B │ │ └── if-up.d -rw-r--r-- 0:0 340 B │ ├── passwd -rw------- 0:0 243 B │ └── shadow drwxr-xr-x 65534:65534 0 B ├── home drwx------ 0:0 21 kB ├── root drwxr-xr-x 0:0 8.6 kB │ ├── .data -rw-r--r-- 0:0 6.4 kB │ │ ├── saved.again2.txt -rwxrwxr-x 0:0 917 B │ │ ├── tag.sh -rwxr-xr-x 0:0 1.3 kB │ │ └── test.sh -rw-r--r-- 0:0 6.4 kB │ ├── .saved.txt drwxr-xr-x 0:0 19 kB │ ├── example drwxr-xr-x 0:0 0 B │ │ ├── really drwxr-xr-x 0:0 0 B │ │ │ └── nested -r--r--r-- 0:0 6.4 kB │ │ ├── somefile1.txt -rw-r--r-- 0:0 6.4 kB │ │ ├── somefile2.txt -rw-r--r-- 0:0 6.4 kB │ │ └── somefile3.txt -rwxr-xr-x 0:0 6.4 kB │ └── saved.txt -rw-rw-r-- 0:0 6.4 kB ├── somefile.txt drwxrwxrwt 0:0 6.4 kB ├── tmp -rw-r--r-- 0:0 6.4 kB │ └── saved.again1.txt drwxr-xr-x 0:0 0 B ├── usr drwxr-xr-x 1:1 0 B │ └── sbin drwxr-xr-x 0:0 0 B └── var drwxr-xr-x 0:0 0 B ├── spool drwxr-xr-x 8:8 0 B │ └── mail drwxr-xr-x 0:0 0 B └── www ================================================ FILE: cmd/dive/cli/internal/ui/v1/viewmodel/testdata/TestFileTreeDirCollapse.txt ================================================ drwxr-xr-x 0:0 1.2 MB ├─⊕ bin drwxr-xr-x 0:0 0 B ├── dev drwxr-xr-x 0:0 1.0 kB ├─⊕ etc drwxr-xr-x 65534:65534 0 B ├── home drwx------ 0:0 0 B ├── root drwxrwxrwt 0:0 0 B ├── tmp drwxr-xr-x 0:0 0 B ├── usr drwxr-xr-x 1:1 0 B │ └── sbin drwxr-xr-x 0:0 0 B └── var drwxr-xr-x 0:0 0 B ├── spool drwxr-xr-x 8:8 0 B │ └── mail drwxr-xr-x 0:0 0 B └── www ================================================ FILE: cmd/dive/cli/internal/ui/v1/viewmodel/testdata/TestFileTreeDirCollapseAll.txt ================================================ drwxr-xr-x 0:0 1.2 MB ├─⊕ bin drwxr-xr-x 0:0 0 B ├── dev drwxr-xr-x 0:0 1.0 kB ├─⊕ etc drwxr-xr-x 65534:65534 0 B ├── home drwx------ 0:0 0 B ├── root drwxrwxrwt 0:0 0 B ├── tmp drwxr-xr-x 0:0 0 B ├─⊕ usr drwxr-xr-x 0:0 0 B └─⊕ var ================================================ FILE: cmd/dive/cli/internal/ui/v1/viewmodel/testdata/TestFileTreeDirCursorRight.txt ================================================ drwxr-xr-x 0:0 1.2 MB ├─⊕ bin drwxr-xr-x 0:0 0 B ├── dev drwxr-xr-x 0:0 1.0 kB ├── etc -rw-rw-r-- 0:0 307 B │ ├── group -rw-r--r-- 0:0 127 B │ ├── localtime drwxr-xr-x 0:0 0 B │ ├── network drwxr-xr-x 0:0 0 B │ │ ├── if-down.d drwxr-xr-x 0:0 0 B │ │ ├── if-post-down.d drwxr-xr-x 0:0 0 B │ │ ├── if-pre-up.d drwxr-xr-x 0:0 0 B │ │ └── if-up.d -rw-r--r-- 0:0 340 B │ ├── passwd -rw------- 0:0 243 B │ └── shadow drwxr-xr-x 65534:65534 0 B ├── home drwx------ 0:0 0 B ├── root drwxrwxrwt 0:0 0 B ├── tmp drwxr-xr-x 0:0 0 B ├── usr drwxr-xr-x 1:1 0 B │ └── sbin drwxr-xr-x 0:0 0 B └── var drwxr-xr-x 0:0 0 B ├── spool drwxr-xr-x 8:8 0 B │ └── mail drwxr-xr-x 0:0 0 B └── www ================================================ FILE: cmd/dive/cli/internal/ui/v1/viewmodel/testdata/TestFileTreeFilterTree.txt ================================================ drwxr-xr-x 0:0 0 B └── etc drwxr-xr-x 0:0 0 B └── network drwxr-xr-x 0:0 0 B ├── if-down.d drwxr-xr-x 0:0 0 B ├── if-post-down.d drwxr-xr-x 0:0 0 B ├── if-pre-up.d drwxr-xr-x 0:0 0 B └── if-up.d ================================================ FILE: cmd/dive/cli/internal/ui/v1/viewmodel/testdata/TestFileTreeGoCase.txt ================================================ drwxr-xr-x 0:0 1.2 MB ├── bin -rwxr-xr-x 0:0 1.1 MB │ ├── [ -rwxr-xr-x 0:0 0 B │ ├── [[ → bin/[ -rwxr-xr-x 0:0 0 B │ ├── acpid → bin/[ -rwxr-xr-x 0:0 0 B │ ├── add-shell → bin/[ -rwxr-xr-x 0:0 0 B │ ├── addgroup → bin/[ -rwxr-xr-x 0:0 0 B │ ├── adduser → bin/[ -rwxr-xr-x 0:0 0 B │ ├── adjtimex → bin/[ -rwxr-xr-x 0:0 0 B │ ├── ar → bin/[ -rwxr-xr-x 0:0 0 B │ ├── arch → bin/[ -rwxr-xr-x 0:0 0 B │ ├── arp → bin/[ -rwxr-xr-x 0:0 0 B │ ├── arping → bin/[ -rwxr-xr-x 0:0 0 B │ ├── ash → bin/[ -rwxr-xr-x 0:0 0 B │ ├── awk → bin/[ -rwxr-xr-x 0:0 0 B │ ├── base64 → bin/[ -rwxr-xr-x 0:0 0 B │ ├── basename → bin/[ -rwxr-xr-x 0:0 0 B │ ├── beep → bin/[ -rwxr-xr-x 0:0 0 B │ ├── blkdiscard → bin/[ -rwxr-xr-x 0:0 0 B │ ├── blkid → bin/[ -rwxr-xr-x 0:0 0 B │ ├── blockdev → bin/[ -rwxr-xr-x 0:0 0 B │ ├── bootchartd → bin/[ -rwxr-xr-x 0:0 0 B │ ├── brctl → bin/[ -rwxr-xr-x 0:0 0 B │ ├── bunzip2 → bin/[ -rwxr-xr-x 0:0 0 B │ ├── busybox → bin/[ -rwxr-xr-x 0:0 0 B │ ├── bzcat → bin/[ -rwxr-xr-x 0:0 0 B │ ├── bzip2 → bin/[ -rwxr-xr-x 0:0 0 B │ ├── cal → bin/[ -rwxr-xr-x 0:0 0 B │ ├── cat → bin/[ -rwxr-xr-x 0:0 0 B │ ├── chat → bin/[ -rwxr-xr-x 0:0 0 B │ ├── chattr → bin/[ -rwxr-xr-x 0:0 0 B │ ├── chgrp → bin/[ -rwxr-xr-x 0:0 0 B │ ├── chmod → bin/[ -rwxr-xr-x 0:0 0 B │ ├── chown → bin/[ -rwxr-xr-x 0:0 0 B │ ├── chpasswd → bin/[ -rwxr-xr-x 0:0 0 B │ ├── chpst → bin/[ -rwxr-xr-x 0:0 0 B │ ├── chroot → bin/[ -rwxr-xr-x 0:0 0 B │ ├── chrt → bin/[ -rwxr-xr-x 0:0 0 B │ ├── chvt → bin/[ -rwxr-xr-x 0:0 0 B │ ├── cksum → bin/[ -rwxr-xr-x 0:0 0 B │ ├── clear → bin/[ -rwxr-xr-x 0:0 0 B │ ├── cmp → bin/[ -rwxr-xr-x 0:0 0 B │ ├── comm → bin/[ -rwxr-xr-x 0:0 0 B │ ├── conspy → bin/[ -rwxr-xr-x 0:0 0 B │ ├── cp → bin/[ -rwxr-xr-x 0:0 0 B │ ├── cpio → bin/[ -rwxr-xr-x 0:0 0 B │ ├── crond → bin/[ -rwxr-xr-x 0:0 0 B │ ├── crontab → bin/[ -rwxr-xr-x 0:0 0 B │ ├── cryptpw → bin/[ -rwxr-xr-x 0:0 0 B │ ├── cttyhack → bin/[ -rwxr-xr-x 0:0 0 B │ ├── cut → bin/[ -rwxr-xr-x 0:0 0 B │ ├── date → bin/[ -rwxr-xr-x 0:0 0 B │ ├── dc → bin/[ -rwxr-xr-x 0:0 0 B │ ├── dd → bin/[ -rwxr-xr-x 0:0 0 B │ ├── deallocvt → bin/[ -rwxr-xr-x 0:0 0 B │ ├── delgroup → bin/[ -rwxr-xr-x 0:0 0 B │ ├── deluser → bin/[ -rwxr-xr-x 0:0 0 B │ ├── depmod → bin/[ -rwxr-xr-x 0:0 0 B │ ├── devmem → bin/[ -rwxr-xr-x 0:0 0 B │ ├── df → bin/[ -rwxr-xr-x 0:0 0 B │ ├── dhcprelay → bin/[ -rwxr-xr-x 0:0 0 B │ ├── diff → bin/[ -rwxr-xr-x 0:0 0 B │ ├── dirname → bin/[ -rwxr-xr-x 0:0 0 B │ ├── dmesg → bin/[ -rwxr-xr-x 0:0 0 B │ ├── dnsd → bin/[ -rwxr-xr-x 0:0 0 B │ ├── dnsdomainname → bin/[ -rwxr-xr-x 0:0 0 B │ ├── dos2unix → bin/[ -rwxr-xr-x 0:0 0 B │ ├── dpkg → bin/[ -rwxr-xr-x 0:0 0 B │ ├── dpkg-deb → bin/[ -rwxr-xr-x 0:0 0 B │ ├── du → bin/[ -rwxr-xr-x 0:0 0 B │ ├── dumpkmap → bin/[ -rwxr-xr-x 0:0 0 B │ ├── dumpleases → bin/[ -rwxr-xr-x 0:0 0 B │ ├── echo → bin/[ -rwxr-xr-x 0:0 0 B │ ├── ed → bin/[ -rwxr-xr-x 0:0 0 B │ ├── egrep → bin/[ -rwxr-xr-x 0:0 0 B │ ├── eject → bin/[ -rwxr-xr-x 0:0 0 B │ ├── env → bin/[ -rwxr-xr-x 0:0 0 B │ ├── envdir → bin/[ -rwxr-xr-x 0:0 0 B │ ├── envuidgid → bin/[ -rwxr-xr-x 0:0 0 B │ ├── ether-wake → bin/[ -rwxr-xr-x 0:0 0 B │ ├── expand → bin/[ -rwxr-xr-x 0:0 0 B │ ├── expr → bin/[ -rwxr-xr-x 0:0 0 B │ ├── factor → bin/[ -rwxr-xr-x 0:0 0 B │ ├── fakeidentd → bin/[ -rwxr-xr-x 0:0 0 B │ ├── fallocate → bin/[ -rwxr-xr-x 0:0 0 B │ ├── false → bin/[ -rwxr-xr-x 0:0 0 B │ ├── fatattr → bin/[ -rwxr-xr-x 0:0 0 B │ ├── fbset → bin/[ -rwxr-xr-x 0:0 0 B │ ├── fbsplash → bin/[ -rwxr-xr-x 0:0 0 B │ ├── fdflush → bin/[ -rwxr-xr-x 0:0 0 B │ ├── fdformat → bin/[ -rwxr-xr-x 0:0 0 B │ ├── fdisk → bin/[ -rwxr-xr-x 0:0 0 B │ ├── fgconsole → bin/[ -rwxr-xr-x 0:0 0 B │ ├── fgrep → bin/[ -rwxr-xr-x 0:0 0 B │ ├── find → bin/[ -rwxr-xr-x 0:0 0 B │ ├── findfs → bin/[ -rwxr-xr-x 0:0 0 B │ ├── flock → bin/[ -rwxr-xr-x 0:0 0 B │ ├── fold → bin/[ -rwxr-xr-x 0:0 0 B │ ├── free → bin/[ -rwxr-xr-x 0:0 0 B │ ├── freeramdisk → bin/[ -rwxr-xr-x 0:0 0 B │ ├── fsck → bin/[ -rwxr-xr-x 0:0 0 B │ ├── fsck.minix → bin/[ -rwxr-xr-x 0:0 0 B │ ├── fsfreeze → bin/[ -rwxr-xr-x 0:0 0 B │ ├── fstrim → bin/[ -rwxr-xr-x 0:0 0 B │ ├── fsync → bin/[ -rwxr-xr-x 0:0 0 B │ ├── ftpd → bin/[ -rwxr-xr-x 0:0 0 B │ ├── ftpget → bin/[ -rwxr-xr-x 0:0 0 B │ ├── ftpput → bin/[ -rwxr-xr-x 0:0 0 B │ ├── fuser → bin/[ -rwxr-xr-x 0:0 78 kB │ ├── getconf -rwxr-xr-x 0:0 0 B │ ├── getopt → bin/[ -rwxr-xr-x 0:0 0 B │ ├── getty → bin/[ -rwxr-xr-x 0:0 0 B │ ├── grep → bin/[ -rwxr-xr-x 0:0 0 B │ ├── groups → bin/[ -rwxr-xr-x 0:0 0 B │ ├── gunzip → bin/[ -rwxr-xr-x 0:0 0 B │ ├── gzip → bin/[ -rwxr-xr-x 0:0 0 B │ ├── halt → bin/[ -rwxr-xr-x 0:0 0 B │ ├── hd → bin/[ -rwxr-xr-x 0:0 0 B │ ├── hdparm → bin/[ -rwxr-xr-x 0:0 0 B │ ├── head → bin/[ -rwxr-xr-x 0:0 0 B │ ├── hexdump → bin/[ -rwxr-xr-x 0:0 0 B │ ├── hexedit → bin/[ -rwxr-xr-x 0:0 0 B │ ├── hostid → bin/[ -rwxr-xr-x 0:0 0 B │ ├── hostname → bin/[ -rwxr-xr-x 0:0 0 B │ ├── httpd → bin/[ -rwxr-xr-x 0:0 0 B │ ├── hush → bin/[ -rwxr-xr-x 0:0 0 B │ ├── hwclock → bin/[ -rwxr-xr-x 0:0 0 B │ ├── i2cdetect → bin/[ -rwxr-xr-x 0:0 0 B │ ├── i2cdump → bin/[ -rwxr-xr-x 0:0 0 B │ ├── i2cget → bin/[ -rwxr-xr-x 0:0 0 B │ ├── i2cset → bin/[ -rwxr-xr-x 0:0 0 B │ ├── id → bin/[ -rwxr-xr-x 0:0 0 B │ ├── ifconfig → bin/[ -rwxr-xr-x 0:0 0 B │ ├── ifdown → bin/[ -rwxr-xr-x 0:0 0 B │ ├── ifenslave → bin/[ -rwxr-xr-x 0:0 0 B │ ├── ifplugd → bin/[ -rwxr-xr-x 0:0 0 B │ ├── ifup → bin/[ -rwxr-xr-x 0:0 0 B │ ├── inetd → bin/[ -rwxr-xr-x 0:0 0 B │ ├── init → bin/[ -rwxr-xr-x 0:0 0 B │ ├── insmod → bin/[ -rwxr-xr-x 0:0 0 B │ ├── install → bin/[ -rwxr-xr-x 0:0 0 B │ ├── ionice → bin/[ -rwxr-xr-x 0:0 0 B │ ├── iostat → bin/[ -rwxr-xr-x 0:0 0 B │ ├── ip → bin/[ -rwxr-xr-x 0:0 0 B │ ├── ipaddr → bin/[ -rwxr-xr-x 0:0 0 B │ ├── ipcalc → bin/[ -rwxr-xr-x 0:0 0 B │ ├── ipcrm → bin/[ -rwxr-xr-x 0:0 0 B │ ├── ipcs → bin/[ -rwxr-xr-x 0:0 0 B │ ├── iplink → bin/[ -rwxr-xr-x 0:0 0 B │ ├── ipneigh → bin/[ -rwxr-xr-x 0:0 0 B │ ├── iproute → bin/[ -rwxr-xr-x 0:0 0 B │ ├── iprule → bin/[ -rwxr-xr-x 0:0 0 B │ ├── iptunnel → bin/[ -rwxr-xr-x 0:0 0 B │ ├── kbd_mode → bin/[ -rwxr-xr-x 0:0 0 B │ ├── kill → bin/[ -rwxr-xr-x 0:0 0 B │ ├── killall → bin/[ -rwxr-xr-x 0:0 0 B │ ├── killall5 → bin/[ -rwxr-xr-x 0:0 0 B │ ├── klogd → bin/[ -rwxr-xr-x 0:0 0 B │ ├── last → bin/[ -rwxr-xr-x 0:0 0 B │ ├── less → bin/[ -rwxr-xr-x 0:0 0 B │ ├── link → bin/[ -rwxr-xr-x 0:0 0 B │ ├── linux32 → bin/[ -rwxr-xr-x 0:0 0 B │ ├── linux64 → bin/[ -rwxr-xr-x 0:0 0 B │ ├── linuxrc → bin/[ -rwxr-xr-x 0:0 0 B │ ├── ln → bin/[ -rwxr-xr-x 0:0 0 B │ ├── loadfont → bin/[ -rwxr-xr-x 0:0 0 B │ ├── loadkmap → bin/[ -rwxr-xr-x 0:0 0 B │ ├── logger → bin/[ -rwxr-xr-x 0:0 0 B │ ├── login → bin/[ -rwxr-xr-x 0:0 0 B │ ├── logname → bin/[ -rwxr-xr-x 0:0 0 B │ ├── logread → bin/[ -rwxr-xr-x 0:0 0 B │ ├── losetup → bin/[ -rwxr-xr-x 0:0 0 B │ ├── lpd → bin/[ -rwxr-xr-x 0:0 0 B │ ├── lpq → bin/[ -rwxr-xr-x 0:0 0 B │ ├── lpr → bin/[ -rwxr-xr-x 0:0 0 B │ ├── ls → bin/[ -rwxr-xr-x 0:0 0 B │ ├── lsattr → bin/[ -rwxr-xr-x 0:0 0 B │ ├── lsmod → bin/[ -rwxr-xr-x 0:0 0 B │ ├── lsof → bin/[ -rwxr-xr-x 0:0 0 B │ ├── lspci → bin/[ -rwxr-xr-x 0:0 0 B │ ├── lsscsi → bin/[ -rwxr-xr-x 0:0 0 B │ ├── lsusb → bin/[ -rwxr-xr-x 0:0 0 B │ ├── lzcat → bin/[ -rwxr-xr-x 0:0 0 B │ ├── lzma → bin/[ -rwxr-xr-x 0:0 0 B │ ├── lzop → bin/[ -rwxr-xr-x 0:0 0 B │ ├── makedevs → bin/[ -rwxr-xr-x 0:0 0 B │ ├── makemime → bin/[ -rwxr-xr-x 0:0 0 B │ ├── man → bin/[ -rwxr-xr-x 0:0 0 B │ ├── md5sum → bin/[ -rwxr-xr-x 0:0 0 B │ ├── mdev → bin/[ -rwxr-xr-x 0:0 0 B │ ├── mesg → bin/[ -rwxr-xr-x 0:0 0 B │ ├── microcom → bin/[ -rwxr-xr-x 0:0 0 B │ ├── mkdir → bin/[ -rwxr-xr-x 0:0 0 B │ ├── mkdosfs → bin/[ -rwxr-xr-x 0:0 0 B │ ├── mke2fs → bin/[ -rwxr-xr-x 0:0 0 B │ ├── mkfifo → bin/[ -rwxr-xr-x 0:0 0 B │ ├── mkfs.ext2 → bin/[ -rwxr-xr-x 0:0 0 B │ ├── mkfs.minix → bin/[ -rwxr-xr-x 0:0 0 B │ ├── mkfs.vfat → bin/[ -rwxr-xr-x 0:0 0 B │ ├── mknod → bin/[ -rwxr-xr-x 0:0 0 B │ ├── mkpasswd → bin/[ -rwxr-xr-x 0:0 0 B │ ├── mkswap → bin/[ -rwxr-xr-x 0:0 0 B │ ├── mktemp → bin/[ -rwxr-xr-x 0:0 0 B │ ├── modinfo → bin/[ -rwxr-xr-x 0:0 0 B │ ├── modprobe → bin/[ -rwxr-xr-x 0:0 0 B │ ├── more → bin/[ -rwxr-xr-x 0:0 0 B │ ├── mount → bin/[ -rwxr-xr-x 0:0 0 B │ ├── mountpoint → bin/[ -rwxr-xr-x 0:0 0 B │ ├── mpstat → bin/[ -rwxr-xr-x 0:0 0 B │ ├── mt → bin/[ -rwxr-xr-x 0:0 0 B │ ├── mv → bin/[ -rwxr-xr-x 0:0 0 B │ ├── nameif → bin/[ -rwxr-xr-x 0:0 0 B │ ├── nanddump → bin/[ -rwxr-xr-x 0:0 0 B │ ├── nandwrite → bin/[ -rwxr-xr-x 0:0 0 B │ ├── nbd-client → bin/[ -rwxr-xr-x 0:0 0 B │ ├── nc → bin/[ -rwxr-xr-x 0:0 0 B │ ├── netstat → bin/[ -rwxr-xr-x 0:0 0 B │ ├── nice → bin/[ -rwxr-xr-x 0:0 0 B │ ├── nl → bin/[ -rwxr-xr-x 0:0 0 B │ ├── nmeter → bin/[ -rwxr-xr-x 0:0 0 B │ ├── nohup → bin/[ -rwxr-xr-x 0:0 0 B │ ├── nproc → bin/[ -rwxr-xr-x 0:0 0 B │ ├── nsenter → bin/[ -rwxr-xr-x 0:0 0 B │ ├── nslookup → bin/[ -rwxr-xr-x 0:0 0 B │ ├── ntpd → bin/[ -rwxr-xr-x 0:0 0 B │ ├── nuke → bin/[ -rwxr-xr-x 0:0 0 B │ ├── od → bin/[ -rwxr-xr-x 0:0 0 B │ ├── openvt → bin/[ -rwxr-xr-x 0:0 0 B │ ├── partprobe → bin/[ -rwxr-xr-x 0:0 0 B │ ├── passwd → bin/[ -rwxr-xr-x 0:0 0 B │ ├── paste → bin/[ -rwxr-xr-x 0:0 0 B │ ├── patch → bin/[ -rwxr-xr-x 0:0 0 B │ ├── pgrep → bin/[ -rwxr-xr-x 0:0 0 B │ ├── pidof → bin/[ -rwxr-xr-x 0:0 0 B │ ├── ping → bin/[ -rwxr-xr-x 0:0 0 B │ ├── ping6 → bin/[ -rwxr-xr-x 0:0 0 B │ ├── pipe_progress → bin/[ -rwxr-xr-x 0:0 0 B │ ├── pivot_root → bin/[ -rwxr-xr-x 0:0 0 B │ ├── pkill → bin/[ -rwxr-xr-x 0:0 0 B │ ├── pmap → bin/[ -rwxr-xr-x 0:0 0 B │ ├── popmaildir → bin/[ -rwxr-xr-x 0:0 0 B │ ├── poweroff → bin/[ -rwxr-xr-x 0:0 0 B │ ├── powertop → bin/[ -rwxr-xr-x 0:0 0 B │ ├── printenv → bin/[ -rwxr-xr-x 0:0 0 B │ ├── printf → bin/[ -rwxr-xr-x 0:0 0 B │ ├── ps → bin/[ -rwxr-xr-x 0:0 0 B │ ├── pscan → bin/[ -rwxr-xr-x 0:0 0 B │ ├── pstree → bin/[ -rwxr-xr-x 0:0 0 B │ ├── pwd → bin/[ -rwxr-xr-x 0:0 0 B │ ├── pwdx → bin/[ -rwxr-xr-x 0:0 0 B │ ├── raidautorun → bin/[ -rwxr-xr-x 0:0 0 B │ ├── rdate → bin/[ -rwxr-xr-x 0:0 0 B │ ├── rdev → bin/[ -rwxr-xr-x 0:0 0 B │ ├── readahead → bin/[ -rwxr-xr-x 0:0 0 B │ ├── readlink → bin/[ -rwxr-xr-x 0:0 0 B │ ├── readprofile → bin/[ -rwxr-xr-x 0:0 0 B │ ├── realpath → bin/[ -rwxr-xr-x 0:0 0 B │ ├── reboot → bin/[ -rwxr-xr-x 0:0 0 B │ ├── reformime → bin/[ -rwxr-xr-x 0:0 0 B │ ├── remove-shell → bin/[ -rwxr-xr-x 0:0 0 B │ ├── renice → bin/[ -rwxr-xr-x 0:0 0 B │ ├── reset → bin/[ -rwxr-xr-x 0:0 0 B │ ├── resize → bin/[ -rwxr-xr-x 0:0 0 B │ ├── resume → bin/[ -rwxr-xr-x 0:0 0 B │ ├── rev → bin/[ -rwxr-xr-x 0:0 0 B │ ├── rm → bin/[ -rwxr-xr-x 0:0 0 B │ ├── rmdir → bin/[ -rwxr-xr-x 0:0 0 B │ ├── rmmod → bin/[ -rwxr-xr-x 0:0 0 B │ ├── route → bin/[ -rwxr-xr-x 0:0 0 B │ ├── rpm → bin/[ -rwxr-xr-x 0:0 0 B │ ├── rpm2cpio → bin/[ -rwxr-xr-x 0:0 0 B │ ├── rtcwake → bin/[ -rwxr-xr-x 0:0 0 B │ ├── run-init → bin/[ -rwxr-xr-x 0:0 0 B │ ├── run-parts → bin/[ -rwxr-xr-x 0:0 0 B │ ├── runlevel → bin/[ -rwxr-xr-x 0:0 0 B │ ├── runsv → bin/[ -rwxr-xr-x 0:0 0 B │ ├── runsvdir → bin/[ -rwxr-xr-x 0:0 0 B │ ├── rx → bin/[ -rwxr-xr-x 0:0 0 B │ ├── script → bin/[ -rwxr-xr-x 0:0 0 B │ ├── scriptreplay → bin/[ -rwxr-xr-x 0:0 0 B │ ├── sed → bin/[ -rwxr-xr-x 0:0 0 B │ ├── sendmail → bin/[ -rwxr-xr-x 0:0 0 B │ ├── seq → bin/[ -rwxr-xr-x 0:0 0 B │ ├── setarch → bin/[ -rwxr-xr-x 0:0 0 B │ ├── setconsole → bin/[ -rwxr-xr-x 0:0 0 B │ ├── setfattr → bin/[ -rwxr-xr-x 0:0 0 B │ ├── setfont → bin/[ -rwxr-xr-x 0:0 0 B │ ├── setkeycodes → bin/[ -rwxr-xr-x 0:0 0 B │ ├── setlogcons → bin/[ -rwxr-xr-x 0:0 0 B │ ├── setpriv → bin/[ -rwxr-xr-x 0:0 0 B │ ├── setserial → bin/[ -rwxr-xr-x 0:0 0 B │ ├── setsid → bin/[ -rwxr-xr-x 0:0 0 B │ ├── setuidgid → bin/[ -rwxr-xr-x 0:0 0 B │ ├── sh → bin/[ -rwxr-xr-x 0:0 0 B │ ├── sha1sum → bin/[ -rwxr-xr-x 0:0 0 B │ ├── sha256sum → bin/[ -rwxr-xr-x 0:0 0 B │ ├── sha3sum → bin/[ -rwxr-xr-x 0:0 0 B │ ├── sha512sum → bin/[ -rwxr-xr-x 0:0 0 B │ ├── showkey → bin/[ -rwxr-xr-x 0:0 0 B │ ├── shred → bin/[ -rwxr-xr-x 0:0 0 B │ ├── shuf → bin/[ -rwxr-xr-x 0:0 0 B │ ├── slattach → bin/[ -rwxr-xr-x 0:0 0 B │ ├── sleep → bin/[ -rwxr-xr-x 0:0 0 B │ ├── smemcap → bin/[ -rwxr-xr-x 0:0 0 B │ ├── softlimit → bin/[ -rwxr-xr-x 0:0 0 B │ ├── sort → bin/[ -rwxr-xr-x 0:0 0 B │ ├── split → bin/[ -rwxr-xr-x 0:0 0 B │ ├── ssl_client → bin/[ -rwxr-xr-x 0:0 0 B │ ├── start-stop-daemon → bin/[ -rwxr-xr-x 0:0 0 B │ ├── stat → bin/[ -rwxr-xr-x 0:0 0 B │ ├── strings → bin/[ -rwxr-xr-x 0:0 0 B │ ├── stty → bin/[ -rwxr-xr-x 0:0 0 B │ ├── su → bin/[ -rwxr-xr-x 0:0 0 B │ ├── sulogin → bin/[ -rwxr-xr-x 0:0 0 B │ ├── sum → bin/[ -rwxr-xr-x 0:0 0 B │ ├── sv → bin/[ -rwxr-xr-x 0:0 0 B │ ├── svc → bin/[ -rwxr-xr-x 0:0 0 B │ ├── svlogd → bin/[ -rwxr-xr-x 0:0 0 B │ ├── svok → bin/[ -rwxr-xr-x 0:0 0 B │ ├── swapoff → bin/[ -rwxr-xr-x 0:0 0 B │ ├── swapon → bin/[ -rwxr-xr-x 0:0 0 B │ ├── switch_root → bin/[ -rwxr-xr-x 0:0 0 B │ ├── sync → bin/[ -rwxr-xr-x 0:0 0 B │ ├── sysctl → bin/[ -rwxr-xr-x 0:0 0 B │ ├── syslogd → bin/[ -rwxr-xr-x 0:0 0 B │ ├── tac → bin/[ -rwxr-xr-x 0:0 0 B │ ├── tail → bin/[ -rwxr-xr-x 0:0 0 B │ ├── tar → bin/[ -rwxr-xr-x 0:0 0 B │ ├── taskset → bin/[ -rwxr-xr-x 0:0 0 B │ ├── tc → bin/[ -rwxr-xr-x 0:0 0 B │ ├── tcpsvd → bin/[ -rwxr-xr-x 0:0 0 B │ ├── tee → bin/[ -rwxr-xr-x 0:0 0 B │ ├── telnet → bin/[ -rwxr-xr-x 0:0 0 B │ ├── telnetd → bin/[ -rwxr-xr-x 0:0 0 B │ ├── test → bin/[ -rwxr-xr-x 0:0 0 B │ ├── tftp → bin/[ -rwxr-xr-x 0:0 0 B │ ├── tftpd → bin/[ -rwxr-xr-x 0:0 0 B │ ├── time → bin/[ -rwxr-xr-x 0:0 0 B │ ├── timeout → bin/[ -rwxr-xr-x 0:0 0 B │ ├── top → bin/[ -rwxr-xr-x 0:0 0 B │ ├── touch → bin/[ -rwxr-xr-x 0:0 0 B │ ├── tr → bin/[ -rwxr-xr-x 0:0 0 B │ ├── traceroute → bin/[ -rwxr-xr-x 0:0 0 B │ ├── traceroute6 → bin/[ -rwxr-xr-x 0:0 0 B │ ├── true → bin/[ -rwxr-xr-x 0:0 0 B │ ├── truncate → bin/[ -rwxr-xr-x 0:0 0 B │ ├── tty → bin/[ -rwxr-xr-x 0:0 0 B │ ├── ttysize → bin/[ -rwxr-xr-x 0:0 0 B │ ├── tunctl → bin/[ -rwxr-xr-x 0:0 0 B │ ├── ubiattach → bin/[ -rwxr-xr-x 0:0 0 B │ ├── ubidetach → bin/[ -rwxr-xr-x 0:0 0 B │ ├── ubimkvol → bin/[ -rwxr-xr-x 0:0 0 B │ ├── ubirename → bin/[ -rwxr-xr-x 0:0 0 B │ ├── ubirmvol → bin/[ -rwxr-xr-x 0:0 0 B │ ├── ubirsvol → bin/[ -rwxr-xr-x 0:0 0 B │ ├── ubiupdatevol → bin/[ -rwxr-xr-x 0:0 0 B │ ├── udhcpc → bin/[ -rwxr-xr-x 0:0 0 B │ ├── udhcpd → bin/[ -rwxr-xr-x 0:0 0 B │ ├── udpsvd → bin/[ -rwxr-xr-x 0:0 0 B │ ├── uevent → bin/[ -rwxr-xr-x 0:0 0 B │ ├── umount → bin/[ -rwxr-xr-x 0:0 0 B │ ├── uname → bin/[ -rwxr-xr-x 0:0 0 B │ ├── unexpand → bin/[ -rwxr-xr-x 0:0 0 B │ ├── uniq → bin/[ -rwxr-xr-x 0:0 0 B │ ├── unix2dos → bin/[ -rwxr-xr-x 0:0 0 B │ ├── unlink → bin/[ -rwxr-xr-x 0:0 0 B │ ├── unlzma → bin/[ -rwxr-xr-x 0:0 0 B │ ├── unshare → bin/[ -rwxr-xr-x 0:0 0 B │ ├── unxz → bin/[ -rwxr-xr-x 0:0 0 B │ ├── unzip → bin/[ -rwxr-xr-x 0:0 0 B │ ├── uptime → bin/[ -rwxr-xr-x 0:0 0 B │ ├── users → bin/[ -rwxr-xr-x 0:0 0 B │ ├── usleep → bin/[ -rwxr-xr-x 0:0 0 B │ ├── uudecode → bin/[ -rwxr-xr-x 0:0 0 B │ ├── uuencode → bin/[ -rwxr-xr-x 0:0 0 B │ ├── vconfig → bin/[ -rwxr-xr-x 0:0 0 B │ ├── vi → bin/[ -rwxr-xr-x 0:0 0 B │ ├── vlock → bin/[ -rwxr-xr-x 0:0 0 B │ ├── volname → bin/[ -rwxr-xr-x 0:0 0 B │ ├── w → bin/[ -rwxr-xr-x 0:0 0 B │ ├── wall → bin/[ -rwxr-xr-x 0:0 0 B │ ├── watch → bin/[ -rwxr-xr-x 0:0 0 B │ ├── watchdog → bin/[ -rwxr-xr-x 0:0 0 B │ ├── wc → bin/[ -rwxr-xr-x 0:0 0 B │ ├── wget → bin/[ -rwxr-xr-x 0:0 0 B │ ├── which → bin/[ -rwxr-xr-x 0:0 0 B │ ├── who → bin/[ -rwxr-xr-x 0:0 0 B │ ├── whoami → bin/[ -rwxr-xr-x 0:0 0 B │ ├── whois → bin/[ -rwxr-xr-x 0:0 0 B │ ├── xargs → bin/[ -rwxr-xr-x 0:0 0 B │ ├── xxd → bin/[ -rwxr-xr-x 0:0 0 B │ ├── xz → bin/[ -rwxr-xr-x 0:0 0 B │ ├── xzcat → bin/[ -rwxr-xr-x 0:0 0 B │ ├── yes → bin/[ -rwxr-xr-x 0:0 0 B │ ├── zcat → bin/[ -rwxr-xr-x 0:0 0 B │ └── zcip → bin/[ drwxr-xr-x 0:0 0 B ├── dev drwxr-xr-x 0:0 1.0 kB ├── etc -rw-rw-r-- 0:0 307 B │ ├── group -rw-r--r-- 0:0 127 B │ ├── localtime drwxr-xr-x 0:0 0 B │ ├── network drwxr-xr-x 0:0 0 B │ │ ├── if-down.d drwxr-xr-x 0:0 0 B │ │ ├── if-post-down.d drwxr-xr-x 0:0 0 B │ │ ├── if-pre-up.d drwxr-xr-x 0:0 0 B │ │ └── if-up.d -rw-r--r-- 0:0 340 B │ ├── passwd -rw------- 0:0 243 B │ └── shadow drwxr-xr-x 65534:65534 0 B ├── home drwx------ 0:0 0 B ├── root drwxrwxrwt 0:0 0 B ├── tmp drwxr-xr-x 0:0 0 B ├── usr drwxr-xr-x 1:1 0 B │ └── sbin drwxr-xr-x 0:0 0 B └── var drwxr-xr-x 0:0 0 B ├── spool drwxr-xr-x 8:8 0 B │ └── mail drwxr-xr-x 0:0 0 B └── www ================================================ FILE: cmd/dive/cli/internal/ui/v1/viewmodel/testdata/TestFileTreeHideAddedRemovedModified.txt ================================================ drwxr-xr-x 0:0 1.2 MB ├─⊕ bin drwxr-xr-x 0:0 0 B ├── dev drwxr-xr-x 0:0 1.0 kB ├── etc -rw-rw-r-- 0:0 307 B │ ├── group -rw-r--r-- 0:0 127 B │ ├── localtime drwxr-xr-x 0:0 0 B │ ├── network drwxr-xr-x 0:0 0 B │ │ ├── if-down.d drwxr-xr-x 0:0 0 B │ │ ├── if-post-down.d drwxr-xr-x 0:0 0 B │ │ ├── if-pre-up.d drwxr-xr-x 0:0 0 B │ │ └── if-up.d -rw-r--r-- 0:0 340 B │ ├── passwd -rw------- 0:0 243 B │ └── shadow drwxr-xr-x 65534:65534 0 B ├── home drwxrwxrwt 0:0 0 B ├── tmp drwxr-xr-x 0:0 0 B ├── usr drwxr-xr-x 1:1 0 B │ └── sbin drwxr-xr-x 0:0 0 B └── var drwxr-xr-x 0:0 0 B ├── spool drwxr-xr-x 8:8 0 B │ └── mail drwxr-xr-x 0:0 0 B └── www ================================================ FILE: cmd/dive/cli/internal/ui/v1/viewmodel/testdata/TestFileTreeHideTypeWithFilter.txt ================================================ ================================================ FILE: cmd/dive/cli/internal/ui/v1/viewmodel/testdata/TestFileTreeHideUnmodified.txt ================================================ drwx------ 0:0 19 kB ├── root drwxr-xr-x 0:0 13 kB │ ├── example drwxr-xr-x 0:0 0 B │ │ ├── really drwxr-xr-x 0:0 0 B │ │ │ └── nested -r--r--r-- 0:0 6.4 kB │ │ ├── somefile1.txt -rw-r--r-- 0:0 6.4 kB │ │ ├── somefile2.txt -rw-r--r-- 0:0 6.4 kB │ │ └── somefile3.txt -rw-r--r-- 0:0 6.4 kB │ └── saved.txt -rw-rw-r-- 0:0 6.4 kB └── somefile.txt ================================================ FILE: cmd/dive/cli/internal/ui/v1/viewmodel/testdata/TestFileTreeNoAttributes.txt ================================================ ├── bin │ ├── [ │ ├── [[ → bin/[ │ ├── acpid → bin/[ │ ├── add-shell → bin/[ │ ├── addgroup → bin/[ │ ├── adduser → bin/[ │ ├── adjtimex → bin/[ │ ├── ar → bin/[ │ ├── arch → bin/[ │ ├── arp → bin/[ │ ├── arping → bin/[ │ ├── ash → bin/[ │ ├── awk → bin/[ │ ├── base64 → bin/[ │ ├── basename → bin/[ │ ├── beep → bin/[ │ ├── blkdiscard → bin/[ │ ├── blkid → bin/[ │ ├── blockdev → bin/[ │ ├── bootchartd → bin/[ │ ├── brctl → bin/[ │ ├── bunzip2 → bin/[ │ ├── busybox → bin/[ │ ├── bzcat → bin/[ │ ├── bzip2 → bin/[ │ ├── cal → bin/[ │ ├── cat → bin/[ │ ├── chat → bin/[ │ ├── chattr → bin/[ │ ├── chgrp → bin/[ │ ├── chmod → bin/[ │ ├── chown → bin/[ │ ├── chpasswd → bin/[ │ ├── chpst → bin/[ │ ├── chroot → bin/[ │ ├── chrt → bin/[ │ ├── chvt → bin/[ │ ├── cksum → bin/[ │ ├── clear → bin/[ │ ├── cmp → bin/[ │ ├── comm → bin/[ │ ├── conspy → bin/[ │ ├── cp → bin/[ │ ├── cpio → bin/[ │ ├── crond → bin/[ │ ├── crontab → bin/[ │ ├── cryptpw → bin/[ │ ├── cttyhack → bin/[ │ ├── cut → bin/[ │ ├── date → bin/[ │ ├── dc → bin/[ │ ├── dd → bin/[ │ ├── deallocvt → bin/[ │ ├── delgroup → bin/[ │ ├── deluser → bin/[ │ ├── depmod → bin/[ │ ├── devmem → bin/[ │ ├── df → bin/[ │ ├── dhcprelay → bin/[ │ ├── diff → bin/[ │ ├── dirname → bin/[ │ ├── dmesg → bin/[ │ ├── dnsd → bin/[ │ ├── dnsdomainname → bin/[ │ ├── dos2unix → bin/[ │ ├── dpkg → bin/[ │ ├── dpkg-deb → bin/[ │ ├── du → bin/[ │ ├── dumpkmap → bin/[ │ ├── dumpleases → bin/[ │ ├── echo → bin/[ │ ├── ed → bin/[ │ ├── egrep → bin/[ │ ├── eject → bin/[ │ ├── env → bin/[ │ ├── envdir → bin/[ │ ├── envuidgid → bin/[ │ ├── ether-wake → bin/[ │ ├── expand → bin/[ │ ├── expr → bin/[ │ ├── factor → bin/[ │ ├── fakeidentd → bin/[ │ ├── fallocate → bin/[ │ ├── false → bin/[ │ ├── fatattr → bin/[ │ ├── fbset → bin/[ │ ├── fbsplash → bin/[ │ ├── fdflush → bin/[ │ ├── fdformat → bin/[ │ ├── fdisk → bin/[ │ ├── fgconsole → bin/[ │ ├── fgrep → bin/[ │ ├── find → bin/[ │ ├── findfs → bin/[ │ ├── flock → bin/[ │ ├── fold → bin/[ │ ├── free → bin/[ │ ├── freeramdisk → bin/[ │ ├── fsck → bin/[ │ ├── fsck.minix → bin/[ │ ├── fsfreeze → bin/[ │ ├── fstrim → bin/[ │ ├── fsync → bin/[ │ ├── ftpd → bin/[ │ ├── ftpget → bin/[ │ ├── ftpput → bin/[ │ ├── fuser → bin/[ │ ├── getconf │ ├── getopt → bin/[ │ ├── getty → bin/[ │ ├── grep → bin/[ │ ├── groups → bin/[ │ ├── gunzip → bin/[ │ ├── gzip → bin/[ │ ├── halt → bin/[ │ ├── hd → bin/[ │ ├── hdparm → bin/[ │ ├── head → bin/[ │ ├── hexdump → bin/[ │ ├── hexedit → bin/[ │ ├── hostid → bin/[ │ ├── hostname → bin/[ │ ├── httpd → bin/[ │ ├── hush → bin/[ │ ├── hwclock → bin/[ │ ├── i2cdetect → bin/[ │ ├── i2cdump → bin/[ │ ├── i2cget → bin/[ │ ├── i2cset → bin/[ │ ├── id → bin/[ │ ├── ifconfig → bin/[ │ ├── ifdown → bin/[ │ ├── ifenslave → bin/[ │ ├── ifplugd → bin/[ │ ├── ifup → bin/[ │ ├── inetd → bin/[ │ ├── init → bin/[ │ ├── insmod → bin/[ │ ├── install → bin/[ │ ├── ionice → bin/[ │ ├── iostat → bin/[ │ ├── ip → bin/[ │ ├── ipaddr → bin/[ │ ├── ipcalc → bin/[ │ ├── ipcrm → bin/[ │ ├── ipcs → bin/[ │ ├── iplink → bin/[ │ ├── ipneigh → bin/[ │ ├── iproute → bin/[ │ ├── iprule → bin/[ │ ├── iptunnel → bin/[ │ ├── kbd_mode → bin/[ │ ├── kill → bin/[ │ ├── killall → bin/[ │ ├── killall5 → bin/[ │ ├── klogd → bin/[ │ ├── last → bin/[ │ ├── less → bin/[ │ ├── link → bin/[ │ ├── linux32 → bin/[ │ ├── linux64 → bin/[ │ ├── linuxrc → bin/[ │ ├── ln → bin/[ │ ├── loadfont → bin/[ │ ├── loadkmap → bin/[ │ ├── logger → bin/[ │ ├── login → bin/[ │ ├── logname → bin/[ │ ├── logread → bin/[ │ ├── losetup → bin/[ │ ├── lpd → bin/[ │ ├── lpq → bin/[ │ ├── lpr → bin/[ │ ├── ls → bin/[ │ ├── lsattr → bin/[ │ ├── lsmod → bin/[ │ ├── lsof → bin/[ │ ├── lspci → bin/[ │ ├── lsscsi → bin/[ │ ├── lsusb → bin/[ │ ├── lzcat → bin/[ │ ├── lzma → bin/[ │ ├── lzop → bin/[ │ ├── makedevs → bin/[ │ ├── makemime → bin/[ │ ├── man → bin/[ │ ├── md5sum → bin/[ │ ├── mdev → bin/[ │ ├── mesg → bin/[ │ ├── microcom → bin/[ │ ├── mkdir → bin/[ │ ├── mkdosfs → bin/[ │ ├── mke2fs → bin/[ │ ├── mkfifo → bin/[ │ ├── mkfs.ext2 → bin/[ │ ├── mkfs.minix → bin/[ │ ├── mkfs.vfat → bin/[ │ ├── mknod → bin/[ │ ├── mkpasswd → bin/[ │ ├── mkswap → bin/[ │ ├── mktemp → bin/[ │ ├── modinfo → bin/[ │ ├── modprobe → bin/[ │ ├── more → bin/[ │ ├── mount → bin/[ │ ├── mountpoint → bin/[ │ ├── mpstat → bin/[ │ ├── mt → bin/[ │ ├── mv → bin/[ │ ├── nameif → bin/[ │ ├── nanddump → bin/[ │ ├── nandwrite → bin/[ │ ├── nbd-client → bin/[ │ ├── nc → bin/[ │ ├── netstat → bin/[ │ ├── nice → bin/[ │ ├── nl → bin/[ │ ├── nmeter → bin/[ │ ├── nohup → bin/[ │ ├── nproc → bin/[ │ ├── nsenter → bin/[ │ ├── nslookup → bin/[ │ ├── ntpd → bin/[ │ ├── nuke → bin/[ │ ├── od → bin/[ │ ├── openvt → bin/[ │ ├── partprobe → bin/[ │ ├── passwd → bin/[ │ ├── paste → bin/[ │ ├── patch → bin/[ │ ├── pgrep → bin/[ │ ├── pidof → bin/[ │ ├── ping → bin/[ │ ├── ping6 → bin/[ │ ├── pipe_progress → bin/[ │ ├── pivot_root → bin/[ │ ├── pkill → bin/[ │ ├── pmap → bin/[ │ ├── popmaildir → bin/[ │ ├── poweroff → bin/[ │ ├── powertop → bin/[ │ ├── printenv → bin/[ │ ├── printf → bin/[ │ ├── ps → bin/[ │ ├── pscan → bin/[ │ ├── pstree → bin/[ │ ├── pwd → bin/[ │ ├── pwdx → bin/[ │ ├── raidautorun → bin/[ │ ├── rdate → bin/[ │ ├── rdev → bin/[ │ ├── readahead → bin/[ │ ├── readlink → bin/[ │ ├── readprofile → bin/[ │ ├── realpath → bin/[ │ ├── reboot → bin/[ │ ├── reformime → bin/[ │ ├── remove-shell → bin/[ │ ├── renice → bin/[ │ ├── reset → bin/[ │ ├── resize → bin/[ │ ├── resume → bin/[ │ ├── rev → bin/[ │ ├── rm → bin/[ │ ├── rmdir → bin/[ │ ├── rmmod → bin/[ │ ├── route → bin/[ │ ├── rpm → bin/[ │ ├── rpm2cpio → bin/[ │ ├── rtcwake → bin/[ │ ├── run-init → bin/[ │ ├── run-parts → bin/[ │ ├── runlevel → bin/[ │ ├── runsv → bin/[ │ ├── runsvdir → bin/[ │ ├── rx → bin/[ │ ├── script → bin/[ │ ├── scriptreplay → bin/[ │ ├── sed → bin/[ │ ├── sendmail → bin/[ │ ├── seq → bin/[ │ ├── setarch → bin/[ │ ├── setconsole → bin/[ │ ├── setfattr → bin/[ │ ├── setfont → bin/[ │ ├── setkeycodes → bin/[ │ ├── setlogcons → bin/[ │ ├── setpriv → bin/[ │ ├── setserial → bin/[ │ ├── setsid → bin/[ │ ├── setuidgid → bin/[ │ ├── sh → bin/[ │ ├── sha1sum → bin/[ │ ├── sha256sum → bin/[ │ ├── sha3sum → bin/[ │ ├── sha512sum → bin/[ │ ├── showkey → bin/[ │ ├── shred → bin/[ │ ├── shuf → bin/[ │ ├── slattach → bin/[ │ ├── sleep → bin/[ │ ├── smemcap → bin/[ │ ├── softlimit → bin/[ │ ├── sort → bin/[ │ ├── split → bin/[ │ ├── ssl_client → bin/[ │ ├── start-stop-daemon → bin/[ │ ├── stat → bin/[ │ ├── strings → bin/[ │ ├── stty → bin/[ │ ├── su → bin/[ │ ├── sulogin → bin/[ │ ├── sum → bin/[ │ ├── sv → bin/[ │ ├── svc → bin/[ │ ├── svlogd → bin/[ │ ├── svok → bin/[ │ ├── swapoff → bin/[ │ ├── swapon → bin/[ │ ├── switch_root → bin/[ │ ├── sync → bin/[ │ ├── sysctl → bin/[ │ ├── syslogd → bin/[ │ ├── tac → bin/[ │ ├── tail → bin/[ │ ├── tar → bin/[ │ ├── taskset → bin/[ │ ├── tc → bin/[ │ ├── tcpsvd → bin/[ │ ├── tee → bin/[ │ ├── telnet → bin/[ │ ├── telnetd → bin/[ │ ├── test → bin/[ │ ├── tftp → bin/[ │ ├── tftpd → bin/[ │ ├── time → bin/[ │ ├── timeout → bin/[ │ ├── top → bin/[ │ ├── touch → bin/[ │ ├── tr → bin/[ │ ├── traceroute → bin/[ │ ├── traceroute6 → bin/[ │ ├── true → bin/[ │ ├── truncate → bin/[ │ ├── tty → bin/[ │ ├── ttysize → bin/[ │ ├── tunctl → bin/[ │ ├── ubiattach → bin/[ │ ├── ubidetach → bin/[ │ ├── ubimkvol → bin/[ │ ├── ubirename → bin/[ │ ├── ubirmvol → bin/[ │ ├── ubirsvol → bin/[ │ ├── ubiupdatevol → bin/[ │ ├── udhcpc → bin/[ │ ├── udhcpd → bin/[ │ ├── udpsvd → bin/[ │ ├── uevent → bin/[ │ ├── umount → bin/[ │ ├── uname → bin/[ │ ├── unexpand → bin/[ │ ├── uniq → bin/[ │ ├── unix2dos → bin/[ │ ├── unlink → bin/[ │ ├── unlzma → bin/[ │ ├── unshare → bin/[ │ ├── unxz → bin/[ │ ├── unzip → bin/[ │ ├── uptime → bin/[ │ ├── users → bin/[ │ ├── usleep → bin/[ │ ├── uudecode → bin/[ │ ├── uuencode → bin/[ │ ├── vconfig → bin/[ │ ├── vi → bin/[ │ ├── vlock → bin/[ │ ├── volname → bin/[ │ ├── w → bin/[ │ ├── wall → bin/[ │ ├── watch → bin/[ │ ├── watchdog → bin/[ │ ├── wc → bin/[ │ ├── wget → bin/[ │ ├── which → bin/[ │ ├── who → bin/[ │ ├── whoami → bin/[ │ ├── whois → bin/[ │ ├── xargs → bin/[ │ ├── xxd → bin/[ │ ├── xz → bin/[ │ ├── xzcat → bin/[ │ ├── yes → bin/[ │ ├── zcat → bin/[ │ └── zcip → bin/[ ├── dev ├── etc │ ├── group │ ├── localtime │ ├── network │ │ ├── if-down.d │ │ ├── if-post-down.d │ │ ├── if-pre-up.d │ │ └── if-up.d │ ├── passwd │ └── shadow ├── home ├── root ├── tmp ├── usr │ └── sbin └── var ├── spool │ └── mail └── www ================================================ FILE: cmd/dive/cli/internal/ui/v1/viewmodel/testdata/TestFileTreePageDown.txt ================================================ -rwxr-xr-x 0:0 0 B │ ├── cat → bin/[ -rwxr-xr-x 0:0 0 B │ ├── chat → bin/[ -rwxr-xr-x 0:0 0 B │ ├── chattr → bin/[ -rwxr-xr-x 0:0 0 B │ ├── chgrp → bin/[ -rwxr-xr-x 0:0 0 B │ ├── chmod → bin/[ -rwxr-xr-x 0:0 0 B │ ├── chown → bin/[ -rwxr-xr-x 0:0 0 B │ ├── chpasswd → bin/[ -rwxr-xr-x 0:0 0 B │ ├── chpst → bin/[ -rwxr-xr-x 0:0 0 B │ ├── chroot → bin/[ -rwxr-xr-x 0:0 0 B │ ├── chrt → bin/[ ================================================ FILE: cmd/dive/cli/internal/ui/v1/viewmodel/testdata/TestFileTreePageUp.txt ================================================ -rwxr-xr-x 0:0 0 B │ ├── arch → bin/[ -rwxr-xr-x 0:0 0 B │ ├── arp → bin/[ -rwxr-xr-x 0:0 0 B │ ├── arping → bin/[ -rwxr-xr-x 0:0 0 B │ ├── ash → bin/[ -rwxr-xr-x 0:0 0 B │ ├── awk → bin/[ -rwxr-xr-x 0:0 0 B │ ├── base64 → bin/[ -rwxr-xr-x 0:0 0 B │ ├── basename → bin/[ -rwxr-xr-x 0:0 0 B │ ├── beep → bin/[ -rwxr-xr-x 0:0 0 B │ ├── blkdiscard → bin/[ -rwxr-xr-x 0:0 0 B │ ├── blkid → bin/[ ================================================ FILE: cmd/dive/cli/internal/ui/v1/viewmodel/testdata/TestFileTreeRestrictedHeight.txt ================================================ ├── bin │ ├── [ │ ├── [[ → bin/[ │ ├── acpid → bin/[ │ ├── add-shell → bin/[ │ ├── addgroup → bin/[ │ ├── adduser → bin/[ │ ├── adjtimex → bin/[ │ ├── ar → bin/[ │ ├── arch → bin/[ │ ├── arp → bin/[ │ ├── arping → bin/[ │ ├── ash → bin/[ │ ├── awk → bin/[ │ ├── base64 → bin/[ │ ├── basename → bin/[ │ ├── beep → bin/[ │ ├── blkdiscard → bin/[ │ ├── blkid → bin/[ │ ├── blockdev → bin/[ │ ├── bootchartd → bin/[ ================================================ FILE: cmd/dive/cli/internal/ui/v1/viewmodel/testdata/TestFileTreeSelectLayer.txt ================================================ drwxr-xr-x 0:0 1.2 MB ├─⊕ bin drwxr-xr-x 0:0 0 B ├── dev drwxr-xr-x 0:0 1.0 kB ├── etc -rw-rw-r-- 0:0 307 B │ ├── group -rw-r--r-- 0:0 127 B │ ├── localtime drwxr-xr-x 0:0 0 B │ ├── network drwxr-xr-x 0:0 0 B │ │ ├── if-down.d drwxr-xr-x 0:0 0 B │ │ ├── if-post-down.d drwxr-xr-x 0:0 0 B │ │ ├── if-pre-up.d drwxr-xr-x 0:0 0 B │ │ └── if-up.d -rw-r--r-- 0:0 340 B │ ├── passwd -rw------- 0:0 243 B │ └── shadow drwxr-xr-x 65534:65534 0 B ├── home drwx------ 0:0 0 B ├── root -rw-rw-r-- 0:0 6.4 kB ├── somefile.txt drwxrwxrwt 0:0 0 B ├── tmp drwxr-xr-x 0:0 0 B ├── usr drwxr-xr-x 1:1 0 B │ └── sbin drwxr-xr-x 0:0 0 B └── var drwxr-xr-x 0:0 0 B ├── spool drwxr-xr-x 8:8 0 B │ └── mail drwxr-xr-x 0:0 0 B └── www ================================================ FILE: cmd/dive/cli/internal/ui/v1.go ================================================ package ui import ( "context" "fmt" "github.com/anchore/clio" "github.com/anchore/go-logger/adapter/discard" "github.com/charmbracelet/lipgloss" "github.com/muesli/termenv" v1 "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/app" "github.com/wagoodman/dive/internal/bus/event" "github.com/wagoodman/dive/internal/bus/event/parser" "github.com/wagoodman/dive/internal/log" "github.com/wagoodman/go-partybus" "io" "os" "strings" ) var _ clio.UI = (*V1UI)(nil) type V1UI struct { cfg v1.Preferences out io.Writer err io.Writer subscription partybus.Unsubscribable quiet bool verbosity int format format } type format struct { Title lipgloss.Style Aux lipgloss.Style Line lipgloss.Style Notification lipgloss.Style } func NewV1UI(cfg v1.Preferences, out io.Writer, quiet bool, verbosity int) *V1UI { return &V1UI{ cfg: cfg, out: out, err: os.Stderr, quiet: quiet, verbosity: verbosity, format: format{ Title: lipgloss.NewStyle().Bold(true).Width(30), Aux: lipgloss.NewStyle().Faint(true), Notification: lipgloss.NewStyle().Foreground(lipgloss.Color("#A77BCA")), }, } } func (n *V1UI) Setup(subscription partybus.Unsubscribable) error { if n.verbosity == 0 || n.quiet { // we still use the UI, but we want to suppress responding to events that would print out what is already // being logged. log.Set(discard.New()) } // remove CI var from consideration when determining if we should use the UI lipgloss.SetDefaultRenderer(lipgloss.NewRenderer(n.out, termenv.WithEnvironment(environWithoutCI{}))) n.subscription = subscription return nil } var _ termenv.Environ = (*environWithoutCI)(nil) type environWithoutCI struct { } func (e environWithoutCI) Environ() []string { var out []string for _, s := range os.Environ() { if strings.HasPrefix(s, "CI=") { continue } out = append(out, s) } return out } func (e environWithoutCI) Getenv(s string) string { if s == "CI" { return "" } return os.Getenv(s) } func (n *V1UI) Handle(e partybus.Event) error { switch e.Type { case event.TaskStarted: if n.quiet { return nil } prog, task, err := parser.ParseTaskStarted(e) if err != nil { log.WithFields("error", err, "event", fmt.Sprintf("%#v", e)).Warn("failed to parse event") } var aux string stage := prog.Stage() switch { case task.Context != "": aux = task.Context case stage != "": aux = stage } if aux != "" { aux = n.format.Aux.Render(aux) } n.writeToStderr(n.format.Title.Render(task.Title.Default) + aux) case event.Notification: if n.quiet { return nil } _, text, err := parser.ParseNotification(e) if err != nil { log.WithFields("error", err, "event", fmt.Sprintf("%#v", e)).Warn("failed to parse event") } n.writeToStderr(n.format.Notification.Render(text)) case event.Report: if n.quiet { return nil } _, text, err := parser.ParseReport(e) if err != nil { log.WithFields("error", err, "event", fmt.Sprintf("%#v", e)).Warn("failed to parse event") } n.writeToStderr("") n.writeToStdout(text) case event.ExploreAnalysis: analysis, content, err := parser.ParseExploreAnalysis(e) if err != nil { log.WithFields("error", err, "event", fmt.Sprintf("%#v", e)).Warn("failed to parse event") } // ensure the logger will not interfere with the UI log.Set(discard.New()) return app.Run( // TODO: this is not plumbed through from the command object... context.Background(), v1.Config{ Content: content, Analysis: analysis, Preferences: n.cfg, }, ) } return nil } func (n *V1UI) writeToStdout(s string) { fmt.Fprintln(n.out, s) } func (n *V1UI) writeToStderr(s string) { if n.quiet || n.verbosity > 0 { // we've been told to not report anything or that we're in verbose mode thus the logger should report all info. // This only applies to status like info on stderr, not to primary reports on stdout. return } fmt.Fprintln(n.err, s) } func (n V1UI) Teardown(_ bool) error { return nil } ================================================ FILE: cmd/dive/cli/testdata/config/dive-ci-legacy.yaml ================================================ rules: lowestEfficiency: 0.95 highestWastedBytes: 20MB highestUserWastedPercent: 0.20 ================================================ FILE: cmd/dive/cli/testdata/default-ci-config/.dive-ci ================================================ rules: # If the efficiency is measured below X%, mark as failed. (expressed as a percentage between 0-1) lowestEfficiency: 0.96 # If the amount of wasted space is at least X or larger than X, mark as failed. (expressed in B, KB, MB, and GB) highestWastedBytes: 19Mb # If the amount of wasted space makes up for X% of the image, mark as failed. (fail if the threshold is met or crossed; expressed as a percentage between 0-1) highestUserWastedPercent: 0.6 ================================================ FILE: cmd/dive/cli/testdata/dive-enable-ci.yaml ================================================ ci: true rules: # lowest allowable image efficiency (as a ratio between 0-1), otherwise CI validation will fail. (env: DIVE_RULES_LOWEST_EFFICIENCY_THRESHOLD) lowest-efficiency-threshold: '0.10' # highest allowable bytes wasted, otherwise CI validation will fail. (env: DIVE_RULES_HIGHEST_WASTED_BYTES) highest-wasted-bytes: '20MB' # highest allowable percentage of bytes wasted (as a ratio between 0-1), otherwise CI validation will fail. (env: DIVE_RULES_HIGHEST_USER_WASTED_PERCENT) highest-user-wasted-percent: '0.90' ================================================ FILE: cmd/dive/cli/testdata/image-multi-layer-containerfile/Containerfile ================================================ FROM busybox:1.37.0@sha256:37f7b378a29ceb4c551b1b5582e27747b855bbfaa73fa11914fe0df028dc581f ADD example.md /somefile.txt RUN mkdir -p /root/example/really/nested RUN cp /somefile.txt /root/example/somefile1.txt RUN chmod 444 /root/example/somefile1.txt RUN cp /somefile.txt /root/example/somefile2.txt RUN cp /somefile.txt /root/example/somefile3.txt RUN mv /root/example/somefile3.txt /root/saved.txt RUN cp /root/saved.txt /root/.saved.txt RUN chmod +x /root/saved.txt RUN chmod 421 /root RUN rm -rf /root/example/ ADD overwrite.md /root/saved.txt ================================================ FILE: cmd/dive/cli/testdata/image-multi-layer-containerfile/dive-pass.yaml ================================================ ci: true rules: # lowest allowable image efficiency (as a ratio between 0-1), otherwise CI validation will fail. (env: DIVE_RULES_LOWEST_EFFICIENCY_THRESHOLD) lowest-efficiency-threshold: '0.10' # highest allowable bytes wasted, otherwise CI validation will fail. (env: DIVE_RULES_HIGHEST_WASTED_BYTES) highest-wasted-bytes: '20MB' # highest allowable percentage of bytes wasted (as a ratio between 0-1), otherwise CI validation will fail. (env: DIVE_RULES_HIGHEST_USER_WASTED_PERCENT) highest-user-wasted-percent: '0.90' ================================================ FILE: cmd/dive/cli/testdata/image-multi-layer-containerfile/example.md ================================================ # exmaple! woot! ================================================ FILE: cmd/dive/cli/testdata/image-multi-layer-containerfile/overwrite.md ================================================ # evil! this will overwrite the other file... ================================================ FILE: cmd/dive/cli/testdata/image-multi-layer-dockerfile/Dockerfile ================================================ FROM busybox:1.37.0@sha256:37f7b378a29ceb4c551b1b5582e27747b855bbfaa73fa11914fe0df028dc581f ADD example.md /somefile.txt RUN mkdir -p /root/example/really/nested RUN cp /somefile.txt /root/example/somefile1.txt RUN chmod 444 /root/example/somefile1.txt RUN cp /somefile.txt /root/example/somefile2.txt RUN cp /somefile.txt /root/example/somefile3.txt RUN mv /root/example/somefile3.txt /root/saved.txt RUN cp /root/saved.txt /root/.saved.txt RUN chmod +x /root/saved.txt RUN chmod 421 /root RUN rm -rf /root/example/ ADD overwrite.md /root/saved.txt ================================================ FILE: cmd/dive/cli/testdata/image-multi-layer-dockerfile/dive-fail.yaml ================================================ ci: true rules: lowest-efficiency-threshold: '0.9' ================================================ FILE: cmd/dive/cli/testdata/image-multi-layer-dockerfile/dive-pass.yaml ================================================ ci: true rules: lowest-efficiency-threshold: '0.10' highest-wasted-bytes: '20MB' highest-user-wasted-percent: '0.90' ================================================ FILE: cmd/dive/cli/testdata/image-multi-layer-dockerfile/example.md ================================================ # exmaple! woot! ================================================ FILE: cmd/dive/cli/testdata/image-multi-layer-dockerfile/overwrite.md ================================================ # evil! this will overwrite the other file... ================================================ FILE: cmd/dive/cli/testdata/invalid/Dockerfile ================================================ FROM scratch INVALID woops ================================================ FILE: cmd/dive/cli/testdata/snapshots/cli_build_test.snap ================================================ [Test_Build_Dockerfile/implicit_dockerfile - 1] Analysis: efficiency: 100.00 % wastedBytes: 131 bytes (131 B) userWastedPercent: 71.98 % Inefficient Files: Count Wasted Space File Path 3 80 B /root/saved.txt 2 34 B /root/example/somefile1.txt 2 17 B /root/example/somefile3.txt 2 0 B /root 10 0 B /etc Evaluation: PASS highestUserWastedPercent (0.90) PASS highestWastedBytes (20MB) PASS lowestEfficiency (0.9) PASS [pass:3] --- [Test_Build_Dockerfile/explicit_file_flag - 1] Analysis: efficiency: 100.00 % wastedBytes: 131 bytes (131 B) userWastedPercent: 71.98 % Inefficient Files: Count Wasted Space File Path 3 80 B /root/saved.txt 2 34 B /root/example/somefile1.txt 2 17 B /root/example/somefile3.txt 2 0 B /root 10 0 B /etc Evaluation: PASS highestUserWastedPercent (0.90) PASS highestWastedBytes (20MB) PASS lowestEfficiency (0.9) PASS [pass:3] --- [Test_Build_Containerfile/implicit_containerfile - 1] Analysis: efficiency: 100.00 % wastedBytes: 131 bytes (131 B) userWastedPercent: 71.98 % Inefficient Files: Count Wasted Space File Path 3 80 B /root/saved.txt 2 34 B /root/example/somefile1.txt 2 17 B /root/example/somefile3.txt 2 0 B /root 10 0 B /etc Evaluation: PASS highestUserWastedPercent (0.90) PASS highestWastedBytes (20MB) PASS lowestEfficiency (0.9) PASS [pass:3] --- [Test_Build_Containerfile/explicit_file_flag - 1] Analysis: efficiency: 100.00 % wastedBytes: 131 bytes (131 B) userWastedPercent: 71.98 % Inefficient Files: Count Wasted Space File Path 3 80 B /root/saved.txt 2 34 B /root/example/somefile1.txt 2 17 B /root/example/somefile3.txt 2 0 B /root 10 0 B /etc Evaluation: PASS highestUserWastedPercent (0.90) PASS highestWastedBytes (20MB) PASS lowestEfficiency (0.9) PASS [pass:3] --- [Test_BuildFailure/nonexistent_directory - 1] Building image ... ./path/does/not/exist --- [Test_BuildFailure/invalid_dockerfile - 1] Building image ... ./testdata/invalid #0 building with "desktop-linux" instance using docker driver #1 [internal] load build definition from Dockerfile #1 transferring dockerfile: 100B done #1 DONE 0.0s Dockerfile:2 -------------------- 1 | FROM scratch 2 | >>> INVALID woops -------------------- ERROR: failed to solve: dockerfile parse error on line 2: unknown instruction: INVALID View build details: docker-desktop:// --- [Test_Build_CI_gate_fail - 1] Analysis: efficiency: 100.00 % wastedBytes: 131 bytes (131 B) userWastedPercent: 71.98 % Inefficient Files: Count Wasted Space File Path 3 80 B /root/saved.txt 2 34 B /root/example/somefile1.txt 2 17 B /root/example/somefile3.txt 2 0 B /root 10 0 B /etc Evaluation: FAIL highestUserWastedPercent (too many bytes wasted, relative to the user bytes added (%-user-wasted-bytes=0.72 > threshold=0.1)) SKIP highestWastedBytes (disabled) PASS lowestEfficiency (0.9) FAIL [pass:1 fail:1 skip:1] --- ================================================ FILE: cmd/dive/cli/testdata/snapshots/cli_ci_test.snap ================================================ [Test_CI_Fail - 1] Analysis: efficiency: 100.00 % wastedBytes: 131 bytes (131 B) userWastedPercent: 71.98 % Inefficient Files: Count Wasted Space File Path 3 80 B /root/saved.txt 2 34 B /root/example/somefile1.txt 2 17 B /root/example/somefile3.txt 2 0 B /root 10 0 B /etc Evaluation: FAIL highestUserWastedPercent (too many bytes wasted, relative to the user bytes added (%-user-wasted-bytes=0.72 > threshold=0.1)) SKIP highestWastedBytes (disabled) PASS lowestEfficiency (0.9) FAIL [pass:1 fail:1 skip:1] --- [Test_CI_DefaultCIConfig - 1] [0001] INFO dive version: testing [0001] DEBUG config:  log: quiet: false level: debug file: "" dev: profile: none image: /Users/wagoodman/code/dive/.data/test-docker-image.tar container-engine: docker ignore-errors: false ci: false ci-config: .dive-ci rules: lowest-efficiency: "0.96" highest-wasted-bytes: 19Mb highest-user-wasted-percent: "0.6" json-path: "" keybinding: quit: ctrl+c toggle-view: tab filter-files: ctrl+f, ctrl+slash close-filter-files: esc up: up,k down: down,j left: left,h right: right,l page-up: pgup,u page-down: pgdn,d compare-all: ctrl+a compare-layer: ctrl+l toggle-collapse-dir: space toggle-collapse-all-dir: ctrl+space toggle-added-files: ctrl+a toggle-removed-files: ctrl+r toggle-modified-files: ctrl+m toggle-unmodified-files: ctrl+u toggle-filetree-attributes: ctrl+b toggle-sort-order: ctrl+o toggle-wrap-tree: ctrl+p extract-file: ctrl+e diff: hide: [] filetree: collapse-dir: false pane-width: 0.5 show-attributes: true layer: show-aggregated-changes: false [0001] INFO fetching image=/Users/wagoodman/code/dive/.data/test-docker-image.tar [0001] DEBUG └── resolver: docker-engine --- ================================================ FILE: cmd/dive/cli/testdata/snapshots/cli_config_test.snap ================================================ [Test_Config - 1] log: # suppress all logging output (env: DIVE_LOG_QUIET) quiet: false # explicitly set the logging level (available: [error warn info debug trace]) (env: DIVE_LOG_LEVEL) level: 'warn' # file path to write logs to (env: DIVE_LOG_FILE) file: '' dev: # capture resource profiling data (available: [cpu, mem]) (env: DIVE_DEV_PROFILE) profile: 'none' # container engine to use for image analysis (supported options: 'docker' and 'podman') (env: DIVE_CONTAINER_ENGINE) container-engine: 'docker' # continue with analysis even if there are errors parsing the image archive (env: DIVE_IGNORE_ERRORS) ignore-errors: false # enable CI mode (env: DIVE_CI) ci: true # path to the CI config file (env: DIVE_CI_CONFIG) ci-config: '.dive-ci' rules: # lowest allowable image efficiency (as a ratio between 0-1), otherwise CI validation will fail. (env: DIVE_RULES_LOWEST_EFFICIENCY) lowest-efficiency: '0.9' # highest allowable bytes wasted, otherwise CI validation will fail. (env: DIVE_RULES_HIGHEST_WASTED_BYTES) highest-wasted-bytes: '20MB' # highest allowable percentage of bytes wasted (as a ratio between 0-1), otherwise CI validation will fail. (env: DIVE_RULES_HIGHEST_USER_WASTED_PERCENT) highest-user-wasted-percent: '0.90' # Skip the interactive TUI and write the layer analysis statistics to a given file. (env: DIVE_JSON_PATH) json-path: '' keybinding: # quit the application (global) (env: DIVE_KEYBINDING_QUIT) quit: 'ctrl+c' # toggle between different views (global) (env: DIVE_KEYBINDING_TOGGLE_VIEW) toggle-view: 'tab' # filter files by name (global) (env: DIVE_KEYBINDING_FILTER_FILES) filter-files: 'ctrl+f, ctrl+slash' # close file filtering (global) (env: DIVE_KEYBINDING_CLOSE_FILTER_FILES) close-filter-files: 'esc' # move cursor up (global) (env: DIVE_KEYBINDING_UP) up: 'up,k' # move cursor down (global) (env: DIVE_KEYBINDING_DOWN) down: 'down,j' # move cursor left (global) (env: DIVE_KEYBINDING_LEFT) left: 'left,h' # move cursor right (global) (env: DIVE_KEYBINDING_RIGHT) right: 'right,l' # scroll page up (file view) (env: DIVE_KEYBINDING_PAGE_UP) page-up: 'pgup,u' # scroll page down (file view) (env: DIVE_KEYBINDING_PAGE_DOWN) page-down: 'pgdn,d' # compare all layers (layer view) (env: DIVE_KEYBINDING_COMPARE_ALL) compare-all: 'ctrl+a' # compare specific layer (layer view) (env: DIVE_KEYBINDING_COMPARE_LAYER) compare-layer: 'ctrl+l' # toggle directory collapse (file view) (env: DIVE_KEYBINDING_TOGGLE_COLLAPSE_DIR) toggle-collapse-dir: 'space' # toggle collapse all directories (file view) (env: DIVE_KEYBINDING_TOGGLE_COLLAPSE_ALL_DIR) toggle-collapse-all-dir: 'ctrl+space' # toggle visibility of added files (file view) (env: DIVE_KEYBINDING_TOGGLE_ADDED_FILES) toggle-added-files: 'ctrl+a' # toggle visibility of removed files (file view) (env: DIVE_KEYBINDING_TOGGLE_REMOVED_FILES) toggle-removed-files: 'ctrl+r' # toggle visibility of modified files (file view) (env: DIVE_KEYBINDING_TOGGLE_MODIFIED_FILES) toggle-modified-files: 'ctrl+m' # toggle visibility of unmodified files (file view) (env: DIVE_KEYBINDING_TOGGLE_UNMODIFIED_FILES) toggle-unmodified-files: 'ctrl+u' # toggle display of file attributes (file view) (env: DIVE_KEYBINDING_TOGGLE_FILETREE_ATTRIBUTES) toggle-filetree-attributes: 'ctrl+b' # toggle sort order (file view) (env: DIVE_KEYBINDING_TOGGLE_SORT_ORDER) toggle-sort-order: 'ctrl+o' # (env: DIVE_KEYBINDING_TOGGLE_WRAP_TREE) toggle-wrap-tree: 'ctrl+p' # extract file contents (file view) (env: DIVE_KEYBINDING_EXTRACT_FILE) extract-file: 'ctrl+e' diff: # types of file differences to hide (added, removed, modified, unmodified) (env: DIVE_DIFF_HIDE) hide: [] filetree: # collapse directories by default in the filetree (env: DIVE_FILETREE_COLLAPSE_DIR) collapse-dir: false # percentage of screen width for the filetree pane (must be >0 and <1) (env: DIVE_FILETREE_PANE_WIDTH) pane-width: 0.5 # show file attributes in the filetree view (env: DIVE_FILETREE_SHOW_ATTRIBUTES) show-attributes: true layer: # show aggregated changes across all previous layers (env: DIVE_LAYER_SHOW_AGGREGATED_CHANGES) show-aggregated-changes: false --- ================================================ FILE: cmd/dive/cli/testdata/snapshots/cli_json_test.snap ================================================ [Test_JsonOutput/json_output - 1] { "image": { "efficiencyScore": 1, "fileReference": [], "inefficientBytes": 0, "sizeBytes": 4277894 }, "layer": [ { "command": "BusyBox 1.37.0 (glibc), Debian 12", "digestId": "sha256:068f50152bbc6e10c9d223150c9fbd30d11bcfd7789c432152aa0a99703bd03a", "fileList": [ { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "", "path": "bin/[", "size": 1029688, "typeFlag": 48, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/[[", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/acpid", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/add-shell", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/addgroup", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/adduser", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/adjtimex", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ar", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/arch", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/arp", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/arping", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ascii", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ash", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/awk", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/base32", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/base64", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/basename", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/bc", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/beep", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/blkdiscard", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/blkid", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/blockdev", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/bootchartd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/brctl", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/bunzip2", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/busybox", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/bzcat", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/bzip2", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/cal", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/cat", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/chat", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/chattr", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/chgrp", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/chmod", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/chown", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/chpasswd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/chpst", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/chroot", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/chrt", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/chvt", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/cksum", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/clear", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/cmp", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/comm", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/conspy", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/cp", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/cpio", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/crc32", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/crond", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/crontab", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/cryptpw", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/cttyhack", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/cut", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/date", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/dc", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/dd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/deallocvt", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/delgroup", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/deluser", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/depmod", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/devmem", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/df", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/dhcprelay", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/diff", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/dirname", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/dmesg", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/dnsd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/dnsdomainname", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/dos2unix", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/dpkg", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/dpkg-deb", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/du", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/dumpkmap", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/dumpleases", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/echo", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ed", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/egrep", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/eject", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/env", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/envdir", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/envuidgid", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ether-wake", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/expand", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/expr", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/factor", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/fakeidentd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/fallocate", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/false", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/fatattr", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/fbset", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/fbsplash", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/fdflush", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/fdformat", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/fdisk", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/fgconsole", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/fgrep", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/find", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/findfs", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/flock", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/fold", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/free", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/freeramdisk", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/fsck", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/fsck.minix", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/fsfreeze", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/fstrim", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/fsync", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ftpd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ftpget", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ftpput", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/fuser", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "", "path": "bin/getconf", "size": 27136, "typeFlag": 48, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/getfattr", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/getopt", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/getty", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/grep", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/groups", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/gunzip", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/gzip", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/halt", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/hd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/hdparm", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/head", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/hexdump", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/hexedit", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/hostid", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/hostname", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/httpd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/hush", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/hwclock", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/i2cdetect", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/i2cdump", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/i2cget", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/i2cset", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/i2ctransfer", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/id", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ifconfig", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ifdown", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ifenslave", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ifplugd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ifup", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/inetd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/init", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/insmod", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/install", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ionice", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/iostat", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ip", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ipaddr", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ipcalc", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ipcrm", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ipcs", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/iplink", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ipneigh", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/iproute", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/iprule", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/iptunnel", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/kbd_mode", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/kill", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/killall", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/killall5", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/klogd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/last", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/less", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/link", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/linux32", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/linux64", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/linuxrc", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ln", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/loadfont", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/loadkmap", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/logger", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/login", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/logname", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/logread", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/losetup", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/lpd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/lpq", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/lpr", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ls", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/lsattr", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/lsmod", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/lsof", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/lspci", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/lsscsi", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/lsusb", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/lzcat", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/lzma", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/lzop", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/makedevs", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/makemime", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/man", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/md5sum", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/mdev", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/mesg", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/microcom", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/mim", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/mkdir", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/mkdosfs", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/mke2fs", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/mkfifo", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/mkfs.ext2", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/mkfs.minix", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/mkfs.vfat", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/mknod", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/mkpasswd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/mkswap", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/mktemp", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/modinfo", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/modprobe", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/more", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/mount", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/mountpoint", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/mpstat", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/mt", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/mv", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/nameif", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/nanddump", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/nandwrite", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/nbd-client", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/nc", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/netstat", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/nice", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/nl", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/nmeter", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/nohup", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/nologin", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/nproc", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/nsenter", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/nslookup", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ntpd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/od", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/openvt", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/partprobe", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/passwd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/paste", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/patch", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/pgrep", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/pidof", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ping", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ping6", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/pipe_progress", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/pivot_root", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/pkill", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/pmap", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/popmaildir", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/poweroff", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/powertop", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/printenv", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/printf", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ps", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/pscan", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/pstree", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/pwd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/pwdx", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/raidautorun", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/rdate", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/rdev", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/readahead", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/readlink", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/readprofile", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/realpath", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/reboot", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/reformime", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/remove-shell", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/renice", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/reset", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/resize", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/resume", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/rev", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/rm", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/rmdir", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/rmmod", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/route", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/rpm", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/rpm2cpio", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/rtcwake", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/run-init", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/run-parts", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/runlevel", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/runsv", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/runsvdir", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/rx", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/script", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/scriptreplay", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/sed", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/seedrng", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/sendmail", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/seq", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/setarch", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/setconsole", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/setfattr", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/setfont", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/setkeycodes", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/setlogcons", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/setpriv", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/setserial", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/setsid", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/setuidgid", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/sh", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/sha1sum", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/sha256sum", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/sha3sum", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/sha512sum", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/showkey", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/shred", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/shuf", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/slattach", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/sleep", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/smemcap", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/softlimit", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/sort", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/split", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ssl_client", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/start-stop-daemon", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/stat", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/strings", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/stty", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/su", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/sulogin", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/sum", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/sv", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/svc", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/svlogd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/svok", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/swapoff", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/swapon", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/switch_root", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/sync", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/sysctl", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/syslogd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/tac", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/tail", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/tar", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/taskset", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/tc", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/tcpsvd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/tee", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/telnet", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/telnetd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/test", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/tftp", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/tftpd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/time", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/timeout", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/top", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/touch", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/tr", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/traceroute", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/traceroute6", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/tree", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/true", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/truncate", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ts", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/tsort", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/tty", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ttysize", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/tunctl", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ubiattach", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ubidetach", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ubimkvol", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ubirename", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ubirmvol", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ubirsvol", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/ubiupdatevol", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/udhcpc", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/udhcpc6", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/udhcpd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/udpsvd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/uevent", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/umount", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/uname", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/unexpand", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/uniq", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/unix2dos", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/unlink", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/unlzma", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/unshare", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/unxz", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/unzip", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/uptime", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/users", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/usleep", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/uudecode", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/uuencode", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/vconfig", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/vi", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/vlock", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/volname", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/w", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/wall", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/watch", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/watchdog", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/wc", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/wget", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/which", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/who", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/whoami", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/whois", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/xargs", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/xxd", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/xz", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/xzcat", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/yes", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/zcat", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "bin/[", "path": "bin/zcip", "size": 0, "typeFlag": 49, "uid": 0 }, { "fileMode": 2147484141, "gid": 0, "isDir": true, "linkName": "", "path": "bin", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 2147484141, "gid": 0, "isDir": true, "linkName": "", "path": "dev", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 420, "gid": 0, "isDir": false, "linkName": "", "path": "etc/group", "size": 306, "typeFlag": 48, "uid": 0 }, { "fileMode": 420, "gid": 0, "isDir": false, "linkName": "", "path": "etc/localtime", "size": 114, "typeFlag": 48, "uid": 0 }, { "fileMode": 2147484141, "gid": 0, "isDir": true, "linkName": "", "path": "etc/network/if-down.d", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 2147484141, "gid": 0, "isDir": true, "linkName": "", "path": "etc/network/if-post-down.d", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 2147484141, "gid": 0, "isDir": true, "linkName": "", "path": "etc/network/if-pre-up.d", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 2147484141, "gid": 0, "isDir": true, "linkName": "", "path": "etc/network/if-up.d", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 2147484141, "gid": 0, "isDir": true, "linkName": "", "path": "etc/network", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 420, "gid": 0, "isDir": false, "linkName": "", "path": "etc/nsswitch.conf", "size": 494, "typeFlag": 48, "uid": 0 }, { "fileMode": 420, "gid": 0, "isDir": false, "linkName": "", "path": "etc/passwd", "size": 340, "typeFlag": 48, "uid": 0 }, { "fileMode": 384, "gid": 0, "isDir": false, "linkName": "", "path": "etc/shadow", "size": 136, "typeFlag": 48, "uid": 0 }, { "fileMode": 2147484141, "gid": 0, "isDir": true, "linkName": "", "path": "etc", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 2147484141, "gid": 65534, "isDir": true, "linkName": "", "path": "home", "size": 0, "typeFlag": 53, "uid": 65534 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "", "path": "lib/ld-linux-x86-64.so.2", "size": 215000, "typeFlag": 48, "uid": 0 }, { "fileMode": 493, "gid": 0, "isDir": false, "linkName": "", "path": "lib/libc.so.6", "size": 1922136, "typeFlag": 48, "uid": 0 }, { "fileMode": 420, "gid": 0, "isDir": false, "linkName": "", "path": "lib/libm.so.6", "size": 911904, "typeFlag": 48, "uid": 0 }, { "fileMode": 420, "gid": 0, "isDir": false, "linkName": "", "path": "lib/libnss_compat.so.2", "size": 39896, "typeFlag": 48, "uid": 0 }, { "fileMode": 420, "gid": 0, "isDir": false, "linkName": "", "path": "lib/libnss_dns.so.2", "size": 14400, "typeFlag": 48, "uid": 0 }, { "fileMode": 420, "gid": 0, "isDir": false, "linkName": "", "path": "lib/libnss_files.so.2", "size": 14400, "typeFlag": 48, "uid": 0 }, { "fileMode": 420, "gid": 0, "isDir": false, "linkName": "", "path": "lib/libnss_hesiod.so.2", "size": 27136, "typeFlag": 48, "uid": 0 }, { "fileMode": 420, "gid": 0, "isDir": false, "linkName": "", "path": "lib/libpthread.so.0", "size": 14480, "typeFlag": 48, "uid": 0 }, { "fileMode": 420, "gid": 0, "isDir": false, "linkName": "", "path": "lib/libresolv.so.2", "size": 60328, "typeFlag": 48, "uid": 0 }, { "fileMode": 2147484141, "gid": 0, "isDir": true, "linkName": "", "path": "lib", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 134218239, "gid": 0, "isDir": false, "linkName": "lib", "path": "lib64", "size": 0, "typeFlag": 50, "uid": 0 }, { "fileMode": 2147484096, "gid": 0, "isDir": true, "linkName": "", "path": "root", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 2148532735, "gid": 0, "isDir": true, "linkName": "", "path": "tmp", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 134218239, "gid": 0, "isDir": false, "linkName": "../../bin/env", "path": "usr/bin/env", "size": 0, "typeFlag": 50, "uid": 0 }, { "fileMode": 2147484141, "gid": 0, "isDir": true, "linkName": "", "path": "usr/bin", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 2147484141, "gid": 1, "isDir": true, "linkName": "", "path": "usr/sbin", "size": 0, "typeFlag": 53, "uid": 1 }, { "fileMode": 2147484141, "gid": 0, "isDir": true, "linkName": "", "path": "usr", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 2147484141, "gid": 8, "isDir": true, "linkName": "", "path": "var/spool/mail", "size": 0, "typeFlag": 53, "uid": 8 }, { "fileMode": 2147484141, "gid": 0, "isDir": true, "linkName": "", "path": "var/spool", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 2147484141, "gid": 0, "isDir": true, "linkName": "", "path": "var/www", "size": 0, "typeFlag": 53, "uid": 0 }, { "fileMode": 2147484141, "gid": 0, "isDir": true, "linkName": "", "path": "var", "size": 0, "typeFlag": 53, "uid": 0 } ], "id": "blobs", "index": 0, "sizeBytes": 4277894 } ] } --- ================================================ FILE: cmd/dive/cli/testdata/snapshots/cli_load_test.snap ================================================ [Test_LoadImage/from_docker_engine - 1] Loading image busybox:1.37.0@sha256:ad9fa4d07136a83e69a54ef00102f579d04eba431932de3b0f098cc5d5948f9f Analyzing image [layers:1 files:441 size:4.3 MB] Evaluating image [rules: 3] Analysis: efficiency: 100.00 % wastedBytes: 0 bytes userWastedPercent: 0 % Inefficient Files: (None) Evaluation: PASS highestUserWastedPercent (0.90) PASS highestWastedBytes (20MB) PASS lowestEfficiency (0.9) PASS [pass:3] --- [Test_LoadImage/from_docker_engine_(flag) - 1] Loading image busybox:1.37.0@sha256:ad9fa4d07136a83e69a54ef00102f579d04eba431932de3b0f098cc5d5948f9f Analyzing image [layers:1 files:441 size:4.3 MB] Evaluating image [rules: 3] Analysis: efficiency: 100.00 % wastedBytes: 0 bytes userWastedPercent: 0 % Inefficient Files: (None) Evaluation: PASS highestUserWastedPercent (0.90) PASS highestWastedBytes (20MB) PASS lowestEfficiency (0.9) PASS [pass:3] --- [Test_LoadImage/from_podman_engine - 1] Loading image busybox:1.37.0@sha256:ad9fa4d07136a83e69a54ef00102f579d04eba431932de3b0f098cc5d5948f9f Analyzing image [layers:1 files:441 size:4.3 MB] Evaluating image [rules: 3] Analysis: efficiency: 100.00 % wastedBytes: 0 bytes userWastedPercent: 0 % Inefficient Files: (None) Evaluation: PASS highestUserWastedPercent (0.90) PASS highestWastedBytes (20MB) PASS lowestEfficiency (0.9) PASS [pass:3] --- [Test_LoadImage/from_podman_engine_(flag) - 1] Loading image busybox:1.37.0@sha256:ad9fa4d07136a83e69a54ef00102f579d04eba431932de3b0f098cc5d5948f9f Analyzing image [layers:1 files:441 size:4.3 MB] Evaluating image [rules: 3] Analysis: efficiency: 100.00 % wastedBytes: 0 bytes userWastedPercent: 0 % Inefficient Files: (None) Evaluation: PASS highestUserWastedPercent (0.90) PASS highestWastedBytes (20MB) PASS lowestEfficiency (0.9) PASS [pass:3] --- [Test_LoadImage/from_archive - 1] Loading image /Users/wagoodman/code/dive/.data/test-docker-image.tar Analyzing image [layers:14 files:451 size:1.2 MB] Evaluating image [rules: 3] Analysis: efficiency: 98.44 % wastedBytes: 32025 bytes (32 kB) userWastedPercent: 48.35 % Inefficient Files: Count Wasted Space File Path 2 13 kB /root/saved.txt 2 13 kB /root/example/somefile1.txt 2 6.4 kB /root/example/somefile3.txt Evaluation: PASS highestUserWastedPercent (0.90) PASS highestWastedBytes (20MB) PASS lowestEfficiency (0.9) PASS [pass:3] --- [Test_LoadImage/from_archive_(flag) - 1] Loading image /Users/wagoodman/code/dive/.data/test-docker-image.tar Analyzing image [layers:14 files:451 size:1.2 MB] Evaluating image [rules: 3] Analysis: efficiency: 98.44 % wastedBytes: 32025 bytes (32 kB) userWastedPercent: 48.35 % Inefficient Files: Count Wasted Space File Path 2 13 kB /root/saved.txt 2 13 kB /root/example/somefile1.txt 2 6.4 kB /root/example/somefile3.txt Evaluation: PASS highestUserWastedPercent (0.90) PASS highestWastedBytes (20MB) PASS lowestEfficiency (0.9) PASS [pass:3] --- [Test_FetchFailure/nonexistent_image - 1] Loading image docker:wagoodman/nonexistent/image:tag --- [Test_FetchFailure/invalid_image_name - 1] Loading image /wagoodman/invalid:image:format --- ================================================ FILE: cmd/dive/main.go ================================================ package main // Copyright © 2018 Alex Goodman // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. import ( "github.com/anchore/clio" "github.com/wagoodman/dive/cmd/dive/cli" ) // applicationName is the non-capitalized name of the application (do not change this) const ( applicationName = "dive" notProvided = "[not provided]" ) // TODO: these need to be wired up to the build flags // all variables here are provided as build-time arguments, with clear default values var ( version = notProvided buildDate = notProvided gitCommit = notProvided gitDescription = notProvided ) func main() { app := cli.Application( clio.Identification{ Name: applicationName, Version: version, BuildDate: buildDate, GitCommit: gitCommit, GitDescription: gitDescription, }, ) app.Run() } ================================================ FILE: dive/filetree/comparer.go ================================================ package filetree import ( "fmt" ) type TreeIndexKey struct { bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop int } func NewTreeIndexKey(bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop int) TreeIndexKey { return TreeIndexKey{ bottomTreeStart: bottomTreeStart, bottomTreeStop: bottomTreeStop, topTreeStart: topTreeStart, topTreeStop: topTreeStop, } } func (index TreeIndexKey) String() string { if index.bottomTreeStart == index.bottomTreeStop && index.topTreeStart == index.topTreeStop { return fmt.Sprintf("Index(%d:%d)", index.bottomTreeStart, index.topTreeStart) } else if index.bottomTreeStart == index.bottomTreeStop { return fmt.Sprintf("Index(%d:%d-%d)", index.bottomTreeStart, index.topTreeStart, index.topTreeStop) } else if index.topTreeStart == index.topTreeStop { return fmt.Sprintf("Index(%d-%d:%d)", index.bottomTreeStart, index.bottomTreeStop, index.topTreeStart) } return fmt.Sprintf("Index(%d-%d:%d-%d)", index.bottomTreeStart, index.bottomTreeStop, index.topTreeStart, index.topTreeStop) } type Comparer struct { refTrees []*FileTree trees map[TreeIndexKey]*FileTree pathErrors map[TreeIndexKey][]PathError } func NewComparer(refTrees []*FileTree) Comparer { return Comparer{ refTrees: refTrees, trees: make(map[TreeIndexKey]*FileTree), pathErrors: make(map[TreeIndexKey][]PathError), } } func (cmp *Comparer) GetPathErrors(key TreeIndexKey) ([]PathError, error) { _, pathErrors, err := cmp.get(key) if err != nil { return nil, err } return pathErrors, nil } func (cmp *Comparer) GetTree(key TreeIndexKey) (*FileTree, error) { // func (cmp *Comparer) GetTree(bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop int) (*FileTree, []PathError, error) { // key := TreeIndexKey{bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop} if value, exists := cmp.trees[key]; exists { return value, nil } value, pathErrors, err := cmp.get(key) if err != nil { return nil, err } cmp.trees[key] = value cmp.pathErrors[key] = pathErrors return value, nil } func (cmp *Comparer) get(key TreeIndexKey) (*FileTree, []PathError, error) { newTree, pathErrors, err := StackTreeRange(cmp.refTrees, key.bottomTreeStart, key.bottomTreeStop) if err != nil { return nil, nil, err } for idx := key.topTreeStart; idx <= key.topTreeStop; idx++ { markPathErrors, err := newTree.CompareAndMark(cmp.refTrees[idx]) pathErrors = append(pathErrors, markPathErrors...) if err != nil { return nil, nil, fmt.Errorf("failed to build tree: %w", err) } } return newTree, pathErrors, nil } // case 1: layer compare (top tree SIZE is fixed (BUT floats forward), Bottom tree SIZE changes) func (cmp *Comparer) NaturalIndexes() <-chan TreeIndexKey { indexes := make(chan TreeIndexKey) go func() { defer close(indexes) var bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop int for selectIdx := 0; selectIdx < len(cmp.refTrees); selectIdx++ { bottomTreeStart = 0 topTreeStop = selectIdx if selectIdx == 0 { bottomTreeStop = selectIdx topTreeStart = selectIdx } else { bottomTreeStop = selectIdx - 1 topTreeStart = selectIdx } indexes <- TreeIndexKey{ bottomTreeStart: bottomTreeStart, bottomTreeStop: bottomTreeStop, topTreeStart: topTreeStart, topTreeStop: topTreeStop, } } }() return indexes } // case 2: aggregated compare (bottom tree is ENTIRELY fixed, top tree SIZE changes) func (cmp *Comparer) AggregatedIndexes() <-chan TreeIndexKey { indexes := make(chan TreeIndexKey) go func() { defer close(indexes) var bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop int for selectIdx := 0; selectIdx < len(cmp.refTrees); selectIdx++ { bottomTreeStart = 0 topTreeStop = selectIdx if selectIdx == 0 { bottomTreeStop = selectIdx topTreeStart = selectIdx } else { bottomTreeStop = 0 topTreeStart = 1 } indexes <- TreeIndexKey{ bottomTreeStart: bottomTreeStart, bottomTreeStop: bottomTreeStop, topTreeStart: topTreeStart, topTreeStop: topTreeStop, } } }() return indexes } func (cmp *Comparer) BuildCache() (errors []error) { for index := range cmp.NaturalIndexes() { pathError, _ := cmp.GetPathErrors(index) if len(pathError) > 0 { for _, path := range pathError { errors = append(errors, fmt.Errorf("path error at layer index %s: %s", index, path)) } } _, err := cmp.GetTree(index) if err != nil { errors = append(errors, err) return errors } } for index := range cmp.AggregatedIndexes() { _, err := cmp.GetTree(index) if err != nil { errors = append(errors, err) return errors } } return errors } ================================================ FILE: dive/filetree/diff.go ================================================ package filetree import ( "fmt" ) const ( Unmodified DiffType = iota Modified Added Removed ) // DiffType defines the comparison result between two FileNodes type DiffType int // String of a DiffType func (diff DiffType) String() string { switch diff { case Unmodified: return "Unmodified" case Modified: return "Modified" case Added: return "Added" case Removed: return "Removed" default: return fmt.Sprintf("%d", int(diff)) } } // merge two DiffTypes into a single result. Essentially, return the given value unless they two values differ, // in which case we can only determine that there is "a change". func (diff DiffType) merge(other DiffType) DiffType { if diff == other { return diff } return Modified } ================================================ FILE: dive/filetree/efficiency.go ================================================ package filetree import ( "fmt" "github.com/wagoodman/dive/internal/log" "sort" ) // EfficiencyData represents the storage and reference statistics for a given file tree path. type EfficiencyData struct { Path string Nodes []*FileNode CumulativeSize int64 minDiscoveredSize int64 } // EfficiencySlice represents an ordered set of EfficiencyData data structures. type EfficiencySlice []*EfficiencyData // Len is required for sorting. func (efs EfficiencySlice) Len() int { return len(efs) } // Swap operation is required for sorting. func (efs EfficiencySlice) Swap(i, j int) { efs[i], efs[j] = efs[j], efs[i] } // Less comparison is required for sorting. func (efs EfficiencySlice) Less(i, j int) bool { return efs[i].CumulativeSize < efs[j].CumulativeSize } // Efficiency returns the score and file set of the given set of FileTrees (layers). This is loosely based on: // 1. Files that are duplicated across layers discounts your score, weighted by file size // 2. Files that are removed discounts your score, weighted by the original file size func Efficiency(trees []*FileTree) (float64, EfficiencySlice) { efficiencyMap := make(map[string]*EfficiencyData) inefficientMatches := make(EfficiencySlice, 0) currentTree := 0 visitor := func(node *FileNode) error { path := node.Path() if _, ok := efficiencyMap[path]; !ok { efficiencyMap[path] = &EfficiencyData{ Path: path, Nodes: make([]*FileNode, 0), minDiscoveredSize: -1, } } data := efficiencyMap[path] // this node may have had children that were deleted, however, we won't explicitly list out every child, only // the top-most parent with the cumulative size. These operations will need to be done on the full (stacked) // tree. // Note: whiteout files may also represent directories, so we need to find out if this was previously a file or dir. var sizeBytes int64 if node.IsWhiteout() { sizer := func(curNode *FileNode) error { sizeBytes += curNode.Data.FileInfo.Size return nil } stackedTree, failedPaths, err := StackTreeRange(trees, 0, currentTree-1) if len(failedPaths) > 0 { for _, path := range failedPaths { log.WithFields("path", path.String()).Debug("unable to include path in stacked tree") } } if err != nil { return fmt.Errorf("unable to stack tree range: %w", err) } previousTreeNode, err := stackedTree.GetNode(node.Path()) if err != nil { return err } if previousTreeNode.Data.FileInfo.IsDir { err = previousTreeNode.VisitDepthChildFirst(sizer, nil, nil) if err != nil { return fmt.Errorf("unable to propagate whiteout dir: %w", err) } } } else { sizeBytes = node.Data.FileInfo.Size } data.CumulativeSize += sizeBytes if data.minDiscoveredSize < 0 || sizeBytes < data.minDiscoveredSize { data.minDiscoveredSize = sizeBytes } data.Nodes = append(data.Nodes, node) if len(data.Nodes) == 2 { inefficientMatches = append(inefficientMatches, data) } return nil } visitEvaluator := func(node *FileNode) bool { return node.IsLeaf() } for idx, tree := range trees { currentTree = idx err := tree.VisitDepthChildFirst(visitor, visitEvaluator) if err != nil { log.WithFields("layer", tree.Id, "error", err).Debug("unable to propagate layer tree") } } // calculate the score var minimumPathSizes int64 var discoveredPathSizes int64 for _, value := range efficiencyMap { minimumPathSizes += value.minDiscoveredSize discoveredPathSizes += value.CumulativeSize } var score float64 if discoveredPathSizes == 0 { score = 1.0 } else { score = float64(minimumPathSizes) / float64(discoveredPathSizes) } sort.Sort(inefficientMatches) return score, inefficientMatches } ================================================ FILE: dive/filetree/efficiency_test.go ================================================ package filetree import ( "testing" ) func checkError(t *testing.T, err error, message string) { if err != nil { t.Errorf(message+": %+v", err) } } func TestEfficiency(t *testing.T) { trees := make([]*FileTree, 3) for idx := range trees { trees[idx] = NewFileTree() } _, _, err := trees[0].AddPath("/etc/nginx/nginx.conf", FileInfo{Size: 2000}) checkError(t, err, "could not setup test") _, _, err = trees[0].AddPath("/etc/nginx/public", FileInfo{Size: 3000}) checkError(t, err, "could not setup test") _, _, err = trees[1].AddPath("/etc/nginx/nginx.conf", FileInfo{Size: 5000}) checkError(t, err, "could not setup test") _, _, err = trees[1].AddPath("/etc/athing", FileInfo{Size: 10000}) checkError(t, err, "could not setup test") _, _, err = trees[2].AddPath("/etc/.wh.nginx", *BlankFileChangeInfo("/etc/.wh.nginx")) checkError(t, err, "could not setup test") var expectedScore = 0.75 var expectedMatches = EfficiencySlice{ &EfficiencyData{Path: "/etc/nginx/nginx.conf", CumulativeSize: 7000}, } actualScore, actualMatches := Efficiency(trees) if expectedScore != actualScore { t.Errorf("Expected score of %v but go %v", expectedScore, actualScore) } if len(actualMatches) != len(expectedMatches) { for _, match := range actualMatches { t.Logf(" match: %+v", match) } t.Fatalf("Expected to find %d inefficient paths, but found %d", len(expectedMatches), len(actualMatches)) } if expectedMatches[0].Path != actualMatches[0].Path { t.Errorf("Expected path of %s but go %s", expectedMatches[0].Path, actualMatches[0].Path) } if expectedMatches[0].CumulativeSize != actualMatches[0].CumulativeSize { t.Errorf("Expected cumulative size of %v but go %v", expectedMatches[0].CumulativeSize, actualMatches[0].CumulativeSize) } } func TestEfficiency_ScratchImage(t *testing.T) { trees := make([]*FileTree, 3) for idx := range trees { trees[idx] = NewFileTree() } _, _, err := trees[0].AddPath("/nothing", FileInfo{Size: 0}) checkError(t, err, "could not setup test") var expectedScore = 1.0 var expectedMatches = EfficiencySlice{} actualScore, actualMatches := Efficiency(trees) if expectedScore != actualScore { t.Errorf("Expected score of %v but go %v", expectedScore, actualScore) } if len(actualMatches) > 0 { t.Fatalf("Expected to find %d inefficient paths, but found %d", len(expectedMatches), len(actualMatches)) } } ================================================ FILE: dive/filetree/file_info.go ================================================ package filetree import ( "archive/tar" "fmt" "io" "os" "github.com/cespare/xxhash/v2" ) // FileInfo contains tar metadata for a specific FileNode type FileInfo struct { Path string `json:"path"` TypeFlag byte `json:"typeFlag"` Linkname string `json:"linkName"` hash uint64 //`json:"hash"` Size int64 `json:"size"` Mode os.FileMode `json:"fileMode"` Uid int `json:"uid"` Gid int `json:"gid"` IsDir bool `json:"isDir"` } // NewFileInfoFromTarHeader extracts the metadata from a tar header and file contents and generates a new FileInfo object. func NewFileInfoFromTarHeader(reader *tar.Reader, header *tar.Header, path string) FileInfo { var hash uint64 if header.Typeflag != tar.TypeDir { hash = getHashFromReader(reader) } return FileInfo{ Path: path, TypeFlag: header.Typeflag, Linkname: header.Linkname, hash: hash, Size: header.FileInfo().Size(), Mode: header.FileInfo().Mode(), Uid: header.Uid, Gid: header.Gid, IsDir: header.FileInfo().IsDir(), } } func NewFileInfo(realPath, path string, info os.FileInfo) FileInfo { var err error // todo: don't use tar types here, create our own... var fileType byte var linkName string var size int64 if info.Mode()&os.ModeSymlink != 0 { fileType = tar.TypeSymlink linkName, err = os.Readlink(realPath) if err != nil { panic(fmt.Errorf("unable to read symlink %q: %s", realPath, err)) } } else if info.IsDir() { fileType = tar.TypeDir } else { fileType = tar.TypeReg size = info.Size() } var hash uint64 if fileType != tar.TypeDir { file, err := os.Open(realPath) if err != nil { panic(fmt.Errorf("unable to open file %q: %s", realPath, err)) } defer file.Close() hash = getHashFromReader(file) } return FileInfo{ Path: path, TypeFlag: fileType, Linkname: linkName, hash: hash, Size: size, Mode: info.Mode(), // todo: support UID/GID Uid: -1, Gid: -1, IsDir: info.IsDir(), } } // Copy duplicates a FileInfo func (data *FileInfo) Copy() *FileInfo { if data == nil { return nil } return &FileInfo{ Path: data.Path, TypeFlag: data.TypeFlag, Linkname: data.Linkname, hash: data.hash, Size: data.Size, Mode: data.Mode, Uid: data.Uid, Gid: data.Gid, IsDir: data.IsDir, } } // Compare determines the DiffType between two FileInfos based on the type and contents of each given FileInfo func (data *FileInfo) Compare(other FileInfo) DiffType { if data.TypeFlag == other.TypeFlag { if data.hash == other.hash && data.Mode == other.Mode && data.Uid == other.Uid && data.Gid == other.Gid { return Unmodified } } return Modified } func getHashFromReader(reader io.Reader) uint64 { h := xxhash.New() buf := make([]byte, 1024) for { n, err := reader.Read(buf) if err != nil && err != io.EOF { panic(fmt.Errorf("unable to read file: %w", err)) } if n == 0 { break } _, err = h.Write(buf[:n]) if err != nil { panic(fmt.Errorf("unable to write to hash: %w", err)) } } return h.Sum64() } ================================================ FILE: dive/filetree/file_node.go ================================================ package filetree import ( "archive/tar" "fmt" "github.com/wagoodman/dive/internal/log" "strings" "github.com/dustin/go-humanize" "github.com/fatih/color" "github.com/phayes/permbits" ) const ( AttributeFormat = "%s%s %11s %10s " ) var diffTypeColor = map[DiffType]*color.Color{ Added: color.New(color.FgGreen), Removed: color.New(color.FgRed), Modified: color.New(color.FgYellow), Unmodified: color.New(color.Reset), } // FileNode represents a single file, its relation to files beneath it, the tree it exists in, and the metadata of the given file. type FileNode struct { Tree *FileTree Parent *FileNode Size int64 // memoized total size of file or directory Name string Data NodeData Children map[string]*FileNode path string } // NewNode creates a new FileNode relative to the given parent node with a payload. func NewNode(parent *FileNode, name string, data FileInfo) (node *FileNode) { node = new(FileNode) node.Name = name node.Data = *NewNodeData() node.Data.FileInfo = *data.Copy() node.Size = -1 // signal lazy load later node.Children = make(map[string]*FileNode) node.Parent = parent if parent != nil { node.Tree = parent.Tree } return node } // renderTreeLine returns a string representing this FileNode in the context of a greater ASCII tree. func (node *FileNode) renderTreeLine(spaces []bool, last bool, collapsed bool) string { var otherBranches string for _, space := range spaces { if space { otherBranches += noBranchSpace } else { otherBranches += branchSpace } } thisBranch := middleItem if last { thisBranch = lastItem } collapsedIndicator := uncollapsedItem if collapsed { collapsedIndicator = collapsedItem } return otherBranches + thisBranch + collapsedIndicator + node.String() + newLine } // Copy duplicates the existing node relative to a new parent node. func (node *FileNode) Copy(parent *FileNode) *FileNode { newNode := NewNode(parent, node.Name, node.Data.FileInfo) newNode.Data.ViewInfo = node.Data.ViewInfo newNode.Data.DiffType = node.Data.DiffType for name, child := range node.Children { newNode.Children[name] = child.Copy(newNode) child.Parent = newNode } return newNode } // AddChild creates a new node relative to the current FileNode. func (node *FileNode) AddChild(name string, data FileInfo) (child *FileNode) { // never allow processing of purely whiteout flag files (for now) if strings.HasPrefix(name, doubleWhiteoutPrefix) { return nil } child = NewNode(node, name, data) if node.Children[name] != nil { // tree node already exists, replace the payload, keep the children node.Children[name].Data.FileInfo = *data.Copy() } else { node.Children[name] = child node.Tree.Size++ } return child } // Remove deletes the current FileNode from it's parent FileNode's relations. func (node *FileNode) Remove() error { if node == node.Tree.Root { return fmt.Errorf("cannot remove the tree root") } for _, child := range node.Children { err := child.Remove() if err != nil { return err } } delete(node.Parent.Children, node.Name) node.Tree.Size-- return nil } // String shows the filename formatted into the proper color (by DiffType), additionally indicating if it is a symlink. func (node *FileNode) String() string { var display string if node == nil { return "" } display = node.Name if node.Data.FileInfo.TypeFlag == tar.TypeSymlink || node.Data.FileInfo.TypeFlag == tar.TypeLink { display += " → " + node.Data.FileInfo.Linkname } return diffTypeColor[node.Data.DiffType].Sprint(display) } // MetadatString returns the FileNode metadata in a columnar string. func (node *FileNode) MetadataString() string { if node == nil { return "" } dir := "-" if node.Data.FileInfo.IsDir { dir = "d" } fm := permbits.FileMode(node.Data.FileInfo.Mode) var fileMode strings.Builder fileMode.Grow(9) cond := func(c bool, x, y byte) byte { if c { return x } else { return y } } fileMode.WriteByte(cond(fm.UserRead(), 'r', '-')) fileMode.WriteByte(cond(fm.UserWrite(), 'w', '-')) fileMode.WriteByte(cond(fm.UserExecute(), cond(fm.Setuid(), 's', 'x'), cond(fm.Setuid(), 'S', '-'))) fileMode.WriteByte(cond(fm.GroupRead(), 'r', '-')) fileMode.WriteByte(cond(fm.GroupWrite(), 'w', '-')) fileMode.WriteByte(cond(fm.GroupExecute(), cond(fm.Setgid(), 's', 'x'), cond(fm.Setgid(), 'S', '-'))) fileMode.WriteByte(cond(fm.OtherRead(), 'r', '-')) fileMode.WriteByte(cond(fm.OtherWrite(), 'w', '-')) fileMode.WriteByte(cond(fm.OtherExecute(), cond(fm.Sticky(), 't', 'x'), cond(fm.Sticky(), 'T', '-'))) user := node.Data.FileInfo.Uid group := node.Data.FileInfo.Gid userGroup := fmt.Sprintf("%d:%d", user, group) // don't include file sizes of children that have been removed (unless the node in question is a removed dir, // then show the accumulated size of removed files) sizeBytes := node.GetSize() size := humanize.Bytes(uint64(sizeBytes)) return diffTypeColor[node.Data.DiffType].Sprint(fmt.Sprintf(AttributeFormat, dir, fileMode.String(), userGroup, size)) } func (node *FileNode) GetSize() int64 { if 0 <= node.Size { return node.Size } var sizeBytes int64 if node.IsLeaf() { sizeBytes = node.Data.FileInfo.Size } else { sizer := func(curNode *FileNode) error { if curNode.Data.DiffType != Removed || node.Data.DiffType == Removed { sizeBytes += curNode.Data.FileInfo.Size } return nil } err := node.VisitDepthChildFirst(sizer, nil, nil) if err != nil { log.WithFields("error", err).Debug("unable to propagate tree to get file size") } } node.Size = sizeBytes return node.Size } // VisitDepthChildFirst iterates a tree depth-first (starting at this FileNode), evaluating the deepest depths first (visit on bubble up) func (node *FileNode) VisitDepthChildFirst(visitor Visitor, evaluator VisitEvaluator, sorter OrderStrategy) error { if sorter == nil { sorter = GetSortOrderStrategy(ByName) } keys := sorter.orderKeys(node.Children) for _, name := range keys { child := node.Children[name] err := child.VisitDepthChildFirst(visitor, evaluator, sorter) if err != nil { return err } } // never visit the root node if node == node.Tree.Root { return nil } else if evaluator != nil && evaluator(node) || evaluator == nil { return visitor(node) } return nil } // VisitDepthParentFirst iterates a tree depth-first (starting at this FileNode), evaluating the shallowest depths first (visit while sinking down) func (node *FileNode) VisitDepthParentFirst(visitor Visitor, evaluator VisitEvaluator, sorter OrderStrategy) error { var err error doVisit := evaluator != nil && evaluator(node) || evaluator == nil if !doVisit { return nil } // never visit the root node if node != node.Tree.Root { err = visitor(node) if err != nil { return err } } if sorter == nil { sorter = GetSortOrderStrategy(ByName) } keys := sorter.orderKeys(node.Children) for _, name := range keys { child := node.Children[name] err = child.VisitDepthParentFirst(visitor, evaluator, sorter) if err != nil { return err } } return err } // IsWhiteout returns an indication if this file may be a overlay-whiteout file. func (node *FileNode) IsWhiteout() bool { return strings.HasPrefix(node.Name, whiteoutPrefix) } // IsLeaf returns true is the current node has no child nodes. func (node *FileNode) IsLeaf() bool { return len(node.Children) == 0 } // Path returns a slash-delimited string from the root of the greater tree to the current node (e.g. /a/path/to/here) func (node *FileNode) Path() string { if node.path == "" { var path []string curNode := node for { if curNode.Parent == nil { break } name := curNode.Name if curNode == node { // white out prefixes are fictitious on leaf nodes name = strings.TrimPrefix(name, whiteoutPrefix) } path = append([]string{name}, path...) curNode = curNode.Parent } node.path = "/" + strings.Join(path, "/") } return strings.Replace(node.path, "//", "/", -1) } // deriveDiffType determines a DiffType to the current FileNode. Note: the DiffType of a node is always the DiffType of // its attributes and its contents. The contents are the bytes of the file of the children of a directory. func (node *FileNode) deriveDiffType(diffType DiffType) error { if node.IsLeaf() { return node.AssignDiffType(diffType) } myDiffType := diffType for _, v := range node.Children { myDiffType = myDiffType.merge(v.Data.DiffType) } return node.AssignDiffType(myDiffType) } // AssignDiffType will assign the given DiffType to this node, possibly affecting child nodes. func (node *FileNode) AssignDiffType(diffType DiffType) error { var err error node.Data.DiffType = diffType if diffType == Removed { // if we've removed this node, then all children have been removed as well for _, child := range node.Children { err = child.AssignDiffType(diffType) if err != nil { return err } } } return nil } // compare the current node against the given node, returning a definitive DiffType. func (node *FileNode) compare(other *FileNode) DiffType { if node == nil && other == nil { return Unmodified } if node == nil && other != nil { return Added } if node != nil && other == nil { return Removed } if other.IsWhiteout() { return Removed } if node.Name != other.Name { panic("comparing mismatched nodes") } return node.Data.FileInfo.Compare(other.Data.FileInfo) } ================================================ FILE: dive/filetree/file_node_test.go ================================================ package filetree import ( "testing" ) func TestAddChild(t *testing.T) { var expected, actual int tree := NewFileTree() payload := FileInfo{ Path: "stufffffs", } one := tree.Root.AddChild("first node!", payload) two := tree.Root.AddChild("nil node!", FileInfo{}) tree.Root.AddChild("third node!", FileInfo{}) two.AddChild("forth, one level down...", FileInfo{}) two.AddChild("fifth, one level down...", FileInfo{}) two.AddChild("fifth, one level down...", FileInfo{}) expected, actual = 5, tree.Size if expected != actual { t.Errorf("Expected a tree size of %d got %d.", expected, actual) } expected, actual = 2, len(two.Children) if expected != actual { t.Errorf("Expected 'twos' number of children to be %d got %d.", expected, actual) } expected, actual = 3, len(tree.Root.Children) if expected != actual { t.Errorf("Expected 'twos' number of children to be %d got %d.", expected, actual) } expectedFC := FileInfo{ Path: "stufffffs", } actualFC := one.Data.FileInfo if expectedFC.Path != actualFC.Path { t.Errorf("Expected 'ones' payload to be %+v got %+v.", expectedFC, actualFC) } } func TestRemoveChild(t *testing.T) { var expected, actual int tree := NewFileTree() tree.Root.AddChild("first", FileInfo{}) two := tree.Root.AddChild("nil", FileInfo{}) tree.Root.AddChild("third", FileInfo{}) forth := two.AddChild("forth", FileInfo{}) two.AddChild("fifth", FileInfo{}) err := forth.Remove() checkError(t, err, "unable to setup test") expected, actual = 4, tree.Size if expected != actual { t.Errorf("Expected a tree size of %d got %d.", expected, actual) } if tree.Root.Children["forth"] != nil { t.Errorf("Expected 'forth' node to be deleted.") } err = two.Remove() checkError(t, err, "unable to setup test") expected, actual = 2, tree.Size if expected != actual { t.Errorf("Expected a tree size of %d got %d.", expected, actual) } if tree.Root.Children["nil"] != nil { t.Errorf("Expected 'nil' node to be deleted.") } } func TestPath(t *testing.T) { expected := "/etc/nginx/nginx.conf" tree := NewFileTree() node, _, _ := tree.AddPath(expected, FileInfo{}) actual := node.Path() if expected != actual { t.Errorf("Expected path '%s' got '%s'", expected, actual) } } func TestIsWhiteout(t *testing.T) { tree1 := NewFileTree() p1, _, _ := tree1.AddPath("/etc/nginx/public1", FileInfo{}) p2, _, _ := tree1.AddPath("/etc/nginx/.wh.public2", FileInfo{}) p3, _, _ := tree1.AddPath("/etc/nginx/public3/.wh..wh..opq", FileInfo{}) if p1.IsWhiteout() != false { t.Errorf("Expected path '%s' to **not** be a whiteout file", p1.Name) } if p2.IsWhiteout() != true { t.Errorf("Expected path '%s' to be a whiteout file", p2.Name) } if p3 != nil { t.Errorf("Expected to not be able to add path '%s'", p2.Name) } } func TestDiffTypeFromAddedChildren(t *testing.T) { tree := NewFileTree() node, _, _ := tree.AddPath("/usr", *BlankFileChangeInfo("/usr")) node.Data.DiffType = Unmodified node, _, _ = tree.AddPath("/usr/bin", *BlankFileChangeInfo("/usr/bin")) node.Data.DiffType = Added node, _, _ = tree.AddPath("/usr/bin2", *BlankFileChangeInfo("/usr/bin2")) node.Data.DiffType = Removed err := tree.Root.Children["usr"].deriveDiffType(Unmodified) checkError(t, err, "unable to setup test") if tree.Root.Children["usr"].Data.DiffType != Modified { t.Errorf("Expected Modified but got %v", tree.Root.Children["usr"].Data.DiffType) } } func TestDiffTypeFromRemovedChildren(t *testing.T) { tree := NewFileTree() _, _, _ = tree.AddPath("/usr", *BlankFileChangeInfo("/usr")) info1 := BlankFileChangeInfo("/usr/.wh.bin") node, _, _ := tree.AddPath("/usr/.wh.bin", *info1) node.Data.DiffType = Removed info2 := BlankFileChangeInfo("/usr/.wh.bin2") node, _, _ = tree.AddPath("/usr/.wh.bin2", *info2) node.Data.DiffType = Removed err := tree.Root.Children["usr"].deriveDiffType(Unmodified) checkError(t, err, "unable to setup test") if tree.Root.Children["usr"].Data.DiffType != Modified { t.Errorf("Expected Modified but got %v", tree.Root.Children["usr"].Data.DiffType) } } func TestDirSize(t *testing.T) { tree1 := NewFileTree() _, _, err := tree1.AddPath("/etc/nginx/public1", FileInfo{Size: 100}) checkError(t, err, "unable to setup test") _, _, err = tree1.AddPath("/etc/nginx/thing1", FileInfo{Size: 200}) checkError(t, err, "unable to setup test") _, _, err = tree1.AddPath("/etc/nginx/public3/thing2", FileInfo{Size: 300}) checkError(t, err, "unable to setup test") node, _ := tree1.GetNode("/etc/nginx") expected, actual := "---------- 0:0 600 B ", node.MetadataString() if expected != actual { t.Errorf("Expected metadata '%s' got '%s'", expected, actual) } } ================================================ FILE: dive/filetree/file_tree.go ================================================ package filetree import ( "fmt" "github.com/wagoodman/dive/internal/log" "path" "strings" "github.com/google/uuid" ) const ( newLine = "\n" noBranchSpace = " " branchSpace = "│ " middleItem = "├─" lastItem = "└─" whiteoutPrefix = ".wh." doubleWhiteoutPrefix = ".wh..wh.." uncollapsedItem = "─ " collapsedItem = "⊕ " ) // FileTree represents a set of files, directories, and their relations. type FileTree struct { Root *FileNode Size int FileSize uint64 Name string Id uuid.UUID SortOrder SortOrder } // NewFileTree creates an empty FileTree func NewFileTree() (tree *FileTree) { tree = new(FileTree) tree.Size = 0 tree.Root = new(FileNode) tree.Root.Tree = tree tree.Root.Children = make(map[string]*FileNode) tree.Id = uuid.New() tree.SortOrder = ByName return tree } // renderParams is a representation of a FileNode in the context of the greater tree. All // data stored is necessary for rendering a single line in a tree format. type renderParams struct { node *FileNode spaces []bool childSpaces []bool showCollapsed bool isLast bool } // renderStringTreeBetween returns a string representing the given tree between the given rows. Since each node // is rendered on its own line, the returned string shows the visible nodes not affected by a collapsed parent. func (tree *FileTree) renderStringTreeBetween(startRow, stopRow int, showAttributes bool) string { // generate a list of nodes to render var params = make([]renderParams, 0) var result string // visit from the front of the list var paramsToVisit = []renderParams{{node: tree.Root, spaces: []bool{}, showCollapsed: false, isLast: false}} for currentRow := 0; len(paramsToVisit) > 0 && currentRow <= stopRow; currentRow++ { // pop the first node var currentParams renderParams currentParams, paramsToVisit = paramsToVisit[0], paramsToVisit[1:] // take note of the next nodes to visit later sorter := GetSortOrderStrategy(tree.SortOrder) keys := sorter.orderKeys(currentParams.node.Children) var childParams = make([]renderParams, 0) for idx, name := range keys { child := currentParams.node.Children[name] // don't visit this node... if child.Data.ViewInfo.Hidden || currentParams.node.Data.ViewInfo.Collapsed { continue } // visit this node... isLast := idx == (len(currentParams.node.Children) - 1) showCollapsed := child.Data.ViewInfo.Collapsed && len(child.Children) > 0 // completely copy the reference slice childSpaces := make([]bool, len(currentParams.childSpaces)) copy(childSpaces, currentParams.childSpaces) if len(child.Children) > 0 && !child.Data.ViewInfo.Collapsed { childSpaces = append(childSpaces, isLast) } childParams = append(childParams, renderParams{ node: child, spaces: currentParams.childSpaces, childSpaces: childSpaces, showCollapsed: showCollapsed, isLast: isLast, }) } // keep the child nodes to visit later paramsToVisit = append(childParams, paramsToVisit...) // never process the root node if currentParams.node == tree.Root { currentRow-- continue } // process the current node if currentRow >= startRow && currentRow <= stopRow { params = append(params, currentParams) } } // render the result for idx := range params { currentParams := params[idx] if showAttributes { result += currentParams.node.MetadataString() + " " } result += currentParams.node.renderTreeLine(currentParams.spaces, currentParams.isLast, currentParams.showCollapsed) } return result } func (tree *FileTree) VisibleSize() int { var size int visitor := func(node *FileNode) error { size++ return nil } visitEvaluator := func(node *FileNode) bool { if node.Data.FileInfo.IsDir { // we won't visit a collapsed dir, but we need to count it if node.Data.ViewInfo.Collapsed { size++ } return !node.Data.ViewInfo.Collapsed && !node.Data.ViewInfo.Hidden } return !node.Data.ViewInfo.Hidden } err := tree.VisitDepthParentFirst(visitor, visitEvaluator) if err != nil { log.WithFields("error", err).Debug("unable to determine visible tree size") } // don't include root size-- return size } // String returns the entire tree in an ASCII representation. func (tree *FileTree) String(showAttributes bool) string { return tree.renderStringTreeBetween(0, tree.Size, showAttributes) } // StringBetween returns a partial tree in an ASCII representation. func (tree *FileTree) StringBetween(start, stop int, showAttributes bool) string { return tree.renderStringTreeBetween(start, stop, showAttributes) } // Copy returns a copy of the given FileTree func (tree *FileTree) Copy() *FileTree { newTree := NewFileTree() newTree.Size = tree.Size newTree.FileSize = tree.FileSize newTree.Root = tree.Root.Copy(newTree.Root) newTree.SortOrder = tree.SortOrder // update the tree pointers err := newTree.VisitDepthChildFirst(func(node *FileNode) error { node.Tree = newTree return nil }, nil) if err != nil { log.WithFields("error", err).Debug("unable to propagate tree on copy") } return newTree } // Visitor is a function that processes, observes, or otherwise transforms the given node type Visitor func(*FileNode) error // VisitEvaluator is a function that indicates whether the given node should be visited by a Visitor. type VisitEvaluator func(*FileNode) bool // VisitDepthChildFirst iterates the given tree depth-first, evaluating the deepest depths first (visit on bubble up) func (tree *FileTree) VisitDepthChildFirst(visitor Visitor, evaluator VisitEvaluator) error { sorter := GetSortOrderStrategy(tree.SortOrder) return tree.Root.VisitDepthChildFirst(visitor, evaluator, sorter) } // VisitDepthParentFirst iterates the given tree depth-first, evaluating the shallowest depths first (visit while sinking down) func (tree *FileTree) VisitDepthParentFirst(visitor Visitor, evaluator VisitEvaluator) error { sorter := GetSortOrderStrategy(tree.SortOrder) return tree.Root.VisitDepthParentFirst(visitor, evaluator, sorter) } // Stack takes two trees and combines them together. This is done by "stacking" the given tree on top of the owning tree. func (tree *FileTree) Stack(upper *FileTree) (failed []PathError, stackErr error) { graft := func(node *FileNode) error { if node.IsWhiteout() { err := tree.RemovePath(node.Path()) if err != nil { failed = append(failed, NewPathError(node.Path(), ActionAdd, err)) } } else { _, _, err := tree.AddPath(node.Path(), node.Data.FileInfo) if err != nil { failed = append(failed, NewPathError(node.Path(), ActionRemove, err)) } } return nil } stackErr = upper.VisitDepthChildFirst(graft, nil) return failed, stackErr } // GetNode fetches a single node when given a slash-delimited string from root ('/') to the desired node (e.g. '/a/node/path') func (tree *FileTree) GetNode(path string) (*FileNode, error) { nodeNames := strings.Split(strings.Trim(path, "/"), "/") node := tree.Root for _, name := range nodeNames { if name == "" { continue } if node.Children[name] == nil { return nil, fmt.Errorf("path does not exist: %s", path) } node = node.Children[name] } return node, nil } // AddPath adds a new node to the tree with the given payload func (tree *FileTree) AddPath(filepath string, data FileInfo) (*FileNode, []*FileNode, error) { filepath = path.Clean(filepath) if filepath == "." { return nil, nil, fmt.Errorf("cannot add relative path '%s'", filepath) } nodeNames := strings.Split(strings.Trim(filepath, "/"), "/") node := tree.Root addedNodes := make([]*FileNode, 0) for idx, name := range nodeNames { if name == "" { continue } // find or create node if node.Children[name] != nil { node = node.Children[name] } else { // don't add paths that should be deleted if strings.HasPrefix(name, doubleWhiteoutPrefix) { return nil, addedNodes, nil } // don't attach the payload. The payload is destined for the // Path's end node, not any intermediary node. node = node.AddChild(name, FileInfo{}) addedNodes = append(addedNodes, node) if node == nil { // the child could not be added return node, addedNodes, fmt.Errorf("could not add child node: '%s' (path:'%s')", name, filepath) } } // attach payload to the last specified node if idx == len(nodeNames)-1 { node.Data.FileInfo = data } } return node, addedNodes, nil } // RemovePath removes a node from the tree given its path. func (tree *FileTree) RemovePath(path string) error { node, err := tree.GetNode(path) if err != nil { return err } return node.Remove() } type compareMark struct { lowerNode *FileNode upperNode *FileNode tentative DiffType final DiffType } // CompareAndMark marks the FileNodes in the owning (lower) tree with DiffType annotations when compared to the given (upper) tree. func (tree *FileTree) CompareAndMark(upper *FileTree) ([]PathError, error) { // always compare relative to the original, unaltered tree. originalTree := tree modifications := make([]compareMark, 0) failed := make([]PathError, 0) graft := func(upperNode *FileNode) error { if upperNode.IsWhiteout() { err := tree.markRemoved(upperNode.Path()) if err != nil { failed = append(failed, NewPathError(upperNode.Path(), ActionRemove, err)) } return nil } // note: since we are not comparing against the original tree (copying the tree is expensive) we may mark the parent // of an added node incorrectly as modified. This will be corrected later. originalLowerNode, _ := originalTree.GetNode(upperNode.Path()) if originalLowerNode == nil { _, newNodes, err := tree.AddPath(upperNode.Path(), upperNode.Data.FileInfo) if err != nil { failed = append(failed, NewPathError(upperNode.Path(), ActionAdd, err)) return nil } for idx := len(newNodes) - 1; idx >= 0; idx-- { newNode := newNodes[idx] modifications = append(modifications, compareMark{lowerNode: newNode, upperNode: upperNode, tentative: -1, final: Added}) } return nil } // the file exists in the lower layer lowerNode, _ := tree.GetNode(upperNode.Path()) diffType := lowerNode.compare(upperNode) modifications = append(modifications, compareMark{lowerNode: lowerNode, upperNode: upperNode, tentative: diffType, final: -1}) return nil } // we must visit from the leaves upwards to ensure that diff types can be derived from and assigned to children err := upper.VisitDepthChildFirst(graft, nil) if err != nil { return failed, err } // take note of the comparison results on each note in the owning tree. for _, pair := range modifications { if pair.final > 0 { err = pair.lowerNode.AssignDiffType(pair.final) if err != nil { return failed, err } } else if pair.lowerNode.Data.DiffType == Unmodified { err = pair.lowerNode.deriveDiffType(pair.tentative) if err != nil { return failed, err } } // persist the upper's payload on the owning tree pair.lowerNode.Data.FileInfo = *pair.upperNode.Data.FileInfo.Copy() } return failed, nil } // markRemoved annotates the FileNode at the given path as Removed. func (tree *FileTree) markRemoved(path string) error { node, err := tree.GetNode(path) if err != nil { return err } return node.AssignDiffType(Removed) } // StackTreeRange combines an array of trees into a single tree func StackTreeRange(trees []*FileTree, start, stop int) (*FileTree, []PathError, error) { errors := make([]PathError, 0) tree := trees[0].Copy() for idx := start; idx <= stop; idx++ { failedPaths, err := tree.Stack(trees[idx]) if len(failedPaths) > 0 { errors = append(errors, failedPaths...) } if err != nil { return nil, nil, fmt.Errorf("could not stack tree range: %w", err) } } return tree, errors, nil } ================================================ FILE: dive/filetree/file_tree_test.go ================================================ package filetree import ( "fmt" "github.com/stretchr/testify/assert" "testing" ) func stringInSlice(a string, list []string) bool { for _, b := range list { if b == a { return true } } return false } func AssertDiffType(node *FileNode, expectedDiffType DiffType) error { if node.Data.DiffType != expectedDiffType { return fmt.Errorf("Expecting node at %s to have DiffType %v, but had %v", node.Path(), expectedDiffType, node.Data.DiffType) } return nil } func TestStringCollapsed(t *testing.T) { tree := NewFileTree() tree.Root.AddChild("1 node!", FileInfo{}) two := tree.Root.AddChild("2 node!", FileInfo{}) subTwo := two.AddChild("2 child!", FileInfo{}) subTwo.AddChild("2 grandchild!", FileInfo{}) subTwo.Data.ViewInfo.Collapsed = true three := tree.Root.AddChild("3 node!", FileInfo{}) subThree := three.AddChild("3 child!", FileInfo{}) three.AddChild("3 nested child 1!", FileInfo{}) threeGc1 := subThree.AddChild("3 grandchild 1!", FileInfo{}) threeGc1.AddChild("3 greatgrandchild 1!", FileInfo{}) subThree.AddChild("3 grandchild 2!", FileInfo{}) four := tree.Root.AddChild("4 node!", FileInfo{}) four.Data.ViewInfo.Collapsed = true tree.Root.AddChild("5 node!", FileInfo{}) four.AddChild("6, one level down...", FileInfo{}) expected := `├── 1 node! ├── 2 node! │ └─⊕ 2 child! ├── 3 node! │ ├── 3 child! │ │ ├── 3 grandchild 1! │ │ │ └── 3 greatgrandchild 1! │ │ └── 3 grandchild 2! │ └── 3 nested child 1! ├─⊕ 4 node! └── 5 node! ` actual := tree.String(false) if expected != actual { t.Errorf("Expected tree string:\n--->%s<---\nGot:\n--->%s<---", expected, actual) } } func TestString(t *testing.T) { tree := NewFileTree() tree.Root.AddChild("1 node!", FileInfo{}) tree.Root.AddChild("2 node!", FileInfo{}) tree.Root.AddChild("3 node!", FileInfo{}) four := tree.Root.AddChild("4 node!", FileInfo{}) tree.Root.AddChild("5 node!", FileInfo{}) four.AddChild("6, one level down...", FileInfo{}) expected := `├── 1 node! ├── 2 node! ├── 3 node! ├── 4 node! │ └── 6, one level down... └── 5 node! ` actual := tree.String(false) if expected != actual { t.Errorf("Expected tree string:\n--->%s<---\nGot:\n--->%s<---", expected, actual) } } func TestStringBetween(t *testing.T) { tree := NewFileTree() _, _, err := tree.AddPath("/etc/nginx/nginx.conf", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } _, _, err = tree.AddPath("/etc/nginx/public", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } _, _, err = tree.AddPath("/var/run/systemd", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } _, _, err = tree.AddPath("/var/run/bashful", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } _, _, err = tree.AddPath("/tmp", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } _, _, err = tree.AddPath("/tmp/nonsense", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } expected := `│ └── public ├── tmp │ └── nonsense ` actual := tree.StringBetween(3, 5, false) if expected != actual { t.Errorf("Expected tree string:\n--->%s<---\nGot:\n--->%s<---", expected, actual) } } func TestRejectPurelyRelativePath(t *testing.T) { tree := NewFileTree() _, _, err := tree.AddPath("./etc/nginx/nginx.conf", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } _, _, err = tree.AddPath("./", FileInfo{}) if err == nil { t.Errorf("expected to reject relative path, but did not") } } func TestAddRelativePath(t *testing.T) { tree := NewFileTree() _, _, err := tree.AddPath("./etc/nginx/nginx.conf", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } expected := `└── etc └── nginx └── nginx.conf ` actual := tree.String(false) if expected != actual { t.Errorf("Expected tree string:\n--->%s<---\nGot:\n--->%s<---", expected, actual) } } func TestAddPath(t *testing.T) { tree := NewFileTree() _, _, err := tree.AddPath("/etc/nginx/nginx.conf", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } _, _, err = tree.AddPath("/etc/nginx/public", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } _, _, err = tree.AddPath("/var/run/systemd", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } _, _, err = tree.AddPath("/var/run/bashful", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } _, _, err = tree.AddPath("/tmp", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } _, _, err = tree.AddPath("/tmp/nonsense", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } expected := `├── etc │ └── nginx │ ├── nginx.conf │ └── public ├── tmp │ └── nonsense └── var └── run ├── bashful └── systemd ` actual := tree.String(false) if expected != actual { t.Errorf("Expected tree string:\n--->%s<---\nGot:\n--->%s<---", expected, actual) } } func TestAddWhiteoutPath(t *testing.T) { tree := NewFileTree() node, _, err := tree.AddPath("usr/local/lib/python3.7/site-packages/pip/.wh..wh..opq", FileInfo{}) if err != nil { t.Errorf("expected no error but got: %v", err) } if node != nil { t.Errorf("expected node to be nil, but got: %v", node) } expected := `└── usr └── local └── lib └── python3.7 └── site-packages └── pip ` actual := tree.String(false) if expected != actual { t.Errorf("Expected tree string:\n--->%s<---\nGot:\n--->%s<---", expected, actual) } } func TestRemovePath(t *testing.T) { tree := NewFileTree() _, _, err := tree.AddPath("/etc/nginx/nginx.conf", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } _, _, err = tree.AddPath("/etc/nginx/public", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } _, _, err = tree.AddPath("/var/run/systemd", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } _, _, err = tree.AddPath("/var/run/bashful", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } _, _, err = tree.AddPath("/tmp", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } _, _, err = tree.AddPath("/tmp/nonsense", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } err = tree.RemovePath("/var/run/bashful") if err != nil { t.Errorf("could not setup test: %v", err) } err = tree.RemovePath("/tmp") if err != nil { t.Errorf("could not setup test: %v", err) } expected := `├── etc │ └── nginx │ ├── nginx.conf │ └── public └── var └── run └── systemd ` actual := tree.String(false) if expected != actual { t.Errorf("Expected tree string:\n--->%s<---\nGot:\n--->%s<---", expected, actual) } } func TestStack(t *testing.T) { payloadKey := "/var/run/systemd" payloadValue := FileInfo{ Path: "yup", } tree1 := NewFileTree() _, _, err := tree1.AddPath("/etc/nginx/public", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } _, _, err = tree1.AddPath(payloadKey, FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } _, _, err = tree1.AddPath("/var/run/bashful", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } _, _, err = tree1.AddPath("/tmp", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } _, _, err = tree1.AddPath("/tmp/nonsense", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } tree2 := NewFileTree() // add new files _, _, err = tree2.AddPath("/etc/nginx/nginx.conf", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } // modify current files _, _, err = tree2.AddPath(payloadKey, payloadValue) if err != nil { t.Errorf("could not setup test: %v", err) } // whiteout the following files _, _, err = tree2.AddPath("/var/run/.wh.bashful", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } _, _, err = tree2.AddPath("/.wh.tmp", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } // ignore opaque whiteout files entirely node, _, err := tree2.AddPath("/.wh..wh..opq", FileInfo{}) if err != nil { t.Errorf("expected no error on whiteout file add, but got %v", err) } if node != nil { t.Errorf("expected no node on whiteout file add, but got %v", node) } failedPaths, err := tree1.Stack(tree2) if err != nil { t.Errorf("Could not stack refTrees: %v", err) } if len(failedPaths) > 0 { t.Errorf("expected no filepath errors, got %d", len(failedPaths)) } expected := `├── etc │ └── nginx │ ├── nginx.conf │ └── public └── var └── run └── systemd ` node, err = tree1.GetNode(payloadKey) if err != nil { t.Errorf("Expected '%s' to still exist, but it doesn't", payloadKey) } if node == nil || node.Data.FileInfo.Path != payloadValue.Path { t.Errorf("Expected '%s' value to be %+v but got %+v", payloadKey, payloadValue, node.Data.FileInfo) } actual := tree1.String(false) if expected != actual { t.Errorf("Expected tree string:\n--->%s<---\nGot:\n--->%s<---", expected, actual) } } func TestCopy(t *testing.T) { tree := NewFileTree() _, _, err := tree.AddPath("/etc/nginx/nginx.conf", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } _, _, err = tree.AddPath("/etc/nginx/public", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } _, _, err = tree.AddPath("/var/run/systemd", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } _, _, err = tree.AddPath("/var/run/bashful", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } _, _, err = tree.AddPath("/tmp", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } _, _, err = tree.AddPath("/tmp/nonsense", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } err = tree.RemovePath("/var/run/bashful") if err != nil { t.Errorf("could not setup test: %v", err) } err = tree.RemovePath("/tmp") if err != nil { t.Errorf("could not setup test: %v", err) } expected := `├── etc │ └── nginx │ ├── nginx.conf │ └── public └── var └── run └── systemd ` NewFileTree := tree.Copy() actual := NewFileTree.String(false) if expected != actual { t.Errorf("Expected tree string:\n--->%s<---\nGot:\n--->%s<---", expected, actual) } } func TestCompareWithNoChanges(t *testing.T) { lowerTree := NewFileTree() upperTree := NewFileTree() paths := [...]string{"/etc", "/etc/sudoers", "/etc/hosts", "/usr/bin", "/usr/bin/bash", "/usr"} for _, value := range paths { fakeData := FileInfo{ Path: value, TypeFlag: 1, hash: 123, } _, _, err := lowerTree.AddPath(value, fakeData) if err != nil { t.Errorf("could not setup test: %v", err) } _, _, err = upperTree.AddPath(value, fakeData) if err != nil { t.Errorf("could not setup test: %v", err) } } failedPaths, err := lowerTree.CompareAndMark(upperTree) if err != nil { t.Errorf("could not setup test: %v", err) } if len(failedPaths) > 0 { t.Errorf("expected no filepath errors, got %d", len(failedPaths)) } asserter := func(n *FileNode) error { if n.Path() == "/" { return nil } if (n.Data.DiffType) != Unmodified { t.Errorf("Expecting node at %s to have DiffType unchanged, but had %v", n.Path(), n.Data.DiffType) } return nil } err = lowerTree.VisitDepthChildFirst(asserter, nil) if err != nil { t.Error(err) } } func TestCompareWithAdds(t *testing.T) { lowerTree := NewFileTree() upperTree := NewFileTree() lowerPaths := [...]string{"/etc", "/etc/sudoers", "/usr", "/etc/hosts", "/usr/bin"} upperPaths := [...]string{"/etc", "/etc/sudoers", "/usr", "/etc/hosts", "/usr/bin", "/usr/bin/bash", "/a/new/path"} for _, value := range lowerPaths { _, _, err := lowerTree.AddPath(value, FileInfo{ Path: value, TypeFlag: 1, hash: 123, }) if err != nil { t.Errorf("could not setup test: %v", err) } } for _, value := range upperPaths { _, _, err := upperTree.AddPath(value, FileInfo{ Path: value, TypeFlag: 1, hash: 123, }) if err != nil { t.Errorf("could not setup test: %v", err) } } failedAssertions := []error{} failedPaths, err := lowerTree.CompareAndMark(upperTree) if err != nil { t.Errorf("Expected tree compare to have no errors, got: %v", err) } if len(failedPaths) > 0 { t.Errorf("expected no filepath errors, got %d", len(failedPaths)) } asserter := func(n *FileNode) error { p := n.Path() if p == "/" { return nil } else if stringInSlice(p, []string{"/usr/bin/bash", "/a", "/a/new", "/a/new/path"}) { if err := AssertDiffType(n, Added); err != nil { failedAssertions = append(failedAssertions, err) } } else if stringInSlice(p, []string{"/usr/bin", "/usr"}) { if err := AssertDiffType(n, Modified); err != nil { failedAssertions = append(failedAssertions, err) } } else { if err := AssertDiffType(n, Unmodified); err != nil { failedAssertions = append(failedAssertions, err) } } return nil } err = lowerTree.VisitDepthChildFirst(asserter, nil) if err != nil { t.Errorf("Expected no errors when visiting nodes, got: %+v", err) } if len(failedAssertions) > 0 { str := "\n" for _, value := range failedAssertions { str += fmt.Sprintf(" - %s\n", value.Error()) } t.Errorf("Expected no errors when evaluating nodes, got: %s", str) } } func TestCompareWithChanges(t *testing.T) { lowerTree := NewFileTree() upperTree := NewFileTree() changedPaths := []string{"/etc", "/usr", "/etc/hosts", "/etc/sudoers", "/usr/bin"} for _, value := range changedPaths { _, _, err := lowerTree.AddPath(value, FileInfo{ Path: value, TypeFlag: 1, hash: 123, }) if err != nil { t.Errorf("could not setup test: %v", err) } _, _, err = upperTree.AddPath(value, FileInfo{ Path: value, TypeFlag: 1, hash: 456, }) if err != nil { t.Errorf("could not setup test: %v", err) } } chmodPath := "/etc/non-data-change" _, _, err := lowerTree.AddPath(chmodPath, FileInfo{ Path: chmodPath, TypeFlag: 1, hash: 123, Mode: 0, }) if err != nil { t.Errorf("could not setup test: %v", err) } _, _, err = upperTree.AddPath(chmodPath, FileInfo{ Path: chmodPath, TypeFlag: 1, hash: 123, Mode: 1, }) if err != nil { t.Errorf("could not setup test: %v", err) } changedPaths = append(changedPaths, chmodPath) chownPath := "/etc/non-data-change-2" _, _, err = lowerTree.AddPath(chmodPath, FileInfo{ Path: chownPath, TypeFlag: 1, hash: 123, Mode: 1, Gid: 0, Uid: 0, }) if err != nil { t.Errorf("could not setup test: %v", err) } _, _, err = upperTree.AddPath(chmodPath, FileInfo{ Path: chownPath, TypeFlag: 1, hash: 123, Mode: 1, Gid: 12, Uid: 12, }) if err != nil { t.Errorf("could not setup test: %v", err) } changedPaths = append(changedPaths, chownPath) failedPaths, err := lowerTree.CompareAndMark(upperTree) if err != nil { t.Errorf("unable to compare and mark: %+v", err) } if len(failedPaths) > 0 { t.Errorf("expected no filepath errors, got %d", len(failedPaths)) } failedAssertions := []error{} asserter := func(n *FileNode) error { p := n.Path() if p == "/" { return nil } else if stringInSlice(p, changedPaths) { if err := AssertDiffType(n, Modified); err != nil { failedAssertions = append(failedAssertions, err) } } else { if err := AssertDiffType(n, Unmodified); err != nil { failedAssertions = append(failedAssertions, err) } } return nil } err = lowerTree.VisitDepthChildFirst(asserter, nil) if err != nil { t.Errorf("Expected no errors when visiting nodes, got: %+v", err) } if len(failedAssertions) > 0 { str := "\n" for _, value := range failedAssertions { str += fmt.Sprintf(" - %s\n", value.Error()) } t.Errorf("Expected no errors when evaluating nodes, got: %s", str) } } func TestCompareWithRemoves(t *testing.T) { lowerTree := NewFileTree() upperTree := NewFileTree() lowerPaths := [...]string{"/etc", "/usr", "/etc/hosts", "/etc/sudoers", "/usr/bin", "/root", "/root/example", "/root/example/some1", "/root/example/some2"} upperPaths := [...]string{"/.wh.etc", "/usr", "/usr/.wh.bin", "/root/.wh.example"} for _, value := range lowerPaths { fakeData := FileInfo{ Path: value, TypeFlag: 1, hash: 123, } _, _, err := lowerTree.AddPath(value, fakeData) if err != nil { t.Errorf("could not setup test: %v", err) } } for _, value := range upperPaths { fakeData := FileInfo{ Path: value, TypeFlag: 1, hash: 123, } _, _, err := upperTree.AddPath(value, fakeData) if err != nil { t.Errorf("could not setup test: %v", err) } } failedPaths, err := lowerTree.CompareAndMark(upperTree) if err != nil { t.Errorf("could not setup test: %v", err) } if len(failedPaths) > 0 { t.Errorf("expected no filepath errors, got %d", len(failedPaths)) } failedAssertions := []error{} asserter := func(n *FileNode) error { p := n.Path() if p == "/" { return nil } else if stringInSlice(p, []string{"/etc", "/usr/bin", "/etc/hosts", "/etc/sudoers", "/root/example/some1", "/root/example/some2", "/root/example"}) { if err := AssertDiffType(n, Removed); err != nil { failedAssertions = append(failedAssertions, err) } } else if stringInSlice(p, []string{"/usr", "/root"}) { if err := AssertDiffType(n, Modified); err != nil { failedAssertions = append(failedAssertions, err) } } else { if err := AssertDiffType(n, Unmodified); err != nil { failedAssertions = append(failedAssertions, err) } } return nil } err = lowerTree.VisitDepthChildFirst(asserter, nil) if err != nil { t.Errorf("Expected no errors when visiting nodes, got: %+v", err) } if len(failedAssertions) > 0 { str := "\n" for _, value := range failedAssertions { str += fmt.Sprintf(" - %s\n", value.Error()) } t.Errorf("Expected no errors when evaluating nodes, got: %s", str) } } func TestStackRange(t *testing.T) { tree := NewFileTree() _, _, err := tree.AddPath("/etc/nginx/nginx.conf", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } _, _, err = tree.AddPath("/etc/nginx/public", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } _, _, err = tree.AddPath("/var/run/systemd", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } _, _, err = tree.AddPath("/var/run/bashful", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } _, _, err = tree.AddPath("/tmp", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } _, _, err = tree.AddPath("/tmp/nonsense", FileInfo{}) if err != nil { t.Errorf("could not setup test: %v", err) } err = tree.RemovePath("/var/run/bashful") if err != nil { t.Errorf("could not setup test: %v", err) } err = tree.RemovePath("/tmp") if err != nil { t.Errorf("could not setup test: %v", err) } lowerTree := NewFileTree() upperTree := NewFileTree() lowerPaths := [...]string{"/etc", "/usr", "/etc/hosts", "/etc/sudoers", "/usr/bin"} upperPaths := [...]string{"/etc", "/usr", "/etc/hosts", "/etc/sudoers", "/usr/bin"} for _, value := range lowerPaths { fakeData := FileInfo{ Path: value, TypeFlag: 1, hash: 123, } _, _, err = lowerTree.AddPath(value, fakeData) if err != nil { t.Errorf("could not setup test: %v", err) } } for _, value := range upperPaths { fakeData := FileInfo{ Path: value, TypeFlag: 1, hash: 456, } _, _, err = upperTree.AddPath(value, fakeData) if err != nil { t.Errorf("could not setup test: %v", err) } } trees := []*FileTree{lowerTree, upperTree, tree} _, failedPaths, err := StackTreeRange(trees, 0, 2) if len(failedPaths) > 0 { t.Errorf("expected no filepath errors, got %d", len(failedPaths)) } assert.NoError(t, err) } func TestRemoveOnIterate(t *testing.T) { tree := NewFileTree() paths := [...]string{"/etc", "/usr", "/etc/hosts", "/etc/sudoers", "/usr/bin", "/usr/something"} for _, value := range paths { fakeData := FileInfo{ Path: value, TypeFlag: 1, hash: 123, } node, _, err := tree.AddPath(value, fakeData) if err == nil && stringInSlice(node.Path(), []string{"/etc"}) { node.Data.ViewInfo.Hidden = true } } err := tree.VisitDepthChildFirst(func(node *FileNode) error { if node.Data.ViewInfo.Hidden { err := tree.RemovePath(node.Path()) if err != nil { t.Errorf("could not setup test: %v", err) } } return nil }, nil) if err != nil { t.Errorf("could not setup test: %v", err) } expected := `└── usr ├── bin └── something ` actual := tree.String(false) if expected != actual { t.Errorf("Expected tree string:\n--->%s<---\nGot:\n--->%s<---", expected, actual) } } ================================================ FILE: dive/filetree/node_data.go ================================================ package filetree var GlobalFileTreeCollapse bool // NodeData is the payload for a FileNode type NodeData struct { ViewInfo ViewInfo FileInfo FileInfo `json:"fileInfo"` DiffType DiffType } // NewNodeData creates an empty NodeData struct for a FileNode func NewNodeData() *NodeData { return &NodeData{ ViewInfo: *NewViewInfo(), FileInfo: FileInfo{}, DiffType: Unmodified, } } // Copy duplicates a NodeData func (data *NodeData) Copy() *NodeData { return &NodeData{ ViewInfo: *data.ViewInfo.Copy(), FileInfo: *data.FileInfo.Copy(), DiffType: data.DiffType, } } ================================================ FILE: dive/filetree/node_data_test.go ================================================ package filetree import ( "testing" ) func TestAssignDiffType(t *testing.T) { tree := NewFileTree() node, _, err := tree.AddPath("/usr", *BlankFileChangeInfo("/usr")) if err != nil { t.Errorf("Expected no error from fetching path. got: %v", err) } node.Data.DiffType = Modified if tree.Root.Children["usr"].Data.DiffType != Modified { t.Fail() } } func TestMergeDiffTypes(t *testing.T) { a := Unmodified b := Unmodified merged := a.merge(b) if merged != Unmodified { t.Errorf("Expected Unchanged (0) but got %v", merged) } a = Modified b = Unmodified merged = a.merge(b) if merged != Modified { t.Errorf("Expected Unchanged (0) but got %v", merged) } } func BlankFileChangeInfo(path string) (f *FileInfo) { result := FileInfo{ Path: path, TypeFlag: 1, hash: 123, } return &result } ================================================ FILE: dive/filetree/order_strategy.go ================================================ package filetree import ( "sort" ) type SortOrder int const ( ByName = iota BySizeDesc NumSortOrderConventions ) type OrderStrategy interface { orderKeys(files map[string]*FileNode) []string } func GetSortOrderStrategy(sortOrder SortOrder) OrderStrategy { switch sortOrder { case ByName: return orderByNameStrategy{} case BySizeDesc: return orderBySizeDescStrategy{} } return orderByNameStrategy{} } type orderByNameStrategy struct{} func (orderByNameStrategy) orderKeys(files map[string]*FileNode) []string { var keys []string for key := range files { keys = append(keys, key) } sort.Strings(keys) return keys } type orderBySizeDescStrategy struct{} func (orderBySizeDescStrategy) orderKeys(files map[string]*FileNode) []string { var keys []string for key := range files { keys = append(keys, key) } sort.Slice(keys, func(i, j int) bool { ki, kj := keys[i], keys[j] ni, nj := files[ki], files[kj] if ni.GetSize() == nj.GetSize() { return ki < kj } return ni.GetSize() > nj.GetSize() }) return keys } ================================================ FILE: dive/filetree/path_error.go ================================================ package filetree import "fmt" const ( ActionAdd FileAction = iota ActionRemove ) type FileAction int func (fa FileAction) String() string { switch fa { case ActionAdd: return "add" case ActionRemove: return "remove" default: return "" } } type PathError struct { Path string Action FileAction Err error } func NewPathError(path string, action FileAction, err error) PathError { return PathError{ Path: path, Action: action, Err: err, } } func (pe PathError) String() string { return fmt.Sprintf("unable to %s '%s': %+v", pe.Action.String(), pe.Path, pe.Err) } ================================================ FILE: dive/filetree/view_info.go ================================================ package filetree // ViewInfo contains UI specific detail for a specific FileNode type ViewInfo struct { Collapsed bool Hidden bool } // NewViewInfo creates a default ViewInfo func NewViewInfo() (view *ViewInfo) { return &ViewInfo{ Collapsed: GlobalFileTreeCollapse, Hidden: false, } } // Copy duplicates a ViewInfo func (view *ViewInfo) Copy() (newView *ViewInfo) { newView = NewViewInfo() *newView = *view return newView } ================================================ FILE: dive/get_image_resolver.go ================================================ package dive import ( "fmt" "strings" "github.com/wagoodman/dive/dive/image" "github.com/wagoodman/dive/dive/image/docker" "github.com/wagoodman/dive/dive/image/podman" ) const ( SourceUnknown ImageSource = iota SourceDockerEngine SourcePodmanEngine SourceDockerArchive ) type ImageSource int var ImageSources = []string{SourceDockerEngine.String(), SourcePodmanEngine.String(), SourceDockerArchive.String()} func (r ImageSource) String() string { return [...]string{"unknown", "docker", "podman", "docker-archive"}[r] } func ParseImageSource(r string) ImageSource { switch r { case SourceDockerEngine.String(): return SourceDockerEngine case SourcePodmanEngine.String(): return SourcePodmanEngine case SourceDockerArchive.String(): return SourceDockerArchive case "docker-tar": return SourceDockerArchive default: return SourceUnknown } } func DeriveImageSource(image string) (ImageSource, string) { s := strings.SplitN(image, "://", 2) if len(s) < 2 { return SourceUnknown, "" } scheme, imageSource := s[0], s[1] switch scheme { case SourceDockerEngine.String(): return SourceDockerEngine, imageSource case SourcePodmanEngine.String(): return SourcePodmanEngine, imageSource case SourceDockerArchive.String(): return SourceDockerArchive, imageSource case "docker-tar": return SourceDockerArchive, imageSource } return SourceUnknown, "" } func GetImageResolver(r ImageSource) (image.Resolver, error) { switch r { case SourceDockerEngine: return docker.NewResolverFromEngine(), nil case SourcePodmanEngine: return podman.NewResolverFromEngine(), nil case SourceDockerArchive: return docker.NewResolverFromArchive(), nil } return nil, fmt.Errorf("unable to determine image resolver") } ================================================ FILE: dive/image/analysis.go ================================================ package image import ( "context" "github.com/wagoodman/dive/dive/filetree" ) type Analysis struct { Image string Layers []*Layer RefTrees []*filetree.FileTree Efficiency float64 SizeBytes uint64 UserSizeByes uint64 // this is all bytes except for the base image WastedUserPercent float64 // = wasted-bytes/user-size-bytes WastedBytes uint64 Inefficiencies filetree.EfficiencySlice } func Analyze(ctx context.Context, img *Image) (*Analysis, error) { efficiency, inefficiencies := filetree.Efficiency(img.Trees) var sizeBytes, userSizeBytes uint64 for i, v := range img.Layers { sizeBytes += v.Size if i != 0 { userSizeBytes += v.Size } } var wastedBytes uint64 for _, file := range inefficiencies { wastedBytes += uint64(file.CumulativeSize) } return &Analysis{ Image: img.Request, Layers: img.Layers, RefTrees: img.Trees, Efficiency: efficiency, UserSizeByes: userSizeBytes, SizeBytes: sizeBytes, WastedBytes: wastedBytes, WastedUserPercent: float64(wastedBytes) / float64(userSizeBytes), Inefficiencies: inefficiencies, }, nil } ================================================ FILE: dive/image/docker/archive_resolver.go ================================================ package docker import ( "context" "fmt" "os" "github.com/wagoodman/dive/dive/image" ) type archiveResolver struct{} func NewResolverFromArchive() *archiveResolver { return &archiveResolver{} } // Name returns the name of the resolver to display to the user. func (r *archiveResolver) Name() string { return "docker-archive" } func (r *archiveResolver) Fetch(ctx context.Context, path string) (*image.Image, error) { reader, err := os.Open(path) if err != nil { return nil, err } defer reader.Close() img, err := NewImageArchive(reader) if err != nil { return nil, err } return img.ToImage(path) } func (r *archiveResolver) Build(ctx context.Context, args []string) (*image.Image, error) { return nil, fmt.Errorf("build option not supported for docker archive resolver") } func (r *archiveResolver) Extract(ctx context.Context, id string, l string, p string) error { return fmt.Errorf("not implemented") } ================================================ FILE: dive/image/docker/build.go ================================================ package docker import ( "fmt" "path/filepath" "strings" "github.com/scylladb/go-set/strset" "github.com/spf13/afero" ) const ( defaultDockerfileName = "Dockerfile" defaultContainerfileName = "Containerfile" ) func buildImageFromCli(fs afero.Fs, buildArgs []string) (string, error) { iidfile, err := afero.TempFile(fs, "", "dive.*.iid") if err != nil { return "", err } defer fs.Remove(iidfile.Name()) // nolint:errcheck defer iidfile.Close() var allArgs []string if isFileFlagsAreSet(buildArgs, "-f", "--file") { allArgs = append([]string{"--iidfile", iidfile.Name()}, buildArgs...) } else { containerFilePath, err := tryFindContainerfile(fs, buildArgs) if err != nil { return "", err } allArgs = append([]string{"--iidfile", iidfile.Name(), "-f", containerFilePath}, buildArgs...) } err = runDockerCmd("build", allArgs...) if err != nil { return "", err } imageId, err := afero.ReadFile(fs, iidfile.Name()) if err != nil { return "", err } return string(imageId), nil } // isFileFlagsAreSet Checks if specified flags are present in the argument list. func isFileFlagsAreSet(args []string, flags ...string) bool { flagSet := strset.New(flags...) for i, arg := range args { if flagSet.Has(arg) && i+1 < len(args) { return true } } return false } // tryFindContainerfile loops through provided build arguments and tries to find a Containerfile or a Dockerfile. func tryFindContainerfile(fs afero.Fs, buildArgs []string) (string, error) { // Look for a build context within the provided build arguments. // Test build arguments one by one to find a valid path containing default names of `Containerfile` or a `Dockerfile` (in that order). candidates := []string{ defaultContainerfileName, // Containerfile strings.ToLower(defaultContainerfileName), // containerfile defaultDockerfileName, // Dockerfile strings.ToLower(defaultDockerfileName), // dockerfile } for _, arg := range buildArgs { fileInfo, err := fs.Stat(arg) if err == nil && fileInfo.IsDir() { for _, candidate := range candidates { filePath := filepath.Join(arg, candidate) if exists, _ := afero.Exists(fs, filePath); exists { return filePath, nil } } } } return "", fmt.Errorf("could not find Containerfile or Dockerfile\n") } ================================================ FILE: dive/image/docker/build_test.go ================================================ package docker import ( "github.com/stretchr/testify/require" "path/filepath" "testing" "github.com/spf13/afero" "github.com/stretchr/testify/assert" ) func TestIsFileFlagsAreSet(t *testing.T) { tests := []struct { name string args []string flags []string expected bool }{ { name: "flag present in the middle with value", args: []string{"arg1", "-f", "dockerfile", "arg2"}, flags: []string{"-f"}, expected: true, }, { name: "flag present at the beginning with value", args: []string{"-f", "dockerfile", "arg1", "arg2"}, flags: []string{"-f"}, expected: true, }, { name: "flag present at the end with no value", args: []string{"arg1", "arg2", "-f"}, flags: []string{"-f"}, expected: false, }, { name: "flag not present", args: []string{"arg1", "arg2", "arg3"}, flags: []string{"-f"}, expected: false, }, { name: "one of multiple flags present", args: []string{"arg1", "--file", "dockerfile", "arg2"}, flags: []string{"-f", "--file"}, expected: true, }, { name: "none of multiple flags present", args: []string{"arg1", "-x", "value", "arg2"}, flags: []string{"-f", "--file"}, expected: false, }, { name: "empty args", args: []string{}, flags: []string{"-f", "--file"}, expected: false, }, { name: "empty flags", args: []string{"arg1", "-f", "value"}, flags: []string{}, expected: false, }, { name: "flag with multiple values", args: []string{"arg1", "-f", "value1", "value2"}, flags: []string{"-f"}, expected: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := isFileFlagsAreSet(tt.args, tt.flags...) assert.Equal(t, tt.expected, result, "isFileFlagsAreSet() = %v, want %v", result, tt.expected) }) } } func TestTryFindContainerfile(t *testing.T) { tests := []struct { name string buildArgs []string setupFs func(t testing.TB, fs afero.Fs) expectedPath string expectedErrMsg string }{ { name: "find Containerfile (uppercase)", buildArgs: []string{"testdir"}, setupFs: func(t testing.TB, fs afero.Fs) { create(t, fs, "testdir/Containerfile", "FROM alpine") }, expectedPath: filepath.Join("testdir", "Containerfile"), expectedErrMsg: "", }, { name: "find containerfile (lowercase)", buildArgs: []string{"testdir"}, setupFs: func(t testing.TB, fs afero.Fs) { create(t, fs, "testdir/containerfile", "FROM alpine") }, expectedPath: filepath.Join("testdir", "containerfile"), expectedErrMsg: "", }, { name: "find Dockerfile when no Containerfile exists", buildArgs: []string{"testdir"}, setupFs: func(t testing.TB, fs afero.Fs) { create(t, fs, "testdir/Dockerfile", "FROM alpine") }, expectedPath: filepath.Join("testdir", "Dockerfile"), expectedErrMsg: "", }, { name: "find dockerfile (lowercase)", buildArgs: []string{"testdir"}, setupFs: func(t testing.TB, fs afero.Fs) { create(t, fs, "testdir/dockerfile", "FROM alpine") }, expectedPath: filepath.Join("testdir", "dockerfile"), expectedErrMsg: "", }, { name: "prefer Containerfile over Dockerfile", buildArgs: []string{"testdir"}, setupFs: func(t testing.TB, fs afero.Fs) { create(t, fs, "testdir/Containerfile", "FROM alpine") create(t, fs, "testdir/Dockerfile", "FROM ubuntu") }, expectedPath: filepath.Join("testdir", "Containerfile"), expectedErrMsg: "", }, { name: "non-existent directory", buildArgs: []string{"nonexistentdir"}, setupFs: func(t testing.TB, fs afero.Fs) {}, expectedPath: "", expectedErrMsg: "could not find Containerfile or Dockerfile", }, { name: "empty build args", buildArgs: []string{}, setupFs: func(t testing.TB, fs afero.Fs) {}, expectedPath: "", expectedErrMsg: "could not find Containerfile or Dockerfile", }, { name: "directory exists but no container files", buildArgs: []string{"testdir"}, setupFs: func(t testing.TB, fs afero.Fs) { create(t, fs, "testdir/somefile.txt", "content") }, expectedPath: "", expectedErrMsg: "could not find Containerfile or Dockerfile", }, { name: "find in second directory", buildArgs: []string{"firstdir", "seconddir"}, setupFs: func(t testing.TB, fs afero.Fs) { err := fs.MkdirAll("firstdir", 0755) require.NoError(t, err, "Failed to create directory: firstdir") create(t, fs, "seconddir/Dockerfile", "FROM alpine") }, expectedPath: filepath.Join("seconddir", "Dockerfile"), expectedErrMsg: "", }, { name: "find in first directory when both have files", buildArgs: []string{"firstdir", "seconddir"}, setupFs: func(t testing.TB, fs afero.Fs) { create(t, fs, "firstdir/Containerfile", "FROM alpine") create(t, fs, "seconddir/Dockerfile", "FROM ubuntu") }, expectedPath: filepath.Join("firstdir", "Containerfile"), expectedErrMsg: "", }, { name: "file argument not a directory", buildArgs: []string{"testfile.txt"}, setupFs: func(t testing.TB, fs afero.Fs) { create(t, fs, "testfile.txt", "content") }, expectedPath: "", expectedErrMsg: "could not find Containerfile or Dockerfile", }, { name: "mixed args with valid directory", buildArgs: []string{"testfile.txt", "testdir"}, setupFs: func(t testing.TB, fs afero.Fs) { create(t, fs, "testfile.txt", "content") create(t, fs, "testdir/Dockerfile", "FROM alpine") }, expectedPath: filepath.Join("testdir", "Dockerfile"), expectedErrMsg: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { fs := afero.NewMemMapFs() tt.setupFs(t, fs) result, err := tryFindContainerfile(fs, tt.buildArgs) if tt.expectedErrMsg != "" { assert.Error(t, err) assert.Contains(t, err.Error(), tt.expectedErrMsg) } else { assert.NoError(t, err) assert.Equal(t, tt.expectedPath, result) } }) } } func create(t testing.TB, fs afero.Fs, path, contents string) { t.Helper() dir := filepath.Dir(path) if dir != "." { err := fs.MkdirAll(dir, 0755) require.NoError(t, err, "Failed to create directory: %s", dir) } err := afero.WriteFile(fs, path, []byte(contents), 0644) require.NoError(t, err, "Failed to write file: %s", path) } ================================================ FILE: dive/image/docker/cli.go ================================================ package docker import ( "fmt" "github.com/wagoodman/dive/internal/log" "github.com/wagoodman/dive/internal/utils" "os" "os/exec" "strings" ) // runDockerCmd runs a given Docker command in the current tty func runDockerCmd(cmdStr string, args ...string) error { if !isDockerClientBinaryAvailable() { return fmt.Errorf("cannot find docker client executable") } allArgs := utils.CleanArgs(append([]string{cmdStr}, args...)) fullCmd := strings.Join(append([]string{"docker"}, allArgs...), " ") log.WithFields("cmd", fullCmd).Trace("executing") cmd := exec.Command("docker", allArgs...) cmd.Env = os.Environ() cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Stdin = os.Stdin return cmd.Run() } func isDockerClientBinaryAvailable() bool { _, err := exec.LookPath("docker") return err == nil } ================================================ FILE: dive/image/docker/config.go ================================================ package docker import ( "encoding/json" "fmt" ) type config struct { History []historyEntry `json:"history"` RootFs rootFs `json:"rootfs"` } type rootFs struct { Type string `json:"type"` DiffIds []string `json:"diff_ids"` } type historyEntry struct { ID string Size uint64 Created string `json:"created"` Author string `json:"author"` CreatedBy string `json:"created_by"` EmptyLayer bool `json:"empty_layer"` } func newConfig(configBytes []byte) config { var imageConfig config err := json.Unmarshal(configBytes, &imageConfig) if err != nil { panic(fmt.Errorf("failed to unmarshal docker config: %w", err)) } layerIdx := 0 for idx := range imageConfig.History { if imageConfig.History[idx].EmptyLayer { imageConfig.History[idx].ID = "" } else { imageConfig.History[idx].ID = imageConfig.RootFs.DiffIds[layerIdx] layerIdx++ } } return imageConfig } func isConfig(configBytes []byte) bool { var imageConfig config err := json.Unmarshal(configBytes, &imageConfig) if err != nil { return false } return imageConfig.RootFs.Type == "layers" } ================================================ FILE: dive/image/docker/docker_host_unix.go ================================================ //go:build !windows package docker const ( defaultDockerHost = "unix:///var/run/docker.sock" ) ================================================ FILE: dive/image/docker/docker_host_windows.go ================================================ package docker const ( defaultDockerHost = "npipe:////.pipe/docker_engine" ) ================================================ FILE: dive/image/docker/engine_resolver.go ================================================ package docker import ( "fmt" "github.com/spf13/afero" "github.com/wagoodman/dive/internal/bus/event/payload" "github.com/wagoodman/dive/internal/log" "io" "net/http" "os" "strings" cliconfig "github.com/docker/cli/cli/config" "github.com/docker/cli/cli/connhelper" ddocker "github.com/docker/cli/cli/context/docker" ctxstore "github.com/docker/cli/cli/context/store" "github.com/docker/docker/client" "golang.org/x/net/context" "github.com/wagoodman/dive/dive/image" ) type engineResolver struct{} func NewResolverFromEngine() *engineResolver { return &engineResolver{} } // Name returns the name of the resolver to display to the user. func (r *engineResolver) Name() string { return "docker-engine" } func (r *engineResolver) Fetch(ctx context.Context, id string) (*image.Image, error) { reader, err := r.fetchArchive(ctx, id) if err != nil { return nil, err } defer reader.Close() img, err := NewImageArchive(reader) if err != nil { return nil, err } return img.ToImage(id) } func (r *engineResolver) Build(ctx context.Context, args []string) (*image.Image, error) { id, err := buildImageFromCli(afero.NewOsFs(), args) if err != nil { return nil, err } return r.Fetch(ctx, id) } func (r *engineResolver) Extract(ctx context.Context, id string, l string, p string) error { reader, err := r.fetchArchive(ctx, id) if err != nil { return err } if err := ExtractFromImage(io.NopCloser(reader), l, p); err == nil { return nil } return fmt.Errorf("unable to extract from image '%s': %+v", id, err) } func (r *engineResolver) fetchArchive(ctx context.Context, id string) (io.ReadCloser, error) { var err error var dockerClient *client.Client host, err := determineDockerHost() if err != nil { return nil, fmt.Errorf("could not determine docker host: %v", err) } clientOpts := []client.Opt{client.FromEnv} clientOpts = append(clientOpts, client.WithHost(host)) switch strings.Split(host, ":")[0] { case "ssh": helper, err := connhelper.GetConnectionHelper(host) if err != nil { return nil, fmt.Errorf("failed to get docker connection helper: %w", err) } clientOpts = append(clientOpts, func(c *client.Client) error { httpClient := &http.Client{ Transport: &http.Transport{ DialContext: helper.Dialer, }, } return client.WithHTTPClient(httpClient)(c) }) clientOpts = append(clientOpts, client.WithHost(host)) clientOpts = append(clientOpts, client.WithDialContext(helper.Dialer)) default: if os.Getenv("DOCKER_TLS_VERIFY") != "" && os.Getenv("DOCKER_CERT_PATH") == "" { os.Setenv("DOCKER_CERT_PATH", "~/.docker") } } clientOpts = append(clientOpts, client.WithAPIVersionNegotiation()) dockerClient, err = client.NewClientWithOpts(clientOpts...) if err != nil { return nil, err } _, err = dockerClient.ImageInspect(ctx, id) if err != nil { // check if the error is due to the image not existing locally if client.IsErrNotFound(err) { mon := payload.GetGenericProgressFromContext(ctx) if mon != nil { mon.AtomicStage.Set("attempting to pull") log.Debugf("the image is not available locally, pulling %q", id) } else { log.Infof("the image is not available locally, pulling %q", id) } err = runDockerCmd("pull", id) if err != nil { return nil, err } } else { // Some other error occurred, return it return nil, err } } readCloser, err := dockerClient.ImageSave(ctx, []string{id}) if err != nil { return nil, err } return readCloser, nil } // determineDockerHost tries to the determine the docker host that we should connect to // in the following order of decreasing precedence: // - value of "DOCKER_HOST" environment variable // - host retrieved from the current context (specified via DOCKER_CONTEXT) // - "default docker host" for the host operating system, otherwise func determineDockerHost() (string, error) { // If the docker host is explicitly set via the "DOCKER_HOST" environment variable, // then its a no-brainer :shrug: if os.Getenv("DOCKER_HOST") != "" { return os.Getenv("DOCKER_HOST"), nil } currentContext := os.Getenv("DOCKER_CONTEXT") if currentContext == "" { cf, err := cliconfig.Load(cliconfig.Dir()) if err != nil { return "", err } currentContext = cf.CurrentContext } if currentContext == "" { // If a docker context is neither specified via the "DOCKER_CONTEXT" environment variable nor via the // $HOME/.docker/config file, then we fall back to connecting to the "default docker host" meant for // the host operating system. return defaultDockerHost, nil } storeConfig := ctxstore.NewConfig( func() interface{} { return &ddocker.EndpointMeta{} }, ctxstore.EndpointTypeGetter(ddocker.DockerEndpoint, func() interface{} { return &ddocker.EndpointMeta{} }), ) st := ctxstore.New(cliconfig.ContextStoreDir(), storeConfig) md, err := st.GetMetadata(currentContext) if err != nil { return "", err } dockerEP, ok := md.Endpoints[ddocker.DockerEndpoint] if !ok { return "", err } dockerEPMeta, ok := dockerEP.(ddocker.EndpointMeta) if !ok { return "", fmt.Errorf("expected docker.EndpointMeta, got %T", dockerEP) } if dockerEPMeta.Host != "" { return dockerEPMeta.Host, nil } // We might end up here, if the context was created with the `host` set to an empty value (i.e. ''). // For example: // ```sh // docker context create foo --docker "host=" // ``` // In such scenario, we mimic the `docker` cli and try to connect to the "default docker host". return defaultDockerHost, nil } ================================================ FILE: dive/image/docker/image_archive.go ================================================ package docker import ( "archive/tar" "bytes" "compress/gzip" "encoding/json" "fmt" "io" "os" "path" "path/filepath" "strings" "github.com/klauspost/compress/zstd" "github.com/wagoodman/dive/dive/filetree" "github.com/wagoodman/dive/dive/image" ) type ImageArchive struct { manifest manifest config config layerMap map[string]*filetree.FileTree } func NewImageArchive(tarFile io.ReadCloser) (*ImageArchive, error) { img := &ImageArchive{ layerMap: make(map[string]*filetree.FileTree), } tarReader := tar.NewReader(tarFile) // store discovered json files in a map so we can read the image in one pass jsonFiles := make(map[string][]byte) var currentLayer uint for { header, err := tarReader.Next() if err == io.EOF { break } if err != nil { fmt.Println(err) os.Exit(1) } name := header.Name // some layer tars can be relative layer symlinks to other layer tars if header.Typeflag == tar.TypeSymlink || header.Typeflag == tar.TypeReg { // For the Docker image format, use file name conventions if strings.HasSuffix(name, ".tar") { currentLayer++ layerReader := tar.NewReader(tarReader) tree, err := processLayerTar(name, layerReader) if err != nil { return img, err } // add the layer to the image img.layerMap[tree.Name] = tree } else if strings.HasSuffix(name, ".tar.gz") || strings.HasSuffix(name, "tgz") { currentLayer++ // Add gzip reader gz, err := gzip.NewReader(tarReader) if err != nil { return img, err } // Add tar reader layerReader := tar.NewReader(gz) // Process layer tree, err := processLayerTar(name, layerReader) if err != nil { return img, err } // add the layer to the image img.layerMap[tree.Name] = tree } else if strings.HasSuffix(name, ".json") || strings.HasPrefix(name, "sha256:") { fileBuffer, err := io.ReadAll(tarReader) if err != nil { return img, err } jsonFiles[name] = fileBuffer } else if strings.HasPrefix(name, "blobs/") { // For the OCI-compatible image format (used since Docker 25), use mime sniffing // but limit this to only the blobs/ (containing the config, and the layers) // The idea here is that we try various formats in turn, and those tries should // never consume more bytes than this buffer contains so we can start again. // 512 bytes ought to be enough (as that's the size of a TAR entry header), // but play it safe with 1024 bytes. This should also include very small layers. buffer := make([]byte, 1024) n, err := io.ReadFull(tarReader, buffer) if err != nil && err != io.ErrUnexpectedEOF { return img, err } originalReader := func() io.Reader { return io.MultiReader(bytes.NewReader(buffer[:n]), tarReader) } // Try reading a gzip/estargz compressed layer gzipReader, err := gzip.NewReader(originalReader()) if err == nil { layerReader := tar.NewReader(gzipReader) tree, err := processLayerTar(name, layerReader) if err == nil { currentLayer++ // add the layer to the image img.layerMap[tree.Name] = tree continue } } // Try reading a zstd compressed layer zstdReader, err := zstd.NewReader(originalReader()) if err == nil { layerReader := tar.NewReader(zstdReader) tree, err := processLayerTar(name, layerReader) if err == nil { currentLayer++ // add the layer to the image img.layerMap[tree.Name] = tree continue } } // Try reading a plain tar layer layerReader := tar.NewReader(originalReader()) tree, err := processLayerTar(name, layerReader) if err == nil { currentLayer++ // add the layer to the image img.layerMap[tree.Name] = tree continue } // Not a TAR/GZIP/ZSTD, might be a JSON file decoder := json.NewDecoder(bytes.NewReader(buffer[:n])) token, err := decoder.Token() if _, ok := token.(json.Delim); err == nil && ok { // Looks like a JSON object (or array) // XXX: should we add a header.Size check too? fileBuffer, err := io.ReadAll(originalReader()) if err != nil { return img, err } jsonFiles[name] = fileBuffer } // Ignore every other unknown file type } } } manifestContent, exists := jsonFiles["manifest.json"] if exists { img.manifest = newManifest(manifestContent) } else { // manifest.json is not part of the OCI spec, docker includes it for compatibility // Provide compatibility by finding the config and using our layerMap var configPath string for path, content := range jsonFiles { if isConfig(content) { configPath = path break } } if len(configPath) == 0 { return img, fmt.Errorf("could not find image manifest") } var layerPaths []string for k := range img.layerMap { layerPaths = append(layerPaths, k) } img.manifest = manifest{ ConfigPath: configPath, LayerTarPaths: layerPaths, } } configContent, exists := jsonFiles[img.manifest.ConfigPath] if !exists { return img, fmt.Errorf("could not find image config") } img.config = newConfig(configContent) return img, nil } func processLayerTar(name string, reader *tar.Reader) (*filetree.FileTree, error) { tree := filetree.NewFileTree() tree.Name = name fileInfos, err := getFileList(reader) if err != nil { return nil, err } for _, element := range fileInfos { tree.FileSize += uint64(element.Size) _, _, err := tree.AddPath(element.Path, element) if err != nil { return nil, err } } return tree, nil } func getFileList(tarReader *tar.Reader) ([]filetree.FileInfo, error) { var files []filetree.FileInfo for { header, err := tarReader.Next() if err == io.EOF { break } else if err != nil { return nil, err } // always ensure relative path notations are not parsed as part of the filename name := path.Clean(header.Name) if name == "." { continue } switch header.Typeflag { case tar.TypeXGlobalHeader: return nil, fmt.Errorf("unexpected tar file: (XGlobalHeader): type=%v name=%s", header.Typeflag, name) case tar.TypeXHeader: return nil, fmt.Errorf("unexpected tar file (XHeader): type=%v name=%s", header.Typeflag, name) default: files = append(files, filetree.NewFileInfoFromTarHeader(tarReader, header, name)) } } return files, nil } func (img *ImageArchive) ToImage(id string) (*image.Image, error) { trees := make([]*filetree.FileTree, 0) // build the content tree for _, treeName := range img.manifest.LayerTarPaths { tr, exists := img.layerMap[treeName] if exists { trees = append(trees, tr) continue } return nil, fmt.Errorf("could not find '%s' in parsed layers", treeName) } // build the layers array layers := make([]*image.Layer, 0) // note that the engineResolver config stores images in reverse chronological order, so iterate backwards through layers // as you iterate chronologically through history (ignoring history items that have no layer contents) // Note: history is not required metadata in a docker image! histIdx := 0 for idx, tree := range trees { // ignore empty layers, we are only observing layers with content historyObj := historyEntry{ CreatedBy: "(missing)", } for nextHistIdx := histIdx; nextHistIdx < len(img.config.History); nextHistIdx++ { if !img.config.History[nextHistIdx].EmptyLayer { histIdx = nextHistIdx break } } if histIdx < len(img.config.History) && !img.config.History[histIdx].EmptyLayer { historyObj = img.config.History[histIdx] histIdx++ } historyObj.Size = tree.FileSize dockerLayer := layer{ history: historyObj, index: idx, tree: tree, } layers = append(layers, dockerLayer.ToLayer()) } return &image.Image{ Request: id, Trees: trees, Layers: layers, }, nil } func ExtractFromImage(tarFile io.ReadCloser, l string, p string) error { tarReader := tar.NewReader(tarFile) for { header, err := tarReader.Next() if err == io.EOF { break } if err != nil { fmt.Println(err) os.Exit(1) } name := header.Name switch header.Typeflag { case tar.TypeReg: if name == l { err = extractInner(tar.NewReader(tarReader), p) if err != nil { return err } return nil } default: continue } } return nil } func extractInner(reader *tar.Reader, p string) error { target := strings.TrimPrefix(p, "/") for { header, err := reader.Next() if err == io.EOF { break } if err != nil { fmt.Println(err) os.Exit(1) } name := header.Name switch header.Typeflag { case tar.TypeReg: if strings.HasPrefix(name, target) { err := os.MkdirAll(filepath.Dir(name), 0755) if err != nil { return err } out, err := os.Create(name) if err != nil { return err } _, err = io.Copy(out, reader) if err != nil { return err } } default: continue } } return nil } ================================================ FILE: dive/image/docker/image_archive_analysis_test.go ================================================ package docker import ( "testing" ) func Test_Analysis(t *testing.T) { table := map[string]struct { efficiency float64 sizeBytes uint64 userSizeBytes uint64 wastedBytes uint64 wastedPercent float64 path string }{ "docker-image": {0.9844212134184309, 1220598, 66237, 32025, 0.4834911001404049, "../../../.data/test-docker-image.tar"}, } for name, test := range table { result := TestAnalysisFromArchive(t, test.path) if result.SizeBytes != test.sizeBytes { t.Errorf("%s.%s: expected sizeBytes=%v, got %v", t.Name(), name, test.sizeBytes, result.SizeBytes) } if result.UserSizeByes != test.userSizeBytes { t.Errorf("%s.%s: expected userSizeBytes=%v, got %v", t.Name(), name, test.userSizeBytes, result.UserSizeByes) } if result.WastedBytes != test.wastedBytes { t.Errorf("%s.%s: expected wasterBytes=%v, got %v", t.Name(), name, test.wastedBytes, result.WastedBytes) } if result.WastedUserPercent != test.wastedPercent { t.Errorf("%s.%s: expected wastedPercent=%v, got %v", t.Name(), name, test.wastedPercent, result.WastedUserPercent) } if result.Efficiency != test.efficiency { t.Errorf("%s.%s: expected efficiency=%v, got %v", t.Name(), name, test.efficiency, result.Efficiency) } } } ================================================ FILE: dive/image/docker/layer.go ================================================ package docker import ( "strings" "github.com/wagoodman/dive/dive/filetree" "github.com/wagoodman/dive/dive/image" ) // Layer represents a Docker image layer and metadata type layer struct { history historyEntry index int tree *filetree.FileTree } // String represents a layer in a columnar format. func (l *layer) ToLayer() *image.Layer { id := strings.Split(l.tree.Name, "/")[0] return &image.Layer{ Id: id, Index: l.index, Command: strings.TrimPrefix(l.history.CreatedBy, "/bin/sh -c "), Size: l.history.Size, Tree: l.tree, // todo: query docker api for tags Names: []string{"(unavailable)"}, Digest: l.history.ID, } } ================================================ FILE: dive/image/docker/manifest.go ================================================ package docker import ( "encoding/json" "fmt" ) type manifest struct { ConfigPath string `json:"Config"` RepoTags []string `json:"RepoTags"` LayerTarPaths []string `json:"Layers"` } func newManifest(manifestBytes []byte) manifest { var manifest []manifest err := json.Unmarshal(manifestBytes, &manifest) if err != nil { panic(fmt.Errorf("failed to unmarshal manifest: %w", err)) } return manifest[0] } ================================================ FILE: dive/image/docker/testing.go ================================================ package docker import ( "github.com/stretchr/testify/require" "golang.org/x/net/context" "os" "testing" "github.com/wagoodman/dive/dive/image" ) func TestLoadArchive(t testing.TB, tarPath string) (*ImageArchive, error) { t.Helper() f, err := os.Open(tarPath) if err != nil { return nil, err } defer f.Close() return NewImageArchive(f) } func TestAnalysisFromArchive(t testing.TB, path string) *image.Analysis { t.Helper() archive, err := TestLoadArchive(t, path) require.NoError(t, err, "unable to load archive") img, err := archive.ToImage(path) require.NoError(t, err, "unable to convert archive to image") result, err := image.Analyze(context.Background(), img) require.NoError(t, err, "unable to analyze image") return result } ================================================ FILE: dive/image/image.go ================================================ package image import ( "github.com/wagoodman/dive/dive/filetree" ) type Image struct { Request string Trees []*filetree.FileTree Layers []*Layer } ================================================ FILE: dive/image/layer.go ================================================ package image import ( "fmt" "strings" "github.com/dustin/go-humanize" "github.com/wagoodman/dive/dive/filetree" ) const ( LayerFormat = "%7s %s" ) type Layer struct { Id string Index int Command string Size uint64 Tree *filetree.FileTree Names []string Digest string } func (l *Layer) ShortId() string { rangeBound := 15 id := l.Id if length := len(id); length < 15 { rangeBound = length } id = id[0:rangeBound] return id } func (l *Layer) commandPreview() string { // Layers using heredocs can be multiple lines; rendering relies on // Layer.String to be a single line. return strings.Replace(l.Command, "\n", "↵", -1) } func (l *Layer) String() string { if l.Index == 0 { return fmt.Sprintf(LayerFormat, humanize.Bytes(l.Size), "FROM "+l.ShortId()) } return fmt.Sprintf(LayerFormat, humanize.Bytes(l.Size), l.commandPreview()) } ================================================ FILE: dive/image/podman/build.go ================================================ //go:build linux || darwin package podman import ( "os" ) func buildImageFromCli(buildArgs []string) (string, error) { iidfile, err := os.CreateTemp("/tmp", "dive.*.iid") if err != nil { return "", err } defer os.Remove(iidfile.Name()) defer iidfile.Close() allArgs := append([]string{"--iidfile", iidfile.Name()}, buildArgs...) err = runPodmanCmd("build", allArgs...) if err != nil { return "", err } imageId, err := os.ReadFile(iidfile.Name()) if err != nil { return "", err } return string(imageId), nil } ================================================ FILE: dive/image/podman/cli.go ================================================ //go:build linux || darwin package podman import ( "fmt" "github.com/wagoodman/dive/internal/log" "github.com/wagoodman/dive/internal/utils" "io" "os" "os/exec" "strings" ) // runPodmanCmd runs a given Podman command in the current tty func runPodmanCmd(cmdStr string, args ...string) error { if !isPodmanClientBinaryAvailable() { return fmt.Errorf("cannot find podman client executable") } allArgs := utils.CleanArgs(append([]string{cmdStr}, args...)) fullCmd := strings.Join(append([]string{"docker"}, allArgs...), " ") log.WithFields("cmd", fullCmd).Trace("executing") cmd := exec.Command("podman", allArgs...) cmd.Env = os.Environ() cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Stdin = os.Stdin return cmd.Run() } func streamPodmanCmd(args ...string) (error, io.Reader) { if !isPodmanClientBinaryAvailable() { return fmt.Errorf("cannot find podman client executable"), nil } allArgs := utils.CleanArgs(args) fullCmd := strings.Join(append([]string{"docker"}, allArgs...), " ") log.WithFields("cmd", fullCmd).Trace("executing (streaming)") cmd := exec.Command("podman", allArgs...) cmd.Env = os.Environ() reader, writer, err := os.Pipe() if err != nil { return err, nil } defer writer.Close() cmd.Stdout = writer cmd.Stderr = os.Stderr return cmd.Start(), reader } func isPodmanClientBinaryAvailable() bool { _, err := exec.LookPath("podman") return err == nil } ================================================ FILE: dive/image/podman/resolver.go ================================================ //go:build linux || darwin package podman import ( "context" "fmt" "io" "github.com/wagoodman/dive/dive/image" "github.com/wagoodman/dive/dive/image/docker" ) type resolver struct{} func NewResolverFromEngine() *resolver { return &resolver{} } // Name returns the name of the resolver to display to the user. func (r *resolver) Name() string { return "podman" } func (r *resolver) Build(ctx context.Context, args []string) (*image.Image, error) { id, err := buildImageFromCli(args) if err != nil { return nil, err } return r.Fetch(ctx, id) } func (r *resolver) Fetch(ctx context.Context, id string) (*image.Image, error) { // todo: add podman fetch attempt via varlink first... img, err := r.resolveFromDockerArchive(id) if err == nil { return img, err } return nil, fmt.Errorf("unable to resolve image %q: %+v", id, err) } func (r *resolver) Extract(ctx context.Context, id string, l string, p string) error { // todo: add podman fetch attempt via varlink first... err, reader := streamPodmanCmd("image", "save", id) if err != nil { return err } if err := docker.ExtractFromImage(io.NopCloser(reader), l, p); err == nil { return nil } return fmt.Errorf("unable to extract from image %q: %+v", id, err) } func (r *resolver) resolveFromDockerArchive(id string) (*image.Image, error) { err, reader := streamPodmanCmd("image", "save", id) if err != nil { return nil, err } img, err := docker.NewImageArchive(io.NopCloser(reader)) if err != nil { return nil, err } return img.ToImage(id) } ================================================ FILE: dive/image/podman/resolver_unsupported.go ================================================ //go:build !linux && !darwin // +build !linux,!darwin package podman import ( "context" "fmt" "github.com/wagoodman/dive/dive/image" ) type resolver struct{} func NewResolverFromEngine() *resolver { return &resolver{} } // Name returns the name of the resolver to display to the user. func (r *resolver) Name() string { return "podman" } func (r *resolver) Build(ctx context.Context, args []string) (*image.Image, error) { return nil, fmt.Errorf("unsupported platform") } func (r *resolver) Fetch(ctx context.Context, id string) (*image.Image, error) { return nil, fmt.Errorf("unsupported platform") } func (r *resolver) Extract(ctx context.Context, id string, l string, p string) error { return fmt.Errorf("unsupported platform") } ================================================ FILE: dive/image/resolver.go ================================================ package image import "golang.org/x/net/context" type Resolver interface { Name() string Fetch(ctx context.Context, id string) (*Image, error) Build(ctx context.Context, options []string) (*Image, error) ContentReader } type ContentReader interface { Extract(ctx context.Context, id string, layer string, path string) error } ================================================ FILE: go.mod ================================================ module github.com/wagoodman/dive go 1.24 require ( github.com/anchore/clio v0.0.0-20250401141128-4c1d6bd1e872 github.com/anchore/go-logger v0.0.0-20250318195838-07ae343dd722 github.com/awesome-gocui/gocui v1.1.0 github.com/awesome-gocui/keybinding v1.0.1-0.20211011072933-86029037a63f github.com/cespare/xxhash/v2 v2.3.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/docker/cli v28.1.1+incompatible github.com/docker/docker v28.1.1+incompatible github.com/dustin/go-humanize v1.0.1 github.com/fatih/color v1.18.0 github.com/gkampitakis/go-snaps v0.5.11 github.com/google/go-cmp v0.7.0 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/google/uuid v1.6.0 github.com/klauspost/compress v1.18.0 github.com/lunixbochs/vtclean v1.0.0 github.com/muesli/termenv v0.16.0 github.com/phayes/permbits v0.0.0-20190612203442-39d7c581d2ee github.com/scylladb/go-set v1.0.2 github.com/spf13/afero v1.14.0 github.com/spf13/cobra v1.9.1 github.com/stretchr/testify v1.10.0 github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651 github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0 go.uber.org/atomic v1.11.0 golang.org/x/net v0.40.0 gopkg.in/yaml.v3 v3.0.1 ) require ( dario.cat/mergo v1.0.1 // indirect github.com/Microsoft/go-winio v0.4.14 // indirect github.com/adrg/xdg v0.5.3 // indirect github.com/anchore/fangs v0.0.0-20250402135612-96e29e45f3fe // indirect github.com/anchore/go-homedir v0.0.0-20250319154043-c29668562e4d // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/ansi v0.8.0 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/containerd/log v0.1.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/docker-credential-helpers v0.8.2 // indirect github.com/docker/go-connections v0.4.0 // indirect github.com/docker/go-units v0.4.0 // indirect github.com/felixge/fgprof v0.9.3 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/fvbommel/sortorder v1.1.0 // indirect github.com/gdamore/encoding v1.0.0 // indirect github.com/gdamore/tcell/v2 v2.4.0 // indirect github.com/gkampitakis/ciinfo v0.3.1 // indirect github.com/gkampitakis/go-diff v1.3.2 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/goccy/go-yaml v1.15.13 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/pprof v0.0.0-20211214055906-6f57359322fd // indirect github.com/gookit/color v1.5.4 // indirect github.com/hashicorp/errwrap v1.0.0 // indirect github.com/hashicorp/go-multierror v1.1.0 // indirect github.com/iancoleman/strcase v0.3.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/maruel/natural v1.1.1 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/moby/sys/sequential v0.6.0 // indirect github.com/moby/term v0.5.0 // indirect github.com/morikuni/aec v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.0.2 // indirect github.com/pborman/indent v1.2.1 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pkg/profile v1.7.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/sagikazarmark/locafero v0.7.0 // indirect github.com/sergi/go-diff v1.3.1 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/spf13/viper v1.20.1 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect go.opentelemetry.io/otel v1.31.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 // indirect go.opentelemetry.io/otel/metric v1.31.0 // indirect go.opentelemetry.io/otel/sdk v1.31.0 // indirect go.opentelemetry.io/otel/trace v1.31.0 // indirect go.uber.org/multierr v1.9.0 // indirect golang.org/x/sys v0.33.0 // indirect golang.org/x/term v0.32.0 // indirect golang.org/x/text v0.25.0 // indirect golang.org/x/time v0.11.0 // indirect gotest.tools/v3 v3.5.0 // indirect ) ================================================ FILE: go.sum ================================================ dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU= github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/anchore/clio v0.0.0-20250401141128-4c1d6bd1e872 h1:iEF0xhHUuh3J8FrlPsZAQVaMpTa2j4lvLRI5XrXzge4= github.com/anchore/clio v0.0.0-20250401141128-4c1d6bd1e872/go.mod h1:Utb9i4kwiCWvqAIxZaJeMIXFO9uOgQXlvH2BfbfO/zI= github.com/anchore/fangs v0.0.0-20250402135612-96e29e45f3fe h1:qv/xxpjF5RdKPqZjx8RM0aBi3HUCAO0DhRBMs2xhY1I= github.com/anchore/fangs v0.0.0-20250402135612-96e29e45f3fe/go.mod h1:vrcYMDps9YXwwx2a9AsvipM6Fi5H9//9bymGb8G8BIQ= github.com/anchore/go-homedir v0.0.0-20250319154043-c29668562e4d h1:gT69osH9AsdpOfqxbRwtxcNnSZ1zg4aKy2BevO3ZBdc= github.com/anchore/go-homedir v0.0.0-20250319154043-c29668562e4d/go.mod h1:PhSnuFYknwPZkOWKB1jXBNToChBA+l0FjwOxtViIc50= github.com/anchore/go-logger v0.0.0-20250318195838-07ae343dd722 h1:2SqmFgE7h+Ql4VyBzhjLkRF/3gDrcpUBj8LjvvO6OOM= github.com/anchore/go-logger v0.0.0-20250318195838-07ae343dd722/go.mod h1:oFuE8YuTCM+spgMXhePGzk3asS94yO9biUfDzVTFqNw= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/awesome-gocui/gocui v0.5.0/go.mod h1:1QikxFaPhe2frKeKvEwZEIGia3haiOxOUXKinrv17mA= github.com/awesome-gocui/gocui v1.1.0 h1:db2j7yFEoHZjpQFeE2xqiatS8bm1lO3THeLwE6MzOII= github.com/awesome-gocui/gocui v1.1.0/go.mod h1:M2BXkrp7PR97CKnPRT7Rk0+rtswChPtksw/vRAESGpg= github.com/awesome-gocui/keybinding v1.0.1-0.20211011072933-86029037a63f h1:u5xQfLwWC98BFToYDifqEcgK2ht2FFlbvRlzRnMb0cQ= github.com/awesome-gocui/keybinding v1.0.1-0.20211011072933-86029037a63f/go.mod h1:z0TyCwIhaT97yU+becTse8Dqh2CvYT0FLw0R0uTk0ag= github.com/awesome-gocui/termbox-go v0.0.0-20190427202837-c0aef3d18bcc/go.mod h1:tOy3o5Nf1bA17mnK4W41gD7PS3u4Cv0P0pqFcoWMy8s= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/cli v28.1.1+incompatible h1:eyUemzeI45DY7eDPuwUcmDyDj1pM98oD5MdSpiItp8k= github.com/docker/cli v28.1.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker v28.1.1+incompatible h1:49M11BFLsVO1gxY9UX9p/zwkE/rswggs8AdFmXQw51I= github.com/docker/docker v28.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo= github.com/docker/docker-credential-helpers v0.8.2/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M= github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw= github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fatih/set v0.2.1 h1:nn2CaJyknWE/6txyUDGwysr3G5QC6xWB/PtVjPBbeaA= github.com/fatih/set v0.2.1/go.mod h1:+RKtMCH+favT2+3YecHGxcc0b4KyVWA1QWWJUs4E0CI= github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fvbommel/sortorder v1.1.0 h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQmYw= github.com/fvbommel/sortorder v1.1.0/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0= github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= github.com/gdamore/tcell/v2 v2.4.0 h1:W6dxJEmaxYvhICFoTY3WrLLEXsQ11SaFnKGVEXW57KM= github.com/gdamore/tcell/v2 v2.4.0/go.mod h1:cTTuF84Dlj/RqmaCIV5p4w8uG1zWdk0SF6oBpwHp4fU= github.com/gkampitakis/ciinfo v0.3.1 h1:lzjbemlGI4Q+XimPg64ss89x8Mf3xihJqy/0Mgagapo= github.com/gkampitakis/ciinfo v0.3.1/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= github.com/gkampitakis/go-snaps v0.5.11 h1:LFG0ggUKR+KEiiaOvFCmLgJ5NO2zf93AxxddkBn3LdQ= github.com/gkampitakis/go-snaps v0.5.11/go.mod h1:PcKmy8q5Se7p48ywpogN5Td13reipz1Iivah4wrTIvY= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-yaml v1.15.13 h1:Xd87Yddmr2rC1SLLTm2MNDcTjeO/GYo0JGiww6gSTDg= github.com/goccy/go-yaml v1.15.13/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y= github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gookit/color v1.2.5/go.mod h1:AhIE+pS6D4Ql0SQWbBeXPHw7gY0/sjHoA4s/n1KB7xg= github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys= github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.0 h1:B9UzwGQJehnUY1yNrnwREHc3fGbC2xefo8g4TbElacI= github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lunixbochs/vtclean v1.0.0 h1:xu2sLAri4lGiovBDQKxl5mrXyESr3gUr5m5SM5+LVb8= github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM= github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= github.com/pborman/indent v1.2.1 h1:lFiviAbISHv3Rf0jcuh489bi06hj98JsVMtIDZQb9yM= github.com/pborman/indent v1.2.1/go.mod h1:FitS+t35kIYtB5xWTZAPhnmrxcciEEOdbyrrpz5K6Vw= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/phayes/permbits v0.0.0-20190612203442-39d7c581d2ee h1:P6U24L02WMfj9ymZTxl7CxS73JC99x3ukk+DBkgQGQs= github.com/phayes/permbits v0.0.0-20190612203442-39d7c581d2ee/go.mod h1:3uODdxMgOaPYeWU7RzZLxVtJHZ/x1f/iHkBZuKJDzuY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= github.com/scylladb/go-set v1.0.2 h1:SkvlMCKhP0wyyct6j+0IHJkBkSZL+TDzZ4E7f7BCcRE= github.com/scylladb/go-set v1.0.2/go.mod h1:DkpGd78rljTxKAnTDPFqXSGxvETQnJyuSOQwsHycqfs= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651 h1:jIVmlAFIqV3d+DOxazTR9v+zgj8+VYuQBzPgBZvWBHA= github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651/go.mod h1:b26F2tHLqaoRQf8DywqzVaV1MQ9yvjb0OMcNl7Nxu20= github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0 h1:0KGbf+0SMg+UFy4e1A/CPVvXn21f1qtWdeJwxZFoQG8= github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0/go.mod h1:jLXFoL31zFaHKAAyZUh+sxiTDFe1L1ZHrcK2T1itVKA= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 h1:K0XaT3DwHAcV4nKLzcQvwAgSyisUghWoY20I7huthMk= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0/go.mod h1:B5Ki776z/MBnVha1Nzwp5arlzBbE3+1jk+pGmaP5HME= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 h1:lUsI2TYsQw2r1IASwoROaCnjdj2cvC2+Jbxvk6nHnWU= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0/go.mod h1:2HpZxxQurfGxJlJDblybejHB6RX6pmExPNe517hREw4= go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9 h1:T6rh4haD3GVYsgEfWExoCZA2o2FmbNyKpTuAxbEFPTg= google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9/go.mod h1:wp2WsuBYj6j8wUdo3ToZsdxxixbvQNAHqVJrTgi5E5M= google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9 h1:QCqS/PdaHTSWGvupk2F/ehwHtGc0/GYkT+3GAcR1CCc= google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.0 h1:Ljk6PdHdOhAb5aDMWXjDLMMhph+BpztA4v1QdqEW2eY= gotest.tools/v3 v3.5.0/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= ================================================ FILE: internal/bus/bus.go ================================================ package bus import "github.com/wagoodman/go-partybus" var publisher partybus.Publisher func Set(p partybus.Publisher) { publisher = p } func Get() partybus.Publisher { return publisher } func Publish(e partybus.Event) { if publisher != nil { publisher.Publish(e) } } ================================================ FILE: internal/bus/event/event.go ================================================ package event import ( "github.com/wagoodman/go-partybus" ) const ( typePrefix = "dive-cli" // TaskStarted encompasses all events that are related to the analysis of a docker image (build, fetch, analyze) TaskStarted partybus.EventType = typePrefix + "-task-started" // ExploreAnalysis is a partybus event that occurs when an analysis result is ready for presentation to stdout ExploreAnalysis partybus.EventType = typePrefix + "-analysis" // Report is a partybus event that occurs when an analysis result is ready for final presentation to stdout Report partybus.EventType = typePrefix + "-report" // Notification is a partybus event that occurs when auxiliary information is ready for presentation to stderr Notification partybus.EventType = typePrefix + "-notification" ) ================================================ FILE: internal/bus/event/parser/parsers.go ================================================ package parser import ( "fmt" "github.com/wagoodman/dive/dive/image" "github.com/wagoodman/dive/internal/bus/event" "github.com/wagoodman/dive/internal/bus/event/payload" "github.com/wagoodman/go-partybus" "github.com/wagoodman/go-progress" ) type ErrBadPayload struct { Type partybus.EventType Field string Value interface{} } func (e *ErrBadPayload) Error() string { return fmt.Sprintf("event='%s' has bad event payload field=%q: %q", string(e.Type), e.Field, e.Value) } func newPayloadErr(t partybus.EventType, field string, value interface{}) error { return &ErrBadPayload{ Type: t, Field: field, Value: value, } } func checkEventType(actual, expected partybus.EventType) error { if actual != expected { return newPayloadErr(expected, "Type", actual) } return nil } func ParseTaskStarted(e partybus.Event) (progress.StagedProgressable, *payload.GenericTask, error) { if err := checkEventType(e.Type, event.TaskStarted); err != nil { return nil, nil, err } var mon progress.StagedProgressable source, ok := e.Source.(payload.GenericTask) if !ok { return nil, nil, newPayloadErr(e.Type, "Source", e.Source) } mon, ok = e.Value.(progress.StagedProgressable) if !ok { mon = nil } return mon, &source, nil } func ParseExploreAnalysis(e partybus.Event) (image.Analysis, image.ContentReader, error) { if err := checkEventType(e.Type, event.ExploreAnalysis); err != nil { return image.Analysis{}, nil, err } ex, ok := e.Value.(payload.Explore) if !ok { return image.Analysis{}, nil, newPayloadErr(e.Type, "Value", e.Value) } return ex.Analysis, ex.Content, nil } func ParseReport(e partybus.Event) (string, string, error) { if err := checkEventType(e.Type, event.Report); err != nil { return "", "", err } context, ok := e.Source.(string) if !ok { // this is optional context = "" } report, ok := e.Value.(string) if !ok { return "", "", newPayloadErr(e.Type, "Value", e.Value) } return context, report, nil } func ParseNotification(e partybus.Event) (string, string, error) { if err := checkEventType(e.Type, event.Notification); err != nil { return "", "", err } context, ok := e.Source.(string) if !ok { // this is optional context = "" } notification, ok := e.Value.(string) if !ok { return "", "", newPayloadErr(e.Type, "Value", e.Value) } return context, notification, nil } ================================================ FILE: internal/bus/event/payload/explore.go ================================================ package payload import "github.com/wagoodman/dive/dive/image" type Explore struct { Analysis image.Analysis Content image.ContentReader } ================================================ FILE: internal/bus/event/payload/generic.go ================================================ package payload import ( "context" "github.com/wagoodman/go-progress" ) type genericProgressKey struct{} func SetGenericProgressToContext(ctx context.Context, mon *GenericProgress) context.Context { return context.WithValue(ctx, genericProgressKey{}, mon) } func GetGenericProgressFromContext(ctx context.Context) *GenericProgress { mon, ok := ctx.Value(genericProgressKey{}).(*GenericProgress) if !ok { return nil } return mon } type GenericTask struct { // required fields Title Title // optional format fields HideOnSuccess bool HideStageOnSuccess bool // optional fields ID string ParentID string Context string } type GenericProgress struct { *progress.AtomicStage *progress.Manual } type Title struct { Default string WhileRunning string OnSuccess string } ================================================ FILE: internal/bus/helpers.go ================================================ package bus import ( "github.com/wagoodman/dive/dive/image" "github.com/wagoodman/dive/internal/bus/event" "github.com/wagoodman/dive/internal/bus/event/payload" "github.com/wagoodman/go-partybus" "github.com/wagoodman/go-progress" ) func Report(report string) { if len(report) == 0 { return } Publish(partybus.Event{ Type: event.Report, Value: report, }) } func Notify(message string) { Publish(partybus.Event{ Type: event.Notification, Value: message, }) } func StartTask(info payload.GenericTask) *payload.GenericProgress { t := &payload.GenericProgress{ AtomicStage: progress.NewAtomicStage(""), Manual: progress.NewManual(-1), } Publish(partybus.Event{ Type: event.TaskStarted, Source: info, Value: progress.StagedProgressable(t), }) return t } func StartSizedTask(info payload.GenericTask, size int64, initialStage string) *payload.GenericProgress { t := &payload.GenericProgress{ AtomicStage: progress.NewAtomicStage(initialStage), Manual: progress.NewManual(size), } Publish(partybus.Event{ Type: event.TaskStarted, Source: info, Value: progress.StagedProgressable(t), }) return t } func ExploreAnalysis(analysis image.Analysis, reader image.ContentReader) { Publish(partybus.Event{ Type: event.ExploreAnalysis, Value: payload.Explore{Analysis: analysis, Content: reader}, }) } ================================================ FILE: internal/log/log.go ================================================ package log import ( "github.com/anchore/go-logger" "github.com/anchore/go-logger/adapter/discard" ) // log is the singleton used to facilitate logging internally within var log = discard.New() // Set replaces the default logger with the provided logger. func Set(l logger.Logger) { log = l } // Get returns the current logger instance. func Get() logger.Logger { return log } // Errorf takes a formatted template string and template arguments for the error logging level. func Errorf(format string, args ...interface{}) { log.Errorf(format, args...) } // Error logs the given arguments at the error logging level. func Error(args ...interface{}) { log.Error(args...) } // Warnf takes a formatted template string and template arguments for the warning logging level. func Warnf(format string, args ...interface{}) { log.Warnf(format, args...) } // Warn logs the given arguments at the warning logging level. func Warn(args ...interface{}) { log.Warn(args...) } // Infof takes a formatted template string and template arguments for the info logging level. func Infof(format string, args ...interface{}) { log.Infof(format, args...) } // Info logs the given arguments at the info logging level. func Info(args ...interface{}) { log.Info(args...) } // Debugf takes a formatted template string and template arguments for the debug logging level. func Debugf(format string, args ...interface{}) { log.Debugf(format, args...) } // Debug logs the given arguments at the debug logging level. func Debug(args ...interface{}) { log.Debug(args...) } // Tracef takes a formatted template string and template arguments for the trace logging level. func Tracef(format string, args ...interface{}) { log.Tracef(format, args...) } // Trace logs the given arguments at the trace logging level. func Trace(args ...interface{}) { log.Trace(args...) } // WithFields returns a message logger with multiple key-value fields. func WithFields(fields ...interface{}) logger.MessageLogger { return log.WithFields(fields...) } // Nested returns a new logger with hard coded key-value pairs func Nested(fields ...interface{}) logger.Logger { return log.Nested(fields...) } ================================================ FILE: internal/utils/format.go ================================================ package utils import ( "strings" ) // CleanArgs trims the whitespace from the given set of strings. func CleanArgs(s []string) []string { var r []string for _, str := range s { if str != "" { r = append(r, strings.Trim(str, " ")) } } return r } ================================================ FILE: internal/utils/view.go ================================================ package utils import ( "errors" "github.com/awesome-gocui/gocui" "github.com/wagoodman/dive/internal/log" ) // IsNewView determines if a view has already been created based on the set of errors given (a bit hokie) func IsNewView(errs ...error) bool { for _, err := range errs { if err == nil { return false } if !errors.Is(err, gocui.ErrUnknownView) { log.WithFields("error", err).Error("IsNewView() unexpected error") return true } } return true }