├── examples ├── .gitignore └── basic.go ├── test └── integration │ ├── tools │ ├── .gitignore │ ├── singularity │ │ ├── .dockerignore │ │ ├── Dockerfile │ │ └── Makefile │ └── Makefile │ ├── test-fixtures │ ├── .gitignore │ ├── image-symlinks │ │ ├── file-1.txt │ │ ├── file-2.txt │ │ ├── new-file-2.txt │ │ └── Dockerfile │ ├── image-simple │ │ ├── file-2.txt │ │ ├── file-1.txt │ │ ├── target │ │ │ └── really │ │ │ │ └── nested │ │ │ │ └── file-3.txt │ │ └── Dockerfile │ ├── image-many-layers │ │ ├── file.txt │ │ ├── a-dir │ │ │ ├── .wa │ │ │ └── content.txt │ │ └── Dockerfile │ ├── registry │ │ ├── .gitignore │ │ ├── loader │ │ │ └── Dockerfile │ │ ├── docker-compose.yaml │ │ ├── test.go │ │ └── Makefile │ ├── podman │ │ ├── .dockerignore │ │ ├── .gitignore │ │ ├── setup.service │ │ ├── Dockerfile │ │ ├── setup.sh │ │ └── Makefile │ ├── Makefile │ └── image-opaque-directory │ │ └── Dockerfile │ ├── fixture_image_opaque_directory_test.go │ ├── mime_type_detection_test.go │ ├── registry_self_signed_cert_test.go │ ├── oci_registry_source_test.go │ └── podman_test.go ├── pkg ├── file │ ├── test-fixtures │ │ ├── tar-cache │ │ │ └── .gitkeep │ │ ├── symlinks-simple │ │ │ ├── link_to_new_readme │ │ │ ├── link_to_link_to_new_readme │ │ │ └── readme │ │ ├── a-file.txt │ │ ├── mime │ │ │ ├── mach-binary │ │ │ └── capture.sh │ │ └── generators │ │ │ └── fixture-1.sh │ ├── opener.go │ ├── size.go │ ├── get_xid_win.go │ ├── path_stack.go │ ├── get_xid.go │ ├── id.go │ ├── tar_index_entry.go │ ├── references.go │ ├── reference_set.go │ ├── reference.go │ ├── mime_type_test.go │ ├── mime_type.go │ ├── id_set.go │ ├── squashfs_walk.go │ ├── temp_dir_generator.go │ ├── lazy_read_closer.go │ ├── lazy_read_closer_test.go │ ├── path_set.go │ ├── tar_index.go │ ├── temp_dir_generator_test.go │ ├── type.go │ ├── lazy_bounded_read_closer_test.go │ ├── path.go │ └── lazy_bounded_read_closer.go ├── image │ ├── docker │ │ ├── test-fixtures │ │ │ ├── empty-file │ │ │ ├── no-descriptors.json │ │ │ ├── single-blank-manifest.json │ │ │ ├── valid-multi-manifest-with-tags.json │ │ │ └── snapshot │ │ │ │ └── TestAssembleOCIManifest.golden │ │ └── tarball_provider.go │ ├── test-fixtures │ │ ├── tar-cache │ │ │ └── .gitkeep │ │ ├── generators │ │ │ ├── fixture-1.sh │ │ │ └── fixture-2.sh │ │ └── certs │ │ │ └── server.crt │ ├── oci │ │ ├── test-fixtures │ │ │ ├── invalid_file │ │ │ │ └── index.json │ │ │ ├── valid_oci_dir │ │ │ │ ├── oci-layout │ │ │ │ ├── blobs │ │ │ │ │ └── sha256 │ │ │ │ │ │ ├── 00ffd085e9e7c06d20fdc61119a8d08e5c8bd3c1c320d10494ce6ed86691c06c │ │ │ │ │ │ ├── c1ed04a3da941a5dd09b58b16c37f065557863d382ef97995ddac885a8452ebb │ │ │ │ │ │ └── 61ee19e869a529075634f762c1eb191962777e0803598758cbf076edfadfb046 │ │ │ │ └── index.json │ │ │ ├── valid_manifest │ │ │ │ ├── blobs │ │ │ │ │ └── sha256 │ │ │ │ │ │ └── f67dcc5fc786f04f0743abfe0ee5dae9bd8caf8efa6c8144f7f2a43889dc513b │ │ │ │ └── index.json │ │ │ ├── valid-oci.tar │ │ │ ├── no_manifests │ │ │ │ └── index.json │ │ │ └── valid_manifest_equal_digests │ │ │ │ └── index.json │ │ ├── credhelpers │ │ │ ├── helper.go │ │ │ ├── ecr_helper.go │ │ │ ├── ecr_helper_test.go │ │ │ ├── gcr_helper.go │ │ │ └── gcr_helper_test.go │ │ ├── tarball_provider_test.go │ │ ├── directory_provider_test.go │ │ ├── tarball_provider.go │ │ └── directory_provider.go │ ├── sif │ │ ├── test-fixtures │ │ │ ├── fifo.sif │ │ │ ├── empty.sif │ │ │ └── one-group.sif │ │ ├── archive_provider_test.go │ │ ├── image_test.go │ │ └── archive_provider.go │ ├── source.go │ ├── parse_reference.go │ ├── podman │ │ └── daemon_provider.go │ ├── content_helpers.go │ ├── provider.go │ ├── layer_metadata.go │ ├── parse_reference_test.go │ ├── image_metadata.go │ ├── containerd │ │ ├── jobs.go │ │ └── pull_status.go │ ├── platform_test.go │ ├── file_catalog.go │ ├── registry_credentials.go │ ├── image_test.go │ └── layer_test.go ├── tree │ ├── node │ │ ├── node.go │ │ ├── stack.go │ │ ├── nodes.go │ │ ├── queue.go │ │ └── id.go │ ├── reader.go │ └── depth_first_walker.go ├── imagetest │ ├── README.md │ └── fileutils.go ├── event │ ├── event.go │ └── parsers │ │ └── parsers.go └── filetree │ ├── union_filetree.go │ ├── interfaces.go │ ├── node_access.go │ ├── builder.go │ ├── link_strategy.go │ └── filenode │ └── filenode.go ├── internal ├── podman │ ├── test-fixtures │ │ ├── known_hosts_empty │ │ ├── user │ │ │ └── podman.sock │ │ ├── default │ │ │ └── podman.sock │ │ ├── xdg-runtime │ │ │ └── podman │ │ │ │ └── podman.sock │ │ ├── emty.conf │ │ ├── conf1.conf │ │ ├── containers-relative.conf │ │ ├── conf3.conf │ │ ├── known_hosts │ │ ├── conf2.conf │ │ └── containers.conf │ └── client_test.go ├── containerd │ ├── test-fixtures │ │ ├── default │ │ │ └── containerd.sock │ │ ├── xdg-runtime │ │ │ └── containerd-rootless │ │ │ │ └── child_pid │ │ ├── xdg-runtime-empty │ │ │ └── containerd-rootless │ │ │ │ └── child_pid │ │ ├── proc │ │ │ └── 42 │ │ │ │ └── root │ │ │ │ └── run │ │ │ │ └── containerd │ │ │ │ └── containerd.sock │ │ └── xdg-runtime-stale │ │ │ └── containerd-rootless │ │ │ └── child_pid │ ├── client.go │ └── client_test.go ├── bus │ └── bus.go ├── string_set.go ├── log │ └── log.go └── docker │ ├── docker_client_test.go │ └── client.go ├── .github ├── zizmor.yml ├── workflows │ ├── dependabot-automation.yaml │ ├── remove-awaiting-response-label.yaml │ ├── oss-project-board-add.yaml │ ├── validate-github-actions.yaml │ └── benchmark-testing.yaml ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ └── bug_report.md ├── dependabot.yml ├── scripts │ ├── coverage.py │ ├── go-mod-tidy-check.sh │ ├── trigger-release.sh │ └── build.sh └── actions │ └── bootstrap │ └── action.yaml ├── .bouncer.yaml ├── .goreleaser.yaml ├── RELEASE.md ├── option.go ├── .gitignore ├── deprecated.go ├── Makefile ├── README.md ├── .binny.yaml ├── providers.go ├── .chronicle.yaml └── .golangci.yaml /examples/.gitignore: -------------------------------------------------------------------------------- 1 | images 2 | -------------------------------------------------------------------------------- /test/integration/tools/.gitignore: -------------------------------------------------------------------------------- 1 | cache -------------------------------------------------------------------------------- /pkg/file/test-fixtures/tar-cache/.gitkeep: -------------------------------------------------------------------------------- 1 | ;) -------------------------------------------------------------------------------- /pkg/image/docker/test-fixtures/empty-file: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /internal/podman/test-fixtures/known_hosts_empty: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pkg/image/test-fixtures/tar-cache/.gitkeep: -------------------------------------------------------------------------------- 1 | ;) -------------------------------------------------------------------------------- /test/integration/test-fixtures/.gitignore: -------------------------------------------------------------------------------- 1 | cache/ -------------------------------------------------------------------------------- /pkg/image/oci/test-fixtures/invalid_file/index.json: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pkg/image/docker/test-fixtures/no-descriptors.json: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /test/integration/tools/singularity/.dockerignore: -------------------------------------------------------------------------------- 1 | Makefile -------------------------------------------------------------------------------- /internal/podman/test-fixtures/user/podman.sock: -------------------------------------------------------------------------------- 1 | fake socket file -------------------------------------------------------------------------------- /pkg/file/test-fixtures/symlinks-simple/link_to_new_readme: -------------------------------------------------------------------------------- 1 | readme -------------------------------------------------------------------------------- /test/integration/test-fixtures/image-symlinks/file-1.txt: -------------------------------------------------------------------------------- 1 | file 1! -------------------------------------------------------------------------------- /test/integration/test-fixtures/image-symlinks/file-2.txt: -------------------------------------------------------------------------------- 1 | file 2! -------------------------------------------------------------------------------- /internal/podman/test-fixtures/default/podman.sock: -------------------------------------------------------------------------------- 1 | fake socket file -------------------------------------------------------------------------------- /internal/containerd/test-fixtures/default/containerd.sock: -------------------------------------------------------------------------------- 1 | fake socket! -------------------------------------------------------------------------------- /pkg/file/test-fixtures/a-file.txt: -------------------------------------------------------------------------------- 1 | a file with contents! How exciting... -------------------------------------------------------------------------------- /test/integration/test-fixtures/image-simple/file-2.txt: -------------------------------------------------------------------------------- 1 | file-2 contents! -------------------------------------------------------------------------------- /internal/containerd/test-fixtures/xdg-runtime/containerd-rootless/child_pid: -------------------------------------------------------------------------------- 1 | 42 -------------------------------------------------------------------------------- /internal/podman/test-fixtures/xdg-runtime/podman/podman.sock: -------------------------------------------------------------------------------- 1 | fake socket file -------------------------------------------------------------------------------- /test/integration/test-fixtures/image-simple/file-1.txt: -------------------------------------------------------------------------------- 1 | this file has contents -------------------------------------------------------------------------------- /test/integration/test-fixtures/image-symlinks/new-file-2.txt: -------------------------------------------------------------------------------- 1 | NEW file override! -------------------------------------------------------------------------------- /internal/containerd/test-fixtures/xdg-runtime-empty/containerd-rootless/child_pid: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pkg/file/test-fixtures/symlinks-simple/link_to_link_to_new_readme: -------------------------------------------------------------------------------- 1 | link_to_new_readme -------------------------------------------------------------------------------- /pkg/image/oci/test-fixtures/valid_oci_dir/oci-layout: -------------------------------------------------------------------------------- 1 | {"imageLayoutVersion":"1.0.0"} -------------------------------------------------------------------------------- /test/integration/test-fixtures/image-many-layers/file.txt: -------------------------------------------------------------------------------- 1 | this file has contents -------------------------------------------------------------------------------- /test/integration/test-fixtures/image-many-layers/a-dir/.wa: -------------------------------------------------------------------------------- 1 | I'm not a whiteout file ;) -------------------------------------------------------------------------------- /test/integration/test-fixtures/image-many-layers/a-dir/content.txt: -------------------------------------------------------------------------------- 1 | This file has content -------------------------------------------------------------------------------- /test/integration/test-fixtures/registry/.gitignore: -------------------------------------------------------------------------------- 1 | config 2 | bin 3 | *.tar.gz 4 | *.tar -------------------------------------------------------------------------------- /internal/containerd/test-fixtures/proc/42/root/run/containerd/containerd.sock: -------------------------------------------------------------------------------- 1 | fake socket! -------------------------------------------------------------------------------- /internal/podman/test-fixtures/emty.conf: -------------------------------------------------------------------------------- 1 | [containers] 2 | 3 | [engine] 4 | 5 | [network] 6 | -------------------------------------------------------------------------------- /test/integration/test-fixtures/podman/.dockerignore: -------------------------------------------------------------------------------- 1 | **/* 2 | !/setup.sh 3 | !/setup.service -------------------------------------------------------------------------------- /internal/containerd/test-fixtures/xdg-runtime-stale/containerd-rootless/child_pid: -------------------------------------------------------------------------------- 1 | not-empty-but-not-good -------------------------------------------------------------------------------- /pkg/image/docker/test-fixtures/single-blank-manifest.json: -------------------------------------------------------------------------------- 1 | [{"Config":"","RepoTags":[],"Layers":[]}] 2 | -------------------------------------------------------------------------------- /pkg/file/opener.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import "io" 4 | 5 | type Opener func() (io.ReadCloser, error) 6 | -------------------------------------------------------------------------------- /pkg/tree/node/node.go: -------------------------------------------------------------------------------- 1 | package node 2 | 3 | type Node interface { 4 | ID() ID 5 | Copy() Node 6 | } 7 | -------------------------------------------------------------------------------- /test/integration/test-fixtures/image-simple/target/really/nested/file-3.txt: -------------------------------------------------------------------------------- 1 | another file! 2 | with lines... -------------------------------------------------------------------------------- /test/integration/test-fixtures/podman/.gitignore: -------------------------------------------------------------------------------- 1 | id_ed25519 2 | id_ed25519.pub 3 | authorized_keys 4 | /ssh 5 | -------------------------------------------------------------------------------- /pkg/file/size.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | const ( 4 | _ = iota 5 | KB = 1 << (10 * iota) 6 | MB 7 | GB 8 | ) 9 | -------------------------------------------------------------------------------- /pkg/image/oci/test-fixtures/valid_manifest/blobs/sha256/f67dcc5fc786f04f0743abfe0ee5dae9bd8caf8efa6c8144f7f2a43889dc513b: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pkg/image/sif/test-fixtures/fifo.sif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anchore/stereoscope/HEAD/pkg/image/sif/test-fixtures/fifo.sif -------------------------------------------------------------------------------- /pkg/file/test-fixtures/mime/mach-binary: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anchore/stereoscope/HEAD/pkg/file/test-fixtures/mime/mach-binary -------------------------------------------------------------------------------- /pkg/image/sif/test-fixtures/empty.sif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anchore/stereoscope/HEAD/pkg/image/sif/test-fixtures/empty.sif -------------------------------------------------------------------------------- /pkg/image/oci/test-fixtures/valid-oci.tar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anchore/stereoscope/HEAD/pkg/image/oci/test-fixtures/valid-oci.tar -------------------------------------------------------------------------------- /pkg/image/sif/test-fixtures/one-group.sif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anchore/stereoscope/HEAD/pkg/image/sif/test-fixtures/one-group.sif -------------------------------------------------------------------------------- /.github/zizmor.yml: -------------------------------------------------------------------------------- 1 | rules: 2 | unpinned-uses: 3 | ignore: 4 | # Allow unpinned uses of trusted internal anchore/workflows actions 5 | - update-anchore-dependencies.yml 6 | -------------------------------------------------------------------------------- /pkg/file/test-fixtures/symlinks-simple/readme: -------------------------------------------------------------------------------- 1 | this directory exists for unit tests on irregular files. You can't see other files here because they are removed after each test. 2 | This readme is a better version of Russell's teapot. 3 | -------------------------------------------------------------------------------- /pkg/file/get_xid_win.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package file 4 | 5 | import ( 6 | "os" 7 | ) 8 | 9 | // getXid is a placeholder for windows file information 10 | func getXid(info os.FileInfo) (uid, gid int) { 11 | return -1, -1 12 | } 13 | -------------------------------------------------------------------------------- /pkg/imagetest/README.md: -------------------------------------------------------------------------------- 1 | # Image fixture utilities 2 | 3 | These are a set of go-utilities for testing to provide on-the-fly images from a docker build, a tar cache dir, or otherwise. 4 | 5 | Note: These are **NOT** meant for use in production, only in go tests. -------------------------------------------------------------------------------- /.github/workflows/dependabot-automation.yaml: -------------------------------------------------------------------------------- 1 | name: Dependabot Automation 2 | on: 3 | pull_request: 4 | 5 | permissions: 6 | pull-requests: write 7 | 8 | jobs: 9 | run: 10 | uses: anchore/workflows/.github/workflows/dependabot-automation.yaml@main 11 | -------------------------------------------------------------------------------- /pkg/image/oci/test-fixtures/valid_oci_dir/blobs/sha256/00ffd085e9e7c06d20fdc61119a8d08e5c8bd3c1c320d10494ce6ed86691c06c: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anchore/stereoscope/HEAD/pkg/image/oci/test-fixtures/valid_oci_dir/blobs/sha256/00ffd085e9e7c06d20fdc61119a8d08e5c8bd3c1c320d10494ce6ed86691c06c -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | contact_links: 2 | 3 | - name: Support & Developer Discourse Community ⛑️ 4 | # link to toolbox-help channel 5 | url: https://anchore.com/discourse 6 | about: Ask a questions and get answers, and participate in design discussions and feature development 7 | -------------------------------------------------------------------------------- /pkg/tree/reader.go: -------------------------------------------------------------------------------- 1 | package tree 2 | 3 | import ( 4 | "github.com/anchore/stereoscope/pkg/tree/node" 5 | ) 6 | 7 | type Reader interface { 8 | Node(id node.ID) node.Node 9 | Nodes() node.Nodes 10 | Children(n node.Node) node.Nodes 11 | Parent(n node.Node) node.Node 12 | Roots() node.Nodes 13 | } 14 | -------------------------------------------------------------------------------- /.bouncer.yaml: -------------------------------------------------------------------------------- 1 | permit: 2 | - BSD.* 3 | - CC0.* 4 | - MIT.* 5 | - Apache.* 6 | - MPL.* 7 | - ISC 8 | - WTFPL 9 | - Unlicense 10 | 11 | ignore-packages: 12 | # crypto/internal/boring is released under the openSSL license as a part of the Golang Standard Libary 13 | - crypto/internal/boring 14 | -------------------------------------------------------------------------------- /test/integration/test-fixtures/podman/setup.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Setup SSH material generation 3 | Requires=local-fs.target 4 | After=local-fs.target 5 | 6 | [Service] 7 | Type=oneshot 8 | ExecStart=/setup.sh 9 | RemainAfterExit=true 10 | StandardOutput=journal 11 | 12 | [Install] 13 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /pkg/image/oci/credhelpers/helper.go: -------------------------------------------------------------------------------- 1 | package credhelpers 2 | 3 | import "github.com/anchore/stereoscope/pkg/image" 4 | 5 | type CredentialHelper interface { 6 | GetRegistryCredentials() (*image.RegistryCredentials, error) 7 | } 8 | 9 | type internalHelper interface { 10 | Get(serverURL string) (string, string, error) 11 | } 12 | -------------------------------------------------------------------------------- /pkg/tree/node/stack.go: -------------------------------------------------------------------------------- 1 | package node 2 | 3 | type Stack []Node 4 | 5 | func (s *Stack) Size() int { 6 | return len(*s) 7 | } 8 | 9 | func (s *Stack) Pop() Node { 10 | v := *s 11 | v, n := v[:len(v)-1], v[len(v)-1] 12 | *s = v 13 | return n 14 | } 15 | 16 | func (s *Stack) Push(n Node) { 17 | *s = append(*s, n) 18 | } 19 | -------------------------------------------------------------------------------- /pkg/file/path_stack.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | type PathStack []Path 4 | 5 | func (s *PathStack) Size() int { 6 | return len(*s) 7 | } 8 | 9 | func (s *PathStack) Pop() Path { 10 | v := *s 11 | v, n := v[:len(v)-1], v[len(v)-1] 12 | *s = v 13 | return n 14 | } 15 | 16 | func (s *PathStack) Push(n Path) { 17 | *s = append(*s, n) 18 | } 19 | -------------------------------------------------------------------------------- /pkg/file/test-fixtures/mime/capture.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eux -o pipefail 3 | 4 | # use this script to capture only the beginning of files for use a MIME type detection testing 5 | 6 | input=$1 7 | name=$(basename $input) 8 | 9 | # all you need to mimetype detection usually is within the first sector of reading 10 | head -c 512 $input > $name -------------------------------------------------------------------------------- /test/integration/test-fixtures/Makefile: -------------------------------------------------------------------------------- 1 | # change these if you want CI to not use previous stored cache 2 | INTEGRATION_CACHE_BUSTER := "894d8ca" 3 | 4 | .PHONY: cache.fingerprint 5 | cache.fingerprint: 6 | find image-* -type f -exec md5sum {} + | awk '{print $1}' | sort | tee /dev/stderr | md5sum | tee cache.fingerprint && echo "$(INTEGRATION_CACHE_BUSTER)" >> cache.fingerprint 7 | -------------------------------------------------------------------------------- /test/integration/test-fixtures/image-simple/Dockerfile: -------------------------------------------------------------------------------- 1 | # Note: changes to this file will result in updating several test values. Consider making a new image fixture instead of editing this one. 2 | FROM scratch 3 | ADD file-1.txt /somefile-1.txt 4 | ADD file-2.txt /somefile-2.txt 5 | # note: adding a directory will behave differently on docker engine v18 vs v19 6 | ADD target / 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **What would you like to be added**: 11 | 12 | **Why is this needed**: 13 | 14 | **Additional context**: 15 | 16 | -------------------------------------------------------------------------------- /internal/bus/bus.go: -------------------------------------------------------------------------------- 1 | package bus 2 | 3 | import "github.com/wagoodman/go-partybus" 4 | 5 | var publisher partybus.Publisher 6 | var active bool 7 | 8 | func SetPublisher(p partybus.Publisher) { 9 | publisher = p 10 | if p != nil { 11 | active = true 12 | } 13 | } 14 | 15 | func Publish(event partybus.Event) { 16 | if active { 17 | publisher.Publish(event) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /pkg/file/get_xid.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package file 4 | 5 | import ( 6 | "os" 7 | "syscall" 8 | ) 9 | 10 | // getXid is the UID GID system info for unix 11 | func getXid(info os.FileInfo) (uid, gid int) { 12 | uid = -1 13 | gid = -1 14 | if stat, ok := info.Sys().(*syscall.Stat_t); ok { 15 | uid = int(stat.Uid) 16 | gid = int(stat.Gid) 17 | } 18 | 19 | return uid, gid 20 | } 21 | -------------------------------------------------------------------------------- /test/integration/test-fixtures/image-opaque-directory/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=linux/amd64 rockylinux:9.2.20230513-minimal@sha256:6ff3d41b1fea114dfe6f3b8cf0517a0806f9410404df7e931c32b65f7e76d1d8 2 | 3 | RUN curl -sLO https://corretto.aws/downloads/latest/amazon-corretto-11-x64-linux-jdk.rpm 4 | RUN rpm -i amazon-corretto-11-x64-linux-jdk.rpm 5 | 6 | # Regression: https://github.com/anchore/syft/issues/264 7 | -------------------------------------------------------------------------------- /test/integration/tools/singularity/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=linux/amd64 ubuntu:20.04 2 | # force platform since https://github.com/sylabs/singularity/releases 3 | # has no arm64 releases. 4 | RUN apt-get update && apt-get install curl -y && \ 5 | curl -sLO https://github.com/sylabs/singularity/releases/download/v4.1.3/singularity-ce_4.1.3-focal_amd64.deb && \ 6 | apt-get install -f ./singularity-ce_4.1.3-focal_amd64.deb -y 7 | -------------------------------------------------------------------------------- /internal/podman/test-fixtures/conf1.conf: -------------------------------------------------------------------------------- 1 | [engine] 2 | active_service = "default" 3 | [engine.service_destinations] 4 | [engine.service_destinations.default] 5 | uri = "unix://jonas@:22/low/precedence/1234/podman/podman.sock" 6 | [engine.service_destinations.podman-machine-default] 7 | uri = "ssh://core@localhost:45983/low/precedence/1234/podman/podman.sock" 8 | identity = "/home/jonas/.ssh/podman-machine-default" -------------------------------------------------------------------------------- /internal/podman/test-fixtures/containers-relative.conf: -------------------------------------------------------------------------------- 1 | [containers] 2 | log_size_max = -1 3 | pids_limit = 2048 4 | userns_size = 65536 5 | 6 | [engine] 7 | image_parallel_copies = 0 8 | num_locks = 2048 9 | active_service = "podman-machine-default" 10 | stop_timeout = 10 11 | [engine.service_destinations] 12 | [engine.service_destinations.default] 13 | uri = "unix:///user/podman.sock" 14 | 15 | [network] -------------------------------------------------------------------------------- /test/integration/tools/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all 2 | all: 3 | cd singularity && make 4 | 5 | .PHONY: save-cache 6 | save-cache: 7 | cd singularity && make save-cache 8 | 9 | .PHONY: load-cache 10 | load-cache: 11 | cd singularity && make load-cache 12 | 13 | .PHONY: fingerprint 14 | fingerprint: 15 | @# for all tools, generate 16 | @rm -f cache.fingerprint 17 | @cat */cache.fingerprint | sort | md5sum | tee cache.fingerprint 18 | -------------------------------------------------------------------------------- /internal/podman/test-fixtures/conf3.conf: -------------------------------------------------------------------------------- 1 | [engine] 2 | active_service = "default" 3 | [engine.service_destinations] 4 | [engine.service_destinations.default] 5 | uri = "unix://jonas@:22/high/precedence/1234/podman/podman.sock" 6 | [engine.service_destinations.podman-machine-default] 7 | uri = "ssh://core@localhost:45983/high/precedence/1234/podman/podman.sock" 8 | identity = "/home/jonas/.ssh/podman-machine-default" 9 | -------------------------------------------------------------------------------- /internal/podman/test-fixtures/known_hosts: -------------------------------------------------------------------------------- 1 | github.com,140.82.112.4 ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ== 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **What happened**: 11 | 12 | **What you expected to happen**: 13 | 14 | **How to reproduce it (as minimally and precisely as possible)**: 15 | 16 | **Anything else we need to know?**: 17 | 18 | **Environment**: 19 | - OS (e.g: `cat /etc/os-release` or similar): 20 | -------------------------------------------------------------------------------- /internal/podman/test-fixtures/conf2.conf: -------------------------------------------------------------------------------- 1 | [engine] 2 | active_service = "default" 3 | [engine.service_destinations] 4 | [engine.service_destinations.default] 5 | uri = "unix://jonas@:22/medium/precedence/1234/podman/podman.sock" 6 | [engine.service_destinations.podman-machine-default] 7 | uri = "ssh://core@localhost:45983/medium/precedence/1234/podman/podman.sock" 8 | identity = "/home/jonas/.ssh/podman-machine-default" 9 | -------------------------------------------------------------------------------- /.github/workflows/remove-awaiting-response-label.yaml: -------------------------------------------------------------------------------- 1 | name: "Manage Awaiting Response Label" 2 | 3 | on: 4 | issue_comment: 5 | types: [created] 6 | 7 | permissions: {} # deny default GITHUB_TOKEN permissions (workflow uses custom OSS_PROJECT_GH_TOKEN) 8 | 9 | jobs: 10 | run: 11 | uses: "anchore/workflows/.github/workflows/remove-awaiting-response-label.yaml@main" 12 | secrets: 13 | token: ${{ secrets.OSS_PROJECT_GH_TOKEN }} 14 | -------------------------------------------------------------------------------- /test/integration/test-fixtures/podman/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM quay.io/podman/stable:v4.6.1@sha256:6a070625cff8ed7b3c0a77534e50c2efbaf333929f20094883911acb071b4cd8 2 | 3 | EXPOSE 22 4 | 5 | RUN yum -y install openssh openssh-server openssh-clients && \ 6 | yum -y clean all 7 | 8 | ADD setup.sh /setup.sh 9 | ADD setup.service /etc/systemd/system/setup.service 10 | RUN systemctl enable sshd.service podman.socket setup.service 11 | 12 | CMD [ "/sbin/init" ] 13 | -------------------------------------------------------------------------------- /.github/workflows/oss-project-board-add.yaml: -------------------------------------------------------------------------------- 1 | name: Add to OSS board 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | - reopened 8 | - transferred 9 | - labeled 10 | 11 | permissions: {} # workflow GH token has all necessary permissions 12 | 13 | jobs: 14 | 15 | run: 16 | uses: "anchore/workflows/.github/workflows/oss-project-board-add.yaml@main" 17 | secrets: 18 | token: ${{ secrets.OSS_PROJECT_GH_TOKEN }} 19 | -------------------------------------------------------------------------------- /pkg/event/event.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "github.com/wagoodman/go-partybus" 5 | ) 6 | 7 | const ( 8 | PullDockerImage partybus.EventType = "pull-docker-image-event" 9 | PullContainerdImage partybus.EventType = "pull-containerd-image-event" 10 | FetchImage partybus.EventType = "fetch-image-event" 11 | ReadImage partybus.EventType = "read-image-event" 12 | ReadLayer partybus.EventType = "read-layer-event" 13 | ) 14 | -------------------------------------------------------------------------------- /pkg/image/oci/test-fixtures/valid_oci_dir/blobs/sha256/c1ed04a3da941a5dd09b58b16c37f065557863d382ef97995ddac885a8452ebb: -------------------------------------------------------------------------------- 1 | {"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"sha256:61ee19e869a529075634f762c1eb191962777e0803598758cbf076edfadfb046","size":433},"layers":[{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:00ffd085e9e7c06d20fdc61119a8d08e5c8bd3c1c320d10494ce6ed86691c06c","size":120}]} -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | release: 2 | prerelease: auto 3 | draft: false 4 | 5 | builds: 6 | - skip: true 7 | 8 | signs: 9 | - cmd: .tool/cosign 10 | signature: "${artifact}.sig" 11 | certificate: "${artifact}.pem" 12 | args: 13 | - "sign-blob" 14 | - "--oidc-issuer=https://token.actions.githubusercontent.com" 15 | - "--output-certificate=${certificate}" 16 | - "--output-signature=${signature}" 17 | - "${artifact}" 18 | - "--yes" 19 | artifacts: checksum 20 | -------------------------------------------------------------------------------- /pkg/file/id.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import "sync/atomic" 4 | 5 | var nextID atomic.Uint64 // note: this is governed by the reference constructor 6 | 7 | // ID is used for file tree manipulation to uniquely identify tree nodes. 8 | type ID uint64 9 | 10 | type IDs []ID 11 | 12 | func (ids IDs) Len() int { 13 | return len(ids) 14 | } 15 | 16 | func (ids IDs) Less(i, j int) bool { 17 | return ids[i] < ids[j] 18 | } 19 | 20 | func (ids IDs) Swap(i, j int) { 21 | ids[i], ids[j] = ids[j], ids[i] 22 | } 23 | -------------------------------------------------------------------------------- /pkg/image/source.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | type Source = string 4 | 5 | const ( 6 | UnknownSource Source = "" 7 | ContainerdDaemonSource Source = "containerd" 8 | DockerTarballSource Source = "docker-archive" 9 | DockerDaemonSource Source = "docker" 10 | OciDirectorySource Source = "oci-dir" 11 | OciTarballSource Source = "oci-archive" 12 | OciRegistrySource Source = "oci-registry" 13 | PodmanDaemonSource Source = "podman" 14 | SingularitySource Source = "singularity" 15 | ) 16 | -------------------------------------------------------------------------------- /test/integration/test-fixtures/registry/loader/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:jammy@sha256:b492494d8e0113c4ad3fe4528a4b5ff89faa5331f7d52c5c138196f69ce176a6 2 | 3 | # install make 4 | RUN apt-get update && \ 5 | apt-get install -y \ 6 | make \ 7 | curl 8 | 9 | # install crane 10 | RUN curl -o /tmp/ggcr.tar.gz -LsS https://github.com/google/go-containerregistry/releases/download/v0.16.1/go-containerregistry_Linux_x86_64.tar.gz && \ 11 | tar -C /usr/local/bin -xzf /tmp/ggcr.tar.gz && \ 12 | rm /tmp/ggcr.tar.gz 13 | -------------------------------------------------------------------------------- /pkg/image/oci/test-fixtures/valid_oci_dir/blobs/sha256/61ee19e869a529075634f762c1eb191962777e0803598758cbf076edfadfb046: -------------------------------------------------------------------------------- 1 | {"created":"2024-02-07T09:02:36.826417009Z","architecture":"amd64","os":"linux","config":{"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"WorkingDir":"/"},"rootfs":{"type":"layers","diff_ids":["sha256:7cec5b0994b7ef959705617ab9c8bbd495446c4a80afa28aba0490f16772c9b3"]},"history":[{"created":"2024-02-07T09:02:36.826417009Z","created_by":"COPY README.md / # buildkit","comment":"buildkit.dockerfile.v0"}]} -------------------------------------------------------------------------------- /pkg/image/parse_reference.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import "github.com/google/go-containerregistry/pkg/name" 4 | 5 | func ParseReference(imageStr string) (imageRef string, originalRef string, err error) { 6 | ref, err := name.ParseReference(imageStr, name.WithDefaultRegistry("")) 7 | if err != nil { 8 | return "", "", err 9 | } 10 | tag, ok := ref.(name.Tag) 11 | if ok { 12 | imageStr = tag.Name() 13 | originalRef = tag.String() // blindly takes the original input passed into Tag 14 | } 15 | return imageStr, originalRef, nil 16 | } 17 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | 5 | - package-ecosystem: "gomod" 6 | open-pull-requests-limit: 10 7 | directory: "/" 8 | schedule: 9 | interval: "weekly" 10 | 11 | - package-ecosystem: "github-actions" 12 | open-pull-requests-limit: 10 13 | directory: "/.github/actions/bootstrap" 14 | schedule: 15 | interval: "daily" 16 | 17 | - package-ecosystem: "github-actions" 18 | open-pull-requests-limit: 10 19 | directory: "/.github/workflows" 20 | schedule: 21 | interval: "daily" 22 | -------------------------------------------------------------------------------- /pkg/image/oci/test-fixtures/valid_manifest/index.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaVersion": 2, 3 | "mediaType": "application/vnd.docker.distribution.manifest.list.v2+json", 4 | "manifests": [ 5 | { 6 | "mediaType": "application/vnd.docker.distribution.manifest.v2+json", 7 | "size": 424, 8 | "digest": "sha256:f67dcc5fc786f04f0743abfe0ee5dae9bd8caf8efa6c8144f7f2a43889dc513b", 9 | "platform": { 10 | "architecture": "arm", 11 | "os": "linux" 12 | } 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /pkg/file/tar_index_entry.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "archive/tar" 5 | "io" 6 | ) 7 | 8 | type TarIndexEntry struct { 9 | path string 10 | sequence int64 11 | header tar.Header 12 | seekPosition int64 13 | } 14 | 15 | func (t *TarIndexEntry) ToTarFileEntry() TarFileEntry { 16 | return TarFileEntry{ 17 | Sequence: t.sequence, 18 | Header: t.header, 19 | Reader: t.Open(), 20 | } 21 | } 22 | 23 | func (t *TarIndexEntry) Open() io.ReadCloser { 24 | return newLazyBoundedReadCloser(t.path, t.seekPosition, t.header.Size) 25 | } 26 | -------------------------------------------------------------------------------- /pkg/image/oci/test-fixtures/no_manifests/index.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaVersion": 2, 3 | "mediaType": "application/vnd.docker.distribution.manifest.v2+json", 4 | "config": { 5 | "mediaType": "application/vnd.docker.container.image.v1+json", 6 | "size": 1520, 7 | "digest": "sha256:1815c82652c03bfd8644afda26fb184f2ed891d921b20a0703b46768f9755c57" 8 | }, 9 | "layers": [ 10 | { 11 | "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", 12 | "size": 972, 13 | "digest": "sha256:b04784fba78d739b526e27edc02a5a8cd07b1052e9283f5fc155828f4b614c28" 14 | } 15 | ] 16 | } -------------------------------------------------------------------------------- /test/integration/test-fixtures/image-symlinks/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM busybox:1.36@sha256:023917ec6a886d0e8e15f28fb543515a5fcd8d938edb091e8147db4efed388ee 2 | # link with previous data 3 | ADD file-1.txt . 4 | RUN ln -s ./file-1.txt link-1 5 | # link with future data 6 | RUN ln -s ./file-2.txt link-2 7 | ADD file-2.txt . 8 | # link with current data 9 | RUN echo "file 3" > file-3.txt && ln -s ./file-3.txt link-within 10 | # multiple links (link-indirect > link-2 > file-2.txt) 11 | RUN ln -s ./link-2 link-indirect 12 | # override contents / resolution 13 | ADD new-file-2.txt file-2.txt 14 | # dead link (link-indirect > [non-existant file]) 15 | RUN unlink link-2 16 | -------------------------------------------------------------------------------- /pkg/image/podman/daemon_provider.go: -------------------------------------------------------------------------------- 1 | package podman 2 | 3 | import ( 4 | "github.com/docker/docker/client" 5 | 6 | "github.com/anchore/stereoscope/internal/podman" 7 | "github.com/anchore/stereoscope/pkg/file" 8 | "github.com/anchore/stereoscope/pkg/image" 9 | "github.com/anchore/stereoscope/pkg/image/docker" 10 | ) 11 | 12 | const Daemon image.Source = image.PodmanDaemonSource 13 | 14 | func NewDaemonProvider(tmpDirGen *file.TempDirGenerator, imageStr string, platform *image.Platform) image.Provider { 15 | return docker.NewAPIClientProvider(Daemon, tmpDirGen, imageStr, platform, func() (client.APIClient, error) { 16 | return podman.GetClient() 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /test/integration/test-fixtures/podman/setup.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | /usr/bin/echo "setting up ssh material..." 4 | 5 | test -f /root/.ssh/id_ed25519 || (/usr/bin/echo -e 'y\n' | /usr/bin/ssh-keygen -t ed25519 -f /root/.ssh/id_ed25519 -N '') 6 | test -f /root/.ssh/id_ed25519.pub || (/usr/bin/echo -e 'y\n' | ssh-keygen -y -t ed25519 -f ~/.ssh/id_ed25519 > ~/.ssh/id_ed25519.pub) 7 | test -f /root/.ssh/authorized_keys || (/usr/bin/echo -e 'y\n' | /usr/bin/cp /root/.ssh/id_ed25519.pub /root/.ssh/authorized_keys) 8 | 9 | chown -R root:root /root/.ssh 10 | chmod 777 /root/.ssh/id_ed25519 11 | chmod 777 /root/.ssh/id_ed25519.pub 12 | 13 | /usr/bin/echo "ssh material setup!" 14 | -------------------------------------------------------------------------------- /pkg/file/references.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | // References is a slice of file references (useful for attaching sorting-related methods) 4 | type References []*Reference 5 | 6 | func (f References) Len() int { 7 | return len(f) 8 | } 9 | 10 | func (f References) Swap(idx1, idx2 int) { 11 | f[idx1], f[idx2] = f[idx2], f[idx1] 12 | } 13 | 14 | func (f References) Less(idx1, idx2 int) bool { 15 | return f[idx1].RealPath < f[idx2].RealPath 16 | } 17 | 18 | func (f References) Equal(other References) bool { 19 | if len(f) != len(other) { 20 | return false 21 | } 22 | for i, v := range f { 23 | if v != other[i] { 24 | return false 25 | } 26 | } 27 | return true 28 | } 29 | -------------------------------------------------------------------------------- /test/integration/test-fixtures/podman/Makefile: -------------------------------------------------------------------------------- 1 | IMAGE = localhost/podman-ssh:latest 2 | 3 | all: build start 4 | 5 | .PHONY: build 6 | build: 7 | docker build -t $(IMAGE) . 8 | 9 | .PHONY: ssh 10 | ssh: 11 | ssh -o 'StrictHostKeyChecking no' -i ./ssh/id_ed25519 -p 2222 root@localhost 12 | 13 | .PHONY: stop 14 | stop: 15 | docker kill podman 16 | 17 | .PHONY: exec 18 | exec: 19 | docker exec -it podman bash 20 | 21 | .PHONY: status 22 | status: 23 | docker exec -t podman systemctl status sshd podman.socket 24 | 25 | .PHONY: start 26 | start: 27 | docker run --rm \ 28 | -d \ 29 | --name podman \ 30 | -t \ 31 | --privileged \ 32 | -p 2222:22 \ 33 | -v $(shell pwd)/ssh:/root/.ssh/ \ 34 | $(IMAGE) -------------------------------------------------------------------------------- /test/integration/test-fixtures/image-many-layers/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM busybox:1.36@sha256:023917ec6a886d0e8e15f28fb543515a5fcd8d938edb091e8147db4efed388ee 2 | ADD file.txt /somefile.txt 3 | RUN mkdir -p /root/example/really/nested 4 | RUN cp /somefile.txt /root/example/somefile1.txt 5 | RUN chmod 444 /root/example/somefile1.txt 6 | RUN cp /somefile.txt /root/example/somefile2.txt 7 | RUN cp /somefile.txt /root/example/somefile3.txt 8 | RUN mv /root/example/somefile3.txt /root/saved.txt 9 | RUN cp /root/saved.txt /root/.saved.txt 10 | RUN rm -rf /root/example/ 11 | ADD a-dir /root/.data/ 12 | RUN cp /root/saved.txt /tmp/saved.again1.txt 13 | RUN cp /root/saved.txt /root/.data/saved.again2.txt 14 | RUN chmod +x /root/saved.txt 15 | RUN chmod 421 /root 16 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release process 2 | 3 | A release of stereoscope results in: 4 | - a new semver git tag from the current tip of the main branch 5 | - a new [github release](https://github.com/anchore/stereoscope/releases) with a changelog 6 | 7 | A new release can be created by running: 8 | ``` 9 | make release 10 | ``` 11 | 12 | When prompted to continue (`Do you want to trigger a release for version?`) review the generated changelog. If it is inaccurate then select `n` to cancel. Then you can edit issue/PR titles and labels, restarting the release process again. 13 | 14 | Follow the subsequent run of the [github action workflow](https://github.com/anchore/stereoscope/actions/workflows/release.yaml) to see the progress / result of the release. 15 | -------------------------------------------------------------------------------- /pkg/file/reference_set.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | // ReferenceSet is a set of file references 4 | type ReferenceSet map[ID]struct{} 5 | 6 | // NewFileReferenceSet creates a new ReferenceSet instance. 7 | func NewFileReferenceSet() ReferenceSet { 8 | return make(ReferenceSet) 9 | } 10 | 11 | // Add the ID of the given file reference to the set. 12 | func (s ReferenceSet) Add(ref Reference) { 13 | s[ref.ID()] = struct{}{} 14 | } 15 | 16 | // Remove the ID of the given file reference from the set. 17 | func (s ReferenceSet) Remove(ref Reference) { 18 | delete(s, ref.ID()) 19 | } 20 | 21 | // Contains indicates if the given file reference ID is already contained in this set. 22 | func (s ReferenceSet) Contains(ref Reference) bool { 23 | _, ok := s[ref.ID()] 24 | return ok 25 | } 26 | -------------------------------------------------------------------------------- /test/integration/fixture_image_opaque_directory_test.go: -------------------------------------------------------------------------------- 1 | package integration 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/anchore/stereoscope/pkg/file" 7 | "github.com/anchore/stereoscope/pkg/filetree" 8 | "github.com/anchore/stereoscope/pkg/imagetest" 9 | ) 10 | 11 | func TestImage_SquashedTree_OpaqueDirectoryExistsInFileCatalog(t *testing.T) { 12 | image := imagetest.GetFixtureImage(t, "docker-archive", "image-opaque-directory") 13 | 14 | tree := image.SquashedTree() 15 | path := "/usr/lib/jvm" 16 | _, ref, err := tree.File(file.Path(path), filetree.FollowBasenameLinks) 17 | if err != nil { 18 | t.Fatalf("unable to get file=%q : %+v", path, err) 19 | } 20 | 21 | _, err = image.FileCatalog.Get(*ref.Reference) 22 | if err != nil { 23 | t.Fatal(err) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /pkg/tree/node/nodes.go: -------------------------------------------------------------------------------- 1 | package node 2 | 3 | import "sort" 4 | 5 | type Nodes []Node 6 | 7 | func (n Nodes) Len() int { 8 | return len(n) 9 | } 10 | 11 | func (n Nodes) Swap(idx1, idx2 int) { 12 | n[idx1], n[idx2] = n[idx2], n[idx1] 13 | } 14 | 15 | func (n Nodes) Less(idx1, idx2 int) bool { 16 | return n[idx1].ID() < n[idx2].ID() 17 | } 18 | 19 | func (n Nodes) Equal(other Nodes) bool { 20 | // TODO: this is bad, since it changes the order of the nodes, which is unexpected for the caller 21 | // however, this is only supporting tests, which need to be refactored. 22 | sort.Sort(n) 23 | sort.Sort(other) 24 | 25 | if len(n) != len(other) { 26 | return false 27 | } 28 | for i, v := range n { 29 | if v != other[i] { 30 | return false 31 | } 32 | } 33 | return true 34 | } 35 | -------------------------------------------------------------------------------- /pkg/image/oci/credhelpers/ecr_helper.go: -------------------------------------------------------------------------------- 1 | package credhelpers 2 | 3 | import ( 4 | ecr "github.com/awslabs/amazon-ecr-credential-helper/ecr-login" 5 | 6 | "github.com/anchore/stereoscope/pkg/image" 7 | ) 8 | 9 | type ECRHelper struct { 10 | authority string 11 | helper internalHelper 12 | } 13 | 14 | func NewECRHelper(authority string) ECRHelper { 15 | return ECRHelper{ 16 | authority: authority, 17 | helper: ecr.NewECRHelper(), 18 | } 19 | } 20 | 21 | func (e *ECRHelper) GetECRCredentials() (*image.RegistryCredentials, error) { 22 | username, password, err := e.helper.Get(e.authority) 23 | if err != nil { 24 | return nil, err 25 | } 26 | return &image.RegistryCredentials{ 27 | Authority: e.authority, 28 | Username: username, 29 | Password: password, 30 | }, nil 31 | } 32 | -------------------------------------------------------------------------------- /pkg/image/oci/test-fixtures/valid_oci_dir/index.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaVersion": 2, 3 | "manifests": [ 4 | { 5 | "mediaType": "application/vnd.oci.image.manifest.v1+json", 6 | "digest": "sha256:c1ed04a3da941a5dd09b58b16c37f065557863d382ef97995ddac885a8452ebb", 7 | "size": 401, 8 | "annotations": { 9 | "org.opencontainers.image.ref.name": "latest" 10 | } 11 | }, 12 | { 13 | "mediaType": "application/vnd.oci.image.manifest.v1+json", 14 | "digest": "sha256:c1ed04a3da941a5dd09b58b16c37f065557863d382ef97995ddac885a8452ebb", 15 | "size": 401, 16 | "annotations": { 17 | "org.opencontainers.image.ref.name": "1.0" 18 | }, 19 | "platform": { 20 | "architecture": "amd64", 21 | "os": "linux" 22 | } 23 | } 24 | ] 25 | } -------------------------------------------------------------------------------- /pkg/tree/node/queue.go: -------------------------------------------------------------------------------- 1 | package node 2 | 3 | type Queue struct { 4 | head int 5 | data []Node 6 | } 7 | 8 | func (q *Queue) Size() int { 9 | return len(q.data) - q.head 10 | } 11 | 12 | func (q *Queue) Enqueue(n Node) { 13 | if len(q.data) == cap(q.data) && q.head > 0 { 14 | l := q.Size() 15 | copy(q.data, q.data[q.head:]) 16 | q.head = 0 17 | q.data = append(q.data[:l], n) 18 | } else { 19 | q.data = append(q.data, n) 20 | } 21 | } 22 | 23 | func (q *Queue) Dequeue() Node { 24 | if q.Size() == 0 { 25 | return nil 26 | } 27 | 28 | var node Node 29 | node, q.data[q.head] = q.data[q.head], nil 30 | q.head++ 31 | 32 | if q.Size() == 0 { 33 | q.head = 0 34 | q.data = q.data[:0] 35 | } 36 | 37 | return node 38 | } 39 | 40 | func (q *Queue) Reset() { 41 | q.head = 0 42 | q.data = q.data[:0] 43 | } 44 | -------------------------------------------------------------------------------- /internal/podman/test-fixtures/containers.conf: -------------------------------------------------------------------------------- 1 | [containers] 2 | log_size_max = -1 3 | pids_limit = 2048 4 | userns_size = 65536 5 | 6 | [engine] 7 | image_parallel_copies = 0 8 | num_locks = 2048 9 | active_service = "podman-machine-default" 10 | stop_timeout = 10 11 | [engine.service_destinations] 12 | [engine.service_destinations.default] 13 | uri = "unix://jonas@:22/run/user/1000/podman/podman.sock" 14 | [engine.service_destinations.podman-machine-default] 15 | uri = "ssh://core@localhost:45983/run/user/1000/podman/podman.sock" 16 | identity = "/home/jonas/.ssh/podman-machine-default" 17 | [engine.service_destinations.podman-machine-default-root] 18 | uri = "ssh://root@localhost:45983/run/podman/podman.sock" 19 | identity = "/home/jonas/.ssh/podman-machine-default" 20 | 21 | [network] -------------------------------------------------------------------------------- /test/integration/tools/singularity/Makefile: -------------------------------------------------------------------------------- 1 | CACHE_DIR = ../cache 2 | CACHE_FILE_NAME = singularity-image.tar 3 | CACHE_FILE = $(CACHE_DIR)/$(CACHE_FILE_NAME) 4 | TAG = localhost/singularity:dev 5 | 6 | .PHONY: all 7 | all: 8 | @docker inspect $(TAG) > /dev/null && echo "$(TAG) already exists" || (make build && make save-cache) 9 | 10 | .PHONY: build 11 | build: 12 | docker build -t $(TAG) . 13 | 14 | .PHONY: save-cache 15 | save-cache: $(CACHE_DIR) 16 | docker image save $(TAG) -o $(CACHE_FILE) 17 | 18 | .PHONY: load-cache 19 | load-cache: 20 | docker image load -i $(CACHE_FILE) 21 | 22 | $(CACHE_DIR): 23 | mkdir -p $(CACHE_DIR) 24 | 25 | # note: this is used by CI to determine if the tool image should be rebuilt/refreshed 26 | .PHONY: fingerprint 27 | fingerprint: 28 | find Dockerfile -type f -exec md5sum {} + | sort | md5sum | tee cache.fingerprint 29 | -------------------------------------------------------------------------------- /pkg/image/content_helpers.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/anchore/stereoscope/pkg/file" 8 | "github.com/anchore/stereoscope/pkg/filetree" 9 | ) 10 | 11 | // fetchReaderByPath is a common helper function for resolving the file contents for a path from the file 12 | // catalog relative to the given tree. 13 | func fetchReaderByPath(ft filetree.Reader, fileCatalog FileCatalogReader, path file.Path) (io.ReadCloser, error) { 14 | exists, refVia, err := ft.File(path, filetree.FollowBasenameLinks) 15 | if err != nil { 16 | return nil, err 17 | } 18 | if !exists && refVia == nil || refVia.Reference == nil { 19 | return nil, fmt.Errorf("could not find file path in Tree: %s", path) 20 | } 21 | 22 | reader, err := fileCatalog.Open(*refVia.Reference) 23 | if err != nil { 24 | return nil, err 25 | } 26 | return reader, nil 27 | } 28 | -------------------------------------------------------------------------------- /option.go: -------------------------------------------------------------------------------- 1 | package stereoscope 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/anchore/stereoscope/pkg/image" 8 | ) 9 | 10 | type Option func(*config) error 11 | 12 | type config struct { 13 | Registry image.RegistryOptions 14 | AdditionalMetadata []image.AdditionalMetadata 15 | Platform *image.Platform 16 | } 17 | 18 | func applyOptions(cfg *config, options ...Option) error { 19 | for _, option := range options { 20 | if option == nil { 21 | continue 22 | } 23 | if err := option(cfg); err != nil { 24 | return fmt.Errorf("unable to parse option: %w", err) 25 | } 26 | } 27 | return nil 28 | } 29 | 30 | func applyAdditionalMetadata(img *image.Image, metadata ...image.AdditionalMetadata) error { 31 | var errs error 32 | for _, userMetadata := range metadata { 33 | err := userMetadata(img) 34 | errs = errors.Join(errs, err) 35 | } 36 | return errs 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/validate-github-actions.yaml: -------------------------------------------------------------------------------- 1 | name: "Validate GitHub Actions" 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - '.github/workflows/**' 7 | - '.github/actions/**' 8 | push: 9 | branches: 10 | - main 11 | paths: 12 | - '.github/workflows/**' 13 | - '.github/actions/**' 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | zizmor: 20 | name: "Lint" 21 | runs-on: ubuntu-latest 22 | permissions: 23 | contents: read 24 | security-events: write # for uploading SARIF results 25 | steps: 26 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 27 | with: 28 | persist-credentials: false 29 | 30 | - name: "Run zizmor" 31 | uses: zizmorcore/zizmor-action@e639db99335bc9038abc0e066dfcd72e23d26fb4 # v0.3.0 32 | with: 33 | config-file: .github/zizmor.yml 34 | sarif-upload: true 35 | inputs: .github 36 | -------------------------------------------------------------------------------- /pkg/image/provider.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | ) 7 | 8 | // ErrPlatformMismatch is meant to be used when a provider has positively resolved the image but the image OS or 9 | // architecture does not match with what was requested. 10 | type ErrPlatformMismatch struct { 11 | ExpectedPlatform string 12 | Err error 13 | } 14 | 15 | func (e *ErrPlatformMismatch) Error() string { 16 | if e.ExpectedPlatform == "" { 17 | return fmt.Sprintf("mismatched platform: %v", e.Err) 18 | } 19 | return fmt.Sprintf("mismatched platform (expected %v): %v", e.ExpectedPlatform, e.Err) 20 | } 21 | 22 | func (e *ErrPlatformMismatch) Unwrap() error { 23 | return e.Err 24 | } 25 | 26 | // Provider is an abstraction for any object that provides image objects (e.g. the docker daemon API, a tar file of 27 | // an OCI image, podman varlink API, etc.). 28 | type Provider interface { 29 | Name() string 30 | Provide(context.Context) (*Image, error) 31 | } 32 | -------------------------------------------------------------------------------- /pkg/filetree/union_filetree.go: -------------------------------------------------------------------------------- 1 | package filetree 2 | 3 | import "fmt" 4 | 5 | type UnionFileTree struct { 6 | trees []ReadWriter 7 | } 8 | 9 | func NewUnionFileTree() *UnionFileTree { 10 | return &UnionFileTree{ 11 | trees: make([]ReadWriter, 0), 12 | } 13 | } 14 | 15 | func (u *UnionFileTree) PushTree(t ReadWriter) { 16 | u.trees = append(u.trees, t) 17 | } 18 | 19 | func (u *UnionFileTree) Squash() (ReadWriter, error) { 20 | switch len(u.trees) { 21 | case 0: 22 | return New(), nil 23 | case 1: 24 | return u.trees[0].Copy() 25 | } 26 | 27 | var squashedTree ReadWriter 28 | var err error 29 | for layerIdx, refTree := range u.trees { 30 | if layerIdx == 0 { 31 | squashedTree, err = refTree.Copy() 32 | if err != nil { 33 | return nil, err 34 | } 35 | continue 36 | } 37 | 38 | if err = squashedTree.Merge(refTree); err != nil { 39 | return nil, fmt.Errorf("unable to squash layer=%d : %w", layerIdx, err) 40 | } 41 | } 42 | return squashedTree, nil 43 | } 44 | -------------------------------------------------------------------------------- /pkg/image/layer_metadata.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | v1 "github.com/google/go-containerregistry/pkg/v1" 5 | v1Types "github.com/google/go-containerregistry/pkg/v1/types" 6 | ) 7 | 8 | // LayerMetadata represents container layer metadata. 9 | type LayerMetadata struct { 10 | Index uint 11 | // Digest is the sha256 digest of the layer contents (the docker "diff id") 12 | Digest string 13 | MediaType v1Types.MediaType 14 | // Size in bytes of the layer content size 15 | Size int64 16 | } 17 | 18 | // newLayerMetadata aggregates pertinent layer metadata information. 19 | func newLayerMetadata(layer v1.Layer, idx int) (LayerMetadata, error) { 20 | mediaType, err := layer.MediaType() 21 | if err != nil { 22 | return LayerMetadata{}, err 23 | } 24 | diffID, err := layer.DiffID() 25 | if err != nil { 26 | return LayerMetadata{}, err 27 | } 28 | 29 | return LayerMetadata{ 30 | Index: uint(idx), 31 | Digest: diffID.String(), 32 | MediaType: mediaType, 33 | }, nil 34 | } 35 | -------------------------------------------------------------------------------- /pkg/file/reference.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import "fmt" 4 | 5 | // Reference represents a unique file. This is useful when path is not good enough (i.e. you have the same file path for two files in two different container image layers, and you need to be able to distinguish them apart) 6 | type Reference struct { 7 | id ID 8 | RealPath Path // file path with NO symlinks or hardlinks in constituent paths 9 | } 10 | 11 | // NewFileReference creates a new unique file reference for the given path. 12 | func NewFileReference(path Path) *Reference { 13 | return &Reference{ 14 | RealPath: path, 15 | id: ID(nextID.Add(1)), 16 | } 17 | } 18 | 19 | // ID returns the unique ID for this file reference. 20 | func (f *Reference) ID() ID { 21 | return f.id 22 | } 23 | 24 | // String returns a string representation of the path with a unique ID. 25 | func (f *Reference) String() string { 26 | if f == nil { 27 | return "[nil]" 28 | } 29 | return fmt.Sprintf("[%v] real=%q", f.id, f.RealPath) 30 | } 31 | -------------------------------------------------------------------------------- /.github/scripts/coverage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import subprocess 3 | import sys 4 | import shlex 5 | 6 | 7 | class bcolors: 8 | HEADER = '\033[95m' 9 | OKBLUE = '\033[94m' 10 | OKCYAN = '\033[96m' 11 | OKGREEN = '\033[92m' 12 | WARNING = '\033[93m' 13 | FAIL = '\033[91m' 14 | ENDC = '\033[0m' 15 | BOLD = '\033[1m' 16 | UNDERLINE = '\033[4m' 17 | 18 | 19 | if len(sys.argv) < 3: 20 | print("Usage: coverage.py [threshold] [go-coverage-report]") 21 | sys.exit(1) 22 | 23 | 24 | threshold = float(sys.argv[1]) 25 | report = sys.argv[2] 26 | 27 | 28 | args = shlex.split(f"go tool cover -func {report}") 29 | p = subprocess.run(args, capture_output=True, text=True) 30 | 31 | percent_coverage = float(p.stdout.splitlines()[-1].split()[-1].replace("%", "")) 32 | print(f"{bcolors.BOLD}Coverage: {percent_coverage}%{bcolors.ENDC}") 33 | 34 | if percent_coverage < threshold: 35 | print(f"{bcolors.BOLD}{bcolors.FAIL}Coverage below threshold of {threshold}%{bcolors.ENDC}") 36 | sys.exit(1) 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # local development tailoring 2 | go.work 3 | go.work.sum 4 | .tool-versions 5 | 6 | # tool and bin directories 7 | .tmp/ 8 | bin/ 9 | /bin 10 | /.bin 11 | /build 12 | /dist 13 | /snapshot 14 | /.tool 15 | /.task 16 | .mise.toml 17 | 18 | # changelog generation 19 | CHANGELOG.md 20 | VERSION 21 | 22 | # IDE configuration 23 | .vscode/ 24 | .idea/ 25 | .server/ 26 | .history/ 27 | 28 | # test related 29 | *.fingerprint 30 | /test/results 31 | coverage.txt 32 | *.log 33 | 34 | # probable archives 35 | .images 36 | *.tar 37 | *.jar 38 | *.war 39 | *.ear 40 | *.jpi 41 | *.hpi 42 | *.zip 43 | *.iml 44 | 45 | # Binaries for programs and plugins 46 | *.exe 47 | *.exe~ 48 | *.dll 49 | *.so 50 | *.dylib 51 | 52 | # Test binary, build with `go test -c` 53 | *.test 54 | 55 | # Output of the go coverage tool, specifically when used with LiteIDE 56 | *.out 57 | 58 | # macOS Finder metadata 59 | .DS_STORE 60 | 61 | *.profile 62 | 63 | # attestation 64 | cosign.key 65 | cosign.pub 66 | 67 | # Byte-compiled object files for python 68 | __pycache__/ 69 | *.py[cod] 70 | *$py.class 71 | -------------------------------------------------------------------------------- /.github/scripts/go-mod-tidy-check.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eu 3 | 4 | ORIGINAL_STATE_DIR=$(mktemp -d "TEMP-original-state-XXXXXXXXX") 5 | TIDY_STATE_DIR=$(mktemp -d "TEMP-tidy-state-XXXXXXXXX") 6 | 7 | trap "cp -p ${ORIGINAL_STATE_DIR}/* ./ && git update-index -q --refresh && rm -fR ${ORIGINAL_STATE_DIR} ${TIDY_STATE_DIR}" EXIT 8 | 9 | # capturing original state of files... 10 | cp go.mod go.sum "${ORIGINAL_STATE_DIR}" 11 | 12 | # capturing state of go.mod and go.sum after running go mod tidy... 13 | go mod tidy 14 | cp go.mod go.sum "${TIDY_STATE_DIR}" 15 | 16 | set +e 17 | 18 | # detect difference between the git HEAD state and the go mod tidy state 19 | DIFF_MOD=$(diff -u "${ORIGINAL_STATE_DIR}/go.mod" "${TIDY_STATE_DIR}/go.mod") 20 | DIFF_SUM=$(diff -u "${ORIGINAL_STATE_DIR}/go.sum" "${TIDY_STATE_DIR}/go.sum") 21 | 22 | if [[ -n "${DIFF_MOD}" || -n "${DIFF_SUM}" ]]; then 23 | echo "go.mod diff:" 24 | echo "${DIFF_MOD}" 25 | echo "go.sum diff:" 26 | echo "${DIFF_SUM}" 27 | echo "" 28 | printf "FAILED! go.mod and/or go.sum are NOT tidy; please run 'go mod tidy'.\n\n" 29 | exit 1 30 | fi 31 | -------------------------------------------------------------------------------- /test/integration/mime_type_detection_test.go: -------------------------------------------------------------------------------- 1 | package integration 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/scylladb/go-set/strset" 8 | "github.com/stretchr/testify/assert" 9 | 10 | "github.com/anchore/stereoscope" 11 | "github.com/anchore/stereoscope/pkg/imagetest" 12 | ) 13 | 14 | func TestContentMIMETypeDetection(t *testing.T) { 15 | request := imagetest.PrepareFixtureImage(t, "docker-archive", "image-simple") 16 | 17 | img, err := stereoscope.GetImage(context.TODO(), request) 18 | 19 | assert.NoError(t, err) 20 | t.Cleanup(stereoscope.Cleanup) 21 | 22 | pathsByMIMEType := map[string]*strset.Set{ 23 | "text/plain": strset.New("/somefile-1.txt", "/somefile-2.txt", "/really", "/really/nested", "/really/nested/file-3.txt"), 24 | } 25 | 26 | for mimeType, paths := range pathsByMIMEType { 27 | refs, err := img.SquashedSearchContext.SearchByMIMEType(mimeType) 28 | assert.NoError(t, err) 29 | assert.NotZero(t, len(refs), "found no refs for type=%q", mimeType) 30 | for _, ref := range refs { 31 | if !paths.Has(string(ref.RealPath)) { 32 | t.Errorf("unable to find %q", ref.RealPath) 33 | } 34 | } 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /pkg/file/mime_type_test.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func Test_MIMEType(t *testing.T) { 14 | 15 | fileReader := func(path string) io.Reader { 16 | f, err := os.Open(path) 17 | require.NoError(t, err) 18 | return f 19 | } 20 | 21 | tests := []struct { 22 | name string 23 | fixture io.Reader 24 | expected string 25 | }{ 26 | { 27 | name: "binary", 28 | fixture: fileReader("test-fixtures/mime/mach-binary"), 29 | expected: "application/x-mach-binary", 30 | }, 31 | { 32 | name: "script", 33 | fixture: fileReader("test-fixtures/mime/capture.sh"), 34 | expected: "text/x-shellscript", 35 | }, 36 | { 37 | name: "no contents", 38 | fixture: strings.NewReader(""), 39 | expected: "", 40 | }, 41 | { 42 | name: "no reader", 43 | fixture: nil, 44 | expected: "", 45 | }, 46 | } 47 | for _, test := range tests { 48 | t.Run(test.name, func(t *testing.T) { 49 | assert.Equal(t, test.expected, MIMEType(test.fixture)) 50 | }) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /pkg/file/mime_type.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "io" 5 | "strings" 6 | 7 | "github.com/gabriel-vasile/mimetype" 8 | ) 9 | 10 | // MIMEType attempts to guess at the MIME type of a file given the contents. If there is no contents, then an empty 11 | // string is returned. If the MIME type could not be determined and the contents are not empty, then a MIME type 12 | // of "application/octet-stream" is returned. 13 | func MIMEType(reader io.Reader) string { 14 | if reader == nil { 15 | return "" 16 | } 17 | 18 | s := sizer{reader: reader} 19 | 20 | var mTypeStr string 21 | mType, err := mimetype.DetectReader(&s) 22 | if err == nil { 23 | // extract the string mimetype and ignore aux information (e.g. 'text/plain; charset=utf-8' -> 'text/plain') 24 | mTypeStr = strings.Split(mType.String(), ";")[0] 25 | } 26 | 27 | // we may have a reader that is not nil but the observed contents was empty 28 | if s.size == 0 { 29 | return "" 30 | } 31 | 32 | return mTypeStr 33 | } 34 | 35 | type sizer struct { 36 | reader io.Reader 37 | size int64 38 | } 39 | 40 | func (s *sizer) Read(p []byte) (int, error) { 41 | n, err := s.reader.Read(p) 42 | s.size += int64(n) 43 | return n, err 44 | } 45 | -------------------------------------------------------------------------------- /deprecated.go: -------------------------------------------------------------------------------- 1 | package stereoscope 2 | 3 | import ( 4 | "slices" 5 | "strings" 6 | ) 7 | 8 | // ExtractSchemeSource parses a string with any colon-delimited prefix and validates it against the set 9 | // of known provider tags, returning a valid source name and input string to use for GetImageFromSource 10 | // 11 | // NOTE: since it is now possible to select which providers to use, using schemes 12 | // in the user input text is not necessary and should be avoided due to some ambiguity this introduces 13 | func ExtractSchemeSource(userInput string, sources ...string) (source, newInput string) { 14 | const SchemeSeparator = ":" 15 | parts := strings.SplitN(userInput, SchemeSeparator, 2) 16 | if len(parts) < 2 { 17 | return "", userInput 18 | } 19 | // the user may have provided a source hint (or this is a split from a path or docker image reference, we aren't certain yet) 20 | sourceHint := parts[0] 21 | sourceHint = strings.TrimSpace(strings.ToLower(sourceHint)) 22 | // check the hint against the possible tags 23 | if slices.Contains(sources, sourceHint) { 24 | return sourceHint, parts[1] 25 | } 26 | // did not have any matching tags, scheme is not a valid provider scheme 27 | return "", userInput 28 | } 29 | -------------------------------------------------------------------------------- /pkg/image/oci/test-fixtures/valid_manifest_equal_digests/index.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaVersion": 2, 3 | "manifests": [ 4 | { 5 | "mediaType": "application/vnd.oci.image.manifest.v1+json", 6 | "digest": "sha256:27280e6c2e98b6ec460a9c1a5f354d894037dac6a649535bcc4063eb1f555c15", 7 | "size": 480, 8 | "annotations": { 9 | "io.containerd.image.name": "docker.io/anchore/app:latest", 10 | "org.opencontainers.image.created": "2023-09-19T15:16:47Z", 11 | "org.opencontainers.image.ref.name": "latest" 12 | }, 13 | "platform": { 14 | "architecture": "amd64", 15 | "os": "linux" 16 | } 17 | }, 18 | { 19 | "mediaType": "application/vnd.oci.image.manifest.v1+json", 20 | "digest": "sha256:27280e6c2e98b6ec460a9c1a5f354d894037dac6a649535bcc4063eb1f555c15", 21 | "size": 480, 22 | "annotations": { 23 | "io.containerd.image.name": "docker.io/anchore/app:1.0.0", 24 | "org.opencontainers.image.created": "2023-09-19T15:16:47Z", 25 | "org.opencontainers.image.ref.name": "1.0.0" 26 | }, 27 | "platform": { 28 | "architecture": "amd64", 29 | "os": "linux" 30 | } 31 | } 32 | ] 33 | } -------------------------------------------------------------------------------- /test/integration/registry_self_signed_cert_test.go: -------------------------------------------------------------------------------- 1 | package integration 2 | 3 | import ( 4 | "os" 5 | "os/exec" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestRegistrySelfSignedCert(t *testing.T) { 13 | cwd, err := os.Getwd() 14 | require.NoErrorf(t, err, "unable to get cwd: %+v", err) 15 | fixturesPath := filepath.Join(cwd, "test-fixtures", "registry") 16 | 17 | runMakeTarget := func(targets ...string) func(*testing.T) { 18 | return func(t *testing.T) { 19 | t.Logf("Running make targets %s", targets) 20 | 21 | cmd := exec.Command("make", targets...) 22 | cmd.Dir = fixturesPath 23 | runAndShow(t, cmd) 24 | } 25 | } 26 | 27 | tests := []struct { 28 | name string 29 | setup func(*testing.T) 30 | run func(*testing.T) 31 | cleanup func(*testing.T) 32 | }{ 33 | { 34 | name: "go case", 35 | setup: runMakeTarget(), 36 | run: runMakeTarget("run"), 37 | cleanup: runMakeTarget("stop"), 38 | }, 39 | } 40 | 41 | for _, tt := range tests { 42 | t.Run(tt.name, func(t *testing.T) { 43 | t.Cleanup(func() { 44 | tt.cleanup(t) 45 | }) 46 | tt.setup(t) 47 | tt.run(t) 48 | }) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /test/integration/test-fixtures/registry/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | registry: 4 | hostname: registry.null 5 | networks: 6 | - backbone 7 | restart: always 8 | image: registry:2 9 | ports: 10 | - "5000:5000" 11 | environment: 12 | REGISTRY_HTTP_TLS_CERTIFICATE: /certs/server.crt 13 | REGISTRY_HTTP_TLS_KEY: /certs/server.key 14 | REGISTRY_AUTH: htpasswd 15 | REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd 16 | REGISTRY_AUTH_HTPASSWD_REALM: Registry Realm 17 | volumes: 18 | - registry-data:/var/lib/registry 19 | - ./config/certs:/certs 20 | - ./config/auth:/auth 21 | 22 | loader: 23 | networks: 24 | - backbone 25 | image: localhost:5000/loader:latest 26 | build: loader 27 | depends_on: 28 | - registry 29 | command: make load 30 | volumes: 31 | - ./Makefile:/Makefile 32 | 33 | runner: 34 | networks: 35 | - backbone 36 | image: ubuntu:20.04 37 | depends_on: 38 | - registry 39 | command: /bin/run-test 40 | volumes: 41 | - ./bin/run-test:/bin/run-test 42 | - ./config/certs:/certs 43 | 44 | volumes: 45 | registry-data: 46 | 47 | networks: 48 | backbone: -------------------------------------------------------------------------------- /pkg/image/sif/archive_provider_test.go: -------------------------------------------------------------------------------- 1 | package sif 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/sylabs/sif/v2/pkg/sif" 10 | 11 | "github.com/anchore/stereoscope/pkg/file" 12 | ) 13 | 14 | func TestSingularityImageProvider_Provide(t *testing.T) { 15 | tests := []struct { 16 | name string 17 | path string 18 | wantErr error 19 | }{ 20 | { 21 | name: "NoObjects", 22 | path: filepath.Join("test-fixtures", "empty.sif"), 23 | wantErr: sif.ErrNoObjects, 24 | }, 25 | { 26 | name: "OK", 27 | path: filepath.Join("test-fixtures", "one-group.sif"), 28 | }, 29 | { 30 | name: "FIFO", 31 | path: filepath.Join("test-fixtures", "fifo.sif"), 32 | }, 33 | } 34 | for _, tt := range tests { 35 | t.Run(tt.name, func(t *testing.T) { 36 | p := NewArchiveProvider(file.NewTempDirGenerator(""), tt.path) 37 | 38 | i, err := p.Provide(context.Background()) 39 | t.Cleanup(func() { _ = i.Cleanup() }) 40 | 41 | if got, want := err, tt.wantErr; !errors.Is(got, want) { 42 | t.Fatalf("got error %v, want %v", got, want) 43 | } 44 | 45 | if err == nil { 46 | if err := i.Read(); err != nil { 47 | t.Fatal(err) 48 | } 49 | } 50 | }) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /pkg/image/test-fixtures/generators/fixture-1.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -ue 3 | 4 | realpath() { 5 | [[ $1 = /* ]] && echo "$1" || echo "$PWD/${1#./}" 6 | } 7 | 8 | FIXTURE_TAR_PATH=$1 9 | FIXTURE_NAME=$(basename $FIXTURE_TAR_PATH) 10 | FIXTURE_DIR=$(realpath $(dirname $FIXTURE_TAR_PATH)) 11 | 12 | # note: since tar --sort is not an option on mac, and we want these generation scripts to be generally portable, we've 13 | # elected to use docker to generate the tar 14 | docker run --rm -i \ 15 | -u $(id -u):$(id -g) \ 16 | -v ${FIXTURE_DIR}:/scratch \ 17 | -w /scratch \ 18 | ubuntu:latest \ 19 | /bin/bash -xs < path/branch/one/file-1.txt 27 | echo "second file" > path/branch/two/file-2.txt 28 | echo "third file" > path/file-3.txt 29 | 30 | # permissions 31 | chmod -R 755 path 32 | chmod -R 700 path/branch/one/ 33 | chmod 664 path/file-3.txt 34 | 35 | # tar + owner 36 | # note: sort by name is important for test file header entry ordering 37 | tar --sort=name --owner=1337 --group=5432 -cvf "/scratch/${FIXTURE_NAME}" path/ 38 | 39 | popd 40 | EOF 41 | -------------------------------------------------------------------------------- /pkg/file/test-fixtures/generators/fixture-1.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -ue 3 | 4 | realpath() { 5 | [[ $1 = /* ]] && echo "$1" || echo "$PWD/${1#./}" 6 | } 7 | 8 | FIXTURE_TAR_PATH=$1 9 | FIXTURE_NAME=$(basename $FIXTURE_TAR_PATH) 10 | FIXTURE_DIR=$(realpath $(dirname $FIXTURE_TAR_PATH)) 11 | 12 | # note: since tar --sort is not an option on mac, and we want these generation scripts to be generally portable, we've 13 | # elected to use docker to generate the tar 14 | docker run --rm -i \ 15 | -u $(id -u):$(id -g) \ 16 | -v ${FIXTURE_DIR}:/scratch \ 17 | -w /scratch \ 18 | ubuntu:latest \ 19 | /bin/bash -xs < path/branch/one/file-1.txt 27 | echo "second file" > path/branch/two/file-2.txt 28 | echo "third file" > path/file-3.txt 29 | 30 | # permissions 31 | chmod -R 755 path 32 | chmod -R 700 path/branch/one/ 33 | chmod 664 path/file-3.txt 34 | 35 | # tar + owner 36 | # note: sort by name is important for test file header entry ordering 37 | tar --sort=name --owner=1337 --group=5432 --mtime='UTC 2019-09-16' -cvf "/scratch/${FIXTURE_NAME}" path/ 38 | 39 | popd 40 | EOF 41 | -------------------------------------------------------------------------------- /pkg/image/oci/credhelpers/ecr_helper_test.go: -------------------------------------------------------------------------------- 1 | package credhelpers 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/mock" 8 | ) 9 | 10 | type mockInternalHelper struct { 11 | mock.Mock 12 | } 13 | 14 | func (m *mockInternalHelper) Get(_ string) (string, string, error) { 15 | args := m.Called() 16 | return args.Get(0).(string), args.Get(1).(string), args.Error(2) 17 | } 18 | 19 | func Test_GetECRCredentials(t *testing.T) { 20 | //GIVEN 21 | username, password, authority := "username", "password", "https://gallery.ecr.aws/" 22 | mInternalHelper := new(mockInternalHelper) 23 | mInternalHelper.On("Get").Return(username, password, nil) 24 | helper := ECRHelper{ 25 | helper: mInternalHelper, 26 | authority: authority, 27 | } 28 | 29 | //WHEN 30 | creds, err := helper.GetECRCredentials() 31 | 32 | //THEN 33 | assert.NoError(t, err) 34 | assert.Equal(t, username, creds.Username) 35 | assert.Equal(t, password, creds.Password) 36 | assert.Equal(t, authority, creds.Authority) 37 | } 38 | 39 | func Test_GetECRCredentials_Fails(t *testing.T) { 40 | //GIVEN 41 | helper := NewECRHelper("") 42 | 43 | //WHEN 44 | creds, err := helper.GetECRCredentials() 45 | 46 | //THEN 47 | assert.Nil(t, creds) 48 | assert.Error(t, err) 49 | } 50 | -------------------------------------------------------------------------------- /pkg/image/oci/tarball_provider_test.go: -------------------------------------------------------------------------------- 1 | package oci 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/anchore/stereoscope/pkg/file" 10 | ) 11 | 12 | func Test_NewProviderFromTarball(t *testing.T) { 13 | //GIVEN 14 | path := "path" 15 | generator := file.TempDirGenerator{} 16 | defer generator.Cleanup() 17 | 18 | //WHEN 19 | provider := NewArchiveProvider(&generator, path).(*tarballImageProvider) 20 | 21 | //THEN 22 | assert.NotNil(t, provider.path) 23 | assert.NotNil(t, provider.tmpDirGen) 24 | } 25 | 26 | func Test_TarballProvide(t *testing.T) { 27 | //GIVEN 28 | generator := file.NewTempDirGenerator("tempDir") 29 | defer generator.Cleanup() 30 | 31 | provider := NewArchiveProvider(generator, "test-fixtures/valid-oci.tar") 32 | 33 | //WHEN 34 | image, err := provider.Provide(context.TODO()) 35 | 36 | //THEN 37 | assert.NoError(t, err) 38 | assert.NotNil(t, image) 39 | } 40 | 41 | func Test_TarballProvide_Fails(t *testing.T) { 42 | //GIVEN 43 | generator := file.NewTempDirGenerator("tempDir") 44 | defer generator.Cleanup() 45 | 46 | provider := NewArchiveProvider(generator, "") 47 | 48 | //WHEN 49 | image, err := provider.Provide(context.TODO()) 50 | 51 | //THEN 52 | assert.Error(t, err) 53 | assert.Nil(t, image) 54 | } 55 | -------------------------------------------------------------------------------- /pkg/file/id_set.go: -------------------------------------------------------------------------------- 1 | //nolint:dupl 2 | package file 3 | 4 | import "sort" 5 | 6 | type IDSet map[ID]struct{} 7 | 8 | func NewIDSet(is ...ID) IDSet { 9 | // TODO: replace with single generic implementation that also incorporates other set implementations 10 | s := make(IDSet) 11 | s.Add(is...) 12 | return s 13 | } 14 | 15 | func (s IDSet) Size() int { 16 | return len(s) 17 | } 18 | 19 | func (s IDSet) Merge(other IDSet) { 20 | for _, i := range other.List() { 21 | s.Add(i) 22 | } 23 | } 24 | 25 | func (s IDSet) Add(ids ...ID) { 26 | for _, i := range ids { 27 | s[i] = struct{}{} 28 | } 29 | } 30 | 31 | func (s IDSet) Remove(ids ...ID) { 32 | for _, i := range ids { 33 | delete(s, i) 34 | } 35 | } 36 | 37 | func (s IDSet) Contains(i ID) bool { 38 | _, ok := s[i] 39 | return ok 40 | } 41 | 42 | func (s IDSet) Clear() { 43 | clear(s) 44 | } 45 | 46 | func (s IDSet) List() []ID { 47 | ret := make([]ID, 0, len(s)) 48 | for i := range s { 49 | ret = append(ret, i) 50 | } 51 | return ret 52 | } 53 | 54 | func (s IDSet) Sorted() []ID { 55 | ids := s.List() 56 | 57 | sort.Slice(ids, func(i, j int) bool { 58 | return ids[i] < ids[j] 59 | }) 60 | 61 | return ids 62 | } 63 | 64 | func (s IDSet) ContainsAny(ids ...ID) bool { 65 | for _, i := range ids { 66 | _, ok := s[i] 67 | if ok { 68 | return true 69 | } 70 | } 71 | return false 72 | } 73 | -------------------------------------------------------------------------------- /test/integration/test-fixtures/registry/test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/anchore/stereoscope" 10 | "github.com/anchore/stereoscope/pkg/image" 11 | ) 12 | 13 | func main() { 14 | img, err := stereoscope.GetImage( 15 | context.Background(), 16 | "registry:registry.null:5000/busybox:latest", 17 | stereoscope.WithRegistryOptions(image.RegistryOptions{ 18 | InsecureSkipTLSVerify: false, 19 | InsecureUseHTTP: false, 20 | CAFileOrDir: "/certs/server.crt", 21 | Credentials: []image.RegistryCredentials{ 22 | { 23 | Authority: "registry.null:5000", 24 | Username: "testuser42", 25 | Password: "testpass42", 26 | ClientCert: "/certs/client.crt", 27 | ClientKey: "/certs/client.key", 28 | }, 29 | }, 30 | }), 31 | ) 32 | if err != nil { 33 | panic("could not get image: " + err.Error()) 34 | } 35 | if img == nil { 36 | panic("image is nil") 37 | } 38 | 39 | if len(img.Layers) == 0 { 40 | panic("image has no layers") 41 | } 42 | 43 | b, err := json.MarshalIndent(img.Metadata, "", "\t") 44 | if err != nil { 45 | panic(fmt.Sprintf("could not marshal image metadata: %+v", err)) 46 | } 47 | if _, err := os.Stdout.Write(b); err != nil { 48 | panic(fmt.Sprintf("could not write image metadata: %+v", err)) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /pkg/image/docker/test-fixtures/valid-multi-manifest-with-tags.json: -------------------------------------------------------------------------------- 1 | [{"Config":"881a352c4517dbf5e561a08dd1c7cf65f6c4349d3ab9b13e95210800e12b14a8.json","RepoTags":["anchore/anchore-engine:latest"],"Layers":["8e575a5d5157dcaf0b35d83dac8ff6c6fcf67553beab3271740c21bb9bbcfd69/layer.tar","c8065ff1b489edf5cc8543c049ffb9b43a7d27899f6f247ce4cd34347833fd1e/layer.tar","4205d61c76bbddb19e80368a1125579ac928075e1a7d60c7e633c0f1c3bf5c82/layer.tar","03d4d84d54df494c5eb321f76015b5c09d4728292d8e0eeb46108f46386c32fe/layer.tar","178622276ac116daab87557ae072d755b5681bf53e533a02a68236eb4a76ae98/layer.tar","e2db3c15baef889f03bfa02000f939dab60a6b5d168f629d448c1b9a920dbe5a/layer.tar","621f23ac643c6ce017f262ce85c044003d388d6ac25dbc85a109a606c7619c3b/layer.tar"]},{"Config":"258fc3fa61e7203db05f712ce0e3b0848103eb3c8c15e9b08f3512d5ef3e581d.json","RepoTags":["anchore/anchore-engine:v0.8.2"],"Layers":["1de76a962da9f1bf775dd311c39a5895878fb76df24ee89b2c7868c501f5a116/layer.tar","7ca3abe1ab65a57fad78cc86dbf8e7dfee1948dbd4507b87b16b07a2efe8e0a4/layer.tar","2b798eb83dcbbb3db5a77d6a57002450e911adc6f5cc880419c4be3f1449a49d/layer.tar","d4f39edae13ce61545905f18fc48c2e3384a7263dd67e5309e5440a305a2a109/layer.tar","69dce11006d381f36f65f62c2c2828d95dc1bfdea81003c6be0c569acebbcc7c/layer.tar","be929bbb8ad150f3fb900131700fe5b143256f35c05c827ee387c1a36410036d/layer.tar","a1a7a378b88dd00be88e0c63639a7b59225a16b325c1264a0376965245f11ac9/layer.tar"]}] 2 | -------------------------------------------------------------------------------- /pkg/tree/node/id.go: -------------------------------------------------------------------------------- 1 | package node 2 | 3 | import "sort" 4 | 5 | type ID string 6 | 7 | type IDSet map[ID]struct{} 8 | 9 | func NewIDSet(is ...ID) IDSet { 10 | // TODO: replace with single generic implementation that also incorporates other set implementations 11 | s := make(IDSet) 12 | s.Add(is...) 13 | return s 14 | } 15 | 16 | func (s IDSet) Size() int { 17 | return len(s) 18 | } 19 | 20 | func (s IDSet) Merge(other IDSet) { 21 | for _, i := range other.List() { 22 | s.Add(i) 23 | } 24 | } 25 | 26 | func (s IDSet) Add(ids ...ID) { 27 | for _, i := range ids { 28 | s[i] = struct{}{} 29 | } 30 | } 31 | 32 | func (s IDSet) Remove(ids ...ID) { 33 | for _, i := range ids { 34 | delete(s, i) 35 | } 36 | } 37 | 38 | func (s IDSet) Contains(i ID) bool { 39 | _, ok := s[i] 40 | return ok 41 | } 42 | 43 | func (s IDSet) Clear() { 44 | clear(s) 45 | } 46 | 47 | func (s IDSet) List() []ID { 48 | ret := make([]ID, 0, len(s)) 49 | for i := range s { 50 | ret = append(ret, i) 51 | } 52 | return ret 53 | } 54 | 55 | func (s IDSet) Sorted() []ID { 56 | ids := s.List() 57 | 58 | sort.Slice(ids, func(i, j int) bool { 59 | return ids[i] < ids[j] 60 | }) 61 | 62 | return ids 63 | } 64 | 65 | func (s IDSet) ContainsAny(ids ...ID) bool { 66 | for _, i := range ids { 67 | _, ok := s[i] 68 | if ok { 69 | return true 70 | } 71 | } 72 | return false 73 | } 74 | -------------------------------------------------------------------------------- /pkg/filetree/interfaces.go: -------------------------------------------------------------------------------- 1 | package filetree 2 | 3 | import ( 4 | "github.com/anchore/stereoscope/pkg/file" 5 | "github.com/anchore/stereoscope/pkg/filetree/filenode" 6 | "github.com/anchore/stereoscope/pkg/tree" 7 | ) 8 | 9 | type ReadWriter interface { 10 | Reader 11 | Writer 12 | } 13 | 14 | type Reader interface { 15 | AllFiles(types ...file.Type) []file.Reference 16 | TreeReader() tree.Reader 17 | PathReader 18 | Walker 19 | Copier 20 | } 21 | 22 | type PathReader interface { 23 | File(path file.Path, options ...LinkResolutionOption) (bool, *file.Resolution, error) 24 | FilesByGlob(query string, options ...LinkResolutionOption) ([]file.Resolution, error) 25 | AllRealPaths() []file.Path 26 | ListPaths(dir file.Path) ([]file.Path, error) 27 | HasPath(path file.Path, options ...LinkResolutionOption) bool 28 | } 29 | 30 | type Copier interface { 31 | Copy() (ReadWriter, error) 32 | } 33 | 34 | type Walker interface { 35 | Walk(fn func(path file.Path, f filenode.FileNode) error, conditions *WalkConditions) error 36 | } 37 | 38 | type Writer interface { 39 | AddFile(realPath file.Path) (*file.Reference, error) 40 | AddSymLink(realPath file.Path, linkPath file.Path) (*file.Reference, error) 41 | AddHardLink(realPath file.Path, linkPath file.Path) (*file.Reference, error) 42 | AddDir(realPath file.Path) (*file.Reference, error) 43 | RemovePath(path file.Path) error 44 | Merge(upper Reader) error 45 | } 46 | -------------------------------------------------------------------------------- /pkg/filetree/node_access.go: -------------------------------------------------------------------------------- 1 | package filetree 2 | 3 | import ( 4 | "github.com/anchore/stereoscope/pkg/file" 5 | "github.com/anchore/stereoscope/pkg/filetree/filenode" 6 | ) 7 | 8 | // nodeAccess represents a request into the tree for a specific path and the resulting node, which may have a different path. 9 | type nodeAccess struct { 10 | RequestPath file.Path 11 | FileNode *filenode.FileNode // note: it is important that nodeAccess does not behave like FileNode (then it can be added to the tree directly) 12 | LeafLinkResolution []nodeAccess 13 | } 14 | 15 | func (na *nodeAccess) HasFileNode() bool { 16 | if na == nil { 17 | return false 18 | } 19 | return na.FileNode != nil 20 | } 21 | 22 | func (na *nodeAccess) FileResolution() *file.Resolution { 23 | if !na.HasFileNode() { 24 | return nil 25 | } 26 | return file.NewResolution( 27 | na.RequestPath, 28 | na.FileNode.Reference, 29 | newResolutions(na.LeafLinkResolution), 30 | ) 31 | } 32 | 33 | func (na *nodeAccess) References() []file.Reference { 34 | if !na.HasFileNode() { 35 | return nil 36 | } 37 | var refs []file.Reference 38 | 39 | if na.FileNode.Reference != nil { 40 | refs = append(refs, *na.FileNode.Reference) 41 | } 42 | 43 | for _, l := range na.LeafLinkResolution { 44 | if l.HasFileNode() && l.FileNode.Reference != nil { 45 | refs = append(refs, *l.FileNode.Reference) 46 | } 47 | } 48 | 49 | return refs 50 | } 51 | -------------------------------------------------------------------------------- /pkg/image/docker/test-fixtures/snapshot/TestAssembleOCIManifest.golden: -------------------------------------------------------------------------------- 1 | {"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","config":{"mediaType":"application/vnd.docker.container.image.v1+json","size":16026,"digest":"sha256:881a352c4517dbf5e561a08dd1c7cf65f6c4349d3ab9b13e95210800e12b14a8"},"layers":[{"mediaType":"application/vnd.docker.image.rootfs.diff.tar.gzip","size":210882560,"digest":"sha256:226bfaae015f1d5712cfced3b5b628206618eaacf72f4a44d0e4084071996319"},{"mediaType":"application/vnd.docker.image.rootfs.diff.tar.gzip","size":20480,"digest":"sha256:70056249a0e202adae10aa45fef56ac4cc6497619767753515022bc9c1278251"},{"mediaType":"application/vnd.docker.image.rootfs.diff.tar.gzip","size":64316928,"digest":"sha256:1f0424cd48d3f00eb11bdcd5fce37d2d4051a112e0adff7c5325f56b9376ffd1"},{"mediaType":"application/vnd.docker.image.rootfs.diff.tar.gzip","size":38535168,"digest":"sha256:dd90319800e003577548b230c546877aa3ad197b85512f5ded7f3eaf9fbe3fb9"},{"mediaType":"application/vnd.docker.image.rootfs.diff.tar.gzip","size":1536,"digest":"sha256:d0bc4ce1e6c976e24b3d57f043554ce33e84f8613691b621a36b18a4fa602c43"},{"mediaType":"application/vnd.docker.image.rootfs.diff.tar.gzip","size":56832,"digest":"sha256:1fd78c99113cf5aba3b4cb5ad711d299099ec84f9afdf93edcd1128e4899ab8e"},{"mediaType":"application/vnd.docker.image.rootfs.diff.tar.gzip","size":232243200,"digest":"sha256:c1498afd34f52b2d9040abe65256f3c4145282d499c5dc15bf2462905233fc9c"}]} -------------------------------------------------------------------------------- /internal/string_set.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "sort" 5 | ) 6 | 7 | type StringSet map[string]struct{} 8 | 9 | func NewStringSet(is ...string) StringSet { 10 | // TODO: replace with single generic implementation that also incorporates other set implementations 11 | s := make(StringSet) 12 | s.Add(is...) 13 | return s 14 | } 15 | 16 | func (s StringSet) Size() int { 17 | return len(s) 18 | } 19 | 20 | func (s StringSet) Merge(other StringSet) { 21 | for _, i := range other.List() { 22 | s.Add(i) 23 | } 24 | } 25 | 26 | func (s StringSet) Add(ids ...string) { 27 | for _, i := range ids { 28 | s[i] = struct{}{} 29 | } 30 | } 31 | 32 | func (s StringSet) Remove(ids ...string) { 33 | for _, i := range ids { 34 | delete(s, i) 35 | } 36 | } 37 | 38 | func (s StringSet) Contains(i string) bool { 39 | _, ok := s[i] 40 | return ok 41 | } 42 | 43 | func (s StringSet) Clear() { 44 | clear(s) 45 | } 46 | 47 | func (s StringSet) List() []string { 48 | ret := make([]string, 0, len(s)) 49 | for i := range s { 50 | ret = append(ret, i) 51 | } 52 | return ret 53 | } 54 | 55 | func (s StringSet) Sorted() []string { 56 | ids := s.List() 57 | 58 | sort.Slice(ids, func(i, j int) bool { 59 | return ids[i] < ids[j] 60 | }) 61 | 62 | return ids 63 | } 64 | 65 | func (s StringSet) ContainsAny(ids ...string) bool { 66 | for _, i := range ids { 67 | _, ok := s[i] 68 | if ok { 69 | return true 70 | } 71 | } 72 | return false 73 | } 74 | -------------------------------------------------------------------------------- /pkg/image/oci/credhelpers/gcr_helper.go: -------------------------------------------------------------------------------- 1 | package credhelpers 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/GoogleCloudPlatform/docker-credential-gcr/config" 7 | "github.com/GoogleCloudPlatform/docker-credential-gcr/credhelper" 8 | "github.com/GoogleCloudPlatform/docker-credential-gcr/store" 9 | 10 | "github.com/anchore/stereoscope/pkg/image" 11 | ) 12 | 13 | type GCRHelper struct { 14 | authority string 15 | helper internalHelper 16 | } 17 | 18 | var loadConfig = config.LoadUserConfig 19 | var getDefaultGCRCredStore = store.DefaultGCRCredStore 20 | 21 | func NewGCRHelper(authority string) (*GCRHelper, error) { 22 | userConfig, err := loadConfig() 23 | if err != nil { 24 | return nil, fmt.Errorf("unable to load user config: %w", err) 25 | } 26 | credStore, err := getDefaultGCRCredStore() 27 | if err != nil { 28 | return nil, fmt.Errorf("unable to read load default cred store: %w", err) 29 | } 30 | helper := credhelper.NewGCRCredentialHelper(credStore, userConfig) 31 | 32 | return &GCRHelper{ 33 | authority: authority, 34 | helper: helper, 35 | }, nil 36 | } 37 | 38 | func (g *GCRHelper) GetRegistryCredentials() (*image.RegistryCredentials, error) { 39 | username, token, err := g.helper.Get(g.authority) 40 | if err != nil { 41 | return nil, fmt.Errorf("unable to load credentials: %w", err) 42 | } 43 | 44 | return &image.RegistryCredentials{ 45 | Authority: g.authority, 46 | Username: username, 47 | Token: token, 48 | }, nil 49 | } 50 | -------------------------------------------------------------------------------- /pkg/filetree/builder.go: -------------------------------------------------------------------------------- 1 | package filetree 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/anchore/stereoscope/pkg/file" 7 | ) 8 | 9 | // Builder is a helper for building a filetree and accompanying index in a coordinated fashion. 10 | type Builder struct { 11 | tree Writer 12 | index IndexWriter 13 | } 14 | 15 | func NewBuilder(tree Writer, index IndexWriter) *Builder { 16 | return &Builder{ 17 | tree: tree, 18 | index: index, 19 | } 20 | } 21 | 22 | func (b *Builder) Add(metadata file.Metadata) (*file.Reference, error) { 23 | var ( 24 | ref *file.Reference 25 | err error 26 | ) 27 | switch metadata.Type { 28 | case file.TypeSymLink: 29 | ref, err = b.tree.AddSymLink(file.Path(metadata.Path), file.Path(metadata.LinkDestination)) 30 | if err != nil { 31 | return nil, err 32 | } 33 | case file.TypeHardLink: 34 | ref, err = b.tree.AddHardLink(file.Path(metadata.Path), file.Path(metadata.LinkDestination)) 35 | if err != nil { 36 | return nil, err 37 | } 38 | case file.TypeDirectory: 39 | ref, err = b.tree.AddDir(file.Path(metadata.Path)) 40 | if err != nil { 41 | return nil, err 42 | } 43 | default: 44 | ref, err = b.tree.AddFile(file.Path(metadata.Path)) 45 | if err != nil { 46 | return nil, err 47 | } 48 | } 49 | if ref == nil { 50 | return nil, fmt.Errorf("could not add path=%q link=%q during tar iteration", metadata.Path, metadata.LinkDestination) 51 | } 52 | 53 | b.index.Add(*ref, metadata) 54 | 55 | return ref, nil 56 | } 57 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | OWNER = anchore 2 | PROJECT = stereoscope 3 | 4 | TOOL_DIR = .tool 5 | BINNY = $(TOOL_DIR)/binny 6 | TASK = $(TOOL_DIR)/task 7 | 8 | .DEFAULT_GOAL := make-default 9 | 10 | ## Bootstrapping targets ################################# 11 | 12 | # note: we need to assume that binny and task have not already been installed 13 | $(BINNY): 14 | @mkdir -p $(TOOL_DIR) 15 | @curl -sSfL https://raw.githubusercontent.com/$(OWNER)/binny/main/install.sh | sh -s -- -b $(TOOL_DIR) 16 | 17 | # note: we need to assume that binny and task have not already been installed 18 | .PHONY: task 19 | $(TASK) task: $(BINNY) 20 | @$(BINNY) install task -q 21 | 22 | .PHONY: ci-bootstrap-go 23 | ci-bootstrap-go: 24 | go mod download 25 | 26 | # this is a bootstrapping catch-all, where if the target doesn't exist, we'll ensure the tools are installed and then try again 27 | %: 28 | make $(TASK) 29 | $(TASK) $@ 30 | 31 | ## Shim targets ################################# 32 | 33 | .PHONY: make-default 34 | make-default: $(TASK) 35 | @# run the default task in the taskfile 36 | @$(TASK) 37 | 38 | # for those of us that can't seem to kick the habit of typing `make ...` lets wrap the superior `task` tool 39 | 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 ','") 40 | 41 | .PHONY: $(TASKS) 42 | $(TASKS): $(TASK) 43 | @$(TASK) $@ 44 | 45 | help: $(TASK) 46 | @$(TASK) -l 47 | -------------------------------------------------------------------------------- /pkg/image/sif/image_test.go: -------------------------------------------------------------------------------- 1 | package sif 2 | 3 | import ( 4 | "errors" 5 | "path/filepath" 6 | "testing" 7 | 8 | v1 "github.com/google/go-containerregistry/pkg/v1" 9 | "github.com/sylabs/sif/v2/pkg/sif" 10 | ) 11 | 12 | func Test_newSIFImage(t *testing.T) { 13 | tests := []struct { 14 | name string 15 | path string 16 | wantErr error 17 | wantArch string 18 | wantDiffID v1.Hash 19 | }{ 20 | { 21 | name: "NoObjects", 22 | path: filepath.Join("test-fixtures", "empty.sif"), 23 | wantErr: sif.ErrNoObjects, 24 | }, 25 | { 26 | name: "OK", 27 | path: filepath.Join("test-fixtures", "one-group.sif"), 28 | wantArch: "386", 29 | wantDiffID: v1.Hash{ 30 | Algorithm: "sha256", 31 | Hex: "9f9c4e5e131934969b4ac8f495691c70b8c6c8e3f489c2c9ab5f1af82bce0604", 32 | }, 33 | }, 34 | } 35 | for _, tt := range tests { 36 | t.Run(tt.name, func(t *testing.T) { 37 | im, err := newSIFImage(tt.path) 38 | 39 | if got, want := err, tt.wantErr; !errors.Is(got, want) { 40 | t.Fatalf("got error %v, want %v", got, want) 41 | } 42 | 43 | if im != nil { 44 | if got, want := tt.path, im.path; got != want { 45 | t.Errorf("got path %v, want %v", got, want) 46 | } 47 | 48 | if got, want := tt.wantArch, im.arch; got != want { 49 | t.Errorf("got arch %v, want %v", got, want) 50 | } 51 | 52 | if _, ok := im.diffIDs[tt.wantDiffID]; !ok { 53 | t.Errorf("diffID %v not found", tt.wantDiffID) 54 | } 55 | } 56 | }) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /pkg/image/parse_reference_test.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestNewProviderFromDaemon_ParseReference(t *testing.T) { 10 | tests := []struct { 11 | image string 12 | want string 13 | wantErr require.ErrorAssertionFunc 14 | }{ 15 | { 16 | image: "alpine:sometag", 17 | want: "alpine:sometag", 18 | }, 19 | { 20 | image: "alpine:latest", 21 | want: "alpine:latest", 22 | }, 23 | { 24 | image: "alpine", 25 | want: "alpine:latest", 26 | }, 27 | { 28 | image: "registry.place.io/thing:version", 29 | want: "registry.place.io/thing:version", 30 | }, 31 | { 32 | image: "alpine@sha256:95cf004f559831017cdf4628aaf1bb30133677be8702a8c5f2994629f637a209", 33 | want: "alpine@sha256:95cf004f559831017cdf4628aaf1bb30133677be8702a8c5f2994629f637a209", 34 | }, 35 | { 36 | image: "alpine:sometag@sha256:95cf004f559831017cdf4628aaf1bb30133677be8702a8c5f2994629f637a209", 37 | want: "alpine:sometag@sha256:95cf004f559831017cdf4628aaf1bb30133677be8702a8c5f2994629f637a209", 38 | }, 39 | { 40 | image: "some:invalid:tag", 41 | wantErr: require.Error, 42 | }, 43 | } 44 | for _, tt := range tests { 45 | t.Run(tt.image, func(t *testing.T) { 46 | if tt.wantErr == nil { 47 | tt.wantErr = require.NoError 48 | } 49 | got, _, err := ParseReference(tt.image) 50 | tt.wantErr(t, err) 51 | if err != nil { 52 | return 53 | } 54 | require.NotNil(t, got) 55 | require.Equal(t, tt.want, got) 56 | }) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /test/integration/oci_registry_source_test.go: -------------------------------------------------------------------------------- 1 | package integration 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | 11 | "github.com/anchore/stereoscope" 12 | ) 13 | 14 | func TestOciRegistrySourceMetadata(t *testing.T) { 15 | rawManifest := `{ 16 | "schemaVersion": 2, 17 | "mediaType": "application/vnd.docker.distribution.manifest.v2+json", 18 | "config": { 19 | "mediaType": "application/vnd.docker.container.image.v1+json", 20 | "size": 1509, 21 | "digest": "sha256:a24bb4013296f61e89ba57005a7b3e52274d8edd3ae2077d04395f806b63d83e" 22 | }, 23 | "layers": [ 24 | { 25 | "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", 26 | "size": 2797541, 27 | "digest": "sha256:df20fa9351a15782c64e6dddb2d4a6f50bf6d3688060a34c4014b0d9a752eb4c" 28 | } 29 | ] 30 | }` 31 | digest := "sha256:a15790640a6690aa1730c38cf0a440e2aa44aaca9b0e8931a9f2b0d7cc90fd65" 32 | imgStr := "anchore/test_images" 33 | ref := fmt.Sprintf("%s@%s", imgStr, digest) 34 | 35 | img, err := stereoscope.GetImage(context.TODO(), "registry:"+ref, stereoscope.WithPlatform("linux/amd64")) 36 | require.NoError(t, err) 37 | t.Cleanup(func() { 38 | require.NoError(t, img.Cleanup()) 39 | }) 40 | 41 | require.NoError(t, img.Read()) 42 | 43 | assert.Len(t, img.Metadata.RepoDigests, 1) 44 | assert.Equal(t, "index.docker.io/"+ref, img.Metadata.RepoDigests[0]) 45 | assert.Equal(t, []byte(rawManifest), img.Metadata.RawManifest) 46 | } 47 | -------------------------------------------------------------------------------- /internal/log/log.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "github.com/anchore/go-logger" 5 | "github.com/anchore/go-logger/adapter/discard" 6 | ) 7 | 8 | var Log logger.Logger = discard.New() 9 | 10 | func Errorf(format string, args ...interface{}) { 11 | Log.Errorf(format, args...) 12 | } 13 | 14 | func Error(args ...interface{}) { 15 | Log.Error(args...) 16 | } 17 | 18 | func Warn(args ...interface{}) { 19 | Log.Warn(args...) 20 | } 21 | 22 | func Warnf(format string, args ...interface{}) { 23 | Log.Warnf(format, args...) 24 | } 25 | 26 | func Infof(format string, args ...interface{}) { 27 | Log.Infof(format, args...) 28 | } 29 | 30 | func Info(args ...interface{}) { 31 | Log.Info(args...) 32 | } 33 | 34 | func Debugf(format string, args ...interface{}) { 35 | Log.Debugf(format, args...) 36 | } 37 | 38 | func Debug(args ...interface{}) { 39 | Log.Debug(args...) 40 | } 41 | 42 | // Tracef takes a formatted template string and template arguments for the trace logging level. 43 | func Tracef(format string, args ...interface{}) { 44 | Log.Tracef(format, args...) 45 | } 46 | 47 | // Trace logs the given arguments at the trace logging level. 48 | func Trace(args ...interface{}) { 49 | Log.Trace(args...) 50 | } 51 | 52 | // WithFields returns a message logger with multiple key-value fields. 53 | func WithFields(fields ...interface{}) logger.MessageLogger { 54 | return Log.WithFields(fields...) 55 | } 56 | 57 | // Nested returns a new logger with hard coded key-value pairs 58 | func Nested(fields ...interface{}) logger.Logger { 59 | return Log.Nested(fields...) 60 | } 61 | -------------------------------------------------------------------------------- /pkg/image/image_metadata.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "github.com/google/go-containerregistry/pkg/name" 5 | v1 "github.com/google/go-containerregistry/pkg/v1" 6 | v1Types "github.com/google/go-containerregistry/pkg/v1/types" 7 | ) 8 | 9 | // Metadata represents container image metadata. 10 | type Metadata struct { 11 | // ID is the sha256 of this image config json (not manifest) 12 | ID string 13 | // Size in bytes of all the image layer content sizes (does not include config / manifest / index metadata sizes) 14 | Size int64 15 | Config v1.ConfigFile 16 | MediaType v1Types.MediaType 17 | // --- below fields are optional metadata 18 | Tags []name.Tag 19 | RawManifest []byte 20 | ManifestDigest string 21 | RawConfig []byte 22 | RepoDigests []string 23 | Architecture string 24 | Variant string 25 | OS string 26 | } 27 | 28 | // readImageMetadata extracts the most pertinent information from the underlying image tar. 29 | func readImageMetadata(img v1.Image) (Metadata, error) { 30 | id, err := img.ConfigName() 31 | if err != nil { 32 | return Metadata{}, err 33 | } 34 | 35 | config, err := img.ConfigFile() 36 | if err != nil { 37 | return Metadata{}, err 38 | } 39 | 40 | mediaType, err := img.MediaType() 41 | if err != nil { 42 | return Metadata{}, err 43 | } 44 | 45 | rawConfig, err := img.RawConfigFile() 46 | if err != nil { 47 | return Metadata{}, err 48 | } 49 | 50 | return Metadata{ 51 | ID: id.String(), 52 | Config: *config, 53 | MediaType: mediaType, 54 | RawConfig: rawConfig, 55 | }, nil 56 | } 57 | -------------------------------------------------------------------------------- /pkg/image/oci/directory_provider_test.go: -------------------------------------------------------------------------------- 1 | package oci 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/anchore/stereoscope/pkg/file" 10 | ) 11 | 12 | func Test_NewProviderFromPath(t *testing.T) { 13 | //GIVEN 14 | path := "path" 15 | generator := file.TempDirGenerator{} 16 | defer generator.Cleanup() 17 | 18 | //WHEN 19 | provider := NewDirectoryProvider(&generator, path).(*directoryImageProvider) 20 | 21 | //THEN 22 | assert.NotNil(t, provider.path) 23 | assert.NotNil(t, provider.tmpDirGen) 24 | } 25 | 26 | func Test_Directory_Provider(t *testing.T) { 27 | //GIVEN 28 | tests := []struct { 29 | name string 30 | path string 31 | expectedErr bool 32 | }{ 33 | {"fails to read from path", "", true}, 34 | {"fails to read invalid oci manifest", "test-fixtures/invalid_file", true}, 35 | {"fails to read valid oci manifest with no images", "test-fixtures/no_manifests", true}, 36 | {"fails to read an invalid oci directory", "test-fixtures/valid_manifest", true}, 37 | {"reads a valid oci directory", "test-fixtures/valid_oci_dir", false}, 38 | } 39 | 40 | tmpDirGen := file.NewTempDirGenerator("tempDir") 41 | defer tmpDirGen.Cleanup() 42 | 43 | for _, tc := range tests { 44 | provider := NewDirectoryProvider(tmpDirGen, tc.path) 45 | t.Run(tc.name, func(t *testing.T) { 46 | //WHEN 47 | image, err := provider.Provide(context.Background()) 48 | 49 | //THEN 50 | if tc.expectedErr { 51 | assert.Error(t, err) 52 | assert.Nil(t, image) 53 | } else { 54 | assert.NoError(t, err) 55 | assert.NotNil(t, image) 56 | } 57 | 58 | }) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /pkg/image/containerd/jobs.go: -------------------------------------------------------------------------------- 1 | package containerd 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/opencontainers/go-digest" 7 | ocispec "github.com/opencontainers/image-spec/specs-go/v1" 8 | ) 9 | 10 | // note: this was copied from https://github.com/containerd/containerd/blob/v1.7.0/cmd/ctr/commands/content/content.go 11 | // and is Apache 2.0 licensed 12 | 13 | // jobs provides a way of identifying the download keys for a particular task 14 | // encountering during the pull walk. 15 | // 16 | // This is very minimal and will probably be replaced with something more 17 | // featured. 18 | type jobs struct { 19 | name string 20 | added map[digest.Digest]struct{} 21 | descs []ocispec.Descriptor 22 | mu sync.Mutex 23 | resolved bool 24 | } 25 | 26 | // newJobs creates a new instance of the job status tracker 27 | func newJobs(name string) *jobs { 28 | return &jobs{ 29 | name: name, 30 | added: map[digest.Digest]struct{}{}, 31 | } 32 | } 33 | 34 | // Add adds a descriptor to be tracked 35 | func (j *jobs) Add(desc ocispec.Descriptor) { 36 | j.mu.Lock() 37 | defer j.mu.Unlock() 38 | j.resolved = true 39 | 40 | if _, ok := j.added[desc.Digest]; ok { 41 | return 42 | } 43 | j.descs = append(j.descs, desc) 44 | j.added[desc.Digest] = struct{}{} 45 | } 46 | 47 | // jobs returns a list of all tracked descriptors 48 | func (j *jobs) Jobs() []ocispec.Descriptor { 49 | j.mu.Lock() 50 | defer j.mu.Unlock() 51 | 52 | var descs []ocispec.Descriptor 53 | return append(descs, j.descs...) 54 | } 55 | 56 | // IsResolved checks whether a descriptor has been resolved 57 | func (j *jobs) IsResolved() bool { 58 | j.mu.Lock() 59 | defer j.mu.Unlock() 60 | return j.resolved 61 | } 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # stereoscope 2 | 3 |

4 |  Go Report Card  5 |  GitHub go.mod Go version  6 |  License: Apache-2.0  7 |  Join our Discourse  8 |

9 | 10 | A library for working with container image contents, layer file trees, and squashed file trees. 11 | 12 | ## Getting Started 13 | 14 | See `examples/basic.go` 15 | 16 | ```bash 17 | docker image save centos:8 -o centos.tar 18 | go run examples/basic.go ./centos.tar 19 | ``` 20 | 21 | Note: To run tests you will need `skopeo` installed. 22 | 23 | ## Overview 24 | 25 | This library provides the means to: 26 | - parse and read images from multiple sources, supporting: 27 | - docker V2 schema images from the docker daemon, podman, or archive 28 | - OCI images from disk, directory, or registry 29 | - singularity formatted image files 30 | - build a file tree representing each layer blob 31 | - create a squashed file tree representation for each layer 32 | - search one or more file trees for selected paths 33 | - catalog file metadata in all layers 34 | - query the underlying image tar for content (file content within a layer) 35 | -------------------------------------------------------------------------------- /pkg/image/test-fixtures/generators/fixture-2.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -ue 3 | 4 | realpath() { 5 | [[ $1 = /* ]] && echo "$1" || echo "$PWD/${1#./}" 6 | } 7 | 8 | FIXTURE_TAR_PATH=$1 9 | FIXTURE_NAME=$(basename $FIXTURE_TAR_PATH) 10 | FIXTURE_DIR=$(realpath $(dirname $FIXTURE_TAR_PATH)) 11 | 12 | # note: since tar --sort is not an option on mac, and we want these generation scripts to be generally portable, we've 13 | # elected to use docker to generate the tar 14 | docker run --rm -i \ 15 | -u $(id -u):$(id -g) \ 16 | -v ${FIXTURE_DIR}:/scratch \ 17 | -w /scratch \ 18 | ubuntu:latest \ 19 | /bin/bash -xs < path/branch.d/one/file-1.txt 29 | echo "forth file" > path/branch.d/one/file-4.d 30 | echo "multi ext file" > path/branch.d/one/file-4.tar.gz 31 | echo "hidden file" > path/branch.d/one/.file-4.tar.gz 32 | 33 | ln -s path/branch.d path/common/branch.d 34 | ln -s path/branch.d path/common/branch 35 | ln -s path/branch.d/one/file-4.d path/common/file-4 36 | ln -s path/branch.d/one/file-1.txt path/common/file-1.d 37 | 38 | echo "second file" > path/branch.d/two/file-2.txt 39 | 40 | echo "third file" > path/file-3.txt 41 | 42 | # permissions 43 | chmod -R 755 path 44 | chmod -R 700 path/branch/one/ 45 | chmod 664 path/file-3.txt 46 | 47 | # tar + owner 48 | # note: sort by name is important for test file header entry ordering 49 | tar --sort=name --owner=1337 --group=5432 -cvf "/scratch/${FIXTURE_NAME}" path/ 50 | 51 | popd 52 | EOF 53 | -------------------------------------------------------------------------------- /pkg/file/squashfs_walk.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "io/fs" 5 | "os" 6 | 7 | "github.com/sylabs/squashfs" 8 | ) 9 | 10 | // SquashFSVisitor is the type of the function called by WalkSquashFS to visit each file or 11 | // directory. 12 | // 13 | // The sqfsPath argument contains the path to the SquashFS filesystem that was passed to 14 | // WalkSquashFS. The filePath argument contains the full path of the file or directory within the 15 | // SquashFS filesystem. 16 | // 17 | // The error result returned by the function controls how WalkSquashFS continues. If the function 18 | // returns the special value fs.SkipDir, WalkSquashFS skips the current directory (filePath if 19 | // d.IsDir() is true, otherwise filePath's parent directory). Otherwise, if the function returns a 20 | // non-nil error, WalkSquashFS stops entirely and returns that error. 21 | type SquashFSVisitor func(fsys fs.FS, sqfsPath, filePath string) error 22 | 23 | // WalkSquashFS walks the file tree within the SquashFS filesystem at sqfsPath, calling fn for each 24 | // file or directory in the tree, including root. 25 | func WalkSquashFS(sqfsPath string, fn SquashFSVisitor) error { 26 | f, err := os.Open(sqfsPath) 27 | if err != nil { 28 | return err 29 | } 30 | defer f.Close() 31 | 32 | fsys, err := squashfs.NewReader(f) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | return fs.WalkDir(fsys, ".", walkDir(fsys, sqfsPath, fn)) 38 | } 39 | 40 | // walkDir returns a fs.WalkDirFunc bound to fn. 41 | func walkDir(fsys fs.FS, sqfsPath string, fn SquashFSVisitor) fs.WalkDirFunc { 42 | return func(path string, _ fs.DirEntry, err error) error { 43 | if err != nil { 44 | return err 45 | } 46 | 47 | return fn(fsys, sqfsPath, path) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /pkg/file/temp_dir_generator.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | 7 | "github.com/hashicorp/go-multierror" 8 | ) 9 | 10 | type TempDirGenerator struct { 11 | rootPrefix string 12 | rootLocation string 13 | children []*TempDirGenerator 14 | } 15 | 16 | func NewTempDirGenerator(name string) *TempDirGenerator { 17 | return &TempDirGenerator{ 18 | rootPrefix: name, 19 | } 20 | } 21 | 22 | func (t *TempDirGenerator) getOrCreateRootLocation() (string, error) { 23 | if t.rootLocation == "" { 24 | location, err := os.MkdirTemp("", t.rootPrefix+"-") 25 | if err != nil { 26 | return "", err 27 | } 28 | 29 | t.rootLocation = location 30 | } 31 | return t.rootLocation, nil 32 | } 33 | 34 | // NewGenerator creates a child generator capable of making sibling temp directories. 35 | func (t *TempDirGenerator) NewGenerator() *TempDirGenerator { 36 | gen := NewTempDirGenerator(t.rootPrefix) 37 | t.children = append(t.children, gen) 38 | return gen 39 | } 40 | 41 | // NewDirectory creates a new temp dir within the generators prefix temp dir. 42 | func (t *TempDirGenerator) NewDirectory(name ...string) (string, error) { 43 | location, err := t.getOrCreateRootLocation() 44 | if err != nil { 45 | return "", err 46 | } 47 | 48 | return os.MkdirTemp(location, strings.Join(name, "-")+"-") 49 | } 50 | 51 | // Cleanup deletes all temp dirs created by this generator and any child generator. 52 | func (t *TempDirGenerator) Cleanup() error { 53 | var allErrs error 54 | for _, gen := range t.children { 55 | if err := gen.Cleanup(); err != nil { 56 | allErrs = multierror.Append(allErrs, err) 57 | } 58 | } 59 | if t.rootLocation != "" { 60 | if err := os.RemoveAll(t.rootLocation); err != nil { 61 | allErrs = multierror.Append(allErrs, err) 62 | } 63 | } 64 | return allErrs 65 | } 66 | -------------------------------------------------------------------------------- /pkg/file/lazy_read_closer.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "os" 7 | ) 8 | 9 | var _ io.ReadCloser = (*LazyReadCloser)(nil) 10 | var _ io.Seeker = (*LazyReadCloser)(nil) 11 | var _ io.ReaderAt = (*LazyReadCloser)(nil) 12 | 13 | // LazyReadCloser is a "lazy" read closer, allocating a file descriptor for the given path only upon the first Read() call. 14 | type LazyReadCloser struct { 15 | // path is the path to be opened 16 | path string 17 | // file is the io.ReadCloser source for the path 18 | file *os.File 19 | } 20 | 21 | // NewLazyReadCloser creates a new LazyReadCloser for the given path. 22 | func NewLazyReadCloser(path string) *LazyReadCloser { 23 | return &LazyReadCloser{ 24 | path: path, 25 | } 26 | } 27 | 28 | // Read implements the io.Reader interface for the previously loaded path, opening the file upon the first invocation. 29 | func (d *LazyReadCloser) Read(b []byte) (n int, err error) { 30 | if err := d.openFile(); err != nil { 31 | return 0, err 32 | } 33 | return d.file.Read(b) 34 | } 35 | 36 | // Close implements the io.Closer interface for the previously loaded path / opened file. 37 | func (d *LazyReadCloser) Close() error { 38 | if d.file == nil { 39 | return nil 40 | } 41 | 42 | err := d.file.Close() 43 | if err != nil && errors.Is(err, os.ErrClosed) { 44 | err = nil 45 | } 46 | d.file = nil 47 | return err 48 | } 49 | 50 | func (d *LazyReadCloser) Seek(offset int64, whence int) (int64, error) { 51 | if err := d.openFile(); err != nil { 52 | return 0, err 53 | } 54 | 55 | return d.file.Seek(offset, whence) 56 | } 57 | 58 | func (d *LazyReadCloser) ReadAt(p []byte, off int64) (n int, err error) { 59 | if err := d.openFile(); err != nil { 60 | return 0, err 61 | } 62 | 63 | return d.file.ReadAt(p, off) 64 | } 65 | 66 | func (d *LazyReadCloser) openFile() error { 67 | if d.file != nil { 68 | return nil 69 | } 70 | 71 | var err error 72 | d.file, err = os.Open(d.path) 73 | return err 74 | } 75 | -------------------------------------------------------------------------------- /.github/scripts/trigger-release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eu 3 | 4 | bold=$(tput bold) 5 | normal=$(tput sgr0) 6 | 7 | GH_CLI=.tool/gh 8 | 9 | if ! [ -x "$(command -v $GH_CLI)" ]; then 10 | echo "The GitHub CLI could not be found. run: make bootstrap" 11 | exit 1 12 | fi 13 | 14 | $GH_CLI auth status 15 | 16 | # set the default repo in cases where multiple remotes are defined 17 | $GH_CLI repo set-default anchore/stereoscope 18 | 19 | export GITHUB_TOKEN="${GITHUB_TOKEN-"$($GH_CLI auth token)"}" 20 | 21 | # we need all of the git state to determine the next version. Since tagging is done by 22 | # the release pipeline it is possible to not have all of the tags from previous releases. 23 | git fetch --tags 24 | 25 | # populates the CHANGELOG.md and VERSION files 26 | echo "${bold}Generating changelog...${normal}" 27 | make changelog 2> /dev/null 28 | 29 | NEXT_VERSION=$(cat VERSION) 30 | 31 | if [[ "$NEXT_VERSION" == "" || "${NEXT_VERSION}" == "(Unreleased)" ]]; then 32 | echo "Could not determine the next version to release. Exiting..." 33 | exit 1 34 | fi 35 | 36 | while true; do 37 | read -p "${bold}Do you want to trigger a release for version '${NEXT_VERSION}'?${normal} [y/n] " yn 38 | case $yn in 39 | [Yy]* ) echo; break;; 40 | [Nn]* ) echo; echo "Cancelling release..."; exit;; 41 | * ) echo "Please answer yes or no.";; 42 | esac 43 | done 44 | 45 | echo "${bold}Kicking off release for ${NEXT_VERSION}${normal}..." 46 | echo 47 | $GH_CLI workflow run release.yaml -f version=${NEXT_VERSION} 48 | 49 | echo 50 | echo "${bold}Waiting for release to start...${normal}" 51 | sleep 10 52 | 53 | set +e 54 | 55 | echo "${bold}Head to the release workflow to monitor the release:${normal} $($GH_CLI run list --workflow=release.yaml --limit=1 --json url --jq '.[].url')" 56 | id=$($GH_CLI run list --workflow=release.yaml --limit=1 --json databaseId --jq '.[].databaseId') 57 | $GH_CLI run watch $id --exit-status || (echo ; echo "${bold}Logs of failed step:${normal}" && GH_PAGER="" $GH_CLI run view $id --log-failed) 58 | -------------------------------------------------------------------------------- /pkg/image/sif/archive_provider.go: -------------------------------------------------------------------------------- 1 | package sif 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/go-containerregistry/pkg/v1/partial" 7 | 8 | "github.com/anchore/stereoscope/pkg/file" 9 | "github.com/anchore/stereoscope/pkg/image" 10 | ) 11 | 12 | const ProviderName = image.SingularitySource 13 | 14 | // NewArchiveProvider creates a new provider instance for the Singularity Image Format (SIF) image 15 | // at path. 16 | func NewArchiveProvider(tmpDirGen *file.TempDirGenerator, path string) image.Provider { 17 | return &singularityImageProvider{ 18 | tmpDirGen: tmpDirGen, 19 | path: path, 20 | } 21 | } 22 | 23 | // singularityImageProvider is an image.Provider for a Singularity Image Format (SIF) image. 24 | type singularityImageProvider struct { 25 | tmpDirGen *file.TempDirGenerator 26 | path string 27 | } 28 | 29 | func (p *singularityImageProvider) Name() string { 30 | return ProviderName 31 | } 32 | 33 | // Provide returns an Image that represents a Singularity Image Format (SIF) image. 34 | func (p *singularityImageProvider) Provide(_ context.Context) (*image.Image, error) { 35 | // We need to map the SIF to a GGCR v1.Image. Start with an implementation of the GGCR 36 | // partial.UncompressedImageCore interface. 37 | si, err := newSIFImage(p.path) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | // Promote our partial.UncompressedImageCore implementation to an v1.Image. 43 | ui, err := partial.UncompressedToImage(si) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | // The returned image must reference a content cache dir. 49 | contentCacheDir, err := p.tmpDirGen.NewDirectory() 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | // Apply user-supplied metadata last to override any default behavior. 55 | metadata := []image.AdditionalMetadata{ 56 | image.WithOS("linux"), 57 | image.WithArchitecture(si.arch, ""), 58 | } 59 | 60 | out := image.New(ui, p.tmpDirGen, contentCacheDir, metadata...) 61 | err = out.Read() 62 | if err != nil { 63 | return nil, err 64 | } 65 | return out, err 66 | } 67 | -------------------------------------------------------------------------------- /pkg/image/oci/tarball_provider.go: -------------------------------------------------------------------------------- 1 | package oci 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "time" 8 | 9 | "github.com/anchore/stereoscope/internal/log" 10 | "github.com/anchore/stereoscope/pkg/file" 11 | "github.com/anchore/stereoscope/pkg/image" 12 | ) 13 | 14 | const Archive image.Source = image.OciTarballSource 15 | 16 | // NewArchiveProvider creates a new provider instance for the specific image tarball already at the given path. 17 | func NewArchiveProvider(tmpDirGen *file.TempDirGenerator, path string) image.Provider { 18 | return &tarballImageProvider{ 19 | tmpDirGen: tmpDirGen, 20 | path: path, 21 | } 22 | } 23 | 24 | // tarballImageProvider is an image.Provider for an OCI image (V1) for an existing tar on disk (from a buildah push oci-archive:.tar command). 25 | type tarballImageProvider struct { 26 | tmpDirGen *file.TempDirGenerator 27 | path string 28 | } 29 | 30 | func (p *tarballImageProvider) Name() string { 31 | return Archive 32 | } 33 | 34 | // Provide an image object that represents the OCI image from a tarball. 35 | func (p *tarballImageProvider) Provide(ctx context.Context) (*image.Image, error) { 36 | // note: we are untaring the image and using the existing directory provider, we could probably enhance the google 37 | // container registry lib to do this without needing to untar to a temp dir (https://github.com/google/go-containerregistry/issues/726) 38 | f, err := os.Open(p.path) 39 | if err != nil { 40 | return nil, fmt.Errorf("unable to open OCI tarball: %w", err) 41 | } 42 | 43 | tempDir, err := p.tmpDirGen.NewDirectory("oci-tarball-image") 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | log.WithFields("file", p.path, "tempDir", tempDir).Trace("extracting OCI tar file to tempdir") 49 | startTime := time.Now() 50 | 51 | if err = file.UntarToDirectory(f, tempDir); err != nil { 52 | return nil, err 53 | } 54 | 55 | log.WithFields("file", p.path, "tempDir", tempDir, "time", time.Since(startTime)).Debug("extracted OCI tar file to tempdir") 56 | 57 | return NewDirectoryProvider(p.tmpDirGen, tempDir).Provide(ctx) 58 | } 59 | -------------------------------------------------------------------------------- /pkg/file/lazy_read_closer_test.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "io" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestDeferredReadCloser(t *testing.T) { 11 | filepath := "test-fixtures/a-file.txt" 12 | allContent := getFixture(t, filepath) 13 | 14 | dReader := NewLazyReadCloser(filepath) 15 | require.Nil(t, dReader.file, "should not have a file, but we do somehow") 16 | 17 | actualContents, err := io.ReadAll(dReader) 18 | require.NotNil(t, dReader.file, "should have a file, but we do not somehow") 19 | require.NoError(t, err) 20 | require.Equal(t, allContent, actualContents) 21 | 22 | require.NoError(t, dReader.Close()) 23 | require.Nil(t, dReader.file, "should not have a file, but we do somehow") 24 | } 25 | 26 | func TestLazyReader_ReadAt(t *testing.T) { 27 | filepath := "test-fixtures/a-file.txt" 28 | allContent := getFixture(t, filepath) 29 | 30 | dReader := NewLazyReadCloser(filepath) 31 | require.Nil(t, dReader.file, "should not have a file, but we do somehow") 32 | 33 | off := 5 34 | left := len(allContent) - off 35 | s := make([]byte, left) 36 | n, err := dReader.ReadAt(s, int64(off)) 37 | require.NoError(t, err) 38 | require.Equal(t, left, n) 39 | require.Equal(t, allContent[off:], s) 40 | 41 | require.NoError(t, dReader.Close()) 42 | require.Nil(t, dReader.file, "should not have a file, but we do somehow") 43 | 44 | } 45 | 46 | func TestLazyReader_Seek(t *testing.T) { 47 | filepath := "test-fixtures/a-file.txt" 48 | allContent := getFixture(t, filepath) 49 | 50 | dReader := NewLazyReadCloser(filepath) 51 | require.Nil(t, dReader.file, "should not have a file, but we do somehow") 52 | 53 | off := 5 54 | left := len(allContent) - off 55 | s := make([]byte, left) 56 | seek, err := dReader.Seek(int64(off), io.SeekStart) 57 | require.NoError(t, err) 58 | require.Equal(t, seek, int64(off)) 59 | 60 | n, err := dReader.Read(s) 61 | require.NoError(t, err) 62 | require.Equal(t, left, n) 63 | require.Equal(t, allContent[off:], s) 64 | 65 | require.NoError(t, dReader.Close()) 66 | require.Nil(t, dReader.file, "should not have a file, but we do somehow") 67 | } 68 | -------------------------------------------------------------------------------- /pkg/filetree/link_strategy.go: -------------------------------------------------------------------------------- 1 | package filetree 2 | 3 | const ( 4 | // followAncestorLinks deals with link resolution for all constituent paths of a given path (everything except the basename). 5 | // This should not be available to users but may be used internal to the package. 6 | followAncestorLinks LinkResolutionOption = iota 7 | 8 | // FollowBasenameLinks deals with link resolution for the basename of a given path (not ancestors). 9 | FollowBasenameLinks 10 | 11 | // DoNotFollowDeadBasenameLinks deals with a special case in link resolution: when a basename resolution results in 12 | // a dead link. This option ensures that the last link file that resolved is returned (which exists) instead of 13 | // the non-existing path. This is useful when the caller wants to do custom link resolution (e.g. for container 14 | // images: the link is dead in this layer squash, but does it resolve in a higher layer?). 15 | DoNotFollowDeadBasenameLinks 16 | ) 17 | 18 | // LinkResolutionOption is a single link resolution rule. 19 | type LinkResolutionOption int 20 | 21 | // linkResolutionStrategy describes the full set of possible link resolution rules and their indications (to follow or not). 22 | type linkResolutionStrategy struct { 23 | FollowAncestorLinks bool 24 | FollowBasenameLinks bool 25 | DoNotFollowDeadBasenameLinks bool 26 | } 27 | 28 | // newLinkResolutionStrategy creates a new linkResolutionStrategy for the given set of LinkResolutionOptions. 29 | func newLinkResolutionStrategy(options ...LinkResolutionOption) linkResolutionStrategy { 30 | s := linkResolutionStrategy{} 31 | for _, o := range options { 32 | switch o { 33 | case FollowBasenameLinks: 34 | s.FollowBasenameLinks = true 35 | case DoNotFollowDeadBasenameLinks: 36 | s.DoNotFollowDeadBasenameLinks = true 37 | case followAncestorLinks: 38 | s.FollowAncestorLinks = true 39 | } 40 | } 41 | return s 42 | } 43 | 44 | // FollowLinks indicates if the current strategy supports following links in one way or another (either in path 45 | // ancestors or basename). 46 | func (s linkResolutionStrategy) FollowLinks() bool { 47 | return s.FollowAncestorLinks || s.FollowBasenameLinks 48 | } 49 | -------------------------------------------------------------------------------- /pkg/file/path_set.go: -------------------------------------------------------------------------------- 1 | //nolint:dupl 2 | package file 3 | 4 | import ( 5 | "sort" 6 | ) 7 | 8 | type PathSet map[Path]struct{} 9 | 10 | func NewPathSet(is ...Path) PathSet { 11 | // TODO: replace with single generic implementation that also incorporates other set implementations 12 | s := make(PathSet) 13 | s.Add(is...) 14 | return s 15 | } 16 | 17 | func (s PathSet) Size() int { 18 | return len(s) 19 | } 20 | 21 | func (s PathSet) Merge(other PathSet) { 22 | for _, i := range other.List() { 23 | s.Add(i) 24 | } 25 | } 26 | 27 | func (s PathSet) Add(ids ...Path) { 28 | for _, i := range ids { 29 | s[i] = struct{}{} 30 | } 31 | } 32 | 33 | func (s PathSet) Remove(ids ...Path) { 34 | for _, i := range ids { 35 | delete(s, i) 36 | } 37 | } 38 | 39 | func (s PathSet) Contains(i Path) bool { 40 | _, ok := s[i] 41 | return ok 42 | } 43 | 44 | func (s PathSet) Clear() { 45 | clear(s) 46 | } 47 | 48 | func (s PathSet) List() []Path { 49 | ret := make([]Path, 0, len(s)) 50 | for i := range s { 51 | ret = append(ret, i) 52 | } 53 | return ret 54 | } 55 | 56 | func (s PathSet) Sorted() []Path { 57 | ids := s.List() 58 | 59 | sort.Slice(ids, func(i, j int) bool { 60 | return ids[i] < ids[j] 61 | }) 62 | 63 | return ids 64 | } 65 | 66 | func (s PathSet) ContainsAny(ids ...Path) bool { 67 | for _, i := range ids { 68 | _, ok := s[i] 69 | if ok { 70 | return true 71 | } 72 | } 73 | return false 74 | } 75 | 76 | type PathCountSet map[Path]int 77 | 78 | func NewPathCountSet(is ...Path) PathCountSet { 79 | s := make(PathCountSet) 80 | s.Add(is...) 81 | return s 82 | } 83 | 84 | func (s PathCountSet) Add(ids ...Path) { 85 | for _, i := range ids { 86 | if _, ok := s[i]; !ok { 87 | s[i] = 1 88 | continue 89 | } 90 | s[i]++ 91 | } 92 | } 93 | 94 | func (s PathCountSet) Remove(ids ...Path) { 95 | for _, i := range ids { 96 | if _, ok := s[i]; !ok { 97 | continue 98 | } 99 | 100 | s[i]-- 101 | if s[i] <= 0 { 102 | delete(s, i) 103 | } 104 | } 105 | } 106 | 107 | func (s PathCountSet) Contains(i Path) bool { 108 | count, ok := s[i] 109 | return ok && count > 0 110 | } 111 | -------------------------------------------------------------------------------- /pkg/file/tar_index.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | ) 8 | 9 | type TarIndexVisitor func(TarIndexEntry) error 10 | 11 | // TarIndex is a tar reader capable of O(1) fetching of entry contents after the first read. 12 | type TarIndex struct { 13 | indexByName map[string][]TarIndexEntry 14 | } 15 | 16 | // NewTarIndex creates a new TarIndex that is already indexed. 17 | func NewTarIndex(tarFilePath string, onIndex TarIndexVisitor) (*TarIndex, error) { 18 | t := &TarIndex{ 19 | indexByName: make(map[string][]TarIndexEntry), 20 | } 21 | tarFileHandle, err := os.Open(tarFilePath) 22 | if err != nil { 23 | return nil, err 24 | } 25 | defer tarFileHandle.Close() 26 | 27 | visitor := func(entry TarFileEntry) error { 28 | // keep track of the current location (just after reading the tar header) as this is the file content for the 29 | // current entry being processed. 30 | entrySeekPosition, err := tarFileHandle.Seek(0, io.SeekCurrent) 31 | if err != nil { 32 | return fmt.Errorf("unable to read current position in tar: %v", err) 33 | } 34 | 35 | // keep track of the header position for this entry; the current tarFileHandle position is where the entry 36 | // body payload starts (after the header has been read). 37 | indexEntry := TarIndexEntry{ 38 | path: tarFileHandle.Name(), 39 | sequence: entry.Sequence, 40 | header: entry.Header, 41 | seekPosition: entrySeekPosition, 42 | } 43 | t.indexByName[entry.Header.Name] = append(t.indexByName[entry.Header.Name], indexEntry) 44 | 45 | // run though the visitors 46 | if onIndex != nil { 47 | if err := onIndex(indexEntry); err != nil { 48 | return fmt.Errorf("failed visitor on tar indexEntry: %w", err) 49 | } 50 | } 51 | 52 | return nil 53 | } 54 | 55 | return t, IterateTar(tarFileHandle, visitor) 56 | } 57 | 58 | // EntriesByName fetches all TarFileEntries for the given tar header name. 59 | func (t *TarIndex) EntriesByName(name string) ([]TarFileEntry, error) { 60 | if indexes, exists := t.indexByName[name]; exists { 61 | entries := make([]TarFileEntry, len(indexes)) 62 | for i, index := range indexes { 63 | entries[i] = index.ToTarFileEntry() 64 | } 65 | return entries, nil 66 | } 67 | return nil, nil 68 | } 69 | -------------------------------------------------------------------------------- /pkg/image/test-fixtures/certs/server.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIF+TCCA+GgAwIBAgIUZJYC/jyCZquu2E06VZFHDTt9LnQwDQYJKoZIhvcNAQEL 3 | BQAwfzELMAkGA1UEBhMCWFgxEjAQBgNVBAgMCVN0YXRlTmFtZTERMA8GA1UEBwwI 4 | Q2l0eU5hbWUxFDASBgNVBAoMC0NvbXBhbnlOYW1lMRswGQYDVQQLDBJDb21wYW55 5 | U2VjdGlvbk5hbWUxFjAUBgNVBAMMDXJlZ2lzdHJ5Lm51bGwwHhcNMjMwODI0MjE1 6 | MjQ1WhcNMzMwODIxMjE1MjQ1WjB/MQswCQYDVQQGEwJYWDESMBAGA1UECAwJU3Rh 7 | dGVOYW1lMREwDwYDVQQHDAhDaXR5TmFtZTEUMBIGA1UECgwLQ29tcGFueU5hbWUx 8 | GzAZBgNVBAsMEkNvbXBhbnlTZWN0aW9uTmFtZTEWMBQGA1UEAwwNcmVnaXN0cnku 9 | bnVsbDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAL1AGed5Mv5BOajb 10 | +BrDJIbsvNvyVQOOz91+rqfJlPXHUlcnpvqwW2UY+gvxTj9y4IiElSs4klUiZ9wf 11 | GzhTnV6tlN2n8dXAvQlfyKxlCEcvsU0DelSs4ih5WfUzLh3FECK7sAsyUsQtCL59 12 | Ro8u5NWSx2EqAcAZdhwRJWrnUOZRxLT4idQkzyOCzKNG4GgwrLwRDMtmsZnxTTDv 13 | Al6dobW8luLZKxRUm+/mOC0WMeHfNgE4crkR+O1VpzXRkTeNSzapu97w0mfzwpAe 14 | PcRIqfBNA0Fw6I/Mkn0bkaNjHyNtpfwasDfTcs+Y0YjO9x+DNwV895C4sa6+A+pX 15 | gOAb/BVsNAL0/Z6jcOnSrprfQdw/6nNPayxcHKsBmZv6vczGbLXhBP3zSTz+BaeO 16 | T8j/15VTELizNs9wdmWmJeWd5kH+gpE15Wl2VVNTazW5BrJmntqiExW8gJ1ArgPe 17 | HpgySTrYG/RtYTYLIL4/X0PBJ9CgqJpbTblhFYSX2GI36CkbIHsxYBnIzOc/7uCF 18 | baYtM8LwaTTpcz1lw2HI2auQEvFOT1R+vxjw1s2pXj6P6fPht3+iuJDYIetT/nIC 19 | gJxLkXzl5iZtAeZUrTGS6HV9Ygcmb8FshhcBas+i9khaEci2d1WjxJ2CKFVhVm2K 20 | H2hqGEmXjnMdrei+NVklagaqJlvnAgMBAAGjbTBrMB0GA1UdDgQWBBREm9XIuEgg 21 | VXA23r3hStAtOTrKUjAfBgNVHSMEGDAWgBREm9XIuEggVXA23r3hStAtOTrKUjAP 22 | BgNVHRMBAf8EBTADAQH/MBgGA1UdEQQRMA+CDXJlZ2lzdHJ5Lm51bGwwDQYJKoZI 23 | hvcNAQELBQADggIBAJXBMshYydCA4UWoIQu7eh9th53F8XEGdT8Ckp1D4haEsLN0 24 | ty2hYrS0NRzBRfC/zTkg1gtCigwYjJ2rPAmJuOnvsbneZ9w7AShw+dR3NBuLIZHM 25 | 6nySJ2LaH/85Xf2ordl+a8vlQsGWJv1L324LEWmOj5zds52LDdfRtN4QiIJo/trF 26 | 5cpXzFkiAbukKxtZs24cUQcVWmJshkBLDi60sl8ZYt/WQUl5BORR5PywE8/Z3ei3 27 | P1h/Jw26hCDeasMpB/+US7L9ZY7Ysbrg7bKB/WONe0GQ5AVKnSfzx/eU/sKJwN6h 28 | kB/dJ4yFX5SOsr5dgch257JW0Cb78aAv08x4YHZUj1eOxnHp3C2FFlSVpJQcOeAt 29 | /Axl/r8ALJDl9VidkBlxDeTPGVSetpkWa9mbX3VvfxVsq889hgdy+iQWcJeU8ha/ 30 | DOal36rl3+oGhGHs8DewVRG42sBoVMuL5HPDrJDL8odVqbdidI3yF2grq8lwF1Xj 31 | y60ZjON0zFyoXbRGFKhn+Cs7V8Pu+zEOzlTx85UNzPXWXsSDTFukwxgxdjRbVaY7 32 | EcpZW4zvIINfmkul0RhA4/YcOBQ7uEMH8Cke+E/EambO8VA/lege8Lk7rhdvEJ9N 33 | iRsh1tlEzNbJtjpxljXry8vyBN8ZtgRZx32vKErDN5eaDmjmNuFkxFF7d+Fh 34 | -----END CERTIFICATE----- 35 | -------------------------------------------------------------------------------- /.github/actions/bootstrap/action.yaml: -------------------------------------------------------------------------------- 1 | name: "Bootstrap" 2 | 3 | description: "Bootstrap all tools and dependencies" 4 | inputs: 5 | go-version: 6 | description: "Go version to install" 7 | required: true 8 | default: ">= 1.24" 9 | go-dependencies: 10 | description: "Download go dependencies" 11 | required: true 12 | default: "true" 13 | cache-key-prefix: 14 | description: "Prefix all cache keys with this value" 15 | required: true 16 | default: "1ac8281053" 17 | compute-fingerprints: 18 | description: "Compute test fixture fingerprints" 19 | required: true 20 | default: "true" 21 | bootstrap-apt-packages: 22 | description: "Space delimited list of tools to install via apt" 23 | default: "" 24 | 25 | 26 | runs: 27 | using: "composite" 28 | steps: 29 | # note: go mod and build is automatically cached on default with v4+ 30 | - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c #v6.1.0 31 | if: inputs.go-version != '' 32 | with: 33 | go-version: ${{ inputs.go-version }} 34 | 35 | - name: Restore tool cache 36 | id: tool-cache 37 | uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 #v4.3.0 38 | with: 39 | path: ${{ github.workspace }}/.tool 40 | key: ${{ inputs.cache-key-prefix }}-${{ runner.os }}-tool-${{ hashFiles('.binny.yaml') }} 41 | 42 | - name: Install project tools 43 | shell: bash 44 | run: make tools 45 | 46 | - name: Install go dependencies 47 | if: inputs.go-dependencies == 'true' 48 | shell: bash 49 | run: make ci-bootstrap-go 50 | 51 | - name: Install apt packages 52 | if: inputs.bootstrap-apt-packages != '' 53 | shell: bash 54 | env: 55 | APT_PACKAGES: ${{ inputs.bootstrap-apt-packages }} 56 | run: | 57 | # Convert space-separated string to bash array for safe handling 58 | read -ra packages <<< "$APT_PACKAGES" 59 | if [ ${#packages[@]} -gt 0 ]; then 60 | DEBIAN_FRONTEND=noninteractive sudo apt update && sudo -E apt install -y "${packages[@]}" 61 | fi 62 | 63 | - name: Create all cache fingerprints 64 | if: inputs.compute-fingerprints == 'true' 65 | shell: bash 66 | run: make fingerprints 67 | -------------------------------------------------------------------------------- /.binny.yaml: -------------------------------------------------------------------------------- 1 | tools: 2 | # we want to use a pinned version of binny to manage the toolchain (so binny manages itself!) 3 | - name: binny 4 | version: 5 | want: v0.6.3 6 | method: github-release 7 | with: 8 | repo: anchore/binny 9 | 10 | # used for linting 11 | - name: golangci-lint 12 | version: 13 | want: v1.64.7 14 | method: github-release 15 | with: 16 | repo: golangci/golangci-lint 17 | 18 | # used for showing the changelog at release 19 | - name: glow 20 | version: 21 | want: v1.5.1 22 | method: github-release 23 | with: 24 | repo: charmbracelet/glow 25 | 26 | # used to release 27 | - name: goreleaser 28 | version: 29 | want: v1.24.0 30 | method: github-release 31 | with: 32 | repo: goreleaser/goreleaser 33 | 34 | # used for organizing imports during static analysis 35 | - name: gosimports 36 | version: 37 | want: v0.3.8 38 | method: github-release 39 | with: 40 | repo: rinchsan/gosimports 41 | 42 | # used at release to generate the changelog 43 | - name: chronicle 44 | version: 45 | want: v0.8.0 46 | method: github-release 47 | with: 48 | repo: anchore/chronicle 49 | 50 | # used during static analysis for license compliance 51 | - name: bouncer 52 | version: 53 | want: v0.4.0 54 | method: github-release 55 | with: 56 | repo: wagoodman/go-bouncer 57 | 58 | # used for showing benchmark testing 59 | - name: benchstat 60 | version: 61 | want: latest 62 | method: go-proxy 63 | with: 64 | module: golang.org/x/perf 65 | allow-unresolved-version: true 66 | method: go-install 67 | with: 68 | entrypoint: cmd/benchstat 69 | module: golang.org/x/perf 70 | 71 | # used for running all local and CI tasks 72 | - name: task 73 | version: 74 | want: v3.34.1 75 | method: github-release 76 | with: 77 | repo: go-task/task 78 | 79 | # used for triggering a release 80 | - name: gh 81 | version: 82 | want: v2.43.1 83 | method: github-release 84 | with: 85 | repo: cli/cli 86 | 87 | # used for signing the checksums file at release 88 | - name: cosign 89 | version: 90 | want: v2.2.4 91 | method: github-release 92 | with: 93 | repo: sigstore/cosign 94 | -------------------------------------------------------------------------------- /internal/docker/docker_client_test.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/docker/docker/client" 9 | ) 10 | 11 | func Test_newClient(t *testing.T) { 12 | cases := []struct { 13 | name string 14 | providedSocket string 15 | expectedSocket string 16 | setEnv func(t *testing.T) 17 | }{ 18 | { 19 | name: "Test newClient returns the correct default location", 20 | providedSocket: "", 21 | expectedSocket: "unix:///var/run/docker.sock", 22 | }, 23 | { 24 | name: "Test newClient with runtime specific path", 25 | providedSocket: "", 26 | setEnv: func(t *testing.T) { 27 | os.Setenv("DOCKER_HOST", "unix:///var/CUSTOM/docker.sock") 28 | 29 | }, 30 | expectedSocket: "unix:///var/CUSTOM/docker.sock", 31 | }, 32 | { 33 | name: "Test newClient with runtime specific path", 34 | providedSocket: "unix:///var/NEWCUSTOM/docker.sock", 35 | expectedSocket: "unix:///var/NEWCUSTOM/docker.sock", 36 | }, 37 | } 38 | 39 | for _, c := range cases { 40 | t.Run(c.name, func(t *testing.T) { 41 | if c.setEnv != nil { 42 | c.setEnv(t) 43 | } 44 | clientOpts := []client.Opt{ 45 | client.FromEnv, 46 | client.WithAPIVersionNegotiation(), 47 | } 48 | 49 | client, err := newClient(c.providedSocket, clientOpts...) 50 | if err != nil { 51 | t.Errorf("newClient() error = %v", err) 52 | return 53 | } 54 | 55 | if client.DaemonHost() != c.expectedSocket { 56 | t.Errorf("newClient() = %v, want %v", client.DaemonHost(), c.expectedSocket) 57 | } 58 | }) 59 | } 60 | } 61 | 62 | func Test_possibleSocketPaths(t *testing.T) { 63 | cases := []struct { 64 | name string 65 | provided string 66 | expected []string 67 | }{ 68 | { 69 | name: "Test possibleSocketPaths returns the correct default location for darwin", 70 | provided: "darwin", 71 | expected: []string{"", "Library/Containers/com.docker.docker/Data/docker.raw.sock"}, 72 | }, 73 | } 74 | 75 | for _, c := range cases { 76 | t.Run(c.name, func(t *testing.T) { 77 | for i, socketPath := range possibleSocketPaths(c.provided) { 78 | if !strings.HasSuffix(socketPath, c.expected[i]) { 79 | t.Errorf("possibleSocketPaths() = %v, want %v", socketPath, c.expected[i]) 80 | } 81 | } 82 | }) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /pkg/file/temp_dir_generator_test.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "os" 5 | "path" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestTempDirGenerator(t *testing.T) { 13 | tests := []struct { 14 | name string 15 | genPrefix string 16 | names []string 17 | extraGenerators int 18 | }{ 19 | { 20 | name: "3 temp dirs", 21 | genPrefix: "a-special-prefix", 22 | names: []string{ 23 | "a", 24 | "bee", 25 | "si", 26 | }, 27 | }, 28 | { 29 | name: "3 temp dirs on the root generator + 2 extra generators", 30 | genPrefix: "b-special-prefix", 31 | names: []string{ 32 | "a", 33 | "bee", 34 | "si", 35 | }, 36 | extraGenerators: 2, 37 | }, 38 | } 39 | for _, test := range tests { 40 | t.Run(test.name, func(t *testing.T) { 41 | expectedPrefix := path.Join(os.TempDir(), test.genPrefix) 42 | 43 | assert.True(t, !doesGlobExist(t, expectedPrefix+"*"), 44 | "prefix temp dir already exists before test started") 45 | 46 | root := NewTempDirGenerator(test.genPrefix) 47 | 48 | for _, n := range test.names { 49 | d, err := root.NewDirectory(n) 50 | assert.NoError(t, err) 51 | assert.True(t, doesGlobExist(t, d), "sub-temp dir does not exist (root)") 52 | assert.Contains(t, d, expectedPrefix) 53 | assert.NotEmpty(t, root.rootLocation) 54 | assert.Contains(t, d, root.rootLocation) 55 | } 56 | 57 | assert.True(t, doesGlobExist(t, expectedPrefix+"*"), "prefix temp dir does not exist") 58 | 59 | var gen *TempDirGenerator 60 | for i := 0; i < test.extraGenerators; i++ { 61 | gen = root.NewGenerator() 62 | for _, n := range test.names { 63 | d, err := gen.NewDirectory(n) 64 | assert.NoError(t, err) 65 | assert.True(t, doesGlobExist(t, d), "sub-temp dir does not exist (sub)") 66 | assert.Contains(t, d, expectedPrefix) 67 | assert.NotEmpty(t, gen.rootLocation) 68 | assert.Contains(t, d, gen.rootLocation) 69 | } 70 | 71 | } 72 | 73 | assert.NoError(t, root.Cleanup()) 74 | 75 | assert.True(t, !doesGlobExist(t, expectedPrefix+"*"), "cleanup did not remove prefix temp dir") 76 | 77 | }) 78 | } 79 | } 80 | 81 | func doesGlobExist(t *testing.T, pattern string) bool { 82 | t.Helper() 83 | m, err := filepath.Glob(pattern) 84 | if err != nil { 85 | t.Fatal(err) 86 | } 87 | if len(m) > 0 { 88 | return true 89 | } 90 | return false 91 | } 92 | -------------------------------------------------------------------------------- /providers.go: -------------------------------------------------------------------------------- 1 | package stereoscope 2 | 3 | import ( 4 | "github.com/anchore/go-collections" 5 | containerdClient "github.com/anchore/stereoscope/internal/containerd" 6 | "github.com/anchore/stereoscope/pkg/image" 7 | "github.com/anchore/stereoscope/pkg/image/containerd" 8 | "github.com/anchore/stereoscope/pkg/image/docker" 9 | "github.com/anchore/stereoscope/pkg/image/oci" 10 | "github.com/anchore/stereoscope/pkg/image/podman" 11 | "github.com/anchore/stereoscope/pkg/image/sif" 12 | ) 13 | 14 | const ( 15 | FileTag = "file" 16 | DirTag = "dir" 17 | DaemonTag = "daemon" 18 | PullTag = "pull" 19 | RegistryTag = "registry" 20 | ) 21 | 22 | // ImageProviderConfig is the user-configuration containing all configuration needed by stereoscope image providers 23 | type ImageProviderConfig struct { 24 | UserInput string 25 | Platform *image.Platform 26 | Registry image.RegistryOptions 27 | } 28 | 29 | func ImageProviders(cfg ImageProviderConfig) []collections.TaggedValue[image.Provider] { 30 | tempDirGenerator := rootTempDirGenerator.NewGenerator() 31 | return []collections.TaggedValue[image.Provider]{ 32 | // file providers 33 | taggedProvider(docker.NewArchiveProvider(tempDirGenerator, cfg.UserInput), FileTag), 34 | taggedProvider(oci.NewArchiveProvider(tempDirGenerator, cfg.UserInput), FileTag), 35 | taggedProvider(oci.NewDirectoryProvider(tempDirGenerator, cfg.UserInput), FileTag, DirTag), 36 | taggedProvider(sif.NewArchiveProvider(tempDirGenerator, cfg.UserInput), FileTag), 37 | 38 | // daemon providers 39 | taggedProvider(docker.NewDaemonProvider(tempDirGenerator, cfg.UserInput, cfg.Platform), DaemonTag, PullTag), 40 | taggedProvider(podman.NewDaemonProvider(tempDirGenerator, cfg.UserInput, cfg.Platform), DaemonTag, PullTag), 41 | taggedProvider(containerd.NewDaemonProvider(tempDirGenerator, cfg.Registry, containerdClient.Namespace(), cfg.UserInput, cfg.Platform), DaemonTag, PullTag), 42 | 43 | // registry providers 44 | taggedProvider(oci.NewRegistryProvider(tempDirGenerator, cfg.Registry, cfg.UserInput, cfg.Platform), RegistryTag, PullTag), 45 | } 46 | } 47 | 48 | func taggedProvider(provider image.Provider, tags ...string) collections.TaggedValue[image.Provider] { 49 | return collections.NewTaggedValue[image.Provider](provider, append([]string{provider.Name()}, tags...)...) 50 | } 51 | 52 | func allProviderTags() []string { 53 | return collections.TaggedValueSet[image.Provider]{}.Join(ImageProviders(ImageProviderConfig{})...).Tags() 54 | } 55 | -------------------------------------------------------------------------------- /.github/workflows/benchmark-testing.yaml: -------------------------------------------------------------------------------- 1 | name: "Benchmark testing" 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | 7 | permissions: 8 | contents: read 9 | pull-requests: write 10 | 11 | jobs: 12 | 13 | Benchmark-Test: 14 | name: "Benchmark tests" 15 | runs-on: ubuntu-24.04 16 | # note: we want benchmarks to run on pull_request events in order to publish results to a sticky comment, and 17 | # we also want to run on push such that merges to main are recorded to the cache. For this reason we don't filter 18 | # the job by event. 19 | steps: 20 | - uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac #v4.0.0 21 | with: 22 | persist-credentials: false 23 | 24 | - name: Bootstrap environment 25 | uses: ./.github/actions/bootstrap 26 | 27 | - name: Restore base benchmark result 28 | uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 #v4.3.0 29 | with: 30 | path: test/results/benchmark-main.txt 31 | # use base sha for PR or new commit hash for main push in benchmark result key 32 | key: ${{ runner.os }}-bench-${{ (github.event.pull_request.base.sha != github.event.after) && github.event.pull_request.base.sha || github.event.after }} 33 | 34 | - name: Run benchmark tests 35 | id: benchmark 36 | run: | 37 | REF_NAME=${GITHUB_REF##*/} make benchmark 38 | OUTPUT=$(make show-benchstat) 39 | OUTPUT="${OUTPUT//'%'/'%25'}" # URL encode all '%' characters 40 | OUTPUT="${OUTPUT//$'\n'/'%0A'}" # URL encode all '\n' characters 41 | OUTPUT="${OUTPUT//$'\r'/'%0D'}" # URL encode all '\r' characters 42 | echo "::set-output name=result::$OUTPUT" 43 | 44 | - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 #v5.0.0 45 | with: 46 | name: benchmark-test-results 47 | path: test/results/**/* 48 | 49 | - name: Update PR benchmark results comment 50 | uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 #v2.9.4 51 | continue-on-error: true 52 | with: 53 | header: benchmark 54 | message: | 55 | ### Benchmark Test Results 56 | 57 |
58 | Benchmark results from the latest changes vs base branch 59 | 60 | ``` 61 | ${{ steps.benchmark.outputs.result }} 62 | ``` 63 | 64 |
65 | -------------------------------------------------------------------------------- /pkg/filetree/filenode/filenode.go: -------------------------------------------------------------------------------- 1 | package filenode 2 | 3 | import ( 4 | "path" 5 | "path/filepath" 6 | 7 | "github.com/anchore/stereoscope/pkg/file" 8 | "github.com/anchore/stereoscope/pkg/tree/node" 9 | ) 10 | 11 | type FileNode struct { 12 | RealPath file.Path // all constituent paths cannot have links (the base may be a link however) 13 | FileType file.Type 14 | LinkPath file.Path // a relative or absolute path to another file 15 | Reference *file.Reference 16 | } 17 | 18 | func NewDir(p file.Path, ref *file.Reference) *FileNode { 19 | return &FileNode{ 20 | RealPath: p, 21 | FileType: file.TypeDirectory, 22 | Reference: ref, 23 | } 24 | } 25 | 26 | func NewFile(p file.Path, ref *file.Reference) *FileNode { 27 | return &FileNode{ 28 | RealPath: p, 29 | FileType: file.TypeRegular, 30 | Reference: ref, 31 | } 32 | } 33 | 34 | func NewSymLink(p, linkPath file.Path, ref *file.Reference) *FileNode { 35 | return &FileNode{ 36 | RealPath: p, 37 | FileType: file.TypeSymLink, 38 | LinkPath: linkPath, 39 | Reference: ref, 40 | } 41 | } 42 | 43 | func NewHardLink(p, linkPath file.Path, ref *file.Reference) *FileNode { 44 | // hard link MUST be interpreted as an absolute path 45 | linkPath = file.Path(path.Clean(file.DirSeparator + string(linkPath))) 46 | return &FileNode{ 47 | RealPath: p, 48 | FileType: file.TypeHardLink, 49 | LinkPath: linkPath, 50 | Reference: ref, 51 | } 52 | } 53 | 54 | func (n *FileNode) ID() node.ID { 55 | return IDByPath(n.RealPath) 56 | } 57 | 58 | func (n *FileNode) Copy() node.Node { 59 | return &FileNode{ 60 | RealPath: n.RealPath, 61 | FileType: n.FileType, 62 | LinkPath: n.LinkPath, 63 | Reference: n.Reference, 64 | } 65 | } 66 | 67 | func (n *FileNode) IsLink() bool { 68 | return n.FileType == file.TypeHardLink || n.FileType == file.TypeSymLink 69 | } 70 | 71 | func IDByPath(p file.Path) node.ID { 72 | return node.ID(p) 73 | } 74 | 75 | func (n *FileNode) RenderLinkDestination() file.Path { 76 | if !n.IsLink() { 77 | return "" 78 | } 79 | 80 | if n.LinkPath.IsAbsolutePath() { 81 | // use links with absolute paths blindly 82 | return n.LinkPath 83 | } 84 | 85 | // resolve relative link paths 86 | var parentDir string 87 | parentDir, _ = filepath.Split(string(n.RealPath)) // TODO: alex: should this be path.Split, not filepath.Split? 88 | 89 | // assemble relative link path by normalizing: "/cur/dir/../file1.txt" --> "/cur/file1.txt" 90 | return file.Path(path.Clean(path.Join(parentDir, string(n.LinkPath)))) 91 | } 92 | -------------------------------------------------------------------------------- /pkg/file/type.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "archive/tar" 5 | "os" 6 | ) 7 | 8 | const ( 9 | TypeRegular Type = iota 10 | TypeHardLink 11 | TypeSymLink 12 | TypeCharacterDevice 13 | TypeBlockDevice 14 | TypeDirectory 15 | TypeFIFO 16 | TypeSocket 17 | TypeIrregular 18 | ) 19 | 20 | // why use a rune type? we're looking for something that is memory compact but is easily human interpretable. 21 | 22 | type Type int 23 | 24 | func AllTypes() []Type { 25 | return []Type{ 26 | TypeRegular, 27 | TypeHardLink, 28 | TypeSymLink, 29 | TypeCharacterDevice, 30 | TypeBlockDevice, 31 | TypeDirectory, 32 | TypeFIFO, 33 | TypeSocket, 34 | TypeIrregular, 35 | } 36 | } 37 | 38 | func TypeFromTarType(ty byte) Type { 39 | switch ty { 40 | case tar.TypeReg, tar.TypeRegA: // nolint: staticcheck 41 | return TypeRegular 42 | case tar.TypeLink: 43 | return TypeHardLink 44 | case tar.TypeSymlink: 45 | return TypeSymLink 46 | case tar.TypeChar: 47 | return TypeCharacterDevice 48 | case tar.TypeBlock: 49 | return TypeBlockDevice 50 | case tar.TypeDir: 51 | return TypeDirectory 52 | case tar.TypeFifo: 53 | return TypeFIFO 54 | default: 55 | return TypeIrregular 56 | } 57 | } 58 | 59 | func TypeFromMode(mode os.FileMode) Type { 60 | switch { 61 | case isSet(mode, os.ModeSymlink): 62 | return TypeSymLink 63 | case isSet(mode, os.ModeIrregular): 64 | return TypeIrregular 65 | case isSet(mode, os.ModeCharDevice): 66 | return TypeCharacterDevice 67 | case isSet(mode, os.ModeDevice): 68 | return TypeBlockDevice 69 | case isSet(mode, os.ModeNamedPipe): 70 | return TypeFIFO 71 | case isSet(mode, os.ModeSocket): 72 | return TypeSocket 73 | case mode.IsDir(): 74 | return TypeDirectory 75 | case mode.IsRegular(): 76 | return TypeRegular 77 | default: 78 | return TypeIrregular 79 | } 80 | } 81 | 82 | func isSet(mode, field os.FileMode) bool { 83 | return mode&field != 0 84 | } 85 | 86 | func (t Type) String() string { 87 | switch t { 88 | case TypeRegular: 89 | return "RegularFile" 90 | case TypeHardLink: 91 | return "HardLink" 92 | case TypeSymLink: 93 | return "SymbolicLink" 94 | case TypeCharacterDevice: 95 | return "CharacterDevice" 96 | case TypeBlockDevice: 97 | return "BlockDevice" 98 | case TypeDirectory: 99 | return "Directory" 100 | case TypeFIFO: 101 | return "FIFONode" 102 | case TypeSocket: 103 | return "Socket" 104 | case TypeIrregular: 105 | return "IrregularFile" 106 | default: 107 | return "Unknown" 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /pkg/image/platform_test.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestNewPlatform(t *testing.T) { 11 | tests := []struct { 12 | specifier string 13 | want *Platform 14 | wantErr assert.ErrorAssertionFunc 15 | }{ 16 | { 17 | specifier: "linux", 18 | want: &Platform{ 19 | OS: "linux", 20 | }, 21 | }, 22 | { 23 | specifier: "linux/arm64", 24 | want: &Platform{ 25 | OS: "linux", 26 | Architecture: "arm64", 27 | }, 28 | }, 29 | { 30 | specifier: "linux/arm64/v8", 31 | want: &Platform{ 32 | OS: "linux", 33 | Architecture: "arm64", 34 | Variant: "", // v8 on arm64is normalized out 35 | }, 36 | }, 37 | { 38 | specifier: "linux/arm/v8", 39 | want: &Platform{ 40 | OS: "linux", 41 | Architecture: "arm", 42 | Variant: "v8", 43 | }, 44 | }, 45 | { 46 | specifier: "arm64", 47 | want: &Platform{ 48 | OS: "linux", // default to linux if not provided an OS 49 | Architecture: "arm64", 50 | }, 51 | }, 52 | { 53 | specifier: "arm64/v8", 54 | want: &Platform{ 55 | OS: "linux", 56 | Architecture: "arm64", 57 | Variant: "", // v8 on arm64is normalized out 58 | }, 59 | }, 60 | { 61 | specifier: "arm/v8", 62 | want: &Platform{ 63 | OS: "linux", 64 | Architecture: "arm", 65 | Variant: "v8", 66 | }, 67 | }, 68 | { 69 | specifier: "arm", 70 | want: &Platform{ 71 | OS: "linux", 72 | Architecture: "arm", 73 | Variant: "v7", // default to v7 if not specified 74 | }, 75 | }, 76 | { 77 | specifier: "quindows", // bogus OS 78 | wantErr: assert.Error, 79 | }, 80 | { 81 | specifier: "windows/aaarm", // bogus arch 82 | wantErr: assert.Error, 83 | }, 84 | { 85 | specifier: "windows/arm/valpha", // bogus variant, which is allowed 86 | want: &Platform{ 87 | OS: "windows", 88 | Architecture: "arm", 89 | Variant: "valpha", 90 | }, 91 | }, 92 | } 93 | for _, tt := range tests { 94 | t.Run(tt.specifier, func(t *testing.T) { 95 | if tt.wantErr == nil { 96 | tt.wantErr = assert.NoError 97 | } 98 | got, err := NewPlatform(tt.specifier) 99 | if !tt.wantErr(t, err, fmt.Sprintf("NewPlatform(%v)", tt.specifier)) { 100 | return 101 | } 102 | assert.Equalf(t, tt.want, got, "NewPlatform(%v)", tt.specifier) 103 | }) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /internal/containerd/client.go: -------------------------------------------------------------------------------- 1 | package containerd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/adrg/xdg" 8 | "github.com/containerd/containerd" 9 | "github.com/containerd/containerd/defaults" 10 | "github.com/containerd/containerd/namespaces" 11 | "github.com/spf13/afero" 12 | 13 | "github.com/anchore/stereoscope/internal/log" 14 | ) 15 | 16 | var ErrNoSocketAddress = fmt.Errorf("no socket address") 17 | 18 | func GetClient() (*containerd.Client, error) { 19 | client, err := containerd.New(Address()) 20 | if err != nil { 21 | return nil, err 22 | } 23 | return client, nil 24 | } 25 | 26 | func Address() string { 27 | address, err := getAddress(afero.NewOsFs(), xdg.RuntimeDir, defaults.DefaultAddress) 28 | if err != nil { 29 | return "" 30 | } 31 | return address 32 | } 33 | 34 | func Namespace() string { 35 | namespace := os.Getenv("CONTAINERD_NAMESPACE") 36 | if namespace == "" { 37 | namespace = namespaces.Default 38 | } 39 | 40 | return namespace 41 | } 42 | 43 | func getAddress(fs afero.Fs, xdgRuntimeDir, defaultSocketPath string) (string, error) { 44 | var addr string 45 | if v, found := os.LookupEnv("CONTAINERD_ADDRESS"); found && v != "" { 46 | addr = v 47 | } 48 | 49 | if addr != "" { 50 | return addr, nil 51 | } 52 | 53 | candidateAddresses := []string{ 54 | // default rootless address 55 | rootlessSocketPath(fs, xdgRuntimeDir), 56 | 57 | // typically accessible to only root, but last ditch effort 58 | defaultSocketPath, 59 | } 60 | 61 | for _, candidate := range candidateAddresses { 62 | if candidate == "" { 63 | continue 64 | } 65 | log.WithFields("path", candidate).Trace("trying containerd socket") 66 | _, err := fs.Stat(candidate) 67 | if err == nil { 68 | addr = candidate 69 | break 70 | } 71 | } 72 | 73 | if addr == "" { 74 | return "", ErrNoSocketAddress 75 | } 76 | 77 | return addr, nil 78 | } 79 | 80 | func rootlessSocketPath(fs afero.Fs, xdgRuntimeDir string) string { 81 | // look for rootless address (fallback to default if not found) 82 | //export CONTAINERD_ADDRESS=/proc/$(cat $XDG_RUNTIME_DIR/containerd-rootless/child_pid)/root/run/containerd/containerd.sock 83 | 84 | p := fmt.Sprintf("%s/containerd-rootless/child_pid", xdgRuntimeDir) 85 | if _, err := fs.Stat(p); err != nil { 86 | return "" 87 | } 88 | 89 | by, err := afero.ReadFile(fs, p) 90 | if err != nil { 91 | return "" 92 | } 93 | 94 | if len(by) == 0 { 95 | return "" 96 | } 97 | 98 | return fmt.Sprintf("/proc/%s/root/run/containerd/containerd.sock", string(by)) 99 | } 100 | -------------------------------------------------------------------------------- /pkg/image/oci/credhelpers/gcr_helper_test.go: -------------------------------------------------------------------------------- 1 | package credhelpers 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/GoogleCloudPlatform/docker-credential-gcr/config" 8 | "github.com/GoogleCloudPlatform/docker-credential-gcr/store" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func Test_NewGCRHelper_Fails_UnableToLoadConfig(t *testing.T) { 13 | //GIVEN 14 | loadConfig = func() (config.UserConfig, error) { 15 | return nil, errors.New("failed to find file") 16 | } 17 | 18 | //WHEN 19 | helper, err := NewGCRHelper("https://gcr.io/google-containers") 20 | 21 | //THEN 22 | assert.Nil(t, helper) 23 | assert.Error(t, err) 24 | } 25 | 26 | func Test_NewGCRHelper_Fails_UnableToLoadStore(t *testing.T) { 27 | //GIVEN 28 | loadConfig = func() (config.UserConfig, error) { 29 | return nil, nil 30 | } 31 | getDefaultGCRCredStore = func() (store.GCRCredStore, error) { 32 | return nil, errors.New("failed to load store") 33 | } 34 | 35 | //WHEN 36 | helper, err := NewGCRHelper("https://gcr.io/google-containers") 37 | 38 | //THEN 39 | assert.Nil(t, helper) 40 | assert.Error(t, err) 41 | } 42 | 43 | func Test_NewGCRHelper(t *testing.T) { 44 | //GIVEN 45 | loadConfig = func() (config.UserConfig, error) { 46 | return nil, nil 47 | } 48 | getDefaultGCRCredStore = func() (store.GCRCredStore, error) { 49 | return nil, nil 50 | } 51 | 52 | //WHEN 53 | helper, err := NewGCRHelper("https://gcr.io/google-containers") 54 | 55 | //THEN 56 | assert.NotNil(t, helper) 57 | assert.NoError(t, err) 58 | } 59 | 60 | func Test_GetRegistryCredentials(t *testing.T) { 61 | //GIVEN 62 | username, token, authority := "username", "token", "https://gcr.io/google-containers" 63 | mInternalHelper := new(mockInternalHelper) 64 | mInternalHelper.On("Get").Return(username, token, nil) 65 | helper := GCRHelper{ 66 | helper: mInternalHelper, 67 | authority: authority, 68 | } 69 | 70 | //WHEN 71 | creds, err := helper.GetRegistryCredentials() 72 | 73 | //THEN 74 | assert.NoError(t, err) 75 | assert.Equal(t, username, creds.Username) 76 | assert.Equal(t, token, creds.Token) 77 | assert.Equal(t, authority, creds.Authority) 78 | } 79 | 80 | func Test_GetRegistryCredentials_Fails(t *testing.T) { 81 | //GIVEN 82 | authority := "https://gcr.io/google-containers" 83 | mInternalHelper := new(mockInternalHelper) 84 | mInternalHelper.On("Get").Return("", "", errors.New("fails")) 85 | helper := GCRHelper{ 86 | helper: mInternalHelper, 87 | authority: authority, 88 | } 89 | 90 | //WHEN 91 | creds, err := helper.GetRegistryCredentials() 92 | 93 | //THEN 94 | assert.Error(t, err) 95 | assert.Nil(t, creds) 96 | } 97 | -------------------------------------------------------------------------------- /pkg/image/file_catalog.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "sync" 7 | 8 | "github.com/anchore/stereoscope/pkg/file" 9 | "github.com/anchore/stereoscope/pkg/filetree" 10 | ) 11 | 12 | type FileCatalogReader interface { 13 | Layer(file.Reference) *Layer 14 | Open(file.Reference) (io.ReadCloser, error) 15 | filetree.IndexReader 16 | } 17 | 18 | // FileCatalog represents all file metadata and source tracing for all files contained within the image layer 19 | // blobs (i.e. everything except for the image index/manifest/metadata files). 20 | type FileCatalog struct { 21 | *sync.RWMutex 22 | filetree.Index 23 | layerByID map[file.ID]*Layer 24 | openerByID map[file.ID]file.Opener 25 | } 26 | 27 | // NewFileCatalog returns an empty FileCatalog. 28 | func NewFileCatalog() *FileCatalog { 29 | return &FileCatalog{ 30 | RWMutex: &sync.RWMutex{}, 31 | Index: filetree.NewIndex(), 32 | layerByID: make(map[file.ID]*Layer), 33 | openerByID: make(map[file.ID]file.Opener), 34 | } 35 | } 36 | 37 | // Add creates a new FileCatalogEntry for the given file reference and metadata, cataloged by the ID of the 38 | // file reference (overwriting any existing entries without warning). 39 | func (c *FileCatalog) Add(f file.Reference, m file.Metadata, l *Layer, opener file.Opener) { 40 | c.Index.Add(f, m) // note: the index is already thread-safe 41 | c.addImageReferences(f.ID(), l, opener) 42 | } 43 | 44 | func (c *FileCatalog) AssociateOpener(f file.Reference, opener file.Opener) { 45 | c.addImageReferences(f.ID(), nil, opener) 46 | } 47 | 48 | func (c *FileCatalog) AssociateLayer(f file.Reference, l *Layer) { 49 | c.addImageReferences(f.ID(), l, nil) 50 | } 51 | 52 | func (c *FileCatalog) addImageReferences(id file.ID, l *Layer, opener file.Opener) { 53 | c.Lock() 54 | defer c.Unlock() 55 | if l != nil { 56 | c.layerByID[id] = l 57 | } 58 | if opener != nil { 59 | c.openerByID[id] = opener 60 | } 61 | } 62 | 63 | func (c *FileCatalog) Layer(f file.Reference) *Layer { 64 | c.RLock() 65 | defer c.RUnlock() 66 | 67 | return c.layerByID[f.ID()] 68 | } 69 | 70 | // Open returns a io.ReadCloser for the given file reference. The underlying io.ReadCloser will not attempt to 71 | // allocate resources until the first read is performed. 72 | func (c *FileCatalog) Open(f file.Reference) (io.ReadCloser, error) { 73 | c.RLock() 74 | defer c.RUnlock() 75 | 76 | opener, ok := c.openerByID[f.ID()] 77 | if !ok { 78 | return nil, fmt.Errorf("could not find file: %+v", f.RealPath) 79 | } 80 | 81 | if opener == nil { 82 | return nil, fmt.Errorf("no contents available for file: %+v", f.RealPath) 83 | } 84 | 85 | return opener() 86 | } 87 | -------------------------------------------------------------------------------- /test/integration/test-fixtures/registry/Makefile: -------------------------------------------------------------------------------- 1 | CONFIG_DIR = config 2 | BIN_DIR = bin 3 | 4 | TEST_BIN = $(BIN_DIR)/run-test 5 | 6 | CERTS_DIR = $(CONFIG_DIR)/certs 7 | CA_CERT_FILE = $(CERTS_DIR)/server.crt 8 | CA_KEY_FILE = $(CERTS_DIR)/server.key 9 | CLIENT_CSR_FILE = $(CERTS_DIR)/client.csr 10 | CLIENT_KEY_FILE = $(CERTS_DIR)/client.key 11 | CLIENT_CERT_FILE = $(CERTS_DIR)/client.crt 12 | AUTH_FILE = $(CONFIG_DIR)/auth/htpasswd 13 | 14 | # registry credentials 15 | USERNAME = testuser42 16 | PASSWORD = testpass42 17 | 18 | CN = registry.null 19 | REGISTRY_HOST = $(CN):5000 20 | REGISTRY_URL = https://$(REGISTRY_HOST)/v2/ 21 | IMAGE = $(REGISTRY_HOST)/busybox:latest 22 | IMAGE_FILE = image.tar.gz 23 | 24 | all: clean-bin start 25 | 26 | $(CERTS_DIR): 27 | mkdir -p $(CERTS_DIR) 28 | 29 | $(CA_CERT_FILE): $(CERTS_DIR) 30 | openssl req -x509 \ 31 | -newkey rsa:4096 \ 32 | -keyout $(CA_KEY_FILE) \ 33 | -out $(CA_CERT_FILE) \ 34 | -sha256 -days 3650 -nodes \ 35 | -addext "subjectAltName = DNS:$(CN)" \ 36 | -subj "/C=XX/ST=StateName/L=CityName/O=CompanyName/OU=CompanySectionName/CN=$(CN)" 37 | 38 | $(CLIENT_CERT_FILE): $(CERTS_DIR) 39 | openssl genpkey -algorithm RSA -out $(CLIENT_KEY_FILE) 40 | openssl req -new \ 41 | -key $(CLIENT_KEY_FILE) \ 42 | -out $(CLIENT_CSR_FILE) \ 43 | -addext "subjectAltName = DNS:$(CN)" \ 44 | -subj "/C=XX/ST=StateName/L=CityName/O=CompanyName/OU=CompanySectionName/CN=$(CN)" 45 | openssl x509 -req \ 46 | -in $(CLIENT_CSR_FILE) \ 47 | -CAcreateserial \ 48 | -CA $(CA_CERT_FILE) \ 49 | -CAkey $(CA_KEY_FILE) \ 50 | -out $(CLIENT_CERT_FILE) \ 51 | -days 1000 \ 52 | -sha256 53 | 54 | $(AUTH_FILE): 55 | mkdir -p $(dir $(AUTH_FILE)) 56 | htpasswd -Bbn $(USERNAME) $(PASSWORD) > $(AUTH_FILE) 57 | 58 | $(CONFIG_DIR): $(CERTS_DIR) $(AUTH_FILE) 59 | 60 | .PHONY: config 61 | config: $(CA_CERT_FILE) $(AUTH_FILE) $(CLIENT_CERT_FILE) 62 | 63 | .PHONY: test-auth 64 | test-auth: 65 | curl --cacert $(CA_CERT_FILE) -i -H 'Authorization: Basic $(shell bash -c 'echo -n "$(USERNAME):$(PASSWORD)" | base64')' $(REGISTRY_URL) 66 | 67 | .PHONY: start 68 | start: $(CA_CERT_FILE) $(AUTH_FILE) $(CLIENT_CERT_FILE) $(TEST_BIN) 69 | docker compose up -d registry 70 | docker compose up loader --exit-code-from loader 71 | 72 | .PHONY: run 73 | run: 74 | docker compose up runner --exit-code-from runner 75 | 76 | .PHONY: stop 77 | stop: 78 | docker compose down 79 | 80 | .PHONY: load 81 | load: 82 | crane pull busybox:latest $(IMAGE_FILE) 83 | crane auth login $(REGISTRY_HOST) -u $(USERNAME) -p $(PASSWORD) 84 | crane push --insecure $(IMAGE_FILE) $(IMAGE) 85 | 86 | $(TEST_BIN): 87 | CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o $(TEST_BIN) . 88 | 89 | .PHONY: clean 90 | clean: stop clean-bin 91 | rm -rf $(CONFIG_DIR) 92 | 93 | clean-bin: 94 | rm -rf $(BIN_DIR) -------------------------------------------------------------------------------- /pkg/file/lazy_bounded_read_closer_test.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func getFixture(t *testing.T, filepath string) []byte { 12 | fh, err := os.Open(filepath) 13 | require.NoError(t, err) 14 | expectedContents, err := io.ReadAll(fh) 15 | require.NoError(t, err) 16 | 17 | return expectedContents 18 | } 19 | 20 | func TestDeferredPartialReadCloser(t *testing.T) { 21 | p := "test-fixtures/a-file.txt" 22 | contents := getFixture(t, p) 23 | 24 | dReader := newLazyBoundedReadCloser(p, 0, int64(len(contents))) 25 | require.Nil(t, dReader.file) 26 | 27 | actualContents, err := io.ReadAll(dReader) 28 | require.NoError(t, err) 29 | 30 | require.Equal(t, contents, actualContents) 31 | require.Nil(t, dReader.reader) // file is closed when reader is nil at EOF 32 | 33 | // test EOF behavior 34 | ignore := make([]byte, 0, 16) 35 | eofBytesRead, err := dReader.Read(ignore) 36 | require.ErrorIs(t, err, io.EOF) // continues to return EOF for later reads 37 | require.Equal(t, 0, eofBytesRead) 38 | 39 | // able to seek after EOF 40 | _, err = dReader.Seek(0, io.SeekStart) 41 | require.NoError(t, err) 42 | require.NotNil(t, dReader.file) // file is reopened 43 | 44 | secondReadContents, err := io.ReadAll(dReader) 45 | require.NoError(t, err) 46 | require.Equal(t, contents, secondReadContents) 47 | require.Nil(t, dReader.reader) // file is closed when reader is nil at EOF 48 | 49 | require.NoError(t, dReader.Close()) 50 | require.Nil(t, dReader.file, "should not have a file, but we do somehow") 51 | 52 | _, err = io.ReadAll(dReader) 53 | require.ErrorIs(t, err, os.ErrClosed) 54 | } 55 | 56 | func TestDeferredPartialReadCloser_Seek(t *testing.T) { 57 | p := "test-fixtures/a-file.txt" 58 | content := getFixture(t, p) 59 | 60 | dReader := newLazyBoundedReadCloser(p, 0, int64(len(content))) 61 | require.Nil(t, dReader.file) 62 | 63 | var off int64 = 5 64 | seek, err := dReader.Seek(off, io.SeekStart) 65 | require.Equal(t, off, seek) 66 | require.NoError(t, err) 67 | actualContent, err := io.ReadAll(dReader) 68 | require.NoError(t, err) 69 | 70 | require.Equal(t, content[int(off):], actualContent) 71 | require.Nil(t, dReader.reader) // file is closed when reader is nil at EOF 72 | 73 | require.NoError(t, dReader.Close()) 74 | require.Nil(t, dReader.file, "should not have a file, but we do somehow") 75 | } 76 | 77 | func TestDeferredPartialReadCloser_PartialRead(t *testing.T) { 78 | p := "test-fixtures/a-file.txt" 79 | contents := getFixture(t, p) 80 | 81 | var start, size int64 = 10, 7 82 | dReader := newLazyBoundedReadCloser(p, start, size) 83 | 84 | actualContents, err := io.ReadAll(dReader) 85 | require.NoError(t, err) 86 | require.Equal(t, contents[start:start+size], actualContents) 87 | } 88 | -------------------------------------------------------------------------------- /.chronicle.yaml: -------------------------------------------------------------------------------- 1 | enforce-v0: true # don't make breaking-change label bump major version before 1.0. 2 | 3 | github: 4 | # (env: CHRONICLE_GITHUB_HOST) 5 | host: 'github.com' 6 | 7 | # (env: CHRONICLE_GITHUB_EXCLUDE_LABELS) 8 | exclude-labels: 9 | - 'duplicate' 10 | - 'question' 11 | - 'invalid' 12 | - 'wontfix' 13 | - 'wont-fix' 14 | - 'release-ignore' 15 | - 'changelog-ignore' 16 | - 'ignore' 17 | 18 | # (env: CHRONICLE_GITHUB_INCLUDE_ISSUE_PR_AUTHORS) 19 | include-issue-pr-authors: true 20 | 21 | # (env: CHRONICLE_GITHUB_INCLUDE_ISSUE_PRS) 22 | include-issue-prs: true 23 | 24 | # (env: CHRONICLE_GITHUB_INCLUDE_ISSUES_NOT_PLANNED) 25 | include-issues-not-planned: false 26 | 27 | # (env: CHRONICLE_GITHUB_INCLUDE_PRS) 28 | include-prs: true 29 | 30 | # (env: CHRONICLE_GITHUB_INCLUDE_ISSUES) 31 | include-issues: true 32 | 33 | # (env: CHRONICLE_GITHUB_INCLUDE_UNLABELED_ISSUES) 34 | include-unlabeled-issues: true 35 | 36 | # (env: CHRONICLE_GITHUB_INCLUDE_UNLABELED_PRS) 37 | include-unlabeled-prs: true 38 | 39 | # (env: CHRONICLE_GITHUB_ISSUES_REQUIRE_LINKED_PRS) 40 | issues-require-linked-prs: false 41 | 42 | # (env: CHRONICLE_GITHUB_CONSIDER_PR_MERGE_COMMITS) 43 | consider-pr-merge-commits: true 44 | 45 | # (env: CHRONICLE_GITHUB_CHANGES) 46 | changes: 47 | - name: 'security-fixes' 48 | title: 'Security Fixes' 49 | semver-field: 'patch' 50 | labels: 51 | - 'security' 52 | - 'vulnerability' 53 | 54 | - name: 'added-feature' 55 | title: 'Added Features' 56 | semver-field: 'minor' 57 | labels: 58 | - 'enhancement' 59 | - 'feature' 60 | - 'minor' 61 | 62 | - name: 'bug-fix' 63 | title: 'Bug Fixes' 64 | semver-field: 'patch' 65 | labels: 66 | - 'bug' 67 | - 'fix' 68 | - 'bug-fix' 69 | - 'patch' 70 | 71 | - name: 'dependencies' 72 | title: 'Dependency Updates' 73 | semver-field: 'patch' 74 | labels: 75 | - 'dependencies' 76 | 77 | - name: 'breaking-feature' 78 | title: 'Breaking Changes' 79 | semver-field: 'major' 80 | labels: 81 | - 'breaking' 82 | - 'backwards-incompatible' 83 | - 'breaking-change' 84 | - 'breaking-feature' 85 | - 'major' 86 | 87 | - name: 'removed-feature' 88 | title: 'Removed Features' 89 | semver-field: 'major' 90 | labels: 91 | - 'removed' 92 | 93 | - name: 'deprecated-feature' 94 | title: 'Deprecated Features' 95 | semver-field: 'minor' 96 | labels: 97 | - 'deprecated' 98 | 99 | - name: 'unknown' 100 | title: 'Additional Changes' 101 | semver-field: '' 102 | labels: [] 103 | -------------------------------------------------------------------------------- /test/integration/podman_test.go: -------------------------------------------------------------------------------- 1 | package integration 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "testing" 9 | "time" 10 | 11 | "github.com/docker/docker/client" 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | 15 | "github.com/anchore/stereoscope/internal/podman" 16 | ) 17 | 18 | func TestPodmanConnections(t *testing.T) { 19 | tests := []struct { 20 | name string 21 | constructor func() (*client.Client, error) 22 | setup func(*testing.T) 23 | cleanup func() 24 | }{ 25 | { 26 | name: "ssh connection", 27 | constructor: podman.ClientOverSSH, 28 | setup: func(t *testing.T) { 29 | cwd, err := os.Getwd() 30 | require.NoErrorf(t, err, "unable to get cwd: %+v", err) 31 | 32 | fixturesPath := filepath.Join(cwd, "test-fixtures", "podman") 33 | 34 | cmd := exec.Command("make") 35 | cmd.Dir = fixturesPath 36 | runAndShow(t, cmd) 37 | 38 | t.Setenv("CONTAINER_HOST", "ssh://root@localhost:2222/run/podman/podman.sock") 39 | 40 | keyPath := filepath.Join(fixturesPath, "ssh", "id_ed25519") 41 | t.Setenv("CONTAINER_SSHKEY", keyPath) 42 | 43 | t.Logf("ssh key %s", keyPath) 44 | 45 | start := time.Now() 46 | attempt := 1 47 | for { 48 | time.Sleep(time.Second * 2) 49 | 50 | t.Logf("waiting for podman to be ready (attempt %d)", attempt) 51 | if time.Since(start) > time.Second*30 { 52 | t.Fatal("timed out waiting for sshd to start") 53 | } 54 | cmd = exec.Command("make", "status") 55 | cmd.Dir = fixturesPath 56 | if err = runAndShowPassive(t, cmd); err == nil { 57 | t.Log("podman is ready") 58 | break 59 | } 60 | 61 | attempt++ 62 | } 63 | 64 | }, 65 | cleanup: func() { 66 | cwd, err := os.Getwd() 67 | assert.NoErrorf(t, err, "unable to get cwd: %+v", err) 68 | 69 | fixturesPath := filepath.Join(cwd, "test-fixtures", "podman") 70 | 71 | cmd := exec.Command("make", "stop") 72 | cmd.Dir = fixturesPath 73 | runAndShow(t, cmd) 74 | }, 75 | }, 76 | { 77 | name: "unix socket connection", 78 | constructor: podman.ClientOverUnixSocket, 79 | setup: func(t *testing.T) {}, 80 | cleanup: func() {}, 81 | }, 82 | } 83 | 84 | for _, tt := range tests { 85 | t.Run(tt.name, func(t *testing.T) { 86 | t.Cleanup(tt.cleanup) 87 | 88 | tt.setup(t) 89 | c, err := tt.constructor() 90 | require.NoError(t, err) 91 | assert.NotEmpty(t, c.ClientVersion()) 92 | 93 | p, err := c.Ping(context.Background()) 94 | require.NoError(t, err) 95 | assert.NotNil(t, p) 96 | 97 | version, err := c.ServerVersion(context.Background()) 98 | require.NoError(t, err) 99 | assert.NotEmpty(t, version) 100 | }) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /internal/podman/client_test.go: -------------------------------------------------------------------------------- 1 | package podman 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/spf13/afero" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func Test_getContainerHostAddress(t *testing.T) { 12 | type args struct { 13 | containerHostEnvVar string 14 | configPaths []string 15 | xdgRuntimeDir string 16 | defaultSocketPath string 17 | } 18 | tests := []struct { 19 | name string 20 | args args 21 | want string 22 | wantErr assert.ErrorAssertionFunc 23 | }{ 24 | { 25 | name: "env vars > config", 26 | args: args{ 27 | containerHostEnvVar: "unix:///somewhere/podman.sock", 28 | configPaths: []string{ 29 | "containers.conf", 30 | }, 31 | xdgRuntimeDir: "/xdg-runtime", 32 | defaultSocketPath: "/default/podman.sock", 33 | }, 34 | want: "unix:///somewhere/podman.sock", 35 | wantErr: assert.NoError, 36 | }, 37 | { 38 | name: "config > candidates", 39 | args: args{ 40 | containerHostEnvVar: "", 41 | configPaths: []string{ 42 | "containers-relative.conf", 43 | }, 44 | xdgRuntimeDir: "/xdg-runtime", 45 | defaultSocketPath: "/default/podman.sock", 46 | }, 47 | want: "unix:///user/podman.sock", 48 | wantErr: assert.NoError, 49 | }, 50 | { 51 | name: "attempt candidate socket from xdg runtime dir", 52 | args: args{ 53 | containerHostEnvVar: "", 54 | configPaths: []string{}, 55 | xdgRuntimeDir: "/xdg-runtime", 56 | defaultSocketPath: "/default/podman.sock", 57 | }, 58 | want: "unix:///xdg-runtime/podman/podman.sock", 59 | wantErr: assert.NoError, 60 | }, 61 | { 62 | name: "use default socket candidate last", 63 | args: args{ 64 | containerHostEnvVar: "", 65 | configPaths: []string{}, 66 | xdgRuntimeDir: "does-not-exist", 67 | defaultSocketPath: "/default/podman.sock", 68 | }, 69 | want: "unix:///default/podman.sock", 70 | wantErr: assert.NoError, 71 | }, 72 | { 73 | name: "error when there are no candidates", 74 | args: args{ 75 | containerHostEnvVar: "", 76 | configPaths: []string{}, 77 | xdgRuntimeDir: "does-not-exist", 78 | defaultSocketPath: "does-not-exist", 79 | }, 80 | wantErr: assert.Error, 81 | }, 82 | } 83 | for _, tt := range tests { 84 | t.Run(tt.name, func(t *testing.T) { 85 | t.Setenv("CONTAINER_HOST", tt.args.containerHostEnvVar) 86 | fs := afero.NewBasePathFs(afero.NewOsFs(), "test-fixtures") 87 | got, err := getContainerHostAddress(fs, tt.args.configPaths, tt.args.xdgRuntimeDir, tt.args.defaultSocketPath) 88 | if !tt.wantErr(t, err, fmt.Sprintf("getContainerHostAddress(%v, %v)", tt.args.configPaths, tt.args.xdgRuntimeDir)) { 89 | return 90 | } 91 | assert.Equalf(t, tt.want, got, "getContainerHostAddress(%v, %v)", tt.args.configPaths, tt.args.xdgRuntimeDir) 92 | }) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /pkg/tree/depth_first_walker.go: -------------------------------------------------------------------------------- 1 | package tree 2 | 3 | import ( 4 | "sort" 5 | 6 | "github.com/anchore/stereoscope/pkg/tree/node" 7 | ) 8 | 9 | type NodeVisitor func(node.Node) error 10 | 11 | type WalkConditions struct { 12 | // Return true when the walker should stop traversing (before visiting current node) 13 | ShouldTerminate func(node.Node) bool 14 | 15 | // Whether we should visit the current node. Note: this will continue down the same traversal 16 | // path, only "skipping" over a single node (but still potentially visiting children later) 17 | // Return true to visit the current node. 18 | ShouldVisit func(node.Node) bool 19 | 20 | // Whether we should consider children of this node to be included in the traversal path. 21 | // Return true to traverse children of this node. 22 | ShouldContinueBranch func(node.Node) bool 23 | } 24 | 25 | // DepthFirstWalker implements stateful depth-first Tree traversal. 26 | type DepthFirstWalker struct { 27 | visitor NodeVisitor 28 | tree Reader 29 | stack node.Stack 30 | visited node.IDSet 31 | conditions WalkConditions 32 | } 33 | 34 | func NewDepthFirstWalker(reader Reader, visitor NodeVisitor) *DepthFirstWalker { 35 | return &DepthFirstWalker{ 36 | visitor: visitor, 37 | tree: reader, 38 | visited: node.NewIDSet(), 39 | } 40 | } 41 | 42 | func NewDepthFirstWalkerWithConditions(reader Reader, visitor NodeVisitor, conditions WalkConditions) *DepthFirstWalker { 43 | return &DepthFirstWalker{ 44 | visitor: visitor, 45 | tree: reader, 46 | visited: node.NewIDSet(), 47 | conditions: conditions, 48 | } 49 | } 50 | 51 | func (w *DepthFirstWalker) Walk(from node.Node) (node.Node, error) { 52 | w.stack.Push(from) 53 | 54 | for w.stack.Size() > 0 { 55 | current := w.stack.Pop() 56 | if w.conditions.ShouldTerminate != nil && w.conditions.ShouldTerminate(current) { 57 | return current, nil 58 | } 59 | cid := current.ID() 60 | 61 | // visit 62 | if w.visitor != nil && !w.visited.Contains(cid) { 63 | if w.conditions.ShouldVisit == nil || w.conditions.ShouldVisit != nil && w.conditions.ShouldVisit(current) { 64 | if err := w.visitor(current); err != nil { 65 | return current, err 66 | } 67 | w.visited.Add(cid) 68 | } 69 | } 70 | 71 | if w.conditions.ShouldContinueBranch != nil && !w.conditions.ShouldContinueBranch(current) { 72 | continue 73 | } 74 | 75 | // enqueue children 76 | children := w.tree.Children(current) 77 | sort.Sort(sort.Reverse(children)) 78 | for _, child := range children { 79 | w.stack.Push(child) 80 | } 81 | } 82 | 83 | return nil, nil 84 | } 85 | 86 | func (w *DepthFirstWalker) WalkAll() error { 87 | for _, from := range w.tree.Roots() { 88 | if _, err := w.Walk(from); err != nil { 89 | return err 90 | } 91 | } 92 | return nil 93 | } 94 | 95 | func (w *DepthFirstWalker) Visited(n node.Node) bool { 96 | return w.visited.Contains(n.ID()) 97 | } 98 | -------------------------------------------------------------------------------- /internal/containerd/client_test.go: -------------------------------------------------------------------------------- 1 | package containerd 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/spf13/afero" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func Test_getAddress(t *testing.T) { 12 | type args struct { 13 | containerHostEnvVar string 14 | xdgRuntimeDir string 15 | defaultSocketPath string 16 | } 17 | tests := []struct { 18 | name string 19 | args args 20 | want string 21 | wantErr assert.ErrorAssertionFunc 22 | }{ 23 | { 24 | name: "env vars trump default socket values", 25 | args: args{ 26 | containerHostEnvVar: "/somewhere/containerd.sock", 27 | xdgRuntimeDir: "/xdg-runtime", 28 | defaultSocketPath: "/default/containerd.sock", 29 | }, 30 | want: "/somewhere/containerd.sock", 31 | wantErr: assert.NoError, 32 | }, 33 | 34 | { 35 | name: "attempt candidate socket from xdg runtime dir", 36 | args: args{ 37 | containerHostEnvVar: "", 38 | xdgRuntimeDir: "/xdg-runtime", 39 | defaultSocketPath: "/default/containerd.sock", 40 | }, 41 | want: "/proc/42/root/run/containerd/containerd.sock", 42 | wantErr: assert.NoError, 43 | }, 44 | { 45 | name: "use default socket candidate last", 46 | args: args{ 47 | containerHostEnvVar: "", 48 | xdgRuntimeDir: "does-not-exist", 49 | defaultSocketPath: "/default/containerd.sock", 50 | }, 51 | want: "/default/containerd.sock", 52 | wantErr: assert.NoError, 53 | }, 54 | { 55 | name: "use default socket candidate last when child_pid file is empty", 56 | args: args{ 57 | containerHostEnvVar: "", 58 | xdgRuntimeDir: "/xdg-runtime-empty", 59 | defaultSocketPath: "/default/containerd.sock", 60 | }, 61 | want: "/default/containerd.sock", 62 | wantErr: assert.NoError, 63 | }, 64 | { 65 | name: "use default socket candidate last when child_pid is stale", 66 | args: args{ 67 | containerHostEnvVar: "", 68 | xdgRuntimeDir: "/xdg-runtime-stale", 69 | defaultSocketPath: "/default/containerd.sock", 70 | }, 71 | want: "/default/containerd.sock", 72 | wantErr: assert.NoError, 73 | }, 74 | { 75 | name: "error when there are no candidates", 76 | args: args{ 77 | containerHostEnvVar: "", 78 | xdgRuntimeDir: "does-not-exist", 79 | defaultSocketPath: "does-not-exist", 80 | }, 81 | wantErr: assert.Error, 82 | }, 83 | } 84 | for _, tt := range tests { 85 | t.Run(tt.name, func(t *testing.T) { 86 | t.Setenv("CONTAINERD_ADDRESS", tt.args.containerHostEnvVar) 87 | fs := afero.NewBasePathFs(afero.NewOsFs(), "test-fixtures") 88 | got, err := getAddress(fs, tt.args.xdgRuntimeDir, tt.args.defaultSocketPath) 89 | if !tt.wantErr(t, err, fmt.Sprintf("getAddress(%v)", tt.args.xdgRuntimeDir)) { 90 | return 91 | } 92 | assert.Equalf(t, tt.want, got, "getAddress(%v)", tt.args.xdgRuntimeDir) 93 | }) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /pkg/image/registry_credentials.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "github.com/google/go-containerregistry/pkg/authn" 5 | "github.com/scylladb/go-set/strset" 6 | 7 | "github.com/anchore/stereoscope/internal/log" 8 | ) 9 | 10 | // RegistryCredentials contains any information necessary to authenticate against an OCI-distribution-compliant 11 | // registry (either with basic auth, or bearer token, or ggcr authenticator implementation). 12 | // Note: only valid for the OCI registry provider. 13 | type RegistryCredentials struct { 14 | Authority string 15 | Username string 16 | Password string 17 | Token string 18 | 19 | // Explicitly pass in the Authenticator, allowing for things like 20 | // k8schain to be passed through explicitly. 21 | Authenticator authn.Authenticator 22 | 23 | // MTLS configuration 24 | ClientCert string 25 | ClientKey string 26 | } 27 | 28 | // authenticator returns an authn.Authenticator for the given credentials. 29 | // Authentication methods are attempted in the following order until a viable method is found: (1) basic auth, 30 | // (2) bearer token. If no viable authentication method is found, authenticator returns nil. 31 | func (c RegistryCredentials) authenticator() authn.Authenticator { 32 | if c.Authenticator != nil { 33 | return c.Authenticator 34 | } 35 | if c.Username != "" && c.Password != "" { 36 | log.Debugf("using basic auth for registry %q", c.Authority) 37 | return &authn.Basic{ 38 | Username: c.Username, 39 | Password: c.Password, 40 | } 41 | } 42 | 43 | if c.Token != "" { 44 | log.Debugf("using token for registry %q", c.Authority) 45 | return &authn.Bearer{ 46 | Token: c.Token, 47 | } 48 | } 49 | 50 | return nil 51 | } 52 | 53 | // canBeUsedWithRegistry returns a bool indicating if these credentials should be used when accessing the given registry. 54 | func (c RegistryCredentials) canBeUsedWithRegistry(registry string) bool { 55 | if !c.hasAuthoritySpecified() { 56 | return true 57 | } 58 | 59 | // the containerd code will normalize docker.io requests to registry-1.docker.io , however 60 | // it might be that the user has configured docker.io specifically in the credentials. 61 | // try again with the new host. The same can occur when asking for docker.io directly, containerd 62 | // will transform this to index.docker.io. 63 | dockerAliases := strset.New("registry-1.docker.io", "index.docker.io", "docker.io") 64 | if dockerAliases.Has(c.Authority) && dockerAliases.Has(registry) { 65 | // these are all the same in terms of auth 66 | return true 67 | } 68 | 69 | // find an exact match 70 | return registry == c.Authority 71 | } 72 | 73 | // hasAuthoritySpecified returns a bool indicating if there is a specified "authority" value, 74 | // meaning that the user has requested these credentials to be used for retrieving only the images whose registry 75 | // matches this "authority" value. 76 | func (c RegistryCredentials) hasAuthoritySpecified() bool { 77 | return c.Authority != "" 78 | } 79 | -------------------------------------------------------------------------------- /internal/docker/client.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "runtime" 9 | "strings" 10 | 11 | "github.com/docker/cli/cli/connhelper" 12 | "github.com/docker/docker/client" 13 | 14 | "github.com/anchore/go-homedir" 15 | ) 16 | 17 | func GetClient() (*client.Client, error) { 18 | var clientOpts = []client.Opt{ 19 | client.FromEnv, 20 | client.WithAPIVersionNegotiation(), 21 | } 22 | 23 | host := os.Getenv("DOCKER_HOST") 24 | if strings.HasPrefix(host, "ssh") { 25 | var ( 26 | helper *connhelper.ConnectionHelper 27 | err error 28 | ) 29 | 30 | helper, err = connhelper.GetConnectionHelper(host) 31 | 32 | if err != nil { 33 | return nil, fmt.Errorf("failed to fetch docker connection helper: %w", err) 34 | } 35 | clientOpts = append(clientOpts, func(c *client.Client) error { 36 | httpClient := &http.Client{ 37 | Transport: &http.Transport{ 38 | DialContext: helper.Dialer, 39 | }, 40 | } 41 | return client.WithHTTPClient(httpClient)(c) 42 | }) 43 | clientOpts = append(clientOpts, client.WithHost(helper.Host)) 44 | clientOpts = append(clientOpts, client.WithDialContext(helper.Dialer)) 45 | } 46 | 47 | if os.Getenv("DOCKER_TLS_VERIFY") != "" && os.Getenv("DOCKER_CERT_PATH") == "" { 48 | err := os.Setenv("DOCKER_CERT_PATH", "~/.docker") 49 | if err != nil { 50 | return nil, fmt.Errorf("failed create docker client: %w", err) 51 | } 52 | } 53 | 54 | possibleSocketPaths := possibleSocketPaths(runtime.GOOS) 55 | for _, socketPath := range possibleSocketPaths { 56 | dockerClient, err := newClient(socketPath, clientOpts...) 57 | if err == nil { 58 | err := checkConnection(dockerClient) 59 | if err == nil { 60 | return dockerClient, nil // Successfully connected 61 | } 62 | } 63 | } 64 | 65 | // If both attempts failed 66 | return nil, fmt.Errorf("failed to connect to Docker daemon. Ensure Docker is running and accessible") 67 | } 68 | 69 | func checkConnection(dockerClient *client.Client) error { 70 | ctx := context.Background() 71 | _, err := dockerClient.Ping(ctx) 72 | if err != nil { 73 | return fmt.Errorf("failed to ping Docker daemon: %w", err) 74 | } 75 | return nil 76 | } 77 | 78 | func newClient(socket string, opts ...client.Opt) (*client.Client, error) { 79 | if socket == "" { 80 | return client.NewClientWithOpts(opts...) 81 | } 82 | opts = append(opts, client.WithHost(socket)) 83 | return client.NewClientWithOpts(opts...) 84 | } 85 | 86 | func possibleSocketPaths(os string) []string { 87 | switch os { 88 | case "darwin": 89 | hDir, err := homedir.Dir() 90 | if err != nil { 91 | return []string{""} 92 | } 93 | return []string{ 94 | "", // try the client default first 95 | fmt.Sprintf("unix://%s/Library/Containers/com.docker.docker/Data/docker.raw.sock", hDir), 96 | } 97 | default: 98 | return []string{""} // try the client default first 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /.github/scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -uo pipefail 3 | 4 | SNAPSHOT_DIR=$1 5 | 6 | # Based on https://gist.github.com/eduncan911/68775dba9d3c028181e4 and https://gist.github.com/makeworld-the-better-one/e1bb127979ae4195f43aaa3ad46b1097 7 | # but improved to use the `go` command so it never goes out of date. 8 | 9 | type setopt >/dev/null 2>&1 10 | 11 | contains() { 12 | # Source: https://stackoverflow.com/a/8063398/7361270 13 | [[ $1 =~ (^|[[:space:]])$2($|[[:space:]]) ]] 14 | } 15 | 16 | mkdir -p "${SNAPSHOT_DIR}" 17 | 18 | BUILD_TARGET=./examples 19 | OUTPUT=${SNAPSHOT_DIR}/stereoscope-example 20 | FAILURES="" 21 | 22 | # You can set your own flags on the command line 23 | FLAGS=${FLAGS:-"-ldflags=\"-s -w\""} 24 | 25 | # A list of OSes and architectures to not build for, space-separated 26 | # It can be set from the command line when the script is called. 27 | NOT_ALLOWED_OS=${NOT_ALLOWED_OS:-"js android ios solaris illumos aix dragonfly plan9 freebsd openbsd netbsd"} 28 | NOT_ALLOWED_ARCH=${NOT_ALLOWED_ARCH:-"riscv64 mips mips64 mips64le ppc64 ppc64le s390x wasm"} 29 | 30 | 31 | # Get all targets 32 | while IFS= read -r target; do 33 | GOOS=${target%/*} 34 | GOARCH=${target#*/} 35 | BIN_FILENAME="${OUTPUT}-${GOOS}-${GOARCH}" 36 | 37 | if contains "$NOT_ALLOWED_OS" "$GOOS" ; then 38 | continue 39 | fi 40 | 41 | if contains "$NOT_ALLOWED_ARCH" "$GOARCH" ; then 42 | continue 43 | fi 44 | 45 | # Check for arm and set arm version 46 | if [[ $GOARCH == "arm" ]]; then 47 | # Set what arm versions each platform supports 48 | if [[ $GOOS == "darwin" ]]; then 49 | arms="7" 50 | elif [[ $GOOS == "windows" ]]; then 51 | # This is a guess, it's not clear what Windows supports from the docs 52 | # But I was able to build all these on my machine 53 | arms="5 6 7" 54 | elif [[ $GOOS == *"bsd" ]]; then 55 | arms="6 7" 56 | else 57 | # Linux goes here 58 | arms="5 6 7" 59 | fi 60 | 61 | # Now do the arm build 62 | for GOARM in $arms; do 63 | BIN_FILENAME="${OUTPUT}-${GOOS}-${GOARCH}${GOARM}" 64 | if [[ "${GOOS}" == "windows" ]]; then BIN_FILENAME="${BIN_FILENAME}.exe"; fi 65 | CMD="GOARM=${GOARM} GOOS=${GOOS} GOARCH=${GOARCH} go build $FLAGS -o ${BIN_FILENAME} ${BUILD_TARGET}" 66 | echo "${CMD}" 67 | eval "${CMD}" || FAILURES="${FAILURES} ${GOOS}/${GOARCH}${GOARM}" 68 | done 69 | else 70 | # Build non-arm here 71 | if [[ "${GOOS}" == "windows" ]]; then BIN_FILENAME="${BIN_FILENAME}.exe"; fi 72 | CMD="GOOS=${GOOS} GOARCH=${GOARCH} go build $FLAGS -o ${BIN_FILENAME} ${BUILD_TARGET}" 73 | echo "${CMD}" 74 | eval "${CMD}" || FAILURES="${FAILURES} ${GOOS}/${GOARCH}" 75 | fi 76 | done <<< "$(go tool dist list)" 77 | 78 | if [[ "${FAILURES}" != "" ]]; then 79 | echo "" 80 | echo "build failed for: ${FAILURES}" 81 | exit 1 82 | fi -------------------------------------------------------------------------------- /pkg/imagetest/fileutils.go: -------------------------------------------------------------------------------- 1 | package imagetest 2 | 3 | import ( 4 | "crypto/sha256" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "testing" 10 | ) 11 | 12 | func copyFile(t testing.TB, src, dst string) { 13 | t.Helper() 14 | 15 | in, err := os.Open(src) 16 | if err != nil { 17 | t.Fatalf("could not open src (%s): %+v", src, err) 18 | } 19 | defer in.Close() 20 | 21 | out, err := os.Create(dst) 22 | if err != nil { 23 | t.Fatalf("could not open dst (%s): %+v", dst, err) 24 | } 25 | defer out.Close() 26 | 27 | _, err = io.Copy(out, in) 28 | if err != nil { 29 | t.Fatalf("could not copy file (%s -> %s): %+v", src, dst, err) 30 | } 31 | } 32 | 33 | func fileExists(t testing.TB, filename string) bool { 34 | t.Helper() 35 | s, err := os.Stat(filename) 36 | return !os.IsNotExist(err) && !s.IsDir() 37 | } 38 | 39 | func dirExists(t testing.TB, filename string) bool { 40 | t.Helper() 41 | s, err := os.Stat(filename) 42 | return !os.IsNotExist(err) && s.IsDir() 43 | } 44 | 45 | func dirHash(t testing.TB, root string) string { 46 | hasher := sha256.New() 47 | walkFn := func(path string, _ os.FileInfo, err error) error { 48 | if err != nil { 49 | return fmt.Errorf("unable to walk path=%q : %w", path, err) 50 | } 51 | 52 | // walk does not provide Lstat info, only stat info... 53 | info, err := os.Lstat(path) 54 | if err != nil { 55 | return fmt.Errorf("unable to lstat path=%q : %w", path, err) 56 | } 57 | 58 | if !info.Mode().IsRegular() { 59 | return nil 60 | } 61 | 62 | f, err := os.Open(path) 63 | if err != nil { 64 | return fmt.Errorf("unable to open path=%q : %w", path, err) 65 | } 66 | defer func() { 67 | err := f.Close() 68 | if err != nil { 69 | t.Fatalf("unable to close walk root=%q path=%q : %+v", root, path, err) 70 | } 71 | }() 72 | 73 | if _, err := io.Copy(hasher, f); err != nil { 74 | return fmt.Errorf("unable to copy path=%q : %w", path, err) 75 | } 76 | 77 | return nil 78 | } 79 | if err := walk(root, walkFn); err != nil { 80 | t.Fatalf("unable to hash %q : %+v", root, err) 81 | } 82 | return fmt.Sprintf("%x", hasher.Sum(nil)) 83 | } 84 | 85 | func walkEvaluateLinks(root string, virtualPath string, fn filepath.WalkFunc) error { 86 | symWalkFunc := func(path string, info os.FileInfo, err error) error { 87 | if relativePath, err := filepath.Rel(root, path); err == nil { 88 | path = filepath.Join(virtualPath, relativePath) 89 | } else { 90 | return err 91 | } 92 | 93 | if err == nil && info.Mode()&os.ModeSymlink == os.ModeSymlink { 94 | finalPath, err := filepath.EvalSymlinks(path) 95 | if err != nil { 96 | return err 97 | } 98 | info, err := os.Lstat(finalPath) 99 | if err != nil { 100 | return fn(path, info, err) 101 | } 102 | if info.IsDir() { 103 | return walkEvaluateLinks(finalPath, path, fn) 104 | } 105 | } 106 | 107 | return fn(path, info, err) 108 | } 109 | return filepath.Walk(root, symWalkFunc) 110 | } 111 | 112 | func walk(root string, fn filepath.WalkFunc) error { 113 | return walkEvaluateLinks(root, root, fn) 114 | } 115 | -------------------------------------------------------------------------------- /examples/basic.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "os" 8 | 9 | "github.com/anchore/go-logger" 10 | "github.com/anchore/go-logger/adapter/logrus" 11 | "github.com/anchore/stereoscope" 12 | "github.com/anchore/stereoscope/pkg/file" 13 | "github.com/anchore/stereoscope/pkg/filetree/filenode" 14 | ) 15 | 16 | func main() { 17 | 18 | // context for network requests 19 | ctx, cancel := context.WithCancel(context.Background()) 20 | defer cancel() 21 | 22 | lctx, err := logrus.New(logrus.Config{ 23 | EnableConsole: true, 24 | Level: logger.TraceLevel, 25 | }) 26 | if err != nil { 27 | panic(err) 28 | } 29 | stereoscope.SetLogger(lctx) 30 | 31 | ///////////////////////////////////////////////////////////////// 32 | // pass a path to an Docker save tar, docker image, or OCI directory/archive as an argument: 33 | // ./path/to.tar 34 | // 35 | // This will catalog the file metadata and resolve all squash trees 36 | image, err := stereoscope.GetImage(ctx, os.Args[1]) 37 | if err != nil { 38 | panic(err) 39 | } 40 | 41 | // note: we are writing out temp files which should be cleaned up after you're done with the image object 42 | defer image.Cleanup() 43 | 44 | for _, layer := range image.Layers { 45 | fmt.Printf("layer: %s\n", layer.Metadata.Digest) 46 | } 47 | 48 | //////////////////////////////////////////////////////////////// 49 | // Show the filetree for each layer 50 | for idx, layer := range image.Layers { 51 | fmt.Printf("Walking layer: %d", idx) 52 | err = layer.Tree.Walk(func(path file.Path, f filenode.FileNode) error { 53 | fmt.Println(" ", path) 54 | return nil 55 | }, nil) 56 | fmt.Println("-----------------------------") 57 | if err != nil { 58 | panic(err) 59 | } 60 | } 61 | 62 | ////////////////////////////////////////////////////////////////// 63 | // Show the squashed filetree for each layer 64 | for idx, layer := range image.Layers { 65 | fmt.Printf("Walking squashed layer: %d", idx) 66 | err = layer.SquashedTree.Walk(func(path file.Path, f filenode.FileNode) error { 67 | fmt.Println(" ", path) 68 | return nil 69 | }, nil) 70 | fmt.Println("-----------------------------") 71 | if err != nil { 72 | panic(err) 73 | } 74 | } 75 | 76 | ////////////////////////////////////////////////////////////////// 77 | // Show the final squashed tree 78 | fmt.Printf("Walking squashed image (same as the last layer squashed tree)") 79 | err = image.SquashedTree().Walk(func(path file.Path, f filenode.FileNode) error { 80 | fmt.Println(" ", path) 81 | return nil 82 | }, nil) 83 | if err != nil { 84 | panic(err) 85 | } 86 | 87 | ////////////////////////////////////////////////////////////////// 88 | // Fetch file contents from the (squashed) image 89 | filePath := file.Path("/etc/group") 90 | contentReader, err := image.OpenPathFromSquash(filePath) 91 | if err != nil { 92 | panic(err) 93 | } 94 | 95 | content, err := io.ReadAll(contentReader) 96 | if err != nil { 97 | panic(err) 98 | } 99 | 100 | fmt.Printf("File content for: %+v\n", filePath) 101 | fmt.Println(string(content)) 102 | } 103 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | issues: 2 | max-same-issues: 25 3 | uniq-by-line: false 4 | 5 | # TODO: enable this when we have coverage on docstring comments 6 | # # The list of ids of default excludes to include or disable. 7 | # include: 8 | # - EXC0002 # disable excluding of issues about comments from golint 9 | 10 | linters: 11 | # inverted configuration with `enable-all` and `disable` is not scalable during updates of golangci-lint 12 | disable-all: true 13 | enable: 14 | - asciicheck 15 | - bodyclose 16 | - dogsled 17 | - dupl 18 | - errcheck 19 | - funlen 20 | - gocognit 21 | - goconst 22 | - gocritic 23 | - gocyclo 24 | - gofmt 25 | - goimports 26 | - goprintffuncname 27 | - gosec 28 | - gosimple 29 | - govet 30 | - ineffassign 31 | - misspell 32 | - nakedret 33 | - revive 34 | - staticcheck 35 | - stylecheck 36 | - typecheck 37 | - unconvert 38 | - unparam 39 | - unused 40 | - whitespace 41 | 42 | linters-settings: 43 | funlen: 44 | # Checks the number of lines in a function. 45 | # If lower than 0, disable the check. 46 | # Default: 60 47 | lines: 70 48 | # Checks the number of statements in a function. 49 | # If lower than 0, disable the check. 50 | # Default: 40 51 | statements: 50 52 | gosec: 53 | excludes: G115 54 | 55 | run: 56 | timeout: 10m 57 | 58 | # do not enable... 59 | # - deadcode # The owner seems to have abandoned the linter. Replaced by "unused". 60 | # - depguard # We don't have a configuration for this yet 61 | # - goprintffuncname # does not catch all cases and there are exceptions 62 | # - nakedret # does not catch all cases and should not fail a build 63 | # - gochecknoglobals 64 | # - gochecknoinits # this is too aggressive 65 | # - rowserrcheck disabled per generics https://github.com/golangci/golangci-lint/issues/2649 66 | # - godot 67 | # - godox 68 | # - goerr113 69 | # - goimports # we're using gosimports now instead to account for extra whitespaces (see https://github.com/golang/go/issues/20818) 70 | # - golint # deprecated 71 | # - gomnd # this is too aggressive 72 | # - interfacer # this is a good idea, but is no longer supported and is prone to false positives 73 | # - lll # without a way to specify per-line exception cases, this is not usable 74 | # - maligned # this is an excellent linter, but tricky to optimize and we are not sensitive to memory layout optimizations 75 | # - nestif 76 | # - nolintlint # as of go1.19 this conflicts with the behavior of gofmt, which is a deal-breaker (lint-fix will still fail when running lint) 77 | # - prealloc # following this rule isn't consistently a good idea, as it sometimes forces unnecessary allocations that result in less idiomatic code 78 | # - rowserrcheck # not in a repo with sql, so this is not useful 79 | # - scopelint # deprecated 80 | # - structcheck # The owner seems to have abandoned the linter. Replaced by "unused". 81 | # - testpackage 82 | # - varcheck # The owner seems to have abandoned the linter. Replaced by "unused". 83 | # - wsl # this doens't have an auto-fixer yet and is pretty noisy (https://github.com/bombsimon/wsl/issues/90) 84 | -------------------------------------------------------------------------------- /pkg/image/containerd/pull_status.go: -------------------------------------------------------------------------------- 1 | package containerd 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "time" 7 | 8 | "github.com/containerd/containerd" 9 | "github.com/wagoodman/go-progress" 10 | ) 11 | 12 | // StatusInfoStatus describes status info for an upload or download. 13 | type StatusInfoStatus string 14 | 15 | const ( 16 | StatusResolved StatusInfoStatus = "resolved" 17 | StatusResolving StatusInfoStatus = "resolving" 18 | StatusWaiting StatusInfoStatus = "waiting" 19 | StatusCommitting StatusInfoStatus = "committing" 20 | StatusDone StatusInfoStatus = "done" 21 | StatusDownloading StatusInfoStatus = "downloading" 22 | StatusUploading StatusInfoStatus = "uploading" 23 | StatusExists StatusInfoStatus = "exists" 24 | ) 25 | 26 | type LayerID string 27 | 28 | type PullStatus struct { 29 | state apiState 30 | layers []LayerID 31 | progress map[LayerID]*progress.Manual 32 | lock *sync.RWMutex 33 | } 34 | 35 | func newPullStatus(client *containerd.Client, ongoing *jobs) *PullStatus { 36 | return &PullStatus{ 37 | state: newAPIState(client, ongoing), 38 | progress: make(map[LayerID]*progress.Manual), 39 | lock: &sync.RWMutex{}, 40 | } 41 | } 42 | 43 | func (ps *PullStatus) Complete() bool { 44 | _, done := ps.state.current() 45 | return done 46 | } 47 | 48 | func (ps *PullStatus) Layers() []LayerID { 49 | ordered, _ := ps.state.current() 50 | 51 | var layers []LayerID 52 | for _, status := range ordered { 53 | layers = append(layers, LayerID(status.Ref)) 54 | } 55 | 56 | return layers 57 | } 58 | 59 | func (ps *PullStatus) Current(layer LayerID) progress.Progressable { 60 | ps.state.lock.RLock() 61 | defer ps.state.lock.RUnlock() 62 | 63 | p := ps.progress[layer] 64 | if p == nil { 65 | return progress.NewManual(-1) 66 | } 67 | return p 68 | } 69 | 70 | func (s *apiState) current() ([]statusInfo, bool) { 71 | s.lock.RLock() 72 | defer s.lock.RUnlock() 73 | 74 | return append([]statusInfo{}, s.ordered...), s.done 75 | } 76 | 77 | func (ps *PullStatus) start(ctx context.Context) *PullStatus { 78 | go func() { 79 | for { 80 | if ps.state.done { 81 | break 82 | } 83 | select { 84 | case <-ctx.Done(): 85 | return 86 | case <-time.After(100 * time.Millisecond): 87 | ps.update(ctx) 88 | } 89 | } 90 | }() 91 | return ps 92 | } 93 | 94 | func (ps *PullStatus) update(ctx context.Context) { 95 | // get the latest API state 96 | ps.state.update(ctx) 97 | 98 | // use the API state to update the progress that can drive callers (UIs) 99 | ordered, done := ps.state.current() 100 | ps.lock.Lock() 101 | defer ps.lock.Unlock() 102 | 103 | ps.layers = nil 104 | 105 | for _, status := range ordered { 106 | layer := LayerID(status.Ref) 107 | if status.Status == "" { 108 | continue 109 | } 110 | if _, ok := ps.progress[layer]; !ok { 111 | ps.progress[layer] = progress.NewManual(status.Total) 112 | } else { 113 | // based on the behavior of containerd, these values were found to drift 114 | // during initialization. Let's make certain we're using the latest values 115 | ps.progress[layer].SetTotal(status.Total) 116 | } 117 | ps.progress[layer].Set(status.Offset) 118 | if done { 119 | // TODO: is this right? or do we want to show intermediate failures at the spot they failed? 120 | ps.progress[layer].SetCompleted() 121 | } 122 | ps.layers = append(ps.layers, layer) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /pkg/image/oci/directory_provider.go: -------------------------------------------------------------------------------- 1 | package oci 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | v1 "github.com/google/go-containerregistry/pkg/v1" 8 | "github.com/google/go-containerregistry/pkg/v1/layout" 9 | 10 | "github.com/anchore/stereoscope/pkg/file" 11 | "github.com/anchore/stereoscope/pkg/image" 12 | ) 13 | 14 | const Directory image.Source = image.OciDirectorySource 15 | 16 | // NewDirectoryProvider creates a new provider instance for the specific image already at the given path. 17 | func NewDirectoryProvider(tmpDirGen *file.TempDirGenerator, path string) image.Provider { 18 | return &directoryImageProvider{ 19 | tmpDirGen: tmpDirGen, 20 | path: path, 21 | } 22 | } 23 | 24 | // directoryImageProvider is an image.Provider for an OCI image (V1) for an existing tar on disk (from a buildah push oci: command). 25 | type directoryImageProvider struct { 26 | tmpDirGen *file.TempDirGenerator 27 | path string 28 | } 29 | 30 | func (p *directoryImageProvider) Name() string { 31 | return Directory 32 | } 33 | 34 | // Provide an image object that represents the OCI image as a directory. 35 | func (p *directoryImageProvider) Provide(_ context.Context) (*image.Image, error) { 36 | pathObj, err := layout.FromPath(p.path) 37 | if err != nil { 38 | return nil, fmt.Errorf("unable to read image from OCI directory path %q: %w", p.path, err) 39 | } 40 | 41 | index, err := layout.ImageIndexFromPath(p.path) 42 | if err != nil { 43 | return nil, fmt.Errorf("unable to parse OCI directory index: %w", err) 44 | } 45 | 46 | indexManifest, err := index.IndexManifest() 47 | if err != nil { 48 | return nil, fmt.Errorf("unable to parse OCI directory indexManifest: %w", err) 49 | } 50 | 51 | // for now, lets only support one image indexManifest (it is not clear how to handle multiple manifests) 52 | if len(indexManifest.Manifests) != 1 { 53 | if len(indexManifest.Manifests) == 0 { 54 | return nil, fmt.Errorf("unexpected number of OCI directory manifests (found %d)", len(indexManifest.Manifests)) 55 | } 56 | // if all the manifests have the same digest, then we can treat this as a single image 57 | if !checkManifestDigestsEqual(indexManifest.Manifests) { 58 | return nil, fmt.Errorf("unexpected number of OCI directory manifests (found %d)", len(indexManifest.Manifests)) 59 | } 60 | } 61 | 62 | manifest := indexManifest.Manifests[0] 63 | img, err := pathObj.Image(manifest.Digest) 64 | if err != nil { 65 | return nil, fmt.Errorf("unable to parse OCI directory as an image: %w", err) 66 | } 67 | 68 | var metadata = []image.AdditionalMetadata{ 69 | image.WithManifestDigest(manifest.Digest.String()), 70 | } 71 | 72 | // make a best-effort attempt at getting the raw indexManifest 73 | rawManifest, err := img.RawManifest() 74 | if err == nil { 75 | metadata = append(metadata, image.WithManifest(rawManifest)) 76 | } 77 | 78 | contentTempDir, err := p.tmpDirGen.NewDirectory("oci-dir-image") 79 | if err != nil { 80 | return nil, err 81 | } 82 | 83 | out := image.New(img, p.tmpDirGen, contentTempDir, metadata...) 84 | err = out.Read() 85 | if err != nil { 86 | return nil, err 87 | } 88 | return out, err 89 | } 90 | 91 | func checkManifestDigestsEqual(manifests []v1.Descriptor) bool { 92 | if len(manifests) < 1 { 93 | return false 94 | } 95 | for _, m := range manifests { 96 | if m.Digest != manifests[0].Digest { 97 | return false 98 | } 99 | } 100 | return true 101 | } 102 | -------------------------------------------------------------------------------- /pkg/image/image_test.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "crypto/sha256" 5 | "fmt" 6 | "os" 7 | "testing" 8 | 9 | "github.com/google/go-cmp/cmp" 10 | "github.com/google/go-cmp/cmp/cmpopts" 11 | "github.com/google/go-containerregistry/pkg/name" 12 | ) 13 | 14 | func TestImageAdditionalMetadata(t *testing.T) { 15 | theTag, err := name.NewTag("a/tag:latest") 16 | if err != nil { 17 | t.Fatalf("could not create a tag: %+v", err) 18 | } 19 | 20 | tests := []struct { 21 | name string 22 | options []AdditionalMetadata 23 | image Image 24 | }{ 25 | { 26 | name: "no options", 27 | options: []AdditionalMetadata{}, 28 | image: Image{}, 29 | }, 30 | { 31 | name: "with tags", 32 | options: []AdditionalMetadata{ 33 | WithTags(theTag.String()), 34 | }, 35 | image: Image{ 36 | Metadata: Metadata{ 37 | Tags: []name.Tag{theTag}, 38 | }, 39 | }, 40 | }, 41 | { 42 | name: "with manifest", 43 | options: []AdditionalMetadata{ 44 | WithManifest([]byte("some bytes")), 45 | }, 46 | image: Image{ 47 | Metadata: Metadata{ 48 | RawManifest: []byte("some bytes"), 49 | ManifestDigest: fmt.Sprintf("sha256:%x", sha256.Sum256([]byte("some bytes"))), 50 | }, 51 | }, 52 | }, 53 | { 54 | name: "with manifest digest", 55 | options: []AdditionalMetadata{ 56 | WithManifestDigest("the-digest"), 57 | }, 58 | image: Image{ 59 | Metadata: Metadata{ 60 | ManifestDigest: "the-digest", 61 | }, 62 | }, 63 | }, 64 | { 65 | name: "with config", 66 | options: []AdditionalMetadata{ 67 | WithConfig([]byte("some bytes")), 68 | }, 69 | image: Image{ 70 | Metadata: Metadata{ 71 | RawConfig: []byte("some bytes"), 72 | ID: fmt.Sprintf("sha256:%x", sha256.Sum256([]byte("some bytes"))), 73 | }, 74 | }, 75 | }, 76 | { 77 | name: "with platform", 78 | options: []AdditionalMetadata{ 79 | WithPlatform("windows/arm64/v9"), 80 | }, 81 | image: Image{ 82 | Metadata: Metadata{ 83 | OS: "windows", 84 | Architecture: "arm64", 85 | Variant: "v9", 86 | }, 87 | }, 88 | }, 89 | } 90 | 91 | for _, test := range tests { 92 | t.Run(test.name, func(t *testing.T) { 93 | tempFile, err := os.CreateTemp("", "") 94 | if err != nil { 95 | t.Fatalf("could not create tempfile: %+v", err) 96 | } 97 | t.Cleanup(func() { 98 | os.Remove(tempFile.Name()) 99 | }) 100 | 101 | img := New(nil, nil, tempFile.Name(), test.options...) 102 | 103 | err = img.applyOverrideMetadata() 104 | if err != nil { 105 | t.Fatalf("could not create image: %+v", err) 106 | } 107 | if d := cmp.Diff(img, &test.image, 108 | cmpopts.IgnoreFields(Image{}, "FileCatalog"), 109 | cmpopts.IgnoreUnexported(Image{}), 110 | cmp.AllowUnexported(name.Tag{}, name.Repository{}, name.Registry{}), 111 | ); d != "" { 112 | t.Errorf("diff: %+v", d) 113 | } 114 | }) 115 | } 116 | } 117 | 118 | func TestImage_SquashedTree(t *testing.T) { 119 | t.Run("zero layers", func(t *testing.T) { 120 | i := Image{ 121 | Layers: []*Layer{}, 122 | } 123 | 124 | defer func() { 125 | if r := recover(); r != nil { 126 | t.Errorf("panicked (and recovered) while computing squashed tree for image with zero layers: %v", r) 127 | } 128 | }() 129 | 130 | // Asserting that this call doesn't panic (regression: https://github.com/anchore/stereoscope/issues/56) 131 | result := i.SquashedTree() 132 | 133 | if result == nil { 134 | t.Error("expected an initialized, empty FileTree, but got a nil FileTree") 135 | } 136 | }) 137 | } 138 | -------------------------------------------------------------------------------- /pkg/file/path.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "fmt" 5 | "path" 6 | "strings" 7 | ) 8 | 9 | const ( 10 | WhiteoutPrefix = ".wh." 11 | OpaqueWhiteout = WhiteoutPrefix + WhiteoutPrefix + ".opq" 12 | DirSeparator = "/" 13 | ) 14 | 15 | // Path represents a file path 16 | type Path string 17 | 18 | // Normalize returns the cleaned file path representation (trimmed of spaces and resolve relative notations) 19 | func (p Path) Normalize() Path { 20 | // note: when normalizing we cannot trim trailing whitespace since it is valid for a path to have suffix whitespace 21 | var trimmed = string(p) 22 | if strings.Count(trimmed, " ") < len(trimmed) { 23 | trimmed = strings.TrimLeft(string(p), " ") 24 | } 25 | 26 | // remove trailing dir separators 27 | trimmed = strings.TrimRight(trimmed, DirSeparator) 28 | 29 | // special case for root "/" 30 | if trimmed == "" { 31 | return DirSeparator 32 | } 33 | return Path(path.Clean(trimmed)) 34 | } 35 | 36 | func (p Path) IsAbsolutePath() bool { 37 | return strings.HasPrefix(string(p), DirSeparator) 38 | } 39 | 40 | // Basename of the path (i.e. filename) 41 | func (p Path) Basename() string { 42 | return path.Base(string(p)) 43 | } 44 | 45 | // IsDirWhiteout indicates if the path has a basename is a opaque whiteout (which means all parent directory contents should be ignored during squashing) 46 | func (p Path) IsDirWhiteout() bool { 47 | return p.Basename() == OpaqueWhiteout 48 | } 49 | 50 | // IsWhiteout indicates if the file basename has a whiteout prefix (which means that the file should be removed during squashing) 51 | func (p Path) IsWhiteout() bool { 52 | return strings.HasPrefix(p.Basename(), WhiteoutPrefix) 53 | } 54 | 55 | // UnWhiteoutPath is a representation of the current path with no whiteout prefixes 56 | func (p Path) UnWhiteoutPath() (Path, error) { 57 | basename := p.Basename() 58 | if strings.HasPrefix(basename, OpaqueWhiteout) { 59 | return p.ParentPath() 60 | } 61 | parent, err := p.ParentPath() 62 | if err != nil { 63 | return "", err 64 | } 65 | return Path(path.Join(string(parent), strings.TrimPrefix(basename, WhiteoutPrefix))), nil 66 | } 67 | 68 | // ParentPath returns a path object to the current files parent directory (or errors out if there is no parent) 69 | func (p Path) ParentPath() (Path, error) { 70 | parent, child := path.Split(string(p)) 71 | sanitized := Path(parent).Normalize() 72 | if sanitized == "/" { 73 | if child != "" { 74 | return "/", nil 75 | } 76 | return "", fmt.Errorf("no parent") 77 | } 78 | return sanitized, nil 79 | } 80 | 81 | // AllPaths returns all constituent paths of the current path + the current path itself (e.g. /home/wagoodman/file.txt -> /, /home, /home/wagoodman, /home/wagoodman/file.txt ) 82 | func (p Path) AllPaths() []Path { 83 | fullPaths := p.ConstituentPaths() 84 | if p != "/" { 85 | fullPaths = append(fullPaths, p) 86 | } 87 | return fullPaths 88 | } 89 | 90 | // ConstituentPaths returns all constituent paths for the current path (not including the current path itself) (e.g. /home/wagoodman/file.txt -> /, /home, /home/wagoodman ) 91 | func (p Path) ConstituentPaths() []Path { 92 | parents := strings.Split(strings.Trim(string(p), DirSeparator), DirSeparator) 93 | fullPaths := make([]Path, len(parents)) 94 | for idx := range parents { 95 | cur := DirSeparator + strings.Join(parents[:idx], DirSeparator) 96 | fullPaths[idx] = Path(cur) 97 | } 98 | return fullPaths 99 | } 100 | 101 | type Paths []Path 102 | 103 | func (p Paths) Len() int { return len(p) } 104 | func (p Paths) Swap(i, j int) { p[i], p[j] = p[j], p[i] } 105 | func (p Paths) Less(i, j int) bool { return string(p[i]) < string(p[j]) } 106 | -------------------------------------------------------------------------------- /pkg/event/parsers/parsers.go: -------------------------------------------------------------------------------- 1 | package parsers 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/wagoodman/go-partybus" 7 | "github.com/wagoodman/go-progress" 8 | 9 | "github.com/anchore/stereoscope/pkg/event" 10 | "github.com/anchore/stereoscope/pkg/image" 11 | "github.com/anchore/stereoscope/pkg/image/containerd" 12 | "github.com/anchore/stereoscope/pkg/image/docker" 13 | ) 14 | 15 | type ErrBadPayload struct { 16 | Type partybus.EventType 17 | Field string 18 | Value interface{} 19 | } 20 | 21 | func (e *ErrBadPayload) Error() string { 22 | return fmt.Sprintf("event='%s' has bad event payload field='%v': '%+v'", string(e.Type), e.Field, e.Value) 23 | } 24 | 25 | func newPayloadErr(t partybus.EventType, field string, value interface{}) error { 26 | return &ErrBadPayload{ 27 | Type: t, 28 | Field: field, 29 | Value: value, 30 | } 31 | } 32 | 33 | func checkEventType(actual, expected partybus.EventType) error { 34 | if actual != expected { 35 | return newPayloadErr(expected, "Type", actual) 36 | } 37 | return nil 38 | } 39 | 40 | func ParsePullDockerImage(e partybus.Event) (string, *docker.PullStatus, error) { 41 | if err := checkEventType(e.Type, event.PullDockerImage); err != nil { 42 | return "", nil, err 43 | } 44 | 45 | imgName, ok := e.Source.(string) 46 | if !ok { 47 | return "", nil, newPayloadErr(e.Type, "Source", e.Source) 48 | } 49 | 50 | pullStatus, ok := e.Value.(*docker.PullStatus) 51 | if !ok { 52 | return "", nil, newPayloadErr(e.Type, "Value", e.Value) 53 | } 54 | 55 | return imgName, pullStatus, nil 56 | } 57 | 58 | func ParsePullContainerdImage(e partybus.Event) (string, *containerd.PullStatus, error) { 59 | if err := checkEventType(e.Type, event.PullContainerdImage); err != nil { 60 | return "", nil, err 61 | } 62 | 63 | imgName, ok := e.Source.(string) 64 | if !ok { 65 | return "", nil, newPayloadErr(e.Type, "Source", e.Source) 66 | } 67 | 68 | pullStatus, ok := e.Value.(*containerd.PullStatus) 69 | if !ok { 70 | return "", nil, newPayloadErr(e.Type, "Value", e.Value) 71 | } 72 | 73 | return imgName, pullStatus, nil 74 | } 75 | 76 | func ParseFetchImage(e partybus.Event) (string, progress.StagedProgressable, error) { 77 | if err := checkEventType(e.Type, event.FetchImage); err != nil { 78 | return "", nil, err 79 | } 80 | 81 | imgName, ok := e.Source.(string) 82 | if !ok { 83 | return "", nil, newPayloadErr(e.Type, "Source", e.Source) 84 | } 85 | 86 | prog, ok := e.Value.(progress.StagedProgressable) 87 | if !ok { 88 | return "", nil, newPayloadErr(e.Type, "Value", e.Value) 89 | } 90 | 91 | return imgName, prog, nil 92 | } 93 | 94 | func ParseReadImage(e partybus.Event) (*image.Metadata, progress.Progressable, error) { 95 | if err := checkEventType(e.Type, event.ReadImage); err != nil { 96 | return nil, nil, err 97 | } 98 | 99 | imgMetadata, ok := e.Source.(image.Metadata) 100 | if !ok { 101 | return nil, nil, newPayloadErr(e.Type, "Source", e.Source) 102 | } 103 | 104 | prog, ok := e.Value.(progress.Progressable) 105 | if !ok { 106 | return nil, nil, newPayloadErr(e.Type, "Value", e.Value) 107 | } 108 | 109 | return &imgMetadata, prog, nil 110 | } 111 | 112 | func ParseReadLayer(e partybus.Event) (*image.LayerMetadata, progress.Monitorable, error) { 113 | if err := checkEventType(e.Type, event.ReadLayer); err != nil { 114 | return nil, nil, err 115 | } 116 | 117 | layerMetadata, ok := e.Source.(image.LayerMetadata) 118 | if !ok { 119 | return nil, nil, newPayloadErr(e.Type, "Source", e.Source) 120 | } 121 | 122 | prog, ok := e.Value.(progress.Monitorable) 123 | if !ok { 124 | return nil, nil, newPayloadErr(e.Type, "Value", e.Value) 125 | } 126 | 127 | return &layerMetadata, prog, nil 128 | } 129 | -------------------------------------------------------------------------------- /pkg/image/layer_test.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "strings" 7 | "testing" 8 | 9 | v1 "github.com/google/go-containerregistry/pkg/v1" 10 | v1Types "github.com/google/go-containerregistry/pkg/v1/types" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | type mockLayer struct { 15 | mediaType v1Types.MediaType 16 | err error 17 | } 18 | 19 | func (m mockLayer) Digest() (v1.Hash, error) { 20 | return v1.Hash{ 21 | Algorithm: "sha256", 22 | Hex: "aaaaaaaaaa1234", 23 | }, nil 24 | } 25 | 26 | func (m mockLayer) DiffID() (v1.Hash, error) { 27 | return v1.Hash{ 28 | Algorithm: "sha256", 29 | Hex: "aaaaaaaaaa1234", 30 | }, nil 31 | } 32 | 33 | func (m mockLayer) Compressed() (io.ReadCloser, error) { 34 | panic("implement me") 35 | } 36 | 37 | func (m mockLayer) Uncompressed() (io.ReadCloser, error) { 38 | return io.NopCloser(strings.NewReader("")), nil 39 | } 40 | 41 | func (m mockLayer) Size() (int64, error) { 42 | return 0, nil 43 | } 44 | 45 | func (m mockLayer) MediaType() (v1Types.MediaType, error) { 46 | return m.mediaType, m.err 47 | } 48 | 49 | var _ v1.Layer = &mockLayer{} 50 | 51 | func fakeLayer(mediaType v1Types.MediaType, err error) v1.Layer { 52 | return mockLayer{ 53 | mediaType: mediaType, 54 | err: err, 55 | } 56 | } 57 | 58 | func TestRead(t *testing.T) { 59 | tests := []struct { 60 | name string 61 | mediaType v1Types.MediaType 62 | mediaTypeErr error 63 | wantErrContents string 64 | }{ 65 | { 66 | name: "unsupported media type", 67 | mediaType: "garbage", 68 | mediaTypeErr: nil, 69 | wantErrContents: "unknown layer media type: garbage", 70 | }, 71 | { 72 | name: "unsupported media type: helm chart", 73 | mediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip", 74 | wantErrContents: "application/vnd.cncf.helm.chart.content.v1.tar+gzip", 75 | }, 76 | { 77 | name: "err on media type returned", 78 | mediaTypeErr: errors.New("no media type for you"), 79 | wantErrContents: "no media type for you", 80 | }, 81 | { 82 | name: "support OCI layer", 83 | mediaType: v1Types.OCILayer, 84 | }, 85 | { 86 | name: "support OCI uncompressed layer", 87 | mediaType: v1Types.OCIUncompressedLayer, 88 | }, 89 | { 90 | name: "support OCI restricted layer", 91 | mediaType: v1Types.OCIRestrictedLayer, 92 | }, 93 | { 94 | name: "support OCI uncompressed restricted layer", 95 | mediaType: v1Types.OCIUncompressedRestrictedLayer, 96 | }, 97 | { 98 | name: "support OCI zstd layer", 99 | mediaType: v1Types.OCILayerZStd, 100 | }, 101 | { 102 | name: "support docker tar.gz layer", 103 | mediaType: v1Types.DockerLayer, 104 | }, 105 | { 106 | name: "support docker foreign layer", 107 | mediaType: v1Types.DockerForeignLayer, 108 | }, 109 | { 110 | name: "support docker uncompressed layer", 111 | mediaType: v1Types.DockerUncompressedLayer, 112 | }, 113 | { 114 | name: "support docker tar.zstd layer", 115 | mediaType: BuildKitZstdCompressedLayer, 116 | }, 117 | { 118 | name: "support docker tar+zstd layer", 119 | mediaType: BuildKitZstdCompressedLayerAlt, 120 | }, 121 | } 122 | 123 | for _, tt := range tests { 124 | t.Run(tt.name, func(t *testing.T) { 125 | layer := Layer{layer: fakeLayer(tt.mediaType, tt.mediaTypeErr)} 126 | catalog := NewFileCatalog() 127 | err := layer.Read(catalog, 0, t.TempDir()) 128 | if tt.wantErrContents != "" { 129 | require.ErrorContains(t, err, tt.wantErrContents) 130 | return 131 | } 132 | require.NoError(t, err) 133 | }) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /pkg/file/lazy_bounded_read_closer.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "os" 7 | 8 | "github.com/anchore/stereoscope/internal/log" 9 | ) 10 | 11 | var _ interface { 12 | io.ReadCloser 13 | io.ReaderAt 14 | io.Seeker 15 | } = (*lazyBoundedReadCloser)(nil) 16 | 17 | // lazyBoundedReadCloser is a "lazy" read closer, allocating a file descriptor for the given path only upon the first Read() call. 18 | // Only part of the file is allowed to be read, starting at a given position. 19 | type lazyBoundedReadCloser struct { 20 | // path is the path to be opened 21 | path string 22 | // file is the active file handle for the given path 23 | file *os.File 24 | // reader is the LimitedReader that wraps the open file 25 | reader *io.SectionReader 26 | start int64 27 | size int64 28 | isEOF bool 29 | isClosed bool 30 | } 31 | 32 | // NewDeferredPartialReadCloser creates a new NewDeferredPartialReadCloser for the given path. 33 | func newLazyBoundedReadCloser(path string, start, size int64) *lazyBoundedReadCloser { 34 | return &lazyBoundedReadCloser{ 35 | path: path, 36 | start: start, 37 | size: size, 38 | } 39 | } 40 | 41 | // Read implements the io.Reader interface for the previously loaded path, opening the file upon the first invocation. 42 | func (d *lazyBoundedReadCloser) Read(b []byte) (int, error) { 43 | if err := d.openFile(); err != nil { 44 | return 0, err 45 | } 46 | 47 | n, err := d.reader.Read(b) 48 | if err != nil && errors.Is(err, io.EOF) { 49 | d.isEOF = true 50 | d.reader = nil // IMPORTANT: this needs to be unset so opneFile continues to work when appropriate 51 | // we've reached the end of the file, release of the file descriptor. continue to return EOF 52 | if closeErr := d.file.Close(); closeErr != nil { 53 | log.Tracef("unable to close: %v: %v", d.path, closeErr) 54 | } 55 | } 56 | return n, err 57 | } 58 | 59 | // Close implements the io.Closer interface for the previously loaded path / opened file. 60 | func (d *lazyBoundedReadCloser) Close() error { 61 | d.isClosed = true 62 | 63 | if d.file == nil { 64 | return nil 65 | } 66 | 67 | err := d.file.Close() 68 | if err != nil && errors.Is(err, os.ErrClosed) { 69 | // ignore the fact that this file has already been closed 70 | err = nil 71 | } 72 | d.file = nil 73 | d.reader = nil 74 | return err 75 | } 76 | 77 | func (d *lazyBoundedReadCloser) Seek(offset int64, whence int) (int64, error) { 78 | // let Read determine further EOF state 79 | d.isEOF = false 80 | 81 | if err := d.openFile(); err != nil { 82 | return 0, err 83 | } 84 | 85 | return d.reader.Seek(offset, whence) 86 | } 87 | 88 | func (d *lazyBoundedReadCloser) ReadAt(b []byte, off int64) (n int, err error) { 89 | // let Read determine further EOF state 90 | d.isEOF = false 91 | 92 | if err := d.openFile(); err != nil { 93 | return 0, err 94 | } 95 | 96 | n, err = d.reader.ReadAt(b, off) 97 | if err != nil && errors.Is(err, io.EOF) { 98 | d.isEOF = true 99 | d.reader = nil // IMPORTANT: this needs to be unset so opneFile continues to work when appropriate 100 | // we've reached the end of the file, release of the file descriptor. continue to return EOF 101 | if closeErr := d.file.Close(); closeErr != nil { 102 | log.Tracef("unable to close: %v: %v", d.path, closeErr) 103 | } 104 | } 105 | return n, err 106 | } 107 | 108 | func (d *lazyBoundedReadCloser) openFile() error { 109 | if d.isClosed { 110 | return os.ErrClosed 111 | } 112 | if d.isEOF { 113 | return io.EOF 114 | } 115 | if d.reader != nil { 116 | return nil 117 | } 118 | 119 | file, err := os.Open(d.path) 120 | if err != nil { 121 | return err 122 | } 123 | 124 | d.file = file 125 | d.reader = io.NewSectionReader(d.file, d.start, d.size) 126 | return nil 127 | } 128 | -------------------------------------------------------------------------------- /pkg/image/docker/tarball_provider.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "time" 8 | 9 | v1 "github.com/google/go-containerregistry/pkg/v1" 10 | "github.com/google/go-containerregistry/pkg/v1/tarball" 11 | 12 | "github.com/anchore/stereoscope/internal/log" 13 | "github.com/anchore/stereoscope/pkg/file" 14 | "github.com/anchore/stereoscope/pkg/image" 15 | ) 16 | 17 | const Archive image.Source = image.DockerTarballSource 18 | 19 | // NewArchiveProvider creates a new provider able to resolve docker tarball archives 20 | func NewArchiveProvider(tmpDirGen *file.TempDirGenerator, path string, additionalMetadata ...image.AdditionalMetadata) image.Provider { 21 | return &tarballImageProvider{ 22 | tmpDirGen: tmpDirGen, 23 | path: path, 24 | additionalMetadata: additionalMetadata, 25 | } 26 | } 27 | 28 | var ErrMultipleManifests = fmt.Errorf("cannot process multiple docker manifests") 29 | 30 | // tarballImageProvider is a image.Provider for a docker image (V2) for an existing tar on disk (the output from a "docker image save ..." command). 31 | type tarballImageProvider struct { 32 | tmpDirGen *file.TempDirGenerator 33 | path string 34 | additionalMetadata []image.AdditionalMetadata 35 | } 36 | 37 | func (p *tarballImageProvider) Name() string { 38 | return Archive 39 | } 40 | 41 | // Provide an image object that represents the docker image tar at the configured location on disk. 42 | func (p *tarballImageProvider) Provide(_ context.Context) (*image.Image, error) { 43 | startTime := time.Now() 44 | 45 | img, err := tarball.ImageFromPath(p.path, nil) 46 | if err != nil { 47 | // raise a more controlled error for when there are multiple images within the given tar (from https://github.com/anchore/grype/issues/215) 48 | if err.Error() == "tarball must contain only a single image to be used with tarball.Image" { 49 | return nil, ErrMultipleManifests 50 | } 51 | return nil, fmt.Errorf("unable to provide image from tarball: %w", err) 52 | } 53 | 54 | log.WithFields("image", p.path, "time", time.Since(startTime)).Debug("got uncompressed image tarball") 55 | 56 | // make a best-effort to generate an OCI manifest and gets tags, but ultimately this should be considered optional 57 | var rawOCIManifest []byte 58 | var rawConfig []byte 59 | var ociManifest *v1.Manifest 60 | var metadata []image.AdditionalMetadata 61 | 62 | theManifest, err := extractManifest(p.path) 63 | if err != nil { 64 | log.Warnf("could not extract manifest: %+v", err) 65 | } 66 | 67 | if theManifest != nil { 68 | // given that we have a manifest, continue processing to get the tags and OCI manifest 69 | metadata = append(metadata, image.WithTags(theManifest.allTags()...)) 70 | 71 | ociManifest, rawConfig, err = generateOCIManifest(p.path, theManifest) 72 | if err != nil { 73 | log.Warnf("failed to generate OCI manifest from docker archive: %+v", err) 74 | } 75 | 76 | // we may have the config available, use it 77 | if rawConfig != nil { 78 | metadata = append(metadata, image.WithConfig(rawConfig)) 79 | } 80 | } 81 | 82 | if ociManifest != nil { 83 | rawOCIManifest, err = json.Marshal(&ociManifest) 84 | if err != nil { 85 | log.Warnf("failed to serialize OCI manifest: %+v", err) 86 | } else { 87 | metadata = append(metadata, image.WithManifest(rawOCIManifest)) 88 | } 89 | } 90 | 91 | // apply user-supplied metadata last to override any default behavior 92 | metadata = append(metadata, p.additionalMetadata...) 93 | 94 | contentTempDir, err := p.tmpDirGen.NewDirectory("docker-tarball-image") 95 | if err != nil { 96 | return nil, err 97 | } 98 | 99 | out := image.New(img, p.tmpDirGen, contentTempDir, metadata...) 100 | err = out.Read() 101 | if err != nil { 102 | return nil, err 103 | } 104 | return out, err 105 | } 106 | --------------------------------------------------------------------------------