pax_global_header00006660000000000000000000000064145767453030014530gustar00rootroot0000000000000052 comment=9b6f32158776a699b23edb3db86d053623619b60 oras-go-2.5.0/000077500000000000000000000000001457674530300131035ustar00rootroot00000000000000oras-go-2.5.0/.github/000077500000000000000000000000001457674530300144435ustar00rootroot00000000000000oras-go-2.5.0/.github/.codecov.yml000066400000000000000000000012331457674530300166650ustar00rootroot00000000000000# Copyright The ORAS Authors. # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. coverage: status: project: default: target: 75% if_ci_failed: error oras-go-2.5.0/.github/dependabot.yml000066400000000000000000000016411457674530300172750ustar00rootroot00000000000000# Copyright The ORAS Authors. # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. version: 2 updates: - package-ecosystem: "gomod" directory: "/" schedule: interval: "daily" - package-ecosystem: "github-actions" directory: "/" schedule: interval: "daily" # update go dependencies for v1 branch - package-ecosystem: "gomod" directory: "/" schedule: interval: "daily" target-branch: "v1" oras-go-2.5.0/.github/licenserc.yml000066400000000000000000000026101457674530300171340ustar00rootroot00000000000000# Copyright The ORAS Authors. # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. header: license: spdx-id: Apache-2.0 content: | Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. paths-ignore: - '**/*.md' - 'CODEOWNERS' - 'LICENSE' - 'go.mod' - 'go.sum' - '**/testdata/**' comment: on-failure dependency: files: - go.mod oras-go-2.5.0/.github/workflows/000077500000000000000000000000001457674530300165005ustar00rootroot00000000000000oras-go-2.5.0/.github/workflows/build.yml000066400000000000000000000023061457674530300203230ustar00rootroot00000000000000# Copyright The ORAS Authors. # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. name: build on: push: branches: - main - release-* pull_request: branches: - main - release-* jobs: build: runs-on: ubuntu-latest strategy: matrix: go-version: ['1.21', '1.22'] fail-fast: true steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Go ${{ matrix.go-version }} environment uses: actions/setup-go@v5 with: go-version: ${{ matrix.go-version }} check-latest: true - name: Run unit tests run: make test - name: Upload coverage to codecov.io uses: codecov/codecov-action@v3 oras-go-2.5.0/.github/workflows/codeql-analysis.yml000066400000000000000000000026401457674530300223150ustar00rootroot00000000000000# Copyright The ORAS Authors. # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. name: CodeQL on: push: branches: - main - release-* pull_request: branches: - main - release-* schedule: - cron: '34 13 * * 3' jobs: analyze: name: Analyze runs-on: ubuntu-latest permissions: actions: read contents: read security-events: write strategy: matrix: go-version: ['1.21', '1.22'] fail-fast: false steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Go ${{ matrix.go-version }} environment uses: actions/setup-go@v5 with: go-version: ${{ matrix.go-version }} check-latest: true - name: Initialize CodeQL uses: github/codeql-action/init@v3 with: languages: go - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 oras-go-2.5.0/.github/workflows/license-checker.yml000066400000000000000000000022561457674530300222540ustar00rootroot00000000000000# Copyright The ORAS Authors. # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. name: License Checker on: push: branches: - main - release-* pull_request: branches: - main - release-* permissions: contents: write pull-requests: write jobs: check-license: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Check license header uses: apache/skywalking-eyes/header@v0.5.0 with: mode: check config: .github/licenserc.yml - name: Check dependencies license uses: apache/skywalking-eyes/dependency@v0.5.0 with: config: .github/licenserc.yml oras-go-2.5.0/.gitignore000066400000000000000000000015501457674530300150740ustar00rootroot00000000000000# Copyright The ORAS Authors. # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib # Test binary, build with `go test -c` *.test # Output of the go coverage tool, specifically when used with LiteIDE *.out # VS Code .vscode debug # Jetbrains .idea # Custom coverage.txt bin/ dist/ *.tar.gz vendor/ _dist/ .cover oras-go-2.5.0/CODEOWNERS000066400000000000000000000001131457674530300144710ustar00rootroot00000000000000# Derived from OWNERS.md * @sajayantony @shizhMSFT @stevelasker @Wwwsylvia oras-go-2.5.0/CODE_OF_CONDUCT.md000066400000000000000000000002311457674530300156760ustar00rootroot00000000000000# Code of Conduct OCI Registry As Storage (ORAS) follows the [CNCF Code of Conduct](https://github.com/cncf/foundation/blob/master/code-of-conduct.md). oras-go-2.5.0/LICENSE000066400000000000000000000261171457674530300141170ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2021 ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. oras-go-2.5.0/MIGRATION_GUIDE.md000066400000000000000000000033241457674530300156750ustar00rootroot00000000000000# Migration Guide In version `v2`, ORAS Go library has been completely refreshed with: - More unified interfaces - Notably fewer dependencies - Higher test coverage - Better documentation **Besides, ORAS Go `v2` is now a registry client.** ## Major Changes in `v2` - Moves `content.FileStore` to [file.Store](https://pkg.go.dev/oras.land/oras-go/v2/content/file#Store) - Moves `content.OCIStore` to [oci.Store](https://pkg.go.dev/oras.land/oras-go/v2/content/oci#Store) - Moves `content.MemoryStore` to [memory.Store](https://pkg.go.dev/oras.land/oras-go/v2/content/memory#Store) - Provides [SDK](https://pkg.go.dev/oras.land/oras-go/v2/registry/remote) to interact with OCI-compliant and Docker-compliant registries - Supports [Copy](https://pkg.go.dev/oras.land/oras-go/v2#Copy) with more flexible options - Supports [Extended Copy](https://pkg.go.dev/oras.land/oras-go/v2#ExtendedCopy) with options *(experimental)* - No longer supports `docker.Login` and `docker.Logout` (removes the dependency on `docker`); instead, provides authentication through [auth.Client](https://pkg.go.dev/oras.land/oras-go/v2/registry/remote/auth#Client) Documentation and examples are available at [pkg.go.dev](https://pkg.go.dev/oras.land/oras-go/v2). ## Migrating from `v1` to `v2` 1. Get the `v2` package ```sh go get oras.land/oras-go/v2 ``` 2. Import and use the `v2` package ```go import "oras.land/oras-go/v2" ``` 3. Run ```sh go mod tidy ``` Since breaking changes are introduced in `v2`, code refactoring is required for migrating from `v1` to `v2`. The migration can be done in an iterative fashion, as `v1` and `v2` can be imported and used at the same time. oras-go-2.5.0/Makefile000066400000000000000000000023441457674530300145460ustar00rootroot00000000000000# Copyright The ORAS Authors. # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. .PHONY: test test: vendor check-encoding go test -race -v -coverprofile=coverage.txt -covermode=atomic ./... .PHONY: covhtml covhtml: open .cover/coverage.html .PHONY: clean clean: git status --ignored --short | grep '^!! ' | sed 's/!! //' | xargs rm -rf .PHONY: check-encoding check-encoding: ! find . -not -path "./vendor/*" -name "*.go" -type f -exec file "{}" ";" | grep CRLF ! find scripts -name "*.sh" -type f -exec file "{}" ";" | grep CRLF .PHONY: fix-encoding fix-encoding: find . -not -path "./vendor/*" -name "*.go" -type f -exec sed -i -e "s/\r//g" {} + find scripts -name "*.sh" -type f -exec sed -i -e "s/\r//g" {} + .PHONY: vendor vendor: go mod vendor oras-go-2.5.0/OWNERS.md000066400000000000000000000003211457674530300144360ustar00rootroot00000000000000# Owners Owners: - Sajay Antony (@sajayantony) - Shiwei Zhang (@shizhMSFT) - Steve Lasker (@stevelasker) - Sylvia Lei (@Wwwsylvia) Emeritus: - Avi Deitcher (@deitch) - Josh Dolitsky (@jdolitsky) oras-go-2.5.0/README.md000066400000000000000000000067301457674530300143700ustar00rootroot00000000000000# ORAS Go library

banner

## Project status ### Versioning The ORAS Go library follows [Semantic Versioning](https://semver.org/), where breaking changes are reserved for MAJOR releases, and MINOR and PATCH releases must be 100% backwards compatible. ### v2: stable [![Build Status](https://github.com/oras-project/oras-go/actions/workflows/build.yml/badge.svg?event=push&branch=main)](https://github.com/oras-project/oras-go/actions/workflows/build.yml?query=workflow%3Abuild+event%3Apush+branch%3Amain) [![codecov](https://codecov.io/gh/oras-project/oras-go/branch/main/graph/badge.svg)](https://codecov.io/gh/oras-project/oras-go) [![Go Report Card](https://goreportcard.com/badge/oras.land/oras-go/v2)](https://goreportcard.com/report/oras.land/oras-go/v2) [![Go Reference](https://pkg.go.dev/badge/oras.land/oras-go/v2.svg)](https://pkg.go.dev/oras.land/oras-go/v2) The version `2` is actively developed in the [`main`](https://github.com/oras-project/oras-go/tree/main) branch with all new features. > [!Note] > The `main` branch follows [Go's Security Policy](https://github.com/golang/go/security/policy) and supports the two latest versions of Go (currently `1.21` and `1.22`). Examples for common use cases can be found below: - [Copy examples](https://pkg.go.dev/oras.land/oras-go/v2#pkg-examples) - [Registry interaction examples](https://pkg.go.dev/oras.land/oras-go/v2/registry#pkg-examples) - [Repository interaction examples](https://pkg.go.dev/oras.land/oras-go/v2/registry/remote#pkg-examples) - [Authentication examples](https://pkg.go.dev/oras.land/oras-go/v2/registry/remote/auth#pkg-examples) If you are seeking latest changes, you should use the [`main`](https://github.com/oras-project/oras-go/tree/main) branch (or a specific commit hash) over a tagged version when including the ORAS Go library in your project's `go.mod`. The Go Reference for the `main` branch is available [here](https://pkg.go.dev/oras.land/oras-go/v2@main). To migrate from `v1` to `v2`, see [MIGRATION_GUIDE.md](./MIGRATION_GUIDE.md). ### v1: stable [![Build Status](https://github.com/oras-project/oras-go/actions/workflows/build.yml/badge.svg?event=push&branch=v1)](https://github.com/oras-project/oras-go/actions/workflows/build.yml?query=workflow%3Abuild+event%3Apush+branch%3Av1) [![Go Report Card](https://goreportcard.com/badge/oras.land/oras-go)](https://goreportcard.com/report/oras.land/oras-go) [![Go Reference](https://pkg.go.dev/badge/oras.land/oras-go.svg)](https://pkg.go.dev/oras.land/oras-go) As there are various stable projects depending on the ORAS Go library `v1`, the [`v1`](https://github.com/oras-project/oras-go/tree/v1) branch is maintained for API stability, dependency updates, and security patches. All `v1.*` releases are based upon this branch. Since `v1` is in a maintenance state, you are highly encouraged to use releases with major version `2` for new features. ## Docs - [oras.land/client_libraries/go](https://oras.land/docs/Client_Libraries/go): Documentation for the ORAS Go library - [Reviewing guide](https://github.com/oras-project/community/blob/main/REVIEWING.md): All reviewers must read the reviewing guide and agree to follow the project review guidelines. ## Code of Conduct This project has adopted the [CNCF Code of Conduct](https://github.com/cncf/foundation/blob/master/code-of-conduct.md). See [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for further details. oras-go-2.5.0/SECURITY.md000066400000000000000000000002441457674530300146740ustar00rootroot00000000000000# Security Policy Please follow the [security policy](https://oras.land/docs/community/reporting_security_concerns) to report a security vulnerability or concern. oras-go-2.5.0/content.go000066400000000000000000000330141457674530300151050ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package oras import ( "bytes" "context" "errors" "fmt" "io" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/errdef" "oras.land/oras-go/v2/internal/cas" "oras.land/oras-go/v2/internal/docker" "oras.land/oras-go/v2/internal/interfaces" "oras.land/oras-go/v2/internal/platform" "oras.land/oras-go/v2/internal/syncutil" "oras.land/oras-go/v2/registry" "oras.land/oras-go/v2/registry/remote/auth" ) const ( // defaultTagConcurrency is the default concurrency of tagging. defaultTagConcurrency int = 5 // This value is consistent with dockerd // defaultTagNMaxMetadataBytes is the default value of // TagNOptions.MaxMetadataBytes. defaultTagNMaxMetadataBytes int64 = 4 * 1024 * 1024 // 4 MiB // defaultResolveMaxMetadataBytes is the default value of // ResolveOptions.MaxMetadataBytes. defaultResolveMaxMetadataBytes int64 = 4 * 1024 * 1024 // 4 MiB // defaultMaxBytes is the default value of FetchBytesOptions.MaxBytes. defaultMaxBytes int64 = 4 * 1024 * 1024 // 4 MiB ) // DefaultTagNOptions provides the default TagNOptions. var DefaultTagNOptions TagNOptions // TagNOptions contains parameters for [oras.TagN]. type TagNOptions struct { // Concurrency limits the maximum number of concurrent tag tasks. // If less than or equal to 0, a default (currently 5) is used. Concurrency int // MaxMetadataBytes limits the maximum size of metadata that can be cached // in the memory. // If less than or equal to 0, a default (currently 4 MiB) is used. MaxMetadataBytes int64 } // TagN tags the descriptor identified by srcReference with dstReferences. func TagN(ctx context.Context, target Target, srcReference string, dstReferences []string, opts TagNOptions) (ocispec.Descriptor, error) { switch len(dstReferences) { case 0: return ocispec.Descriptor{}, fmt.Errorf("dstReferences cannot be empty: %w", errdef.ErrMissingReference) case 1: return Tag(ctx, target, srcReference, dstReferences[0]) } if opts.Concurrency <= 0 { opts.Concurrency = defaultTagConcurrency } if opts.MaxMetadataBytes <= 0 { opts.MaxMetadataBytes = defaultTagNMaxMetadataBytes } _, isRefFetcher := target.(registry.ReferenceFetcher) _, isRefPusher := target.(registry.ReferencePusher) if isRefFetcher && isRefPusher { if repo, ok := target.(interfaces.ReferenceParser); ok { // add scope hints to minimize the number of auth requests ref, err := repo.ParseReference(srcReference) if err != nil { return ocispec.Descriptor{}, err } ctx = auth.AppendRepositoryScope(ctx, ref, auth.ActionPull, auth.ActionPush) } desc, contentBytes, err := FetchBytes(ctx, target, srcReference, FetchBytesOptions{ MaxBytes: opts.MaxMetadataBytes, }) if err != nil { if errors.Is(err, errdef.ErrSizeExceedsLimit) { err = fmt.Errorf( "content size %v exceeds MaxMetadataBytes %v: %w", desc.Size, opts.MaxMetadataBytes, errdef.ErrSizeExceedsLimit) } return ocispec.Descriptor{}, err } if err := tagBytesN(ctx, target, desc, contentBytes, dstReferences, TagBytesNOptions{ Concurrency: opts.Concurrency, }); err != nil { return ocispec.Descriptor{}, err } return desc, nil } desc, err := target.Resolve(ctx, srcReference) if err != nil { return ocispec.Descriptor{}, err } eg, egCtx := syncutil.LimitGroup(ctx, opts.Concurrency) for _, dstRef := range dstReferences { eg.Go(func(dst string) func() error { return func() error { if err := target.Tag(egCtx, desc, dst); err != nil { return fmt.Errorf("failed to tag %s as %s: %w", srcReference, dst, err) } return nil } }(dstRef)) } if err := eg.Wait(); err != nil { return ocispec.Descriptor{}, err } return desc, nil } // Tag tags the descriptor identified by src with dst. func Tag(ctx context.Context, target Target, src, dst string) (ocispec.Descriptor, error) { refFetcher, okFetch := target.(registry.ReferenceFetcher) refPusher, okPush := target.(registry.ReferencePusher) if okFetch && okPush { if repo, ok := target.(interfaces.ReferenceParser); ok { // add scope hints to minimize the number of auth requests ref, err := repo.ParseReference(src) if err != nil { return ocispec.Descriptor{}, err } ctx = auth.AppendRepositoryScope(ctx, ref, auth.ActionPull, auth.ActionPush) } desc, rc, err := refFetcher.FetchReference(ctx, src) if err != nil { return ocispec.Descriptor{}, err } defer rc.Close() if err := refPusher.PushReference(ctx, desc, rc, dst); err != nil { return ocispec.Descriptor{}, err } return desc, nil } desc, err := target.Resolve(ctx, src) if err != nil { return ocispec.Descriptor{}, err } if err := target.Tag(ctx, desc, dst); err != nil { return ocispec.Descriptor{}, err } return desc, nil } // DefaultResolveOptions provides the default ResolveOptions. var DefaultResolveOptions ResolveOptions // ResolveOptions contains parameters for [oras.Resolve]. type ResolveOptions struct { // TargetPlatform ensures the resolved content matches the target platform // if the node is a manifest, or selects the first resolved content that // matches the target platform if the node is a manifest list. TargetPlatform *ocispec.Platform // MaxMetadataBytes limits the maximum size of metadata that can be cached // in the memory. // If less than or equal to 0, a default (currently 4 MiB) is used. MaxMetadataBytes int64 } // Resolve resolves a descriptor with provided reference from the target. func Resolve(ctx context.Context, target ReadOnlyTarget, reference string, opts ResolveOptions) (ocispec.Descriptor, error) { if opts.TargetPlatform == nil { return target.Resolve(ctx, reference) } return resolve(ctx, target, nil, reference, opts) } // resolve resolves a descriptor with provided reference from the target, with // specified caching. func resolve(ctx context.Context, target ReadOnlyTarget, proxy *cas.Proxy, reference string, opts ResolveOptions) (ocispec.Descriptor, error) { if opts.MaxMetadataBytes <= 0 { opts.MaxMetadataBytes = defaultResolveMaxMetadataBytes } if refFetcher, ok := target.(registry.ReferenceFetcher); ok { // optimize performance for ReferenceFetcher targets desc, rc, err := refFetcher.FetchReference(ctx, reference) if err != nil { return ocispec.Descriptor{}, err } defer rc.Close() switch desc.MediaType { case docker.MediaTypeManifestList, ocispec.MediaTypeImageIndex, docker.MediaTypeManifest, ocispec.MediaTypeImageManifest: // cache the fetched content if desc.Size > opts.MaxMetadataBytes { return ocispec.Descriptor{}, fmt.Errorf( "content size %v exceeds MaxMetadataBytes %v: %w", desc.Size, opts.MaxMetadataBytes, errdef.ErrSizeExceedsLimit) } if proxy == nil { proxy = cas.NewProxyWithLimit(target, cas.NewMemory(), opts.MaxMetadataBytes) } if err := proxy.Cache.Push(ctx, desc, rc); err != nil { return ocispec.Descriptor{}, err } // stop caching as SelectManifest may fetch a config blob proxy.StopCaching = true return platform.SelectManifest(ctx, proxy, desc, opts.TargetPlatform) default: return ocispec.Descriptor{}, fmt.Errorf("%s: %s: %w", desc.Digest, desc.MediaType, errdef.ErrUnsupported) } } desc, err := target.Resolve(ctx, reference) if err != nil { return ocispec.Descriptor{}, err } return platform.SelectManifest(ctx, target, desc, opts.TargetPlatform) } // DefaultFetchOptions provides the default FetchOptions. var DefaultFetchOptions FetchOptions // FetchOptions contains parameters for [oras.Fetch]. type FetchOptions struct { // ResolveOptions contains parameters for resolving reference. ResolveOptions } // Fetch fetches the content identified by the reference. func Fetch(ctx context.Context, target ReadOnlyTarget, reference string, opts FetchOptions) (ocispec.Descriptor, io.ReadCloser, error) { if opts.TargetPlatform == nil { if refFetcher, ok := target.(registry.ReferenceFetcher); ok { return refFetcher.FetchReference(ctx, reference) } desc, err := target.Resolve(ctx, reference) if err != nil { return ocispec.Descriptor{}, nil, err } rc, err := target.Fetch(ctx, desc) if err != nil { return ocispec.Descriptor{}, nil, err } return desc, rc, nil } if opts.MaxMetadataBytes <= 0 { opts.MaxMetadataBytes = defaultResolveMaxMetadataBytes } proxy := cas.NewProxyWithLimit(target, cas.NewMemory(), opts.MaxMetadataBytes) desc, err := resolve(ctx, target, proxy, reference, opts.ResolveOptions) if err != nil { return ocispec.Descriptor{}, nil, err } // if the content exists in cache, fetch it from cache // otherwise fetch without caching proxy.StopCaching = true rc, err := proxy.Fetch(ctx, desc) if err != nil { return ocispec.Descriptor{}, nil, err } return desc, rc, nil } // DefaultFetchBytesOptions provides the default FetchBytesOptions. var DefaultFetchBytesOptions FetchBytesOptions // FetchBytesOptions contains parameters for [oras.FetchBytes]. type FetchBytesOptions struct { // FetchOptions contains parameters for fetching content. FetchOptions // MaxBytes limits the maximum size of the fetched content bytes. // If less than or equal to 0, a default (currently 4 MiB) is used. MaxBytes int64 } // FetchBytes fetches the content bytes identified by the reference. func FetchBytes(ctx context.Context, target ReadOnlyTarget, reference string, opts FetchBytesOptions) (ocispec.Descriptor, []byte, error) { if opts.MaxBytes <= 0 { opts.MaxBytes = defaultMaxBytes } desc, rc, err := Fetch(ctx, target, reference, opts.FetchOptions) if err != nil { return ocispec.Descriptor{}, nil, err } defer rc.Close() if desc.Size > opts.MaxBytes { return ocispec.Descriptor{}, nil, fmt.Errorf( "content size %v exceeds MaxBytes %v: %w", desc.Size, opts.MaxBytes, errdef.ErrSizeExceedsLimit) } bytes, err := content.ReadAll(rc, desc) if err != nil { return ocispec.Descriptor{}, nil, err } return desc, bytes, nil } // PushBytes describes the contentBytes using the given mediaType and pushes it. // If mediaType is not specified, "application/octet-stream" is used. func PushBytes(ctx context.Context, pusher content.Pusher, mediaType string, contentBytes []byte) (ocispec.Descriptor, error) { desc := content.NewDescriptorFromBytes(mediaType, contentBytes) r := bytes.NewReader(contentBytes) if err := pusher.Push(ctx, desc, r); err != nil { return ocispec.Descriptor{}, err } return desc, nil } // DefaultTagBytesNOptions provides the default TagBytesNOptions. var DefaultTagBytesNOptions TagBytesNOptions // TagBytesNOptions contains parameters for [oras.TagBytesN]. type TagBytesNOptions struct { // Concurrency limits the maximum number of concurrent tag tasks. // If less than or equal to 0, a default (currently 5) is used. Concurrency int } // TagBytesN describes the contentBytes using the given mediaType, pushes it, // and tag it with the given references. // If mediaType is not specified, "application/octet-stream" is used. func TagBytesN(ctx context.Context, target Target, mediaType string, contentBytes []byte, references []string, opts TagBytesNOptions) (ocispec.Descriptor, error) { if len(references) == 0 { return PushBytes(ctx, target, mediaType, contentBytes) } desc := content.NewDescriptorFromBytes(mediaType, contentBytes) if opts.Concurrency <= 0 { opts.Concurrency = defaultTagConcurrency } if err := tagBytesN(ctx, target, desc, contentBytes, references, opts); err != nil { return ocispec.Descriptor{}, err } return desc, nil } // tagBytesN pushes the contentBytes using the given desc, and tag it with the // given references. func tagBytesN(ctx context.Context, target Target, desc ocispec.Descriptor, contentBytes []byte, references []string, opts TagBytesNOptions) error { eg, egCtx := syncutil.LimitGroup(ctx, opts.Concurrency) if refPusher, ok := target.(registry.ReferencePusher); ok { for _, reference := range references { eg.Go(func(ref string) func() error { return func() error { r := bytes.NewReader(contentBytes) if err := refPusher.PushReference(egCtx, desc, r, ref); err != nil && !errors.Is(err, errdef.ErrAlreadyExists) { return fmt.Errorf("failed to tag %s: %w", ref, err) } return nil } }(reference)) } } else { r := bytes.NewReader(contentBytes) if err := target.Push(ctx, desc, r); err != nil && !errors.Is(err, errdef.ErrAlreadyExists) { return fmt.Errorf("failed to push content: %w", err) } for _, reference := range references { eg.Go(func(ref string) func() error { return func() error { if err := target.Tag(egCtx, desc, ref); err != nil { return fmt.Errorf("failed to tag %s: %w", ref, err) } return nil } }(reference)) } } return eg.Wait() } // TagBytes describes the contentBytes using the given mediaType, pushes it, // and tag it with the given reference. // If mediaType is not specified, "application/octet-stream" is used. func TagBytes(ctx context.Context, target Target, mediaType string, contentBytes []byte, reference string) (ocispec.Descriptor, error) { return TagBytesN(ctx, target, mediaType, contentBytes, []string{reference}, DefaultTagBytesNOptions) } oras-go-2.5.0/content/000077500000000000000000000000001457674530300145555ustar00rootroot00000000000000oras-go-2.5.0/content/descriptor.go000066400000000000000000000025161457674530300172660ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package content import ( "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/internal/descriptor" ) // NewDescriptorFromBytes returns a descriptor, given the content and media type. // If no media type is specified, "application/octet-stream" will be used. func NewDescriptorFromBytes(mediaType string, content []byte) ocispec.Descriptor { if mediaType == "" { mediaType = descriptor.DefaultMediaType } return ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(content), Size: int64(len(content)), } } // Equal returns true if two descriptors point to the same content. func Equal(a, b ocispec.Descriptor) bool { return a.Size == b.Size && a.Digest == b.Digest && a.MediaType == b.MediaType } oras-go-2.5.0/content/descriptor_test.go000066400000000000000000000105351457674530300203250ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package content import ( "reflect" "testing" "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/internal/descriptor" ) func TestGenerateDescriptor(t *testing.T) { contentFoo := []byte("foo") contentBar := []byte("bar") type args struct { content []byte mediaType string } tests := []struct { name string args args want ocispec.Descriptor }{ { name: "foo descriptor", args: args{contentFoo, "example media type"}, want: ocispec.Descriptor{ MediaType: "example media type", Digest: digest.FromBytes(contentFoo), Size: int64(len(contentFoo))}, }, { name: "empty content", args: args{[]byte(""), "example media type"}, want: ocispec.Descriptor{ MediaType: "example media type", Digest: digest.FromBytes([]byte("")), Size: int64(len([]byte("")))}, }, { name: "missing media type", args: args{contentBar, ""}, want: ocispec.Descriptor{ MediaType: descriptor.DefaultMediaType, Digest: digest.FromBytes(contentBar), Size: int64(len(contentBar))}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := NewDescriptorFromBytes(tt.args.mediaType, tt.args.content) if !reflect.DeepEqual(got, tt.want) { t.Errorf("GenerateDescriptor() = %v, want %v", got, tt.want) } }) } } func TestEqual(t *testing.T) { contentFoo := []byte("foo") contentBar := []byte("bar") type args struct { a ocispec.Descriptor b ocispec.Descriptor } tests := []struct { name string args args want bool }{ { name: "same media type, digest and size", args: args{ ocispec.Descriptor{ MediaType: "example media type", Digest: digest.FromBytes(contentFoo), Size: int64(len(contentFoo))}, ocispec.Descriptor{ MediaType: "example media type", Digest: digest.FromBytes(contentFoo), Size: int64(len(contentFoo))}}, want: true, }, { name: "different media type, same digest and size", args: args{ ocispec.Descriptor{ MediaType: "example media type", Digest: digest.FromBytes(contentFoo), Size: int64(len(contentFoo))}, ocispec.Descriptor{ MediaType: "another media type", Digest: digest.FromBytes(contentFoo), Size: int64(len(contentFoo))}}, want: false, }, { name: "different digest, same media type and size", args: args{ ocispec.Descriptor{ MediaType: "example media type", Digest: digest.FromBytes(contentFoo), Size: int64(len(contentFoo))}, ocispec.Descriptor{ MediaType: "example media type", Digest: digest.FromBytes(contentBar), Size: int64(len(contentBar))}}, want: false, }, { name: "only same media type", args: args{ ocispec.Descriptor{ MediaType: "example media type", Digest: digest.FromBytes([]byte("fooooo")), Size: int64(len([]byte("foooo")))}, ocispec.Descriptor{ MediaType: "example media type", Digest: digest.FromBytes(contentBar), Size: int64(len(contentBar))}}, want: false, }, { name: "different size, same media type and digest", args: args{ ocispec.Descriptor{ MediaType: "example media type", Digest: digest.FromBytes(contentFoo), Size: int64(len(contentFoo))}, ocispec.Descriptor{ MediaType: "example media type", Digest: digest.FromBytes(contentFoo), Size: int64(len(contentFoo)) + 1}}, want: false, }, { name: "two empty descriptors", args: args{ ocispec.Descriptor{}, ocispec.Descriptor{}}, want: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := Equal(tt.args.a, tt.args.b); got != tt.want { t.Errorf("Equal() = %v, want %v", got, tt.want) } }) } } oras-go-2.5.0/content/example_test.go000066400000000000000000000023571457674530300176050ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package content_test import ( "bytes" "fmt" "io" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/content" ) // ExampleVerifyReader gives an example of creating and using VerifyReader. func ExampleVerifyReader() { blob := []byte("hello world") desc := content.NewDescriptorFromBytes(ocispec.MediaTypeImageLayer, blob) r := bytes.NewReader(blob) vr := content.NewVerifyReader(r, desc) buf := bytes.NewBuffer(nil) if _, err := io.Copy(buf, vr); err != nil { panic(err) } // note: users should not trust the the read content until // Verify() returns nil. if err := vr.Verify(); err != nil { panic(err) } fmt.Println(buf) // Output: // hello world } oras-go-2.5.0/content/file/000077500000000000000000000000001457674530300154745ustar00rootroot00000000000000oras-go-2.5.0/content/file/errors.go000066400000000000000000000017151457674530300173430ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package file import "errors" var ( ErrMissingName = errors.New("missing name") ErrDuplicateName = errors.New("duplicate name") ErrPathTraversalDisallowed = errors.New("path traversal disallowed") ErrOverwriteDisallowed = errors.New("overwrite disallowed") ErrStoreClosed = errors.New("store already closed") ) var errSkipUnnamed = errors.New("unnamed descriptor skipped") oras-go-2.5.0/content/file/example_test.go000066400000000000000000000057621457674530300205270ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // Package file_test includes all the testable examples for the file package. package file_test import ( "context" "fmt" "os" "path/filepath" "testing" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2" "oras.land/oras-go/v2/content/file" ) var workingDir string // the working directory for the examples func TestMain(m *testing.M) { // prepare test directory var err error workingDir, err = os.MkdirTemp("", "oras_file_example_*") if err != nil { panic(err) } tearDown := func() { if err := os.RemoveAll(workingDir); err != nil { panic(err) } } // prepare test file 1 content := []byte("foo") filename := "foo.txt" path := filepath.Join(workingDir, filename) if err := os.WriteFile(path, content, 0444); err != nil { panic(err) } // prepare test file 2 content = []byte("bar") filename = "bar.txt" path = filepath.Join(workingDir, filename) if err := os.WriteFile(path, content, 0444); err != nil { panic(err) } // run tests exitCode := m.Run() // tear down and exit tearDown() os.Exit(exitCode) } // Example_packFiles gives an example of adding files and generating a manifest // referencing the files. func Example_packFiles() { store, err := file.New(workingDir) if err != nil { panic(err) } defer store.Close() ctx := context.Background() // 1. Add files into the file store mediaType := "example/file" fileNames := []string{"foo.txt", "bar.txt"} fileDescriptors := make([]ocispec.Descriptor, 0, len(fileNames)) for _, name := range fileNames { fileDescriptor, err := store.Add(ctx, name, mediaType, "") if err != nil { panic(err) } fileDescriptors = append(fileDescriptors, fileDescriptor) fmt.Printf("file descriptor for %s: %v\n", name, fileDescriptor) } // 2. Generate a manifest referencing the files artifactType := "example/test" manifestDescriptor, err := oras.Pack(ctx, store, artifactType, fileDescriptors, oras.PackOptions{}) if err != nil { panic(err) } fmt.Println("manifest media type:", manifestDescriptor.MediaType) // Output: // file descriptor for foo.txt: {example/file sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae 3 [] map[org.opencontainers.image.title:foo.txt] [] } // file descriptor for bar.txt: {example/file sha256:fcde2b2edba56bf408601fb721fe9b5c338d10ee429ea04fae5511b68fbf8fb9 3 [] map[org.opencontainers.image.title:bar.txt] [] } // manifest media type: application/vnd.oci.artifact.manifest.v1+json } oras-go-2.5.0/content/file/file.go000066400000000000000000000475161457674530300167570ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // Package file provides implementation of a content store based on file system. package file import ( "compress/gzip" "context" "errors" "fmt" "io" "os" "path/filepath" "strings" "sync" "sync/atomic" "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/errdef" "oras.land/oras-go/v2/internal/cas" "oras.land/oras-go/v2/internal/graph" "oras.land/oras-go/v2/internal/ioutil" "oras.land/oras-go/v2/internal/resolver" ) // bufPool is a pool of byte buffers that can be reused for copying content // between files. var bufPool = sync.Pool{ New: func() interface{} { // the buffer size should be larger than or equal to 128 KiB // for performance considerations. // we choose 1 MiB here so there will be less disk I/O. buffer := make([]byte, 1<<20) // buffer size = 1 MiB return &buffer }, } const ( // AnnotationDigest is the annotation key for the digest of the uncompressed content. AnnotationDigest = "io.deis.oras.content.digest" // AnnotationUnpack is the annotation key for indication of unpacking. AnnotationUnpack = "io.deis.oras.content.unpack" // defaultBlobMediaType specifies the default blob media type. defaultBlobMediaType = ocispec.MediaTypeImageLayer // defaultBlobDirMediaType specifies the default blob directory media type. defaultBlobDirMediaType = ocispec.MediaTypeImageLayerGzip // defaultFallbackPushSizeLimit specifies the default size limit for pushing no-name contents. defaultFallbackPushSizeLimit = 1 << 22 // 4 MiB ) // Store represents a file system based store, which implements `oras.Target`. // // In the file store, the contents described by names are location-addressed // by file paths. Meanwhile, the file paths are mapped to a virtual CAS // where all metadata are stored in the memory. // // The contents that are not described by names are stored in a fallback storage, // which is a limited memory CAS by default. // As all the metadata are stored in the memory, the file store // cannot be restored from the file system. // // After use, the file store needs to be closed by calling the [Store.Close] function. // The file store cannot be used after being closed. type Store struct { // TarReproducible controls if the tarballs generated // for the added directories are reproducible. // When specified, some metadata such as change time // will be removed from the files in the tarballs. Default value: false. TarReproducible bool // AllowPathTraversalOnWrite controls if path traversal is allowed // when writing files. When specified, writing files // outside the working directory will be allowed. Default value: false. AllowPathTraversalOnWrite bool // DisableOverwrite controls if push operations can overwrite existing files. // When specified, saving files to existing paths will be disabled. // Default value: false. DisableOverwrite bool // ForceCAS controls if files with same content but different names are // deduped after push operations. When a DAG is copied between CAS // targets, nodes are deduped by content. By default, file store restores // deduped successor files after a node is copied. This may result in two // files with identical content. If this is not the desired behavior, // ForceCAS can be specified to enforce CAS style dedup. // Default value: false. ForceCAS bool // IgnoreNoName controls if push operations should ignore descriptors // without a name. When specified, corresponding content will be discarded. // Otherwise, content will be saved to a fallback storage. // A typical scenario is pulling an arbitrary artifact masqueraded as OCI // image to file store. This option can be specified to discard unnamed // manifest and config file, while leaving only named layer files. // Default value: false. IgnoreNoName bool // SkipUnpack controls if push operations should skip unpacking files. This // value overrides the [AnnotationUnpack]. // Default value: false. SkipUnpack bool workingDir string // the working directory of the file store closed int32 // if the store is closed - 0: false, 1: true. digestToPath sync.Map // map[digest.Digest]string nameToStatus sync.Map // map[string]*nameStatus tmpFiles sync.Map // map[string]bool fallbackStorage content.Storage resolver content.TagResolver graph *graph.Memory } // nameStatus contains a flag indicating if a name exists, // and a RWMutex protecting it. type nameStatus struct { sync.RWMutex exists bool } // New creates a file store, using a default limited memory CAS // as the fallback storage for contents without names. // When pushing content without names, the size of content being pushed // cannot exceed the default size limit: 4 MiB. func New(workingDir string) (*Store, error) { return NewWithFallbackLimit(workingDir, defaultFallbackPushSizeLimit) } // NewWithFallbackLimit creates a file store, using a default // limited memory CAS as the fallback storage for contents without names. // When pushing content without names, the size of content being pushed // cannot exceed the size limit specified by the `limit` parameter. func NewWithFallbackLimit(workingDir string, limit int64) (*Store, error) { m := cas.NewMemory() ls := content.LimitStorage(m, limit) return NewWithFallbackStorage(workingDir, ls) } // NewWithFallbackStorage creates a file store, // using the provided fallback storage for contents without names. func NewWithFallbackStorage(workingDir string, fallbackStorage content.Storage) (*Store, error) { workingDirAbs, err := filepath.Abs(workingDir) if err != nil { return nil, fmt.Errorf("failed to resolve absolute path for %s: %w", workingDir, err) } return &Store{ workingDir: workingDirAbs, fallbackStorage: fallbackStorage, resolver: resolver.NewMemory(), graph: graph.NewMemory(), }, nil } // Close closes the file store and cleans up all the temporary files used by it. // The store cannot be used after being closed. // This function is not go-routine safe. func (s *Store) Close() error { if s.isClosedSet() { return nil } s.setClosed() var errs []string s.tmpFiles.Range(func(name, _ interface{}) bool { if err := os.Remove(name.(string)); err != nil { errs = append(errs, err.Error()) } return true }) if len(errs) > 0 { return errors.New(strings.Join(errs, "; ")) } return nil } // Fetch fetches the content identified by the descriptor. func (s *Store) Fetch(ctx context.Context, target ocispec.Descriptor) (io.ReadCloser, error) { if s.isClosedSet() { return nil, ErrStoreClosed } // if the target has name, check if the name exists. name := target.Annotations[ocispec.AnnotationTitle] if name != "" && !s.nameExists(name) { return nil, fmt.Errorf("%s: %s: %w", name, target.MediaType, errdef.ErrNotFound) } // check if the content exists in the store val, exists := s.digestToPath.Load(target.Digest) if exists { path := val.(string) fp, err := os.Open(path) if err != nil { if os.IsNotExist(err) { return nil, fmt.Errorf("%s: %s: %w", target.Digest, target.MediaType, errdef.ErrNotFound) } return nil, err } return fp, nil } // if the content does not exist in the store, // then fall back to the fallback storage. return s.fallbackStorage.Fetch(ctx, target) } // Push pushes the content, matching the expected descriptor. // If name is not specified in the descriptor, the content will be pushed to // the fallback storage by default, or will be discarded when // Store.IgnoreNoName is true. func (s *Store) Push(ctx context.Context, expected ocispec.Descriptor, content io.Reader) error { if s.isClosedSet() { return ErrStoreClosed } if err := s.push(ctx, expected, content); err != nil { if errors.Is(err, errSkipUnnamed) { return nil } return err } if !s.ForceCAS { if err := s.restoreDuplicates(ctx, expected); err != nil { return fmt.Errorf("failed to restore duplicated file: %w", err) } } return s.graph.Index(ctx, s, expected) } // push pushes the content, matching the expected descriptor. // If name is not specified in the descriptor, the content will be pushed to // the fallback storage by default, or will be discarded when // Store.IgnoreNoName is true. func (s *Store) push(ctx context.Context, expected ocispec.Descriptor, content io.Reader) error { name := expected.Annotations[ocispec.AnnotationTitle] if name == "" { if s.IgnoreNoName { return errSkipUnnamed } return s.fallbackStorage.Push(ctx, expected, content) } // check the status of the name status := s.status(name) status.Lock() defer status.Unlock() if status.exists { return fmt.Errorf("%s: %w", name, ErrDuplicateName) } target, err := s.resolveWritePath(name) if err != nil { return fmt.Errorf("failed to resolve path for writing: %w", err) } if needUnpack := expected.Annotations[AnnotationUnpack]; needUnpack == "true" && !s.SkipUnpack { err = s.pushDir(name, target, expected, content) } else { err = s.pushFile(target, expected, content) } if err != nil { return err } // update the name status as existed status.exists = true return nil } // restoreDuplicates restores successor files with same content but different // names. // See Store.ForceCAS for more info. func (s *Store) restoreDuplicates(ctx context.Context, desc ocispec.Descriptor) error { successors, err := content.Successors(ctx, s, desc) if err != nil { return err } for _, successor := range successors { name := successor.Annotations[ocispec.AnnotationTitle] if name == "" || s.nameExists(name) { continue } if err := func() error { desc := ocispec.Descriptor{ MediaType: successor.MediaType, Digest: successor.Digest, Size: successor.Size, } rc, err := s.Fetch(ctx, desc) if err != nil { return fmt.Errorf("%q: %s: %w", name, desc.MediaType, err) } defer rc.Close() if err := s.push(ctx, successor, rc); err != nil { return fmt.Errorf("%q: %s: %w", name, desc.MediaType, err) } return nil }(); err != nil { switch { case errors.Is(err, errdef.ErrNotFound): // allow pushing manifests before blobs case errors.Is(err, ErrDuplicateName): // in case multiple goroutines are pushing or restoring the same // named content, the error is ignored default: return err } } } return nil } // Exists returns true if the described content exists. func (s *Store) Exists(ctx context.Context, target ocispec.Descriptor) (bool, error) { if s.isClosedSet() { return false, ErrStoreClosed } // if the target has name, check if the name exists. name := target.Annotations[ocispec.AnnotationTitle] if name != "" && !s.nameExists(name) { return false, nil } // check if the content exists in the store _, exists := s.digestToPath.Load(target.Digest) if exists { return true, nil } // if the content does not exist in the store, // then fall back to the fallback storage. return s.fallbackStorage.Exists(ctx, target) } // Resolve resolves a reference to a descriptor. func (s *Store) Resolve(ctx context.Context, ref string) (ocispec.Descriptor, error) { if s.isClosedSet() { return ocispec.Descriptor{}, ErrStoreClosed } if ref == "" { return ocispec.Descriptor{}, errdef.ErrMissingReference } return s.resolver.Resolve(ctx, ref) } // Tag tags a descriptor with a reference string. func (s *Store) Tag(ctx context.Context, desc ocispec.Descriptor, ref string) error { if s.isClosedSet() { return ErrStoreClosed } if ref == "" { return errdef.ErrMissingReference } exists, err := s.Exists(ctx, desc) if err != nil { return err } if !exists { return fmt.Errorf("%s: %s: %w", desc.Digest, desc.MediaType, errdef.ErrNotFound) } return s.resolver.Tag(ctx, desc, ref) } // Predecessors returns the nodes directly pointing to the current node. // Predecessors returns nil without error if the node does not exists in the // store. func (s *Store) Predecessors(ctx context.Context, node ocispec.Descriptor) ([]ocispec.Descriptor, error) { if s.isClosedSet() { return nil, ErrStoreClosed } return s.graph.Predecessors(ctx, node) } // Add adds a file into the file store. func (s *Store) Add(_ context.Context, name, mediaType, path string) (ocispec.Descriptor, error) { if s.isClosedSet() { return ocispec.Descriptor{}, ErrStoreClosed } if name == "" { return ocispec.Descriptor{}, ErrMissingName } // check the status of the name status := s.status(name) status.Lock() defer status.Unlock() if status.exists { return ocispec.Descriptor{}, fmt.Errorf("%s: %w", name, ErrDuplicateName) } if path == "" { path = name } path = s.absPath(path) fi, err := os.Stat(path) if err != nil { return ocispec.Descriptor{}, fmt.Errorf("failed to stat %s: %w", path, err) } // generate descriptor var desc ocispec.Descriptor if fi.IsDir() { desc, err = s.descriptorFromDir(name, mediaType, path) } else { desc, err = s.descriptorFromFile(fi, mediaType, path) } if err != nil { return ocispec.Descriptor{}, fmt.Errorf("failed to generate descriptor from %s: %w", path, err) } if desc.Annotations == nil { desc.Annotations = make(map[string]string) } desc.Annotations[ocispec.AnnotationTitle] = name // update the name status as existed status.exists = true return desc, nil } // saveFile saves content matching the descriptor to the given file. func (s *Store) saveFile(fp *os.File, expected ocispec.Descriptor, content io.Reader) (err error) { defer func() { closeErr := fp.Close() if err == nil { err = closeErr } }() path := fp.Name() buf := bufPool.Get().(*[]byte) defer bufPool.Put(buf) if err := ioutil.CopyBuffer(fp, content, *buf, expected); err != nil { return fmt.Errorf("failed to copy content to %s: %w", path, err) } s.digestToPath.Store(expected.Digest, path) return nil } // pushFile saves content matching the descriptor to the target path. func (s *Store) pushFile(target string, expected ocispec.Descriptor, content io.Reader) error { if err := ensureDir(filepath.Dir(target)); err != nil { return fmt.Errorf("failed to ensure directories of the target path: %w", err) } fp, err := os.Create(target) if err != nil { return fmt.Errorf("failed to create file %s: %w", target, err) } return s.saveFile(fp, expected, content) } // pushDir saves content matching the descriptor to the target directory. func (s *Store) pushDir(name, target string, expected ocispec.Descriptor, content io.Reader) (err error) { if err := ensureDir(target); err != nil { return fmt.Errorf("failed to ensure directories of the target path: %w", err) } gz, err := s.tempFile() if err != nil { return err } gzPath := gz.Name() // the digest of the gz is verified while saving if err := s.saveFile(gz, expected, content); err != nil { return fmt.Errorf("failed to save gzip to %s: %w", gzPath, err) } checksum := expected.Annotations[AnnotationDigest] buf := bufPool.Get().(*[]byte) defer bufPool.Put(buf) if err := extractTarGzip(target, name, gzPath, checksum, *buf); err != nil { return fmt.Errorf("failed to extract tar to %s: %w", target, err) } return nil } // descriptorFromDir generates descriptor from the given directory. func (s *Store) descriptorFromDir(name, mediaType, dir string) (desc ocispec.Descriptor, err error) { // make a temp file to store the gzip gz, err := s.tempFile() if err != nil { return ocispec.Descriptor{}, err } defer func() { closeErr := gz.Close() if err == nil { err = closeErr } }() // compress the directory gzDigester := digest.Canonical.Digester() gzw := gzip.NewWriter(io.MultiWriter(gz, gzDigester.Hash())) defer func() { closeErr := gzw.Close() if err == nil { err = closeErr } }() tarDigester := digest.Canonical.Digester() tw := io.MultiWriter(gzw, tarDigester.Hash()) buf := bufPool.Get().(*[]byte) defer bufPool.Put(buf) if err := tarDirectory(dir, name, tw, s.TarReproducible, *buf); err != nil { return ocispec.Descriptor{}, fmt.Errorf("failed to tar %s: %w", dir, err) } // flush all if err := gzw.Close(); err != nil { return ocispec.Descriptor{}, err } if err := gz.Sync(); err != nil { return ocispec.Descriptor{}, err } fi, err := gz.Stat() if err != nil { return ocispec.Descriptor{}, err } // map gzip digest to gzip path gzDigest := gzDigester.Digest() s.digestToPath.Store(gzDigest, gz.Name()) // generate descriptor if mediaType == "" { mediaType = defaultBlobDirMediaType } return ocispec.Descriptor{ MediaType: mediaType, Digest: gzDigest, // digest for the compressed content Size: fi.Size(), Annotations: map[string]string{ AnnotationDigest: tarDigester.Digest().String(), // digest fot the uncompressed content AnnotationUnpack: "true", // the content needs to be unpacked }, }, nil } // descriptorFromFile generates descriptor from the given file. func (s *Store) descriptorFromFile(fi os.FileInfo, mediaType, path string) (desc ocispec.Descriptor, err error) { fp, err := os.Open(path) if err != nil { return ocispec.Descriptor{}, err } defer func() { closeErr := fp.Close() if err == nil { err = closeErr } }() dgst, err := digest.FromReader(fp) if err != nil { return ocispec.Descriptor{}, err } // map digest to file path s.digestToPath.Store(dgst, path) // generate descriptor if mediaType == "" { mediaType = defaultBlobMediaType } return ocispec.Descriptor{ MediaType: mediaType, Digest: dgst, Size: fi.Size(), }, nil } // resolveWritePath resolves the path to write for the given name. func (s *Store) resolveWritePath(name string) (string, error) { path := s.absPath(name) if !s.AllowPathTraversalOnWrite { base, err := filepath.Abs(s.workingDir) if err != nil { return "", err } target, err := filepath.Abs(path) if err != nil { return "", err } rel, err := filepath.Rel(base, target) if err != nil { return "", ErrPathTraversalDisallowed } rel = filepath.ToSlash(rel) if strings.HasPrefix(rel, "../") || rel == ".." { return "", ErrPathTraversalDisallowed } } if s.DisableOverwrite { if _, err := os.Stat(path); err == nil { return "", ErrOverwriteDisallowed } else if !os.IsNotExist(err) { return "", err } } return path, nil } // status returns the nameStatus for the given name. func (s *Store) status(name string) *nameStatus { v, _ := s.nameToStatus.LoadOrStore(name, &nameStatus{sync.RWMutex{}, false}) status := v.(*nameStatus) return status } // nameExists returns if the given name exists in the file store. func (s *Store) nameExists(name string) bool { status := s.status(name) status.RLock() defer status.RUnlock() return status.exists } // tempFile creates a temp file with the file name format "oras_file_randomString", // and returns the pointer to the temp file. func (s *Store) tempFile() (*os.File, error) { tmp, err := os.CreateTemp("", "oras_file_*") if err != nil { return nil, err } s.tmpFiles.Store(tmp.Name(), true) return tmp, nil } // absPath returns the absolute path of the path. func (s *Store) absPath(path string) string { if filepath.IsAbs(path) { return path } return filepath.Join(s.workingDir, path) } // isClosedSet returns true if the `closed` flag is set, otherwise returns false. func (s *Store) isClosedSet() bool { return atomic.LoadInt32(&s.closed) == 1 } // setClosed sets the `closed` flag. func (s *Store) setClosed() { atomic.StoreInt32(&s.closed, 1) } // ensureDir ensures the directories of the path exists. func ensureDir(path string) error { return os.MkdirAll(path, 0777) } oras-go-2.5.0/content/file/file_test.go000066400000000000000000002432641457674530300200140ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package file import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "reflect" "strings" "sync/atomic" "testing" _ "crypto/sha256" "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "golang.org/x/sync/errgroup" "oras.land/oras-go/v2" "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/content/memory" "oras.land/oras-go/v2/errdef" "oras.land/oras-go/v2/internal/cas" "oras.land/oras-go/v2/internal/descriptor" "oras.land/oras-go/v2/internal/spec" ) // storageTracker tracks storage API counts. type storageTracker struct { content.Storage fetch int64 push int64 exists int64 } func (t *storageTracker) Fetch(ctx context.Context, target ocispec.Descriptor) (io.ReadCloser, error) { atomic.AddInt64(&t.fetch, 1) return t.Storage.Fetch(ctx, target) } func (t *storageTracker) Push(ctx context.Context, expected ocispec.Descriptor, content io.Reader) error { atomic.AddInt64(&t.push, 1) return t.Storage.Push(ctx, expected, content) } func (t *storageTracker) Exists(ctx context.Context, target ocispec.Descriptor) (bool, error) { atomic.AddInt64(&t.exists, 1) return t.Storage.Exists(ctx, target) } func TestStoreInterface(t *testing.T) { var store interface{} = &Store{} if _, ok := store.(oras.Target); !ok { t.Error("&Store{} does not conform oras.Target") } if _, ok := store.(content.PredecessorFinder); !ok { t.Error("&Store{} does not conform content.PredecessorFinder") } } func TestStore_Success(t *testing.T) { tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer s.Close() ctx := context.Background() blob := []byte("hello world") name := "test.txt" mediaType := "test" desc := ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), Annotations: map[string]string{ ocispec.AnnotationTitle: name, }, } path := filepath.Join(tempDir, name) if err := os.WriteFile(path, blob, 0444); err != nil { t.Fatal("error calling WriteFile(), error =", err) } // test blob add gotDesc, err := s.Add(ctx, name, mediaType, path) if err != nil { t.Fatal("Store.Add() error =", err) } if descriptor.FromOCI(gotDesc) != descriptor.FromOCI(desc) { t.Fatal("got descriptor mismatch") } // test blob exists exists, err := s.Exists(ctx, gotDesc) if err != nil { t.Fatal("Store.Exists() error =", err) } if !exists { t.Errorf("Store.Exists() = %v, want %v", exists, true) } // test blob fetch rc, err := s.Fetch(ctx, gotDesc) if err != nil { t.Fatal("Store.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Store.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got, blob) { t.Errorf("Store.Fetch() = %v, want %v", got, blob) } // test push config config := []byte("{}") configDesc := ocispec.Descriptor{ MediaType: "config", Digest: digest.FromBytes(config), Size: int64(len(config)), Annotations: map[string]string{ ocispec.AnnotationTitle: "config.blob", }, } if err := s.Push(ctx, configDesc, bytes.NewReader(config)); err != nil { t.Fatal("Store.Push() error =", err) } // test push manifest manifest := ocispec.Manifest{ MediaType: ocispec.MediaTypeImageManifest, Config: configDesc, Layers: []ocispec.Descriptor{ gotDesc, }, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal("json.Marshal() error =", err) } manifestDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageManifest, Digest: digest.FromBytes(manifestJSON), Size: int64(len(manifestJSON)), } if err := s.Push(ctx, manifestDesc, bytes.NewReader(manifestJSON)); err != nil { t.Fatal("Store.Push() error =", err) } // test tag ref := "foobar" if err := s.Tag(ctx, manifestDesc, ref); err != nil { t.Fatal("Store.Tag() error =", err) } // test resolve gotManifestDesc, err := s.Resolve(ctx, ref) if err != nil { t.Fatal("Store.Resolve() error =", err) } if !reflect.DeepEqual(gotManifestDesc, manifestDesc) { t.Errorf("Store.Resolve() = %v, want %v", gotManifestDesc, manifestDesc) } // test fetch exists, err = s.Exists(ctx, gotManifestDesc) if err != nil { t.Fatal("Store.Exists() error =", err) } if !exists { t.Errorf("Store.Exists() = %v, want %v", exists, true) } mrc, err := s.Fetch(ctx, gotManifestDesc) if err != nil { t.Fatal("Store.Fetch() error =", err) } got, err = io.ReadAll(mrc) if err != nil { t.Fatal("Store.Fetch().Read() error =", err) } err = mrc.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got, manifestJSON) { t.Errorf("Store.Fetch() = %v, want %v", got, manifestJSON) } } func TestStore_RelativeRoot_Success(t *testing.T) { tempDir, err := filepath.EvalSymlinks(t.TempDir()) if err != nil { t.Fatal("error calling filepath.EvalSymlinks(), error =", err) } currDir, err := os.Getwd() if err != nil { t.Fatal("error calling Getwd(), error=", err) } if err := os.Chdir(tempDir); err != nil { t.Fatal("error calling Chdir(), error=", err) } s, err := New(".") if err != nil { t.Fatal("Store.New() error =", err) } defer s.Close() if want := tempDir; s.workingDir != want { t.Errorf("Store.workingDir = %s, want %s", s.workingDir, want) } // cd back to allow the temp directory to be removed if err := os.Chdir(currDir); err != nil { t.Fatal("error calling Chdir(), error=", err) } ctx := context.Background() blob := []byte("hello world") name := "test.txt" mediaType := "test" desc := ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), Annotations: map[string]string{ ocispec.AnnotationTitle: name, }, } path := filepath.Join(tempDir, name) if err := os.WriteFile(path, blob, 0444); err != nil { t.Fatal("error calling WriteFile(), error =", err) } // test blob add gotDesc, err := s.Add(ctx, name, mediaType, path) if err != nil { t.Fatal("Store.Add() error =", err) } if descriptor.FromOCI(gotDesc) != descriptor.FromOCI(desc) { t.Fatal("got descriptor mismatch") } // test blob exists exists, err := s.Exists(ctx, gotDesc) if err != nil { t.Fatal("Store.Exists() error =", err) } if !exists { t.Errorf("Store.Exists() = %v, want %v", exists, true) } // test blob fetch rc, err := s.Fetch(ctx, gotDesc) if err != nil { t.Fatal("Store.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Store.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got, blob) { t.Errorf("Store.Fetch() = %v, want %v", got, blob) } // test push config config := []byte("{}") configDesc := ocispec.Descriptor{ MediaType: "config", Digest: digest.FromBytes(config), Size: int64(len(config)), Annotations: map[string]string{ ocispec.AnnotationTitle: "config.blob", }, } if err := s.Push(ctx, configDesc, bytes.NewReader(config)); err != nil { t.Fatal("Store.Push() error =", err) } // test push manifest manifest := ocispec.Manifest{ MediaType: ocispec.MediaTypeImageManifest, Config: configDesc, Layers: []ocispec.Descriptor{ gotDesc, }, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal("json.Marshal() error =", err) } manifestDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageManifest, Digest: digest.FromBytes(manifestJSON), Size: int64(len(manifestJSON)), } if err := s.Push(ctx, manifestDesc, bytes.NewReader(manifestJSON)); err != nil { t.Fatal("Store.Push() error =", err) } // test tag ref := "foobar" if err := s.Tag(ctx, manifestDesc, ref); err != nil { t.Fatal("Store.Tag() error =", err) } // test resolve gotManifestDesc, err := s.Resolve(ctx, ref) if err != nil { t.Fatal("Store.Resolve() error =", err) } if !reflect.DeepEqual(gotManifestDesc, manifestDesc) { t.Errorf("Store.Resolve() = %v, want %v", gotManifestDesc, manifestDesc) } // test fetch exists, err = s.Exists(ctx, gotManifestDesc) if err != nil { t.Fatal("Store.Exists() error =", err) } if !exists { t.Errorf("Store.Exists() = %v, want %v", exists, true) } mrc, err := s.Fetch(ctx, gotManifestDesc) if err != nil { t.Fatal("Store.Fetch() error =", err) } got, err = io.ReadAll(mrc) if err != nil { t.Fatal("Store.Fetch().Read() error =", err) } err = mrc.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got, manifestJSON) { t.Errorf("Store.Fetch() = %v, want %v", got, manifestJSON) } } func TestStore_Close(t *testing.T) { content := []byte("hello world") name := "test.txt" mediaType := "test" desc := ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(content), Size: int64(len(content)), Annotations: map[string]string{ ocispec.AnnotationTitle: name, }, } ref := "foobar" tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } ctx := context.Background() // test push err = s.Push(ctx, desc, bytes.NewReader(content)) if err != nil { t.Fatal("Store.Push() error =", err) } // test exists exists, err := s.Exists(ctx, desc) if err != nil { t.Fatal("Store.Exists() error =", err) } if !exists { t.Errorf("Store.Exists() = %v, want %v", exists, true) } // test fetch rc, err := s.Fetch(ctx, desc) if err != nil { t.Fatal("Store.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Store.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got, content) { t.Errorf("Store.Fetch() = %v, want %v", got, content) } // test close if err := s.Close(); err != nil { t.Error("Store.Close() error =", err) } // test close twice if err := s.Close(); err != nil { t.Error("Store.Close() error =", err) } // test add after closed if _, err := s.Add(ctx, name, mediaType, ""); !errors.Is(err, ErrStoreClosed) { t.Errorf("Store.Add() = %v, want %v", err, ErrStoreClosed) } // test push after closed if err = s.Push(ctx, desc, bytes.NewReader(content)); !errors.Is(err, ErrStoreClosed) { t.Errorf("Store.Push() = %v, want %v", err, ErrStoreClosed) } // test exists after closed if _, err := s.Exists(ctx, desc); !errors.Is(err, ErrStoreClosed) { t.Errorf("Store.Exists() = %v, want %v", err, ErrStoreClosed) } // test tag after closed if err := s.Tag(ctx, desc, ref); !errors.Is(err, ErrStoreClosed) { t.Errorf("Store.Tag() = %v, want %v", err, ErrStoreClosed) } // test resolve after closed if _, err := s.Resolve(ctx, ref); !errors.Is(err, ErrStoreClosed) { t.Errorf("Store.Resolve() = %v, want %v", err, ErrStoreClosed) } // test fetch after closed if _, err := s.Fetch(ctx, desc); !errors.Is(err, ErrStoreClosed) { t.Errorf("Store.Fetch() = %v, want %v", err, ErrStoreClosed) } // test Predecessors after closed if _, err := s.Predecessors(ctx, desc); !errors.Is(err, ErrStoreClosed) { t.Errorf("Store.Predecessors() = %v, want %v", err, ErrStoreClosed) } } func TestStore_File_Push(t *testing.T) { content := []byte("hello world") desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), Annotations: map[string]string{ ocispec.AnnotationTitle: "test.txt", }, } tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer s.Close() ctx := context.Background() // test push err = s.Push(ctx, desc, bytes.NewReader(content)) if err != nil { t.Fatal("Store.Push() error =", err) } // test exists exists, err := s.Exists(ctx, desc) if err != nil { t.Fatal("Store.Exists() error =", err) } if !exists { t.Errorf("Store.Exists() = %v, want %v", exists, true) } // test fetch rc, err := s.Fetch(ctx, desc) if err != nil { t.Fatal("Store.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Store.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got, content) { t.Errorf("Store.Fetch() = %v, want %v", got, content) } } func TestStore_Dir_Push(t *testing.T) { tempDir := t.TempDir() dirName := "testdir" dirPath := filepath.Join(tempDir, dirName) if err := os.MkdirAll(dirPath, 0777); err != nil { t.Fatal("error calling Mkdir(), error =", err) } content := []byte("hello world") fileName := "test.txt" if err := os.WriteFile(filepath.Join(dirPath, fileName), content, 0444); err != nil { t.Fatal("error calling WriteFile(), error =", err) } s, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer s.Close() ctx := context.Background() // test add desc, err := s.Add(ctx, dirName, "", dirPath) if err != nil { t.Fatal("Store.Add() error=", err) } val, ok := s.digestToPath.Load(desc.Digest) if !ok { t.Fatal("failed to find internal gz") } tmpPath := val.(string) zrc, err := os.Open(tmpPath) if err != nil { t.Fatal("failed to open internal gz") } gz, err := io.ReadAll(zrc) if err != nil { t.Fatal("failed to read internal gz") } if err := zrc.Close(); err != nil { t.Fatal("failed to close internal gz reader") } anotherTempDir := t.TempDir() // test with another file store instance to mock push gz anotherS, err := New(anotherTempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer anotherS.Close() // test push if err := anotherS.Push(ctx, desc, bytes.NewReader(gz)); err != nil { t.Fatal("Store.Push() error =", err) } // test exists exists, err := s.Exists(ctx, desc) if err != nil { t.Fatal("Store.Exists() error =", err) } if !exists { t.Errorf("Store.Exists() = %v, want %v", exists, true) } // test fetch rc, err := s.Fetch(ctx, desc) if err != nil { t.Fatal("Store.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Store.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got, gz) { t.Errorf("Store.Fetch() = %v, want %v", got, gz) } // test file content path := filepath.Join(s.workingDir, dirName, fileName) fp, err := os.Open(path) if err != nil { t.Fatalf("failed to open file %s:%v", path, err) } fc, err := io.ReadAll(fp) if err != nil { t.Fatalf("failed to read file %s:%v", path, err) } if err := fp.Close(); err != nil { t.Fatalf("failed to close file %s:%v", path, err) } anotherFilePath := filepath.Join(anotherS.workingDir, dirName, fileName) anotherFp, err := os.Open(anotherFilePath) if err != nil { t.Fatalf("failed to open file %s:%v", anotherFilePath, err) } anotherFc, err := io.ReadAll(anotherFp) if err != nil { t.Fatalf("failed to read file %s:%v", anotherFilePath, err) } if err := anotherFp.Close(); err != nil { t.Fatalf("failed to close file %s:%v", anotherFilePath, err) } if !bytes.Equal(fc, anotherFc) { t.Errorf("file content mismatch") } } func TestStore_Dir_Push_SkipUnpack(t *testing.T) { // add a file to file store, and obtain its directory as gz tempDir := t.TempDir() dirName := "testdir" dirPath := filepath.Join(tempDir, dirName) if err := os.MkdirAll(dirPath, 0777); err != nil { t.Fatal("error calling Mkdir(), error =", err) } content := []byte("hello world") fileName := "test.txt" if err := os.WriteFile(filepath.Join(dirPath, fileName), content, 0444); err != nil { t.Fatal("error calling WriteFile(), error =", err) } s, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer s.Close() ctx := context.Background() desc, err := s.Add(ctx, dirName, "", dirPath) if err != nil { t.Fatal("Store.Add() error=", err) } val, ok := s.digestToPath.Load(desc.Digest) if !ok { t.Fatal("failed to find internal gz") } tmpPath := val.(string) zrc, err := os.Open(tmpPath) if err != nil { t.Fatal("failed to open internal gz") } gz, err := io.ReadAll(zrc) if err != nil { t.Fatal("failed to read internal gz") } if err := zrc.Close(); err != nil { t.Fatal("failed to close internal gz reader") } // push the gz to another store anotherTempDir := t.TempDir() anotherS, err := New(anotherTempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer anotherS.Close() anotherS.SkipUnpack = true gzPath := filepath.Join(anotherTempDir, dirName) // push the gz to the store if err := anotherS.Push(ctx, desc, bytes.NewReader(gz)); err != nil { t.Fatal("Store.Push() error =", err) } pushedFile, err := os.Open(gzPath) if err != nil { t.Fatal("failed to open internal gz") } pushedContent, err := io.ReadAll(pushedFile) if err != nil { t.Fatal(err) } // check that the pushedContent is equal to the original gz, i.e. it is // not unpacked if !bytes.Equal(gz, pushedContent) { t.Errorf("file content mismatch") } } func TestStore_Push_NoName(t *testing.T) { content := []byte("hello world") desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), } tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer s.Close() ctx := context.Background() // test push err = s.Push(ctx, desc, bytes.NewReader(content)) if err != nil { t.Fatal("Store.Push() error =", err) } // test exists exists, err := s.Exists(ctx, desc) if err != nil { t.Fatal("Store.Exists() error =", err) } if !exists { t.Errorf("Store.Exists() = %v, want %v", exists, true) } // test fetch rc, err := s.Fetch(ctx, desc) if err != nil { t.Fatal("Store.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Store.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got, content) { t.Errorf("Store.Fetch() = %v, want %v", got, content) } } func TestStore_Push_NoName_ExceedLimit(t *testing.T) { blob := []byte("hello world") desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(blob), Size: int64(len(blob)), } tempDir := t.TempDir() s, err := NewWithFallbackLimit(tempDir, 1) if err != nil { t.Fatal("Store.NewWithFallbackLimit() error =", err) } defer s.Close() ctx := context.Background() // test push err = s.Push(ctx, desc, bytes.NewReader(blob)) if !errors.Is(err, errdef.ErrSizeExceedsLimit) { t.Errorf("Store.Push() error = %v, want %v", err, errdef.ErrSizeExceedsLimit) } } func TestStore_Push_NoName_SizeNotMatch(t *testing.T) { blob := []byte("hello world") desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(blob), Size: 1, } tempDir := t.TempDir() s, err := NewWithFallbackLimit(tempDir, 1) if err != nil { t.Fatal("Store.NewWithFallbackLimit() error =", err) } defer s.Close() ctx := context.Background() // test push err = s.Push(ctx, desc, bytes.NewReader(blob)) if err == nil { t.Errorf("Store.Push() error = nil, want: error") } } func TestStore_File_NotFound(t *testing.T) { content := []byte("hello world") desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), Annotations: map[string]string{ ocispec.AnnotationTitle: "test.txt", }, } tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer s.Close() ctx := context.Background() exists, err := s.Exists(ctx, desc) if err != nil { t.Error("Store.Exists() error =", err) } if exists { t.Errorf("Store.Exists() = %v, want %v", exists, false) } _, err = s.Fetch(ctx, desc) if !errors.Is(err, errdef.ErrNotFound) { t.Errorf("Store.Fetch() error = %v, want %v", err, errdef.ErrNotFound) } } func TestStore_File_ContentBadPush(t *testing.T) { content := []byte("hello world") desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), Annotations: map[string]string{ ocispec.AnnotationTitle: "test.txt", }, } tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer s.Close() ctx := context.Background() err = s.Push(ctx, desc, strings.NewReader("foobar")) if err == nil { t.Errorf("Store.Push() error = %v, wantErr %v", err, true) } } func TestStore_File_Add(t *testing.T) { content := []byte("hello world") name := "test.txt" mediaType := "test" desc := ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(content), Size: int64(len(content)), Annotations: map[string]string{ ocispec.AnnotationTitle: name, }, } tempDir := t.TempDir() path := filepath.Join(tempDir, name) if err := os.WriteFile(path, content, 0444); err != nil { t.Fatal("error calling WriteFile(), error =", err) } s, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer s.Close() ctx := context.Background() // test add gotDesc, err := s.Add(ctx, name, mediaType, path) if err != nil { t.Fatal("Store.Add() error =", err) } if descriptor.FromOCI(gotDesc) != descriptor.FromOCI(desc) { t.Fatal("got descriptor mismatch") } // test exists exists, err := s.Exists(ctx, gotDesc) if err != nil { t.Fatal("Store.Exists() error =", err) } if !exists { t.Errorf("Store.Exists() = %v, want %v", exists, true) } // test fetch rc, err := s.Fetch(ctx, gotDesc) if err != nil { t.Fatal("Store.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Store.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got, content) { t.Errorf("Store.Fetch() = %v, want %v", got, content) } } func TestStore_Dir_Add(t *testing.T) { tempDir := t.TempDir() dirName := "testdir" dirPath := filepath.Join(tempDir, dirName) if err := os.MkdirAll(dirPath, 0777); err != nil { t.Fatal("error calling Mkdir(), error =", err) } content := []byte("hello world") if err := os.WriteFile(filepath.Join(dirPath, "test.txt"), content, 0444); err != nil { t.Fatal("error calling WriteFile(), error =", err) } s, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer s.Close() ctx := context.Background() // test add gotDesc, err := s.Add(ctx, dirName, "", dirPath) if err != nil { t.Fatal("Store.Add() error=", err) } // test exists exists, err := s.Exists(ctx, gotDesc) if err != nil { t.Fatal("Store.Exists() error =", err) } if !exists { t.Errorf("Store.Exists() = %v, want %v", exists, true) } val, ok := s.digestToPath.Load(gotDesc.Digest) if !ok { t.Fatal("failed to find internal gz") } tmpPath := val.(string) zrc, err := os.Open(tmpPath) if err != nil { t.Fatal("failed to open internal gz") } gotgz, err := io.ReadAll(zrc) if err != nil { t.Fatal("failed to read internal gz") } // test fetch rc, err := s.Fetch(ctx, gotDesc) if err != nil { t.Fatal("Store.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Store.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got, gotgz) { t.Errorf("Store.Fetch() = %v, want %v", got, gotgz) } } func TestStore_File_SameContent_DuplicateName(t *testing.T) { content := []byte("hello world") name := "test.txt" mediaType := "test" desc := ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(content), Size: int64(len(content)), Annotations: map[string]string{ ocispec.AnnotationTitle: name, }, } tempDir := t.TempDir() path := filepath.Join(tempDir, name) if err := os.WriteFile(path, content, 0444); err != nil { t.Fatal("error calling WriteFile(), error =", err) } s, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer s.Close() ctx := context.Background() // test add gotDesc, err := s.Add(ctx, name, mediaType, path) if err != nil { t.Fatal("Store.Add() error =", err) } if descriptor.FromOCI(gotDesc) != descriptor.FromOCI(desc) { t.Fatal("got descriptor mismatch") } // test exists exists, err := s.Exists(ctx, gotDesc) if err != nil { t.Fatal("Store.Exists() error =", err) } if !exists { t.Errorf("Store.Exists() = %v, want %v", exists, true) } // test fetch rc, err := s.Fetch(ctx, gotDesc) if err != nil { t.Fatal("Store.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Store.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got, content) { t.Errorf("Store.Fetch() = %v, want %v", got, content) } // test duplicate name if _, err := s.Add(ctx, name, mediaType, path); !errors.Is(err, ErrDuplicateName) { t.Errorf("Store.Add() = %v, want %v", err, ErrDuplicateName) } } func TestStore_File_DifferentContent_DuplicateName(t *testing.T) { content_1 := []byte("hello world") content_2 := []byte("goodbye world") name_1 := "test_1.txt" name_2 := "test_2.txt" mediaType_1 := "test" mediaType_2 := "test_2" desc_1 := ocispec.Descriptor{ MediaType: mediaType_1, Digest: digest.FromBytes(content_1), Size: int64(len(content_1)), Annotations: map[string]string{ ocispec.AnnotationTitle: name_1, }, } tempDir := t.TempDir() path_1 := filepath.Join(tempDir, name_1) if err := os.WriteFile(path_1, content_1, 0444); err != nil { t.Fatal("error calling WriteFile(), error =", err) } s, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer s.Close() ctx := context.Background() // test add gotDesc, err := s.Add(ctx, name_1, mediaType_1, path_1) if err != nil { t.Fatal("Store.Add() error =", err) } if descriptor.FromOCI(gotDesc) != descriptor.FromOCI(desc_1) { t.Fatal("got descriptor mismatch") } // test exists exists, err := s.Exists(ctx, gotDesc) if err != nil { t.Fatal("Store.Exists() error =", err) } if !exists { t.Errorf("Store.Exists() = %v, want %v", exists, true) } // test fetch rc, err := s.Fetch(ctx, gotDesc) if err != nil { t.Fatal("Store.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Store.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got, content_1) { t.Errorf("Store.Fetch() = %v, want %v", got, content_1) } // test add duplicate name path_2 := filepath.Join(tempDir, name_2) if err := os.WriteFile(path_2, content_2, 0444); err != nil { t.Fatal("error calling WriteFile(), error =", err) } if _, err := s.Add(ctx, name_1, mediaType_2, path_2); !errors.Is(err, ErrDuplicateName) { t.Errorf("Store.Add() = %v, want %v", err, ErrDuplicateName) } } func TestStore_File_Add_MissingName(t *testing.T) { content := []byte("hello world") name := "test.txt" mediaType := "test" tempDir := t.TempDir() path := filepath.Join(tempDir, name) if err := os.WriteFile(path, content, 0444); err != nil { t.Fatal("error calling WriteFile(), error =", err) } s, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer s.Close() ctx := context.Background() // test add with empty name _, err = s.Add(ctx, "", mediaType, path) if !errors.Is(err, ErrMissingName) { t.Errorf("Store.Add() error = %v, want %v", err, ErrMissingName) } } func TestStore_File_Add_SameContent(t *testing.T) { mediaType := "test" content := []byte("hello world") name_1 := "test_1.txt" desc_1 := ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(content), Size: int64(len(content)), Annotations: map[string]string{ ocispec.AnnotationTitle: name_1, }, } name_2 := "test_2.txt" desc_2 := ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(content), Size: int64(len(content)), Annotations: map[string]string{ ocispec.AnnotationTitle: name_2, }, } tempDir := t.TempDir() path_1 := filepath.Join(tempDir, name_1) if err := os.WriteFile(path_1, content, 0444); err != nil { t.Fatal("error calling WriteFile(), error =", err) } path_2 := filepath.Join(tempDir, name_2) if err := os.WriteFile(path_2, content, 0444); err != nil { t.Fatal("error calling WriteFile(), error =", err) } s, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer s.Close() ctx := context.Background() // test add gotDesc_1, err := s.Add(ctx, name_1, mediaType, path_1) if err != nil { t.Fatal("Store.Add() error =", err) } if descriptor.FromOCI(gotDesc_1) != descriptor.FromOCI(desc_1) { t.Fatal("got descriptor mismatch") } gotDesc_2, err := s.Add(ctx, name_2, mediaType, path_2) if err != nil { t.Fatal("Store.Add() error =", err) } if descriptor.FromOCI(gotDesc_2) != descriptor.FromOCI(desc_2) { t.Fatal("got descriptor mismatch") } // test exists exists, err := s.Exists(ctx, gotDesc_1) if err != nil { t.Fatal("Store.Exists() error =", err) } if !exists { t.Errorf("Store.Exists() = %v, want %v", exists, true) } exists, err = s.Exists(ctx, gotDesc_2) if err != nil { t.Fatal("Store.Exists() error =", err) } if !exists { t.Errorf("Store.Exists() = %v, want %v", exists, true) } // test fetch rc_1, err := s.Fetch(ctx, gotDesc_1) if err != nil { t.Fatal("Store.Fetch() error =", err) } got_1, err := io.ReadAll(rc_1) if err != nil { t.Fatal("Store.Fetch().Read() error =", err) } err = rc_1.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got_1, content) { t.Errorf("Store.Fetch() = %v, want %v", got_1, content) } rc_2, err := s.Fetch(ctx, gotDesc_2) if err != nil { t.Fatal("Store.Fetch() error =", err) } got_2, err := io.ReadAll(rc_2) if err != nil { t.Fatal("Store.Fetch().Read() error =", err) } err = rc_2.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got_2, content) { t.Errorf("Store.Fetch() = %v, want %v", got_2, content) } } func TestStore_File_Push_SameContent(t *testing.T) { mediaType := "test" content := []byte("hello world") name_1 := "test_1.txt" desc_1 := ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(content), Size: int64(len(content)), Annotations: map[string]string{ ocispec.AnnotationTitle: name_1, }, } name_2 := "test_2.txt" desc_2 := ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(content), Size: int64(len(content)), Annotations: map[string]string{ ocispec.AnnotationTitle: name_2, }, } tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer s.Close() ctx := context.Background() // test push if err := s.Push(ctx, desc_1, bytes.NewReader(content)); err != nil { t.Fatal("Store.Push() error =", err) } if err := s.Push(ctx, desc_2, bytes.NewReader(content)); err != nil { t.Fatal("Store.Push() error =", err) } // test exists exists, err := s.Exists(ctx, desc_1) if err != nil { t.Fatal("Store.Exists() error =", err) } if !exists { t.Errorf("Store.Exists() = %v, want %v", exists, true) } exists, err = s.Exists(ctx, desc_2) if err != nil { t.Fatal("Store.Exists() error =", err) } if !exists { t.Errorf("Store.Exists() = %v, want %v", exists, true) } // test fetch rc_1, err := s.Fetch(ctx, desc_1) if err != nil { t.Fatal("Store.Fetch() error =", err) } got_1, err := io.ReadAll(rc_1) if err != nil { t.Fatal("Store.Fetch().Read() error =", err) } err = rc_1.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got_1, content) { t.Errorf("Store.Fetch() = %v, want %v", got_1, content) } rc_2, err := s.Fetch(ctx, desc_2) if err != nil { t.Fatal("Store.Fetch() error =", err) } got_2, err := io.ReadAll(rc_2) if err != nil { t.Fatal("Store.Fetch().Read() error =", err) } err = rc_2.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got_2, content) { t.Errorf("Store.Fetch() = %v, want %v", got_2, content) } } func TestStore_File_Push_DuplicateName(t *testing.T) { mediaType := "test" name := "test.txt" content_1 := []byte("hello world") content_2 := []byte("goodbye world") desc_1 := ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(content_1), Size: int64(len(content_1)), Annotations: map[string]string{ ocispec.AnnotationTitle: name, }, } desc_2 := ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(content_2), Size: int64(len(content_2)), Annotations: map[string]string{ ocispec.AnnotationTitle: name, }, } tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer s.Close() ctx := context.Background() // test push err = s.Push(ctx, desc_1, bytes.NewReader(content_1)) if err != nil { t.Fatal("Store.Push() error =", err) } // test exists exists, err := s.Exists(ctx, desc_1) if err != nil { t.Fatal("Store.Exists() error =", err) } if !exists { t.Errorf("Store.Exists() = %v, want %v", exists, true) } // test fetch rc, err := s.Fetch(ctx, desc_1) if err != nil { t.Fatal("Store.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Store.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got, content_1) { t.Errorf("Store.Fetch() = %v, want %v", got, content_1) } // test push with duplicate name err = s.Push(ctx, desc_2, bytes.NewBuffer(content_2)) if !errors.Is(err, ErrDuplicateName) { t.Errorf("Store.Push() error = %v, want %v", err, ErrDuplicateName) } } func TestStore_File_Push_ForceCAS(t *testing.T) { mediaType := "test" content := []byte("hello world") desc1 := ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(content), Size: int64(len(content)), Annotations: map[string]string{ ocispec.AnnotationTitle: "blob1", }, } desc2 := ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(content), Size: int64(len(content)), Annotations: map[string]string{ ocispec.AnnotationTitle: "blob2", }, } config := []byte("{}") configDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageConfig, Digest: digest.FromBytes(config), Size: int64(len(config)), } manifest := ocispec.Manifest{ MediaType: ocispec.MediaTypeImageManifest, Config: configDesc, Layers: []ocispec.Descriptor{desc1, desc2}, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal("json.Marshal() error =", err) } manifestDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageManifest, Digest: digest.FromBytes(manifestJSON), Size: int64(len(manifestJSON)), } tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } s.ForceCAS = true defer s.Close() ctx := context.Background() // push blob1 if err := s.Push(ctx, desc1, bytes.NewReader(content)); err != nil { t.Fatal("Store.Push() error =", err) } // push manifest if err := s.Push(ctx, manifestDesc, bytes.NewReader(manifestJSON)); err != nil { t.Fatal("Store.Push() error =", err) } // verify blob2 not exists exists, err := s.Exists(ctx, desc2) if err != nil { t.Fatal("Store.Exists() error =", err) } if exists { t.Error("Blob2 is restored") } } func TestStore_File_Push_RestoreDuplicates(t *testing.T) { mediaType := "test" content := []byte("hello world") desc1 := ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(content), Size: int64(len(content)), Annotations: map[string]string{ ocispec.AnnotationTitle: "blob1", }, } desc2 := ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(content), Size: int64(len(content)), Annotations: map[string]string{ ocispec.AnnotationTitle: "blob2", }, } config := []byte("{}") configDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageConfig, Digest: digest.FromBytes(config), Size: int64(len(config)), } manifest := ocispec.Manifest{ MediaType: ocispec.MediaTypeImageManifest, Config: configDesc, Layers: []ocispec.Descriptor{desc1, desc2}, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal("json.Marshal() error =", err) } manifestDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageManifest, Digest: digest.FromBytes(manifestJSON), Size: int64(len(manifestJSON)), } tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer s.Close() ctx := context.Background() // push blob1 if err := s.Push(ctx, desc1, bytes.NewReader(content)); err != nil { t.Fatal("Store.Push() error =", err) } // push manifest if err := s.Push(ctx, manifestDesc, bytes.NewReader(manifestJSON)); err != nil { t.Fatal("Store.Push() error =", err) } // verify blob2 is restored exists, err := s.Exists(ctx, desc2) if err != nil { t.Fatal("Store.Exists() error =", err) } if !exists { t.Error("Blob2 is not restored") } rc, err := s.Fetch(ctx, desc2) if err != nil { t.Fatal("Store.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Store.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got, content) { t.Errorf("Store.Fetch() = %v, want %v", got, content) } } func TestStore_File_Push_RestoreDuplicates_NotFound(t *testing.T) { mediaType := "test" content := []byte("hello world") desc := ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(content), Size: int64(len(content)), Annotations: map[string]string{ ocispec.AnnotationTitle: "blob1", }, } config := []byte("{}") configDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageConfig, Digest: digest.FromBytes(config), Size: int64(len(config)), } manifest := ocispec.Manifest{ MediaType: ocispec.MediaTypeImageManifest, Config: configDesc, Layers: []ocispec.Descriptor{desc}, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal("json.Marshal() error =", err) } manifestDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageManifest, Digest: digest.FromBytes(manifestJSON), Size: int64(len(manifestJSON)), } tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer s.Close() ctx := context.Background() // push manifest before blob is fine if err := s.Push(ctx, manifestDesc, bytes.NewReader(manifestJSON)); err != nil { t.Error("Store.Push(): error = ", err) } } type storageMock struct { content.Storage OnFetch func(ctx context.Context, desc ocispec.Descriptor) error } func (m *storageMock) Fetch(ctx context.Context, desc ocispec.Descriptor) (io.ReadCloser, error) { if m.OnFetch != nil { if err := m.OnFetch(ctx, desc); err != nil { return nil, err } } return m.Storage.Fetch(ctx, desc) } func TestStore_File_Push_RestoreDuplicates_DuplicateName(t *testing.T) { mediaType := "test" content := []byte("hello world") blobDesc := ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(content), Size: int64(len(content)), Annotations: map[string]string{ ocispec.AnnotationTitle: "blob", }, } config := []byte("{}") configDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageConfig, Digest: digest.FromBytes(config), Size: int64(len(config)), } manifest := ocispec.Manifest{ MediaType: ocispec.MediaTypeImageManifest, Config: configDesc, Layers: []ocispec.Descriptor{blobDesc}, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal("json.Marshal() error =", err) } manifestDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageManifest, Digest: digest.FromBytes(manifestJSON), Size: int64(len(manifestJSON)), } tempDir := t.TempDir() fallbackMock := &storageMock{ Storage: cas.NewMemory(), } s, err := NewWithFallbackStorage(tempDir, fallbackMock) if err != nil { t.Fatal("NewWithFallbackStorage() error =", err) } defer s.Close() ctx := context.Background() // push blob as unnamed if err := fallbackMock.Push(ctx, blobDesc, bytes.NewReader(content)); err != nil { t.Fatal("Store.Push() error =", err) } // push manifest fallbackMock.OnFetch = func(ctx context.Context, desc ocispec.Descriptor) error { if desc.Digest == blobDesc.Digest { // push blob before being restored by manifest put to simulate // concurrent pushing for race condition if err := s.Push(ctx, blobDesc, bytes.NewReader(content)); err != nil { t.Fatal("Store.Push() error =", err) } } return nil } if err := s.Push(ctx, manifestDesc, bytes.NewReader(manifestJSON)); err != nil { t.Fatal("Store.Push() error =", err) } // verify blob is restored got, err := os.ReadFile(filepath.Join(tempDir, "blob")) if err != nil { t.Fatal("os.ReadFile() error =", err) } if !bytes.Equal(got, content) { t.Errorf("os.ReadFile() = %v, want %v", got, content) } } func TestStore_File_Push_RestoreDuplicates_Failure(t *testing.T) { mediaType := "test" content := []byte("hello world") blobDesc := ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(content), Size: int64(len(content)), Annotations: map[string]string{ ocispec.AnnotationTitle: "blob", }, } config := []byte("{}") configDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageConfig, Digest: digest.FromBytes(config), Size: int64(len(config)), } manifest := ocispec.Manifest{ MediaType: ocispec.MediaTypeImageManifest, Config: configDesc, Layers: []ocispec.Descriptor{blobDesc}, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal("json.Marshal() error =", err) } manifestDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageManifest, Digest: digest.FromBytes(manifestJSON), Size: int64(len(manifestJSON)), } tempDir := t.TempDir() fallbackMock := &storageMock{ Storage: cas.NewMemory(), } s, err := NewWithFallbackStorage(tempDir, fallbackMock) if err != nil { t.Fatal("NewWithFallbackStorage() error =", err) } defer s.Close() ctx := context.Background() // push manifest wantErr := errors.New("restoreDuplicates: fetch error") fallbackMock.OnFetch = func(ctx context.Context, desc ocispec.Descriptor) error { if desc.Digest == blobDesc.Digest { return wantErr } return nil } if err := s.Push(ctx, manifestDesc, bytes.NewReader(manifestJSON)); !errors.Is(err, wantErr) { t.Fatalf("Store.Push() error = %v, wantErr %v", err, wantErr) } } func TestStore_File_Fetch_SameDigest_NoName(t *testing.T) { mediaType := "test" content := []byte("hello world") name_1 := "test_1.txt" desc_1 := ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(content), Size: int64(len(content)), Annotations: map[string]string{ ocispec.AnnotationTitle: name_1, }, } desc_2 := ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(content), Size: int64(len(content)), } tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer s.Close() ctx := context.Background() // test push if err := s.Push(ctx, desc_1, bytes.NewReader(content)); err != nil { t.Fatal("Store.Push() error =", err) } if err := s.Push(ctx, desc_2, bytes.NewReader(content)); err != nil { t.Fatal("Store.Push() error =", err) } // test exists exists, err := s.Exists(ctx, desc_1) if err != nil { t.Fatal("Store.Exists() error =", err) } if !exists { t.Errorf("Store.Exists() = %v, want %v", exists, true) } exists, err = s.Exists(ctx, desc_2) if err != nil { t.Fatal("Store.Exists() error =", err) } if !exists { t.Errorf("Store.Exists() = %v, want %v", exists, true) } // test fetch rc_1, err := s.Fetch(ctx, desc_1) if err != nil { t.Fatal("Store.Fetch() error =", err) } got_1, err := io.ReadAll(rc_1) if err != nil { t.Fatal("Store.Fetch().Read() error =", err) } err = rc_1.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got_1, content) { t.Errorf("Store.Fetch() = %v, want %v", got_1, content) } rc_2, err := s.Fetch(ctx, desc_2) if err != nil { t.Fatal("Store.Fetch() error =", err) } got_2, err := io.ReadAll(rc_2) if err != nil { t.Fatal("Store.Fetch().Read() error =", err) } err = rc_2.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got_2, content) { t.Errorf("Store.Fetch() = %v, want %v", got_2, content) } } func TestStore_File_Fetch_SameDigest_DifferentName(t *testing.T) { mediaType := "test" content := []byte("hello world") name_1 := "test_1.txt" desc_1 := ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(content), Size: int64(len(content)), Annotations: map[string]string{ ocispec.AnnotationTitle: name_1, }, } name_2 := "test_2.txt" desc_2 := ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(content), Size: int64(len(content)), Annotations: map[string]string{ ocispec.AnnotationTitle: name_2, }, } tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer s.Close() ctx := context.Background() // test desc_1 if err := s.Push(ctx, desc_1, bytes.NewReader(content)); err != nil { t.Fatal("Store.Push() error =", err) } exists, err := s.Exists(ctx, desc_1) if err != nil { t.Fatal("Store.Exists() error =", err) } if !exists { t.Errorf("Store.Exists() = %v, want %v", exists, true) } rc_1, err := s.Fetch(ctx, desc_1) if err != nil { t.Fatal("Store.Fetch() error =", err) } got_1, err := io.ReadAll(rc_1) if err != nil { t.Fatal("Store.Fetch().Read() error =", err) } err = rc_1.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got_1, content) { t.Errorf("Store.Fetch() = %v, want %v", got_1, content) } // test desc_2 exists, err = s.Exists(ctx, desc_2) if err != nil { t.Fatal("Store.Exists() error =", err) } if exists { t.Errorf("Store.Exists() = %v, want %v", exists, false) } _, err = s.Fetch(ctx, desc_2) if !errors.Is(err, errdef.ErrNotFound) { t.Errorf("Store.Fetch() error = %v, want %v", err, errdef.ErrNotFound) } } func TestStore_File_Push_Overwrite(t *testing.T) { mediaType := "test" name := "test.txt" old_content := []byte("hello world") new_content := []byte("goodbye world") desc := ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(new_content), Size: int64(len(new_content)), Annotations: map[string]string{ ocispec.AnnotationTitle: name, }, } tempDir := t.TempDir() path := filepath.Join(tempDir, name) if err := os.WriteFile(path, old_content, 0666); err != nil { t.Fatal("error calling WriteFile(), error =", err) } s, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer s.Close() ctx := context.Background() // test push err = s.Push(ctx, desc, bytes.NewReader(new_content)) if err != nil { t.Fatal("Store.Push() error =", err) } // test exists exists, err := s.Exists(ctx, desc) if err != nil { t.Fatal("Store.Exists() error =", err) } if !exists { t.Errorf("Store.Exists() = %v, want %v", exists, true) } // test fetch rc, err := s.Fetch(ctx, desc) if err != nil { t.Fatal("Store.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Store.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got, new_content) { t.Errorf("Store.Fetch() = %v, want %v", got, new_content) } } func TestStore_File_Push_DisableOverwrite(t *testing.T) { content := []byte("hello world") name := "test.txt" desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), Annotations: map[string]string{ ocispec.AnnotationTitle: name, }, } tempDir := t.TempDir() path := filepath.Join(tempDir, name) if err := os.WriteFile(path, content, 0444); err != nil { t.Fatal("error calling WriteFile(), error =", err) } s, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer s.Close() s.DisableOverwrite = true ctx := context.Background() err = s.Push(ctx, desc, bytes.NewReader(content)) if !errors.Is(err, ErrOverwriteDisallowed) { t.Errorf("Store.Push() error = %v, want %v", err, ErrOverwriteDisallowed) } } func TestStore_File_Push_IgnoreNoName(t *testing.T) { config := []byte("{}") configDesc := ocispec.Descriptor{ MediaType: "config", Digest: digest.FromBytes(config), Size: int64(len(config)), } manifest := ocispec.Manifest{ MediaType: ocispec.MediaTypeImageManifest, Config: configDesc, Layers: []ocispec.Descriptor{}, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal("json.Marshal() error =", err) } manifestDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageManifest, Digest: digest.FromBytes(manifestJSON), Size: int64(len(manifestJSON)), } tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer s.Close() s.IgnoreNoName = true // push an OCI manifest ctx := context.Background() err = s.Push(ctx, manifestDesc, bytes.NewReader(manifestJSON)) if err != nil { t.Fatal("Store.Push() error = ", err) } // verify the manifest is not saved exists, err := s.Exists(ctx, manifestDesc) if err != nil { t.Fatal("Store.Exists() error =", err) } if exists { t.Errorf("Unnamed manifest is saved in file store") } // verify the manifest is not indexed predecessors, err := s.Predecessors(ctx, configDesc) if err != nil { t.Fatal("Store.Predecessors() error = ", err) } if len(predecessors) != 0 { t.Errorf("Unnamed manifest is indexed in file store") } } func TestStore_File_Push_DisallowPathTraversal(t *testing.T) { content := []byte("hello world") name := "../test.txt" desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), Annotations: map[string]string{ ocispec.AnnotationTitle: name, }, } tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer s.Close() ctx := context.Background() err = s.Push(ctx, desc, bytes.NewReader(content)) if !errors.Is(err, ErrPathTraversalDisallowed) { t.Errorf("Store.Push() error = %v, want %v", err, ErrPathTraversalDisallowed) } } func TestStore_Dir_Push_DisallowPathTraversal(t *testing.T) { tempDir := t.TempDir() dirName := "../testdir" dirPath := filepath.Join(tempDir, dirName) if err := os.MkdirAll(dirPath, 0777); err != nil { t.Fatal("error calling Mkdir(), error =", err) } content := []byte("hello world") fileName := "test.txt" if err := os.WriteFile(filepath.Join(dirPath, fileName), content, 0444); err != nil { t.Fatal("error calling WriteFile(), error =", err) } s, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer s.Close() ctx := context.Background() // test add desc, err := s.Add(ctx, dirName, "", dirPath) if err != nil { t.Fatal("Store.Add() error=", err) } val, ok := s.digestToPath.Load(desc.Digest) if !ok { t.Fatal("failed to find internal gz") } tmpPath := val.(string) zrc, err := os.Open(tmpPath) if err != nil { t.Fatal("failed to open internal gz") } gz, err := io.ReadAll(zrc) if err != nil { t.Fatal("failed to read internal gz") } if err := zrc.Close(); err != nil { t.Fatal("failed to close internal gz reader") } anotherTempDir := t.TempDir() // test with another file store instance to mock push gz anotherS, err := New(anotherTempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer anotherS.Close() // test push err = anotherS.Push(ctx, desc, bytes.NewReader(gz)) if !errors.Is(err, ErrPathTraversalDisallowed) { t.Errorf("Store.Push() error = %v, want %v", err, ErrPathTraversalDisallowed) } } func TestStore_File_Push_PathTraversal(t *testing.T) { content := []byte("hello world") name := "../test.txt" desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), Annotations: map[string]string{ ocispec.AnnotationTitle: name, }, } tempDir := t.TempDir() subTempDir, err := os.MkdirTemp(tempDir, "oras_filestore_*") if err != nil { t.Fatal("error creating temp dir, error =", err) } s, err := New(subTempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer s.Close() s.AllowPathTraversalOnWrite = true ctx := context.Background() // test push err = s.Push(ctx, desc, bytes.NewReader(content)) if err != nil { t.Fatal("Store.Push() error =", err) } // test exists exists, err := s.Exists(ctx, desc) if err != nil { t.Fatal("Store.Exists() error =", err) } if !exists { t.Errorf("Store.Exists() = %v, want %v", exists, true) } // test fetch rc, err := s.Fetch(ctx, desc) if err != nil { t.Fatal("Store.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Store.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got, content) { t.Errorf("Store.Fetch() = %v, want %v", got, content) } } func TestStore_File_Push_Concurrent(t *testing.T) { content := []byte("hello world") desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), Annotations: map[string]string{ ocispec.AnnotationTitle: "test.txt", }, } tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer s.Close() ctx := context.Background() concurrency := 64 eg, egCtx := errgroup.WithContext(ctx) for i := 0; i < concurrency; i++ { eg.Go(func(i int) func() error { return func() error { if err := s.Push(egCtx, desc, bytes.NewReader(content)); err != nil { if errors.Is(err, ErrDuplicateName) { return nil } return fmt.Errorf("failed to push content: %v", err) } return nil } }(i)) } if err := eg.Wait(); err != nil { t.Fatal(err) } exists, err := s.Exists(ctx, desc) if err != nil { t.Fatal("Store.Exists() error =", err) } if !exists { t.Errorf("Store.Exists() = %v, want %v", exists, true) } rc, err := s.Fetch(ctx, desc) if err != nil { t.Fatal("Store.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Store.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got, content) { t.Errorf("Store.Fetch() = %v, want %v", got, content) } } func TestStore_File_Fetch_Concurrent(t *testing.T) { content := []byte("hello world") desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), Annotations: map[string]string{ ocispec.AnnotationTitle: "test.txt", }, } tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer s.Close() ctx := context.Background() if err := s.Push(ctx, desc, bytes.NewReader(content)); err != nil { t.Fatal("Store.Push() error =", err) } concurrency := 64 eg, egCtx := errgroup.WithContext(ctx) for i := 0; i < concurrency; i++ { eg.Go(func(i int) func() error { return func() error { rc, err := s.Fetch(egCtx, desc) if err != nil { return fmt.Errorf("failed to fetch content: %v", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Store.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got, content) { t.Errorf("Store.Fetch() = %v, want %v", got, content) } return nil } }(i)) } if err := eg.Wait(); err != nil { t.Fatal(err) } } func TestStore_TagNotFound(t *testing.T) { ref := "foobar" tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer s.Close() ctx := context.Background() _, err = s.Resolve(ctx, ref) if !errors.Is(err, errdef.ErrNotFound) { t.Errorf("Store.Resolve() error = %v, want %v", err, errdef.ErrNotFound) } } func TestStore_TagUnknownContent(t *testing.T) { content := []byte(`{"layers":[]}`) desc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageManifest, Digest: digest.FromBytes(content), Size: int64(len(content)), } ref := "foobar" tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer s.Close() ctx := context.Background() err = s.Tag(ctx, desc, ref) if !errors.Is(err, errdef.ErrNotFound) { t.Errorf("Store.Resolve() error = %v, want %v", err, errdef.ErrNotFound) } } func TestStore_RepeatTag(t *testing.T) { tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer s.Close() ctx := context.Background() generate := func(content []byte) ocispec.Descriptor { dgst := digest.FromBytes(content) desc := ocispec.Descriptor{ MediaType: "test", Digest: dgst, Size: int64(len(content)), Annotations: map[string]string{ ocispec.AnnotationTitle: dgst.Encoded() + ".blob", }, } return desc } ref := "foobar" // initial tag content := []byte("hello world") desc := generate(content) err = s.Push(ctx, desc, bytes.NewReader(content)) if err != nil { t.Fatal("Store.Push() error =", err) } err = s.Tag(ctx, desc, ref) if err != nil { t.Fatal("Store.Tag() error =", err) } gotDesc, err := s.Resolve(ctx, ref) if err != nil { t.Fatal("Store.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, desc) { t.Errorf("Store.Resolve() = %v, want %v", gotDesc, desc) } // repeat tag content = []byte("foo") desc = generate(content) err = s.Push(ctx, desc, bytes.NewReader(content)) if err != nil { t.Fatal("Store.Push() error =", err) } err = s.Tag(ctx, desc, ref) if err != nil { t.Fatal("Store.Tag() error =", err) } gotDesc, err = s.Resolve(ctx, ref) if err != nil { t.Fatal("Store.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, desc) { t.Errorf("Store.Resolve() = %v, want %v", gotDesc, desc) } // repeat tag content = []byte("bar") desc = generate(content) err = s.Push(ctx, desc, bytes.NewReader(content)) if err != nil { t.Fatal("Store.Push() error =", err) } err = s.Tag(ctx, desc, ref) if err != nil { t.Fatal("Store.Tag() error =", err) } gotDesc, err = s.Resolve(ctx, ref) if err != nil { t.Fatal("Store.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, desc) { t.Errorf("Store.Resolve() = %v, want %v", gotDesc, desc) } } func TestStore_Predecessors(t *testing.T) { tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer s.Close() ctx := context.Background() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) dgst := digest.FromBytes(blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: dgst, Size: int64(len(blob)), Annotations: map[string]string{ ocispec.AnnotationTitle: dgst.Encoded() + ".blob", }, }) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } generateIndex := func(manifests ...ocispec.Descriptor) { index := ocispec.Index{ Manifests: manifests, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageIndex, indexJSON) } generateArtifactManifest := func(subject ocispec.Descriptor, blobs ...ocispec.Descriptor) { var manifest spec.Artifact manifest.Subject = &subject manifest.Blobs = append(manifest.Blobs, blobs...) manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(spec.MediaTypeArtifactManifest, manifestJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 appendBlob(ocispec.MediaTypeImageLayer, []byte("hello")) // Blob 3 generateManifest(descs[0], descs[1:3]...) // Blob 4 generateManifest(descs[0], descs[3]) // Blob 5 generateManifest(descs[0], descs[1:4]...) // Blob 6 generateIndex(descs[4:6]...) // Blob 7 generateIndex(descs[6]) // Blob 8 generateIndex() // Blob 9 generateIndex(descs[7:10]...) // Blob 10 appendBlob(ocispec.MediaTypeImageLayer, []byte("sig_1")) // Blob 11 generateArtifactManifest(descs[6], descs[11]) // Blob 12 appendBlob(ocispec.MediaTypeImageLayer, []byte("sig_2")) // Blob 13 generateArtifactManifest(descs[10], descs[13]) // Blob 14 eg, egCtx := errgroup.WithContext(ctx) for i := range blobs { eg.Go(func(i int) func() error { return func() error { err := s.Push(egCtx, descs[i], bytes.NewReader(blobs[i])) if err != nil { return fmt.Errorf("failed to push test content to src: %d: %v", i, err) } return nil } }(i)) } if err := eg.Wait(); err != nil { t.Fatal(err) } // verify predecessors wants := [][]ocispec.Descriptor{ descs[4:7], // Blob 0 {descs[4], descs[6]}, // Blob 1 {descs[4], descs[6]}, // Blob 2 {descs[5], descs[6]}, // Blob 3 {descs[7]}, // Blob 4 {descs[7]}, // Blob 5 {descs[8], descs[12]}, // Blob 6 {descs[10]}, // Blob 7 {descs[10]}, // Blob 8 {descs[10]}, // Blob 9 {descs[14]}, // Blob 10 {descs[12]}, // Blob 11 nil, // Blob 12 {descs[14]}, // Blob 13 nil, // Blob 14 } for i, want := range wants { predecessors, err := s.Predecessors(ctx, descs[i]) if err != nil { t.Errorf("Store.Predecessors(%d) error = %v", i, err) } if !equalDescriptorSet(predecessors, want) { t.Errorf("Store.Predecessors(%d) = %v, want %v", i, predecessors, want) } } } func TestCopy_File_MemoryToFile_FullCopy(t *testing.T) { src := memory.New() tempDir := t.TempDir() dst, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer dst.Close() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) dgst := digest.FromBytes(blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: dgst, Size: int64(len(blob)), Annotations: map[string]string{ ocispec.AnnotationTitle: dgst.Encoded() + ".blob", }, }) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 generateManifest(descs[0], descs[1:3]...) // Blob 3 ctx := context.Background() for i := range blobs { err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } root := descs[3] ref := "foobar" err = src.Tag(ctx, root, ref) if err != nil { t.Fatal("fail to tag root node", err) } // test copy gotDesc, err := oras.Copy(ctx, src, ref, dst, "", oras.CopyOptions{}) if err != nil { t.Fatalf("Copy() error = %v, wantErr %v", err, false) } if !reflect.DeepEqual(gotDesc, root) { t.Errorf("Copy() = %v, want %v", gotDesc, root) } // verify contents for i, desc := range descs { exists, err := dst.Exists(ctx, desc) if err != nil { t.Fatalf("dst.Exists(%d) error = %v", i, err) } if !exists { t.Errorf("dst.Exists(%d) = %v, want %v", i, exists, true) } } // verify tag gotDesc, err = dst.Resolve(ctx, ref) if err != nil { t.Fatal("dst.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, root) { t.Errorf("dst.Resolve() = %v, want %v", gotDesc, root) } } func TestCopyGraph_MemoryToFile_FullCopy(t *testing.T) { src := memory.New() tempDir := t.TempDir() dst, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer dst.Close() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) dgst := digest.FromBytes(blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: dgst, Size: int64(len(blob)), Annotations: map[string]string{ ocispec.AnnotationTitle: dgst.Encoded() + ".blob", }, }) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } generateIndex := func(manifests ...ocispec.Descriptor) { index := ocispec.Index{ Manifests: manifests, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageIndex, indexJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 appendBlob(ocispec.MediaTypeImageLayer, []byte("hello")) // Blob 3 generateManifest(descs[0], descs[1:3]...) // Blob 4 generateManifest(descs[0], descs[3]) // Blob 5 generateManifest(descs[0], descs[1:4]...) // Blob 6 generateIndex(descs[4:6]...) // Blob 7 generateIndex(descs[6]) // Blob 8 generateIndex() // Blob 9 generateIndex(descs[7:10]...) // Blob 10 ctx := context.Background() for i := range blobs { err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } // test copy srcTracker := &storageTracker{Storage: src} dstTracker := &storageTracker{Storage: dst} root := descs[len(descs)-1] if err := oras.CopyGraph(ctx, srcTracker, dstTracker, root, oras.CopyGraphOptions{}); err != nil { t.Fatalf("CopyGraph() error = %v, wantErr %v", err, false) } // verify contents for i := range blobs { got, err := content.FetchAll(ctx, dst, descs[i]) if err != nil { t.Errorf("content[%d] error = %v, wantErr %v", i, err, false) continue } if want := blobs[i]; !bytes.Equal(got, want) { t.Errorf("content[%d] = %v, want %v", i, got, want) } } // verify API counts if got, want := srcTracker.fetch, int64(len(blobs)); got != want { t.Errorf("count(src.Fetch()) = %v, want %v", got, want) } if got, want := srcTracker.push, int64(0); got != want { t.Errorf("count(src.Push()) = %v, want %v", got, want) } if got, want := srcTracker.exists, int64(0); got != want { t.Errorf("count(src.Exists()) = %v, want %v", got, want) } if got, want := dstTracker.fetch, int64(0); got != want { t.Errorf("count(dst.Fetch()) = %v, want %v", got, want) } if got, want := dstTracker.push, int64(len(blobs)); got != want { t.Errorf("count(dst.Push()) = %v, want %v", got, want) } if got, want := dstTracker.exists, int64(len(blobs)); got != want { t.Errorf("count(dst.Exists()) = %v, want %v", got, want) } } func TestCopyGraph_MemoryToFile_PartialCopy(t *testing.T) { src := memory.New() tempDir := t.TempDir() dst, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer dst.Close() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) dgst := digest.FromBytes(blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: dgst, Size: int64(len(blob)), Annotations: map[string]string{ ocispec.AnnotationTitle: dgst.Encoded() + ".blob", }, }) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } generateIndex := func(manifests ...ocispec.Descriptor) { index := ocispec.Index{ Manifests: manifests, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageIndex, indexJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 generateManifest(descs[0], descs[1:3]...) // Blob 3 appendBlob(ocispec.MediaTypeImageLayer, []byte("hello")) // Blob 4 generateManifest(descs[0], descs[4]) // Blob 5 generateIndex(descs[3], descs[5]) // Blob 6 ctx := context.Background() for i := range blobs { err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } // initial copy root := descs[3] if err := oras.CopyGraph(ctx, src, dst, root, oras.CopyGraphOptions{}); err != nil { t.Fatalf("CopyGraph() error = %v, wantErr %v", err, false) } // verify contents for i := range blobs[:4] { got, err := content.FetchAll(ctx, dst, descs[i]) if err != nil { t.Fatalf("content[%d] error = %v, wantErr %v", i, err, false) } if want := blobs[i]; !bytes.Equal(got, want) { t.Fatalf("content[%d] = %v, want %v", i, got, want) } } // test copy srcTracker := &storageTracker{Storage: src} dstTracker := &storageTracker{Storage: dst} root = descs[len(descs)-1] if err := oras.CopyGraph(ctx, srcTracker, dstTracker, root, oras.CopyGraphOptions{}); err != nil { t.Fatalf("CopyGraph() error = %v, wantErr %v", err, false) } // verify contents for i := range blobs { got, err := content.FetchAll(ctx, dst, descs[i]) if err != nil { t.Errorf("content[%d] error = %v, wantErr %v", i, err, false) continue } if want := blobs[i]; !bytes.Equal(got, want) { t.Errorf("content[%d] = %v, want %v", i, got, want) } } // verify API counts if got, want := srcTracker.fetch, int64(3); got != want { t.Errorf("count(src.Fetch()) = %v, want %v", got, want) } if got, want := srcTracker.push, int64(0); got != want { t.Errorf("count(src.Push()) = %v, want %v", got, want) } if got, want := srcTracker.exists, int64(0); got != want { t.Errorf("count(src.Exists()) = %v, want %v", got, want) } if got, want := dstTracker.fetch, int64(0); got != want { t.Errorf("count(dst.Fetch()) = %v, want %v", got, want) } if got, want := dstTracker.push, int64(3); got != want { t.Errorf("count(dst.Push()) = %v, want %v", got, want) } if got, want := dstTracker.exists, int64(5); got != want { t.Errorf("count(dst.Exists()) = %v, want %v", got, want) } } func TestCopy_File_FileToMemory_FullCopy(t *testing.T) { tempDir := t.TempDir() src, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer src.Close() dst := memory.New() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) dgst := digest.FromBytes(blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: dgst, Size: int64(len(blob)), Annotations: map[string]string{ ocispec.AnnotationTitle: dgst.Encoded() + ".blob", }, }) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 generateManifest(descs[0], descs[1:3]...) // Blob 3 ctx := context.Background() for i := range blobs { err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } root := descs[3] ref := "foobar" err = src.Tag(ctx, root, ref) if err != nil { t.Fatal("fail to tag root node", err) } // test copy gotDesc, err := oras.Copy(ctx, src, ref, dst, "", oras.CopyOptions{}) if err != nil { t.Fatalf("Copy() error = %v, wantErr %v", err, false) } if !reflect.DeepEqual(gotDesc, root) { t.Errorf("Copy() = %v, want %v", gotDesc, root) } // verify contents for i, desc := range descs { exists, err := dst.Exists(ctx, desc) if err != nil { t.Fatalf("dst.Exists(%d) error = %v", i, err) } if !exists { t.Errorf("dst.Exists(%d) = %v, want %v", i, exists, true) } } // verify tag gotDesc, err = dst.Resolve(ctx, ref) if err != nil { t.Fatal("dst.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, root) { t.Errorf("dst.Resolve() = %v, want %v", gotDesc, root) } } func TestCopyGraph_FileToMemory_FullCopy(t *testing.T) { tempDir := t.TempDir() src, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer src.Close() dst := memory.New() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) dgst := digest.FromBytes(blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: dgst, Size: int64(len(blob)), Annotations: map[string]string{ ocispec.AnnotationTitle: dgst.Encoded() + ".blob", }, }) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } generateIndex := func(manifests ...ocispec.Descriptor) { index := ocispec.Index{ Manifests: manifests, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageIndex, indexJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 appendBlob(ocispec.MediaTypeImageLayer, []byte("hello")) // Blob 3 generateManifest(descs[0], descs[1:3]...) // Blob 4 generateManifest(descs[0], descs[3]) // Blob 5 generateManifest(descs[0], descs[1:4]...) // Blob 6 generateIndex(descs[4:6]...) // Blob 7 generateIndex(descs[6]) // Blob 8 generateIndex() // Blob 9 generateIndex(descs[7:10]...) // Blob 10 ctx := context.Background() for i := range blobs { err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } // test copy srcTracker := &storageTracker{Storage: src} dstTracker := &storageTracker{Storage: dst} root := descs[len(descs)-1] if err := oras.CopyGraph(ctx, srcTracker, dstTracker, root, oras.CopyGraphOptions{}); err != nil { t.Fatalf("CopyGraph() error = %v, wantErr %v", err, false) } // verify contents for i := range blobs { got, err := content.FetchAll(ctx, dst, descs[i]) if err != nil { t.Errorf("content[%d] error = %v, wantErr %v", i, err, false) continue } if want := blobs[i]; !bytes.Equal(got, want) { t.Errorf("content[%d] = %v, want %v", i, got, want) } } // verify API counts if got, want := srcTracker.fetch, int64(len(blobs)); got != want { t.Errorf("count(src.Fetch()) = %v, want %v", got, want) } if got, want := srcTracker.push, int64(0); got != want { t.Errorf("count(src.Push()) = %v, want %v", got, want) } if got, want := srcTracker.exists, int64(0); got != want { t.Errorf("count(src.Exists()) = %v, want %v", got, want) } if got, want := dstTracker.fetch, int64(0); got != want { t.Errorf("count(dst.Fetch()) = %v, want %v", got, want) } if got, want := dstTracker.push, int64(len(blobs)); got != want { t.Errorf("count(dst.Push()) = %v, want %v", got, want) } if got, want := dstTracker.exists, int64(len(blobs)); got != want { t.Errorf("count(dst.Exists()) = %v, want %v", got, want) } } func TestCopyGraph_FileToMemory_PartialCopy(t *testing.T) { tempDir := t.TempDir() src, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer src.Close() dst := memory.New() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) dgst := digest.FromBytes(blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: dgst, Size: int64(len(blob)), Annotations: map[string]string{ ocispec.AnnotationTitle: dgst.Encoded() + ".blob", }, }) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } generateIndex := func(manifests ...ocispec.Descriptor) { index := ocispec.Index{ Manifests: manifests, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageIndex, indexJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 generateManifest(descs[0], descs[1:3]...) // Blob 3 appendBlob(ocispec.MediaTypeImageLayer, []byte("hello")) // Blob 4 generateManifest(descs[0], descs[4]) // Blob 5 generateIndex(descs[3], descs[5]) // Blob 6 ctx := context.Background() for i := range blobs { err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } // initial copy root := descs[3] if err := oras.CopyGraph(ctx, src, dst, root, oras.CopyGraphOptions{}); err != nil { t.Fatalf("CopyGraph() error = %v, wantErr %v", err, false) } // verify contents for i := range blobs[:4] { got, err := content.FetchAll(ctx, dst, descs[i]) if err != nil { t.Fatalf("content[%d] error = %v, wantErr %v", i, err, false) } if want := blobs[i]; !bytes.Equal(got, want) { t.Fatalf("content[%d] = %v, want %v", i, got, want) } } // test copy srcTracker := &storageTracker{Storage: src} dstTracker := &storageTracker{Storage: dst} root = descs[len(descs)-1] if err := oras.CopyGraph(ctx, srcTracker, dstTracker, root, oras.CopyGraphOptions{}); err != nil { t.Fatalf("CopyGraph() error = %v, wantErr %v", err, false) } // verify contents for i := range blobs { got, err := content.FetchAll(ctx, dst, descs[i]) if err != nil { t.Errorf("content[%d] error = %v, wantErr %v", i, err, false) continue } if want := blobs[i]; !bytes.Equal(got, want) { t.Errorf("content[%d] = %v, want %v", i, got, want) } } // verify API counts if got, want := srcTracker.fetch, int64(3); got != want { t.Errorf("count(src.Fetch()) = %v, want %v", got, want) } if got, want := srcTracker.push, int64(0); got != want { t.Errorf("count(src.Push()) = %v, want %v", got, want) } if got, want := srcTracker.exists, int64(0); got != want { t.Errorf("count(src.Exists()) = %v, want %v", got, want) } if got, want := dstTracker.fetch, int64(0); got != want { t.Errorf("count(dst.Fetch()) = %v, want %v", got, want) } if got, want := dstTracker.push, int64(3); got != want { t.Errorf("count(dst.Push()) = %v, want %v", got, want) } if got, want := dstTracker.exists, int64(5); got != want { t.Errorf("count(dst.Exists()) = %v, want %v", got, want) } } func equalDescriptorSet(actual []ocispec.Descriptor, expected []ocispec.Descriptor) bool { if len(actual) != len(expected) { return false } contains := func(node ocispec.Descriptor) bool { for _, candidate := range actual { if reflect.DeepEqual(candidate, node) { return true } } return false } for _, node := range expected { if !contains(node) { return false } } return true } oras-go-2.5.0/content/file/file_unix_test.go000066400000000000000000000165601457674530300210540ustar00rootroot00000000000000//go:build !windows /* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package file import ( "bytes" "context" "os" "path/filepath" "testing" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2" ) // Related issue: https://github.com/oras-project/oras-go/issues/402 func TestStore_Dir_ExtractSymlinkRel(t *testing.T) { // prepare test content tempDir := t.TempDir() dirName := "testdir" dirPath := filepath.Join(tempDir, dirName) if err := os.MkdirAll(dirPath, 0777); err != nil { t.Fatal("error calling Mkdir(), error =", err) } content := []byte("hello world") fileName := "test.txt" filePath := filepath.Join(dirPath, fileName) if err := os.WriteFile(filePath, content, 0444); err != nil { t.Fatal("error calling WriteFile(), error =", err) } // create symlink to a relative path symlinkName := "test_symlink" symlinkPath := filepath.Join(dirPath, symlinkName) if err := os.Symlink(fileName, symlinkPath); err != nil { t.Fatal("error calling Symlink(), error =", err) } src, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer src.Close() ctx := context.Background() // add dir desc, err := src.Add(ctx, dirName, "", dirPath) if err != nil { t.Fatal("Store.Add() error =", err) } // pack a manifest manifestDesc, err := oras.Pack(ctx, src, "dir", []ocispec.Descriptor{desc}, oras.PackOptions{}) if err != nil { t.Fatal("oras.Pack() error =", err) } // copy to another file store created from an absolute root, to trigger extracting directory tempDir = t.TempDir() dstAbs, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer dstAbs.Close() if err := oras.CopyGraph(ctx, src, dstAbs, manifestDesc, oras.DefaultCopyGraphOptions); err != nil { t.Fatal("oras.CopyGraph() error =", err) } // verify extracted symlink extractedSymlink := filepath.Join(tempDir, dirName, symlinkName) symlinkDst, err := os.Readlink(extractedSymlink) if err != nil { t.Fatal("failed to get symlink destination, error =", err) } if want := fileName; symlinkDst != want { t.Errorf("symlink destination = %v, want %v", symlinkDst, want) } got, err := os.ReadFile(extractedSymlink) if err != nil { t.Fatal("failed to read symlink file, error =", err) } if !bytes.Equal(got, content) { t.Errorf("symlink content = %v, want %v", got, content) } // copy to another file store created from a relative root, to trigger extracting directory tempDir = t.TempDir() if err := os.Chdir(tempDir); err != nil { t.Fatal("error calling Chdir(), error=", err) } dstRel, err := New(".") if err != nil { t.Fatal("Store.New() error =", err) } defer dstRel.Close() if err := oras.CopyGraph(ctx, src, dstRel, manifestDesc, oras.DefaultCopyGraphOptions); err != nil { t.Fatal("oras.CopyGraph() error =", err) } // verify extracted symlink extractedSymlink = filepath.Join(tempDir, dirName, symlinkName) symlinkDst, err = os.Readlink(extractedSymlink) if err != nil { t.Fatal("failed to get symlink destination, error =", err) } if want := fileName; symlinkDst != want { t.Errorf("symlink destination = %v, want %v", symlinkDst, want) } got, err = os.ReadFile(extractedSymlink) if err != nil { t.Fatal("failed to read symlink file, error =", err) } if !bytes.Equal(got, content) { t.Errorf("symlink content = %v, want %v", got, content) } } // Related issue: https://github.com/oras-project/oras-go/issues/402 func TestStore_Dir_ExtractSymlinkAbs(t *testing.T) { // prepare test content tempDir, err := filepath.EvalSymlinks(t.TempDir()) if err != nil { t.Fatal("error calling filepath.EvalSymlinks(), error =", err) } dirName := "testdir" dirPath := filepath.Join(tempDir, dirName) if err := os.MkdirAll(dirPath, 0777); err != nil { t.Fatal("error calling Mkdir(), error =", err) } content := []byte("hello world") fileName := "test.txt" filePath := filepath.Join(dirPath, fileName) if err := os.WriteFile(filePath, content, 0444); err != nil { t.Fatal("error calling WriteFile(), error =", err) } // create symlink to an absolute path symlink := filepath.Join(dirPath, "test_symlink") if err := os.Symlink(filePath, symlink); err != nil { t.Fatal("error calling Symlink(), error =", err) } src, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer src.Close() ctx := context.Background() // add dir desc, err := src.Add(ctx, dirName, "", dirPath) if err != nil { t.Fatal("Store.Add() error =", err) } // pack a manifest manifestDesc, err := oras.Pack(ctx, src, "dir", []ocispec.Descriptor{desc}, oras.PackOptions{}) if err != nil { t.Fatal("oras.Pack() error =", err) } // remove the original testing directory and create a new store using an absolute root if err := os.RemoveAll(dirPath); err != nil { t.Fatal("error calling RemoveAll(), error =", err) } dstAbs, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer dstAbs.Close() if err := oras.CopyGraph(ctx, src, dstAbs, manifestDesc, oras.DefaultCopyGraphOptions); err != nil { t.Fatal("oras.CopyGraph() error =", err) } // verify extracted symlink symlinkDst, err := os.Readlink(symlink) if err != nil { t.Fatal("failed to get symlink destination, error =", err) } if want := filePath; symlinkDst != want { t.Errorf("symlink destination = %v, want %v", symlinkDst, want) } got, err := os.ReadFile(symlink) if err != nil { t.Fatal("failed to read symlink file, error =", err) } if !bytes.Equal(got, content) { t.Errorf("symlink content = %v, want %v", got, content) } // remove the original testing directory and create a new store using a relative path if err := os.RemoveAll(dirPath); err != nil { t.Fatal("error calling RemoveAll(), error =", err) } if err := os.Chdir(tempDir); err != nil { t.Fatal("error calling Chdir(), error=", err) } dstRel, err := New(".") if err != nil { t.Fatal("Store.New() error =", err) } defer dstRel.Close() if err := oras.CopyGraph(ctx, src, dstRel, manifestDesc, oras.DefaultCopyGraphOptions); err != nil { t.Fatal("oras.CopyGraph() error =", err) } // verify extracted symlink symlinkDst, err = os.Readlink(symlink) if err != nil { t.Fatal("failed to get symlink destination, error =", err) } if want := filePath; symlinkDst != want { t.Errorf("symlink destination = %v, want %v", symlinkDst, want) } got, err = os.ReadFile(symlink) if err != nil { t.Fatal("failed to read symlink file, error =", err) } if !bytes.Equal(got, content) { t.Errorf("symlink content = %v, want %v", got, content) } // copy to another file store created from an outside root, to trigger extracting directory tempDir = t.TempDir() dstOutside, err := New(tempDir) if err != nil { t.Fatal("Store.New() error =", err) } defer dstOutside.Close() if err := oras.CopyGraph(ctx, src, dstOutside, manifestDesc, oras.DefaultCopyGraphOptions); err == nil { t.Error("oras.CopyGraph() error = nil, wantErr ", true) } } oras-go-2.5.0/content/file/utils.go000066400000000000000000000144151457674530300171700ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package file import ( "archive/tar" "compress/gzip" "errors" "fmt" "io" "os" "path/filepath" "strings" "time" "github.com/opencontainers/go-digest" ) // tarDirectory walks the directory specified by path, and tar those files with a new // path prefix. func tarDirectory(root, prefix string, w io.Writer, removeTimes bool, buf []byte) (err error) { tw := tar.NewWriter(w) defer func() { closeErr := tw.Close() if err == nil { err = closeErr } }() return filepath.Walk(root, func(path string, info os.FileInfo, err error) (returnErr error) { if err != nil { return err } // Rename path name, err := filepath.Rel(root, path) if err != nil { return err } name = filepath.Join(prefix, name) name = filepath.ToSlash(name) // Generate header var link string mode := info.Mode() if mode&os.ModeSymlink != 0 { if link, err = os.Readlink(path); err != nil { return err } } header, err := tar.FileInfoHeader(info, link) if err != nil { return fmt.Errorf("%s: %w", path, err) } header.Name = name header.Uid = 0 header.Gid = 0 header.Uname = "" header.Gname = "" if removeTimes { header.ModTime = time.Time{} header.AccessTime = time.Time{} header.ChangeTime = time.Time{} } // Write file if err := tw.WriteHeader(header); err != nil { return fmt.Errorf("tar: %w", err) } if mode.IsRegular() { fp, err := os.Open(path) if err != nil { return err } defer func() { closeErr := fp.Close() if returnErr == nil { returnErr = closeErr } }() if _, err := io.CopyBuffer(tw, fp, buf); err != nil { return fmt.Errorf("failed to copy to %s: %w", path, err) } } return nil }) } // extractTarGzip decompresses the gzip // and extracts tar file to a directory specified by the `dir` parameter. func extractTarGzip(dir, prefix, filename, checksum string, buf []byte) (err error) { fp, err := os.Open(filename) if err != nil { return err } defer func() { closeErr := fp.Close() if err == nil { err = closeErr } }() gzr, err := gzip.NewReader(fp) if err != nil { return err } defer func() { closeErr := gzr.Close() if err == nil { err = closeErr } }() var r io.Reader = gzr var verifier digest.Verifier if checksum != "" { if digest, err := digest.Parse(checksum); err == nil { verifier = digest.Verifier() r = io.TeeReader(r, verifier) } } if err := extractTarDirectory(dir, prefix, r, buf); err != nil { return err } if verifier != nil && !verifier.Verified() { return errors.New("content digest mismatch") } return nil } // extractTarDirectory extracts tar file to a directory specified by the `dir` // parameter. The file name prefix is ensured to be the string specified by the // `prefix` parameter and is trimmed. func extractTarDirectory(dir, prefix string, r io.Reader, buf []byte) error { tr := tar.NewReader(r) for { header, err := tr.Next() if err != nil { if err == io.EOF { return nil } return err } // Name check name := header.Name path, err := ensureBasePath(dir, prefix, name) if err != nil { return err } path = filepath.Join(dir, path) // Create content switch header.Typeflag { case tar.TypeReg: err = writeFile(path, tr, header.FileInfo().Mode(), buf) case tar.TypeDir: err = os.MkdirAll(path, header.FileInfo().Mode()) case tar.TypeLink: var target string if target, err = ensureLinkPath(dir, prefix, path, header.Linkname); err == nil { err = os.Link(target, path) } case tar.TypeSymlink: var target string if target, err = ensureLinkPath(dir, prefix, path, header.Linkname); err == nil { err = os.Symlink(target, path) } default: continue // Non-regular files are skipped } if err != nil { return err } // Change access time and modification time if possible (error ignored) os.Chtimes(path, header.AccessTime, header.ModTime) } } // ensureBasePath ensures the target path is in the base path, // returning its relative path to the base path. // target can be either an absolute path or a relative path. func ensureBasePath(baseAbs, baseRel, target string) (string, error) { base := baseRel if filepath.IsAbs(target) { // ensure base and target are consistent base = baseAbs } path, err := filepath.Rel(base, target) if err != nil { return "", err } cleanPath := filepath.ToSlash(filepath.Clean(path)) if cleanPath == ".." || strings.HasPrefix(cleanPath, "../") { return "", fmt.Errorf("%q is outside of %q", target, baseRel) } // No symbolic link allowed in the relative path dir := filepath.Dir(path) for dir != "." { if info, err := os.Lstat(filepath.Join(baseAbs, dir)); err != nil { if !os.IsNotExist(err) { return "", err } } else if info.Mode()&os.ModeSymlink != 0 { return "", fmt.Errorf("no symbolic link allowed between %q and %q", baseRel, target) } dir = filepath.Dir(dir) } return path, nil } // ensureLinkPath ensures the target path pointed by the link is in the base // path. It returns target path if validated. func ensureLinkPath(baseAbs, baseRel, link, target string) (string, error) { // resolve link path := target if !filepath.IsAbs(target) { path = filepath.Join(filepath.Dir(link), target) } // ensure path is under baseAbs or baseRel if _, err := ensureBasePath(baseAbs, baseRel, path); err != nil { return "", err } return target, nil } // writeFile writes content to the file specified by the `path` parameter. func writeFile(path string, r io.Reader, perm os.FileMode, buf []byte) (err error) { file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm) if err != nil { return err } defer func() { closeErr := file.Close() if err == nil { err = closeErr } }() _, err = io.CopyBuffer(file, r, buf) return err } oras-go-2.5.0/content/file/utils_test.go000066400000000000000000000067041457674530300202310ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package file import ( "os" "path/filepath" "testing" ) func Test_ensureBasePath(t *testing.T) { root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, "hello world", "foo", "bar"), 0700); err != nil { t.Fatal("failed to create temp folders:", err) } base := "hello world/foo" tests := []struct { name string target string want string wantErr bool }{ { name: "valid case (depth 0)", target: "hello world/foo", want: ".", }, { name: "valid case (depth 1)", target: "hello world/foo/bar", want: "bar", }, { name: "valid case (depth 2)", target: "hello world/foo/bar/fun", want: filepath.Join("bar", "fun"), }, { name: "invalid prefix", target: "hello world/fun", wantErr: true, }, { name: "invalid prefix", target: "hello/foo", wantErr: true, }, { name: "bad traversal", target: "hello world/foo/..", wantErr: true, }, { name: "valid traversal", target: "hello world/foo/../foo/bar/../bar", want: "bar", }, { name: "complex traversal", target: "hello world/foo/../foo/bar/../..", wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := ensureBasePath(root, base, tt.target) if (err != nil) != tt.wantErr { t.Errorf("ensureBasePath() error = %v, wantErr %v", err, tt.wantErr) return } if got != tt.want { t.Errorf("ensureBasePath() = %v, want %v", got, tt.want) } }) } } func Test_ensureLinkPath(t *testing.T) { root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, "hello world", "foo", "bar"), 0700); err != nil { t.Fatal("failed to create temp folders:", err) } base := "hello world/foo" tests := []struct { name string link string target string want string wantErr bool }{ { name: "valid case (depth 1)", link: "hello world/foo/bar", target: "fun", want: "fun", }, { name: "valid case (depth 2)", link: "hello world/foo/bar/fun", target: "../fun", want: "../fun", }, { name: "invalid prefix", link: "hello world/foo", target: "fun", wantErr: true, }, { name: "bad traversal", link: "hello world/foo/bar", target: "../fun", wantErr: true, }, { name: "valid traversal", link: "hello world/foo/../foo/bar/../bar", // hello world/foo/bar target: "../foo/../foo/fun", want: "../foo/../foo/fun", }, { name: "complex traversal", link: "hello world/foo/bar", target: "../foo/../../fun", wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := ensureLinkPath(root, base, tt.link, tt.target) if (err != nil) != tt.wantErr { t.Errorf("ensureLinkPath() error = %v, wantErr %v", err, tt.wantErr) return } if got != tt.want { t.Errorf("ensureLinkPath() = %v, want %v", got, tt.want) } }) } } oras-go-2.5.0/content/graph.go000066400000000000000000000071611457674530300162120ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package content import ( "context" "encoding/json" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/internal/docker" "oras.land/oras-go/v2/internal/spec" ) // PredecessorFinder finds out the nodes directly pointing to a given node of a // directed acyclic graph. // In other words, returns the "parents" of the current descriptor. // PredecessorFinder is an extension of Storage. type PredecessorFinder interface { // Predecessors returns the nodes directly pointing to the current node. Predecessors(ctx context.Context, node ocispec.Descriptor) ([]ocispec.Descriptor, error) } // GraphStorage represents a CAS that supports direct predecessor node finding. type GraphStorage interface { Storage PredecessorFinder } // ReadOnlyGraphStorage represents a read-only GraphStorage. type ReadOnlyGraphStorage interface { ReadOnlyStorage PredecessorFinder } // Successors returns the nodes directly pointed by the current node. // In other words, returns the "children" of the current descriptor. func Successors(ctx context.Context, fetcher Fetcher, node ocispec.Descriptor) ([]ocispec.Descriptor, error) { switch node.MediaType { case docker.MediaTypeManifest: content, err := FetchAll(ctx, fetcher, node) if err != nil { return nil, err } // OCI manifest schema can be used to marshal docker manifest var manifest ocispec.Manifest if err := json.Unmarshal(content, &manifest); err != nil { return nil, err } return append([]ocispec.Descriptor{manifest.Config}, manifest.Layers...), nil case ocispec.MediaTypeImageManifest: content, err := FetchAll(ctx, fetcher, node) if err != nil { return nil, err } var manifest ocispec.Manifest if err := json.Unmarshal(content, &manifest); err != nil { return nil, err } var nodes []ocispec.Descriptor if manifest.Subject != nil { nodes = append(nodes, *manifest.Subject) } nodes = append(nodes, manifest.Config) return append(nodes, manifest.Layers...), nil case docker.MediaTypeManifestList: content, err := FetchAll(ctx, fetcher, node) if err != nil { return nil, err } // OCI manifest index schema can be used to marshal docker manifest list var index ocispec.Index if err := json.Unmarshal(content, &index); err != nil { return nil, err } return index.Manifests, nil case ocispec.MediaTypeImageIndex: content, err := FetchAll(ctx, fetcher, node) if err != nil { return nil, err } var index ocispec.Index if err := json.Unmarshal(content, &index); err != nil { return nil, err } var nodes []ocispec.Descriptor if index.Subject != nil { nodes = append(nodes, *index.Subject) } return append(nodes, index.Manifests...), nil case spec.MediaTypeArtifactManifest: content, err := FetchAll(ctx, fetcher, node) if err != nil { return nil, err } var manifest spec.Artifact if err := json.Unmarshal(content, &manifest); err != nil { return nil, err } var nodes []ocispec.Descriptor if manifest.Subject != nil { nodes = append(nodes, *manifest.Subject) } return append(nodes, manifest.Blobs...), nil } return nil, nil } oras-go-2.5.0/content/graph_test.go000066400000000000000000000276401457674530300172550ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package content_test import ( "bytes" "context" "encoding/json" "reflect" "testing" "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/internal/cas" "oras.land/oras-go/v2/internal/docker" "oras.land/oras-go/v2/internal/spec" ) func TestSuccessors_dockerManifest(t *testing.T) { storage := cas.NewMemory() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(docker.MediaTypeManifest, manifestJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 appendBlob(ocispec.MediaTypeImageLayer, []byte("hello")) // Blob 3 generateManifest(descs[0], descs[1:4]...) // Blob 4 ctx := context.Background() for i := range blobs { err := storage.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } // test Successors manifestDesc := descs[4] got, err := content.Successors(ctx, storage, manifestDesc) if err != nil { t.Fatal("Successors() error =", err) } if want := descs[0:4]; !reflect.DeepEqual(got, want) { t.Errorf("Successors() = %v, want %v", got, want) } } func TestSuccessors_imageManifest(t *testing.T) { storage := cas.NewMemory() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(subject *ocispec.Descriptor, config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Subject: subject, Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 appendBlob(ocispec.MediaTypeImageLayer, []byte("hello")) // Blob 3 generateManifest(nil, descs[0], descs[1:4]...) // Blob 4 appendBlob(ocispec.MediaTypeImageConfig, []byte("{}")) // Blob 5 appendBlob("test/sig", []byte("sig")) // Blob 6 generateManifest(&descs[4], descs[5], descs[6]) // Blob 7 ctx := context.Background() for i := range blobs { err := storage.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } // test Successors: image manifest without a subject manifestDesc := descs[4] got, err := content.Successors(ctx, storage, manifestDesc) if err != nil { t.Fatal("Successors() error =", err) } if want := descs[0:4]; !reflect.DeepEqual(got, want) { t.Errorf("Successors() = %v, want %v", got, want) } // test Successors: image manifest with a subject manifestDesc = descs[7] got, err = content.Successors(ctx, storage, manifestDesc) if err != nil { t.Fatal("Successors() error =", err) } if want := descs[4:7]; !reflect.DeepEqual(got, want) { t.Errorf("Successors() = %v, want %v", got, want) } } func TestSuccessors_dockerManifestList(t *testing.T) { storage := cas.NewMemory() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(docker.MediaTypeManifest, manifestJSON) } generateIndex := func(manifests ...ocispec.Descriptor) { index := ocispec.Index{ Manifests: manifests, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatal(err) } appendBlob(docker.MediaTypeManifestList, indexJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 appendBlob(ocispec.MediaTypeImageLayer, []byte("hello")) // Blob 3 generateManifest(descs[0], descs[1:3]...) // Blob 4 generateManifest(descs[0], descs[3]) // Blob 5 generateIndex(descs[4:6]...) // Blob 6 ctx := context.Background() for i := range blobs { err := storage.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } // test Successors manifestDesc := descs[6] got, err := content.Successors(ctx, storage, manifestDesc) if err != nil { t.Fatal("Successors() error =", err) } if want := descs[4:6]; !reflect.DeepEqual(got, want) { t.Errorf("Successors() = %v, want %v", got, want) } } func TestSuccessors_imageIndex(t *testing.T) { storage := cas.NewMemory() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(subject *ocispec.Descriptor, config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Subject: subject, Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } generateIndex := func(subject *ocispec.Descriptor, manifests ...ocispec.Descriptor) { index := ocispec.Index{ Subject: subject, Manifests: manifests, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageIndex, indexJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 appendBlob(ocispec.MediaTypeImageLayer, []byte("hello")) // Blob 3 generateManifest(nil, descs[0], descs[1:3]...) // Blob 4 generateManifest(nil, descs[0], descs[3]) // Blob 5 appendBlob(ocispec.MediaTypeImageConfig, []byte("{}")) // Blob 6 appendBlob("test/sig", []byte("sig")) // Blob 7 generateManifest(&descs[4], descs[5], descs[6]) // Blob 8 generateIndex(&descs[8], descs[4:6]...) // Blob 9 ctx := context.Background() for i := range blobs { err := storage.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } // test Successors manifestDesc := descs[9] got, err := content.Successors(ctx, storage, manifestDesc) if err != nil { t.Fatal("Successors() error =", err) } if want := append([]ocispec.Descriptor{descs[8]}, descs[4:6]...); !reflect.DeepEqual(got, want) { t.Errorf("Successors() = %v, want %v", got, want) } } func TestSuccessors_artifactManifest(t *testing.T) { storage := cas.NewMemory() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateArtifactManifest := func(subject *ocispec.Descriptor, blobs ...ocispec.Descriptor) { manifest := spec.Artifact{ Subject: subject, Blobs: blobs, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(spec.MediaTypeArtifactManifest, manifestJSON) } appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("hello")) // Blob 2 generateArtifactManifest(nil, descs[0:3]...) // Blob 3 appendBlob("test/sig", []byte("sig")) // Blob 4 generateArtifactManifest(&descs[3], descs[4]) // Blob 5 ctx := context.Background() for i := range blobs { err := storage.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } // test Successors: image manifest without a subject manifestDesc := descs[3] got, err := content.Successors(ctx, storage, manifestDesc) if err != nil { t.Fatal("Successors() error =", err) } if want := descs[0:3]; !reflect.DeepEqual(got, want) { t.Errorf("Successors() = %v, want %v", got, want) } // test Successors: image manifest with a subject manifestDesc = descs[5] got, err = content.Successors(ctx, storage, manifestDesc) if err != nil { t.Fatal("Successors() error =", err) } if want := descs[3:5]; !reflect.DeepEqual(got, want) { t.Errorf("Successors() = %v, want %v", got, want) } } func TestSuccessors_otherMediaType(t *testing.T) { storage := cas.NewMemory() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(mediaType string, config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(mediaType, manifestJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 appendBlob(ocispec.MediaTypeImageLayer, []byte("hello")) // Blob 3 generateManifest("whatever", descs[0], descs[1:4]...) // Blob 4 ctx := context.Background() for i := range blobs { err := storage.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } // test Successors: other media type manifestDesc := descs[4] got, err := content.Successors(ctx, storage, manifestDesc) if err != nil { t.Fatal("Successors() error =", err) } if got != nil { t.Errorf("Successors() = %v, want nil", got) } } oras-go-2.5.0/content/limitedstorage.go000066400000000000000000000027431457674530300201260ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package content import ( "context" "fmt" "io" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/errdef" ) // LimitedStorage represents a CAS with a push size limit. type LimitedStorage struct { Storage // underlying storage PushLimit int64 // max size for push } // Push pushes the content, matching the expected descriptor. // The size of the content cannot exceed the push size limit. func (ls *LimitedStorage) Push(ctx context.Context, expected ocispec.Descriptor, content io.Reader) error { if expected.Size > ls.PushLimit { return fmt.Errorf( "content size %v exceeds push size limit %v: %w", expected.Size, ls.PushLimit, errdef.ErrSizeExceedsLimit) } return ls.Storage.Push(ctx, expected, io.LimitReader(content, expected.Size)) } // LimitStorage returns a storage with a push size limit. func LimitStorage(s Storage, n int64) *LimitedStorage { return &LimitedStorage{s, n} } oras-go-2.5.0/content/memory/000077500000000000000000000000001457674530300160655ustar00rootroot00000000000000oras-go-2.5.0/content/memory/memory.go000066400000000000000000000061421457674530300177270ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // Package memory provides implementation of a memory backed content store. package memory import ( "context" "fmt" "io" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/errdef" "oras.land/oras-go/v2/internal/cas" "oras.land/oras-go/v2/internal/graph" "oras.land/oras-go/v2/internal/resolver" ) // Store represents a memory based store, which implements `oras.Target`. type Store struct { storage content.Storage resolver content.TagResolver graph *graph.Memory } // New creates a new memory based store. func New() *Store { return &Store{ storage: cas.NewMemory(), resolver: resolver.NewMemory(), graph: graph.NewMemory(), } } // Fetch fetches the content identified by the descriptor. func (s *Store) Fetch(ctx context.Context, target ocispec.Descriptor) (io.ReadCloser, error) { return s.storage.Fetch(ctx, target) } // Push pushes the content, matching the expected descriptor. func (s *Store) Push(ctx context.Context, expected ocispec.Descriptor, reader io.Reader) error { if err := s.storage.Push(ctx, expected, reader); err != nil { return err } // index predecessors. // there is no data consistency issue as long as deletion is not implemented // for the memory store. return s.graph.Index(ctx, s.storage, expected) } // Exists returns true if the described content exists. func (s *Store) Exists(ctx context.Context, target ocispec.Descriptor) (bool, error) { return s.storage.Exists(ctx, target) } // Resolve resolves a reference to a descriptor. func (s *Store) Resolve(ctx context.Context, reference string) (ocispec.Descriptor, error) { return s.resolver.Resolve(ctx, reference) } // Tag tags a descriptor with a reference string. // Returns ErrNotFound if the tagged content does not exist. func (s *Store) Tag(ctx context.Context, desc ocispec.Descriptor, reference string) error { exists, err := s.storage.Exists(ctx, desc) if err != nil { return err } if !exists { return fmt.Errorf("%s: %s: %w", desc.Digest, desc.MediaType, errdef.ErrNotFound) } return s.resolver.Tag(ctx, desc, reference) } // Predecessors returns the nodes directly pointing to the current node. // Predecessors returns nil without error if the node does not exists in the // store. // Like other operations, calling Predecessors() is go-routine safe. However, // it does not necessarily correspond to any consistent snapshot of the stored // contents. func (s *Store) Predecessors(ctx context.Context, node ocispec.Descriptor) ([]ocispec.Descriptor, error) { return s.graph.Predecessors(ctx, node) } oras-go-2.5.0/content/memory/memory_test.go000066400000000000000000000255701457674530300207740ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package memory import ( "bytes" "context" _ "crypto/sha256" "encoding/json" "errors" "fmt" "io" "reflect" "strings" "testing" "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "golang.org/x/sync/errgroup" "oras.land/oras-go/v2" "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/errdef" "oras.land/oras-go/v2/internal/cas" "oras.land/oras-go/v2/internal/resolver" "oras.land/oras-go/v2/internal/spec" ) func TestStoreInterface(t *testing.T) { var store interface{} = &Store{} if _, ok := store.(oras.Target); !ok { t.Error("&Store{} does not conform oras.Target") } if _, ok := store.(content.PredecessorFinder); !ok { t.Error("&Store{} does not conform content.PredecessorFinder") } } func TestStoreSuccess(t *testing.T) { content := []byte("hello world") desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), } ref := "foobar" s := New() ctx := context.Background() err := s.Push(ctx, desc, bytes.NewReader(content)) if err != nil { t.Fatal("Store.Push() error =", err) } err = s.Tag(ctx, desc, ref) if err != nil { t.Fatal("Store.Tag() error =", err) } gotDesc, err := s.Resolve(ctx, ref) if err != nil { t.Fatal("Store.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, desc) { t.Errorf("Store.Resolve() = %v, want %v", gotDesc, desc) } internalResolver := s.resolver.(*resolver.Memory) if got := len(internalResolver.Map()); got != 1 { t.Errorf("resolver.Map() = %v, want %v", got, 1) } exists, err := s.Exists(ctx, desc) if err != nil { t.Fatal("Store.Exists() error =", err) } if !exists { t.Errorf("Store.Exists() = %v, want %v", exists, true) } rc, err := s.Fetch(ctx, desc) if err != nil { t.Fatal("Store.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Store.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got, content) { t.Errorf("Store.Fetch() = %v, want %v", got, content) } internalStorage := s.storage.(*cas.Memory) if got := len(internalStorage.Map()); got != 1 { t.Errorf("storage.Map() = %v, want %v", got, 1) } } func TestStoreContentNotFound(t *testing.T) { content := []byte("hello world") desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), } s := New() ctx := context.Background() exists, err := s.Exists(ctx, desc) if err != nil { t.Error("Store.Exists() error =", err) } if exists { t.Errorf("Store.Exists() = %v, want %v", exists, false) } _, err = s.Fetch(ctx, desc) if !errors.Is(err, errdef.ErrNotFound) { t.Errorf("Store.Fetch() error = %v, want %v", err, errdef.ErrNotFound) } } func TestStoreContentAlreadyExists(t *testing.T) { content := []byte("hello world") desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), } s := New() ctx := context.Background() err := s.Push(ctx, desc, bytes.NewReader(content)) if err != nil { t.Fatal("Store.Push() error =", err) } err = s.Push(ctx, desc, bytes.NewReader(content)) if !errors.Is(err, errdef.ErrAlreadyExists) { t.Errorf("Store.Push() error = %v, want %v", err, errdef.ErrAlreadyExists) } } func TestStoreContentBadPush(t *testing.T) { content := []byte("hello world") desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), } s := New() ctx := context.Background() err := s.Push(ctx, desc, strings.NewReader("foobar")) if err == nil { t.Errorf("Store.Push() error = %v, wantErr %v", err, true) } } func TestStoreTagNotFound(t *testing.T) { ref := "foobar" s := New() ctx := context.Background() _, err := s.Resolve(ctx, ref) if !errors.Is(err, errdef.ErrNotFound) { t.Errorf("Store.Resolve() error = %v, want %v", err, errdef.ErrNotFound) } } func TestStoreTagUnknownContent(t *testing.T) { content := []byte("hello world") desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), } ref := "foobar" s := New() ctx := context.Background() err := s.Tag(ctx, desc, ref) if !errors.Is(err, errdef.ErrNotFound) { t.Errorf("Store.Resolve() error = %v, want %v", err, errdef.ErrNotFound) } } func TestStoreRepeatTag(t *testing.T) { generate := func(content []byte) ocispec.Descriptor { return ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), } } ref := "foobar" s := New() ctx := context.Background() // get internal resolver internalResolver := s.resolver.(*resolver.Memory) // initial tag content := []byte("hello world") desc := generate(content) err := s.Push(ctx, desc, bytes.NewReader(content)) if err != nil { t.Fatal("Store.Push() error =", err) } err = s.Tag(ctx, desc, ref) if err != nil { t.Fatal("Store.Tag() error =", err) } gotDesc, err := s.Resolve(ctx, ref) if err != nil { t.Fatal("Store.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, desc) { t.Errorf("Store.Resolve() = %v, want %v", gotDesc, desc) } if got := len(internalResolver.Map()); got != 1 { t.Errorf("resolver.Map() = %v, want %v", got, 1) } // repeat tag content = []byte("foo") desc = generate(content) err = s.Push(ctx, desc, bytes.NewReader(content)) if err != nil { t.Fatal("Store.Push() error =", err) } err = s.Tag(ctx, desc, ref) if err != nil { t.Fatal("Store.Tag() error =", err) } gotDesc, err = s.Resolve(ctx, ref) if err != nil { t.Fatal("Store.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, desc) { t.Errorf("Store.Resolve() = %v, want %v", gotDesc, desc) } if got := len(internalResolver.Map()); got != 1 { t.Errorf("resolver.Map() = %v, want %v", got, 1) } // repeat tag content = []byte("bar") desc = generate(content) err = s.Push(ctx, desc, bytes.NewReader(content)) if err != nil { t.Fatal("Store.Push() error =", err) } err = s.Tag(ctx, desc, ref) if err != nil { t.Fatal("Store.Tag() error =", err) } gotDesc, err = s.Resolve(ctx, ref) if err != nil { t.Fatal("Store.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, desc) { t.Errorf("Store.Resolve() = %v, want %v", gotDesc, desc) } if got := len(internalResolver.Map()); got != 1 { t.Errorf("resolver.Map() = %v, want %v", got, 1) } } func TestStorePredecessors(t *testing.T) { s := New() ctx := context.Background() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } generateIndex := func(manifests ...ocispec.Descriptor) { index := ocispec.Index{ Manifests: manifests, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageIndex, indexJSON) } generateArtifactManifest := func(subject ocispec.Descriptor, blobs ...ocispec.Descriptor) { var manifest spec.Artifact manifest.Subject = &subject manifest.Blobs = append(manifest.Blobs, blobs...) manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(spec.MediaTypeArtifactManifest, manifestJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 appendBlob(ocispec.MediaTypeImageLayer, []byte("hello")) // Blob 3 generateManifest(descs[0], descs[1:3]...) // Blob 4 generateManifest(descs[0], descs[3]) // Blob 5 generateManifest(descs[0], descs[1:4]...) // Blob 6 generateIndex(descs[4:6]...) // Blob 7 generateIndex(descs[6]) // Blob 8 generateIndex() // Blob 9 generateIndex(descs[7:10]...) // Blob 10 appendBlob(ocispec.MediaTypeImageLayer, []byte("sig_1")) // Blob 11 generateArtifactManifest(descs[6], descs[11]) // Blob 12 appendBlob(ocispec.MediaTypeImageLayer, []byte("sig_2")) // Blob 13 generateArtifactManifest(descs[10], descs[13]) // Blob 14 eg, egCtx := errgroup.WithContext(ctx) for i := range blobs { eg.Go(func(i int) func() error { return func() error { err := s.Push(egCtx, descs[i], bytes.NewReader(blobs[i])) if err != nil { return fmt.Errorf("failed to push test content to src: %d: %v", i, err) } return nil } }(i)) } if err := eg.Wait(); err != nil { t.Fatal(err) } // verify predecessors wants := [][]ocispec.Descriptor{ descs[4:7], // Blob 0 {descs[4], descs[6]}, // Blob 1 {descs[4], descs[6]}, // Blob 2 {descs[5], descs[6]}, // Blob 3 {descs[7]}, // Blob 4 {descs[7]}, // Blob 5 {descs[8], descs[12]}, // Blob 6 {descs[10]}, // Blob 7 {descs[10]}, // Blob 8 {descs[10]}, // Blob 9 {descs[14]}, // Blob 10 {descs[12]}, // Blob 11 nil, // Blob 12 {descs[14]}, // Blob 13 nil, // Blob 14 } for i, want := range wants { predecessors, err := s.Predecessors(ctx, descs[i]) if err != nil { t.Errorf("Store.Predecessors(%d) error = %v", i, err) } if !equalDescriptorSet(predecessors, want) { t.Errorf("Store.Predecessors(%d) = %v, want %v", i, predecessors, want) } } } func equalDescriptorSet(actual []ocispec.Descriptor, expected []ocispec.Descriptor) bool { if len(actual) != len(expected) { return false } contains := func(node ocispec.Descriptor) bool { for _, candidate := range actual { if reflect.DeepEqual(candidate, node) { return true } } return false } for _, node := range expected { if !contains(node) { return false } } return true } oras-go-2.5.0/content/oci/000077500000000000000000000000001457674530300153275ustar00rootroot00000000000000oras-go-2.5.0/content/oci/oci.go000066400000000000000000000433751457674530300164440ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // Package oci provides access to an OCI content store. // Reference: https://github.com/opencontainers/image-spec/blob/v1.1.0/image-layout.md package oci import ( "context" "encoding/json" "errors" "fmt" "io" "maps" "os" "path" "path/filepath" "sync" "github.com/opencontainers/go-digest" specs "github.com/opencontainers/image-spec/specs-go" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/errdef" "oras.land/oras-go/v2/internal/container/set" "oras.land/oras-go/v2/internal/descriptor" "oras.land/oras-go/v2/internal/graph" "oras.land/oras-go/v2/internal/manifestutil" "oras.land/oras-go/v2/internal/resolver" "oras.land/oras-go/v2/registry" ) // Store implements `oras.Target`, and represents a content store // based on file system with the OCI-Image layout. // Reference: https://github.com/opencontainers/image-spec/blob/v1.1.0/image-layout.md type Store struct { // AutoSaveIndex controls if the OCI store will automatically save the index // file when needed. // - If AutoSaveIndex is set to true, the OCI store will automatically save // the changes to `index.json` when // 1. pushing a manifest // 2. calling Tag() or Delete() // - If AutoSaveIndex is set to false, it's the caller's responsibility // to manually call SaveIndex() when needed. // - Default value: true. AutoSaveIndex bool // AutoGC controls if the OCI store will automatically clean dangling // (unreferenced) blobs created by the Delete() operation. This includes the // referrers and the unreferenced successor blobs of the deleted content. // Tagged manifests will not be deleted. // - Default value: true. AutoGC bool root string indexPath string index *ocispec.Index storage *Storage tagResolver *resolver.Memory graph *graph.Memory // sync ensures that most operations can be done concurrently, while Delete // has the exclusive access to Store if a delete operation is underway. // Operations such as Fetch, Push use sync.RLock(), while Delete uses // sync.Lock(). sync sync.RWMutex // indexLock ensures that only one go-routine is writing to the index. indexLock sync.Mutex } // New creates a new OCI store with context.Background(). func New(root string) (*Store, error) { return NewWithContext(context.Background(), root) } // NewWithContext creates a new OCI store. func NewWithContext(ctx context.Context, root string) (*Store, error) { rootAbs, err := filepath.Abs(root) if err != nil { return nil, fmt.Errorf("failed to resolve absolute path for %s: %w", root, err) } storage, err := NewStorage(rootAbs) if err != nil { return nil, fmt.Errorf("failed to create storage: %w", err) } store := &Store{ AutoSaveIndex: true, AutoGC: true, root: rootAbs, indexPath: filepath.Join(rootAbs, ocispec.ImageIndexFile), storage: storage, tagResolver: resolver.NewMemory(), graph: graph.NewMemory(), } if err := ensureDir(filepath.Join(rootAbs, ocispec.ImageBlobsDir)); err != nil { return nil, err } if err := store.ensureOCILayoutFile(); err != nil { return nil, fmt.Errorf("invalid OCI Image Layout: %w", err) } if err := store.loadIndexFile(ctx); err != nil { return nil, fmt.Errorf("invalid OCI Image Index: %w", err) } return store, nil } // Fetch fetches the content identified by the descriptor. It returns an io.ReadCloser. // It's recommended to close the io.ReadCloser before a Delete operation, otherwise // Delete may fail (for example on NTFS file systems). func (s *Store) Fetch(ctx context.Context, target ocispec.Descriptor) (io.ReadCloser, error) { s.sync.RLock() defer s.sync.RUnlock() return s.storage.Fetch(ctx, target) } // Push pushes the content, matching the expected descriptor. func (s *Store) Push(ctx context.Context, expected ocispec.Descriptor, reader io.Reader) error { s.sync.RLock() defer s.sync.RUnlock() if err := s.storage.Push(ctx, expected, reader); err != nil { return err } if err := s.graph.Index(ctx, s.storage, expected); err != nil { return err } if descriptor.IsManifest(expected) { // tag by digest return s.tag(ctx, expected, expected.Digest.String()) } return nil } // Exists returns true if the described content exists. func (s *Store) Exists(ctx context.Context, target ocispec.Descriptor) (bool, error) { s.sync.RLock() defer s.sync.RUnlock() return s.storage.Exists(ctx, target) } // Delete deletes the content matching the descriptor from the store. Delete may // fail on certain systems (i.e. NTFS), if there is a process (i.e. an unclosed // Reader) using target. If s.AutoGC is set to true, Delete will recursively // remove the dangling blobs caused by the current delete. If s.AutoDeleteReferrers // is set to true, Delete will recursively remove the referrers of the manifests // being deleted. func (s *Store) Delete(ctx context.Context, target ocispec.Descriptor) error { s.sync.Lock() defer s.sync.Unlock() deleteQueue := []ocispec.Descriptor{target} for len(deleteQueue) > 0 { head := deleteQueue[0] deleteQueue = deleteQueue[1:] // get referrers if applicable if s.AutoGC && descriptor.IsManifest(head) { referrers, err := registry.Referrers(ctx, &unsafeStore{s}, head, "") if err != nil { return err } deleteQueue = append(deleteQueue, referrers...) } // delete the head of queue danglings, err := s.delete(ctx, head) if err != nil { return err } if s.AutoGC { for _, d := range danglings { // do not delete existing tagged manifests if !s.isTagged(d) { deleteQueue = append(deleteQueue, d) } } } } return nil } // delete deletes one node and returns the dangling nodes caused by the delete. func (s *Store) delete(ctx context.Context, target ocispec.Descriptor) ([]ocispec.Descriptor, error) { resolvers := s.tagResolver.Map() untagged := false for reference, desc := range resolvers { if content.Equal(desc, target) { s.tagResolver.Untag(reference) untagged = true } } danglings := s.graph.Remove(target) if untagged && s.AutoSaveIndex { err := s.saveIndex() if err != nil { return nil, err } } if err := s.storage.Delete(ctx, target); err != nil { return nil, err } return danglings, nil } // Tag tags a descriptor with a reference string. // reference should be a valid tag (e.g. "latest"). // Reference: https://github.com/opencontainers/image-spec/blob/v1.1.0/image-layout.md#indexjson-file func (s *Store) Tag(ctx context.Context, desc ocispec.Descriptor, reference string) error { s.sync.RLock() defer s.sync.RUnlock() if err := validateReference(reference); err != nil { return err } exists, err := s.storage.Exists(ctx, desc) if err != nil { return err } if !exists { return fmt.Errorf("%s: %s: %w", desc.Digest, desc.MediaType, errdef.ErrNotFound) } return s.tag(ctx, desc, reference) } // tag tags a descriptor with a reference string. func (s *Store) tag(ctx context.Context, desc ocispec.Descriptor, reference string) error { dgst := desc.Digest.String() if reference != dgst { // also tag desc by its digest if err := s.tagResolver.Tag(ctx, desc, dgst); err != nil { return err } } if err := s.tagResolver.Tag(ctx, desc, reference); err != nil { return err } if s.AutoSaveIndex { return s.saveIndex() } return nil } // Resolve resolves a reference to a descriptor. If the reference to be resolved // is a tag, the returned descriptor will be a full descriptor declared by // github.com/opencontainers/image-spec/specs-go/v1. If the reference is a // digest the returned descriptor will be a plain descriptor (containing only // the digest, media type and size). func (s *Store) Resolve(ctx context.Context, reference string) (ocispec.Descriptor, error) { s.sync.RLock() defer s.sync.RUnlock() if reference == "" { return ocispec.Descriptor{}, errdef.ErrMissingReference } // attempt resolving manifest desc, err := s.tagResolver.Resolve(ctx, reference) if err != nil { if errors.Is(err, errdef.ErrNotFound) { // attempt resolving blob return resolveBlob(os.DirFS(s.root), reference) } return ocispec.Descriptor{}, err } if reference == desc.Digest.String() { return descriptor.Plain(desc), nil } return desc, nil } func (s *Store) Untag(ctx context.Context, reference string) error { if reference == "" { return errdef.ErrMissingReference } s.sync.RLock() defer s.sync.RUnlock() desc, err := s.tagResolver.Resolve(ctx, reference) if err != nil { return fmt.Errorf("resolving reference %q: %w", reference, err) } if reference == desc.Digest.String() { return fmt.Errorf("reference %q is a digest and not a tag: %w", reference, errdef.ErrInvalidReference) } s.tagResolver.Untag(reference) if s.AutoSaveIndex { return s.saveIndex() } return nil } // Predecessors returns the nodes directly pointing to the current node. // Predecessors returns nil without error if the node does not exists in the // store. func (s *Store) Predecessors(ctx context.Context, node ocispec.Descriptor) ([]ocispec.Descriptor, error) { s.sync.RLock() defer s.sync.RUnlock() return s.graph.Predecessors(ctx, node) } // Tags lists the tags presented in the `index.json` file of the OCI layout, // returned in ascending order. // If `last` is NOT empty, the entries in the response start after the tag // specified by `last`. Otherwise, the response starts from the top of the tags // list. // // See also `Tags()` in the package `registry`. func (s *Store) Tags(ctx context.Context, last string, fn func(tags []string) error) error { s.sync.RLock() defer s.sync.RUnlock() return listTags(s.tagResolver, last, fn) } // ensureOCILayoutFile ensures the `oci-layout` file. func (s *Store) ensureOCILayoutFile() error { layoutFilePath := filepath.Join(s.root, ocispec.ImageLayoutFile) layoutFile, err := os.Open(layoutFilePath) if err != nil { if !os.IsNotExist(err) { return fmt.Errorf("failed to open OCI layout file: %w", err) } layout := ocispec.ImageLayout{ Version: ocispec.ImageLayoutVersion, } layoutJSON, err := json.Marshal(layout) if err != nil { return fmt.Errorf("failed to marshal OCI layout file: %w", err) } return os.WriteFile(layoutFilePath, layoutJSON, 0666) } defer layoutFile.Close() var layout ocispec.ImageLayout err = json.NewDecoder(layoutFile).Decode(&layout) if err != nil { return fmt.Errorf("failed to decode OCI layout file: %w", err) } return validateOCILayout(&layout) } // loadIndexFile reads index.json from the file system. // Create index.json if it does not exist. func (s *Store) loadIndexFile(ctx context.Context) error { indexFile, err := os.Open(s.indexPath) if err != nil { if !os.IsNotExist(err) { return fmt.Errorf("failed to open index file: %w", err) } // write index.json if it does not exist s.index = &ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value }, Manifests: []ocispec.Descriptor{}, } return s.writeIndexFile() } defer indexFile.Close() var index ocispec.Index if err := json.NewDecoder(indexFile).Decode(&index); err != nil { return fmt.Errorf("failed to decode index file: %w", err) } s.index = &index return loadIndex(ctx, s.index, s.storage, s.tagResolver, s.graph) } // SaveIndex writes the `index.json` file to the file system. // - If AutoSaveIndex is set to true (default value), // the OCI store will automatically save the changes to `index.json` // on Tag() and Delete() calls, and when pushing a manifest. // - If AutoSaveIndex is set to false, it's the caller's responsibility // to manually call this method when needed. func (s *Store) SaveIndex() error { s.sync.RLock() defer s.sync.RUnlock() return s.saveIndex() } func (s *Store) saveIndex() error { s.indexLock.Lock() defer s.indexLock.Unlock() var manifests []ocispec.Descriptor tagged := set.New[digest.Digest]() refMap := s.tagResolver.Map() // 1. Add descriptors that are associated with tags // Note: One descriptor can be associated with multiple tags. for ref, desc := range refMap { if ref != desc.Digest.String() { annotations := make(map[string]string, len(desc.Annotations)+1) maps.Copy(annotations, desc.Annotations) annotations[ocispec.AnnotationRefName] = ref desc.Annotations = annotations manifests = append(manifests, desc) // mark the digest as tagged for deduplication in step 2 tagged.Add(desc.Digest) } } // 2. Add descriptors that are not associated with any tag for ref, desc := range refMap { if ref == desc.Digest.String() && !tagged.Contains(desc.Digest) { // skip tagged ones since they have been added in step 1 manifests = append(manifests, deleteAnnotationRefName(desc)) } } s.index.Manifests = manifests return s.writeIndexFile() } // writeIndexFile writes the `index.json` file. func (s *Store) writeIndexFile() error { indexJSON, err := json.Marshal(s.index) if err != nil { return fmt.Errorf("failed to marshal index file: %w", err) } return os.WriteFile(s.indexPath, indexJSON, 0666) } // GC removes garbage from Store. Unsaved index will be lost. To prevent unexpected // loss, call SaveIndex() before GC or set AutoSaveIndex to true. // The garbage to be cleaned are: // - unreferenced (dangling) blobs in Store which have no predecessors // - garbage blobs in the storage whose metadata is not stored in Store func (s *Store) GC(ctx context.Context) error { s.sync.Lock() defer s.sync.Unlock() // get reachable nodes by reloading the index err := s.gcIndex(ctx) if err != nil { return fmt.Errorf("unable to reload index: %w", err) } reachableNodes := s.graph.DigestSet() // clean up garbage blobs in the storage rootpath := filepath.Join(s.root, ocispec.ImageBlobsDir) algDirs, err := os.ReadDir(rootpath) if err != nil { return err } for _, algDir := range algDirs { if !algDir.IsDir() { continue } alg := algDir.Name() // skip unsupported directories if !isKnownAlgorithm(alg) { continue } algPath := path.Join(rootpath, alg) digestEntries, err := os.ReadDir(algPath) if err != nil { return err } for _, digestEntry := range digestEntries { if err := isContextDone(ctx); err != nil { return err } dgst := digestEntry.Name() blobDigest := digest.NewDigestFromEncoded(digest.Algorithm(alg), dgst) if err := blobDigest.Validate(); err != nil { // skip irrelevant content continue } if !reachableNodes.Contains(blobDigest) { // remove the blob from storage if it does not exist in Store err = os.Remove(path.Join(algPath, dgst)) if err != nil { return err } } } } return nil } // gcIndex reloads the index and updates metadata. Information of untagged blobs // are cleaned and only tagged blobs remain. func (s *Store) gcIndex(ctx context.Context) error { tagResolver := resolver.NewMemory() graph := graph.NewMemory() tagged := set.New[digest.Digest]() // index tagged manifests refMap := s.tagResolver.Map() for ref, desc := range refMap { if ref == desc.Digest.String() { continue } if err := tagResolver.Tag(ctx, deleteAnnotationRefName(desc), desc.Digest.String()); err != nil { return err } if err := tagResolver.Tag(ctx, desc, ref); err != nil { return err } plain := descriptor.Plain(desc) if err := graph.IndexAll(ctx, s.storage, plain); err != nil { return err } tagged.Add(desc.Digest) } // index referrer manifests for ref, desc := range refMap { if ref != desc.Digest.String() || tagged.Contains(desc.Digest) { continue } // check if the referrers manifest can traverse to the existing graph subject := &desc for { subject, err := manifestutil.Subject(ctx, s.storage, *subject) if err != nil { return err } if subject == nil { break } if graph.Exists(*subject) { if err := tagResolver.Tag(ctx, deleteAnnotationRefName(desc), desc.Digest.String()); err != nil { return err } plain := descriptor.Plain(desc) if err := graph.IndexAll(ctx, s.storage, plain); err != nil { return err } break } } } s.tagResolver = tagResolver s.graph = graph return nil } // isTagged checks if the blob given by the descriptor is tagged. func (s *Store) isTagged(desc ocispec.Descriptor) bool { tagSet := s.tagResolver.TagSet(desc) if tagSet.Contains(string(desc.Digest)) { return len(tagSet) > 1 } return len(tagSet) > 0 } // unsafeStore is used to bypass lock restrictions in Delete. type unsafeStore struct { *Store } func (s *unsafeStore) Fetch(ctx context.Context, target ocispec.Descriptor) (io.ReadCloser, error) { return s.storage.Fetch(ctx, target) } func (s *unsafeStore) Predecessors(ctx context.Context, node ocispec.Descriptor) ([]ocispec.Descriptor, error) { return s.graph.Predecessors(ctx, node) } // isContextDone returns an error if the context is done. // Reference: https://pkg.go.dev/context#Context func isContextDone(ctx context.Context) error { select { case <-ctx.Done(): return ctx.Err() default: return nil } } // validateReference validates ref. func validateReference(ref string) error { if ref == "" { return errdef.ErrMissingReference } // TODO: may enforce more strict validation if needed. return nil } // isKnownAlgorithm checks is a string is a supported hash algorithm func isKnownAlgorithm(alg string) bool { switch digest.Algorithm(alg) { case digest.SHA256, digest.SHA512, digest.SHA384: return true default: return false } } oras-go-2.5.0/content/oci/oci_test.go000066400000000000000000002433431457674530300175000ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package oci import ( "bytes" "context" _ "crypto/sha256" "encoding/json" "errors" "fmt" "io" "os" "path" "path/filepath" "reflect" "strconv" "strings" "sync/atomic" "testing" "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "golang.org/x/sync/errgroup" "oras.land/oras-go/v2" "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/content/memory" "oras.land/oras-go/v2/errdef" "oras.land/oras-go/v2/internal/cas" "oras.land/oras-go/v2/internal/descriptor" "oras.land/oras-go/v2/internal/spec" "oras.land/oras-go/v2/registry" ) // storageTracker tracks storage API counts. type storageTracker struct { content.Storage fetch int64 push int64 exists int64 } func (t *storageTracker) Fetch(ctx context.Context, target ocispec.Descriptor) (io.ReadCloser, error) { atomic.AddInt64(&t.fetch, 1) return t.Storage.Fetch(ctx, target) } func (t *storageTracker) Push(ctx context.Context, expected ocispec.Descriptor, content io.Reader) error { atomic.AddInt64(&t.push, 1) return t.Storage.Push(ctx, expected, content) } func (t *storageTracker) Exists(ctx context.Context, target ocispec.Descriptor) (bool, error) { atomic.AddInt64(&t.exists, 1) return t.Storage.Exists(ctx, target) } func TestStoreInterface(t *testing.T) { var store interface{} = &Store{} if _, ok := store.(oras.GraphTarget); !ok { t.Error("&Store{} does not conform oras.Target") } if _, ok := store.(registry.TagLister); !ok { t.Error("&Store{} does not conform registry.TagLister") } } func TestStore_Success(t *testing.T) { blob := []byte("test") blobDesc := content.NewDescriptorFromBytes("test", blob) manifest := []byte(`{"layers":[]}`) manifestDesc := content.NewDescriptorFromBytes(ocispec.MediaTypeImageManifest, manifest) ref := "foobar" tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("New() error =", err) } ctx := context.Background() // validate layout layoutFilePath := filepath.Join(tempDir, ocispec.ImageLayoutFile) layoutFile, err := os.Open(layoutFilePath) if err != nil { t.Errorf("error opening layout file, error = %v", err) } defer layoutFile.Close() var layout *ocispec.ImageLayout err = json.NewDecoder(layoutFile).Decode(&layout) if err != nil { t.Fatal("error decoding layout, error =", err) } if want := ocispec.ImageLayoutVersion; layout.Version != want { t.Errorf("layout.Version = %s, want %s", layout.Version, want) } // validate index.json indexFilePath := filepath.Join(tempDir, "index.json") indexFile, err := os.Open(indexFilePath) if err != nil { t.Errorf("error opening layout file, error = %v", err) } defer indexFile.Close() var index *ocispec.Index err = json.NewDecoder(indexFile).Decode(&index) if err != nil { t.Fatal("error decoding index.json, error =", err) } if want := 2; index.SchemaVersion != want { t.Errorf("index.SchemaVersion = %v, want %v", index.SchemaVersion, want) } // test push blob err = s.Push(ctx, blobDesc, bytes.NewReader(blob)) if err != nil { t.Fatal("Store.Push() error =", err) } internalResolver := s.tagResolver if got, want := len(internalResolver.Map()), 0; got != want { t.Errorf("resolver.Map() = %v, want %v", got, want) } // test push manifest err = s.Push(ctx, manifestDesc, bytes.NewReader(manifest)) if err != nil { t.Fatal("Store.Push() error =", err) } if got, want := len(internalResolver.Map()), 1; got != want { t.Errorf("resolver.Map() = %v, want %v", got, want) } // test resolving blob by digest gotDesc, err := s.Resolve(ctx, blobDesc.Digest.String()) if err != nil { t.Fatal("Store.Resolve() error =", err) } if want := blobDesc; gotDesc.Size != want.Size || gotDesc.Digest != want.Digest { t.Errorf("Store.Resolve() = %v, want %v", gotDesc, blobDesc) } // test resolving manifest by digest gotDesc, err = s.Resolve(ctx, manifestDesc.Digest.String()) if err != nil { t.Fatal("Store.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, manifestDesc) { t.Errorf("Store.Resolve() = %v, want %v", gotDesc, manifestDesc) } // test tag err = s.Tag(ctx, manifestDesc, ref) if err != nil { t.Fatal("Store.Tag() error =", err) } if got, want := len(internalResolver.Map()), 2; got != want { t.Errorf("resolver.Map() = %v, want %v", got, want) } // test resolving manifest by tag gotDesc, err = s.Resolve(ctx, ref) if err != nil { t.Fatal("Store.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, manifestDesc) { t.Errorf("Store.Resolve() = %v, want %v", gotDesc, manifestDesc) } // test fetch exists, err := s.Exists(ctx, manifestDesc) if err != nil { t.Fatal("Store.Exists() error =", err) } if !exists { t.Errorf("Store.Exists() = %v, want %v", exists, true) } rc, err := s.Fetch(ctx, manifestDesc) if err != nil { t.Fatal("Store.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Store.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got, manifest) { t.Errorf("Store.Fetch() = %v, want %v", got, manifest) } } func TestStore_RelativeRoot_Success(t *testing.T) { blob := []byte("test") blobDesc := content.NewDescriptorFromBytes("test", blob) manifest := []byte(`{"layers":[]}`) manifestDesc := content.NewDescriptorFromBytes(ocispec.MediaTypeImageManifest, manifest) ref := "foobar" tempDir, err := filepath.EvalSymlinks(t.TempDir()) if err != nil { t.Fatal("error calling filepath.EvalSymlinks(), error =", err) } currDir, err := os.Getwd() if err != nil { t.Fatal("error calling Getwd(), error=", err) } if err := os.Chdir(tempDir); err != nil { t.Fatal("error calling Chdir(), error=", err) } s, err := New(".") if err != nil { t.Fatal("New() error =", err) } if want := tempDir; s.root != want { t.Errorf("Store.root = %s, want %s", s.root, want) } // cd back to allow the temp directory to be removed if err := os.Chdir(currDir); err != nil { t.Fatal("error calling Chdir(), error=", err) } ctx := context.Background() // validate layout layoutFilePath := filepath.Join(tempDir, ocispec.ImageLayoutFile) layoutFile, err := os.Open(layoutFilePath) if err != nil { t.Errorf("error opening layout file, error = %v", err) } defer layoutFile.Close() var layout *ocispec.ImageLayout err = json.NewDecoder(layoutFile).Decode(&layout) if err != nil { t.Fatal("error decoding layout, error =", err) } if want := ocispec.ImageLayoutVersion; layout.Version != want { t.Errorf("layout.Version = %s, want %s", layout.Version, want) } // test push blob err = s.Push(ctx, blobDesc, bytes.NewReader(blob)) if err != nil { t.Fatal("Store.Push() error =", err) } internalResolver := s.tagResolver if got, want := len(internalResolver.Map()), 0; got != want { t.Errorf("resolver.Map() = %v, want %v", got, want) } // test push manifest err = s.Push(ctx, manifestDesc, bytes.NewReader(manifest)) if err != nil { t.Fatal("Store.Push() error =", err) } if got, want := len(internalResolver.Map()), 1; got != want { t.Errorf("resolver.Map() = %v, want %v", got, want) } // test resolving blob by digest gotDesc, err := s.Resolve(ctx, blobDesc.Digest.String()) if err != nil { t.Fatal("Store.Resolve() error =", err) } if want := blobDesc; gotDesc.Size != want.Size || gotDesc.Digest != want.Digest { t.Errorf("Store.Resolve() = %v, want %v", gotDesc, blobDesc) } // test resolving manifest by digest gotDesc, err = s.Resolve(ctx, manifestDesc.Digest.String()) if err != nil { t.Fatal("Store.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, manifestDesc) { t.Errorf("Store.Resolve() = %v, want %v", gotDesc, manifestDesc) } // test tag err = s.Tag(ctx, manifestDesc, ref) if err != nil { t.Fatal("Store.Tag() error =", err) } if got, want := len(internalResolver.Map()), 2; got != want { t.Errorf("resolver.Map() = %v, want %v", got, want) } // test resolving manifest by tag gotDesc, err = s.Resolve(ctx, ref) if err != nil { t.Fatal("Store.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, manifestDesc) { t.Errorf("Store.Resolve() = %v, want %v", gotDesc, manifestDesc) } // test fetch exists, err := s.Exists(ctx, manifestDesc) if err != nil { t.Fatal("Store.Exists() error =", err) } if !exists { t.Errorf("Store.Exists() = %v, want %v", exists, true) } rc, err := s.Fetch(ctx, manifestDesc) if err != nil { t.Fatal("Store.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Store.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got, manifest) { t.Errorf("Store.Fetch() = %v, want %v", got, manifest) } } func TestStore_NotExistingRoot(t *testing.T) { tempDir := t.TempDir() root := filepath.Join(tempDir, "rootDir") _, err := New(root) if err != nil { t.Fatal("New() error =", err) } // validate layout layoutFilePath := filepath.Join(root, ocispec.ImageLayoutFile) layoutFile, err := os.Open(layoutFilePath) if err != nil { t.Errorf("error opening layout file, error = %v", err) } defer layoutFile.Close() var layout *ocispec.ImageLayout err = json.NewDecoder(layoutFile).Decode(&layout) if err != nil { t.Fatal("error decoding layout, error =", err) } if want := ocispec.ImageLayoutVersion; layout.Version != want { t.Errorf("layout.Version = %s, want %s", layout.Version, want) } // validate index.json indexFilePath := filepath.Join(root, "index.json") indexFile, err := os.Open(indexFilePath) if err != nil { t.Errorf("error opening layout file, error = %v", err) } defer indexFile.Close() var index *ocispec.Index err = json.NewDecoder(indexFile).Decode(&index) if err != nil { t.Fatal("error decoding index.json, error =", err) } if want := 2; index.SchemaVersion != want { t.Errorf("index.SchemaVersion = %v, want %v", index.SchemaVersion, want) } } func TestStore_ContentNotFound(t *testing.T) { content := []byte("hello world") desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), } tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("New() error =", err) } ctx := context.Background() exists, err := s.Exists(ctx, desc) if err != nil { t.Error("Store.Exists() error =", err) } if exists { t.Errorf("Store.Exists() = %v, want %v", exists, false) } _, err = s.Fetch(ctx, desc) if !errors.Is(err, errdef.ErrNotFound) { t.Errorf("Store.Fetch() error = %v, want %v", err, errdef.ErrNotFound) } } func TestStore_ContentAlreadyExists(t *testing.T) { content := []byte("hello world") desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), } tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("New() error =", err) } ctx := context.Background() err = s.Push(ctx, desc, bytes.NewReader(content)) if err != nil { t.Fatal("Store.Push() error =", err) } err = s.Push(ctx, desc, bytes.NewReader(content)) if !errors.Is(err, errdef.ErrAlreadyExists) { t.Errorf("Store.Push() error = %v, want %v", err, errdef.ErrAlreadyExists) } } func TestStore_ContentBadPush(t *testing.T) { content := []byte("hello world") desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), } tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("New() error =", err) } ctx := context.Background() err = s.Push(ctx, desc, strings.NewReader("foobar")) if err == nil { t.Errorf("Store.Push() error = %v, wantErr %v", err, true) } } func TestStore_ResolveByTagReturnsFullDescriptor(t *testing.T) { content := []byte("hello world") ref := "hello-world:0.0.1" annotations := map[string]string{"name": "Hello"} desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), Annotations: annotations, } tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("New() error =", err) } ctx := context.Background() err = s.Push(ctx, desc, bytes.NewReader(content)) if err != nil { t.Errorf("Store.Push() error = %v, wantErr %v", err, false) } err = s.Tag(ctx, desc, ref) if err != nil { t.Errorf("error tagging descriptor error = %v, wantErr %v", err, false) } resolvedDescr, err := s.Resolve(ctx, ref) if err != nil { t.Errorf("error resolving descriptor error = %v, wantErr %v", err, false) } if !reflect.DeepEqual(resolvedDescr, desc) { t.Errorf("Store.Resolve() = %v, want %v", resolvedDescr, desc) } } func TestStore_ResolveByDigestReturnsPlainDescriptor(t *testing.T) { content := []byte("hello world") ref := "hello-world:0.0.1" desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), Annotations: map[string]string{"name": "Hello"}, } plainDescriptor := descriptor.Plain(desc) tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("New() error =", err) } ctx := context.Background() err = s.Push(ctx, desc, bytes.NewReader(content)) if err != nil { t.Errorf("Store.Push() error = %v, wantErr %v", err, false) } err = s.Tag(ctx, desc, ref) if err != nil { t.Errorf("error tagging descriptor error = %v, wantErr %v", err, false) } resolvedDescr, err := s.Resolve(ctx, string(desc.Digest)) if err != nil { t.Errorf("error resolving descriptor error = %v, wantErr %v", err, false) } if !reflect.DeepEqual(resolvedDescr, plainDescriptor) { t.Errorf("Store.Resolve() = %v, want %v", resolvedDescr, plainDescriptor) } } func TestStore_TagNotFound(t *testing.T) { ref := "foobar" tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("New() error =", err) } ctx := context.Background() _, err = s.Resolve(ctx, ref) if !errors.Is(err, errdef.ErrNotFound) { t.Errorf("Store.Resolve() error = %v, want %v", err, errdef.ErrNotFound) } } func TestStore_TagUnknownContent(t *testing.T) { content := []byte("hello world") desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), } ref := "foobar" tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("New() error =", err) } ctx := context.Background() err = s.Tag(ctx, desc, ref) if !errors.Is(err, errdef.ErrNotFound) { t.Errorf("Store.Resolve() error = %v, want %v", err, errdef.ErrNotFound) } } func TestStore_DisableAutoSaveIndex(t *testing.T) { content := []byte(`{"layers":[]}`) desc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageManifest, Digest: digest.FromBytes(content), Size: int64(len(content)), } ref0 := "foobar" ref1 := "barfoo" tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("New() error =", err) } // disable auto save s.AutoSaveIndex = false ctx := context.Background() // validate layout layoutFilePath := filepath.Join(tempDir, ocispec.ImageLayoutFile) layoutFile, err := os.Open(layoutFilePath) if err != nil { t.Errorf("error opening layout file, error = %v", err) } defer layoutFile.Close() var layout *ocispec.ImageLayout err = json.NewDecoder(layoutFile).Decode(&layout) if err != nil { t.Fatal("error decoding layout, error =", err) } if want := ocispec.ImageLayoutVersion; layout.Version != want { t.Errorf("layout.Version = %s, want %s", layout.Version, want) } // test push err = s.Push(ctx, desc, bytes.NewReader(content)) if err != nil { t.Fatal("Store.Push() error =", err) } internalResolver := s.tagResolver if got, want := len(internalResolver.Map()), 1; got != want { t.Errorf("resolver.Map() = %v, want %v", got, want) } // test resolving by digest gotDesc, err := s.Resolve(ctx, desc.Digest.String()) if err != nil { t.Fatal("Store.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, desc) { t.Errorf("Store.Resolve() = %v, want %v", gotDesc, desc) } // test tag err = s.Tag(ctx, desc, ref0) if err != nil { t.Fatal("Store.Tag() error =", err) } err = s.Tag(ctx, desc, ref1) if err != nil { t.Fatal("Store.Tag() error =", err) } if got, want := len(internalResolver.Map()), 3; got != want { t.Errorf("resolver.Map() = %v, want %v", got, want) } // test resolving by digest gotDesc, err = s.Resolve(ctx, ref0) if err != nil { t.Fatal("Store.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, desc) { t.Errorf("Store.Resolve() = %v, want %v", gotDesc, desc) } // test index file if got, want := len(s.index.Manifests), 0; got != want { t.Errorf("len(index.Manifests) = %v, want %v", got, want) } if err := s.saveIndex(); err != nil { t.Fatal("Store.SaveIndex() error =", err) } // test index file again if got, want := len(s.index.Manifests), 2; got != want { t.Errorf("len(index.Manifests) = %v, want %v", got, want) } if _, err := os.Stat(s.indexPath); err != nil { t.Errorf("error: %s does not exist", s.indexPath) } // test untag err = s.Untag(ctx, ref0) if err != nil { t.Fatal("Store.Untag() error =", err) } if got, want := len(internalResolver.Map()), 2; got != want { t.Errorf("resolver.Map() = %v, want %v", got, want) } if got, want := len(s.index.Manifests), 2; got != want { t.Errorf("len(index.Manifests) = %v, want %v", got, want) } if err := s.saveIndex(); err != nil { t.Fatal("Store.SaveIndex() error =", err) } // test index file again if got, want := len(s.index.Manifests), 1; got != want { t.Errorf("len(index.Manifests) = %v, want %v", got, want) } } func TestStore_RepeatTag(t *testing.T) { tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("New() error =", err) } ctx := context.Background() ref := "foobar" // get internal resolver internalResolver := s.tagResolver // first tag a manifest manifest := []byte(`{"layers":[]}`) desc := content.NewDescriptorFromBytes(ocispec.MediaTypeImageManifest, manifest) err = s.Push(ctx, desc, bytes.NewReader(manifest)) if err != nil { t.Fatal("Store.Push() error =", err) } if got, want := len(internalResolver.Map()), 1; got != want { t.Errorf("len(resolver.Map()) = %v, want %v", got, want) } if got, want := len(s.index.Manifests), 1; got != want { t.Errorf("len(index.Manifests) = %v, want %v", got, want) } err = s.Tag(ctx, desc, ref) if err != nil { t.Fatal("Store.Tag() error =", err) } if got, want := len(internalResolver.Map()), 2; got != want { t.Errorf("resolver.Map() = %v, want %v", got, want) } if got, want := len(s.index.Manifests), 1; got != want { t.Errorf("len(index.Manifests) = %v, want %v", got, want) } gotDesc, err := s.Resolve(ctx, desc.Digest.String()) if err != nil { t.Fatal("Store.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, desc) { t.Errorf("Store.Resolve() = %v, want %v", gotDesc, desc) } gotDesc, err = s.Resolve(ctx, ref) if err != nil { t.Fatal("Store.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, desc) { t.Errorf("Store.Resolve() = %v, want %v", gotDesc, desc) } // tag another manifest manifest = []byte(`{"layers":[], "annotations":{}}`) desc = content.NewDescriptorFromBytes(ocispec.MediaTypeImageManifest, manifest) err = s.Push(ctx, desc, bytes.NewReader(manifest)) if err != nil { t.Fatal("Store.Push() error =", err) } if got, want := len(internalResolver.Map()), 3; got != want { t.Errorf("resolver.Map() = %v, want %v", got, want) } if got, want := len(s.index.Manifests), 2; got != want { t.Errorf("len(index.Manifests) = %v, want %v", got, want) } err = s.Tag(ctx, desc, ref) if err != nil { t.Fatal("Store.Tag() error =", err) } if got, want := len(internalResolver.Map()), 3; got != want { t.Errorf("resolver.Map() = %v, want %v", got, want) } if got, want := len(s.index.Manifests), 2; got != want { t.Errorf("len(index.Manifests) = %v, want %v", got, want) } gotDesc, err = s.Resolve(ctx, desc.Digest.String()) if err != nil { t.Fatal("Store.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, desc) { t.Errorf("Store.Resolve() = %v, want %v", gotDesc, desc) } gotDesc, err = s.Resolve(ctx, ref) if err != nil { t.Fatal("Store.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, desc) { t.Errorf("Store.Resolve() = %v, want %v", gotDesc, desc) } // tag a blob blob := []byte("foobar") desc = content.NewDescriptorFromBytes("test", blob) err = s.Push(ctx, desc, bytes.NewReader(blob)) if err != nil { t.Fatal("Store.Push() error =", err) } if got, want := len(internalResolver.Map()), 3; got != want { t.Errorf("resolver.Map() = %v, want %v", got, want) } if got, want := len(s.index.Manifests), 2; got != want { t.Errorf("len(index.Manifests) = %v, want %v", got, want) } err = s.Tag(ctx, desc, ref) if err != nil { t.Fatal("Store.Tag() error =", err) } if got, want := len(internalResolver.Map()), 4; got != want { t.Errorf("resolver.Map() = %v, want %v", got, want) } if got, want := len(s.index.Manifests), 3; got != want { t.Errorf("len(index.Manifests) = %v, want %v", got, want) } gotDesc, err = s.Resolve(ctx, desc.Digest.String()) if err != nil { t.Fatal("Store.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, desc) { t.Errorf("Store.Resolve() = %v, want %v", gotDesc, desc) } gotDesc, err = s.Resolve(ctx, ref) if err != nil { t.Fatal("Store.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, desc) { t.Errorf("Store.Resolve() = %v, want %v", gotDesc, desc) } // tag another blob blob = []byte("barfoo") desc = content.NewDescriptorFromBytes("test", blob) err = s.Push(ctx, desc, bytes.NewReader(blob)) if err != nil { t.Fatal("Store.Push() error =", err) } if got, want := len(internalResolver.Map()), 4; got != want { t.Errorf("resolver.Map() = %v, want %v", got, want) } if got, want := len(s.index.Manifests), 3; got != want { t.Errorf("len(index.Manifests) = %v, want %v", got, want) } err = s.Tag(ctx, desc, ref) if err != nil { t.Fatal("Store.Tag() error =", err) } if got, want := len(internalResolver.Map()), 5; got != want { t.Errorf("resolver.Map() = %v, want %v", got, want) } if got, want := len(s.index.Manifests), 4; got != want { t.Errorf("len(index.Manifests) = %v, want %v", got, want) } gotDesc, err = s.Resolve(ctx, desc.Digest.String()) if err != nil { t.Fatal("Store.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, desc) { t.Errorf("Store.Resolve() = %v, want %v", gotDesc, desc) } gotDesc, err = s.Resolve(ctx, ref) if err != nil { t.Fatal("Store.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, desc) { t.Errorf("Store.Resolve() = %v, want %v", gotDesc, desc) } } // Related bug: https://github.com/oras-project/oras-go/issues/461 func TestStore_TagByDigest(t *testing.T) { tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("New() error =", err) } ctx := context.Background() // get internal resolver internalResolver := s.tagResolver manifest := []byte(`{"layers":[]}`) manifestDesc := content.NewDescriptorFromBytes(ocispec.MediaTypeImageManifest, manifest) // push a manifest err = s.Push(ctx, manifestDesc, bytes.NewReader(manifest)) if err != nil { t.Fatal("Store.Push() error =", err) } if got, want := len(internalResolver.Map()), 1; got != want { t.Errorf("len(resolver.Map()) = %v, want %v", got, want) } if got, want := len(s.index.Manifests), 1; got != want { t.Errorf("len(index.Manifests) = %v, want %v", got, want) } gotDesc, err := s.Resolve(ctx, manifestDesc.Digest.String()) if err != nil { t.Fatal("Store.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, manifestDesc) { t.Errorf("Store.Resolve() = %v, want %v", gotDesc, manifestDesc) } // tag manifest by digest err = s.Tag(ctx, manifestDesc, manifestDesc.Digest.String()) if err != nil { t.Fatal("Store.Tag() error =", err) } if got, want := len(internalResolver.Map()), 1; got != want { t.Errorf("len(resolver.Map()) = %v, want %v", got, want) } if got, want := len(s.index.Manifests), 1; got != want { t.Errorf("len(index.Manifests) = %v, want %v", got, want) } gotDesc, err = s.Resolve(ctx, manifestDesc.Digest.String()) if err != nil { t.Fatal("Store.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, manifestDesc) { t.Errorf("Store.Resolve() = %v, want %v", gotDesc, manifestDesc) } // push a blob blob := []byte("foobar") blobDesc := content.NewDescriptorFromBytes("test", blob) err = s.Push(ctx, blobDesc, bytes.NewReader(blob)) if err != nil { t.Fatal("Store.Push() error =", err) } if got, want := len(internalResolver.Map()), 1; got != want { t.Errorf("resolver.Map() = %v, want %v", got, want) } if got, want := len(s.index.Manifests), 1; got != want { t.Errorf("len(index.Manifests) = %v, want %v", got, want) } gotDesc, err = s.Resolve(ctx, blobDesc.Digest.String()) if err != nil { t.Fatal("Store.Resolve() error =", err) } if gotDesc.Digest != blobDesc.Digest || gotDesc.Size != blobDesc.Size { t.Errorf("Store.Resolve() = %v, want %v", gotDesc, blobDesc) } // tag blob by digest err = s.Tag(ctx, blobDesc, blobDesc.Digest.String()) if err != nil { t.Fatal("Store.Tag() error =", err) } if got, want := len(internalResolver.Map()), 2; got != want { t.Errorf("resolver.Map() = %v, want %v", got, want) } if got, want := len(s.index.Manifests), 2; got != want { t.Errorf("len(index.Manifests) = %v, want %v", got, want) } gotDesc, err = s.Resolve(ctx, blobDesc.Digest.String()) if err != nil { t.Fatal("Store.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, blobDesc) { t.Errorf("Store.Resolve() = %v, want %v", gotDesc, blobDesc) } } func TestStore_BadIndex(t *testing.T) { tempDir := t.TempDir() content := []byte("whatever") path := filepath.Join(tempDir, "index.json") os.WriteFile(path, content, 0666) _, err := New(tempDir) if err == nil { t.Errorf("New() error = nil, want: error") } } func TestStore_BadLayout(t *testing.T) { tempDir := t.TempDir() content := []byte("whatever") path := filepath.Join(tempDir, ocispec.ImageLayoutFile) os.WriteFile(path, content, 0666) _, err := New(tempDir) if err == nil { t.Errorf("New() error = nil, want: error") } } func TestStore_Predecessors(t *testing.T) { tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("New() error =", err) } ctx := context.Background() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } generateIndex := func(manifests ...ocispec.Descriptor) { index := ocispec.Index{ Manifests: manifests, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageIndex, indexJSON) } generateArtifactManifest := func(subject ocispec.Descriptor, blobs ...ocispec.Descriptor) { var manifest spec.Artifact manifest.Subject = &subject manifest.Blobs = append(manifest.Blobs, blobs...) manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(spec.MediaTypeArtifactManifest, manifestJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 appendBlob(ocispec.MediaTypeImageLayer, []byte("hello")) // Blob 3 generateManifest(descs[0], descs[1:3]...) // Blob 4 generateManifest(descs[0], descs[3]) // Blob 5 generateManifest(descs[0], descs[1:4]...) // Blob 6 generateIndex(descs[4:6]...) // Blob 7 generateIndex(descs[6]) // Blob 8 generateIndex() // Blob 9 generateIndex(descs[7:10]...) // Blob 10 appendBlob(ocispec.MediaTypeImageLayer, []byte("sig_1")) // Blob 11 generateArtifactManifest(descs[6], descs[11]) // Blob 12 appendBlob(ocispec.MediaTypeImageLayer, []byte("sig_2")) // Blob 13 generateArtifactManifest(descs[10], descs[13]) // Blob 14 eg, egCtx := errgroup.WithContext(ctx) for i := range blobs { eg.Go(func(i int) func() error { return func() error { err := s.Push(egCtx, descs[i], bytes.NewReader(blobs[i])) if err != nil { return fmt.Errorf("failed to push test content to src: %d: %v", i, err) } return nil } }(i)) } if err := eg.Wait(); err != nil { t.Fatal(err) } // verify predecessors wants := [][]ocispec.Descriptor{ descs[4:7], // Blob 0 {descs[4], descs[6]}, // Blob 1 {descs[4], descs[6]}, // Blob 2 {descs[5], descs[6]}, // Blob 3 {descs[7]}, // Blob 4 {descs[7]}, // Blob 5 {descs[8], descs[12]}, // Blob 6 {descs[10]}, // Blob 7 {descs[10]}, // Blob 8 {descs[10]}, // Blob 9 {descs[14]}, // Blob 10 {descs[12]}, // Blob 11 nil, // Blob 12 {descs[14]}, // Blob 13 nil, // Blob 14 } for i, want := range wants { predecessors, err := s.Predecessors(ctx, descs[i]) if err != nil { t.Errorf("Store.Predecessors(%d) error = %v", i, err) } if !equalDescriptorSet(predecessors, want) { t.Errorf("Store.Predecessors(%d) = %v, want %v", i, predecessors, want) } } } func TestStore_ExistingStore(t *testing.T) { tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("New() error =", err) } // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } generateIndex := func(manifests ...ocispec.Descriptor) { index := ocispec.Index{ Manifests: manifests, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageIndex, indexJSON) } generateArtifactManifest := func(subject ocispec.Descriptor, blobs ...ocispec.Descriptor) { var manifest spec.Artifact manifest.Subject = &subject manifest.Blobs = append(manifest.Blobs, blobs...) manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(spec.MediaTypeArtifactManifest, manifestJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 appendBlob(ocispec.MediaTypeImageLayer, []byte("hello")) // Blob 3 generateManifest(descs[0], descs[1:3]...) // Blob 4 generateManifest(descs[0], descs[3]) // Blob 5 generateManifest(descs[0], descs[1:4]...) // Blob 6 generateIndex(descs[4:6]...) // Blob 7 generateIndex(descs[6]) // Blob 8 generateIndex() // Blob 9 generateIndex(descs[7:10]...) // Blob 10 appendBlob(ocispec.MediaTypeImageLayer, []byte("sig_1")) // Blob 11 generateArtifactManifest(descs[6], descs[11]) // Blob 12 appendBlob(ocispec.MediaTypeImageLayer, []byte("sig_2")) // Blob 13 generateArtifactManifest(descs[10], descs[13]) // Blob 14 ctx := context.Background() eg, egCtx := errgroup.WithContext(ctx) for i := range blobs { eg.Go(func(i int) func() error { return func() error { err := s.Push(egCtx, descs[i], bytes.NewReader(blobs[i])) if err != nil { return fmt.Errorf("failed to push test content to src: %d: %v", i, err) } return nil } }(i)) } if err := eg.Wait(); err != nil { t.Fatal(err) } // tag index root indexRoot := descs[10] tag := "latest" if err := s.Tag(ctx, indexRoot, tag); err != nil { t.Fatal("Tag() error =", err) } // tag index root by digest // related bug: https://github.com/oras-project/oras-go/issues/461 if err := s.Tag(ctx, indexRoot, indexRoot.Digest.String()); err != nil { t.Fatal("Tag() error =", err) } // test with another OCI store instance to mock loading from an existing store anotherS, err := New(tempDir) if err != nil { t.Fatal("New() error =", err) } // test resolving index root by tag gotDesc, err := anotherS.Resolve(ctx, tag) if err != nil { t.Fatal("Store: Resolve() error =", err) } if !content.Equal(gotDesc, indexRoot) { t.Errorf("Store.Resolve() = %v, want %v", gotDesc, indexRoot) } // test resolving index root by digest gotDesc, err = anotherS.Resolve(ctx, indexRoot.Digest.String()) if err != nil { t.Fatal("Store: Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, indexRoot) { t.Errorf("Store.Resolve() = %v, want %v", gotDesc, indexRoot) } // test resolving artifact manifest by digest artifactRootDesc := descs[12] gotDesc, err = anotherS.Resolve(ctx, artifactRootDesc.Digest.String()) if err != nil { t.Fatal("Store: Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, artifactRootDesc) { t.Errorf("Store.Resolve() = %v, want %v", gotDesc, artifactRootDesc) } // test resolving blob by digest gotDesc, err = anotherS.Resolve(ctx, descs[0].Digest.String()) if err != nil { t.Fatal("Store.Resolve() error =", err) } if want := descs[0]; gotDesc.Size != want.Size || gotDesc.Digest != want.Digest { t.Errorf("Store.Resolve() = %v, want %v", gotDesc, want) } // test fetching OCI root index exists, err := anotherS.Exists(ctx, indexRoot) if err != nil { t.Fatal("Store.Exists() error =", err) } if !exists { t.Errorf("Store.Exists() = %v, want %v", exists, true) } // test fetching blobs for i := range blobs { eg.Go(func(i int) func() error { return func() error { rc, err := s.Fetch(egCtx, descs[i]) if err != nil { return fmt.Errorf("Store.Fetch(%d) error = %v", i, err) } got, err := io.ReadAll(rc) if err != nil { return fmt.Errorf("Store.Fetch(%d).Read() error = %v", i, err) } err = rc.Close() if err != nil { return fmt.Errorf("Store.Fetch(%d).Close() error = %v", i, err) } if !bytes.Equal(got, blobs[i]) { return fmt.Errorf("Store.Fetch(%d) = %v, want %v", i, got, blobs[i]) } return nil } }(i)) } if err := eg.Wait(); err != nil { t.Fatal(err) } // verify predecessors wants := [][]ocispec.Descriptor{ descs[4:7], // Blob 0 {descs[4], descs[6]}, // Blob 1 {descs[4], descs[6]}, // Blob 2 {descs[5], descs[6]}, // Blob 3 {descs[7]}, // Blob 4 {descs[7]}, // Blob 5 {descs[8], descs[12]}, // Blob 6 {descs[10]}, // Blob 7 {descs[10]}, // Blob 8 {descs[10]}, // Blob 9 {descs[14]}, // Blob 10 {descs[12]}, // Blob 11 nil, // Blob 12, no predecessors {descs[14]}, // Blob 13 nil, // Blob 14, no predecessors } for i, want := range wants { predecessors, err := anotherS.Predecessors(ctx, descs[i]) if err != nil { t.Errorf("Store.Predecessors(%d) error = %v", i, err) } if !equalDescriptorSet(predecessors, want) { t.Errorf("Store.Predecessors(%d) = %v, want %v", i, predecessors, want) } } } func Test_ExistingStore_Retag(t *testing.T) { tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("New() error =", err) } ctx := context.Background() manifest_1 := []byte(`{"layers":[]}`) manifestDesc_1 := content.NewDescriptorFromBytes(ocispec.MediaTypeImageManifest, manifest_1) manifestDesc_1.Annotations = map[string]string{"key1": "val1"} // push a manifest err = s.Push(ctx, manifestDesc_1, bytes.NewReader(manifest_1)) if err != nil { t.Fatal("Store.Push() error =", err) } // tag manifest by digest err = s.Tag(ctx, manifestDesc_1, manifestDesc_1.Digest.String()) if err != nil { t.Fatal("Store.Tag() error =", err) } // tag manifest by tag ref := "foobar" err = s.Tag(ctx, manifestDesc_1, ref) if err != nil { t.Fatal("Store.Tag() error =", err) } // verify index want := []ocispec.Descriptor{ { MediaType: manifestDesc_1.MediaType, Digest: manifestDesc_1.Digest, Size: manifestDesc_1.Size, Annotations: map[string]string{ "key1": "val1", ocispec.AnnotationRefName: ref, }, }, } if got := s.index.Manifests; !equalDescriptorSet(got, want) { t.Errorf("index.Manifests = %v, want %v", got, want) } // test with another OCI store instance to mock loading from an existing store anotherS, err := New(tempDir) if err != nil { t.Fatal("New() error =", err) } manifest_2 := []byte(`{"layers":[], "annotations":{}}`) manifestDesc_2 := content.NewDescriptorFromBytes(ocispec.MediaTypeImageManifest, manifest_2) manifestDesc_2.Annotations = map[string]string{"key2": "val2"} err = anotherS.Push(ctx, manifestDesc_2, bytes.NewReader(manifest_2)) if err != nil { t.Fatal("Store.Push() error =", err) } err = anotherS.Tag(ctx, manifestDesc_2, ref) if err != nil { t.Fatal("Store.Tag() error =", err) } // verify index want = []ocispec.Descriptor{ { MediaType: manifestDesc_1.MediaType, Digest: manifestDesc_1.Digest, Size: manifestDesc_1.Size, Annotations: map[string]string{ "key1": "val1", }, }, { MediaType: manifestDesc_2.MediaType, Digest: manifestDesc_2.Digest, Size: manifestDesc_2.Size, Annotations: map[string]string{ "key2": "val2", ocispec.AnnotationRefName: ref, }, }, } if got := anotherS.index.Manifests; !equalDescriptorSet(got, want) { t.Errorf("index.Manifests = %v, want %v", got, want) } } func TestCopy_MemoryToOCI_FullCopy(t *testing.T) { src := memory.New() tempDir := t.TempDir() dst, err := New(tempDir) if err != nil { t.Fatal("OCI.New() error =", err) } // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 generateManifest(descs[0], descs[1:3]...) // Blob 3 ctx := context.Background() for i := range blobs { err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } root := descs[3] ref := "foobar" err = src.Tag(ctx, root, ref) if err != nil { t.Fatal("fail to tag root node", err) } // test copy gotDesc, err := oras.Copy(ctx, src, ref, dst, "", oras.CopyOptions{}) if err != nil { t.Fatalf("Copy() error = %v, wantErr %v", err, false) } if !reflect.DeepEqual(gotDesc, root) { t.Errorf("Copy() = %v, want %v", gotDesc, root) } // verify contents for i, desc := range descs { exists, err := dst.Exists(ctx, desc) if err != nil { t.Fatalf("dst.Exists(%d) error = %v", i, err) } if !exists { t.Errorf("dst.Exists(%d) = %v, want %v", i, exists, true) } } // verify tag gotDesc, err = dst.Resolve(ctx, ref) if err != nil { t.Fatal("dst.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, root) { t.Errorf("dst.Resolve() = %v, want %v", gotDesc, root) } } func TestCopyGraph_MemoryToOCI_FullCopy(t *testing.T) { src := cas.NewMemory() tempDir := t.TempDir() dst, err := New(tempDir) if err != nil { t.Fatal("OCI.New() error =", err) } // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } generateIndex := func(manifests ...ocispec.Descriptor) { index := ocispec.Index{ Manifests: manifests, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageIndex, indexJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 appendBlob(ocispec.MediaTypeImageLayer, []byte("hello")) // Blob 3 generateManifest(descs[0], descs[1:3]...) // Blob 4 generateManifest(descs[0], descs[3]) // Blob 5 generateManifest(descs[0], descs[1:4]...) // Blob 6 generateIndex(descs[4:6]...) // Blob 7 generateIndex(descs[6]) // Blob 8 generateIndex() // Blob 9 generateIndex(descs[7:10]...) // Blob 10 ctx := context.Background() for i := range blobs { err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } // test copy srcTracker := &storageTracker{Storage: src} dstTracker := &storageTracker{Storage: dst} root := descs[len(descs)-1] if err := oras.CopyGraph(ctx, srcTracker, dstTracker, root, oras.CopyGraphOptions{}); err != nil { t.Fatalf("CopyGraph() error = %v, wantErr %v", err, false) } // verify contents for i := range blobs { got, err := content.FetchAll(ctx, dst, descs[i]) if err != nil { t.Errorf("content[%d] error = %v, wantErr %v", i, err, false) continue } if want := blobs[i]; !bytes.Equal(got, want) { t.Errorf("content[%d] = %v, want %v", i, got, want) } } // verify API counts if got, want := srcTracker.fetch, int64(len(blobs)); got != want { t.Errorf("count(src.Fetch()) = %v, want %v", got, want) } if got, want := srcTracker.push, int64(0); got != want { t.Errorf("count(src.Push()) = %v, want %v", got, want) } if got, want := srcTracker.exists, int64(0); got != want { t.Errorf("count(src.Exists()) = %v, want %v", got, want) } if got, want := dstTracker.fetch, int64(0); got != want { t.Errorf("count(dst.Fetch()) = %v, want %v", got, want) } if got, want := dstTracker.push, int64(len(blobs)); got != want { t.Errorf("count(dst.Push()) = %v, want %v", got, want) } if got, want := dstTracker.exists, int64(len(blobs)); got != want { t.Errorf("count(dst.Exists()) = %v, want %v", got, want) } } func TestCopyGraph_MemoryToOCI_PartialCopy(t *testing.T) { src := cas.NewMemory() tempDir := t.TempDir() dst, err := New(tempDir) if err != nil { t.Fatal("OCI.New() error =", err) } // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } generateIndex := func(manifests ...ocispec.Descriptor) { index := ocispec.Index{ Manifests: manifests, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageIndex, indexJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 generateManifest(descs[0], descs[1:3]...) // Blob 3 appendBlob(ocispec.MediaTypeImageLayer, []byte("hello")) // Blob 4 generateManifest(descs[0], descs[4]) // Blob 5 generateIndex(descs[3], descs[5]) // Blob 6 ctx := context.Background() for i := range blobs { err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } // initial copy root := descs[3] if err := oras.CopyGraph(ctx, src, dst, root, oras.CopyGraphOptions{}); err != nil { t.Fatalf("CopyGraph() error = %v, wantErr %v", err, false) } // verify contents for i := range blobs[:4] { got, err := content.FetchAll(ctx, dst, descs[i]) if err != nil { t.Fatalf("content[%d] error = %v, wantErr %v", i, err, false) } if want := blobs[i]; !bytes.Equal(got, want) { t.Fatalf("content[%d] = %v, want %v", i, got, want) } } // test copy srcTracker := &storageTracker{Storage: src} dstTracker := &storageTracker{Storage: dst} root = descs[len(descs)-1] if err := oras.CopyGraph(ctx, srcTracker, dstTracker, root, oras.CopyGraphOptions{}); err != nil { t.Fatalf("CopyGraph() error = %v, wantErr %v", err, false) } // verify contents for i := range blobs { got, err := content.FetchAll(ctx, dst, descs[i]) if err != nil { t.Errorf("content[%d] error = %v, wantErr %v", i, err, false) continue } if want := blobs[i]; !bytes.Equal(got, want) { t.Errorf("content[%d] = %v, want %v", i, got, want) } } // verify API counts if got, want := srcTracker.fetch, int64(3); got != want { t.Errorf("count(src.Fetch()) = %v, want %v", got, want) } if got, want := srcTracker.push, int64(0); got != want { t.Errorf("count(src.Push()) = %v, want %v", got, want) } if got, want := srcTracker.exists, int64(0); got != want { t.Errorf("count(src.Exists()) = %v, want %v", got, want) } if got, want := dstTracker.fetch, int64(0); got != want { t.Errorf("count(dst.Fetch()) = %v, want %v", got, want) } if got, want := dstTracker.push, int64(3); got != want { t.Errorf("count(dst.Push()) = %v, want %v", got, want) } if got, want := dstTracker.exists, int64(5); got != want { t.Errorf("count(dst.Exists()) = %v, want %v", got, want) } } func TestCopyGraph_OCIToMemory_FullCopy(t *testing.T) { tempDir := t.TempDir() src, err := New(tempDir) if err != nil { t.Fatal("OCI.New() error =", err) } dst := cas.NewMemory() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } generateIndex := func(manifests ...ocispec.Descriptor) { index := ocispec.Index{ Manifests: manifests, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageIndex, indexJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 appendBlob(ocispec.MediaTypeImageLayer, []byte("hello")) // Blob 3 generateManifest(descs[0], descs[1:3]...) // Blob 4 generateManifest(descs[0], descs[3]) // Blob 5 generateManifest(descs[0], descs[1:4]...) // Blob 6 generateIndex(descs[4:6]...) // Blob 7 generateIndex(descs[6]) // Blob 8 generateIndex() // Blob 9 generateIndex(descs[7:10]...) // Blob 10 ctx := context.Background() for i := range blobs { err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } // test copy srcTracker := &storageTracker{Storage: src} dstTracker := &storageTracker{Storage: dst} root := descs[len(descs)-1] if err := oras.CopyGraph(ctx, srcTracker, dstTracker, root, oras.CopyGraphOptions{}); err != nil { t.Fatalf("CopyGraph() error = %v, wantErr %v", err, false) } // verify contents for i := range blobs { got, err := content.FetchAll(ctx, dst, descs[i]) if err != nil { t.Errorf("content[%d] error = %v, wantErr %v", i, err, false) continue } if want := blobs[i]; !bytes.Equal(got, want) { t.Errorf("content[%d] = %v, want %v", i, got, want) } } // verify API counts if got, want := srcTracker.fetch, int64(len(blobs)); got != want { t.Errorf("count(src.Fetch()) = %v, want %v", got, want) } if got, want := srcTracker.push, int64(0); got != want { t.Errorf("count(src.Push()) = %v, want %v", got, want) } if got, want := srcTracker.exists, int64(0); got != want { t.Errorf("count(src.Exists()) = %v, want %v", got, want) } if got, want := dstTracker.fetch, int64(0); got != want { t.Errorf("count(dst.Fetch()) = %v, want %v", got, want) } if got, want := dstTracker.push, int64(len(blobs)); got != want { t.Errorf("count(dst.Push()) = %v, want %v", got, want) } if got, want := dstTracker.exists, int64(len(blobs)); got != want { t.Errorf("count(dst.Exists()) = %v, want %v", got, want) } } func TestCopyGraph_OCIToMemory_PartialCopy(t *testing.T) { tempDir := t.TempDir() src, err := New(tempDir) if err != nil { t.Fatal("OCI.New() error =", err) } dst := cas.NewMemory() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } generateIndex := func(manifests ...ocispec.Descriptor) { index := ocispec.Index{ Manifests: manifests, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageIndex, indexJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 generateManifest(descs[0], descs[1:3]...) // Blob 3 appendBlob(ocispec.MediaTypeImageLayer, []byte("hello")) // Blob 4 generateManifest(descs[0], descs[4]) // Blob 5 generateIndex(descs[3], descs[5]) // Blob 6 ctx := context.Background() for i := range blobs { err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } // initial copy root := descs[3] if err := oras.CopyGraph(ctx, src, dst, root, oras.CopyGraphOptions{}); err != nil { t.Fatalf("CopyGraph() error = %v, wantErr %v", err, false) } // verify contents for i := range blobs[:4] { got, err := content.FetchAll(ctx, dst, descs[i]) if err != nil { t.Fatalf("content[%d] error = %v, wantErr %v", i, err, false) } if want := blobs[i]; !bytes.Equal(got, want) { t.Fatalf("content[%d] = %v, want %v", i, got, want) } } // test copy srcTracker := &storageTracker{Storage: src} dstTracker := &storageTracker{Storage: dst} root = descs[len(descs)-1] if err := oras.CopyGraph(ctx, srcTracker, dstTracker, root, oras.CopyGraphOptions{}); err != nil { t.Fatalf("CopyGraph() error = %v, wantErr %v", err, false) } // verify contents for i := range blobs { got, err := content.FetchAll(ctx, dst, descs[i]) if err != nil { t.Errorf("content[%d] error = %v, wantErr %v", i, err, false) continue } if want := blobs[i]; !bytes.Equal(got, want) { t.Errorf("content[%d] = %v, want %v", i, got, want) } } // verify API counts if got, want := srcTracker.fetch, int64(3); got != want { t.Errorf("count(src.Fetch()) = %v, want %v", got, want) } if got, want := srcTracker.push, int64(0); got != want { t.Errorf("count(src.Push()) = %v, want %v", got, want) } if got, want := srcTracker.exists, int64(0); got != want { t.Errorf("count(src.Exists()) = %v, want %v", got, want) } if got, want := dstTracker.fetch, int64(0); got != want { t.Errorf("count(dst.Fetch()) = %v, want %v", got, want) } if got, want := dstTracker.push, int64(3); got != want { t.Errorf("count(dst.Push()) = %v, want %v", got, want) } if got, want := dstTracker.exists, int64(5); got != want { t.Errorf("count(dst.Exists()) = %v, want %v", got, want) } } func TestStore_Tags(t *testing.T) { tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("New() error =", err) } ctx := context.Background() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } // add annotation to make each manifest unique manifest.Annotations = map[string]string{ "blob_index": strconv.Itoa(len(blobs)), } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } tagManifest := func(desc ocispec.Descriptor, ref string) { if err := s.Tag(ctx, desc, ref); err != nil { t.Fatal("Store.Tag() error =", err) } } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foobar")) // Blob 1 generateManifest(descs[0], descs[1]) // Blob 2 generateManifest(descs[0], descs[1]) // Blob 3 generateManifest(descs[0], descs[1]) // Blob 4 generateManifest(descs[0], descs[1]) // Blob 5 generateManifest(descs[0], descs[1]) // Blob 6 for i := range blobs { err := s.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content: %d: %v", i, err) } } tagManifest(descs[3], "v2") tagManifest(descs[4], "v3") tagManifest(descs[5], "v1") tagManifest(descs[6], "v4") // test tags tests := []struct { name string last string want []string }{ { name: "list all tags", want: []string{"v1", "v2", "v3", "v4"}, }, { name: "list from middle", last: "v2", want: []string{"v3", "v4"}, }, { name: "list from end", last: "v4", want: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := s.Tags(ctx, tt.last, func(got []string) error { if !reflect.DeepEqual(got, tt.want) { t.Errorf("Store.Tags() = %v, want %v", got, tt.want) } return nil }); err != nil { t.Errorf("Store.Tags() error = %v", err) } }) } wantErr := errors.New("expected error") if err := s.Tags(ctx, "", func(got []string) error { return wantErr }); err != wantErr { t.Errorf("Store.Tags() error = %v, wantErr %v", err, wantErr) } } func TestStore_BasicDelete(t *testing.T) { content := []byte("test delete") desc := ocispec.Descriptor{ MediaType: "test-delete", Digest: digest.FromBytes(content), Size: int64(len(content)), } ref := "latest" tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("NewDeletableStore() error =", err) } ctx := context.Background() err = s.Push(ctx, desc, bytes.NewReader(content)) if err != nil { t.Errorf("Store.Push() error = %v, wantErr %v", err, false) } err = s.Tag(ctx, desc, ref) if err != nil { t.Errorf("error tagging descriptor error = %v, wantErr %v", err, false) } exists, err := s.Exists(ctx, desc) if err != nil { t.Fatal("Store.Exists() error =", err) } if !exists { t.Errorf("Store.Exists() = %v, want %v", exists, true) } resolvedDescr, err := s.Resolve(ctx, ref) if err != nil { t.Errorf("error resolving descriptor error = %v, wantErr %v", err, false) } if !reflect.DeepEqual(resolvedDescr, desc) { t.Errorf("Store.Resolve() = %v, want %v", resolvedDescr, desc) } err = s.Delete(ctx, desc) if err != nil { t.Errorf("Store.Delete() = %v, wantErr %v", err, nil) } exists, err = s.Exists(ctx, desc) if err != nil { t.Fatal("Store.Exists() error =", err) } if exists { t.Errorf("Store.Exists() = %v, want %v", exists, false) } } func TestStore_FetchAndDelete(t *testing.T) { // create a store tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("error =", err) } // push a content content := []byte("test delete") desc := ocispec.Descriptor{ MediaType: "test-delete", Digest: digest.FromBytes(content), Size: int64(len(content)), } err = s.Push(context.Background(), desc, bytes.NewReader(content)) if err != nil { t.Fatal("error =", err) } // fetch a content rc, err := s.Fetch(context.Background(), desc) if err != nil { t.Fatal("error =", err) } // read and verify the content got, err := io.ReadAll(rc) if err != nil { t.Fatal("error =", err) } if !bytes.Equal(got, content) { t.Errorf("Store.Fetch() = %v, want %v", string(got), string(content)) } rc.Close() // delete. If rc is not closed, Delete would fail on some systems. err = s.Delete(context.Background(), desc) if err != nil { t.Fatal("error =", err) } } func TestStore_PredecessorsAndDelete(t *testing.T) { tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("New() error =", err) } s.AutoGC = false ctx := context.Background() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } generateIndex := func(manifests ...ocispec.Descriptor) { index := ocispec.Index{ Manifests: manifests, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageIndex, indexJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 appendBlob(ocispec.MediaTypeImageLayer, []byte("hello")) // Blob 3 generateManifest(descs[0], descs[1:3]...) // Blob 4 generateManifest(descs[0], descs[3]) // Blob 5 generateManifest(descs[0], descs[1:4]...) // Blob 6 generateIndex(descs[4:6]...) // Blob 7 generateIndex(descs[6]) // Blob 8 eg, egCtx := errgroup.WithContext(ctx) for i := range blobs { eg.Go(func(i int) func() error { return func() error { err := s.Push(egCtx, descs[i], bytes.NewReader(blobs[i])) if err != nil { return fmt.Errorf("failed to push test content to src: %d: %v", i, err) } return nil } }(i)) } if err := eg.Wait(); err != nil { t.Fatal(err) } // verify predecessors wants := [][]ocispec.Descriptor{ descs[4:7], // Blob 0 {descs[4], descs[6]}, // Blob 1 {descs[4], descs[6]}, // Blob 2 {descs[5], descs[6]}, // Blob 3 {descs[7]}, // Blob 4 {descs[7]}, // Blob 5 {descs[8]}, // Blob 6 nil, // Blob 7 nil, // Blob 8 } for i, want := range wants { predecessors, err := s.Predecessors(ctx, descs[i]) if err != nil { t.Errorf("Store.Predecessors(%d) error = %v", i, err) } if !equalDescriptorSet(predecessors, want) { t.Errorf("Store.Predecessors(%d) = %v, want %v", i, predecessors, want) } } // delete a node and verify the result s.Delete(egCtx, descs[6]) // verify predecessors wants = [][]ocispec.Descriptor{ descs[4:6], // Blob 0 {descs[4]}, // Blob 1 {descs[4]}, // Blob 2 {descs[5]}, // Blob 3 {descs[7]}, // Blob 4 {descs[7]}, // Blob 5 {descs[8]}, // Blob 6 nil, // Blob 7 nil, // Blob 8 } for i, want := range wants { predecessors, err := s.Predecessors(ctx, descs[i]) if err != nil { t.Errorf("Store.Predecessors(%d) error = %v", i, err) } if !equalDescriptorSet(predecessors, want) { t.Errorf("Store.Predecessors(%d) = %v, want %v", i, predecessors, want) } } // delete a node and verify the result s.Delete(egCtx, descs[8]) // verify predecessors wants = [][]ocispec.Descriptor{ descs[4:6], // Blob 0 {descs[4]}, // Blob 1 {descs[4]}, // Blob 2 {descs[5]}, // Blob 3 {descs[7]}, // Blob 4 {descs[7]}, // Blob 5 nil, // Blob 6 nil, // Blob 7 nil, // Blob 8 } for i, want := range wants { predecessors, err := s.Predecessors(ctx, descs[i]) if err != nil { t.Errorf("Store.Predecessors(%d) error = %v", i, err) } if !equalDescriptorSet(predecessors, want) { t.Errorf("Store.Predecessors(%d) = %v, want %v", i, predecessors, want) } } // delete a node and verify the result s.Delete(egCtx, descs[5]) // verify predecessors wants = [][]ocispec.Descriptor{ {descs[4]}, // Blob 0 {descs[4]}, // Blob 1 {descs[4]}, // Blob 2 nil, // Blob 3 {descs[7]}, // Blob 4 {descs[7]}, // Blob 5 nil, // Blob 6 nil, // Blob 7 nil, // Blob 8 } for i, want := range wants { predecessors, err := s.Predecessors(ctx, descs[i]) if err != nil { t.Errorf("Store.Predecessors(%d) error = %v", i, err) } if !equalDescriptorSet(predecessors, want) { t.Errorf("Store.Predecessors(%d) = %v, want %v", i, predecessors, want) } } } func TestStore_DeleteWithAutoGC(t *testing.T) { tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("New() error =", err) } ctx := context.Background() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(config ocispec.Descriptor, subject *ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Subject: subject, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } generateIndex := func(manifests ...ocispec.Descriptor) { index := ocispec.Index{ Manifests: manifests, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageIndex, indexJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 appendBlob(ocispec.MediaTypeImageLayer, []byte("hello")) // Blob 3 generateManifest(descs[0], nil, descs[1]) // Blob 4 generateManifest(descs[0], nil, descs[2]) // Blob 5 generateManifest(descs[0], nil, descs[3]) // Blob 6 generateIndex(descs[4:6]...) // Blob 7 generateIndex(descs[6]) // Blob 8 appendBlob(ocispec.MediaTypeImageLayer, []byte("world")) // Blob 9 generateManifest(descs[0], &descs[6], descs[9]) // Blob 10 generateManifest(descs[0], &descs[10], descs[2]) // Blob 11 eg, egCtx := errgroup.WithContext(ctx) for i := range blobs { eg.Go(func(i int) func() error { return func() error { err := s.Push(egCtx, descs[i], bytes.NewReader(blobs[i])) if err != nil { return fmt.Errorf("failed to push test content to src: %d: %v", i, err) } return nil } }(i)) } if err := eg.Wait(); err != nil { t.Fatal(err) } // delete blob 4 and verify the result if err := s.Delete(egCtx, descs[4]); err != nil { t.Fatal(err) } // blob 1 and 4 are now deleted, and other blobs are still present notPresent := []ocispec.Descriptor{descs[1], descs[4]} for _, node := range notPresent { if exists, _ := s.Exists(egCtx, node); exists { t.Errorf("%v should not exist in store", node) } } stillPresent := []ocispec.Descriptor{descs[0], descs[2], descs[3], descs[5], descs[6], descs[7], descs[8], descs[9], descs[10], descs[11]} for _, node := range stillPresent { if exists, _ := s.Exists(egCtx, node); !exists { t.Errorf("%v should exist in store", node) } } // delete blob 8 and verify the result if err := s.Delete(egCtx, descs[8]); err != nil { t.Fatal(err) } // blob 1, 4 and 8 are now deleted, and other blobs are still present notPresent = []ocispec.Descriptor{descs[1], descs[4], descs[8]} for _, node := range notPresent { if exists, _ := s.Exists(egCtx, node); exists { t.Errorf("%v should not exist in store", node) } } stillPresent = []ocispec.Descriptor{descs[0], descs[2], descs[3], descs[5], descs[6], descs[7], descs[9], descs[10], descs[11]} for _, node := range stillPresent { if exists, _ := s.Exists(egCtx, node); !exists { t.Errorf("%v should exist in store", node) } } // delete blob 6 and verify the result if err := s.Delete(egCtx, descs[6]); err != nil { t.Fatal(err) } // blob 1, 3, 4, 6, 8, 9, 10, 11 are now deleted, and other blobs are still present notPresent = []ocispec.Descriptor{descs[1], descs[3], descs[4], descs[6], descs[8], descs[9], descs[10], descs[11]} for _, node := range notPresent { if exists, _ := s.Exists(egCtx, node); exists { t.Errorf("%v should not exist in store", node) } } stillPresent = []ocispec.Descriptor{descs[0], descs[2], descs[5], descs[7]} for _, node := range stillPresent { if exists, _ := s.Exists(egCtx, node); !exists { t.Errorf("%v should exist in store", node) } } // verify predecessors information wants := [][]ocispec.Descriptor{ {descs[5]}, // Blob 0 nil, // Blob 1 {descs[5]}, // Blob 2 nil, // Blob 3 {descs[7]}, // Blob 4's predecessor is descs[7], even though blob 4 no longer exist {descs[7]}, // Blob 5 nil, // Blob 6 nil, // Blob 7 nil, // Blob 8 nil, // Blob 9 nil, // Blob 10 nil, // Blob 11 } for i, want := range wants { predecessors, err := s.Predecessors(ctx, descs[i]) if err != nil { t.Errorf("Store.Predecessors(%d) error = %v", i, err) } if !equalDescriptorSet(predecessors, want) { t.Errorf("Store.Predecessors(%d) = %v, want %v", i, predecessors, want) } } } func TestStore_Untag(t *testing.T) { content := []byte("test delete") desc := ocispec.Descriptor{ MediaType: "test-delete", Digest: digest.FromBytes(content), Size: int64(len(content)), } ref := "latest" tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("NewDeletableStore() error =", err) } ctx := context.Background() err = s.Push(ctx, desc, bytes.NewReader(content)) if err != nil { t.Errorf("Store.Push() error = %v, wantErr %v", err, false) } err = s.Tag(ctx, desc, ref) if err != nil { t.Errorf("error tagging descriptor error = %v, wantErr %v", err, false) } exists, err := s.Exists(ctx, desc) if err != nil { t.Fatal("Store.Exists() error =", err) } if !exists { t.Errorf("Store.Exists() = %v, want %v", exists, true) } resolvedDescr, err := s.Resolve(ctx, ref) if err != nil { t.Errorf("error resolving descriptor error = %v, wantErr %v", err, false) } if !reflect.DeepEqual(resolvedDescr, desc) { t.Errorf("Store.Resolve() = %v, want %v", resolvedDescr, desc) } err = s.Untag(ctx, ref) if err != nil { t.Errorf("Store.Untag() = %v, wantErr %v", err, nil) } _, err = s.Resolve(ctx, ref) if !errors.Is(err, errdef.ErrNotFound) { t.Errorf("error resolving descriptor error = %v, wantErr %v", err, errdef.ErrNotFound) } exists, err = s.Exists(ctx, desc) if err != nil { t.Fatal("Store.Exists() error =", err) } if !exists { t.Errorf("Store.Exists() = %v, want %v", exists, true) } } func TestStore_UntagErrorPath(t *testing.T) { content := []byte("test delete") desc := ocispec.Descriptor{ MediaType: "test-delete", Digest: digest.FromBytes(content), Size: int64(len(content)), } ref := "latest" tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("NewDeletableStore() error =", err) } ctx := context.Background() err = s.Untag(ctx, "") if !errors.Is(err, errdef.ErrMissingReference) { t.Errorf("Store.Untag() error = %v, wantErr %v", err, errdef.ErrMissingReference) } err = s.Untag(ctx, "foobar") if !errors.Is(err, errdef.ErrNotFound) { t.Errorf("Store.Untag() error = %v, wantErr %v", err, errdef.ErrNotFound) } err = s.Push(ctx, desc, bytes.NewReader(content)) if err != nil { t.Errorf("Store.Push() error = %v, wantErr %v", err, false) } err = s.Tag(ctx, desc, ref) if err != nil { t.Errorf("error tagging descriptor error = %v, wantErr %v", err, false) } exists, err := s.Exists(ctx, desc) if err != nil { t.Fatal("Store.Exists() error =", err) } if !exists { t.Errorf("Store.Exists() = %v, want %v", exists, true) } resolvedDescr, err := s.Resolve(ctx, ref) if err != nil { t.Errorf("error resolving descriptor error = %v, wantErr %v", err, false) } err = s.Untag(ctx, resolvedDescr.Digest.String()) if !errors.Is(err, errdef.ErrInvalidReference) { t.Errorf("Store.Untag() error = %v, wantErr %v", err, errdef.ErrInvalidReference) } } func TestStore_GC(t *testing.T) { tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("New() error =", err) } ctx := context.Background() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(config ocispec.Descriptor, subject *ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Subject: subject, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } generateImageIndex := func(manifests ...ocispec.Descriptor) { index := ocispec.Index{ Manifests: manifests, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageIndex, indexJSON) } generateArtifactManifest := func(blobs ...ocispec.Descriptor) { var manifest spec.Artifact manifest.Blobs = append(manifest.Blobs, blobs...) manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(spec.MediaTypeArtifactManifest, manifestJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("blob")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("dangling layer")) // Blob 2, dangling layer generateManifest(descs[0], nil, descs[1]) // Blob 3, valid manifest generateManifest(descs[0], &descs[3], descs[1]) // Blob 4, referrer of a valid manifest appendBlob(ocispec.MediaTypeImageLayer, []byte("dangling layer 2")) // Blob 5, dangling layer generateArtifactManifest(descs[4]) // blob 6, dangling artifact generateManifest(descs[0], &descs[5], descs[1]) // Blob 7, referrer of a dangling manifest appendBlob(ocispec.MediaTypeImageLayer, []byte("dangling layer 3")) // Blob 8, dangling layer generateArtifactManifest(descs[6]) // blob 9, dangling artifact generateImageIndex(descs[7], descs[5]) // blob 10, dangling image index appendBlob(ocispec.MediaTypeImageLayer, []byte("garbage layer 1")) // Blob 11, garbage layer 1 generateManifest(descs[0], nil, descs[4]) // Blob 12, garbage manifest 1 appendBlob(ocispec.MediaTypeImageConfig, []byte("garbage config")) // Blob 13, garbage config appendBlob(ocispec.MediaTypeImageLayer, []byte("garbage layer 2")) // Blob 14, garbage layer 2 generateManifest(descs[6], nil, descs[7]) // Blob 15, garbage manifest 2 generateManifest(descs[0], &descs[13], descs[1]) // Blob 16, referrer of a garbage manifest appendBlob(ocispec.MediaTypeImageLayer, []byte("another layer")) // Blob 17, untagged manifest generateManifest(descs[0], nil, descs[17]) // Blob 18, valid untagged manifest // push blobs 0 - blobs 10 into s for i := 0; i <= 10; i++ { err := s.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Errorf("failed to push test content to src: %d: %v", i, err) } } // push blobs 17 - blobs 18 into s for i := 17; i <= 18; i++ { err := s.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Errorf("failed to push test content to src: %d: %v", i, err) } } // remove blobs 5 - blobs 10 from index.json for i := 5; i <= 10; i++ { s.tagResolver.Untag(string(descs[i].Digest)) } s.SaveIndex() // push blobs 11 - blobs 16 into s.storage, making them garbage as their metadata // doesn't exist in s for i := 11; i < 17; i++ { err := s.storage.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Errorf("failed to push test content to src: %d: %v", i, err) } } // confirm that all the blobs are in the storage for i := 0; i < len(blobs); i++ { exists, err := s.Exists(ctx, descs[i]) if err != nil { t.Fatal(err) } if !exists { t.Fatalf("descs[%d] should exist", i) } } // tag manifest blob 3 s.Tag(ctx, descs[3], "latest") // perform double GC if err = s.GC(ctx); err != nil { t.Fatal(err) } if err = s.GC(ctx); err != nil { t.Fatal(err) } // verify existence wantExistence := []bool{true, true, false, true, true, false, false, false, false, false, false, false, false, false, false, false, false, false, false} for i, wantValue := range wantExistence { exists, err := s.Exists(ctx, descs[i]) if err != nil { t.Fatal(err) } if exists != wantValue { t.Fatalf("want existence %d to be %v, got %v", i, wantValue, exists) } } } func TestStore_GCAndDeleteOnIndex(t *testing.T) { tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("New() error =", err) } ctx := context.Background() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(config ocispec.Descriptor, subject *ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Subject: subject, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } generateImageIndex := func(manifests ...ocispec.Descriptor) { index := ocispec.Index{ Manifests: manifests, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageIndex, indexJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("blob1")) // Blob 1 generateManifest(descs[0], nil, descs[1]) // Blob 2, manifest appendBlob(ocispec.MediaTypeImageLayer, []byte("blob2")) // Blob 3 generateManifest(descs[0], nil, descs[3]) // Blob 4, manifest appendBlob(ocispec.MediaTypeImageLayer, []byte("blob3")) // Blob 5 generateManifest(descs[0], nil, descs[5]) // Blob 6, manifest appendBlob(ocispec.MediaTypeImageLayer, []byte("blob4")) // Blob 7 generateManifest(descs[0], nil, descs[7]) // Blob 8, manifest generateImageIndex(descs[2], descs[4], descs[6], descs[8]) // blob 9, image index // push all blobs into the store for i := 0; i < len(blobs); i++ { err := s.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Errorf("failed to push test content to src: %d: %v", i, err) } } // confirm that all the blobs are in the storage for i := 0; i < len(blobs); i++ { exists, err := s.Exists(ctx, descs[i]) if err != nil { t.Fatal(err) } if !exists { t.Fatalf("descs[%d] should exist", i) } } // tag manifest blob 8 s.Tag(ctx, descs[8], "latest") // delete the image index if err := s.Delete(ctx, descs[9]); err != nil { t.Fatal(err) } // verify existence wantExistence := []bool{true, false, false, false, false, false, false, true, true, false} for i, wantValue := range wantExistence { exists, err := s.Exists(ctx, descs[i]) if err != nil { t.Fatal(err) } if exists != wantValue { t.Fatalf("want existence %d to be %v, got %v", i, wantValue, exists) } } } func TestStore_GCErrorPath(t *testing.T) { tempDir := t.TempDir() s, err := New(tempDir) if err != nil { t.Fatal("New() error =", err) } ctx := context.Background() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } appendBlob(ocispec.MediaTypeImageLayer, []byte("valid blob")) // Blob 0 // push the valid blob err = s.Push(ctx, descs[0], bytes.NewReader(blobs[0])) if err != nil { t.Error("failed to push test content to src") } // write random contents algPath := path.Join(tempDir, "blobs") dgstPath := path.Join(algPath, "sha256") if err := os.WriteFile(path.Join(algPath, "other"), []byte("random"), 0444); err != nil { t.Fatal("error calling WriteFile(), error =", err) } if err := os.WriteFile(path.Join(dgstPath, "other2"), []byte("random2"), 0444); err != nil { t.Fatal("error calling WriteFile(), error =", err) } // perform GC if err = s.GC(ctx); err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageLayer, []byte("valid blob 2")) // Blob 1 // push the valid blob err = s.Push(ctx, descs[1], bytes.NewReader(blobs[1])) if err != nil { t.Error("failed to push test content to src") } // unknown algorithm if err := os.Mkdir(path.Join(algPath, "sha666"), 0777); err != nil { t.Fatal(err) } if err = s.GC(ctx); err != nil { t.Fatal("this error should be silently ignored") } // os.Remove() error badDigest := digest.FromBytes([]byte("bad digest")).Encoded() badPath := path.Join(algPath, "sha256", badDigest) if err := os.Mkdir(badPath, 0777); err != nil { t.Fatal(err) } if err := os.WriteFile(path.Join(badPath, "whatever"), []byte("extra content"), 0444); err != nil { t.Fatal("error calling WriteFile(), error =", err) } if err = s.GC(ctx); err == nil { t.Fatal("expect an error when os.Remove()") } } func equalDescriptorSet(actual []ocispec.Descriptor, expected []ocispec.Descriptor) bool { if len(actual) != len(expected) { return false } contains := func(node ocispec.Descriptor) bool { for _, candidate := range actual { if reflect.DeepEqual(candidate, node) { return true } } return false } for _, node := range expected { if !contains(node) { return false } } return true } func Test_isContextDone(t *testing.T) { ctx := context.Background() ctx, cancel := context.WithCancel(ctx) if err := isContextDone(ctx); err != nil { t.Errorf("expect error = %v, got %v", nil, err) } cancel() if err := isContextDone(ctx); err != context.Canceled { t.Errorf("expect error = %v, got %v", context.Canceled, err) } } oras-go-2.5.0/content/oci/readonlyoci.go000066400000000000000000000172701457674530300201750ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package oci import ( "context" "encoding/json" "errors" "fmt" "io" "io/fs" "slices" "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/errdef" "oras.land/oras-go/v2/internal/descriptor" "oras.land/oras-go/v2/internal/fs/tarfs" "oras.land/oras-go/v2/internal/graph" "oras.land/oras-go/v2/internal/resolver" ) // ReadOnlyStore implements `oras.ReadonlyTarget`, and represents a read-only // content store based on file system with the OCI-Image layout. // Reference: https://github.com/opencontainers/image-spec/blob/v1.1.0/image-layout.md type ReadOnlyStore struct { fsys fs.FS storage content.ReadOnlyStorage tagResolver *resolver.Memory graph *graph.Memory } // NewFromFS creates a new read-only OCI store from fsys. func NewFromFS(ctx context.Context, fsys fs.FS) (*ReadOnlyStore, error) { store := &ReadOnlyStore{ fsys: fsys, storage: NewStorageFromFS(fsys), tagResolver: resolver.NewMemory(), graph: graph.NewMemory(), } if err := store.validateOCILayoutFile(); err != nil { return nil, fmt.Errorf("invalid OCI Image Layout: %w", err) } if err := store.loadIndexFile(ctx); err != nil { return nil, fmt.Errorf("invalid OCI Image Index: %w", err) } return store, nil } // NewFromTar creates a new read-only OCI store from a tar archive located at // path. func NewFromTar(ctx context.Context, path string) (*ReadOnlyStore, error) { tfs, err := tarfs.New(path) if err != nil { return nil, err } return NewFromFS(ctx, tfs) } // Fetch fetches the content identified by the descriptor. func (s *ReadOnlyStore) Fetch(ctx context.Context, target ocispec.Descriptor) (io.ReadCloser, error) { return s.storage.Fetch(ctx, target) } // Exists returns true if the described content exists. func (s *ReadOnlyStore) Exists(ctx context.Context, target ocispec.Descriptor) (bool, error) { return s.storage.Exists(ctx, target) } // Resolve resolves a reference to a descriptor. If the reference to be resolved // is a tag, the returned descriptor will be a full descriptor declared by // github.com/opencontainers/image-spec/specs-go/v1. If the reference is a // digest the returned descriptor will be a plain descriptor (containing only // the digest, media type and size). func (s *ReadOnlyStore) Resolve(ctx context.Context, reference string) (ocispec.Descriptor, error) { if reference == "" { return ocispec.Descriptor{}, errdef.ErrMissingReference } // attempt resolving manifest desc, err := s.tagResolver.Resolve(ctx, reference) if err != nil { if errors.Is(err, errdef.ErrNotFound) { // attempt resolving blob return resolveBlob(s.fsys, reference) } return ocispec.Descriptor{}, err } if reference == desc.Digest.String() { return descriptor.Plain(desc), nil } return desc, nil } // Predecessors returns the nodes directly pointing to the current node. // Predecessors returns nil without error if the node does not exists in the // store. func (s *ReadOnlyStore) Predecessors(ctx context.Context, node ocispec.Descriptor) ([]ocispec.Descriptor, error) { return s.graph.Predecessors(ctx, node) } // Tags lists the tags presented in the `index.json` file of the OCI layout, // returned in ascending order. // If `last` is NOT empty, the entries in the response start after the tag // specified by `last`. Otherwise, the response starts from the top of the tags // list. // // See also `Tags()` in the package `registry`. func (s *ReadOnlyStore) Tags(ctx context.Context, last string, fn func(tags []string) error) error { return listTags(s.tagResolver, last, fn) } // validateOCILayoutFile validates the `oci-layout` file. func (s *ReadOnlyStore) validateOCILayoutFile() error { layoutFile, err := s.fsys.Open(ocispec.ImageLayoutFile) if err != nil { return fmt.Errorf("failed to open OCI layout file: %w", err) } defer layoutFile.Close() var layout ocispec.ImageLayout err = json.NewDecoder(layoutFile).Decode(&layout) if err != nil { return fmt.Errorf("failed to decode OCI layout file: %w", err) } return validateOCILayout(&layout) } // validateOCILayout validates layout. func validateOCILayout(layout *ocispec.ImageLayout) error { if layout.Version != ocispec.ImageLayoutVersion { return errdef.ErrUnsupportedVersion } return nil } // loadIndexFile reads index.json from s.fsys. func (s *ReadOnlyStore) loadIndexFile(ctx context.Context) error { indexFile, err := s.fsys.Open(ocispec.ImageIndexFile) if err != nil { return fmt.Errorf("failed to open index file: %w", err) } defer indexFile.Close() var index ocispec.Index if err := json.NewDecoder(indexFile).Decode(&index); err != nil { return fmt.Errorf("failed to decode index file: %w", err) } return loadIndex(ctx, &index, s.storage, s.tagResolver, s.graph) } // loadIndex loads index into memory. func loadIndex(ctx context.Context, index *ocispec.Index, fetcher content.Fetcher, tagger content.Tagger, graph *graph.Memory) error { for _, desc := range index.Manifests { if err := tagger.Tag(ctx, deleteAnnotationRefName(desc), desc.Digest.String()); err != nil { return err } if ref := desc.Annotations[ocispec.AnnotationRefName]; ref != "" { if err := tagger.Tag(ctx, desc, ref); err != nil { return err } } plain := descriptor.Plain(desc) if err := graph.IndexAll(ctx, fetcher, plain); err != nil { return err } } return nil } // resolveBlob returns a descriptor describing the blob identified by dgst. func resolveBlob(fsys fs.FS, dgst string) (ocispec.Descriptor, error) { path, err := blobPath(digest.Digest(dgst)) if err != nil { if errors.Is(err, errdef.ErrInvalidDigest) { return ocispec.Descriptor{}, errdef.ErrNotFound } return ocispec.Descriptor{}, err } fi, err := fs.Stat(fsys, path) if err != nil { if errors.Is(err, fs.ErrNotExist) { return ocispec.Descriptor{}, errdef.ErrNotFound } return ocispec.Descriptor{}, err } return ocispec.Descriptor{ MediaType: descriptor.DefaultMediaType, Size: fi.Size(), Digest: digest.Digest(dgst), }, nil } // listTags returns the tags in ascending order. // If `last` is NOT empty, the entries in the response start after the tag // specified by `last`. Otherwise, the response starts from the top of the tags // list. // // See also `Tags()` in the package `registry`. func listTags(tagResolver *resolver.Memory, last string, fn func(tags []string) error) error { var tags []string tagMap := tagResolver.Map() for tag, desc := range tagMap { if tag == desc.Digest.String() { continue } if last != "" && tag <= last { continue } tags = append(tags, tag) } slices.Sort(tags) return fn(tags) } // deleteAnnotationRefName deletes the AnnotationRefName from the annotation map // of desc. func deleteAnnotationRefName(desc ocispec.Descriptor) ocispec.Descriptor { if _, ok := desc.Annotations[ocispec.AnnotationRefName]; !ok { // no ops return desc } size := len(desc.Annotations) - 1 if size == 0 { desc.Annotations = nil return desc } annotations := make(map[string]string, size) for k, v := range desc.Annotations { if k != ocispec.AnnotationRefName { annotations[k] = v } } desc.Annotations = annotations return desc } oras-go-2.5.0/content/oci/readonlyoci_test.go000066400000000000000000000601671457674530300212370ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package oci import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "os" "reflect" "strconv" "strings" "testing" "testing/fstest" "github.com/opencontainers/go-digest" specs "github.com/opencontainers/image-spec/specs-go" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "golang.org/x/sync/errgroup" "oras.land/oras-go/v2" "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/content/memory" "oras.land/oras-go/v2/internal/docker" "oras.land/oras-go/v2/internal/spec" "oras.land/oras-go/v2/registry" ) func TestReadonlyStoreInterface(t *testing.T) { var store interface{} = &ReadOnlyStore{} if _, ok := store.(oras.ReadOnlyGraphTarget); !ok { t.Error("&ReadOnlyStore{} does not conform oras.ReadOnlyGraphTarget") } if _, ok := store.(registry.TagLister); !ok { t.Error("&ReadOnlyStore{} does not conform registry.TagLister") } } func TestReadOnlyStore(t *testing.T) { // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ MediaType: ocispec.MediaTypeImageManifest, Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(manifest.MediaType, manifestJSON) } generateArtifactManifest := func(subject ocispec.Descriptor, blobs ...ocispec.Descriptor) { manifest := spec.Artifact{ MediaType: spec.MediaTypeArtifactManifest, Subject: &subject, Blobs: blobs, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(manifest.MediaType, manifestJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foobar")) // Blob 1 generateManifest(descs[0], descs[1]) // Blob 2 generateArtifactManifest(descs[2]) // Blob 3 subjectTag := "subject" layout := ocispec.ImageLayout{ Version: ocispec.ImageLayoutVersion, } layoutJSON, err := json.Marshal(layout) if err != nil { t.Fatalf("failed to marshal OCI layout: %v", err) } index := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value }, Manifests: []ocispec.Descriptor{ { MediaType: descs[2].MediaType, Size: descs[2].Size, Digest: descs[2].Digest, Annotations: map[string]string{ocispec.AnnotationRefName: subjectTag}, }, descs[3], }, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatalf("failed to marshal index: %v", err) } // build fs fsys := fstest.MapFS{} for i, desc := range descs { path := strings.Join([]string{"blobs", desc.Digest.Algorithm().String(), desc.Digest.Encoded()}, "/") fsys[path] = &fstest.MapFile{Data: blobs[i]} } fsys[ocispec.ImageLayoutFile] = &fstest.MapFile{Data: layoutJSON} fsys["index.json"] = &fstest.MapFile{Data: indexJSON} // test read-only store ctx := context.Background() s, err := NewFromFS(ctx, fsys) if err != nil { t.Fatal("NewFromFS() error =", err) } // test resolving subject by digest gotDesc, err := s.Resolve(ctx, descs[2].Digest.String()) if err != nil { t.Error("ReadOnlyStore.Resolve() error =", err) } if want := descs[2]; !reflect.DeepEqual(gotDesc, want) { t.Errorf("ReadOnlyStore.Resolve() = %v, want %v", gotDesc, want) } // test resolving subject by tag gotDesc, err = s.Resolve(ctx, subjectTag) if err != nil { t.Error("ReadOnlyStore.Resolve() error =", err) } if want := descs[2]; !content.Equal(gotDesc, want) { t.Errorf("ReadOnlyStore.Resolve() = %v, want %v", gotDesc, want) } // descriptor resolved by tag should have annotations if gotDesc.Annotations[ocispec.AnnotationRefName] != subjectTag { t.Errorf("ReadOnlyStore.Resolve() returned descriptor without annotations %v, want %v", gotDesc.Annotations, map[string]string{ocispec.AnnotationRefName: subjectTag}) } // test resolving artifact by digest gotDesc, err = s.Resolve(ctx, descs[3].Digest.String()) if err != nil { t.Error("ReadOnlyStore.Resolve() error =", err) } if want := descs[3]; !reflect.DeepEqual(gotDesc, want) { t.Errorf("ReadOnlyStore.Resolve() = %v, want %v", gotDesc, want) } // test resolving blob by digest gotDesc, err = s.Resolve(ctx, descs[0].Digest.String()) if err != nil { t.Error("ReadOnlyStore.Resolve() error =", err) } if want := descs[0]; gotDesc.Size != want.Size || gotDesc.Digest != want.Digest { t.Errorf("ReadOnlyStore.Resolve() = %v, want %v", gotDesc, want) } // test fetching blobs eg, egCtx := errgroup.WithContext(ctx) for i := range blobs { eg.Go(func(i int) func() error { return func() error { rc, err := s.Fetch(egCtx, descs[i]) if err != nil { return fmt.Errorf("ReadOnlyStore.Fetch(%d) error = %v", i, err) } got, err := io.ReadAll(rc) if err != nil { return fmt.Errorf("ReadOnlyStore.Fetch(%d).Read() error = %v", i, err) } err = rc.Close() if err != nil { return fmt.Errorf("ReadOnlyStore.Fetch(%d).Close() error = %v", i, err) } if !bytes.Equal(got, blobs[i]) { return fmt.Errorf("ReadOnlyStore.Fetch(%d) = %v, want %v", i, got, blobs[i]) } return nil } }(i)) } if err := eg.Wait(); err != nil { t.Fatal(err) } // test predecessors wants := [][]ocispec.Descriptor{ {descs[2]}, // blob 0 {descs[2]}, // blob 1 {descs[3]}, // blob 2, {}, // blob 3 } for i, want := range wants { predecessors, err := s.Predecessors(ctx, descs[i]) if err != nil { t.Errorf("ReadOnlyStore.Predecessors(%d) error = %v", i, err) } if !equalDescriptorSet(predecessors, want) { t.Errorf("ReadOnlyStore.Predecessors(%d) = %v, want %v", i, predecessors, want) } } } func TestReadOnlyStore_DirFS(t *testing.T) { tempDir := t.TempDir() // build an OCI layout on disk s, err := New(tempDir) if err != nil { t.Fatal("New() error =", err) } // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } generateIndex := func(manifests ...ocispec.Descriptor) { index := ocispec.Index{ Manifests: manifests, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageIndex, indexJSON) } generateArtifactManifest := func(subject ocispec.Descriptor, blobs ...ocispec.Descriptor) { var manifest spec.Artifact manifest.Subject = &subject manifest.Blobs = append(manifest.Blobs, blobs...) manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(spec.MediaTypeArtifactManifest, manifestJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 appendBlob(ocispec.MediaTypeImageLayer, []byte("hello")) // Blob 3 generateManifest(descs[0], descs[1:3]...) // Blob 4 generateManifest(descs[0], descs[3]) // Blob 5 generateManifest(descs[0], descs[1:4]...) // Blob 6 generateIndex(descs[4:6]...) // Blob 7 generateIndex(descs[6]) // Blob 8 generateIndex() // Blob 9 generateIndex(descs[7:10]...) // Blob 10 appendBlob(ocispec.MediaTypeImageLayer, []byte("sig_1")) // Blob 11 generateArtifactManifest(descs[6], descs[11]) // Blob 12 appendBlob(ocispec.MediaTypeImageLayer, []byte("sig_2")) // Blob 13 generateArtifactManifest(descs[10], descs[13]) // Blob 14 ctx := context.Background() eg, egCtx := errgroup.WithContext(ctx) for i := range blobs { eg.Go(func(i int) func() error { return func() error { err := s.Push(egCtx, descs[i], bytes.NewReader(blobs[i])) if err != nil { return fmt.Errorf("failed to push test content to src: %d: %v", i, err) } return nil } }(i)) } if err := eg.Wait(); err != nil { t.Fatal(err) } // tag index root indexRoot := descs[10] tag := "latest" if err := s.Tag(ctx, indexRoot, tag); err != nil { t.Fatal("Tag() error =", err) } // test read-only store readonlyS, err := NewFromFS(ctx, os.DirFS(tempDir)) if err != nil { t.Fatal("New() error =", err) } // test resolving index root by tag gotDesc, err := readonlyS.Resolve(ctx, tag) if err != nil { t.Fatal("ReadOnlyStore: Resolve() error =", err) } if !content.Equal(gotDesc, indexRoot) { t.Errorf("ReadOnlyStore.Resolve() = %v, want %v", gotDesc, indexRoot) } // test resolving index root by digest gotDesc, err = readonlyS.Resolve(ctx, indexRoot.Digest.String()) if err != nil { t.Fatal("ReadOnlyStore: Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, indexRoot) { t.Errorf("ReadOnlyStore.Resolve() = %v, want %v", gotDesc, indexRoot) } // test resolving artifact manifest by digest artifactRootDesc := descs[12] gotDesc, err = readonlyS.Resolve(ctx, artifactRootDesc.Digest.String()) if err != nil { t.Fatal("ReadOnlyStore: Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, artifactRootDesc) { t.Errorf("ReadOnlyStore.Resolve() = %v, want %v", gotDesc, artifactRootDesc) } // test resolving blob by digest gotDesc, err = readonlyS.Resolve(ctx, descs[0].Digest.String()) if err != nil { t.Fatal("ReadOnlyStore: Resolve() error =", err) } if want := descs[0]; gotDesc.Size != want.Size || gotDesc.Digest != want.Digest { t.Errorf("ReadOnlyStore.Resolve() = %v, want %v", gotDesc, want) } // test fetching blobs for i := range blobs { eg.Go(func(i int) func() error { return func() error { rc, err := s.Fetch(egCtx, descs[i]) if err != nil { return fmt.Errorf("ReadOnlyStore.Fetch(%d) error = %v", i, err) } got, err := io.ReadAll(rc) if err != nil { return fmt.Errorf("ReadOnlyStore.Fetch(%d).Read() error = %v", i, err) } err = rc.Close() if err != nil { return fmt.Errorf("ReadOnlyStore.Fetch(%d).Close() error = %v", i, err) } if !bytes.Equal(got, blobs[i]) { return fmt.Errorf("ReadOnlyStore.Fetch(%d) = %v, want %v", i, got, blobs[i]) } return nil } }(i)) } if err := eg.Wait(); err != nil { t.Fatal(err) } // verify predecessors wants := [][]ocispec.Descriptor{ descs[4:7], // Blob 0 {descs[4], descs[6]}, // Blob 1 {descs[4], descs[6]}, // Blob 2 {descs[5], descs[6]}, // Blob 3 {descs[7]}, // Blob 4 {descs[7]}, // Blob 5 {descs[8], descs[12]}, // Blob 6 {descs[10]}, // Blob 7 {descs[10]}, // Blob 8 {descs[10]}, // Blob 9 {descs[14]}, // Blob 10 {descs[12]}, // Blob 11 nil, // Blob 12, no predecessors {descs[14]}, // Blob 13 nil, // Blob 14, no predecessors } for i, want := range wants { predecessors, err := readonlyS.Predecessors(ctx, descs[i]) if err != nil { t.Errorf("ReadOnlyStore.Predecessors(%d) error = %v", i, err) } if !equalDescriptorSet(predecessors, want) { t.Errorf("ReadOnlyStore.Predecessors(%d) = %v, want %v", i, predecessors, want) } } } /* testdata/hello-world.tar contains: blobs/ sha256/ 2db29710123e3e53a794f2694094b9b4338aa9ee5c40b930cb8063a1be392c54 // image layer f54a58bc1aac5ea1a25d796ae155dc228b3f0e11d046ae276b39c4bf2f13d8c4 // image manifest faa03e786c97f07ef34423fccceeec2398ec8a5759259f94d99078f264e9d7af // manifest list feb5d9fea6a5e9606aa995e879d862b825965ba48de054caab5ef356dc6b3412 // config index.json manifest.json oci-layout */ func TestReadOnlyStore_TarFS(t *testing.T) { ctx := context.Background() s, err := NewFromTar(ctx, "testdata/hello-world.tar") if err != nil { t.Fatal("New() error =", err) } // test data in testdata/hello-world.tar descs := []ocispec.Descriptor{ // desc 0: config { MediaType: "application/vnd.docker.container.image.v1+json", Size: 1469, Digest: "sha256:feb5d9fea6a5e9606aa995e879d862b825965ba48de054caab5ef356dc6b3412", }, // desc 1: layer { MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip", Size: 2479, Digest: "sha256:2db29710123e3e53a794f2694094b9b4338aa9ee5c40b930cb8063a1be392c54", }, // desc 2: image manifest { MediaType: "application/vnd.docker.distribution.manifest.v2+json", Digest: "sha256:f54a58bc1aac5ea1a25d796ae155dc228b3f0e11d046ae276b39c4bf2f13d8c4", Size: 525, Platform: &ocispec.Platform{ Architecture: "amd64", OS: "linux", }, }, // desc 3: manifest list { MediaType: docker.MediaTypeManifestList, Size: 2561, Digest: "sha256:faa03e786c97f07ef34423fccceeec2398ec8a5759259f94d99078f264e9d7af", }, } // test resolving by tag for _, desc := range descs { gotDesc, err := s.Resolve(ctx, desc.Digest.String()) if err != nil { t.Fatal("ReadOnlyStore: Resolve() error =", err) } if want := desc; gotDesc.Size != want.Size || gotDesc.Digest != want.Digest { t.Errorf("ReadOnlyStore.Resolve() = %v, want %v", gotDesc, want) } } // test resolving by tag gotDesc, err := s.Resolve(ctx, "latest") if err != nil { t.Fatal("ReadOnlyStore: Resolve() error =", err) } if want := descs[3]; gotDesc.Size != want.Size || gotDesc.Digest != want.Digest { t.Errorf("ReadOnlyStore.Resolve() = %v, want %v", gotDesc, want) } // test Predecessors wantPredecessors := [][]ocispec.Descriptor{ {descs[2]}, // desc 0 {descs[2]}, // desc 1 {descs[3]}, // desc 2 {}, // desc 3 } for i, want := range wantPredecessors { predecessors, err := s.Predecessors(ctx, descs[i]) if err != nil { t.Errorf("ReadOnlyStore.Predecessors(%d) error = %v", i, err) } if !equalDescriptorSet(predecessors, want) { t.Errorf("ReadOnlyStore.Predecessors(%d) = %v, want %v", i, predecessors, want) } } } func TestReadOnlyStore_BadIndex(t *testing.T) { content := []byte("whatever") fsys := fstest.MapFS{ "index.json": &fstest.MapFile{Data: content}, } ctx := context.Background() _, err := NewFromFS(ctx, fsys) if err == nil { t.Errorf("NewFromFS() error = %v, wantErr %v", err, true) } } func TestReadOnlyStore_BadLayout(t *testing.T) { content := []byte("whatever") fsys := fstest.MapFS{ ocispec.ImageLayoutFile: &fstest.MapFile{Data: content}, } ctx := context.Background() _, err := NewFromFS(ctx, fsys) if err == nil { t.Errorf("NewFromFS() error = %v, wantErr %v", err, true) } } func TestReadOnlyStore_Copy_OCIToMemory(t *testing.T) { // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ MediaType: ocispec.MediaTypeImageManifest, Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(manifest.MediaType, manifestJSON) } generateArtifactManifest := func(subject ocispec.Descriptor, blobs ...ocispec.Descriptor) { manifest := spec.Artifact{ MediaType: spec.MediaTypeArtifactManifest, Subject: &subject, Blobs: blobs, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(manifest.MediaType, manifestJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foobar")) // Blob 1 generateManifest(descs[0], descs[1]) // Blob 2 generateArtifactManifest(descs[2]) // Blob 3 tag := "foobar" root := descs[3] layout := ocispec.ImageLayout{ Version: ocispec.ImageLayoutVersion, } layoutJSON, err := json.Marshal(layout) if err != nil { t.Fatalf("failed to marshal OCI layout: %v", err) } index := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value }, Manifests: []ocispec.Descriptor{ { MediaType: descs[3].MediaType, Digest: descs[3].Digest, Size: descs[3].Size, Annotations: map[string]string{ ocispec.AnnotationRefName: tag, }, }, }, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatalf("failed to marshal index: %v", err) } // build fs fsys := fstest.MapFS{} for i, desc := range descs { path := strings.Join([]string{"blobs", desc.Digest.Algorithm().String(), desc.Digest.Encoded()}, "/") fsys[path] = &fstest.MapFile{Data: blobs[i]} } fsys[ocispec.ImageLayoutFile] = &fstest.MapFile{Data: layoutJSON} fsys["index.json"] = &fstest.MapFile{Data: indexJSON} // test read-only store ctx := context.Background() src, err := NewFromFS(ctx, fsys) if err != nil { t.Fatal("NewFromFS() error =", err) } // test copy dst := memory.New() gotDesc, err := oras.Copy(ctx, src, tag, dst, "", oras.DefaultCopyOptions) if err != nil { t.Fatalf("Copy() error = %v, wantErr %v", err, false) } if !content.Equal(gotDesc, root) { t.Errorf("Copy() = %v, want %v", gotDesc, root) } // verify contents for i, desc := range descs { exists, err := dst.Exists(ctx, desc) if err != nil { t.Fatalf("dst.Exists(%d) error = %v", i, err) } if !exists { t.Errorf("dst.Exists(%d) = %v, want %v", i, exists, true) } } // verify tag gotDesc, err = dst.Resolve(ctx, tag) if err != nil { t.Fatal("dst.Resolve() error =", err) } if !content.Equal(gotDesc, root) { t.Errorf("dst.Resolve() = %v, want %v", gotDesc, root) } } func TestReadOnlyStore_Tags(t *testing.T) { // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ MediaType: ocispec.MediaTypeImageManifest, Config: config, Layers: layers, } // add annotation to make each manifest unique manifest.Annotations = map[string]string{ "blob_index": strconv.Itoa(len(blobs)), } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(manifest.MediaType, manifestJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foobar")) // Blob 1 generateManifest(descs[0], descs[1]) // Blob 2 generateManifest(descs[0], descs[1]) // Blob 3 generateManifest(descs[0], descs[1]) // Blob 4 generateManifest(descs[0], descs[1]) // Blob 5 generateManifest(descs[0], descs[1]) // Blob 6 layout := ocispec.ImageLayout{ Version: ocispec.ImageLayoutVersion, } layoutJSON, err := json.Marshal(layout) if err != nil { t.Fatalf("failed to marshal OCI layout: %v", err) } index := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value }, } for _, desc := range descs[2:] { index.Manifests = append(index.Manifests, ocispec.Descriptor{ MediaType: desc.MediaType, Size: desc.Size, Digest: desc.Digest, }) } index.Manifests[1].Annotations = map[string]string{ocispec.AnnotationRefName: "v2"} index.Manifests[2].Annotations = map[string]string{ocispec.AnnotationRefName: "v3"} index.Manifests[3].Annotations = map[string]string{ocispec.AnnotationRefName: "v1"} index.Manifests[4].Annotations = map[string]string{ocispec.AnnotationRefName: "v4"} indexJSON, err := json.Marshal(index) if err != nil { t.Fatalf("failed to marshal index: %v", err) } // build fs fsys := fstest.MapFS{} for i, desc := range descs { path := strings.Join([]string{"blobs", desc.Digest.Algorithm().String(), desc.Digest.Encoded()}, "/") fsys[path] = &fstest.MapFile{Data: blobs[i]} } fsys[ocispec.ImageLayoutFile] = &fstest.MapFile{Data: layoutJSON} fsys["index.json"] = &fstest.MapFile{Data: indexJSON} // test read-only store ctx := context.Background() s, err := NewFromFS(ctx, fsys) if err != nil { t.Fatal("NewFromFS() error =", err) } // test tags tests := []struct { name string last string want []string }{ { name: "list all tags", want: []string{"v1", "v2", "v3", "v4"}, }, { name: "list from middle", last: "v2", want: []string{"v3", "v4"}, }, { name: "list from end", last: "v4", want: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := s.Tags(ctx, tt.last, func(got []string) error { if !reflect.DeepEqual(got, tt.want) { t.Errorf("ReadOnlyStore.Tags() = %v, want %v", got, tt.want) } return nil }); err != nil { t.Errorf("ReadOnlyStore.Tags() error = %v", err) } }) } wantErr := errors.New("expected error") if err := s.Tags(ctx, "", func(got []string) error { return wantErr }); err != wantErr { t.Errorf("ReadOnlyStore.Tags() error = %v, wantErr %v", err, wantErr) } } func Test_deleteAnnotationRefName(t *testing.T) { tests := []struct { name string desc ocispec.Descriptor want ocispec.Descriptor }{ { name: "No annotation", desc: ocispec.Descriptor{}, want: ocispec.Descriptor{}, }, { name: "Nil annotation", desc: ocispec.Descriptor{Annotations: nil}, want: ocispec.Descriptor{}, }, { name: "Empty annotation", desc: ocispec.Descriptor{Annotations: map[string]string{}}, want: ocispec.Descriptor{Annotations: map[string]string{}}, }, { name: "No RefName", desc: ocispec.Descriptor{Annotations: map[string]string{"foo": "bar"}}, want: ocispec.Descriptor{Annotations: map[string]string{"foo": "bar"}}, }, { name: "Empty RefName", desc: ocispec.Descriptor{Annotations: map[string]string{ "foo": "bar", ocispec.AnnotationRefName: "", }}, want: ocispec.Descriptor{Annotations: map[string]string{"foo": "bar"}}, }, { name: "RefName only", desc: ocispec.Descriptor{Annotations: map[string]string{ocispec.AnnotationRefName: "foobar"}}, want: ocispec.Descriptor{}, }, { name: "Multiple annotations with RefName", desc: ocispec.Descriptor{Annotations: map[string]string{ "foo": "bar", ocispec.AnnotationRefName: "foobar", }}, want: ocispec.Descriptor{Annotations: map[string]string{"foo": "bar"}}, }, { name: "Multiple annotations with empty RefName", desc: ocispec.Descriptor{Annotations: map[string]string{ "foo": "bar", ocispec.AnnotationRefName: "", }}, want: ocispec.Descriptor{Annotations: map[string]string{"foo": "bar"}}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := deleteAnnotationRefName(tt.desc); !reflect.DeepEqual(got, tt.want) { t.Errorf("deleteAnnotationRefName() = %v, want %v", got, tt.want) } }) } } oras-go-2.5.0/content/oci/readonlystorage.go000066400000000000000000000054071457674530300210660ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package oci import ( "context" "errors" "fmt" "io" "io/fs" "path" "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/errdef" "oras.land/oras-go/v2/internal/fs/tarfs" ) // ReadOnlyStorage is a read-only CAS based on file system with the OCI-Image // layout. // Reference: https://github.com/opencontainers/image-spec/blob/v1.1.0/image-layout.md type ReadOnlyStorage struct { fsys fs.FS } // NewStorageFromFS creates a new read-only CAS from fsys. func NewStorageFromFS(fsys fs.FS) *ReadOnlyStorage { return &ReadOnlyStorage{ fsys: fsys, } } // NewStorageFromTar creates a new read-only CAS from a tar archive located at // path. func NewStorageFromTar(path string) (*ReadOnlyStorage, error) { tfs, err := tarfs.New(path) if err != nil { return nil, err } return NewStorageFromFS(tfs), nil } // Fetch fetches the content identified by the descriptor. func (s *ReadOnlyStorage) Fetch(_ context.Context, target ocispec.Descriptor) (io.ReadCloser, error) { path, err := blobPath(target.Digest) if err != nil { return nil, fmt.Errorf("%s: %s: %w", target.Digest, target.MediaType, errdef.ErrInvalidDigest) } fp, err := s.fsys.Open(path) if err != nil { if errors.Is(err, fs.ErrNotExist) { return nil, fmt.Errorf("%s: %s: %w", target.Digest, target.MediaType, errdef.ErrNotFound) } return nil, err } return fp, nil } // Exists returns true if the described content Exists. func (s *ReadOnlyStorage) Exists(_ context.Context, target ocispec.Descriptor) (bool, error) { path, err := blobPath(target.Digest) if err != nil { return false, fmt.Errorf("%s: %s: %w", target.Digest, target.MediaType, errdef.ErrInvalidDigest) } _, err = fs.Stat(s.fsys, path) if err != nil { if errors.Is(err, fs.ErrNotExist) { return false, nil } return false, err } return true, nil } // blobPath calculates blob path from the given digest. func blobPath(dgst digest.Digest) (string, error) { if err := dgst.Validate(); err != nil { return "", fmt.Errorf("cannot calculate blob path from invalid digest %s: %w: %v", dgst.String(), errdef.ErrInvalidDigest, err) } return path.Join(ocispec.ImageBlobsDir, dgst.Algorithm().String(), dgst.Encoded()), nil } oras-go-2.5.0/content/oci/readonlystorage_test.go000066400000000000000000000163001457674530300221170ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package oci import ( "bytes" "context" "errors" "io" "os" "path/filepath" "strings" "testing" "testing/fstest" "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/errdef" "oras.land/oras-go/v2/internal/docker" ) func TestReadOnlyStorage_Exists(t *testing.T) { blob := []byte("test") dgst := digest.FromBytes(blob) desc := content.NewDescriptorFromBytes("", blob) fsys := fstest.MapFS{ strings.Join([]string{"blobs", dgst.Algorithm().String(), dgst.Encoded()}, "/"): {}, } s := NewStorageFromFS(fsys) ctx := context.Background() // test sha256 got, err := s.Exists(ctx, desc) if err != nil { t.Fatal("ReadOnlyStorage.Exists() error =", err) } if want := true; got != want { t.Errorf("ReadOnlyStorage.Exists() = %v, want %v", got, want) } // test not found blob = []byte("whaterver") desc = content.NewDescriptorFromBytes("", blob) got, err = s.Exists(ctx, desc) if err != nil { t.Fatal("ReadOnlyStorage.Exists() error =", err) } if want := false; got != want { t.Errorf("ReadOnlyStorage.Exists() = %v, want %v", got, want) } // test invalid digest desc = ocispec.Descriptor{Digest: "not a digest"} _, err = s.Exists(ctx, desc) if err == nil { t.Fatalf("ReadOnlyStorage.Exists() error = %v, wantErr %v", err, true) } } func TestReadOnlyStorage_Fetch(t *testing.T) { blob := []byte("test") dgst := digest.FromBytes(blob) desc := content.NewDescriptorFromBytes("", blob) fsys := fstest.MapFS{ strings.Join([]string{"blobs", dgst.Algorithm().String(), dgst.Encoded()}, "/"): { Data: blob, }, } s := NewStorageFromFS(fsys) ctx := context.Background() // test fetch rc, err := s.Fetch(ctx, desc) if err != nil { t.Fatal("ReadOnlyStorage.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("ReadOnlyStorage.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("ReadOnlyStorage.Fetch().Close() error =", err) } if !bytes.Equal(got, blob) { t.Errorf("ReadOnlyStorage.Fetch() = %v, want %v", got, blob) } // test not found anotherBlob := []byte("whatever") desc = content.NewDescriptorFromBytes("", anotherBlob) _, err = s.Fetch(ctx, desc) if !errors.Is(err, errdef.ErrNotFound) { t.Fatalf("ReadOnlyStorage.Fetch() error = %v, wantErr %v", err, errdef.ErrNotFound) } // test invalid digest desc = ocispec.Descriptor{Digest: "not a digest"} _, err = s.Fetch(ctx, desc) if err == nil { t.Fatalf("ReadOnlyStorage.Fetch() error = %v, wantErr %v", err, true) } } func TestReadOnlyStorage_DirFS(t *testing.T) { tempDir := t.TempDir() blob := []byte("test") dgst := digest.FromBytes(blob) desc := content.NewDescriptorFromBytes("test", blob) // write blob to disk path := filepath.Join(tempDir, "blobs", dgst.Algorithm().String(), dgst.Encoded()) if err := os.MkdirAll(filepath.Dir(path), 0777); err != nil { t.Fatal("error calling Mkdir(), error =", err) } if err := os.WriteFile(path, blob, 0444); err != nil { t.Fatal("error calling WriteFile(), error =", err) } s := NewStorageFromFS(os.DirFS(tempDir)) ctx := context.Background() // test Exists exists, err := s.Exists(ctx, desc) if err != nil { t.Fatal("ReadOnlyStorage.Exists() error =", err) } if !exists { t.Errorf("ReadOnlyStorage.Exists() = %v, want %v", exists, true) } // test Fetch rc, err := s.Fetch(ctx, desc) if err != nil { t.Fatal("ReadOnlyStorage.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("ReadOnlyStorage.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("ReadOnlyStorage.Fetch().Close() error =", err) } if !bytes.Equal(got, blob) { t.Errorf("ReadOnlyStorage.Fetch() = %v, want %v", got, blob) } } func TestReadOnlyStorage_TarFS(t *testing.T) { s, err := NewStorageFromTar("testdata/hello-world.tar") if err != nil { t.Fatal("NewStorageFromTar() error =", err) } ctx := context.Background() // test data in testdata/hello-world.tar blob := []byte(`{"architecture":"amd64","config":{"Hostname":"","Domainname":"","User":"","AttachStdin":false,"AttachStdout":false,"AttachStderr":false,"Tty":false,"OpenStdin":false,"StdinOnce":false,"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd":["/hello"],"Image":"sha256:b9935d4e8431fb1a7f0989304ec86b3329a99a25f5efdc7f09f3f8c41434ca6d","Volumes":null,"WorkingDir":"","Entrypoint":null,"OnBuild":null,"Labels":null},"container":"8746661ca3c2f215da94e6d3f7dfdcafaff5ec0b21c9aff6af3dc379a82fbc72","container_config":{"Hostname":"8746661ca3c2","Domainname":"","User":"","AttachStdin":false,"AttachStdout":false,"AttachStderr":false,"Tty":false,"OpenStdin":false,"StdinOnce":false,"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd":["/bin/sh","-c","#(nop) ","CMD [\"/hello\"]"],"Image":"sha256:b9935d4e8431fb1a7f0989304ec86b3329a99a25f5efdc7f09f3f8c41434ca6d","Volumes":null,"WorkingDir":"","Entrypoint":null,"OnBuild":null,"Labels":{}},"created":"2021-09-23T23:47:57.442225064Z","docker_version":"20.10.7","history":[{"created":"2021-09-23T23:47:57.098990892Z","created_by":"/bin/sh -c #(nop) COPY file:50563a97010fd7ce1ceebd1fa4f4891ac3decdf428333fb2683696f4358af6c2 in / "},{"created":"2021-09-23T23:47:57.442225064Z","created_by":"/bin/sh -c #(nop) CMD [\"/hello\"]","empty_layer":true}],"os":"linux","rootfs":{"type":"layers","diff_ids":["sha256:e07ee1baac5fae6a26f30cabfe54a36d3402f96afda318fe0a96cec4ca393359"]}}`) desc := content.NewDescriptorFromBytes(docker.MediaTypeManifest, blob) // test Exists exists, err := s.Exists(ctx, desc) if err != nil { t.Fatal("ReadOnlyStorage.Exists() error =", err) } if want := true; exists != want { t.Errorf("ReadOnlyStorage.Exists() = %v, want %v", exists, want) } // test Fetch rc, err := s.Fetch(ctx, desc) if err != nil { t.Fatal("ReadOnlyStorage.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("ReadOnlyStorage.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("ReadOnlyStorage.Fetch().Close() error =", err) } if !bytes.Equal(got, blob) { t.Errorf("ReadOnlyStorage.Fetch() = %v, want %v", got, blob) } // test Exists against a non-existing content blob = []byte("whatever") desc = content.NewDescriptorFromBytes("", blob) exists, err = s.Exists(ctx, desc) if err != nil { t.Fatal("ReadOnlyStorage.Exists() error =", err) } if want := false; exists != want { t.Errorf("ReadOnlyStorage.Exists() = %v, want %v", exists, want) } // test Fetch against a non-existing content _, err = s.Fetch(ctx, desc) if want := errdef.ErrNotFound; !errors.Is(err, want) { t.Errorf("ReadOnlyStorage.Fetch() error = %v, wantErr %v", err, want) } } oras-go-2.5.0/content/oci/storage.go000066400000000000000000000120041457674530300173170ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package oci import ( "context" "errors" "fmt" "io" "io/fs" "os" "path/filepath" "sync" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/errdef" "oras.land/oras-go/v2/internal/ioutil" ) // bufPool is a pool of byte buffers that can be reused for copying content // between files. var bufPool = sync.Pool{ New: func() interface{} { // the buffer size should be larger than or equal to 128 KiB // for performance considerations. // we choose 1 MiB here so there will be less disk I/O. buffer := make([]byte, 1<<20) // buffer size = 1 MiB return &buffer }, } // Storage is a CAS based on file system with the OCI-Image layout. // Reference: https://github.com/opencontainers/image-spec/blob/v1.1.0/image-layout.md type Storage struct { *ReadOnlyStorage // root is the root directory of the OCI layout. root string // ingestRoot is the root directory of the temporary ingest files. ingestRoot string } // NewStorage creates a new CAS based on file system with the OCI-Image layout. func NewStorage(root string) (*Storage, error) { rootAbs, err := filepath.Abs(root) if err != nil { return nil, fmt.Errorf("failed to resolve absolute path for %s: %w", root, err) } return &Storage{ ReadOnlyStorage: NewStorageFromFS(os.DirFS(rootAbs)), root: rootAbs, ingestRoot: filepath.Join(rootAbs, "ingest"), }, nil } // Push pushes the content, matching the expected descriptor. func (s *Storage) Push(_ context.Context, expected ocispec.Descriptor, content io.Reader) error { path, err := blobPath(expected.Digest) if err != nil { return fmt.Errorf("%s: %s: %w", expected.Digest, expected.MediaType, errdef.ErrInvalidDigest) } target := filepath.Join(s.root, path) // check if the target content already exists in the blob directory. if _, err := os.Stat(target); err == nil { return fmt.Errorf("%s: %s: %w", expected.Digest, expected.MediaType, errdef.ErrAlreadyExists) } else if !os.IsNotExist(err) { return err } if err := ensureDir(filepath.Dir(target)); err != nil { return err } // write the content to a temporary ingest file. ingest, err := s.ingest(expected, content) if err != nil { return err } // move the content from the temporary ingest file to the target path. // since blobs are read-only once stored, if the target blob already exists, // Rename() will fail for permission denied when trying to overwrite it. if err := os.Rename(ingest, target); err != nil { // remove the ingest file in case of error os.Remove(ingest) if errors.Is(err, os.ErrPermission) { return fmt.Errorf("%s: %s: %w", expected.Digest, expected.MediaType, errdef.ErrAlreadyExists) } return err } return nil } // Delete removes the target from the system. func (s *Storage) Delete(ctx context.Context, target ocispec.Descriptor) error { path, err := blobPath(target.Digest) if err != nil { return fmt.Errorf("%s: %s: %w", target.Digest, target.MediaType, errdef.ErrInvalidDigest) } targetPath := filepath.Join(s.root, path) err = os.Remove(targetPath) if err != nil { if errors.Is(err, fs.ErrNotExist) { return fmt.Errorf("%s: %s: %w", target.Digest, target.MediaType, errdef.ErrNotFound) } return err } return nil } // ingest write the content into a temporary ingest file. func (s *Storage) ingest(expected ocispec.Descriptor, content io.Reader) (path string, ingestErr error) { if err := ensureDir(s.ingestRoot); err != nil { return "", fmt.Errorf("failed to ensure ingest dir: %w", err) } // create a temp file with the file name format "blobDigest_randomString" // in the ingest directory. // Go ensures that multiple programs or goroutines calling CreateTemp // simultaneously will not choose the same file. fp, err := os.CreateTemp(s.ingestRoot, expected.Digest.Encoded()+"_*") if err != nil { return "", fmt.Errorf("failed to create ingest file: %w", err) } path = fp.Name() defer func() { // remove the temp file in case of error. // this executes after the file is closed. if ingestErr != nil { os.Remove(path) } }() defer fp.Close() buf := bufPool.Get().(*[]byte) defer bufPool.Put(buf) if err := ioutil.CopyBuffer(fp, content, *buf, expected); err != nil { return "", fmt.Errorf("failed to ingest: %w", err) } // change to readonly if err := os.Chmod(path, 0444); err != nil { return "", fmt.Errorf("failed to make readonly: %w", err) } return } // ensureDir ensures the directories of the path exists. func ensureDir(path string) error { return os.MkdirAll(path, 0777) } oras-go-2.5.0/content/oci/storage_test.go000066400000000000000000000234101457674530300203610ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package oci import ( "bytes" "context" "errors" "fmt" "io" "os" "path/filepath" "strings" "testing" "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "golang.org/x/sync/errgroup" "oras.land/oras-go/v2/errdef" ) func TestStorage_Success(t *testing.T) { content := []byte("hello world") desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), } tempDir := t.TempDir() s, err := NewStorage(tempDir) if err != nil { t.Fatal("New() error =", err) } ctx := context.Background() // test push err = s.Push(ctx, desc, bytes.NewReader(content)) if err != nil { t.Fatal("Storage.Push() error =", err) } // test fetch exists, err := s.Exists(ctx, desc) if err != nil { t.Fatal("Storage.Exists() error =", err) } if !exists { t.Errorf("Storage.Exists() = %v, want %v", exists, true) } rc, err := s.Fetch(ctx, desc) if err != nil { t.Fatal("Storage.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Storage.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Storage.Fetch().Close() error =", err) } if !bytes.Equal(got, content) { t.Errorf("Storage.Fetch() = %v, want %v", got, content) } } func TestStorage_RelativeRoot_Success(t *testing.T) { content := []byte("hello world") desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), } tempDir, err := filepath.EvalSymlinks(t.TempDir()) if err != nil { t.Fatal("error calling filepath.EvalSymlinks(), error =", err) } currDir, err := os.Getwd() if err != nil { t.Fatal("error calling Getwd(), error=", err) } if err := os.Chdir(tempDir); err != nil { t.Fatal("error calling Chdir(), error=", err) } s, err := NewStorage(".") if err != nil { t.Fatal("New() error =", err) } if want := tempDir; s.root != want { t.Errorf("Storage.root = %s, want %s", s.root, want) } // cd back to allow the temp directory to be removed if err := os.Chdir(currDir); err != nil { t.Fatal("error calling Chdir(), error=", err) } ctx := context.Background() // test push err = s.Push(ctx, desc, bytes.NewReader(content)) if err != nil { t.Fatal("Storage.Push() error =", err) } // test fetch exists, err := s.Exists(ctx, desc) if err != nil { t.Fatal("Storage.Exists() error =", err) } if !exists { t.Errorf("Storage.Exists() = %v, want %v", exists, true) } rc, err := s.Fetch(ctx, desc) if err != nil { t.Fatal("Storage.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Storage.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Storage.Fetch().Close() error =", err) } if !bytes.Equal(got, content) { t.Errorf("Storage.Fetch() = %v, want %v", got, content) } } func TestStorage_NotFound(t *testing.T) { content := []byte("hello world") desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), } tempDir := t.TempDir() s, err := NewStorage(tempDir) if err != nil { t.Fatal("New() error =", err) } ctx := context.Background() exists, err := s.Exists(ctx, desc) if err != nil { t.Error("Storage.Exists() error =", err) } if exists { t.Errorf("Storage.Exists() = %v, want %v", exists, false) } _, err = s.Fetch(ctx, desc) if !errors.Is(err, errdef.ErrNotFound) { t.Errorf("Storage.Fetch() error = %v, want %v", err, errdef.ErrNotFound) } } func TestStorage_AlreadyExists(t *testing.T) { content := []byte("hello world") desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), } tempDir := t.TempDir() s, err := NewStorage(tempDir) if err != nil { t.Fatal("New() error =", err) } ctx := context.Background() err = s.Push(ctx, desc, bytes.NewReader(content)) if err != nil { t.Fatal("Storage.Push() error =", err) } err = s.Push(ctx, desc, bytes.NewReader(content)) if !errors.Is(err, errdef.ErrAlreadyExists) { t.Errorf("Storage.Push() error = %v, want %v", err, errdef.ErrAlreadyExists) } } func TestStorage_BadPush(t *testing.T) { content := []byte("hello world") desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), } tempDir := t.TempDir() s, err := NewStorage(tempDir) if err != nil { t.Fatal("New() error =", err) } ctx := context.Background() err = s.Push(ctx, desc, strings.NewReader("foobar")) if err == nil { t.Errorf("Storage.Push() error = %v, wantErr %v", err, true) } } func TestStorage_Push_Concurrent(t *testing.T) { content := []byte("hello world") desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), } tempDir := t.TempDir() s, err := NewStorage(tempDir) if err != nil { t.Fatal("New() error =", err) } ctx := context.Background() concurrency := 64 eg, egCtx := errgroup.WithContext(ctx) for i := 0; i < concurrency; i++ { eg.Go(func(i int) func() error { return func() error { if err := s.Push(egCtx, desc, bytes.NewReader(content)); err != nil { if errors.Is(err, errdef.ErrAlreadyExists) { return nil } return fmt.Errorf("failed to push content: %v", err) } return nil } }(i)) } if err := eg.Wait(); err != nil { t.Fatal(err) } exists, err := s.Exists(ctx, desc) if err != nil { t.Fatal("Storage.Exists() error =", err) } if !exists { t.Errorf("Storage.Exists() = %v, want %v", exists, true) } rc, err := s.Fetch(ctx, desc) if err != nil { t.Fatal("Storage.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Storage.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Storage.Fetch().Close() error =", err) } if !bytes.Equal(got, content) { t.Errorf("Storage.Fetch() = %v, want %v", got, content) } } func TestStorage_Fetch_ExistingBlobs(t *testing.T) { content := []byte("hello world") dgst := digest.FromBytes(content) desc := ocispec.Descriptor{ MediaType: "test", Digest: dgst, Size: int64(len(content)), } tempDir := t.TempDir() path := filepath.Join(tempDir, "blobs", dgst.Algorithm().String(), dgst.Encoded()) if err := os.MkdirAll(filepath.Dir(path), 0777); err != nil { t.Fatal("error calling Mkdir(), error =", err) } if err := os.WriteFile(path, content, 0444); err != nil { t.Fatal("error calling WriteFile(), error =", err) } s, err := NewStorage(tempDir) if err != nil { t.Fatal("New() error =", err) } ctx := context.Background() exists, err := s.Exists(ctx, desc) if err != nil { t.Fatal("Storage.Exists() error =", err) } if !exists { t.Errorf("Storage.Exists() = %v, want %v", exists, true) } rc, err := s.Fetch(ctx, desc) if err != nil { t.Fatal("Storage.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Storage.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Storage.Fetch().Close() error =", err) } if !bytes.Equal(got, content) { t.Errorf("Storage.Fetch() = %v, want %v", got, content) } } func TestStorage_Fetch_Concurrent(t *testing.T) { content := []byte("hello world") desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), } tempDir := t.TempDir() s, err := NewStorage(tempDir) if err != nil { t.Fatal("New() error =", err) } ctx := context.Background() if err := s.Push(ctx, desc, bytes.NewReader(content)); err != nil { t.Fatal("Storage.Push() error =", err) } concurrency := 64 eg, egCtx := errgroup.WithContext(ctx) for i := 0; i < concurrency; i++ { eg.Go(func(i int) func() error { return func() error { rc, err := s.Fetch(egCtx, desc) if err != nil { return fmt.Errorf("failed to fetch content: %v", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Storage.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Storage.Fetch().Close() error =", err) } if !bytes.Equal(got, content) { t.Errorf("Storage.Fetch() = %v, want %v", got, content) } return nil } }(i)) } if err := eg.Wait(); err != nil { t.Fatal(err) } } func TestStorage_Delete(t *testing.T) { content := []byte("test delete") desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), } tempDir := t.TempDir() s, err := NewStorage(tempDir) if err != nil { t.Fatal("New() error =", err) } ctx := context.Background() if err := s.Push(ctx, desc, bytes.NewReader(content)); err != nil { t.Fatal("Storage.Push() error =", err) } exists, err := s.Exists(ctx, desc) if err != nil { t.Fatal("Storage.Exists() error =", err) } if !exists { t.Errorf("Storage.Exists() = %v, want %v", exists, true) } err = s.Delete(ctx, desc) if err != nil { t.Fatal("Storage.Delete() error =", err) } exists, err = s.Exists(ctx, desc) if err != nil { t.Fatal("Storage.Exists() error =", err) } if exists { t.Errorf("Storage.Exists() = %v, want %v", exists, false) } err = s.Delete(ctx, desc) if !errors.Is(err, errdef.ErrNotFound) { t.Fatalf("got error = %v, want %v", err, errdef.ErrNotFound) } } oras-go-2.5.0/content/oci/testdata/000077500000000000000000000000001457674530300171405ustar00rootroot00000000000000oras-go-2.5.0/content/oci/testdata/hello-world.tar000066400000000000000000000360001457674530300220770ustar00rootroot00000000000000blobs/0000755000000000000000000000000000000000000010306 5ustar0000000000000000blobs/sha256/0000755000000000000000000000000000000000000011316 5ustar0000000000000000blobs/sha256/2db29710123e3e53a794f2694094b9b4338aa9ee5c40b930cb8063a1be392c540000444000000000000000000000465700000000000021624 0ustar0000000000000000[l0?⨤ĺCKCʏbz:@ "lwV%S_OؿN!&4tP[Z lXi}޳u]RN͢#ѱiFnm uPWsCg(0wnBˆIE_;Vmq%qxs{\Elpyh3Ga-Ky /Gʮ+Æ\UVx<Ëlڋj]=BtƥE2uu.D5rv_ %_̫XW… .\p… .\|U),q }NX^z؋PӋP(_j"d#F}׾:s=}#m̕l&Ċ'>0H:m? "9f̓f(md,7>p e m߸K<YU倷5>\xvam7zUJZ55+ĿeY^7Bc"a ='+/C1_ P()09 %S@z!`\;h߻}0'W;!`SJP'\Tʽ>O^UN{ɞ=,;/ԃ5='+K1 lJk,bAY%*g#pBxn X33N-1+/m !QfM<s#=W2o6r`s*qӫ0MGX -˂b=`0x0 B?z@O_*XE/"zK?H?ħcV?䞛⁞fN}_Z`Y~ÔDz>T˾>|ϳ1ˇ=浏"vDa3D4*w31 1<@ 3fg0wDiT#|ptU7=M$RQgWfTb(;M$zf"(Esi#X`{8m@!y=?[o"y;6E;rjq{YQƯ+v1?c?Ё;p(H)|]`r!$ko0y&6[nSԴ< ƋF`ci=Z;ٷAHhZ<W hho '[&gzn 9`v :'?Ϯ6H@mrlOXٶ]HB_pk-F!s^S1kOٟwO% QʙmY c]Μ;`wV(rۜv;@'qw̓ӾέMs;33pT cʭp$2ch]EVB^Wm5>q'8ȷ# blfBѥ\CoY} +@:M9Fn:;,k!nK#O΃qWW]+/%p>} N^^_5% f‰:?BGa+$\wj]p… .9VdpT?-) gdMXK*&I1EWq*)HJbixXT xTQNe⸢r>_YYIr4LiL2Rie5"k[}8}IBSrฒ%R [T䌒 p"3t_c?~TQӉ58aڵW9}0V)x!p'qUXʣNZVI~4'gYe۱|HDLqXUz\ Xɰn7K㺪Yê,%RكS>Qe)c)TW洰ڙ"vFDqVj&u kJF&I,ELkvq)JRYW5b4E[}X ':O;AIK|IV)K:Q2l4VF\6fTvAVN2m ` +:JN˚+%R'V-?(T"'> &`VOfUz~y=7G<B|lY.EVs܅ .\p… w7ػ%\!ВQ4lJeS(* H( 'RFv8%UNAijP--z >oT }5e_~{ qkkk.[gUï# "VVy}|BJ?W}I+~. ={+-w_~]缷;zS_~;j;]p_})O:blobs/sha256/f54a58bc1aac5ea1a25d796ae155dc228b3f0e11d046ae276b39c4bf2f13d8c40000444000000000000000000000101500000000000022366 0ustar0000000000000000{ "schemaVersion": 2, "mediaType": "application/vnd.docker.distribution.manifest.v2+json", "config": { "mediaType": "application/vnd.docker.container.image.v1+json", "size": 1469, "digest": "sha256:feb5d9fea6a5e9606aa995e879d862b825965ba48de054caab5ef356dc6b3412" }, "layers": [ { "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", "size": 2479, "digest": "sha256:2db29710123e3e53a794f2694094b9b4338aa9ee5c40b930cb8063a1be392c54" } ] }blobs/sha256/faa03e786c97f07ef34423fccceeec2398ec8a5759259f94d99078f264e9d7af0000444000000000000000000000500100000000000022346 0ustar0000000000000000{"manifests":[{"digest":"sha256:f54a58bc1aac5ea1a25d796ae155dc228b3f0e11d046ae276b39c4bf2f13d8c4","mediaType":"application\/vnd.docker.distribution.manifest.v2+json","platform":{"architecture":"amd64","os":"linux"},"size":525},{"digest":"sha256:7b8b7289d0536a08eabdf71c20246e23f7116641db7e1d278592236ea4dcb30c","mediaType":"application\/vnd.docker.distribution.manifest.v2+json","platform":{"architecture":"arm","os":"linux","variant":"v5"},"size":525},{"digest":"sha256:f130bd2d67e6e9280ac6d0a6c83857bfaf70234e8ef4236876eccfbd30973b1c","mediaType":"application\/vnd.docker.distribution.manifest.v2+json","platform":{"architecture":"arm","os":"linux","variant":"v7"},"size":525},{"digest":"sha256:432f982638b3aefab73cc58ab28f5c16e96fdb504e8c134fc58dff4bae8bf338","mediaType":"application\/vnd.docker.distribution.manifest.v2+json","platform":{"architecture":"arm64","os":"linux","variant":"v8"},"size":525},{"digest":"sha256:995efde2e81b21d1ea7066aa77a59298a62a9e9fbb4b77f36c189774ec9b1089","mediaType":"application\/vnd.docker.distribution.manifest.v2+json","platform":{"architecture":"386","os":"linux"},"size":525},{"digest":"sha256:eb11b1a194ff8e236a01eff392c4e1296a53b0fb4780d8b0382f7996a15d5392","mediaType":"application\/vnd.docker.distribution.manifest.v2+json","platform":{"architecture":"mips64le","os":"linux"},"size":525},{"digest":"sha256:b836bb24a270b9cc935962d8228517fde0f16990e88893d935efcb1b14c0017a","mediaType":"application\/vnd.docker.distribution.manifest.v2+json","platform":{"architecture":"ppc64le","os":"linux"},"size":525},{"digest":"sha256:98c9722322be649df94780d3fbe594fce7996234b259f27eac9428b84050c849","mediaType":"application\/vnd.docker.distribution.manifest.v2+json","platform":{"architecture":"riscv64","os":"linux"},"size":525},{"digest":"sha256:c7b6944911848ce39b44ed660d95fb54d69bbd531de724c7ce6fc9f743c0b861","mediaType":"application\/vnd.docker.distribution.manifest.v2+json","platform":{"architecture":"s390x","os":"linux"},"size":525},{"digest":"sha256:fb353688bcf45fc724fde3d1dcd7935ddf56803e2b7027164a7acc28758002f6","mediaType":"application\/vnd.docker.distribution.manifest.v2+json","platform":{"architecture":"amd64","os":"windows","os.version":"10.0.20348.1249"},"size":946},{"digest":"sha256:c597fadc51747a7392f4d0312e9740aaee11793a47658a77a4ba51bd5d9b6be0","mediaType":"application\/vnd.docker.distribution.manifest.v2+json","platform":{"architecture":"amd64","os":"windows","os.version":"10.0.17763.3650"},"size":946}],"mediaType":"application\/vnd.docker.distribution.manifest.list.v2+json","schemaVersion":2}blobs/sha256/feb5d9fea6a5e9606aa995e879d862b825965ba48de054caab5ef356dc6b34120000444000000000000000000000267500000000000022407 0ustar0000000000000000{"architecture":"amd64","config":{"Hostname":"","Domainname":"","User":"","AttachStdin":false,"AttachStdout":false,"AttachStderr":false,"Tty":false,"OpenStdin":false,"StdinOnce":false,"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd":["/hello"],"Image":"sha256:b9935d4e8431fb1a7f0989304ec86b3329a99a25f5efdc7f09f3f8c41434ca6d","Volumes":null,"WorkingDir":"","Entrypoint":null,"OnBuild":null,"Labels":null},"container":"8746661ca3c2f215da94e6d3f7dfdcafaff5ec0b21c9aff6af3dc379a82fbc72","container_config":{"Hostname":"8746661ca3c2","Domainname":"","User":"","AttachStdin":false,"AttachStdout":false,"AttachStderr":false,"Tty":false,"OpenStdin":false,"StdinOnce":false,"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd":["/bin/sh","-c","#(nop) ","CMD [\"/hello\"]"],"Image":"sha256:b9935d4e8431fb1a7f0989304ec86b3329a99a25f5efdc7f09f3f8c41434ca6d","Volumes":null,"WorkingDir":"","Entrypoint":null,"OnBuild":null,"Labels":{}},"created":"2021-09-23T23:47:57.442225064Z","docker_version":"20.10.7","history":[{"created":"2021-09-23T23:47:57.098990892Z","created_by":"/bin/sh -c #(nop) COPY file:50563a97010fd7ce1ceebd1fa4f4891ac3decdf428333fb2683696f4358af6c2 in / "},{"created":"2021-09-23T23:47:57.442225064Z","created_by":"/bin/sh -c #(nop) CMD [\"/hello\"]","empty_layer":true}],"os":"linux","rootfs":{"type":"layers","diff_ids":["sha256:e07ee1baac5fae6a26f30cabfe54a36d3402f96afda318fe0a96cec4ca393359"]}}index.json0000644000000000000000000000051100000000000011204 0ustar0000000000000000{"schemaVersion":2,"manifests":[{"mediaType":"application/vnd.docker.distribution.manifest.list.v2+json","digest":"sha256:faa03e786c97f07ef34423fccceeec2398ec8a5759259f94d99078f264e9d7af","size":2561,"annotations":{"io.containerd.image.name":"docker.io/library/hello-world:latest","org.opencontainers.image.ref.name":"latest"}}]}manifest.json0000644000000000000000000000033100000000000011703 0ustar0000000000000000[{"Config":"blobs/sha256/feb5d9fea6a5e9606aa995e879d862b825965ba48de054caab5ef356dc6b3412","RepoTags":["hello-world:latest"],"Layers":["blobs/sha256/2db29710123e3e53a794f2694094b9b4338aa9ee5c40b930cb8063a1be392c54"]}]oci-layout0000444000000000000000000000003600000000000011212 0ustar0000000000000000{"imageLayoutVersion":"1.0.0"}oras-go-2.5.0/content/reader.go000066400000000000000000000072751457674530300163610ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package content import ( "errors" "fmt" "io" "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" ) var ( // ErrInvalidDescriptorSize is returned by ReadAll() when // the descriptor has an invalid size. ErrInvalidDescriptorSize = errors.New("invalid descriptor size") // ErrMismatchedDigest is returned by ReadAll() when // the descriptor has an invalid digest. ErrMismatchedDigest = errors.New("mismatched digest") // ErrTrailingData is returned by ReadAll() when // there exists trailing data unread when the read terminates. ErrTrailingData = errors.New("trailing data") ) var ( // errEarlyVerify is returned by VerifyReader.Verify() when // Verify() is called before completing reading the entire content blob. errEarlyVerify = errors.New("early verify") ) // VerifyReader reads the content described by its descriptor and verifies // against its size and digest. type VerifyReader struct { base *io.LimitedReader verifier digest.Verifier verified bool err error } // Read reads up to len(p) bytes into p. It returns the number of bytes // read (0 <= n <= len(p)) and any error encountered. func (vr *VerifyReader) Read(p []byte) (n int, err error) { if vr.err != nil { return 0, vr.err } n, err = vr.base.Read(p) if err != nil { if err == io.EOF && vr.base.N > 0 { err = io.ErrUnexpectedEOF } vr.err = err } return } // Verify checks for remaining unread content and verifies the read content against the digest func (vr *VerifyReader) Verify() error { if vr.verified { return nil } if vr.err == nil { if vr.base.N > 0 { return errEarlyVerify } } else if vr.err != io.EOF { return vr.err } if err := ensureEOF(vr.base.R); err != nil { vr.err = err return vr.err } if !vr.verifier.Verified() { vr.err = ErrMismatchedDigest return vr.err } vr.verified = true vr.err = io.EOF return nil } // NewVerifyReader wraps r for reading content with verification against desc. func NewVerifyReader(r io.Reader, desc ocispec.Descriptor) *VerifyReader { verifier := desc.Digest.Verifier() lr := &io.LimitedReader{ R: io.TeeReader(r, verifier), N: desc.Size, } return &VerifyReader{ base: lr, verifier: verifier, } } // ReadAll safely reads the content described by the descriptor. // The read content is verified against the size and the digest // using a VerifyReader. func ReadAll(r io.Reader, desc ocispec.Descriptor) ([]byte, error) { if desc.Size < 0 { return nil, ErrInvalidDescriptorSize } buf := make([]byte, desc.Size) vr := NewVerifyReader(r, desc) if n, err := io.ReadFull(vr, buf); err != nil { if errors.Is(err, io.ErrUnexpectedEOF) { return nil, fmt.Errorf("read failed: expected content size of %d, got %d, for digest %s: %w", desc.Size, n, desc.Digest.String(), err) } return nil, fmt.Errorf("read failed: %w", err) } if err := vr.Verify(); err != nil { return nil, err } return buf, nil } // ensureEOF ensures the read operation ends with an EOF and no // trailing data is present. func ensureEOF(r io.Reader) error { var peek [1]byte _, err := io.ReadFull(r, peek[:]) if err != io.EOF { return ErrTrailingData } return nil } oras-go-2.5.0/content/reader_test.go000066400000000000000000000155241457674530300174140ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package content import ( "bytes" _ "crypto/sha256" "errors" "io" "testing" "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" ) func TestVerifyReader_Read(t *testing.T) { // matched content and descriptor with small buffer content := []byte("example content") desc := NewDescriptorFromBytes("test", content) r := bytes.NewReader(content) vr := NewVerifyReader(r, desc) buf := make([]byte, 5) n, err := vr.Read(buf) if err != nil { t.Fatal("Read() error = ", err) } if !bytes.Equal(buf, []byte("examp")) { t.Fatalf("incorrect read content: %s", buf) } if n != 5 { t.Fatalf("incorrect number of bytes read: %d", n) } // matched content and descriptor with sufficient buffer content = []byte("foo foo") desc = NewDescriptorFromBytes("test", content) r = bytes.NewReader(content) vr = NewVerifyReader(r, desc) buf = make([]byte, len(content)) n, err = vr.Read(buf) if err != nil { t.Fatal("Read() error = ", err) } if n != len(content) { t.Fatalf("incorrect number of bytes read: %d", n) } if !bytes.Equal(buf, content) { t.Fatalf("incorrect read content: %s", buf) } // mismatched content and descriptor with sufficient buffer r = bytes.NewReader([]byte("bar")) vr = NewVerifyReader(r, desc) buf = make([]byte, 5) n, err = vr.Read(buf) if err != nil { t.Fatal("Read() error = ", err) } if n != 3 { t.Fatalf("incorrect number of bytes read: %d", n) } } func TestVerifyReader_Verify(t *testing.T) { // matched content and descriptor content := []byte("example content") desc := NewDescriptorFromBytes("test", content) r := bytes.NewReader(content) vr := NewVerifyReader(r, desc) buf := make([]byte, len(content)) if _, err := vr.Read(buf); err != nil { t.Fatal("Read() error = ", err) } if err := vr.Verify(); err != nil { t.Fatal("Verify() error = ", err) } if !bytes.Equal(buf, content) { t.Fatalf("incorrect read content: %s", buf) } // mismatched content and descriptor, read size larger than descriptor size content = []byte("foo") r = bytes.NewReader(content) desc = ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageLayer, Digest: digest.FromBytes(content), Size: int64(len(content)) - 1} vr = NewVerifyReader(r, desc) buf = make([]byte, len(content)) if _, err := vr.Read(buf); err != nil { t.Fatal("Read() error = ", err) } if err := vr.Verify(); !errors.Is(err, ErrTrailingData) { t.Fatalf("Verify() error = %v, want %v", err, ErrTrailingData) } // call vr.Verify again, the result should be the same if err := vr.Verify(); !errors.Is(err, ErrTrailingData) { t.Fatalf("2nd Verify() error = %v, want %v", err, ErrTrailingData) } // mismatched content and descriptor, read size smaller than descriptor size content = []byte("foo") r = bytes.NewReader(content) desc = ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageLayer, Digest: digest.FromBytes(content), Size: int64(len(content)) + 1} vr = NewVerifyReader(r, desc) buf = make([]byte, len(content)) if _, err := vr.Read(buf); err != nil { t.Fatal("Read() error = ", err) } if err := vr.Verify(); !errors.Is(err, errEarlyVerify) { t.Fatalf("Verify() error = %v, want %v", err, errEarlyVerify) } // call vr.Verify again, the result should be the same if err := vr.Verify(); !errors.Is(err, errEarlyVerify) { t.Fatalf("Verify() error = %v, want %v", err, errEarlyVerify) } // mismatched content and descriptor, wrong digest content = []byte("bar") r = bytes.NewReader(content) desc = NewDescriptorFromBytes("test", []byte("foo")) vr = NewVerifyReader(r, desc) buf = make([]byte, len(content)) if _, err := vr.Read(buf); err != nil { t.Fatal("Read() error = ", err) } if err := vr.Verify(); !errors.Is(err, ErrMismatchedDigest) { t.Fatalf("Verify() error = %v, want %v", err, ErrMismatchedDigest) } // call vr.Verify again, the result should be the same if err := vr.Verify(); !errors.Is(err, ErrMismatchedDigest) { t.Fatalf("2nd Verify() error = %v, want %v", err, ErrMismatchedDigest) } } func TestReadAll_CorrectDescriptor(t *testing.T) { content := []byte("example content") desc := NewDescriptorFromBytes("test", content) r := bytes.NewReader([]byte(content)) got, err := ReadAll(r, desc) if err != nil { t.Fatal("ReadAll() error = ", err) } if !bytes.Equal(got, content) { t.Errorf("ReadAll() = %v, want %v", got, content) } } func TestReadAll_ReadSizeSmallerThanDescriptorSize(t *testing.T) { content := []byte("example content") desc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageLayer, Digest: digest.FromBytes(content), Size: int64(len(content) + 1)} r := bytes.NewReader([]byte(content)) _, err := ReadAll(r, desc) if err == nil || !errors.Is(err, io.ErrUnexpectedEOF) { t.Errorf("ReadAll() error = %v, want %v", err, io.ErrUnexpectedEOF) } } func TestReadAll_ReadSizeLargerThanDescriptorSize(t *testing.T) { content := []byte("example content") desc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageLayer, Digest: digest.FromBytes(content), Size: int64(len(content) - 1)} r := bytes.NewReader([]byte(content)) _, err := ReadAll(r, desc) if err == nil || !errors.Is(err, ErrTrailingData) { t.Errorf("ReadAll() error = %v, want %v", err, ErrTrailingData) } } func TestReadAll_InvalidDigest(t *testing.T) { content := []byte("example content") desc := NewDescriptorFromBytes("test", []byte("another content")) r := bytes.NewReader([]byte(content)) _, err := ReadAll(r, desc) if err == nil || !errors.Is(err, ErrMismatchedDigest) { t.Errorf("ReadAll() error = %v, want %v", err, ErrMismatchedDigest) } } func TestReadAll_EmptyContent(t *testing.T) { content := []byte("") desc := NewDescriptorFromBytes("test", content) r := bytes.NewReader([]byte(content)) got, err := ReadAll(r, desc) if err != nil { t.Fatal("ReadAll() error = ", err) } if !bytes.Equal(got, content) { t.Errorf("ReadAll() = %v, want %v", got, content) } } func TestReadAll_InvalidDescriptorSize(t *testing.T) { content := []byte("example content") desc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageLayer, Digest: digest.FromBytes(content), Size: -1, } r := bytes.NewReader([]byte(content)) _, err := ReadAll(r, desc) if err == nil || !errors.Is(err, ErrInvalidDescriptorSize) { t.Errorf("ReadAll() error = %v, want %v", err, ErrInvalidDescriptorSize) } } oras-go-2.5.0/content/resolver.go000066400000000000000000000025311457674530300167460ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // Package content provides implementations to access content stores. package content import ( "context" ocispec "github.com/opencontainers/image-spec/specs-go/v1" ) // Resolver resolves reference tags. type Resolver interface { // Resolve resolves a reference to a descriptor. Resolve(ctx context.Context, reference string) (ocispec.Descriptor, error) } // Tagger tags reference tags. type Tagger interface { // Tag tags a descriptor with a reference string. Tag(ctx context.Context, desc ocispec.Descriptor, reference string) error } // TagResolver provides reference tag indexing services. type TagResolver interface { Tagger Resolver } // Untagger untags reference tags. type Untagger interface { // Untag untags the given reference string. Untag(ctx context.Context, reference string) error } oras-go-2.5.0/content/storage.go000066400000000000000000000051061457674530300165520ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package content import ( "context" "io" ocispec "github.com/opencontainers/image-spec/specs-go/v1" ) // Fetcher fetches content. type Fetcher interface { // Fetch fetches the content identified by the descriptor. Fetch(ctx context.Context, target ocispec.Descriptor) (io.ReadCloser, error) } // Pusher pushes content. type Pusher interface { // Push pushes the content, matching the expected descriptor. // Reader is preferred to Writer so that the suitable buffer size can be // chosen by the underlying implementation. Furthermore, the implementation // can also do reflection on the Reader for more advanced I/O optimization. Push(ctx context.Context, expected ocispec.Descriptor, content io.Reader) error } // Storage represents a content-addressable storage (CAS) where contents are // accessed via Descriptors. // The storage is designed to handle blobs of large sizes. type Storage interface { ReadOnlyStorage Pusher } // ReadOnlyStorage represents a read-only Storage. type ReadOnlyStorage interface { Fetcher // Exists returns true if the described content exists. Exists(ctx context.Context, target ocispec.Descriptor) (bool, error) } // Deleter removes content. // Deleter is an extension of Storage. type Deleter interface { // Delete removes the content identified by the descriptor. Delete(ctx context.Context, target ocispec.Descriptor) error } // FetchAll safely fetches the content described by the descriptor. // The fetched content is verified against the size and the digest. func FetchAll(ctx context.Context, fetcher Fetcher, desc ocispec.Descriptor) ([]byte, error) { rc, err := fetcher.Fetch(ctx, desc) if err != nil { return nil, err } defer rc.Close() return ReadAll(rc, desc) } // FetcherFunc is the basic Fetch method defined in Fetcher. type FetcherFunc func(ctx context.Context, target ocispec.Descriptor) (io.ReadCloser, error) // Fetch performs Fetch operation by the FetcherFunc. func (fn FetcherFunc) Fetch(ctx context.Context, target ocispec.Descriptor) (io.ReadCloser, error) { return fn(ctx, target) } oras-go-2.5.0/content_test.go000066400000000000000000001766361457674530300161660ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package oras_test import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/httptest" "net/url" "reflect" "strconv" "strings" "testing" "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2" "oras.land/oras-go/v2/content/memory" "oras.land/oras-go/v2/errdef" "oras.land/oras-go/v2/internal/cas" "oras.land/oras-go/v2/registry/remote" ) func TestTag_Memory(t *testing.T) { target := memory.New() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 generateManifest(descs[0], descs[1:3]...) // Blob 3 ctx := context.Background() for i := range blobs { err := target.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } manifestDesc := descs[3] ref := "foobar" err := target.Tag(ctx, manifestDesc, ref) if err != nil { t.Fatal("fail to tag manifestDesc node", err) } // test Tag gotDesc, err := oras.Tag(ctx, target, ref, "myTestingTag") if err != nil { t.Fatalf("failed to retag using oras.Tag with err: %v", err) } if !reflect.DeepEqual(gotDesc, manifestDesc) { t.Errorf("oras.Tag() = %v, want %v", gotDesc, manifestDesc) } // verify tag gotDesc, err = target.Resolve(ctx, "myTestingTag") if err != nil { t.Fatal("target.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, manifestDesc) { t.Errorf("target.Resolve() = %v, want %v", gotDesc, manifestDesc) } } func TestTag_Repository(t *testing.T) { index := []byte(`{"manifests":[]}`) indexDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageIndex, Digest: digest.FromBytes(index), Size: int64(len(index)), } src := "foobar" dst := "myTag" var gotIndex []byte ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && (r.URL.Path == "/v2/test/manifests/"+indexDesc.Digest.String() || r.URL.Path == "/v2/test/manifests/"+src): if accept := r.Header.Get("Accept"); !strings.Contains(accept, indexDesc.MediaType) { t.Errorf("manifest not convertable: %s", accept) w.WriteHeader(http.StatusBadRequest) return } w.Header().Set("Content-Type", indexDesc.MediaType) w.Header().Set("Docker-Content-Digest", indexDesc.Digest.String()) if _, err := w.Write(index); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+dst: if contentType := r.Header.Get("Content-Type"); contentType != indexDesc.MediaType { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotIndex = buf.Bytes() w.Header().Set("Docker-Content-Digest", indexDesc.Digest.String()) w.WriteHeader(http.StatusCreated) default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repoName := uri.Host + "/test" repo, err := remote.NewRepository(repoName) if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true ctx := context.Background() // test with manifest tag gotDesc, err := oras.Tag(ctx, repo, src, dst) if err != nil { t.Fatalf("Repository.TagReference() error = %v", err) } if !bytes.Equal(gotIndex, index) { t.Errorf("Repository.TagReference() = %v, want %v", gotIndex, index) } if !reflect.DeepEqual(gotDesc, indexDesc) { t.Errorf("oras.Tag() = %v, want %v", gotDesc, indexDesc) } // test with manifest digest gotDesc, err = oras.Tag(ctx, repo, indexDesc.Digest.String(), dst) if err != nil { t.Fatalf("Repository.TagReference() error = %v", err) } if !bytes.Equal(gotIndex, index) { t.Errorf("Repository.TagReference() = %v, want %v", gotIndex, index) } if !reflect.DeepEqual(gotDesc, indexDesc) { t.Errorf("oras.Tag() = %v, want %v", gotDesc, indexDesc) } // test with manifest tag@digest tagDigestRef := src + "@" + indexDesc.Digest.String() gotDesc, err = oras.Tag(ctx, repo, tagDigestRef, dst) if err != nil { t.Fatalf("Repository.TagReference() error = %v", err) } if !bytes.Equal(gotIndex, index) { t.Errorf("Repository.TagReference() = %v, want %v", gotIndex, index) } if !reflect.DeepEqual(gotDesc, indexDesc) { t.Errorf("oras.Tag() = %v, want %v", gotDesc, indexDesc) } // test with manifest FQDN fqdnRef := repoName + ":" + tagDigestRef gotDesc, err = oras.Tag(ctx, repo, fqdnRef, dst) if err != nil { t.Fatalf("Repository.TagReference() error = %v", err) } if !bytes.Equal(gotIndex, index) { t.Errorf("Repository.TagReference() = %v, want %v", gotIndex, index) } if !reflect.DeepEqual(gotDesc, indexDesc) { t.Errorf("oras.Tag() = %v, want %v", gotDesc, indexDesc) } } func TestTagN_Memory(t *testing.T) { target := memory.New() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 generateManifest(descs[0], descs[1:3]...) // Blob 3 ctx := context.Background() for i := range blobs { err := target.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } manifestDesc := descs[3] srcRef := "foobar" err := target.Tag(ctx, manifestDesc, srcRef) if err != nil { t.Fatalf("oras.Tag(%s) error = %v", srcRef, err) } // test TagN with empty dstReferences _, err = oras.TagN(ctx, target, srcRef, nil, oras.DefaultTagNOptions) if !errors.Is(err, errdef.ErrMissingReference) { t.Fatalf("oras.TagN() error = %v, wantErr %v", err, errdef.ErrMissingReference) } // test TagN with single dstReferences dstRef := "single" gotDesc, err := oras.TagN(ctx, target, srcRef, []string{dstRef}, oras.DefaultTagNOptions) if err != nil { t.Fatalf("failed to retag using oras.Tag with err: %v", err) } if !reflect.DeepEqual(gotDesc, manifestDesc) { t.Errorf("oras.TagN() = %v, want %v", gotDesc, manifestDesc) } // verify tag gotDesc, err = target.Resolve(ctx, dstRef) if err != nil { t.Fatal("target.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, manifestDesc) { t.Errorf("target.Resolve() = %v, want %v", gotDesc, manifestDesc) } // test TagN with single dstReferences and MaxMetadataBytes = 1 // should not return error dstRef = "single2" opts := oras.TagNOptions{ MaxMetadataBytes: 1, } gotDesc, err = oras.TagN(ctx, target, srcRef, []string{dstRef}, opts) if err != nil { t.Fatalf("failed to retag using oras.Tag with err: %v", err) } if !reflect.DeepEqual(gotDesc, manifestDesc) { t.Errorf("oras.TagN() = %v, want %v", gotDesc, manifestDesc) } // verify tag gotDesc, err = target.Resolve(ctx, dstRef) if err != nil { t.Fatal("target.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, manifestDesc) { t.Errorf("target.Resolve() = %v, want %v", gotDesc, manifestDesc) } // test TagN with multiple references dstRefs := []string{"foo", "bar", "baz"} gotDesc, err = oras.TagN(ctx, target, srcRef, dstRefs, oras.DefaultTagNOptions) if err != nil { t.Fatal("oras.TagN() error =", err) } if !reflect.DeepEqual(gotDesc, manifestDesc) { t.Errorf("oras.TagN() = %v, want %v", gotDesc, manifestDesc) } // verify multiple references for _, ref := range dstRefs { gotDesc, err := target.Resolve(ctx, ref) if err != nil { t.Fatal("target.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, manifestDesc) { t.Errorf("target.Resolve() = %v, want %v", gotDesc, manifestDesc) } } // test TagN with multiple references and MaxMetadataBytes = 1 // should not return error dstRefs = []string{"tag1", "tag2", "tag3"} opts = oras.TagNOptions{ MaxMetadataBytes: 1, } gotDesc, err = oras.TagN(ctx, target, srcRef, dstRefs, opts) if err != nil { t.Fatal("oras.TagN() error =", err) } if !reflect.DeepEqual(gotDesc, manifestDesc) { t.Errorf("oras.TagN() = %v, want %v", gotDesc, manifestDesc) } // verify multiple references for _, ref := range dstRefs { gotDesc, err := target.Resolve(ctx, ref) if err != nil { t.Fatal("target.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, manifestDesc) { t.Errorf("target.Resolve() = %v, want %v", gotDesc, manifestDesc) } } } func TestTagN_Repository(t *testing.T) { index := []byte(`{"manifests":[]}`) indexDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageIndex, Digest: digest.FromBytes(index), Size: int64(len(index)), } srcRef := "foobar" refFoo := "foo" refBar := "bar" refTag1 := "tag1" refTag2 := "tag2" dstRefs := []string{refFoo, refBar} ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && (r.URL.Path == "/v2/test/manifests/"+indexDesc.Digest.String() || r.URL.Path == "/v2/test/manifests/"+srcRef): if accept := r.Header.Get("Accept"); !strings.Contains(accept, indexDesc.MediaType) { t.Errorf("manifest not convertable: %s", accept) w.WriteHeader(http.StatusBadRequest) return } w.Header().Set("Content-Type", indexDesc.MediaType) w.Header().Set("Docker-Content-Digest", indexDesc.Digest.String()) if _, err := w.Write(index); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } case r.Method == http.MethodHead && (r.URL.Path == "/v2/test/manifests/"+refFoo || r.URL.Path == "/v2/test/manifests/"+refBar || r.URL.Path == "/v2/test/manifests/"+refTag1 || r.URL.Path == "/v2/test/manifests/"+refTag2): if accept := r.Header.Get("Accept"); !strings.Contains(accept, indexDesc.MediaType) { t.Errorf("manifest not convertable: %s", accept) w.WriteHeader(http.StatusBadRequest) return } w.Header().Set("Content-Type", indexDesc.MediaType) w.Header().Set("Docker-Content-Digest", indexDesc.Digest.String()) w.Header().Set("Content-Length", strconv.Itoa(int(indexDesc.Size))) case r.Method == http.MethodPut && (r.URL.Path == "/v2/test/manifests/"+refFoo || r.URL.Path == "/v2/test/manifests/"+refBar || r.URL.Path == "/v2/test/manifests/"+refTag1 || r.URL.Path == "/v2/test/manifests/"+refTag2): if contentType := r.Header.Get("Content-Type"); contentType != indexDesc.MediaType { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } w.Header().Set("Docker-Content-Digest", indexDesc.Digest.String()) w.WriteHeader(http.StatusCreated) default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repoName := uri.Host + "/test" repo, err := remote.NewRepository(repoName) if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true ctx := context.Background() // test TagN with empty dstReferences _, err = oras.TagN(ctx, repo, srcRef, nil, oras.DefaultTagNOptions) if !errors.Is(err, errdef.ErrMissingReference) { t.Fatalf("oras.TagN() error = %v, wantErr %v", err, errdef.ErrMissingReference) } // test TagN with single dstReferences gotDesc, err := oras.TagN(ctx, repo, srcRef, []string{refTag1}, oras.DefaultTagNOptions) if err != nil { t.Fatalf("failed to retag using oras.Tag with err: %v", err) } if !reflect.DeepEqual(gotDesc, indexDesc) { t.Errorf("oras.TagN() = %v, want %v", gotDesc, indexDesc) } // verify tag gotDesc, err = repo.Resolve(ctx, refTag1) if err != nil { t.Fatal("target.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, indexDesc) { t.Errorf("target.Resolve() = %v, want %v", gotDesc, indexDesc) } // test TagN with single dstReferences and MaxMetadataBytes = 1 // should not return error opts := oras.TagNOptions{ MaxMetadataBytes: 1, } gotDesc, err = oras.TagN(ctx, repo, srcRef, []string{refTag2}, opts) if err != nil { t.Fatalf("failed to retag using oras.Tag with err: %v", err) } if !reflect.DeepEqual(gotDesc, indexDesc) { t.Errorf("oras.TagN() = %v, want %v", gotDesc, indexDesc) } // verify tag gotDesc, err = repo.Resolve(ctx, refTag2) if err != nil { t.Fatal("target.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, indexDesc) { t.Errorf("target.Resolve() = %v, want %v", gotDesc, indexDesc) } // test TagN with multiple references gotDesc, err = oras.TagN(ctx, repo, srcRef, dstRefs, oras.DefaultTagNOptions) if err != nil { t.Fatal("oras.TagN() error =", err) } if !reflect.DeepEqual(gotDesc, indexDesc) { t.Errorf("oras.TagN() = %v, want %v", gotDesc, indexDesc) } // verify multiple references for _, ref := range dstRefs { gotDesc, err := repo.Resolve(ctx, ref) if err != nil { t.Fatal("target.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, indexDesc) { t.Errorf("target.Resolve() = %v, want %v", gotDesc, indexDesc) } } // test TagN with multiple references and MaxMetadataBytes = 1 // should return ErrSizeExceedsLimit dstRefs = []string{refTag1, refTag2} opts = oras.TagNOptions{ MaxMetadataBytes: 1, } _, err = oras.TagN(ctx, repo, srcRef, dstRefs, opts) if !errors.Is(err, errdef.ErrSizeExceedsLimit) { t.Fatalf("oras.TagN() error = %v, wantErr %v", err, errdef.ErrSizeExceedsLimit) } } func TestResolve_Memory(t *testing.T) { target := memory.New() arc_1 := "test-arc-1" os_1 := "test-os-1" variant_1 := "v1" variant_2 := "v2" // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } appendManifest := func(arc, os, variant string, mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), Platform: &ocispec.Platform{ Architecture: arc, OS: os, Variant: variant, }, }) } generateManifest := func(arc, os, variant string, config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendManifest(arc, os, variant, ocispec.MediaTypeImageManifest, manifestJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte(`{"mediaType":"application/vnd.oci.image.config.v1+json", "created":"2022-07-29T08:13:55Z", "author":"test author", "architecture":"test-arc-1", "os":"test-os-1", "variant":"v1"}`)) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 generateManifest(arc_1, os_1, variant_1, descs[0], descs[1:3]...) // Blob 3 ctx := context.Background() for i := range blobs { err := target.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } manifestDesc := descs[3] ref := "foobar" err := target.Tag(ctx, manifestDesc, ref) if err != nil { t.Fatal("fail to tag manifestDesc node", err) } // test Resolve with default resolve options resolveOptions := oras.DefaultResolveOptions gotDesc, err := oras.Resolve(ctx, target, ref, resolveOptions) if err != nil { t.Fatal("oras.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, manifestDesc) { t.Errorf("oras.Resolve() = %v, want %v", gotDesc, manifestDesc) } // test Resolve with empty resolve options gotDesc, err = oras.Resolve(ctx, target, ref, oras.ResolveOptions{}) if err != nil { t.Fatal("oras.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, manifestDesc) { t.Errorf("oras.Resolve() = %v, want %v", gotDesc, manifestDesc) } // test Resolve with MaxMetadataBytes = 1 resolveOptions = oras.ResolveOptions{ MaxMetadataBytes: 1, } gotDesc, err = oras.Resolve(ctx, target, ref, resolveOptions) if err != nil { t.Fatal("oras.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, manifestDesc) { t.Errorf("oras.Resolve() = %v, want %v", gotDesc, manifestDesc) } // test Resolve with TargetPlatform resolveOptions = oras.ResolveOptions{ TargetPlatform: &ocispec.Platform{ Architecture: arc_1, OS: os_1, }, } gotDesc, err = oras.Resolve(ctx, target, ref, resolveOptions) if err != nil { t.Fatal("oras.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, manifestDesc) { t.Errorf("oras.Resolve() = %v, want %v", gotDesc, manifestDesc) } // test Resolve with TargetPlatform and MaxMetadataBytes = 1 resolveOptions = oras.ResolveOptions{ TargetPlatform: &ocispec.Platform{ Architecture: arc_1, OS: os_1, }, MaxMetadataBytes: 1, } gotDesc, err = oras.Resolve(ctx, target, ref, resolveOptions) if err != nil { t.Fatal("oras.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, manifestDesc) { t.Errorf("oras.Resolve() = %v, want %v", gotDesc, manifestDesc) } // test Resolve with TargetPlatform but there is no matching node // Should return not found error resolveOptions = oras.ResolveOptions{ TargetPlatform: &ocispec.Platform{ Architecture: arc_1, OS: os_1, Variant: variant_2, }, } _, err = oras.Resolve(ctx, target, ref, resolveOptions) expected := fmt.Sprintf("%s: %v: platform in manifest does not match target platform", manifestDesc.Digest, errdef.ErrNotFound) if err.Error() != expected { t.Fatalf("oras.Resolve() error = %v, wantErr %v", err, expected) } } func TestResolve_Repository(t *testing.T) { arc_1 := "test-arc-1" arc_2 := "test-arc-2" os_1 := "test-os-1" var digest_1 digest.Digest = "sha256:11ec3af9dfeb49c89ef71877ba85249be527e4dda9d1d74d99dc618d1a5fa151" manifestDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageManifest, Digest: digest_1, Size: 484, Platform: &ocispec.Platform{ Architecture: arc_1, OS: os_1, }, } index := []byte(`{"manifests":[{ "mediaType":"application/vnd.oci.image.manifest.v1+json", "digest":"sha256:11ec3af9dfeb49c89ef71877ba85249be527e4dda9d1d74d99dc618d1a5fa151", "size":484, "platform":{"architecture":"test-arc-1","os":"test-os-1"}},{ "mediaType":"application/vnd.oci.image.manifest.v1+json", "digest":"sha256:b955aefa63749f07fad84ab06a45a951368e3ac79799bc44a158fac1bb8ca208", "size":337, "platform":{"architecture":"test-arc-2","os":"test-os-2"}}]}`) indexDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageIndex, Digest: digest.FromBytes(index), Size: int64(len(index)), } src := "foobar" ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && (r.URL.Path == "/v2/test/manifests/"+indexDesc.Digest.String() || r.URL.Path == "/v2/test/manifests/"+src): if accept := r.Header.Get("Accept"); !strings.Contains(accept, indexDesc.MediaType) { t.Errorf("manifest not convertable: %s", accept) w.WriteHeader(http.StatusBadRequest) return } w.Header().Set("Content-Type", indexDesc.MediaType) w.Header().Set("Docker-Content-Digest", indexDesc.Digest.String()) if _, err := w.Write(index); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repoName := uri.Host + "/test" repo, err := remote.NewRepository(repoName) if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true ctx := context.Background() // test Resolve with TargetPlatform resolveOptions := oras.ResolveOptions{ TargetPlatform: &ocispec.Platform{ Architecture: arc_1, OS: os_1, }, } gotDesc, err := oras.Resolve(ctx, repo, src, resolveOptions) if err != nil { t.Fatal("oras.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, manifestDesc) { t.Errorf("oras.Resolve() = %v, want %v", gotDesc, manifestDesc) } // test Resolve with TargetPlatform and MaxMetadataBytes = 1 resolveOptions = oras.ResolveOptions{ TargetPlatform: &ocispec.Platform{ Architecture: arc_1, OS: os_1, }, MaxMetadataBytes: 1, } _, err = oras.Resolve(ctx, repo, src, resolveOptions) if !errors.Is(err, errdef.ErrSizeExceedsLimit) { t.Fatalf("oras.Resolve() error = %v, wantErr %v", err, errdef.ErrSizeExceedsLimit) } // test Resolve with TargetPlatform but there is no matching node // Should return not found error resolveOptions = oras.ResolveOptions{ TargetPlatform: &ocispec.Platform{ Architecture: arc_1, OS: arc_2, }, } _, err = oras.Resolve(ctx, repo, src, resolveOptions) expected := fmt.Sprintf("%s: %v: no matching manifest was found in the manifest list", indexDesc.Digest, errdef.ErrNotFound) if err.Error() != expected { t.Fatalf("oras.Resolve() error = %v, wantErr %v", err, expected) } } func TestFetch_Memory(t *testing.T) { target := memory.New() arc_1 := "test-arc-1" os_1 := "test-os-1" variant_1 := "v1" variant_2 := "v2" // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } appendManifest := func(arc, os, variant string, mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), Platform: &ocispec.Platform{ Architecture: arc, OS: os, Variant: variant, }, }) } generateManifest := func(arc, os, variant string, config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendManifest(arc, os, variant, ocispec.MediaTypeImageManifest, manifestJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte(`{"mediaType":"application/vnd.oci.image.config.v1+json", "created":"2022-07-29T08:13:55Z", "author":"test author", "architecture":"test-arc-1", "os":"test-os-1", "variant":"v1"}`)) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 generateManifest(arc_1, os_1, variant_1, descs[0], descs[1:3]...) // Blob 3 ctx := context.Background() for i := range blobs { err := target.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } manifestDesc := descs[3] manifestTag := "foobar" err := target.Tag(ctx, manifestDesc, manifestTag) if err != nil { t.Fatal("fail to tag manifestDesc node", err) } blobRef := "blob" err = target.Tag(ctx, descs[2], blobRef) if err != nil { t.Fatal("fail to tag manifestDesc node", err) } // test Fetch with empty FetchOptions gotDesc, rc, err := oras.Fetch(ctx, target, manifestTag, oras.FetchOptions{}) if err != nil { t.Fatal("oras.Fetch() error =", err) } if !reflect.DeepEqual(gotDesc, manifestDesc) { t.Errorf("oras.Fetch() = %v, want %v", gotDesc, manifestDesc) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("oras.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got, blobs[3]) { t.Errorf("oras.Fetch() = %v, want %v", got, blobs[3]) } // test FetchBytes with default FetchBytes options gotDesc, rc, err = oras.Fetch(ctx, target, manifestTag, oras.DefaultFetchOptions) if err != nil { t.Fatal("oras.Fetch() error =", err) } if !reflect.DeepEqual(gotDesc, manifestDesc) { t.Errorf("oras.Fetch() = %v, want %v", gotDesc, manifestDesc) } got, err = io.ReadAll(rc) if err != nil { t.Fatal("oras.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got, blobs[3]) { t.Errorf("oras.Fetch() = %v, want %v", got, blobs[3]) } // test FetchBytes with wrong reference randomRef := "whatever" _, _, err = oras.Fetch(ctx, target, randomRef, oras.DefaultFetchOptions) if !errors.Is(err, errdef.ErrNotFound) { t.Fatalf("oras.Fetch() error = %v, wantErr %v", err, errdef.ErrNotFound) } // test Fetch with TargetPlatform opts := oras.FetchOptions{ ResolveOptions: oras.ResolveOptions{ TargetPlatform: &ocispec.Platform{ Architecture: arc_1, OS: os_1, }, }, } gotDesc, rc, err = oras.Fetch(ctx, target, manifestTag, opts) if err != nil { t.Fatal("oras.Fetch() error =", err) } if !reflect.DeepEqual(gotDesc, manifestDesc) { t.Errorf("oras.Fetch() = %v, want %v", gotDesc, manifestDesc) } got, err = io.ReadAll(rc) if err != nil { t.Fatal("oras.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got, blobs[3]) { t.Errorf("oras.Fetch() = %v, want %v", got, blobs[3]) } // test Fetch with TargetPlatform but there is no matching node // should return not found error opts = oras.FetchOptions{ ResolveOptions: oras.ResolveOptions{ TargetPlatform: &ocispec.Platform{ Architecture: arc_1, OS: os_1, Variant: variant_2, }, }, } _, _, err = oras.Fetch(ctx, target, manifestTag, opts) expected := fmt.Sprintf("%s: %v: platform in manifest does not match target platform", manifestDesc.Digest, errdef.ErrNotFound) if err.Error() != expected { t.Fatalf("oras.Fetch() error = %v, wantErr %v", err, expected) } // test FetchBytes on blob with TargetPlatform // should return unsupported error opts = oras.FetchOptions{ ResolveOptions: oras.ResolveOptions{ TargetPlatform: &ocispec.Platform{ Architecture: arc_1, OS: os_1, }, }, } _, _, err = oras.Fetch(ctx, target, blobRef, opts) if !errors.Is(err, errdef.ErrUnsupported) { t.Fatalf("oras.Fetch() error = %v, wantErr %v", err, errdef.ErrUnsupported) } } func TestFetch_Repository(t *testing.T) { arc_1 := "test-arc-1" arc_2 := "test-arc-2" os_1 := "test-os-1" os_2 := "test-os-2" blob := []byte("hello world") blobDesc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(blob), Size: int64(len(blob)), } manifest := []byte(`{"layers":[]}`) manifestDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageManifest, Digest: digest.FromBytes(manifest), Size: int64(len(manifest)), Platform: &ocispec.Platform{ Architecture: arc_1, OS: os_1, }, } manifest2 := []byte("test manifest") manifestDesc2 := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageManifest, Digest: digest.FromBytes(manifest2), Size: int64(len(manifest2)), Platform: &ocispec.Platform{ Architecture: arc_2, OS: os_2, }, } indexContent := ocispec.Index{ MediaType: ocispec.MediaTypeImageIndex, Manifests: []ocispec.Descriptor{ manifestDesc, manifestDesc2, }, } index, err := json.Marshal(indexContent) if err != nil { t.Fatal("failed to marshal index", err) } indexDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageIndex, Digest: digest.FromBytes(index), Size: int64(len(index)), } ref := "foobar" ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && (r.URL.Path == "/v2/test/manifests/"+indexDesc.Digest.String() || r.URL.Path == "/v2/test/manifests/"+ref): if accept := r.Header.Get("Accept"); !strings.Contains(accept, indexDesc.MediaType) { t.Errorf("manifest not convertable: %s", accept) w.WriteHeader(http.StatusBadRequest) return } w.Header().Set("Content-Type", indexDesc.MediaType) w.Header().Set("Docker-Content-Digest", indexDesc.Digest.String()) if _, err := w.Write(index); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } case r.URL.Path == "/v2/test/manifests/"+manifestDesc.Digest.String(): if accept := r.Header.Get("Accept"); !strings.Contains(accept, manifestDesc.MediaType) { t.Errorf("manifest not convertable: %s", accept) w.WriteHeader(http.StatusBadRequest) return } w.Header().Set("Content-Type", manifestDesc.MediaType) w.Header().Set("Docker-Content-Digest", manifestDesc.Digest.String()) if _, err := w.Write(manifest); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } case r.URL.Path == "/v2/test/blobs/"+blobDesc.Digest.String(): w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Docker-Content-Digest", blobDesc.Digest.String()) if _, err := w.Write(blob); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } default: w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repoName := uri.Host + "/test" repo, err := remote.NewRepository(repoName) if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true ctx := context.Background() // test Fetch with empty option by valid manifest tag gotDesc, rc, err := oras.Fetch(ctx, repo, ref, oras.FetchOptions{}) if err != nil { t.Fatal("oras.Fetch() error =", err) } if !reflect.DeepEqual(gotDesc, indexDesc) { t.Errorf("oras.Fetch() = %v, want %v", gotDesc, indexDesc) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("oras.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got, index) { t.Errorf("oras.Fetch() = %v, want %v", got, index) } // test Fetch with DefaultFetchOptions by valid manifest tag gotDesc, rc, err = oras.Fetch(ctx, repo, ref, oras.DefaultFetchOptions) if err != nil { t.Fatal("oras.Fetch() error =", err) } if !reflect.DeepEqual(gotDesc, indexDesc) { t.Errorf("oras.Fetch() = %v, want %v", gotDesc, indexDesc) } got, err = io.ReadAll(rc) if err != nil { t.Fatal("oras.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got, index) { t.Errorf("oras.Fetch() = %v, want %v", got, index) } // test Fetch with empty option by blob digest gotDesc, rc, err = oras.Fetch(ctx, repo.Blobs(), blobDesc.Digest.String(), oras.FetchOptions{}) if err != nil { t.Fatalf("oras.Fetch() error = %v", err) } if gotDesc.Digest != blobDesc.Digest || gotDesc.Size != blobDesc.Size { t.Errorf("oras.Fetch() = %v, want %v", gotDesc, blobDesc) } got, err = io.ReadAll(rc) if err != nil { t.Fatal("oras.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got, blob) { t.Errorf("oras.Fetch() = %v, want %v", got, blob) } // test FetchBytes with DefaultFetchBytesOptions by blob digest gotDesc, rc, err = oras.Fetch(ctx, repo.Blobs(), blobDesc.Digest.String(), oras.DefaultFetchOptions) if err != nil { t.Fatalf("oras.Fetch() error = %v", err) } if gotDesc.Digest != blobDesc.Digest || gotDesc.Size != blobDesc.Size { t.Errorf("oras.Fetch() = %v, want %v", gotDesc, blobDesc) } got, err = io.ReadAll(rc) if err != nil { t.Fatal("oras.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got, blob) { t.Errorf("oras.Fetch() = %v, want %v", got, blob) } // test FetchBytes with wrong reference randomRef := "whatever" _, _, err = oras.Fetch(ctx, repo, randomRef, oras.DefaultFetchOptions) if !errors.Is(err, errdef.ErrNotFound) { t.Fatalf("oras.Fetch() error = %v, wantErr %v", err, errdef.ErrNotFound) } // test FetchBytes with TargetPlatform opts := oras.FetchOptions{ ResolveOptions: oras.ResolveOptions{ TargetPlatform: &ocispec.Platform{ Architecture: arc_1, OS: os_1, }, }, } gotDesc, rc, err = oras.Fetch(ctx, repo, ref, opts) if err != nil { t.Fatal("oras.Fetch() error =", err) } if !reflect.DeepEqual(gotDesc, manifestDesc) { t.Errorf("oras.Fetch() = %v, want %v", gotDesc, manifestDesc) } got, err = io.ReadAll(rc) if err != nil { t.Fatal("oras.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got, manifest) { t.Errorf("oras.Fetch() = %v, want %v", got, manifest) } // test FetchBytes with TargetPlatform but there is no matching node // Should return not found error opts = oras.FetchOptions{ ResolveOptions: oras.ResolveOptions{ TargetPlatform: &ocispec.Platform{ Architecture: arc_1, OS: arc_2, }, }, } _, _, err = oras.Fetch(ctx, repo, ref, opts) expected := fmt.Sprintf("%s: %v: no matching manifest was found in the manifest list", indexDesc.Digest, errdef.ErrNotFound) if err.Error() != expected { t.Fatalf("oras.Fetch() error = %v, wantErr %v", err, expected) } // test FetchBytes on blob with TargetPlatform // should return unsupported error opts = oras.FetchOptions{ ResolveOptions: oras.ResolveOptions{ TargetPlatform: &ocispec.Platform{ Architecture: arc_1, OS: os_1, }, }, } _, _, err = oras.Fetch(ctx, repo.Blobs(), blobDesc.Digest.String(), opts) if !errors.Is(err, errdef.ErrUnsupported) { t.Fatalf("oras.Fetch() error = %v, wantErr %v", err, errdef.ErrUnsupported) } } func TestFetchBytes_Memory(t *testing.T) { target := memory.New() arc_1 := "test-arc-1" os_1 := "test-os-1" variant_1 := "v1" variant_2 := "v2" // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } appendManifest := func(arc, os, variant string, mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), Platform: &ocispec.Platform{ Architecture: arc, OS: os, Variant: variant, }, }) } generateManifest := func(arc, os, variant string, config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendManifest(arc, os, variant, ocispec.MediaTypeImageManifest, manifestJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte(`{"mediaType":"application/vnd.oci.image.config.v1+json", "created":"2022-07-29T08:13:55Z", "author":"test author", "architecture":"test-arc-1", "os":"test-os-1", "variant":"v1"}`)) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 generateManifest(arc_1, os_1, variant_1, descs[0], descs[1:3]...) // Blob 3 ctx := context.Background() for i := range blobs { err := target.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } manifestDesc := descs[3] manifestTag := "foobar" err := target.Tag(ctx, manifestDesc, manifestTag) if err != nil { t.Fatal("fail to tag manifestDesc node", err) } blobRef := "blob" err = target.Tag(ctx, descs[2], blobRef) if err != nil { t.Fatal("fail to tag manifestDesc node", err) } // test FetchBytes with empty FetchBytes options gotDesc, gotBytes, err := oras.FetchBytes(ctx, target, manifestTag, oras.FetchBytesOptions{}) if err != nil { t.Fatal("oras.FetchBytes() error =", err) } if !reflect.DeepEqual(gotDesc, manifestDesc) { t.Errorf("oras.FetchBytes() = %v, want %v", gotDesc, manifestDesc) } if !bytes.Equal(gotBytes, blobs[3]) { t.Errorf("oras.FetchBytes() = %v, want %v", gotBytes, blobs[3]) } // test FetchBytes with default FetchBytes options gotDesc, gotBytes, err = oras.FetchBytes(ctx, target, manifestTag, oras.DefaultFetchBytesOptions) if err != nil { t.Fatal("oras.FetchBytes() error =", err) } if !reflect.DeepEqual(gotDesc, manifestDesc) { t.Errorf("oras.FetchBytes() = %v, want %v", gotDesc, manifestDesc) } if !bytes.Equal(gotBytes, blobs[3]) { t.Errorf("oras.FetchBytes() = %v, want %v", gotBytes, blobs[3]) } // test FetchBytes with wrong reference randomRef := "whatever" _, _, err = oras.FetchBytes(ctx, target, randomRef, oras.DefaultFetchBytesOptions) if !errors.Is(err, errdef.ErrNotFound) { t.Fatalf("oras.FetchBytes() error = %v, wantErr %v", err, errdef.ErrNotFound) } // test FetchBytes with MaxBytes = 1 _, _, err = oras.FetchBytes(ctx, target, manifestTag, oras.FetchBytesOptions{MaxBytes: 1}) if !errors.Is(err, errdef.ErrSizeExceedsLimit) { t.Fatalf("oras.FetchBytes() error = %v, wantErr %v", err, errdef.ErrSizeExceedsLimit) } // test FetchBytes with TargetPlatform opts := oras.FetchBytesOptions{ FetchOptions: oras.FetchOptions{ ResolveOptions: oras.ResolveOptions{ TargetPlatform: &ocispec.Platform{ Architecture: arc_1, OS: os_1, }, }, }, } gotDesc, gotBytes, err = oras.FetchBytes(ctx, target, manifestTag, opts) if err != nil { t.Fatal("oras.FetchBytes() error =", err) } if !reflect.DeepEqual(gotDesc, manifestDesc) { t.Errorf("oras.FetchBytes() = %v, want %v", gotDesc, manifestDesc) } if !bytes.Equal(gotBytes, blobs[3]) { t.Errorf("oras.FetchBytes() = %v, want %v", gotBytes, blobs[3]) } // test FetchBytes with TargetPlatform and MaxBytes = 1 // should return size exceed error opts = oras.FetchBytesOptions{ FetchOptions: oras.FetchOptions{ ResolveOptions: oras.ResolveOptions{ TargetPlatform: &ocispec.Platform{ Architecture: arc_1, OS: os_1, }, }, }, MaxBytes: 1, } _, _, err = oras.FetchBytes(ctx, target, manifestTag, opts) if !errors.Is(err, errdef.ErrSizeExceedsLimit) { t.Fatalf("oras.FetchBytes() error = %v, wantErr %v", err, errdef.ErrSizeExceedsLimit) } // test FetchBytes with TargetPlatform but there is no matching node // should return not found error opts = oras.FetchBytesOptions{ FetchOptions: oras.FetchOptions{ ResolveOptions: oras.ResolveOptions{ TargetPlatform: &ocispec.Platform{ Architecture: arc_1, OS: os_1, Variant: variant_2, }, }, }, } _, _, err = oras.FetchBytes(ctx, target, manifestTag, opts) expected := fmt.Sprintf("%s: %v: platform in manifest does not match target platform", manifestDesc.Digest, errdef.ErrNotFound) if err.Error() != expected { t.Fatalf("oras.FetchBytes() error = %v, wantErr %v", err, expected) } // test FetchBytes on blob with TargetPlatform // should return unsupported error opts = oras.FetchBytesOptions{ FetchOptions: oras.FetchOptions{ ResolveOptions: oras.ResolveOptions{ TargetPlatform: &ocispec.Platform{ Architecture: arc_1, OS: os_1, }, }, }, } _, _, err = oras.FetchBytes(ctx, target, blobRef, opts) if !errors.Is(err, errdef.ErrUnsupported) { t.Fatalf("oras.FetchBytes() error = %v, wantErr %v", err, errdef.ErrUnsupported) } } func TestFetchBytes_Repository(t *testing.T) { arc_1 := "test-arc-1" arc_2 := "test-arc-2" os_1 := "test-os-1" os_2 := "test-os-2" blob := []byte("hello world") blobDesc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(blob), Size: int64(len(blob)), } manifest := []byte(`{"layers":[]}`) manifestDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageManifest, Digest: digest.FromBytes(manifest), Size: int64(len(manifest)), Platform: &ocispec.Platform{ Architecture: arc_1, OS: os_1, }, } manifest2 := []byte("test manifest") manifestDesc2 := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageManifest, Digest: digest.FromBytes(manifest2), Size: int64(len(manifest2)), Platform: &ocispec.Platform{ Architecture: arc_2, OS: os_2, }, } indexContent := ocispec.Index{ MediaType: ocispec.MediaTypeImageIndex, Manifests: []ocispec.Descriptor{ manifestDesc, manifestDesc2, }, } index, err := json.Marshal(indexContent) if err != nil { t.Fatal("failed to marshal index", err) } indexDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageIndex, Digest: digest.FromBytes(index), Size: int64(len(index)), } ref := "foobar" ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && (r.URL.Path == "/v2/test/manifests/"+indexDesc.Digest.String() || r.URL.Path == "/v2/test/manifests/"+ref): if accept := r.Header.Get("Accept"); !strings.Contains(accept, indexDesc.MediaType) { t.Errorf("manifest not convertable: %s", accept) w.WriteHeader(http.StatusBadRequest) return } w.Header().Set("Content-Type", indexDesc.MediaType) w.Header().Set("Docker-Content-Digest", indexDesc.Digest.String()) if _, err := w.Write(index); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } case r.URL.Path == "/v2/test/manifests/"+manifestDesc.Digest.String(): if accept := r.Header.Get("Accept"); !strings.Contains(accept, manifestDesc.MediaType) { t.Errorf("manifest not convertable: %s", accept) w.WriteHeader(http.StatusBadRequest) return } w.Header().Set("Content-Type", manifestDesc.MediaType) w.Header().Set("Docker-Content-Digest", manifestDesc.Digest.String()) if _, err := w.Write(manifest); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } case r.URL.Path == "/v2/test/blobs/"+blobDesc.Digest.String(): w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Docker-Content-Digest", blobDesc.Digest.String()) if _, err := w.Write(blob); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } default: w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repoName := uri.Host + "/test" repo, err := remote.NewRepository(repoName) if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true ctx := context.Background() // test FetchBytes with empty option by valid manifest tag gotDesc, gotBytes, err := oras.FetchBytes(ctx, repo, ref, oras.FetchBytesOptions{}) if err != nil { t.Fatalf("oras.FetchBytes() error = %v", err) } if !reflect.DeepEqual(gotDesc, indexDesc) { t.Errorf("oras.FetchBytes() = %v, want %v", gotDesc, indexDesc) } if !bytes.Equal(gotBytes, index) { t.Errorf("oras.FetchBytes() = %v, want %v", gotBytes, index) } // test FetchBytes with DefaultFetchBytesOptions by valid manifest tag gotDesc, gotBytes, err = oras.FetchBytes(ctx, repo, ref, oras.DefaultFetchBytesOptions) if err != nil { t.Fatalf("oras.FetchBytes() error = %v", err) } if !reflect.DeepEqual(gotDesc, indexDesc) { t.Errorf("oras.FetchBytes() = %v, want %v", gotDesc, indexDesc) } if !bytes.Equal(gotBytes, index) { t.Errorf("oras.FetchBytes() = %v, want %v", gotBytes, index) } // test FetchBytes with empty option by blob digest gotDesc, gotBytes, err = oras.FetchBytes(ctx, repo.Blobs(), blobDesc.Digest.String(), oras.FetchBytesOptions{}) if err != nil { t.Fatalf("oras.FetchBytes() error = %v", err) } if gotDesc.Digest != blobDesc.Digest || gotDesc.Size != blobDesc.Size { t.Errorf("oras.FetchBytes() = %v, want %v", gotDesc, blobDesc) } if !bytes.Equal(gotBytes, blob) { t.Errorf("oras.FetchBytes() = %v, want %v", gotBytes, blob) } // test FetchBytes with DefaultFetchBytesOptions by blob digest gotDesc, gotBytes, err = oras.FetchBytes(ctx, repo.Blobs(), blobDesc.Digest.String(), oras.DefaultFetchBytesOptions) if err != nil { t.Fatalf("oras.FetchBytes() error = %v", err) } if gotDesc.Digest != blobDesc.Digest || gotDesc.Size != blobDesc.Size { t.Errorf("oras.FetchBytes() = %v, want %v", gotDesc, blobDesc) } if !bytes.Equal(gotBytes, blob) { t.Errorf("oras.FetchBytes() = %v, want %v", gotBytes, blob) } // test FetchBytes with MaxBytes = 1 _, _, err = oras.FetchBytes(ctx, repo, ref, oras.FetchBytesOptions{MaxBytes: 1}) if !errors.Is(err, errdef.ErrSizeExceedsLimit) { t.Fatalf("oras.FetchBytes() error = %v, wantErr %v", err, errdef.ErrSizeExceedsLimit) } // test FetchBytes with wrong reference randomRef := "whatever" _, _, err = oras.FetchBytes(ctx, repo, randomRef, oras.DefaultFetchBytesOptions) if !errors.Is(err, errdef.ErrNotFound) { t.Fatalf("oras.FetchBytes() error = %v, wantErr %v", err, errdef.ErrNotFound) } // test FetchBytes with TargetPlatform opts := oras.FetchBytesOptions{ FetchOptions: oras.FetchOptions{ ResolveOptions: oras.ResolveOptions{ TargetPlatform: &ocispec.Platform{ Architecture: arc_1, OS: os_1, }, }, }, } gotDesc, gotBytes, err = oras.FetchBytes(ctx, repo, ref, opts) if err != nil { t.Fatal("oras.FetchBytes() error =", err) } if !reflect.DeepEqual(gotDesc, manifestDesc) { t.Errorf("oras.FetchBytes() = %v, want %v", gotDesc, manifestDesc) } if !bytes.Equal(gotBytes, manifest) { t.Errorf("oras.FetchBytes() = %v, want %v", gotBytes, manifest) } // test FetchBytes with TargetPlatform and MaxBytes = 1 // should return size exceed error opts = oras.FetchBytesOptions{ FetchOptions: oras.FetchOptions{ ResolveOptions: oras.ResolveOptions{ TargetPlatform: &ocispec.Platform{ Architecture: arc_1, OS: os_1, }, }, }, MaxBytes: 1, } _, _, err = oras.FetchBytes(ctx, repo, ref, opts) if !errors.Is(err, errdef.ErrSizeExceedsLimit) { t.Fatalf("oras.FetchBytes() error = %v, wantErr %v", err, errdef.ErrSizeExceedsLimit) } // test FetchBytes with TargetPlatform but there is no matching node // Should return not found error opts = oras.FetchBytesOptions{ FetchOptions: oras.FetchOptions{ ResolveOptions: oras.ResolveOptions{ TargetPlatform: &ocispec.Platform{ Architecture: arc_1, OS: arc_2, }, }, }, } _, _, err = oras.FetchBytes(ctx, repo, ref, opts) expected := fmt.Sprintf("%s: %v: no matching manifest was found in the manifest list", indexDesc.Digest, errdef.ErrNotFound) if err.Error() != expected { t.Fatalf("oras.FetchBytes() error = %v, wantErr %v", err, expected) } // test FetchBytes on blob with TargetPlatform // should return unsupported error opts = oras.FetchBytesOptions{ FetchOptions: oras.FetchOptions{ ResolveOptions: oras.ResolveOptions{ TargetPlatform: &ocispec.Platform{ Architecture: arc_1, OS: os_1, }, }, }, } _, _, err = oras.FetchBytes(ctx, repo.Blobs(), blobDesc.Digest.String(), opts) if !errors.Is(err, errdef.ErrUnsupported) { t.Fatalf("oras.FetchBytes() error = %v, wantErr %v", err, errdef.ErrUnsupported) } } func TestPushBytes_Memory(t *testing.T) { s := cas.NewMemory() content := []byte("hello world") mediaType := "test" descTest := ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(content), Size: int64(len(content)), } descOctet := ocispec.Descriptor{ MediaType: "application/octet-stream", Digest: digest.FromBytes(content), Size: int64(len(content)), } descEmpty := ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(nil), Size: 0, } ctx := context.Background() // test PushBytes with specified media type gotDesc, err := oras.PushBytes(ctx, s, mediaType, content) if err != nil { t.Fatal("oras.PushBytes() error =", err) } if !reflect.DeepEqual(gotDesc, descTest) { t.Errorf("oras.PushBytes() = %v, want %v", gotDesc, descTest) } rc, err := s.Fetch(ctx, gotDesc) if err != nil { t.Fatal("Memory.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Memory.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Memory.Fetch().Close() error =", err) } if !bytes.Equal(got, content) { t.Errorf("Memory.Fetch() = %v, want %v", got, content) } // test PushBytes with existing content _, err = oras.PushBytes(ctx, s, mediaType, content) if !errors.Is(err, errdef.ErrAlreadyExists) { t.Errorf("oras.PushBytes() error = %v, wantErr %v", err, errdef.ErrAlreadyExists) } // test PushBytes with empty media type gotDesc, err = oras.PushBytes(ctx, s, "", content) if err != nil { t.Fatal("oras.PushBytes() error =", err) } if !reflect.DeepEqual(gotDesc, descOctet) { t.Errorf("oras.PushBytes() = %v, want %v", gotDesc, descOctet) } rc, err = s.Fetch(ctx, gotDesc) if err != nil { t.Fatal("Memory.Fetch() error =", err) } got, err = io.ReadAll(rc) if err != nil { t.Fatal("Memory.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Memory.Fetch().Close() error =", err) } if !bytes.Equal(got, content) { t.Errorf("Memory.Fetch() = %v, want %v", got, content) } // test PushBytes with empty content gotDesc, err = oras.PushBytes(ctx, s, mediaType, nil) if err != nil { t.Fatal("oras.PushBytes() error =", err) } if !reflect.DeepEqual(gotDesc, descEmpty) { t.Errorf("oras.PushBytes() = %v, want %v", gotDesc, descEmpty) } rc, err = s.Fetch(ctx, gotDesc) if err != nil { t.Fatal("Memory.Fetch() error =", err) } got, err = io.ReadAll(rc) if err != nil { t.Fatal("Memory.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Memory.Fetch().Close() error =", err) } if !bytes.Equal(got, nil) { t.Errorf("Memory.Fetch() = %v, want %v", got, nil) } } func TestPushBytes_Repository(t *testing.T) { blob := []byte("hello world") blobMediaType := "test" blobDesc := ocispec.Descriptor{ MediaType: blobMediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), } var gotBlob []byte index := []byte(`{"manifests":[]}`) indexMediaType := ocispec.MediaTypeImageIndex indexDesc := ocispec.Descriptor{ MediaType: indexMediaType, Digest: digest.FromBytes(index), Size: int64(len(index)), } var gotIndex []byte uuid := "4fd53bc9-565d-4527-ab80-3e051ac4880c" ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPost && r.URL.Path == "/v2/test/blobs/uploads/": w.Header().Set("Location", "/v2/test/blobs/uploads/"+uuid) w.WriteHeader(http.StatusAccepted) return case r.Method == http.MethodPut && r.URL.Path == "/v2/test/blobs/uploads/"+uuid: if contentType := r.Header.Get("Content-Type"); contentType != "application/octet-stream" { w.WriteHeader(http.StatusBadRequest) break } if contentDigest := r.URL.Query().Get("digest"); contentDigest != blobDesc.Digest.String() { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotBlob = buf.Bytes() w.Header().Set("Docker-Content-Digest", blobDesc.Digest.String()) w.WriteHeader(http.StatusCreated) return case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+indexDesc.Digest.String(): if contentType := r.Header.Get("Content-Type"); contentType != indexDesc.MediaType { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotIndex = buf.Bytes() w.Header().Set("Docker-Content-Digest", indexDesc.Digest.String()) w.WriteHeader(http.StatusCreated) return default: w.WriteHeader(http.StatusForbidden) } t.Errorf("unexpected access: %s %s", r.Method, r.URL) })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err := remote.NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true ctx := context.Background() // test PushBytes with blob gotDesc, err := oras.PushBytes(ctx, repo.Blobs(), blobMediaType, blob) if err != nil { t.Fatal("oras.PushBytes() error =", err) } if !reflect.DeepEqual(gotDesc, blobDesc) { t.Errorf("oras.PushBytes() = %v, want %v", gotDesc, blobDesc) } if !bytes.Equal(gotBlob, blob) { t.Errorf("oras.PushBytes() = %v, want %v", gotBlob, blob) } // test PushBytes with manifest gotDesc, err = oras.PushBytes(ctx, repo, indexMediaType, index) if err != nil { t.Fatal("oras.PushBytes() error =", err) } if err != nil { t.Fatal("oras.PushBytes() error =", err) } if !reflect.DeepEqual(gotDesc, indexDesc) { t.Errorf("oras.PushBytes() = %v, want %v", gotDesc, indexDesc) } if !bytes.Equal(gotIndex, index) { t.Errorf("oras.PushBytes() = %v, want %v", gotIndex, index) } } func TestTagBytesN_Memory(t *testing.T) { s := memory.New() content := []byte("hello world") mediaType := "test" descTest := ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(content), Size: int64(len(content)), } descOctet := ocispec.Descriptor{ MediaType: "application/octet-stream", Digest: digest.FromBytes(content), Size: int64(len(content)), } descEmpty := ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(nil), Size: 0, } ctx := context.Background() // test TagBytesN with no reference gotDesc, err := oras.TagBytesN(ctx, s, mediaType, content, nil, oras.DefaultTagBytesNOptions) if err != nil { t.Fatal("oras.TagBytes() error =", err) } if !reflect.DeepEqual(gotDesc, descTest) { t.Errorf("oras.TagBytes() = %v, want %v", gotDesc, descTest) } rc, err := s.Fetch(ctx, gotDesc) if err != nil { t.Fatal("Memory.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Memory.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Memory.Fetch().Close() error =", err) } if !bytes.Equal(got, content) { t.Errorf("Memory.Fetch() = %v, want %v", got, content) } // test TagBytesN with multiple references refs := []string{"foo", "bar", "baz"} gotDesc, err = oras.TagBytesN(ctx, s, mediaType, content, refs, oras.DefaultTagBytesNOptions) if err != nil { t.Fatal("oras.TagBytes() error =", err) } if !reflect.DeepEqual(gotDesc, descTest) { t.Fatalf("oras.TagBytes() = %v, want %v", gotDesc, descTest) } for _, ref := range refs { gotDesc, err := s.Resolve(ctx, ref) if err != nil { t.Fatal("Memory.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, descTest) { t.Fatalf("oras.PushBytes() = %v, want %v", gotDesc, descTest) } } rc, err = s.Fetch(ctx, gotDesc) if err != nil { t.Fatal("Memory.Fetch() error =", err) } got, err = io.ReadAll(rc) if err != nil { t.Fatal("Memory.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Memory.Fetch().Close() error =", err) } if !bytes.Equal(got, content) { t.Errorf("Memory.Fetch() = %v, want %v", got, content) } // test TagBytesN with empty media type and multiple references gotDesc, err = oras.TagBytesN(ctx, s, "", content, refs, oras.DefaultTagBytesNOptions) if err != nil { t.Fatal("oras.TagBytes() error =", err) } if !reflect.DeepEqual(gotDesc, descOctet) { t.Fatalf("oras.TagBytes() = %v, want %v", gotDesc, descOctet) } for _, ref := range refs { gotDesc, err := s.Resolve(ctx, ref) if err != nil { t.Fatal("Memory.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, descOctet) { t.Fatalf("oras.PushBytes() = %v, want %v", gotDesc, descOctet) } } rc, err = s.Fetch(ctx, gotDesc) if err != nil { t.Fatal("Memory.Fetch() error =", err) } got, err = io.ReadAll(rc) if err != nil { t.Fatal("Memory.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Memory.Fetch().Close() error =", err) } if !bytes.Equal(got, content) { t.Errorf("Memory.Fetch() = %v, want %v", got, content) } // test TagBytesN with empty content and multiple references gotDesc, err = oras.TagBytesN(ctx, s, mediaType, nil, refs, oras.DefaultTagBytesNOptions) if err != nil { t.Fatal("oras.TagBytes() error =", err) } if !reflect.DeepEqual(gotDesc, descEmpty) { t.Fatalf("oras.TagBytes() = %v, want %v", gotDesc, descEmpty) } for _, ref := range refs { gotDesc, err := s.Resolve(ctx, ref) if err != nil { t.Fatal("Memory.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, descEmpty) { t.Fatalf("oras.TagBytes() = %v, want %v", gotDesc, descEmpty) } } rc, err = s.Fetch(ctx, gotDesc) if err != nil { t.Fatal("Memory.Fetch() error =", err) } got, err = io.ReadAll(rc) if err != nil { t.Fatal("Memory.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Memory.Fetch().Close() error =", err) } if !bytes.Equal(got, nil) { t.Errorf("Memory.Fetch() = %v, want %v", got, nil) } } func TestTagBytesN_Repository(t *testing.T) { index := []byte(`{"manifests":[]}`) indexMediaType := ocispec.MediaTypeImageIndex indexDesc := ocispec.Descriptor{ MediaType: indexMediaType, Digest: digest.FromBytes(index), Size: int64(len(index)), } refFoo := "foo" refBar := "bar" refs := []string{refFoo, refBar} ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPut && (r.URL.Path == "/v2/test/manifests/"+indexDesc.Digest.String() || r.URL.Path == "/v2/test/manifests/"+refFoo || r.URL.Path == "/v2/test/manifests/"+refBar): if contentType := r.Header.Get("Content-Type"); contentType != indexDesc.MediaType { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } w.Header().Set("Docker-Content-Digest", indexDesc.Digest.String()) w.WriteHeader(http.StatusCreated) return case (r.Method == http.MethodHead || r.Method == http.MethodGet) && (r.URL.Path == "/v2/test/manifests/"+indexDesc.Digest.String() || r.URL.Path == "/v2/test/manifests/"+refFoo || r.URL.Path == "/v2/test/manifests/"+refBar): if accept := r.Header.Get("Accept"); !strings.Contains(accept, indexDesc.MediaType) { t.Errorf("manifest not convertable: %s", accept) w.WriteHeader(http.StatusBadRequest) return } w.Header().Set("Content-Type", indexDesc.MediaType) w.Header().Set("Docker-Content-Digest", indexDesc.Digest.String()) w.Header().Set("Content-Length", strconv.Itoa(int(indexDesc.Size))) if r.Method == http.MethodGet { if _, err := w.Write(index); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } } default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusForbidden) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err := remote.NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true ctx := context.Background() // test TagBytesN with no reference gotDesc, err := oras.TagBytesN(ctx, repo, indexMediaType, index, nil, oras.DefaultTagBytesNOptions) if err != nil { t.Fatal("oras.TagBytes() error =", err) } if !reflect.DeepEqual(gotDesc, indexDesc) { t.Errorf("oras.TagBytes() = %v, want %v", gotDesc, indexDesc) } rc, err := repo.Fetch(ctx, gotDesc) if err != nil { t.Fatal("Repository.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Repository.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Repository.Fetch().Close() error =", err) } if !bytes.Equal(got, index) { t.Errorf("Repository.Fetch() = %v, want %v", got, index) } // test TagBytesN with multiple references gotDesc, err = oras.TagBytesN(ctx, repo, indexMediaType, index, refs, oras.DefaultTagBytesNOptions) if err != nil { t.Fatal("oras.TagBytes() error =", err) } if !reflect.DeepEqual(gotDesc, indexDesc) { t.Fatalf("oras.TagBytes() = %v, want %v", gotDesc, indexDesc) } for _, ref := range refs { gotDesc, err := repo.Resolve(ctx, ref) if err != nil { t.Fatal("Repository.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, indexDesc) { t.Fatalf("oras.TagBytes() = %v, want %v", gotDesc, indexDesc) } } rc, err = repo.Fetch(ctx, gotDesc) if err != nil { t.Fatal("Repository.Fetch() error =", err) } got, err = io.ReadAll(rc) if err != nil { t.Fatal("Repository.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Repository.Fetch().Close() error =", err) } if !bytes.Equal(got, index) { t.Errorf("Repository.Fetch() = %v, want %v", got, index) } } func TestTagBytes(t *testing.T) { s := memory.New() content := []byte("hello world") mediaType := "test" descTest := ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(content), Size: int64(len(content)), } ctx := context.Background() ref := "foobar" // test TagBytes gotDesc, err := oras.TagBytes(ctx, s, mediaType, content, ref) if err != nil { t.Fatal("oras.TagBytes() error =", err) } if !reflect.DeepEqual(gotDesc, descTest) { t.Errorf("oras.TagBytes() = %v, want %v", gotDesc, descTest) } gotDesc, err = s.Resolve(ctx, ref) if err != nil { t.Fatal("Memory.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, descTest) { t.Fatalf("oras.TagBytes() = %v, want %v", gotDesc, descTest) } rc, err := s.Fetch(ctx, gotDesc) if err != nil { t.Fatal("Memory.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Memory.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Memory.Fetch().Close() error =", err) } if !bytes.Equal(got, content) { t.Errorf("Memory.Fetch() = %v, want %v", got, content) } } oras-go-2.5.0/copy.go000066400000000000000000000407311457674530300144110ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package oras import ( "context" "errors" "fmt" "io" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "golang.org/x/sync/semaphore" "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/errdef" "oras.land/oras-go/v2/internal/cas" "oras.land/oras-go/v2/internal/descriptor" "oras.land/oras-go/v2/internal/platform" "oras.land/oras-go/v2/internal/registryutil" "oras.land/oras-go/v2/internal/status" "oras.land/oras-go/v2/internal/syncutil" "oras.land/oras-go/v2/registry" ) // defaultConcurrency is the default value of CopyGraphOptions.Concurrency. const defaultConcurrency int = 3 // This value is consistent with dockerd and containerd. // SkipNode signals to stop copying a node. When returned from PreCopy the blob must exist in the target. // This can be used to signal that a blob has been made available in the target repository by "Mount()" or some other technique. var SkipNode = errors.New("skip node") // DefaultCopyOptions provides the default CopyOptions. var DefaultCopyOptions CopyOptions = CopyOptions{ CopyGraphOptions: DefaultCopyGraphOptions, } // CopyOptions contains parameters for [oras.Copy]. type CopyOptions struct { CopyGraphOptions // MapRoot maps the resolved root node to a desired root node for copy. // When MapRoot is provided, the descriptor resolved from the source // reference will be passed to MapRoot, and the mapped descriptor will be // used as the root node for copy. MapRoot func(ctx context.Context, src content.ReadOnlyStorage, root ocispec.Descriptor) (ocispec.Descriptor, error) } // WithTargetPlatform configures opts.MapRoot to select the manifest whose // platform matches the given platform. When MapRoot is provided, the platform // selection will be applied on the mapped root node. // - If the given platform is nil, no platform selection will be applied. // - If the root node is a manifest, it will remain the same if platform // matches, otherwise ErrNotFound will be returned. // - If the root node is a manifest list, it will be mapped to the first // matching manifest if exists, otherwise ErrNotFound will be returned. // - Otherwise ErrUnsupported will be returned. func (opts *CopyOptions) WithTargetPlatform(p *ocispec.Platform) { if p == nil { return } mapRoot := opts.MapRoot opts.MapRoot = func(ctx context.Context, src content.ReadOnlyStorage, root ocispec.Descriptor) (desc ocispec.Descriptor, err error) { if mapRoot != nil { if root, err = mapRoot(ctx, src, root); err != nil { return ocispec.Descriptor{}, err } } return platform.SelectManifest(ctx, src, root, p) } } // defaultCopyMaxMetadataBytes is the default value of // CopyGraphOptions.MaxMetadataBytes. const defaultCopyMaxMetadataBytes int64 = 4 * 1024 * 1024 // 4 MiB // DefaultCopyGraphOptions provides the default CopyGraphOptions. var DefaultCopyGraphOptions CopyGraphOptions // CopyGraphOptions contains parameters for [oras.CopyGraph]. type CopyGraphOptions struct { // Concurrency limits the maximum number of concurrent copy tasks. // If less than or equal to 0, a default (currently 3) is used. Concurrency int // MaxMetadataBytes limits the maximum size of the metadata that can be // cached in the memory. // If less than or equal to 0, a default (currently 4 MiB) is used. MaxMetadataBytes int64 // PreCopy handles the current descriptor before it is copied. PreCopy can // return a SkipNode to signal that desc should be skipped when it already // exists in the target. PreCopy func(ctx context.Context, desc ocispec.Descriptor) error // PostCopy handles the current descriptor after it is copied. PostCopy func(ctx context.Context, desc ocispec.Descriptor) error // OnCopySkipped will be called when the sub-DAG rooted by the current node // is skipped. OnCopySkipped func(ctx context.Context, desc ocispec.Descriptor) error // MountFrom returns the candidate repositories that desc may be mounted from. // The OCI references will be tried in turn. If mounting fails on all of them, // then it falls back to a copy. MountFrom func(ctx context.Context, desc ocispec.Descriptor) ([]string, error) // OnMounted will be invoked when desc is mounted. OnMounted func(ctx context.Context, desc ocispec.Descriptor) error // FindSuccessors finds the successors of the current node. // fetcher provides cached access to the source storage, and is suitable // for fetching non-leaf nodes like manifests. Since anything fetched from // fetcher will be cached in the memory, it is recommended to use original // source storage to fetch large blobs. // If FindSuccessors is nil, content.Successors will be used. FindSuccessors func(ctx context.Context, fetcher content.Fetcher, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) } // Copy copies a rooted directed acyclic graph (DAG) with the tagged root node // in the source Target to the destination Target. // The destination reference will be the same as the source reference if the // destination reference is left blank. // // Returns the descriptor of the root node on successful copy. func Copy(ctx context.Context, src ReadOnlyTarget, srcRef string, dst Target, dstRef string, opts CopyOptions) (ocispec.Descriptor, error) { if src == nil { return ocispec.Descriptor{}, errors.New("nil source target") } if dst == nil { return ocispec.Descriptor{}, errors.New("nil destination target") } if dstRef == "" { dstRef = srcRef } // use caching proxy on non-leaf nodes if opts.MaxMetadataBytes <= 0 { opts.MaxMetadataBytes = defaultCopyMaxMetadataBytes } proxy := cas.NewProxyWithLimit(src, cas.NewMemory(), opts.MaxMetadataBytes) root, err := resolveRoot(ctx, src, srcRef, proxy) if err != nil { return ocispec.Descriptor{}, fmt.Errorf("failed to resolve %s: %w", srcRef, err) } if opts.MapRoot != nil { proxy.StopCaching = true root, err = opts.MapRoot(ctx, proxy, root) if err != nil { return ocispec.Descriptor{}, err } proxy.StopCaching = false } if err := prepareCopy(ctx, dst, dstRef, proxy, root, &opts); err != nil { return ocispec.Descriptor{}, err } if err := copyGraph(ctx, src, dst, root, proxy, nil, nil, opts.CopyGraphOptions); err != nil { return ocispec.Descriptor{}, err } return root, nil } // CopyGraph copies a rooted directed acyclic graph (DAG) from the source CAS to // the destination CAS. func CopyGraph(ctx context.Context, src content.ReadOnlyStorage, dst content.Storage, root ocispec.Descriptor, opts CopyGraphOptions) error { return copyGraph(ctx, src, dst, root, nil, nil, nil, opts) } // copyGraph copies a rooted directed acyclic graph (DAG) from the source CAS to // the destination CAS with specified caching, concurrency limiter and tracker. func copyGraph(ctx context.Context, src content.ReadOnlyStorage, dst content.Storage, root ocispec.Descriptor, proxy *cas.Proxy, limiter *semaphore.Weighted, tracker *status.Tracker, opts CopyGraphOptions) error { if proxy == nil { // use caching proxy on non-leaf nodes if opts.MaxMetadataBytes <= 0 { opts.MaxMetadataBytes = defaultCopyMaxMetadataBytes } proxy = cas.NewProxyWithLimit(src, cas.NewMemory(), opts.MaxMetadataBytes) } if limiter == nil { // if Concurrency is not set or invalid, use the default concurrency if opts.Concurrency <= 0 { opts.Concurrency = defaultConcurrency } limiter = semaphore.NewWeighted(int64(opts.Concurrency)) } if tracker == nil { // track content status tracker = status.NewTracker() } // if FindSuccessors is not provided, use the default one if opts.FindSuccessors == nil { opts.FindSuccessors = content.Successors } // traverse the graph var fn syncutil.GoFunc[ocispec.Descriptor] fn = func(ctx context.Context, region *syncutil.LimitedRegion, desc ocispec.Descriptor) (err error) { // skip the descriptor if other go routine is working on it done, committed := tracker.TryCommit(desc) if !committed { return nil } defer func() { if err == nil { // mark the content as done on success close(done) } }() // skip if a rooted sub-DAG exists exists, err := dst.Exists(ctx, desc) if err != nil { return err } if exists { if opts.OnCopySkipped != nil { if err := opts.OnCopySkipped(ctx, desc); err != nil { return err } } return nil } // find successors while non-leaf nodes will be fetched and cached successors, err := opts.FindSuccessors(ctx, proxy, desc) if err != nil { return err } successors = removeForeignLayers(successors) if len(successors) != 0 { // for non-leaf nodes, process successors and wait for them to complete region.End() if err := syncutil.Go(ctx, limiter, fn, successors...); err != nil { return err } for _, node := range successors { done, committed := tracker.TryCommit(node) if committed { return fmt.Errorf("%s: %s: successor not committed", desc.Digest, node.Digest) } select { case <-done: case <-ctx.Done(): return ctx.Err() } } if err := region.Start(); err != nil { return err } } exists, err = proxy.Cache.Exists(ctx, desc) if err != nil { return err } if exists { return copyNode(ctx, proxy.Cache, dst, desc, opts) } return mountOrCopyNode(ctx, src, dst, desc, opts) } return syncutil.Go(ctx, limiter, fn, root) } // mountOrCopyNode tries to mount the node, if not falls back to copying. func mountOrCopyNode(ctx context.Context, src content.ReadOnlyStorage, dst content.Storage, desc ocispec.Descriptor, opts CopyGraphOptions) error { // Need MountFrom and it must be a blob if opts.MountFrom == nil || descriptor.IsManifest(desc) { return copyNode(ctx, src, dst, desc, opts) } mounter, ok := dst.(registry.Mounter) if !ok { // mounting is not supported by the destination return copyNode(ctx, src, dst, desc, opts) } sourceRepositories, err := opts.MountFrom(ctx, desc) if err != nil { // Technically this error is not fatal, we can still attempt to copy the node // But for consistency with the other callbacks we bail out. return err } if len(sourceRepositories) == 0 { return copyNode(ctx, src, dst, desc, opts) } skipSource := errors.New("skip source") for i, sourceRepository := range sourceRepositories { // try mounting this source repository var mountFailed bool getContent := func() (io.ReadCloser, error) { // the invocation of getContent indicates that mounting has failed mountFailed = true if i < len(sourceRepositories)-1 { // If this is not the last one, skip this source and try next one // We want to return an error that we will test for from mounter.Mount() return nil, skipSource } // this is the last iteration so we need to actually get the content and do the copy // but first call the PreCopy function if opts.PreCopy != nil { if err := opts.PreCopy(ctx, desc); err != nil { return nil, err } } return src.Fetch(ctx, desc) } // Mount or copy if err := mounter.Mount(ctx, desc, sourceRepository, getContent); err != nil && !errors.Is(err, skipSource) { return err } if !mountFailed { // mounted, success if opts.OnMounted != nil { if err := opts.OnMounted(ctx, desc); err != nil { return err } } return nil } } // we copied it if opts.PostCopy != nil { if err := opts.PostCopy(ctx, desc); err != nil { return err } } return nil } // doCopyNode copies a single content from the source CAS to the destination CAS. func doCopyNode(ctx context.Context, src content.ReadOnlyStorage, dst content.Storage, desc ocispec.Descriptor) error { rc, err := src.Fetch(ctx, desc) if err != nil { return err } defer rc.Close() err = dst.Push(ctx, desc, rc) if err != nil && !errors.Is(err, errdef.ErrAlreadyExists) { return err } return nil } // copyNode copies a single content from the source CAS to the destination CAS, // and apply the given options. func copyNode(ctx context.Context, src content.ReadOnlyStorage, dst content.Storage, desc ocispec.Descriptor, opts CopyGraphOptions) error { if opts.PreCopy != nil { if err := opts.PreCopy(ctx, desc); err != nil { if err == SkipNode { return nil } return err } } if err := doCopyNode(ctx, src, dst, desc); err != nil { return err } if opts.PostCopy != nil { return opts.PostCopy(ctx, desc) } return nil } // copyCachedNodeWithReference copies a single content with a reference from the // source cache to the destination ReferencePusher. func copyCachedNodeWithReference(ctx context.Context, src *cas.Proxy, dst registry.ReferencePusher, desc ocispec.Descriptor, dstRef string) error { rc, err := src.FetchCached(ctx, desc) if err != nil { return err } defer rc.Close() err = dst.PushReference(ctx, desc, rc, dstRef) if err != nil && !errors.Is(err, errdef.ErrAlreadyExists) { return err } return nil } // resolveRoot resolves the source reference to the root node. func resolveRoot(ctx context.Context, src ReadOnlyTarget, srcRef string, proxy *cas.Proxy) (ocispec.Descriptor, error) { refFetcher, ok := src.(registry.ReferenceFetcher) if !ok { return src.Resolve(ctx, srcRef) } // optimize performance for ReferenceFetcher targets refProxy := ®istryutil.Proxy{ ReferenceFetcher: refFetcher, Proxy: proxy, } root, rc, err := refProxy.FetchReference(ctx, srcRef) if err != nil { return ocispec.Descriptor{}, err } defer rc.Close() // cache root if it is a non-leaf node fetcher := content.FetcherFunc(func(ctx context.Context, target ocispec.Descriptor) (io.ReadCloser, error) { if content.Equal(target, root) { return rc, nil } return nil, errors.New("fetching only root node expected") }) if _, err = content.Successors(ctx, fetcher, root); err != nil { return ocispec.Descriptor{}, err } // TODO: optimize special case where root is a leaf node (i.e. a blob) // and dst is a ReferencePusher. return root, nil } // prepareCopy prepares the hooks for copy. func prepareCopy(ctx context.Context, dst Target, dstRef string, proxy *cas.Proxy, root ocispec.Descriptor, opts *CopyOptions) error { if refPusher, ok := dst.(registry.ReferencePusher); ok { // optimize performance for ReferencePusher targets preCopy := opts.PreCopy opts.PreCopy = func(ctx context.Context, desc ocispec.Descriptor) error { if preCopy != nil { if err := preCopy(ctx, desc); err != nil { return err } } if !content.Equal(desc, root) { // for non-root node, do nothing return nil } // for root node, prepare optimized copy if err := copyCachedNodeWithReference(ctx, proxy, refPusher, desc, dstRef); err != nil { return err } if opts.PostCopy != nil { if err := opts.PostCopy(ctx, desc); err != nil { return err } } // skip the regular copy workflow return SkipNode } } else { postCopy := opts.PostCopy opts.PostCopy = func(ctx context.Context, desc ocispec.Descriptor) error { if content.Equal(desc, root) { // for root node, tag it after copying it if err := dst.Tag(ctx, root, dstRef); err != nil { return err } } if postCopy != nil { return postCopy(ctx, desc) } return nil } } onCopySkipped := opts.OnCopySkipped opts.OnCopySkipped = func(ctx context.Context, desc ocispec.Descriptor) error { if !content.Equal(desc, root) { if onCopySkipped != nil { return onCopySkipped(ctx, desc) } return nil } // enforce tagging when the skipped node is root if refPusher, ok := dst.(registry.ReferencePusher); ok { // NOTE: refPusher tags the node by copying it with the reference, // so onCopySkipped shouldn't be invoked in this case return copyCachedNodeWithReference(ctx, proxy, refPusher, desc, dstRef) } // invoke onCopySkipped before tagging if onCopySkipped != nil { if err := onCopySkipped(ctx, desc); err != nil { return err } } return dst.Tag(ctx, root, dstRef) } return nil } // removeForeignLayers in-place removes all foreign layers in the given slice. func removeForeignLayers(descs []ocispec.Descriptor) []ocispec.Descriptor { var j int for i, desc := range descs { if !descriptor.IsForeignLayer(desc) { if i != j { descs[j] = desc } j++ } } return descs[:j] } oras-go-2.5.0/copy_test.go000066400000000000000000002063121457674530300154470ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package oras_test import ( "bytes" "context" _ "crypto/sha256" "encoding/json" "errors" "fmt" "io" "reflect" "sync/atomic" "testing" "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2" "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/content/file" "oras.land/oras-go/v2/content/memory" "oras.land/oras-go/v2/errdef" "oras.land/oras-go/v2/internal/cas" "oras.land/oras-go/v2/internal/docker" "oras.land/oras-go/v2/internal/spec" ) // storageTracker tracks storage API counts. type storageTracker struct { content.Storage fetch int64 push int64 exists int64 } func (t *storageTracker) Fetch(ctx context.Context, target ocispec.Descriptor) (io.ReadCloser, error) { atomic.AddInt64(&t.fetch, 1) return t.Storage.Fetch(ctx, target) } func (t *storageTracker) Push(ctx context.Context, expected ocispec.Descriptor, content io.Reader) error { atomic.AddInt64(&t.push, 1) return t.Storage.Push(ctx, expected, content) } func (t *storageTracker) Exists(ctx context.Context, target ocispec.Descriptor) (bool, error) { atomic.AddInt64(&t.exists, 1) return t.Storage.Exists(ctx, target) } func TestCopy_FullCopy(t *testing.T) { src := memory.New() dst := memory.New() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 generateManifest(descs[0], descs[1:3]...) // Blob 3 ctx := context.Background() for i := range blobs { err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } root := descs[3] ref := "foobar" err := src.Tag(ctx, root, ref) if err != nil { t.Fatal("fail to tag root node", err) } // test copy gotDesc, err := oras.Copy(ctx, src, ref, dst, "", oras.CopyOptions{}) if err != nil { t.Fatalf("Copy() error = %v, wantErr %v", err, false) } if !reflect.DeepEqual(gotDesc, root) { t.Errorf("Copy() = %v, want %v", gotDesc, root) } // verify contents for i, desc := range descs { exists, err := dst.Exists(ctx, desc) if err != nil { t.Fatalf("dst.Exists(%d) error = %v", i, err) } if !exists { t.Errorf("dst.Exists(%d) = %v, want %v", i, exists, true) } } // verify tag gotDesc, err = dst.Resolve(ctx, ref) if err != nil { t.Fatal("dst.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, root) { t.Errorf("dst.Resolve() = %v, want %v", gotDesc, root) } } func TestCopy_ExistedRoot(t *testing.T) { src := memory.New() dst := memory.New() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 generateManifest(descs[0], descs[1:3]...) // Blob 3 ctx := context.Background() for i := range blobs { err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } root := descs[3] ref := "foobar" newTag := "newtag" err := src.Tag(ctx, root, ref) if err != nil { t.Fatal("fail to tag root node", err) } var skippedCount int64 copyOpts := oras.CopyOptions{ CopyGraphOptions: oras.CopyGraphOptions{ OnCopySkipped: func(ctx context.Context, desc ocispec.Descriptor) error { atomic.AddInt64(&skippedCount, 1) return nil }, }, } // copy with src tag gotDesc, err := oras.Copy(ctx, src, ref, dst, "", copyOpts) if err != nil { t.Fatalf("Copy() error = %v, wantErr %v", err, false) } if !reflect.DeepEqual(gotDesc, root) { t.Errorf("Copy() = %v, want %v", gotDesc, root) } // copy with new tag gotDesc, err = oras.Copy(ctx, src, ref, dst, newTag, copyOpts) if err != nil { t.Fatalf("Copy() error = %v, wantErr %v", err, false) } if !reflect.DeepEqual(gotDesc, root) { t.Errorf("Copy() = %v, want %v", gotDesc, root) } // verify contents for i, desc := range descs { exists, err := dst.Exists(ctx, desc) if err != nil { t.Fatalf("dst.Exists(%d) error = %v", i, err) } if !exists { t.Errorf("dst.Exists(%d) = %v, want %v", i, exists, true) } } // verify src tag gotDesc, err = dst.Resolve(ctx, ref) if err != nil { t.Fatal("dst.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, root) { t.Errorf("dst.Resolve() = %v, want %v", gotDesc, root) } // verify new tag gotDesc, err = dst.Resolve(ctx, newTag) if err != nil { t.Fatal("dst.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, root) { t.Errorf("dst.Resolve() = %v, want %v", gotDesc, root) } // verify invocation of onCopySkipped() if got, want := skippedCount, int64(1); got != want { t.Errorf("count(OnCopySkipped()) = %v, want %v", got, want) } } func TestCopyGraph_FullCopy(t *testing.T) { src := cas.NewMemory() dst := cas.NewMemory() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } generateIndex := func(manifests ...ocispec.Descriptor) { index := ocispec.Index{ Manifests: manifests, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageIndex, indexJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 appendBlob(ocispec.MediaTypeImageLayer, []byte("hello")) // Blob 3 generateManifest(descs[0], descs[1:3]...) // Blob 4 generateManifest(descs[0], descs[3]) // Blob 5 generateManifest(descs[0], descs[1:4]...) // Blob 6 generateIndex(descs[4:6]...) // Blob 7 generateIndex(descs[6]) // Blob 8 generateIndex() // Blob 9 generateIndex(descs[7:10]...) // Blob 10 ctx := context.Background() for i := range blobs { err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } // test copy graph srcTracker := &storageTracker{Storage: src} dstTracker := &storageTracker{Storage: dst} root := descs[len(descs)-1] if err := oras.CopyGraph(ctx, srcTracker, dstTracker, root, oras.CopyGraphOptions{}); err != nil { t.Fatalf("CopyGraph() error = %v, wantErr %v", err, false) } // verify contents contents := dst.Map() if got, want := len(contents), len(blobs); got != want { t.Errorf("len(dst) = %v, wantErr %v", got, want) } for i := range blobs { got, err := content.FetchAll(ctx, dst, descs[i]) if err != nil { t.Errorf("content[%d] error = %v, wantErr %v", i, err, false) continue } if want := blobs[i]; !bytes.Equal(got, want) { t.Errorf("content[%d] = %v, want %v", i, got, want) } } // verify API counts if got, want := srcTracker.fetch, int64(len(blobs)); got != want { t.Errorf("count(src.Fetch()) = %v, want %v", got, want) } if got, want := srcTracker.push, int64(0); got != want { t.Errorf("count(src.Push()) = %v, want %v", got, want) } if got, want := srcTracker.exists, int64(0); got != want { t.Errorf("count(src.Exists()) = %v, want %v", got, want) } if got, want := dstTracker.fetch, int64(0); got != want { t.Errorf("count(dst.Fetch()) = %v, want %v", got, want) } if got, want := dstTracker.push, int64(len(blobs)); got != want { t.Errorf("count(dst.Push()) = %v, want %v", got, want) } if got, want := dstTracker.exists, int64(len(blobs)); got != want { t.Errorf("count(dst.Exists()) = %v, want %v", got, want) } } func TestCopyGraph_PartialCopy(t *testing.T) { src := cas.NewMemory() dst := cas.NewMemory() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } generateIndex := func(manifests ...ocispec.Descriptor) { index := ocispec.Index{ Manifests: manifests, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageIndex, indexJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 generateManifest(descs[0], descs[1:3]...) // Blob 3 appendBlob(ocispec.MediaTypeImageLayer, []byte("hello")) // Blob 4 generateManifest(descs[0], descs[4]) // Blob 5 generateIndex(descs[3], descs[5]) // Blob 6 ctx := context.Background() for i := range blobs { err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } // initial copy root := descs[3] if err := oras.CopyGraph(ctx, src, dst, root, oras.CopyGraphOptions{}); err != nil { t.Fatalf("CopyGraph() error = %v, wantErr %v", err, false) } // verify contents contents := dst.Map() if got, want := len(contents), len(blobs[:4]); got != want { t.Fatalf("len(dst) = %v, wantErr %v", got, want) } for i := range blobs[:4] { got, err := content.FetchAll(ctx, dst, descs[i]) if err != nil { t.Fatalf("content[%d] error = %v, wantErr %v", i, err, false) } if want := blobs[i]; !bytes.Equal(got, want) { t.Fatalf("content[%d] = %v, want %v", i, got, want) } } // test copy srcTracker := &storageTracker{Storage: src} dstTracker := &storageTracker{Storage: dst} root = descs[len(descs)-1] if err := oras.CopyGraph(ctx, srcTracker, dstTracker, root, oras.CopyGraphOptions{}); err != nil { t.Fatalf("CopyGraph() error = %v, wantErr %v", err, false) } // verify contents contents = dst.Map() if got, want := len(contents), len(blobs); got != want { t.Errorf("len(dst) = %v, wantErr %v", got, want) } for i := range blobs { got, err := content.FetchAll(ctx, dst, descs[i]) if err != nil { t.Errorf("content[%d] error = %v, wantErr %v", i, err, false) continue } if want := blobs[i]; !bytes.Equal(got, want) { t.Errorf("content[%d] = %v, want %v", i, got, want) } } // verify API counts if got, want := srcTracker.fetch, int64(3); got != want { t.Errorf("count(src.Fetch()) = %v, want %v", got, want) } if got, want := srcTracker.push, int64(0); got != want { t.Errorf("count(src.Push()) = %v, want %v", got, want) } if got, want := srcTracker.exists, int64(0); got != want { t.Errorf("count(src.Exists()) = %v, want %v", got, want) } if got, want := dstTracker.fetch, int64(0); got != want { t.Errorf("count(dst.Fetch()) = %v, want %v", got, want) } if got, want := dstTracker.push, int64(3); got != want { t.Errorf("count(dst.Push()) = %v, want %v", got, want) } if got, want := dstTracker.exists, int64(5); got != want { t.Errorf("count(dst.Exists()) = %v, want %v", got, want) } } func TestCopy_WithOptions(t *testing.T) { src := memory.New() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } appendManifest := func(arc, os string, mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), Platform: &ocispec.Platform{ Architecture: arc, OS: os, }, }) } generateManifest := func(arc, os string, config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendManifest(arc, os, ocispec.MediaTypeImageManifest, manifestJSON) } generateIndex := func(manifests ...ocispec.Descriptor) { index := ocispec.Index{ Manifests: manifests, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageIndex, indexJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 generateManifest("test-arc-1", "test-os-1", descs[0], descs[1:3]...) // Blob 3 appendBlob(ocispec.MediaTypeImageLayer, []byte("hello")) // Blob 4 generateManifest("test-arc-2", "test-os-2", descs[0], descs[4]) // Blob 5 generateIndex(descs[3], descs[5]) // Blob 6 ctx := context.Background() for i := range blobs { err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } root := descs[6] ref := "foobar" err := src.Tag(ctx, root, ref) if err != nil { t.Fatal("fail to tag root node", err) } // test copy with media type filter dst := memory.New() opts := oras.DefaultCopyOptions opts.MapRoot = func(ctx context.Context, src content.ReadOnlyStorage, root ocispec.Descriptor) (ocispec.Descriptor, error) { if root.MediaType == ocispec.MediaTypeImageIndex { return root, nil } else { return ocispec.Descriptor{}, errdef.ErrNotFound } } gotDesc, err := oras.Copy(ctx, src, ref, dst, "", opts) if err != nil { t.Fatalf("Copy() error = %v, wantErr %v", err, false) } if !reflect.DeepEqual(gotDesc, root) { t.Errorf("Copy() = %v, want %v", gotDesc, root) } // verify contents for i, desc := range descs { exists, err := dst.Exists(ctx, desc) if err != nil { t.Fatalf("dst.Exists(%d) error = %v", i, err) } if !exists { t.Errorf("dst.Exists(%d) = %v, want %v", i, exists, true) } } // verify tag gotDesc, err = dst.Resolve(ctx, ref) if err != nil { t.Fatal("dst.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, root) { t.Errorf("dst.Resolve() = %v, want %v", gotDesc, root) } // test copy with platform filter and hooks dst = memory.New() preCopyCount := int64(0) postCopyCount := int64(0) opts = oras.CopyOptions{ MapRoot: func(ctx context.Context, src content.ReadOnlyStorage, root ocispec.Descriptor) (ocispec.Descriptor, error) { manifests, err := content.Successors(ctx, src, root) if err != nil { return ocispec.Descriptor{}, errdef.ErrNotFound } // platform filter for _, m := range manifests { if m.Platform.Architecture == "test-arc-2" && m.Platform.OS == "test-os-2" { return m, nil } } return ocispec.Descriptor{}, errdef.ErrNotFound }, CopyGraphOptions: oras.CopyGraphOptions{ PreCopy: func(ctx context.Context, desc ocispec.Descriptor) error { atomic.AddInt64(&preCopyCount, 1) return nil }, PostCopy: func(ctx context.Context, desc ocispec.Descriptor) error { atomic.AddInt64(&postCopyCount, 1) return nil }, }, } wantDesc := descs[5] gotDesc, err = oras.Copy(ctx, src, ref, dst, "", opts) if err != nil { t.Fatalf("Copy() error = %v, wantErr %v", err, false) } if !reflect.DeepEqual(gotDesc, wantDesc) { t.Errorf("Copy() = %v, want %v", gotDesc, wantDesc) } // verify contents for i, desc := range append([]ocispec.Descriptor{descs[0]}, descs[4:6]...) { exists, err := dst.Exists(ctx, desc) if err != nil { t.Fatalf("dst.Exists(%d) error = %v", i, err) } if !exists { t.Errorf("dst.Exists(%d) = %v, want %v", i, exists, true) } } // verify tag gotDesc, err = dst.Resolve(ctx, ref) if err != nil { t.Fatal("dst.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, wantDesc) { t.Errorf("dst.Resolve() = %v, want %v", gotDesc, wantDesc) } // verify API counts if got, want := preCopyCount, int64(3); got != want { t.Errorf("count(PreCopy()) = %v, want %v", got, want) } if got, want := postCopyCount, int64(3); got != want { t.Errorf("count(PostCopy()) = %v, want %v", got, want) } // test copy with root filter, but no matching node can be found dst = memory.New() opts = oras.CopyOptions{ MapRoot: func(ctx context.Context, src content.ReadOnlyStorage, root ocispec.Descriptor) (ocispec.Descriptor, error) { if root.MediaType == "test" { return root, nil } else { return ocispec.Descriptor{}, errdef.ErrNotFound } }, CopyGraphOptions: oras.DefaultCopyGraphOptions, } _, err = oras.Copy(ctx, src, ref, dst, "", opts) if !errors.Is(err, errdef.ErrNotFound) { t.Fatalf("Copy() error = %v, wantErr %v", err, errdef.ErrNotFound) } // test copy with MaxMetadataBytes = 1 dst = memory.New() opts = oras.CopyOptions{ CopyGraphOptions: oras.CopyGraphOptions{ MaxMetadataBytes: 1, }, } if _, err := oras.Copy(ctx, src, ref, dst, "", opts); !errors.Is(err, errdef.ErrSizeExceedsLimit) { t.Fatalf("CopyGraph() error = %v, wantErr %v", err, errdef.ErrSizeExceedsLimit) } } func TestCopy_WithTargetPlatformOptions(t *testing.T) { src := memory.New() arc_1 := "test-arc-1" os_1 := "test-os-1" variant_1 := "v1" arc_2 := "test-arc-2" os_2 := "test-os-2" variant_2 := "v2" // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } appendManifest := func(arc, os, variant string, mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), Platform: &ocispec.Platform{ Architecture: arc, OS: os, Variant: variant, }, }) } generateManifest := func(arc, os, variant string, config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendManifest(arc, os, variant, ocispec.MediaTypeImageManifest, manifestJSON) } generateIndex := func(manifests ...ocispec.Descriptor) { index := ocispec.Index{ Manifests: manifests, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageIndex, indexJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte(`{"mediaType":"application/vnd.oci.image.config.v1+json", "created":"2022-07-29T08:13:55Z", "author":"test author", "architecture":"test-arc-1", "os":"test-os-1", "variant":"v1"}`)) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 generateManifest(arc_1, os_1, variant_1, descs[0], descs[1:3]...) // Blob 3 appendBlob(ocispec.MediaTypeImageLayer, []byte("hello1")) // Blob 4 generateManifest(arc_2, os_2, variant_1, descs[0], descs[4]) // Blob 5 appendBlob(ocispec.MediaTypeImageLayer, []byte("hello2")) // Blob 6 generateManifest(arc_1, os_1, variant_2, descs[0], descs[6]) // Blob 7 generateIndex(descs[3], descs[5], descs[7]) // Blob 8 ctx := context.Background() for i := range blobs { err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } root := descs[8] ref := "foobar" err := src.Tag(ctx, root, ref) if err != nil { t.Fatal("fail to tag root node", err) } // test copy with platform filter for the image index dst := memory.New() opts := oras.CopyOptions{} targetPlatform := ocispec.Platform{ Architecture: arc_2, OS: os_2, } opts.WithTargetPlatform(&targetPlatform) wantDesc := descs[5] gotDesc, err := oras.Copy(ctx, src, ref, dst, "", opts) if err != nil { t.Fatalf("Copy() error = %v, wantErr %v", err, false) } if !reflect.DeepEqual(gotDesc, wantDesc) { t.Errorf("Copy() = %v, want %v", gotDesc, wantDesc) } // verify contents for i, desc := range append([]ocispec.Descriptor{descs[0]}, descs[4:6]...) { exists, err := dst.Exists(ctx, desc) if err != nil { t.Fatalf("dst.Exists(%d) error = %v", i, err) } if !exists { t.Errorf("dst.Exists(%d) = %v, want %v", i, exists, true) } } // verify tag gotDesc, err = dst.Resolve(ctx, ref) if err != nil { t.Fatal("dst.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, wantDesc) { t.Errorf("dst.Resolve() = %v, want %v", gotDesc, wantDesc) } // test copy with platform filter for the image index, and multiple // manifests match the required platform. Should return the first matching // entry. dst = memory.New() targetPlatform = ocispec.Platform{ Architecture: arc_1, OS: os_1, } opts = oras.CopyOptions{} opts.WithTargetPlatform(&targetPlatform) wantDesc = descs[3] gotDesc, err = oras.Copy(ctx, src, ref, dst, "", opts) if err != nil { t.Fatalf("Copy() error = %v, wantErr %v", err, false) } if !reflect.DeepEqual(gotDesc, wantDesc) { t.Errorf("Copy() = %v, want %v", gotDesc, wantDesc) } // verify contents for i, desc := range append([]ocispec.Descriptor{descs[0]}, descs[1:3]...) { exists, err := dst.Exists(ctx, desc) if err != nil { t.Fatalf("dst.Exists(%d) error = %v", i, err) } if !exists { t.Errorf("dst.Exists(%d) = %v, want %v", i, exists, true) } } // verify tag gotDesc, err = dst.Resolve(ctx, ref) if err != nil { t.Fatal("dst.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, wantDesc) { t.Errorf("dst.Resolve() = %v, want %v", gotDesc, wantDesc) } // test copy with platform filter and existing MapRoot func for the image // index, but there is no matching node. Should return not found error. dst = memory.New() opts = oras.CopyOptions{ MapRoot: func(ctx context.Context, src content.ReadOnlyStorage, root ocispec.Descriptor) (ocispec.Descriptor, error) { if root.MediaType == ocispec.MediaTypeImageIndex { return root, nil } else { return ocispec.Descriptor{}, errdef.ErrNotFound } }, } targetPlatform = ocispec.Platform{ Architecture: arc_1, OS: os_2, } opts.WithTargetPlatform(&targetPlatform) _, err = oras.Copy(ctx, src, ref, dst, "", opts) expected := fmt.Sprintf("%s: %v: no matching manifest was found in the manifest list", root.Digest, errdef.ErrNotFound) if err.Error() != expected { t.Fatalf("Copy() error = %v, wantErr %v", err, expected) } // test copy with platform filter for the manifest dst = memory.New() opts = oras.CopyOptions{} targetPlatform = ocispec.Platform{ Architecture: arc_1, OS: os_1, } opts.WithTargetPlatform(&targetPlatform) root = descs[7] err = src.Tag(ctx, root, ref) if err != nil { t.Fatal("fail to tag root node", err) } wantDesc = descs[7] gotDesc, err = oras.Copy(ctx, src, ref, dst, "", opts) if err != nil { t.Fatalf("Copy() error = %v, wantErr %v", err, false) } if !reflect.DeepEqual(gotDesc, wantDesc) { t.Errorf("Copy() = %v, want %v", gotDesc, wantDesc) } // verify contents for i, desc := range append([]ocispec.Descriptor{descs[0]}, descs[6]) { exists, err := dst.Exists(ctx, desc) if err != nil { t.Fatalf("dst.Exists(%d) error = %v", i, err) } if !exists { t.Errorf("dst.Exists(%d) = %v, want %v", i, exists, true) } } // verify tag gotDesc, err = dst.Resolve(ctx, ref) if err != nil { t.Fatal("dst.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, wantDesc) { t.Errorf("dst.Resolve() = %v, want %v", gotDesc, wantDesc) } // test copy with platform filter for the manifest, but there is no matching // node. Should return not found error. dst = memory.New() opts = oras.CopyOptions{} targetPlatform = ocispec.Platform{ Architecture: arc_1, OS: os_1, Variant: variant_2, } opts.WithTargetPlatform(&targetPlatform) _, err = oras.Copy(ctx, src, ref, dst, "", opts) expected = fmt.Sprintf("%s: %v: platform in manifest does not match target platform", root.Digest, errdef.ErrNotFound) if err.Error() != expected { t.Fatalf("Copy() error = %v, wantErr %v", err, expected) } // test copy with platform filter, but the node's media type is not // supported. Should return unsupported error dst = memory.New() opts = oras.CopyOptions{} targetPlatform = ocispec.Platform{ Architecture: arc_1, OS: os_1, } opts.WithTargetPlatform(&targetPlatform) root = descs[1] err = src.Tag(ctx, root, ref) if err != nil { t.Fatal("fail to tag root node", err) } _, err = oras.Copy(ctx, src, ref, dst, "", opts) if !errors.Is(err, errdef.ErrUnsupported) { t.Fatalf("Copy() error = %v, wantErr %v", err, errdef.ErrUnsupported) } // generate incorrect test content blobs = nil descs = nil appendBlob(docker.MediaTypeConfig, []byte(`{"mediaType":"application/vnd.oci.image.config.v1+json", "created":"2022-07-29T08:13:55Z", "author":"test author 1", "architecture":"test-arc-1", "os":"test-os-1", "variant":"v1"}`)) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo1")) // Blob 1 generateManifest(arc_1, os_1, variant_1, descs[0], descs[1]) // Blob 2 generateIndex(descs[2]) // Blob 3 ctx = context.Background() for i := range blobs { err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } dst = memory.New() opts = oras.CopyOptions{} targetPlatform = ocispec.Platform{ Architecture: arc_1, OS: os_1, } opts.WithTargetPlatform(&targetPlatform) // test copy with platform filter for the manifest, but the manifest is // invalid by having docker mediaType config in the manifest and oci // mediaType in the image config. Should return error. root = descs[2] err = src.Tag(ctx, root, ref) if err != nil { t.Fatal("fail to tag root node", err) } _, err = oras.Copy(ctx, src, ref, dst, "", opts) expected = fmt.Sprintf("fail to recognize platform from unknown config %s: expect %s", docker.MediaTypeConfig, ocispec.MediaTypeImageConfig) if err.Error() != expected { t.Fatalf("Copy() error = %v, wantErr %v", err, expected) } // generate test content with null config blob blobs = nil descs = nil appendBlob(ocispec.MediaTypeImageConfig, []byte("null")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo2")) // Blob 1 generateManifest(arc_1, os_1, variant_1, descs[0], descs[1]) // Blob 2 generateIndex(descs[2]) // Blob 3 ctx = context.Background() for i := range blobs { err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } dst = memory.New() opts = oras.CopyOptions{} targetPlatform = ocispec.Platform{ Architecture: arc_1, OS: os_1, } opts.WithTargetPlatform(&targetPlatform) // test copy with platform filter for the manifest with null config blob // should return not found error root = descs[2] err = src.Tag(ctx, root, ref) if err != nil { t.Fatal("fail to tag root node", err) } _, err = oras.Copy(ctx, src, ref, dst, "", opts) expected = fmt.Sprintf("%s: %v: platform in manifest does not match target platform", root.Digest, errdef.ErrNotFound) if err.Error() != expected { t.Fatalf("Copy() error = %v, wantErr %v", err, expected) } // generate test content with empty config blob blobs = nil descs = nil appendBlob(ocispec.MediaTypeImageConfig, []byte("")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo3")) // Blob 1 generateManifest(arc_1, os_1, variant_1, descs[0], descs[1]) // Blob 2 generateIndex(descs[2]) // Blob 3 ctx = context.Background() for i := range blobs { err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } dst = memory.New() opts = oras.CopyOptions{} targetPlatform = ocispec.Platform{ Architecture: arc_1, OS: os_1, } opts.WithTargetPlatform(&targetPlatform) // test copy with platform filter for the manifest with empty config blob // should return not found error root = descs[2] err = src.Tag(ctx, root, ref) if err != nil { t.Fatal("fail to tag root node", err) } _, err = oras.Copy(ctx, src, ref, dst, "", opts) expected = fmt.Sprintf("%s: %v: platform in manifest does not match target platform", root.Digest, errdef.ErrNotFound) if err.Error() != expected { t.Fatalf("Copy() error = %v, wantErr %v", err, expected) } // test copy with no platform filter and nil opts.MapRoot // opts.MapRoot should be nil opts = oras.CopyOptions{} opts.WithTargetPlatform(nil) if opts.MapRoot != nil { t.Fatal("opts.MapRoot not equal to nil when platform is not provided") } // test copy with no platform filter and custom opts.MapRoot // should return ErrNotFound opts = oras.CopyOptions{ MapRoot: func(ctx context.Context, src content.ReadOnlyStorage, root ocispec.Descriptor) (ocispec.Descriptor, error) { if root.MediaType == "test" { return root, nil } else { return ocispec.Descriptor{}, errdef.ErrNotFound } }, CopyGraphOptions: oras.DefaultCopyGraphOptions, } opts.WithTargetPlatform(nil) _, err = oras.Copy(ctx, src, ref, dst, "", opts) if !errors.Is(err, errdef.ErrNotFound) { t.Fatalf("Copy() error = %v, wantErr %v", err, errdef.ErrNotFound) } } func TestCopy_RestoreDuplicates(t *testing.T) { src := memory.New() temp := t.TempDir() dst, err := file.New(temp) if err != nil { t.Fatal("file.New() error =", err) } defer dst.Close() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte, title string) { blobs = append(blobs, blob) desc := ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), } if title != "" { desc.Annotations = map[string]string{ ocispec.AnnotationTitle: title, } } descs = append(descs, desc) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON, "") } appendBlob(ocispec.MediaTypeImageConfig, []byte("{}"), "") // Blob 0 // 2 blobs with same content appendBlob(ocispec.MediaTypeImageLayer, []byte("hello"), "foo.txt") // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("hello"), "bar.txt") // Blob 2 generateManifest(descs[0], descs[1:3]...) // Blob 3 ctx := context.Background() for i := range blobs { exists, err := src.Exists(ctx, descs[i]) if err != nil { t.Fatalf("failed to check existence in src: %d: %v", i, err) } if exists { continue } if err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])); err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } root := descs[3] ref := "latest" err = src.Tag(ctx, root, ref) if err != nil { t.Fatal("fail to tag root node", err) } // test copy gotDesc, err := oras.Copy(ctx, src, ref, dst, "", oras.CopyOptions{}) if err != nil { t.Fatalf("Copy() error = %v, wantErr %v", err, false) } if !reflect.DeepEqual(gotDesc, root) { t.Errorf("Copy() = %v, want %v", gotDesc, root) } // verify contents for i, desc := range descs { exists, err := dst.Exists(ctx, desc) if err != nil { t.Fatalf("dst.Exists(%d) error = %v", i, err) } if !exists { t.Errorf("dst.Exists(%d) = %v, want %v", i, exists, true) } } // verify tag gotDesc, err = dst.Resolve(ctx, ref) if err != nil { t.Fatal("dst.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, root) { t.Errorf("dst.Resolve() = %v, want %v", gotDesc, root) } } func TestCopy_DiscardDuplicates(t *testing.T) { src := memory.New() temp := t.TempDir() dst, err := file.New(temp) if err != nil { t.Fatal("file.New() error =", err) } dst.ForceCAS = true defer dst.Close() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte, title string) { blobs = append(blobs, blob) desc := ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), } if title != "" { desc.Annotations = map[string]string{ ocispec.AnnotationTitle: title, } } descs = append(descs, desc) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON, "") } appendBlob(ocispec.MediaTypeImageConfig, []byte("{}"), "") // Blob 0 // 2 blobs with same content appendBlob(ocispec.MediaTypeImageLayer, []byte("hello"), "foo.txt") // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("hello"), "bar.txt") // Blob 2 generateManifest(descs[0], descs[1:3]...) // Blob 3 ctx := context.Background() for i := range blobs { exists, err := src.Exists(ctx, descs[i]) if err != nil { t.Fatalf("failed to check existence in src: %d: %v", i, err) } if exists { continue } if err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])); err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } root := descs[3] ref := "latest" err = src.Tag(ctx, root, ref) if err != nil { t.Fatal("fail to tag root node", err) } // test copy gotDesc, err := oras.Copy(ctx, src, ref, dst, "", oras.CopyOptions{}) if err != nil { t.Fatalf("Copy() error = %v, wantErr %v", err, false) } if !reflect.DeepEqual(gotDesc, root) { t.Errorf("Copy() = %v, want %v", gotDesc, root) } // verify only one of foo.txt and bar.txt exists fooExists, err := dst.Exists(ctx, descs[1]) if err != nil { t.Fatalf("dst.Exists(foo) error = %v", err) } barExists, err := dst.Exists(ctx, descs[2]) if err != nil { t.Fatalf("dst.Exists(bar) error = %v", err) } if fooExists == barExists { t.Error("Only one of foo.txt and bar.txt should exist") } } func TestCopyGraph_WithOptions(t *testing.T) { src := cas.NewMemory() dst := cas.NewMemory() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } generateIndex := func(manifests ...ocispec.Descriptor) { index := ocispec.Index{ Manifests: manifests, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageIndex, indexJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 generateManifest(descs[0], descs[1:3]...) // Blob 3 appendBlob(ocispec.MediaTypeImageLayer, []byte("hello")) // Blob 4 generateManifest(descs[0], descs[4]) // Blob 5 generateIndex(descs[3], descs[5]) // Blob 6 ctx := context.Background() for i := range blobs { err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } // initial copy root := descs[3] opts := oras.DefaultCopyGraphOptions opts.FindSuccessors = func(ctx context.Context, fetcher content.Fetcher, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) { successors, err := content.Successors(ctx, fetcher, desc) if err != nil { return nil, err } // filter media type var filtered []ocispec.Descriptor for _, s := range successors { if s.MediaType != ocispec.MediaTypeImageConfig { filtered = append(filtered, s) } } return filtered, nil } if err := oras.CopyGraph(ctx, src, dst, root, opts); err != nil { t.Fatalf("CopyGraph() error = %v, wantErr %v", err, false) } // verify contents contents := dst.Map() if got, want := len(contents), len(blobs[1:4]); got != want { t.Fatalf("len(dst) = %v, wantErr %v", got, want) } for i := 1; i < 4; i++ { got, err := content.FetchAll(ctx, dst, descs[i]) if err != nil { t.Fatalf("content[%d] error = %v, wantErr %v", i, err, false) } if want := blobs[i]; !bytes.Equal(got, want) { t.Fatalf("content[%d] = %v, want %v", i, got, want) } } // test successor descriptors not obtained from src root = descs[3] opts = oras.DefaultCopyGraphOptions opts.FindSuccessors = func(ctx context.Context, fetcher content.Fetcher, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) { if content.Equal(desc, root) { return descs[1:3], nil } return content.Successors(ctx, fetcher, desc) } if err := oras.CopyGraph(ctx, src, cas.NewMemory(), root, opts); err != nil { t.Fatalf("CopyGraph() error = %v, wantErr %v", err, false) } // test partial copy var preCopyCount int64 var postCopyCount int64 var skippedCount int64 opts = oras.CopyGraphOptions{ PreCopy: func(ctx context.Context, desc ocispec.Descriptor) error { atomic.AddInt64(&preCopyCount, 1) return nil }, PostCopy: func(ctx context.Context, desc ocispec.Descriptor) error { atomic.AddInt64(&postCopyCount, 1) return nil }, OnCopySkipped: func(ctx context.Context, desc ocispec.Descriptor) error { atomic.AddInt64(&skippedCount, 1) return nil }, } root = descs[len(descs)-1] if err := oras.CopyGraph(ctx, src, dst, root, opts); err != nil { t.Fatalf("CopyGraph() error = %v, wantErr %v", err, false) } // verify contents contents = dst.Map() if got, want := len(contents), len(blobs); got != want { t.Errorf("len(dst) = %v, wantErr %v", got, want) } for i := range blobs { got, err := content.FetchAll(ctx, dst, descs[i]) if err != nil { t.Errorf("content[%d] error = %v, wantErr %v", i, err, false) continue } if want := blobs[i]; !bytes.Equal(got, want) { t.Errorf("content[%d] = %v, want %v", i, got, want) } } // verify API counts if got, want := preCopyCount, int64(4); got != want { t.Errorf("count(PreCopy()) = %v, want %v", got, want) } if got, want := postCopyCount, int64(4); got != want { t.Errorf("count(PostCopy()) = %v, want %v", got, want) } if got, want := skippedCount, int64(1); got != want { t.Errorf("count(OnCopySkipped()) = %v, want %v", got, want) } // test CopyGraph with MaxMetadataBytes = 1 root = descs[6] dst = cas.NewMemory() opts = oras.CopyGraphOptions{ MaxMetadataBytes: 1, } if err := oras.CopyGraph(ctx, src, dst, root, opts); !errors.Is(err, errdef.ErrSizeExceedsLimit) { t.Fatalf("CopyGraph() error = %v, wantErr %v", err, errdef.ErrSizeExceedsLimit) } t.Run("SkipNode", func(t *testing.T) { // test CopyGraph with PreCopy = 1 root = descs[6] dst := &countingStorage{storage: cas.NewMemory()} opts = oras.CopyGraphOptions{ PreCopy: func(ctx context.Context, desc ocispec.Descriptor) error { if descs[1].Digest == desc.Digest { // blob 1 is handled by us (really this would be a Mount but ) rc, err := src.Fetch(ctx, desc) if err != nil { t.Fatalf("Failed to fetch: %v", err) } defer rc.Close() err = dst.storage.Push(ctx, desc, rc) // bypass the counters if err != nil { t.Fatalf("Failed to fetch: %v", err) } return oras.SkipNode } return nil }, } if err := oras.CopyGraph(ctx, src, dst, root, opts); err != nil { t.Fatalf("CopyGraph() error = %v", err) } if got, expected := dst.numExists.Load(), int64(7); got != expected { t.Errorf("count(Exists()) = %d, want %d", got, expected) } if got, expected := dst.numFetch.Load(), int64(0); got != expected { t.Errorf("count(Fetch()) = %d, want %d", got, expected) } // 7 (exists) - 1 (skipped) = 6 pushes expected if got, expected := dst.numPush.Load(), int64(6); got != expected { // If we get >=7 then SkipNode did not short circuit the push like it is supposed to do. t.Errorf("count(Push()) = %d, want %d", got, expected) } }) t.Run("MountFrom mounted", func(t *testing.T) { root = descs[6] dst := &countingStorage{storage: cas.NewMemory()} var numMount atomic.Int64 dst.mount = func(ctx context.Context, desc ocispec.Descriptor, fromRepo string, getContent func() (io.ReadCloser, error), ) error { numMount.Add(1) if expected := "source"; fromRepo != expected { t.Fatalf("fromRepo = %v, want %v", fromRepo, expected) } rc, err := src.Fetch(ctx, desc) if err != nil { t.Fatalf("Failed to fetch content: %v", err) } defer rc.Close() err = dst.storage.Push(ctx, desc, rc) // bypass the counters if err != nil { t.Fatalf("Failed to push content: %v", err) } return nil } opts = oras.CopyGraphOptions{} var numPreCopy, numPostCopy, numOnMounted, numMountFrom atomic.Int64 opts.PreCopy = func(ctx context.Context, desc ocispec.Descriptor) error { numPreCopy.Add(1) return nil } opts.PostCopy = func(ctx context.Context, desc ocispec.Descriptor) error { numPostCopy.Add(1) return nil } opts.OnMounted = func(ctx context.Context, d ocispec.Descriptor) error { numOnMounted.Add(1) return nil } opts.MountFrom = func(ctx context.Context, desc ocispec.Descriptor) ([]string, error) { numMountFrom.Add(1) return []string{"source"}, nil } if err := oras.CopyGraph(ctx, src, dst, root, opts); err != nil { t.Fatalf("CopyGraph() error = %v", err) } if got, expected := dst.numExists.Load(), int64(7); got != expected { t.Errorf("count(Exists()) = %d, want %d", got, expected) } if got, expected := dst.numFetch.Load(), int64(0); got != expected { t.Errorf("count(Fetch()) = %d, want %d", got, expected) } // 7 (exists) - 1 (skipped) = 6 pushes expected if got, expected := dst.numPush.Load(), int64(3); got != expected { // If we get >=7 then ErrSkipDesc did not short circuit the push like it is supposed to do. t.Errorf("count(Push()) = %d, want %d", got, expected) } if got, expected := numMount.Load(), int64(4); got != expected { t.Errorf("count(Mount()) = %d, want %d", got, expected) } if got, expected := numOnMounted.Load(), int64(4); got != expected { t.Errorf("count(OnMounted()) = %d, want %d", got, expected) } if got, expected := numMountFrom.Load(), int64(4); got != expected { t.Errorf("count(MountFrom()) = %d, want %d", got, expected) } if got, expected := numPreCopy.Load(), int64(3); got != expected { t.Errorf("count(PreCopy()) = %d, want %d", got, expected) } if got, expected := numPostCopy.Load(), int64(3); got != expected { t.Errorf("count(PostCopy()) = %d, want %d", got, expected) } }) t.Run("MountFrom copied", func(t *testing.T) { root = descs[6] dst := &countingStorage{storage: cas.NewMemory()} var numMount atomic.Int64 dst.mount = func(ctx context.Context, desc ocispec.Descriptor, fromRepo string, getContent func() (io.ReadCloser, error), ) error { numMount.Add(1) if expected := "source"; fromRepo != expected { t.Fatalf("fromRepo = %v, want %v", fromRepo, expected) } rc, err := getContent() if err != nil { t.Fatalf("Failed to fetch content: %v", err) } defer rc.Close() err = dst.storage.Push(ctx, desc, rc) // bypass the counters if err != nil { t.Fatalf("Failed to push content: %v", err) } return nil } opts = oras.CopyGraphOptions{} var numPreCopy, numPostCopy, numOnMounted, numMountFrom atomic.Int64 opts.PreCopy = func(ctx context.Context, desc ocispec.Descriptor) error { numPreCopy.Add(1) return nil } opts.PostCopy = func(ctx context.Context, desc ocispec.Descriptor) error { numPostCopy.Add(1) return nil } opts.OnMounted = func(ctx context.Context, d ocispec.Descriptor) error { numOnMounted.Add(1) return nil } opts.MountFrom = func(ctx context.Context, desc ocispec.Descriptor) ([]string, error) { numMountFrom.Add(1) return []string{"source"}, nil } if err := oras.CopyGraph(ctx, src, dst, root, opts); err != nil { t.Fatalf("CopyGraph() error = %v", err) } if got, expected := dst.numExists.Load(), int64(7); got != expected { t.Errorf("count(Exists()) = %d, want %d", got, expected) } if got, expected := dst.numFetch.Load(), int64(0); got != expected { t.Errorf("count(Fetch()) = %d, want %d", got, expected) } // 7 (exists) - 1 (skipped) = 6 pushes expected if got, expected := dst.numPush.Load(), int64(3); got != expected { // If we get >=7 then ErrSkipDesc did not short circuit the push like it is supposed to do. t.Errorf("count(Push()) = %d, want %d", got, expected) } if got, expected := numMount.Load(), int64(4); got != expected { t.Errorf("count(Mount()) = %d, want %d", got, expected) } if got, expected := numOnMounted.Load(), int64(0); got != expected { t.Errorf("count(OnMounted()) = %d, want %d", got, expected) } if got, expected := numMountFrom.Load(), int64(4); got != expected { t.Errorf("count(MountFrom()) = %d, want %d", got, expected) } if got, expected := numPreCopy.Load(), int64(7); got != expected { t.Errorf("count(PreCopy()) = %d, want %d", got, expected) } if got, expected := numPostCopy.Load(), int64(7); got != expected { t.Errorf("count(PostCopy()) = %d, want %d", got, expected) } }) t.Run("MountFrom mounted second try", func(t *testing.T) { root = descs[6] dst := &countingStorage{storage: cas.NewMemory()} var numMount atomic.Int64 dst.mount = func(ctx context.Context, desc ocispec.Descriptor, fromRepo string, getContent func() (io.ReadCloser, error), ) error { numMount.Add(1) switch fromRepo { case "source": rc, err := src.Fetch(ctx, desc) if err != nil { t.Fatalf("Failed to fetch content: %v", err) } defer rc.Close() err = dst.storage.Push(ctx, desc, rc) // bypass the counters if err != nil { t.Fatalf("Failed to push content: %v", err) } return nil case "missing/the/data": // simulate a registry mount will fail, so it will request the content to start the copy. rc, err := getContent() if err != nil { return fmt.Errorf("getContent failed: %w", err) } defer rc.Close() err = dst.storage.Push(ctx, desc, rc) // bypass the counters if err != nil { t.Fatalf("Failed to push content: %v", err) } return nil default: t.Fatalf("fromRepo = %v, want either %v or %v", fromRepo, "missing/the/data", "source") return nil } } opts = oras.CopyGraphOptions{} var numPreCopy, numPostCopy, numOnMounted, numMountFrom atomic.Int64 opts.PreCopy = func(ctx context.Context, desc ocispec.Descriptor) error { numPreCopy.Add(1) return nil } opts.PostCopy = func(ctx context.Context, desc ocispec.Descriptor) error { numPostCopy.Add(1) return nil } opts.OnMounted = func(ctx context.Context, d ocispec.Descriptor) error { numOnMounted.Add(1) return nil } opts.MountFrom = func(ctx context.Context, desc ocispec.Descriptor) ([]string, error) { numMountFrom.Add(1) return []string{"missing/the/data", "source"}, nil } if err := oras.CopyGraph(ctx, src, dst, root, opts); err != nil { t.Fatalf("CopyGraph() error = %v", err) } if got, expected := dst.numExists.Load(), int64(7); got != expected { t.Errorf("count(Exists()) = %d, want %d", got, expected) } if got, expected := dst.numFetch.Load(), int64(0); got != expected { t.Errorf("count(Fetch()) = %d, want %d", got, expected) } // 7 (exists) - 1 (skipped) = 6 pushes expected if got, expected := dst.numPush.Load(), int64(3); got != expected { // If we get >=7 then ErrSkipDesc did not short circuit the push like it is supposed to do. t.Errorf("count(Push()) = %d, want %d", got, expected) } if got, expected := numMount.Load(), int64(4*2); got != expected { t.Errorf("count(Mount()) = %d, want %d", got, expected) } if got, expected := numOnMounted.Load(), int64(4); got != expected { t.Errorf("count(OnMounted()) = %d, want %d", got, expected) } if got, expected := numMountFrom.Load(), int64(4); got != expected { t.Errorf("count(MountFrom()) = %d, want %d", got, expected) } if got, expected := numPreCopy.Load(), int64(3); got != expected { t.Errorf("count(PreCopy()) = %d, want %d", got, expected) } if got, expected := numPostCopy.Load(), int64(3); got != expected { t.Errorf("count(PostCopy()) = %d, want %d", got, expected) } }) t.Run("MountFrom copied dst not a Mounter", func(t *testing.T) { root = descs[6] dst := cas.NewMemory() opts = oras.CopyGraphOptions{} var numPreCopy, numPostCopy, numOnMounted, numMountFrom atomic.Int64 opts.PreCopy = func(ctx context.Context, desc ocispec.Descriptor) error { numPreCopy.Add(1) return nil } opts.PostCopy = func(ctx context.Context, desc ocispec.Descriptor) error { numPostCopy.Add(1) return nil } opts.OnMounted = func(ctx context.Context, d ocispec.Descriptor) error { numOnMounted.Add(1) return nil } opts.MountFrom = func(ctx context.Context, desc ocispec.Descriptor) ([]string, error) { numMountFrom.Add(1) return []string{"source"}, nil } if err := oras.CopyGraph(ctx, src, dst, root, opts); err != nil { t.Fatalf("CopyGraph() error = %v", err) } if got, expected := numOnMounted.Load(), int64(0); got != expected { t.Errorf("count(OnMounted()) = %d, want %d", got, expected) } if got, expected := numMountFrom.Load(), int64(0); got != expected { t.Errorf("count(MountFrom()) = %d, want %d", got, expected) } if got, expected := numPreCopy.Load(), int64(7); got != expected { t.Errorf("count(PreCopy()) = %d, want %d", got, expected) } if got, expected := numPostCopy.Load(), int64(7); got != expected { t.Errorf("count(PostCopy()) = %d, want %d", got, expected) } }) t.Run("MountFrom empty sourceRepositories", func(t *testing.T) { root = descs[6] dst := &countingStorage{storage: cas.NewMemory()} opts = oras.CopyGraphOptions{} var numMountFrom atomic.Int64 opts.MountFrom = func(ctx context.Context, desc ocispec.Descriptor) ([]string, error) { numMountFrom.Add(1) return nil, nil } if err := oras.CopyGraph(ctx, src, dst, root, opts); err != nil { t.Fatalf("CopyGraph() error = %v", err) } if got, expected := dst.numExists.Load(), int64(7); got != expected { t.Errorf("count(Exists()) = %d, want %d", got, expected) } if got, expected := dst.numFetch.Load(), int64(0); got != expected { t.Errorf("count(Fetch()) = %d, want %d", got, expected) } if got, expected := dst.numPush.Load(), int64(7); got != expected { t.Errorf("count(Push()) = %d, want %d", got, expected) } if got, expected := numMountFrom.Load(), int64(4); got != expected { t.Errorf("count(MountFrom()) = %d, want %d", got, expected) } }) t.Run("MountFrom error", func(t *testing.T) { root = descs[6] dst := &countingStorage{storage: cas.NewMemory()} opts = oras.CopyGraphOptions{} var numMountFrom atomic.Int64 e := errors.New("mountFrom error") opts.MountFrom = func(ctx context.Context, desc ocispec.Descriptor) ([]string, error) { numMountFrom.Add(1) return nil, e } if err := oras.CopyGraph(ctx, src, dst, root, opts); !errors.Is(err, e) { t.Fatalf("CopyGraph() error = %v, wantErr %v", err, e) } if got, expected := dst.numExists.Load(), int64(7); got != expected { t.Errorf("count(Exists()) = %d, want %d", got, expected) } if got, expected := dst.numFetch.Load(), int64(0); got != expected { t.Errorf("count(Fetch()) = %d, want %d", got, expected) } if got, expected := dst.numPush.Load(), int64(0); got != expected { t.Errorf("count(Push()) = %d, want %d", got, expected) } if got, expected := numMountFrom.Load(), int64(4); got != expected { t.Errorf("count(MountFrom()) = %d, want %d", got, expected) } }) t.Run("MountFrom OnMounted error", func(t *testing.T) { root = descs[6] dst := &countingStorage{storage: cas.NewMemory()} var numMount atomic.Int64 dst.mount = func(ctx context.Context, desc ocispec.Descriptor, fromRepo string, getContent func() (io.ReadCloser, error), ) error { numMount.Add(1) if expected := "source"; fromRepo != expected { t.Fatalf("fromRepo = %v, want %v", fromRepo, expected) } rc, err := src.Fetch(ctx, desc) if err != nil { t.Fatalf("Failed to fetch content: %v", err) } defer rc.Close() err = dst.storage.Push(ctx, desc, rc) // bypass the counters if err != nil { t.Fatalf("Failed to push content: %v", err) } return nil } opts = oras.CopyGraphOptions{} var numPreCopy, numPostCopy, numOnMounted, numMountFrom atomic.Int64 opts.PreCopy = func(ctx context.Context, desc ocispec.Descriptor) error { numPreCopy.Add(1) return nil } opts.PostCopy = func(ctx context.Context, desc ocispec.Descriptor) error { numPostCopy.Add(1) return nil } e := errors.New("onMounted error") opts.OnMounted = func(ctx context.Context, d ocispec.Descriptor) error { numOnMounted.Add(1) return e } opts.MountFrom = func(ctx context.Context, desc ocispec.Descriptor) ([]string, error) { numMountFrom.Add(1) return []string{"source"}, nil } if err := oras.CopyGraph(ctx, src, dst, root, opts); !errors.Is(err, e) { t.Fatalf("CopyGraph() error = %v, wantErr %v", err, e) } if got, expected := dst.numExists.Load(), int64(7); got != expected { t.Errorf("count(Exists()) = %d, want %d", got, expected) } if got, expected := dst.numFetch.Load(), int64(0); got != expected { t.Errorf("count(Fetch()) = %d, want %d", got, expected) } if got, expected := dst.numPush.Load(), int64(0); got != expected { t.Errorf("count(Push()) = %d, want %d", got, expected) } if got, expected := numMount.Load(), int64(4); got != expected { t.Errorf("count(Mount()) = %d, want %d", got, expected) } if got, expected := numOnMounted.Load(), int64(4); got != expected { t.Errorf("count(OnMounted()) = %d, want %d", got, expected) } if got, expected := numMountFrom.Load(), int64(4); got != expected { t.Errorf("count(MountFrom()) = %d, want %d", got, expected) } if got, expected := numPreCopy.Load(), int64(0); got != expected { t.Errorf("count(PreCopy()) = %d, want %d", got, expected) } if got, expected := numPostCopy.Load(), int64(0); got != expected { t.Errorf("count(PostCopy()) = %d, want %d", got, expected) } }) } // countingStorage counts the calls to its content.Storage methods type countingStorage struct { storage content.Storage mount mountFunc numExists, numFetch, numPush atomic.Int64 } func (cs *countingStorage) Exists(ctx context.Context, target ocispec.Descriptor) (bool, error) { cs.numExists.Add(1) return cs.storage.Exists(ctx, target) } func (cs *countingStorage) Fetch(ctx context.Context, target ocispec.Descriptor) (io.ReadCloser, error) { cs.numFetch.Add(1) return cs.storage.Fetch(ctx, target) } func (cs *countingStorage) Push(ctx context.Context, target ocispec.Descriptor, r io.Reader) error { cs.numPush.Add(1) return cs.storage.Push(ctx, target, r) } type mountFunc func(context.Context, ocispec.Descriptor, string, func() (io.ReadCloser, error)) error func (cs *countingStorage) Mount(ctx context.Context, desc ocispec.Descriptor, fromRepo string, getContent func() (io.ReadCloser, error), ) error { return cs.mount(ctx, desc, fromRepo, getContent) } func TestCopyGraph_WithConcurrencyLimit(t *testing.T) { src := cas.NewMemory() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ MediaType: ocispec.MediaTypeImageManifest, Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(manifest.MediaType, manifestJSON) } generateArtifact := func(subject *ocispec.Descriptor, artifactType string, blobs ...ocispec.Descriptor) { manifest := spec.Artifact{ MediaType: spec.MediaTypeArtifactManifest, Subject: subject, Blobs: blobs, ArtifactType: artifactType, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(manifest.MediaType, manifestJSON) } generateIndex := func(manifests ...ocispec.Descriptor) { index := ocispec.Index{ MediaType: ocispec.MediaTypeImageIndex, Manifests: manifests, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatal(err) } appendBlob(index.MediaType, indexJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 generateManifest(descs[0], descs[1:3]...) // Blob 3 generateArtifact(&descs[3], "artifact.1") // Blob 4 generateArtifact(&descs[3], "artifact.2") // Blob 5 generateArtifact(&descs[3], "artifact.3") // Blob 6 generateArtifact(&descs[3], "artifact.4") // Blob 7 generateIndex(descs[3:8]...) // Blob 8 ctx := context.Background() for i := range blobs { err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } // test different concurrency limit root := descs[len(descs)-1] directSuccessorsNum := 5 opts := oras.DefaultCopyGraphOptions for i := 1; i <= directSuccessorsNum; i++ { dst := cas.NewMemory() opts.Concurrency = i if err := oras.CopyGraph(ctx, src, dst, root, opts); err != nil { t.Fatalf("CopyGraph(concurrency: %d) error = %v, wantErr %v", i, err, false) } // verify contents contents := dst.Map() if got, want := len(contents), len(blobs); got != want { t.Errorf("len(dst) = %v, wantErr %v", got, want) } for i := range blobs { got, err := content.FetchAll(ctx, dst, descs[i]) if err != nil { t.Errorf("content[%d] error = %v, wantErr %v", i, err, false) continue } if want := blobs[i]; !bytes.Equal(got, want) { t.Errorf("content[%d] = %v, want %v", i, got, want) } } } } func TestCopyGraph_ForeignLayers(t *testing.T) { src := cas.NewMemory() dst := cas.NewMemory() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { desc := ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), } if mediaType == ocispec.MediaTypeImageLayerNonDistributable { desc.URLs = append(desc.URLs, "http://127.0.0.1/dummy") blob = nil } descs = append(descs, desc) blobs = append(blobs, blob) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayerNonDistributable, []byte("hello")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 2 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 3 generateManifest(descs[0], descs[1:4]...) // Blob 4 ctx := context.Background() for i := range blobs { if blobs[i] == nil { continue } err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } // test copy srcTracker := &storageTracker{Storage: src} dstTracker := &storageTracker{Storage: dst} root := descs[len(descs)-1] if err := oras.CopyGraph(ctx, srcTracker, dstTracker, root, oras.CopyGraphOptions{}); err != nil { t.Fatalf("CopyGraph() error = %v, wantErr %v", err, false) } // verify contents contents := dst.Map() if got, want := len(contents), len(blobs)-1; got != want { t.Errorf("len(dst) = %v, wantErr %v", got, want) } for i := range blobs { if blobs[i] == nil { continue } got, err := content.FetchAll(ctx, dst, descs[i]) if err != nil { t.Errorf("content[%d] error = %v, wantErr %v", i, err, false) continue } if want := blobs[i]; !bytes.Equal(got, want) { t.Errorf("content[%d] = %v, want %v", i, got, want) } } // verify API counts if got, want := srcTracker.fetch, int64(len(blobs)-1); got != want { t.Errorf("count(src.Fetch()) = %v, want %v", got, want) } if got, want := srcTracker.push, int64(0); got != want { t.Errorf("count(src.Push()) = %v, want %v", got, want) } if got, want := srcTracker.exists, int64(0); got != want { t.Errorf("count(src.Exists()) = %v, want %v", got, want) } if got, want := dstTracker.fetch, int64(0); got != want { t.Errorf("count(dst.Fetch()) = %v, want %v", got, want) } if got, want := dstTracker.push, int64(len(blobs)-1); got != want { t.Errorf("count(dst.Push()) = %v, want %v", got, want) } if got, want := dstTracker.exists, int64(len(blobs)-1); got != want { t.Errorf("count(dst.Exists()) = %v, want %v", got, want) } } func TestCopyGraph_ForeignLayers_Mixed(t *testing.T) { src := cas.NewMemory() dst := cas.NewMemory() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { desc := ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), } if mediaType == ocispec.MediaTypeImageLayerNonDistributable { desc.URLs = append(desc.URLs, "http://127.0.0.1/dummy") blob = nil } descs = append(descs, desc) blobs = append(blobs, blob) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayerNonDistributable, []byte("hello")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("hello")) // Blob 2 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 3 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 4 generateManifest(descs[0], descs[1:5]...) // Blob 5 ctx := context.Background() for i := range blobs { if blobs[i] == nil { continue } err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } // test copy srcTracker := &storageTracker{Storage: src} dstTracker := &storageTracker{Storage: dst} root := descs[len(descs)-1] if err := oras.CopyGraph(ctx, srcTracker, dstTracker, root, oras.CopyGraphOptions{ Concurrency: 1, }); err != nil { t.Fatalf("CopyGraph() error = %v, wantErr %v", err, false) } // verify contents contents := dst.Map() if got, want := len(contents), len(blobs)-1; got != want { t.Errorf("len(dst) = %v, wantErr %v", got, want) } for i := range blobs { if blobs[i] == nil { continue } got, err := content.FetchAll(ctx, dst, descs[i]) if err != nil { t.Errorf("content[%d] error = %v, wantErr %v", i, err, false) continue } if want := blobs[i]; !bytes.Equal(got, want) { t.Errorf("content[%d] = %v, want %v", i, got, want) } } // verify API counts if got, want := srcTracker.fetch, int64(len(blobs)-1); got != want { t.Errorf("count(src.Fetch()) = %v, want %v", got, want) } if got, want := srcTracker.push, int64(0); got != want { t.Errorf("count(src.Push()) = %v, want %v", got, want) } if got, want := srcTracker.exists, int64(0); got != want { t.Errorf("count(src.Exists()) = %v, want %v", got, want) } if got, want := dstTracker.fetch, int64(0); got != want { t.Errorf("count(dst.Fetch()) = %v, want %v", got, want) } if got, want := dstTracker.push, int64(len(blobs)-1); got != want { t.Errorf("count(dst.Push()) = %v, want %v", got, want) } if got, want := dstTracker.exists, int64(len(blobs)-1); got != want { t.Errorf("count(dst.Exists()) = %v, want %v", got, want) } } oras-go-2.5.0/errdef/000077500000000000000000000000001457674530300143525ustar00rootroot00000000000000oras-go-2.5.0/errdef/errors.go000066400000000000000000000021431457674530300162150ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package errdef import "errors" // Common errors used in ORAS var ( ErrAlreadyExists = errors.New("already exists") ErrInvalidDigest = errors.New("invalid digest") ErrInvalidReference = errors.New("invalid reference") ErrInvalidMediaType = errors.New("invalid media type") ErrMissingReference = errors.New("missing reference") ErrNotFound = errors.New("not found") ErrSizeExceedsLimit = errors.New("size exceeds limit") ErrUnsupported = errors.New("unsupported") ErrUnsupportedVersion = errors.New("unsupported version") ) oras-go-2.5.0/example_copy_test.go000066400000000000000000000277771457674530300172020ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package oras_test import ( "bytes" "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "net/url" "os" "strconv" "strings" "testing" "github.com/opencontainers/go-digest" specs "github.com/opencontainers/image-spec/specs-go" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2" "oras.land/oras-go/v2/content/memory" "oras.land/oras-go/v2/content/oci" "oras.land/oras-go/v2/internal/spec" "oras.land/oras-go/v2/registry/remote" ) var exampleMemoryStore oras.Target var remoteHost string var ( exampleManifest, _ = json.Marshal(spec.Artifact{ MediaType: spec.MediaTypeArtifactManifest, ArtifactType: "example/content"}) exampleManifestDescriptor = ocispec.Descriptor{ MediaType: spec.MediaTypeArtifactManifest, Digest: digest.Digest(digest.FromBytes(exampleManifest)), Size: int64(len(exampleManifest))} exampleSignatureManifest, _ = json.Marshal(spec.Artifact{ MediaType: spec.MediaTypeArtifactManifest, ArtifactType: "example/signature", Subject: &exampleManifestDescriptor}) exampleSignatureManifestDescriptor = ocispec.Descriptor{ MediaType: spec.MediaTypeArtifactManifest, Digest: digest.FromBytes(exampleSignatureManifest), Size: int64(len(exampleSignatureManifest))} ) func pushBlob(ctx context.Context, mediaType string, blob []byte, target oras.Target) (desc ocispec.Descriptor, err error) { desc = ocispec.Descriptor{ // Generate descriptor based on the media type and blob content MediaType: mediaType, Digest: digest.FromBytes(blob), // Calculate digest Size: int64(len(blob)), // Include blob size } return desc, target.Push(ctx, desc, bytes.NewReader(blob)) // Push the blob to the registry target } func generateManifestContent(config ocispec.Descriptor, layers ...ocispec.Descriptor) ([]byte, error) { content := ocispec.Manifest{ Config: config, // Set config blob Layers: layers, // Set layer blobs Versioned: specs.Versioned{SchemaVersion: 2}, } return json.Marshal(content) // Get json content } func TestMain(m *testing.M) { const exampleTag = "latest" const exampleUploadUUid = "0bc84d80-837c-41d9-824e-1907463c53b3" // Setup example local target exampleMemoryStore = memory.New() layerBlob := []byte("Hello layer") ctx := context.Background() layerDesc, err := pushBlob(ctx, ocispec.MediaTypeImageLayer, layerBlob, exampleMemoryStore) // push layer blob if err != nil { panic(err) } configBlob := []byte("Hello config") configDesc, err := pushBlob(ctx, ocispec.MediaTypeImageConfig, configBlob, exampleMemoryStore) // push config blob if err != nil { panic(err) } manifestBlob, err := generateManifestContent(configDesc, layerDesc) // generate a image manifest if err != nil { panic(err) } manifestDesc, err := pushBlob(ctx, ocispec.MediaTypeImageManifest, manifestBlob, exampleMemoryStore) // push manifest blob if err != nil { panic(err) } err = exampleMemoryStore.Tag(ctx, manifestDesc, exampleTag) if err != nil { panic(err) } // Setup example remote target httpsServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { p := r.URL.Path m := r.Method switch { case strings.Contains(p, "/blobs/uploads/") && m == "POST": w.Header().Set("Content-Type", ocispec.MediaTypeImageManifest) w.Header().Set("Location", p+exampleUploadUUid) w.WriteHeader(http.StatusAccepted) case strings.Contains(p, "/blobs/uploads/"+exampleUploadUUid) && m == "GET": w.WriteHeader(http.StatusCreated) case strings.Contains(p, "/manifests/"+string(exampleSignatureManifestDescriptor.Digest)): w.Header().Set("Content-Type", spec.MediaTypeArtifactManifest) w.Header().Set("Docker-Content-Digest", string(exampleSignatureManifestDescriptor.Digest)) w.Header().Set("Content-Length", strconv.Itoa(len(exampleSignatureManifest))) w.Write(exampleSignatureManifest) case strings.Contains(p, "/manifests/latest") && m == "PUT": w.WriteHeader(http.StatusCreated) case strings.Contains(p, "/manifests/"+string(exampleManifestDescriptor.Digest)), strings.Contains(p, "/manifests/latest") && m == "HEAD": w.Header().Set("Content-Type", spec.MediaTypeArtifactManifest) w.Header().Set("Docker-Content-Digest", string(exampleManifestDescriptor.Digest)) w.Header().Set("Content-Length", strconv.Itoa(len(exampleManifest))) if m == "GET" { w.Write(exampleManifest) } case strings.Contains(p, "/v2/source/referrers/"): var referrers []ocispec.Descriptor if p == "/v2/source/referrers/"+exampleManifestDescriptor.Digest.String() { referrers = []ocispec.Descriptor{exampleSignatureManifestDescriptor} } result := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageIndex, Manifests: referrers, } w.Header().Set("Content-Type", ocispec.MediaTypeImageIndex) if err := json.NewEncoder(w).Encode(result); err != nil { panic(err) } case strings.Contains(p, "/manifests/") && (m == "HEAD" || m == "GET"): w.Header().Set("Content-Type", ocispec.MediaTypeImageManifest) w.Header().Set("Docker-Content-Digest", string(manifestDesc.Digest)) w.Header().Set("Content-Length", strconv.Itoa(len([]byte(manifestBlob)))) w.Write([]byte(manifestBlob)) case strings.Contains(p, "/blobs/") && (m == "GET" || m == "HEAD"): arr := strings.Split(p, "/") digest := arr[len(arr)-1] var desc ocispec.Descriptor var content []byte switch digest { case layerDesc.Digest.String(): desc = layerDesc content = layerBlob case configDesc.Digest.String(): desc = configDesc content = configBlob case manifestDesc.Digest.String(): desc = manifestDesc content = manifestBlob } w.Header().Set("Content-Type", desc.MediaType) w.Header().Set("Docker-Content-Digest", digest) w.Header().Set("Content-Length", strconv.Itoa(len([]byte(content)))) w.Write([]byte(content)) case strings.Contains(p, "/manifests/") && m == "PUT": w.WriteHeader(http.StatusCreated) } })) defer httpsServer.Close() u, err := url.Parse(httpsServer.URL) if err != nil { panic(err) } remoteHost = u.Host http.DefaultTransport = httpsServer.Client().Transport os.Exit(m.Run()) } func ExampleCopy_remoteToRemote() { reg, err := remote.NewRegistry(remoteHost) if err != nil { panic(err) // Handle error } ctx := context.Background() src, err := reg.Repository(ctx, "source") if err != nil { panic(err) // Handle error } dst, err := reg.Repository(ctx, "target") if err != nil { panic(err) // Handle error } tagName := "latest" desc, err := oras.Copy(ctx, src, tagName, dst, tagName, oras.DefaultCopyOptions) if err != nil { panic(err) // Handle error } fmt.Println(desc.Digest) // Output: // sha256:7cbb44b44e8ede5a89cf193db3f5f2fd019d89697e6b87e8ed2589e60649b0d1 } func ExampleCopy_remoteToRemoteWithMount() { reg, err := remote.NewRegistry(remoteHost) if err != nil { panic(err) // Handle error } ctx := context.Background() src, err := reg.Repository(ctx, "source") if err != nil { panic(err) // Handle error } dst, err := reg.Repository(ctx, "target") if err != nil { panic(err) // Handle error } tagName := "latest" opts := oras.CopyOptions{} // optionally be notified that a mount occurred. opts.OnMounted = func(ctx context.Context, desc ocispec.Descriptor) error { // log.Println("Mounted", desc.Digest) return nil } // Enable cross-repository blob mounting opts.MountFrom = func(ctx context.Context, desc ocispec.Descriptor) ([]string, error) { // the slice of source repositores may also come from a database of known locations of blobs return []string{"source/repository/name"}, nil } desc, err := oras.Copy(ctx, src, tagName, dst, tagName, opts) if err != nil { panic(err) // Handle error } fmt.Println("Final", desc.Digest) // Output: // Final sha256:7cbb44b44e8ede5a89cf193db3f5f2fd019d89697e6b87e8ed2589e60649b0d1 } func ExampleCopy_remoteToLocal() { reg, err := remote.NewRegistry(remoteHost) if err != nil { panic(err) // Handle error } ctx := context.Background() src, err := reg.Repository(ctx, "source") if err != nil { panic(err) // Handle error } dst := memory.New() tagName := "latest" desc, err := oras.Copy(ctx, src, tagName, dst, tagName, oras.DefaultCopyOptions) if err != nil { panic(err) // Handle error } fmt.Println(desc.Digest) // Output: // sha256:7cbb44b44e8ede5a89cf193db3f5f2fd019d89697e6b87e8ed2589e60649b0d1 } func ExampleCopy_localToLocal() { src := exampleMemoryStore dst := memory.New() tagName := "latest" ctx := context.Background() desc, err := oras.Copy(ctx, src, tagName, dst, tagName, oras.DefaultCopyOptions) if err != nil { panic(err) // Handle error } fmt.Println(desc.Digest) // Output: // sha256:7cbb44b44e8ede5a89cf193db3f5f2fd019d89697e6b87e8ed2589e60649b0d1 } func ExampleCopy_localToOciFile() { src := exampleMemoryStore tempDir, err := os.MkdirTemp("", "oras_oci_example_*") if err != nil { panic(err) // Handle error } defer os.RemoveAll(tempDir) dst, err := oci.New(tempDir) if err != nil { panic(err) // Handle error } tagName := "latest" ctx := context.Background() desc, err := oras.Copy(ctx, src, tagName, dst, tagName, oras.DefaultCopyOptions) if err != nil { panic(err) // Handle error } fmt.Println(desc.Digest) // Output: // sha256:7cbb44b44e8ede5a89cf193db3f5f2fd019d89697e6b87e8ed2589e60649b0d1 } func ExampleCopy_localToRemote() { src := exampleMemoryStore reg, err := remote.NewRegistry(remoteHost) if err != nil { panic(err) // Handle error } ctx := context.Background() dst, err := reg.Repository(ctx, "target") if err != nil { panic(err) // Handle error } tagName := "latest" desc, err := oras.Copy(ctx, src, tagName, dst, tagName, oras.DefaultCopyOptions) if err != nil { panic(err) // Handle error } fmt.Println(desc.Digest) // Output: // sha256:7cbb44b44e8ede5a89cf193db3f5f2fd019d89697e6b87e8ed2589e60649b0d1 } // ExampleCopyArtifactManifestRemoteToLocal gives an example of copying // an artifact manifest from a remote repository into memory. func Example_copyArtifactManifestRemoteToLocal() { src, err := remote.NewRepository(fmt.Sprintf("%s/source", remoteHost)) if err != nil { panic(err) } dst := memory.New() ctx := context.Background() exampleDigest := "sha256:70c29a81e235dda5c2cebb8ec06eafd3cca346cbd91f15ac74cefd98681c5b3d" descriptor, err := src.Resolve(ctx, exampleDigest) if err != nil { panic(err) } err = oras.CopyGraph(ctx, src, dst, descriptor, oras.DefaultCopyGraphOptions) if err != nil { panic(err) } // verify that the artifact manifest described by the descriptor exists in dst contentExists, err := dst.Exists(ctx, descriptor) if err != nil { panic(err) } fmt.Println(contentExists) // Output: // true } // ExampleExtendedCopyArtifactAndReferrersRemoteToLocal gives an example of // copying an artifact along with its referrers from a remote repository into // memory. func Example_extendedCopyArtifactAndReferrersRemoteToLocal() { src, err := remote.NewRepository(fmt.Sprintf("%s/source", remoteHost)) if err != nil { panic(err) } dst := memory.New() ctx := context.Background() tagName := "latest" // ExtendedCopy will copy the artifact tagged by "latest" along with all of its // referrers from src to dst. desc, err := oras.ExtendedCopy(ctx, src, tagName, dst, tagName, oras.DefaultExtendedCopyOptions) if err != nil { panic(err) } fmt.Println(desc.Digest) // Output: // sha256:f396bc4d300934a39ca28ab0d5ac8a3573336d7d63c654d783a68cd1e2057662 } oras-go-2.5.0/example_pack_test.go000066400000000000000000000077551457674530300171400ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package oras_test import ( "context" "fmt" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2" "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/content/memory" ) // ExampleImageV11 demonstrates packing an OCI Image Manifest as defined in // image-spec v1.1.0. func ExamplePackManifest_imageV11() { // 0. Create a storage store := memory.New() // 1. Set optional parameters opts := oras.PackManifestOptions{ ManifestAnnotations: map[string]string{ // this timestamp will be automatically generated if not specified // use a fixed value here in order to test the output ocispec.AnnotationCreated: "2000-01-01T00:00:00Z", }, } ctx := context.Background() // 2. Pack a manifest artifactType := "application/vnd.example+type" manifestDesc, err := oras.PackManifest(ctx, store, oras.PackManifestVersion1_1, artifactType, opts) if err != nil { panic(err) } fmt.Println("Manifest descriptor:", manifestDesc) // 3. Verify the packed manifest manifestData, err := content.FetchAll(ctx, store, manifestDesc) if err != nil { panic(err) } fmt.Println("Manifest content:", string(manifestData)) // Output: // Manifest descriptor: {application/vnd.oci.image.manifest.v1+json sha256:c259a195a48d8029d75449579c81269ca6225cd5b57d36073a7de6458afdfdbd 528 [] map[org.opencontainers.image.created:2000-01-01T00:00:00Z] [] application/vnd.example+type} // Manifest content: {"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","artifactType":"application/vnd.example+type","config":{"mediaType":"application/vnd.oci.empty.v1+json","digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","size":2,"data":"e30="},"layers":[{"mediaType":"application/vnd.oci.empty.v1+json","digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","size":2,"data":"e30="}],"annotations":{"org.opencontainers.image.created":"2000-01-01T00:00:00Z"}} } // ExampleImageV10 demonstrates packing an OCI Image Manifest as defined in // image-spec v1.0.2. func ExamplePackManifest_imageV10() { // 0. Create a storage store := memory.New() // 1. Set optional parameters opts := oras.PackManifestOptions{ ManifestAnnotations: map[string]string{ // this timestamp will be automatically generated if not specified // use a fixed value here in order to test the output ocispec.AnnotationCreated: "2000-01-01T00:00:00Z", }, } ctx := context.Background() // 2. Pack a manifest artifactType := "application/vnd.example+type" manifestDesc, err := oras.PackManifest(ctx, store, oras.PackManifestVersion1_0, artifactType, opts) if err != nil { panic(err) } fmt.Println("Manifest descriptor:", manifestDesc) // 3. Verify the packed manifest manifestData, err := content.FetchAll(ctx, store, manifestDesc) if err != nil { panic(err) } fmt.Println("Manifest content:", string(manifestData)) // Output: // Manifest descriptor: {application/vnd.oci.image.manifest.v1+json sha256:da221a11559704e4971c3dcf6564303707a333c8de8cb5475fc48b0072b36c19 308 [] map[org.opencontainers.image.created:2000-01-01T00:00:00Z] [] application/vnd.example+type} // Manifest content: {"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"mediaType":"application/vnd.example+type","digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","size":2},"layers":[],"annotations":{"org.opencontainers.image.created":"2000-01-01T00:00:00Z"}} } oras-go-2.5.0/example_test.go000066400000000000000000000134371457674530300161340ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package oras_test import ( "context" "fmt" v1 "github.com/opencontainers/image-spec/specs-go/v1" oras "oras.land/oras-go/v2" "oras.land/oras-go/v2/content/file" "oras.land/oras-go/v2/content/oci" "oras.land/oras-go/v2/registry/remote" "oras.land/oras-go/v2/registry/remote/auth" "oras.land/oras-go/v2/registry/remote/credentials" "oras.land/oras-go/v2/registry/remote/retry" ) // ExamplePullFilesFromRemoteRepository gives an example of pulling files from // a remote repository to the local file system. func Example_pullFilesFromRemoteRepository() { // 0. Create a file store fs, err := file.New("/tmp/") if err != nil { panic(err) } defer fs.Close() // 1. Connect to a remote repository ctx := context.Background() reg := "myregistry.example.com" repo, err := remote.NewRepository(reg + "/myrepo") if err != nil { panic(err) } // Note: The below code can be omitted if authentication is not required repo.Client = &auth.Client{ Client: retry.DefaultClient, Cache: auth.NewCache(), Credential: auth.StaticCredential(reg, auth.Credential{ Username: "username", Password: "password", }), } // 2. Copy from the remote repository to the file store tag := "latest" manifestDescriptor, err := oras.Copy(ctx, repo, tag, fs, tag, oras.DefaultCopyOptions) if err != nil { panic(err) } fmt.Println("manifest descriptor:", manifestDescriptor) } // ExamplePullImageFromRemoteRepository gives an example of pulling an image // from a remote repository to an OCI Image layout folder. func Example_pullImageFromRemoteRepository() { // 0. Create an OCI layout store store, err := oci.New("/tmp/oci-layout-root") if err != nil { panic(err) } // 1. Connect to a remote repository ctx := context.Background() reg := "myregistry.example.com" repo, err := remote.NewRepository(reg + "/myrepo") if err != nil { panic(err) } // Note: The below code can be omitted if authentication is not required repo.Client = &auth.Client{ Client: retry.DefaultClient, Cache: auth.NewCache(), Credential: auth.StaticCredential(reg, auth.Credential{ Username: "username", Password: "password", }), } // 2. Copy from the remote repository to the OCI layout store tag := "latest" manifestDescriptor, err := oras.Copy(ctx, repo, tag, store, tag, oras.DefaultCopyOptions) if err != nil { panic(err) } fmt.Println("manifest descriptor:", manifestDescriptor) } // ExamplePullImageUsingDockerCredentials gives an example of pulling an image // from a remote repository to an OCI Image layout folder using Docker // credentials. func Example_pullImageUsingDockerCredentials() { // 0. Create an OCI layout store store, err := oci.New("/tmp/oci-layout-root") if err != nil { panic(err) } // 1. Connect to a remote repository ctx := context.Background() reg := "docker.io" repo, err := remote.NewRepository(reg + "/user/my-repo") if err != nil { panic(err) } // prepare authentication using Docker credentials storeOpts := credentials.StoreOptions{} credStore, err := credentials.NewStoreFromDocker(storeOpts) if err != nil { panic(err) } repo.Client = &auth.Client{ Client: retry.DefaultClient, Cache: auth.NewCache(), Credential: credentials.Credential(credStore), // Use the credentials store } // 2. Copy from the remote repository to the OCI layout store tag := "latest" manifestDescriptor, err := oras.Copy(ctx, repo, tag, store, tag, oras.DefaultCopyOptions) if err != nil { panic(err) } fmt.Println("manifest pulled:", manifestDescriptor.Digest, manifestDescriptor.MediaType) } // ExamplePushFilesToRemoteRepository gives an example of pushing local files // to a remote repository. func Example_pushFilesToRemoteRepository() { // 0. Create a file store fs, err := file.New("/tmp/") if err != nil { panic(err) } defer fs.Close() ctx := context.Background() // 1. Add files to the file store mediaType := "application/vnd.test.file" fileNames := []string{"/tmp/myfile"} fileDescriptors := make([]v1.Descriptor, 0, len(fileNames)) for _, name := range fileNames { fileDescriptor, err := fs.Add(ctx, name, mediaType, "") if err != nil { panic(err) } fileDescriptors = append(fileDescriptors, fileDescriptor) fmt.Printf("file descriptor for %s: %v\n", name, fileDescriptor) } // 2. Pack the files and tag the packed manifest artifactType := "application/vnd.test.artifact" opts := oras.PackManifestOptions{ Layers: fileDescriptors, } manifestDescriptor, err := oras.PackManifest(ctx, fs, oras.PackManifestVersion1_1, artifactType, opts) if err != nil { panic(err) } fmt.Println("manifest descriptor:", manifestDescriptor) tag := "latest" if err = fs.Tag(ctx, manifestDescriptor, tag); err != nil { panic(err) } // 3. Connect to a remote repository reg := "myregistry.example.com" repo, err := remote.NewRepository(reg + "/myrepo") if err != nil { panic(err) } // Note: The below code can be omitted if authentication is not required repo.Client = &auth.Client{ Client: retry.DefaultClient, Cache: auth.NewCache(), Credential: auth.StaticCredential(reg, auth.Credential{ Username: "username", Password: "password", }), } // 4. Copy from the file store to the remote repository _, err = oras.Copy(ctx, fs, tag, repo, tag, oras.DefaultCopyOptions) if err != nil { panic(err) } } oras-go-2.5.0/extendedcopy.go000066400000000000000000000312651457674530300161340ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package oras import ( "context" "encoding/json" "errors" "regexp" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "golang.org/x/sync/semaphore" "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/internal/cas" "oras.land/oras-go/v2/internal/container/set" "oras.land/oras-go/v2/internal/copyutil" "oras.land/oras-go/v2/internal/descriptor" "oras.land/oras-go/v2/internal/docker" "oras.land/oras-go/v2/internal/spec" "oras.land/oras-go/v2/internal/status" "oras.land/oras-go/v2/internal/syncutil" "oras.land/oras-go/v2/registry" ) // DefaultExtendedCopyOptions provides the default ExtendedCopyOptions. var DefaultExtendedCopyOptions ExtendedCopyOptions = ExtendedCopyOptions{ ExtendedCopyGraphOptions: DefaultExtendedCopyGraphOptions, } // ExtendedCopyOptions contains parameters for [oras.ExtendedCopy]. type ExtendedCopyOptions struct { ExtendedCopyGraphOptions } // DefaultExtendedCopyGraphOptions provides the default ExtendedCopyGraphOptions. var DefaultExtendedCopyGraphOptions ExtendedCopyGraphOptions = ExtendedCopyGraphOptions{ CopyGraphOptions: DefaultCopyGraphOptions, } // ExtendedCopyGraphOptions contains parameters for [oras.ExtendedCopyGraph]. type ExtendedCopyGraphOptions struct { CopyGraphOptions // Depth limits the maximum depth of the directed acyclic graph (DAG) that // will be extended-copied. // If Depth is no specified, or the specified value is less than or // equal to 0, the depth limit will be considered as infinity. Depth int // FindPredecessors finds the predecessors of the current node. // If FindPredecessors is nil, src.Predecessors will be adapted and used. FindPredecessors func(ctx context.Context, src content.ReadOnlyGraphStorage, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) } // ExtendedCopy copies the directed acyclic graph (DAG) that are reachable from // the given tagged node from the source GraphTarget to the destination Target. // The destination reference will be the same as the source reference if the // destination reference is left blank. // // Returns the descriptor of the tagged node on successful copy. func ExtendedCopy(ctx context.Context, src ReadOnlyGraphTarget, srcRef string, dst Target, dstRef string, opts ExtendedCopyOptions) (ocispec.Descriptor, error) { if src == nil { return ocispec.Descriptor{}, errors.New("nil source graph target") } if dst == nil { return ocispec.Descriptor{}, errors.New("nil destination target") } if dstRef == "" { dstRef = srcRef } node, err := src.Resolve(ctx, srcRef) if err != nil { return ocispec.Descriptor{}, err } if err := ExtendedCopyGraph(ctx, src, dst, node, opts.ExtendedCopyGraphOptions); err != nil { return ocispec.Descriptor{}, err } if err := dst.Tag(ctx, node, dstRef); err != nil { return ocispec.Descriptor{}, err } return node, nil } // ExtendedCopyGraph copies the directed acyclic graph (DAG) that are reachable // from the given node from the source GraphStorage to the destination Storage. func ExtendedCopyGraph(ctx context.Context, src content.ReadOnlyGraphStorage, dst content.Storage, node ocispec.Descriptor, opts ExtendedCopyGraphOptions) error { roots, err := findRoots(ctx, src, node, opts) if err != nil { return err } // if Concurrency is not set or invalid, use the default concurrency if opts.Concurrency <= 0 { opts.Concurrency = defaultConcurrency } limiter := semaphore.NewWeighted(int64(opts.Concurrency)) // use caching proxy on non-leaf nodes if opts.MaxMetadataBytes <= 0 { opts.MaxMetadataBytes = defaultCopyMaxMetadataBytes } proxy := cas.NewProxyWithLimit(src, cas.NewMemory(), opts.MaxMetadataBytes) // track content status tracker := status.NewTracker() // copy the sub-DAGs rooted by the root nodes return syncutil.Go(ctx, limiter, func(ctx context.Context, region *syncutil.LimitedRegion, root ocispec.Descriptor) error { // As a root can be a predecessor of other roots, release the limit here // for dispatching, to avoid dead locks where predecessor roots are // handled first and are waiting for its successors to complete. region.End() if err := copyGraph(ctx, src, dst, root, proxy, limiter, tracker, opts.CopyGraphOptions); err != nil { return err } return region.Start() }, roots...) } // findRoots finds the root nodes reachable from the given node through a // depth-first search. func findRoots(ctx context.Context, storage content.ReadOnlyGraphStorage, node ocispec.Descriptor, opts ExtendedCopyGraphOptions) ([]ocispec.Descriptor, error) { visited := set.New[descriptor.Descriptor]() rootMap := make(map[descriptor.Descriptor]ocispec.Descriptor) addRoot := func(key descriptor.Descriptor, val ocispec.Descriptor) { if _, exists := rootMap[key]; !exists { rootMap[key] = val } } // if FindPredecessors is not provided, use the default one if opts.FindPredecessors == nil { opts.FindPredecessors = func(ctx context.Context, src content.ReadOnlyGraphStorage, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) { return src.Predecessors(ctx, desc) } } var stack copyutil.Stack // push the initial node to the stack, set the depth to 0 stack.Push(copyutil.NodeInfo{Node: node, Depth: 0}) for { current, ok := stack.Pop() if !ok { // empty stack break } currentNode := current.Node currentKey := descriptor.FromOCI(currentNode) if visited.Contains(currentKey) { // skip the current node if it has been visited continue } visited.Add(currentKey) // stop finding predecessors if the target depth is reached if opts.Depth > 0 && current.Depth == opts.Depth { addRoot(currentKey, currentNode) continue } predecessors, err := opts.FindPredecessors(ctx, storage, currentNode) if err != nil { return nil, err } // The current node has no predecessor node, // which means it is a root node of a sub-DAG. if len(predecessors) == 0 { addRoot(currentKey, currentNode) continue } // The current node has predecessor nodes, which means it is NOT a root node. // Push the predecessor nodes to the stack and keep finding from there. for _, predecessor := range predecessors { predecessorKey := descriptor.FromOCI(predecessor) if !visited.Contains(predecessorKey) { // push the predecessor node with increased depth stack.Push(copyutil.NodeInfo{Node: predecessor, Depth: current.Depth + 1}) } } } roots := make([]ocispec.Descriptor, 0, len(rootMap)) for _, root := range rootMap { roots = append(roots, root) } return roots, nil } // FilterAnnotation configures opts.FindPredecessors to filter the predecessors // whose annotation matches a given regex pattern. // // A predecessor is kept if key is in its annotations and the annotation value // matches regex. // If regex is nil, predecessors whose annotations contain key will be kept, // no matter of the annotation value. // // For performance consideration, when using both FilterArtifactType and // FilterAnnotation, it's recommended to call FilterArtifactType first. func (opts *ExtendedCopyGraphOptions) FilterAnnotation(key string, regex *regexp.Regexp) { keep := func(desc ocispec.Descriptor) bool { value, ok := desc.Annotations[key] return ok && (regex == nil || regex.MatchString(value)) } fp := opts.FindPredecessors opts.FindPredecessors = func(ctx context.Context, src content.ReadOnlyGraphStorage, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) { var predecessors []ocispec.Descriptor var err error if fp == nil { if rf, ok := src.(registry.ReferrerLister); ok { // if src is a ReferrerLister, use Referrers() for possible memory saving if err := rf.Referrers(ctx, desc, "", func(referrers []ocispec.Descriptor) error { // for each page of the results, filter the referrers for _, r := range referrers { if keep(r) { predecessors = append(predecessors, r) } } return nil }); err != nil { return nil, err } return predecessors, nil } predecessors, err = src.Predecessors(ctx, desc) } else { predecessors, err = fp(ctx, src, desc) } if err != nil { return nil, err } // Predecessor descriptors that are not from Referrers API are not // guaranteed to include the annotations of the corresponding manifests. var kept []ocispec.Descriptor for _, p := range predecessors { if p.Annotations == nil { // If the annotations are not present in the descriptors, // fetch it from the manifest content. switch p.MediaType { case docker.MediaTypeManifest, ocispec.MediaTypeImageManifest, docker.MediaTypeManifestList, ocispec.MediaTypeImageIndex, spec.MediaTypeArtifactManifest: annotations, err := fetchAnnotations(ctx, src, p) if err != nil { return nil, err } p.Annotations = annotations } } if keep(p) { kept = append(kept, p) } } return kept, nil } } // fetchAnnotations fetches the annotations of the manifest described by desc. func fetchAnnotations(ctx context.Context, src content.ReadOnlyGraphStorage, desc ocispec.Descriptor) (map[string]string, error) { rc, err := src.Fetch(ctx, desc) if err != nil { return nil, err } defer rc.Close() var manifest struct { Annotations map[string]string `json:"annotations"` } if err := json.NewDecoder(rc).Decode(&manifest); err != nil { return nil, err } if manifest.Annotations == nil { // to differentiate with nil return make(map[string]string), nil } return manifest.Annotations, nil } // FilterArtifactType configures opts.FindPredecessors to filter the // predecessors whose artifact type matches a given regex pattern. // // A predecessor is kept if its artifact type matches regex. // If regex is nil, all predecessors will be kept. // // For performance consideration, when using both FilterArtifactType and // FilterAnnotation, it's recommended to call FilterArtifactType first. func (opts *ExtendedCopyGraphOptions) FilterArtifactType(regex *regexp.Regexp) { if regex == nil { return } keep := func(desc ocispec.Descriptor) bool { return regex.MatchString(desc.ArtifactType) } fp := opts.FindPredecessors opts.FindPredecessors = func(ctx context.Context, src content.ReadOnlyGraphStorage, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) { var predecessors []ocispec.Descriptor var err error if fp == nil { if rf, ok := src.(registry.ReferrerLister); ok { // if src is a ReferrerLister, use Referrers() for possible memory saving if err := rf.Referrers(ctx, desc, "", func(referrers []ocispec.Descriptor) error { // for each page of the results, filter the referrers for _, r := range referrers { if keep(r) { predecessors = append(predecessors, r) } } return nil }); err != nil { return nil, err } return predecessors, nil } predecessors, err = src.Predecessors(ctx, desc) } else { predecessors, err = fp(ctx, src, desc) } if err != nil { return nil, err } // predecessor descriptors that are not from Referrers API are not // guaranteed to include the artifact type of the corresponding // manifests. var kept []ocispec.Descriptor for _, p := range predecessors { if p.ArtifactType == "" { // if the artifact type is not present in the descriptors, // fetch it from the manifest content. switch p.MediaType { case spec.MediaTypeArtifactManifest, ocispec.MediaTypeImageManifest: artifactType, err := fetchArtifactType(ctx, src, p) if err != nil { return nil, err } p.ArtifactType = artifactType } } if keep(p) { kept = append(kept, p) } } return kept, nil } } // fetchArtifactType fetches the artifact type of the manifest described by desc. func fetchArtifactType(ctx context.Context, src content.ReadOnlyGraphStorage, desc ocispec.Descriptor) (string, error) { rc, err := src.Fetch(ctx, desc) if err != nil { return "", err } defer rc.Close() switch desc.MediaType { case spec.MediaTypeArtifactManifest: var manifest spec.Artifact if err := json.NewDecoder(rc).Decode(&manifest); err != nil { return "", err } return manifest.ArtifactType, nil case ocispec.MediaTypeImageManifest: var manifest ocispec.Manifest if err := json.NewDecoder(rc).Decode(&manifest); err != nil { return "", err } return manifest.Config.MediaType, nil default: return "", nil } } oras-go-2.5.0/extendedcopy_test.go000066400000000000000000002026051457674530300171710ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package oras_test import ( "bytes" "context" "encoding/json" "errors" "net/http" "net/http/httptest" "net/url" "reflect" "regexp" "strconv" "strings" "testing" "github.com/opencontainers/go-digest" specs "github.com/opencontainers/image-spec/specs-go" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2" "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/content/memory" "oras.land/oras-go/v2/errdef" "oras.land/oras-go/v2/internal/spec" "oras.land/oras-go/v2/registry/remote" ) func TestExtendedCopy_FullCopy(t *testing.T) { src := memory.New() dst := memory.New() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(subject *ocispec.Descriptor, config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, Subject: subject, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } generateArtifactManifest := func(subject ocispec.Descriptor, blobs ...ocispec.Descriptor) { manifest := spec.Artifact{ MediaType: spec.MediaTypeArtifactManifest, Subject: &subject, Blobs: blobs, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(spec.MediaTypeArtifactManifest, manifestJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 generateManifest(nil, descs[0], descs[1:3]...) // Blob 3 appendBlob(ocispec.MediaTypeImageLayer, []byte("sig_1")) // Blob 4 generateArtifactManifest(descs[3], descs[4]) // Blob 5 appendBlob(ocispec.MediaTypeImageLayer, []byte("sig_2")) // Blob 6 generateArtifactManifest(descs[5], descs[6]) // Blob 7 appendBlob(ocispec.MediaTypeImageLayer, []byte("baz")) // Blob 8 generateManifest(&descs[3], descs[0], descs[8]) // Blob 9 ctx := context.Background() for i := range blobs { err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } manifest := descs[3] ref := "foobar" err := src.Tag(ctx, manifest, ref) if err != nil { t.Fatal("fail to tag root node", err) } // test extended copy gotDesc, err := oras.ExtendedCopy(ctx, src, ref, dst, "", oras.ExtendedCopyOptions{}) if err != nil { t.Fatalf("Copy() error = %v, wantErr %v", err, false) } if !reflect.DeepEqual(gotDesc, manifest) { t.Errorf("Copy() = %v, want %v", gotDesc, manifest) } // verify contents for i, desc := range descs { exists, err := dst.Exists(ctx, desc) if err != nil { t.Fatalf("dst.Exists(%d) error = %v", i, err) } if !exists { t.Errorf("dst.Exists(%d) = %v, want %v", i, exists, true) } } // verify tag gotDesc, err = dst.Resolve(ctx, ref) if err != nil { t.Fatal("dst.Resolve() error =", err) } if !reflect.DeepEqual(gotDesc, manifest) { t.Errorf("dst.Resolve() = %v, want %v", gotDesc, manifest) } } func TestExtendedCopyGraph_FullCopy(t *testing.T) { // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } generateIndex := func(manifests ...ocispec.Descriptor) { index := ocispec.Index{ Manifests: manifests, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageIndex, indexJSON) } generateArtifactManifest := func(subject ocispec.Descriptor, blobs ...ocispec.Descriptor) { manifest := spec.Artifact{ MediaType: spec.MediaTypeArtifactManifest, Subject: &subject, Blobs: blobs, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(spec.MediaTypeArtifactManifest, manifestJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config_1")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 generateManifest(descs[0], descs[1:3]...) // Blob 3 appendBlob(ocispec.MediaTypeImageLayer, []byte("baz")) // Blob 4 generateManifest(descs[0], descs[4]) // Blob 5 (root) appendBlob(ocispec.MediaTypeImageConfig, []byte("config_2")) // Blob 6 appendBlob(ocispec.MediaTypeImageLayer, []byte("hello")) // Blob 7 generateManifest(descs[6], descs[7]) // Blob 8 appendBlob(ocispec.MediaTypeImageLayer, []byte("sig_1")) // Blob 9 generateArtifactManifest(descs[8], descs[9]) // Blob 10 generateIndex(descs[3], descs[10]) // Blob 11 (root) appendBlob(ocispec.MediaTypeImageLayer, []byte("goodbye")) // Blob 12 appendBlob(ocispec.MediaTypeImageLayer, []byte("sig_2")) // Blob 13 generateArtifactManifest(descs[12], descs[13]) // Blob 14 (root) ctx := context.Background() verifyCopy := func(dst content.Fetcher, copiedIndice []int, uncopiedIndice []int) { for _, i := range copiedIndice { got, err := content.FetchAll(ctx, dst, descs[i]) if err != nil { t.Errorf("content[%d] error = %v, wantErr %v", i, err, false) continue } if want := blobs[i]; !bytes.Equal(got, want) { t.Errorf("content[%d] = %v, want %v", i, got, want) } } for _, i := range uncopiedIndice { if _, err := content.FetchAll(ctx, dst, descs[i]); !errors.Is(err, errdef.ErrNotFound) { t.Errorf("content[%d] error = %v, wantErr %v", i, err, errdef.ErrNotFound) } } } src := memory.New() for i := range blobs { err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } // test extended copy by descs[0] dst := memory.New() if err := oras.ExtendedCopyGraph(ctx, src, dst, descs[0], oras.ExtendedCopyGraphOptions{}); err != nil { t.Fatalf("ExtendedCopyGraph() error = %v, wantErr %v", err, false) } // graph rooted by descs[11] should be copied copiedIndice := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11} uncopiedIndice := []int{12, 13, 14} verifyCopy(dst, copiedIndice, uncopiedIndice) // test extended copy by descs[4] dst = memory.New() if err := oras.ExtendedCopyGraph(ctx, src, dst, descs[4], oras.ExtendedCopyGraphOptions{}); err != nil { t.Fatalf("ExtendedCopyGraph() error = %v, wantErr %v", err, false) } // graph rooted by descs[5] should be copied copiedIndice = []int{0, 4, 5} uncopiedIndice = []int{1, 2, 3, 6, 7, 8, 9, 10, 11, 12, 13, 14} verifyCopy(dst, copiedIndice, uncopiedIndice) // test extended copy by descs[14] dst = memory.New() if err := oras.ExtendedCopyGraph(ctx, src, dst, descs[14], oras.ExtendedCopyGraphOptions{}); err != nil { t.Fatalf("ExtendedCopyGraph() error = %v, wantErr %v", err, false) } // graph rooted by descs[14] should be copied copiedIndice = []int{12, 13, 14} uncopiedIndice = []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11} verifyCopy(dst, copiedIndice, uncopiedIndice) } func TestExtendedCopyGraph_PartialCopy(t *testing.T) { src := memory.New() dst := memory.New() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } generateIndex := func(manifests ...ocispec.Descriptor) { index := ocispec.Index{ Manifests: manifests, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageIndex, indexJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 generateManifest(descs[0], descs[1:3]...) // Blob 3 appendBlob(ocispec.MediaTypeImageLayer, []byte("hello")) // Blob 4 generateManifest(descs[0], descs[4]) // Blob 5 generateIndex(descs[3], descs[5]) // Blob 6 (root) ctx := context.Background() for i := range blobs { err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } // test copy a part of the graph root := descs[3] if err := oras.CopyGraph(ctx, src, dst, root, oras.CopyGraphOptions{}); err != nil { t.Fatalf("CopyGraph() error = %v, wantErr %v", err, false) } // blobs [0-3] should be copied for i := range blobs[:4] { got, err := content.FetchAll(ctx, dst, descs[i]) if err != nil { t.Fatalf("content[%d] error = %v, wantErr %v", i, err, false) } if want := blobs[i]; !bytes.Equal(got, want) { t.Fatalf("content[%d] = %v, want %v", i, got, want) } } // test extended copy by descs[0] if err := oras.ExtendedCopyGraph(ctx, src, dst, descs[0], oras.ExtendedCopyGraphOptions{}); err != nil { t.Fatalf("ExtendedCopyGraph() error = %v, wantErr %v", err, false) } // all blobs should be copied for i := range blobs { got, err := content.FetchAll(ctx, dst, descs[i]) if err != nil { t.Errorf("content[%d] error = %v, wantErr %v", i, err, false) continue } if want := blobs[i]; !bytes.Equal(got, want) { t.Errorf("content[%d] = %v, want %v", i, got, want) } } } func TestExtendedCopyGraph_artifactIndex(t *testing.T) { // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(subject *ocispec.Descriptor, config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Subject: subject, Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } generateIndex := func(subject *ocispec.Descriptor, manifests ...ocispec.Descriptor) { index := ocispec.Index{ Subject: subject, Manifests: manifests, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageIndex, indexJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config_1")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("layer_1")) // Blob 1 generateManifest(nil, descs[0], descs[1]) // Blob 2 appendBlob(ocispec.MediaTypeImageConfig, []byte("config_2")) // Blob 3 appendBlob(ocispec.MediaTypeImageLayer, []byte("layer_2")) // Blob 4 generateManifest(nil, descs[3], descs[4]) // Blob 5 appendBlob(ocispec.MediaTypeImageLayer, []byte("{}")) // Blob 6 appendBlob(ocispec.MediaTypeImageLayer, []byte("sbom_1")) // Blob 7 generateManifest(&descs[2], descs[6], descs[7]) // Blob 8 appendBlob(ocispec.MediaTypeImageLayer, []byte("sbom_2")) // Blob 9 generateManifest(&descs[5], descs[6], descs[9]) // Blob 10 generateIndex(nil, []ocispec.Descriptor{descs[2], descs[5]}...) // Blob 11 (root) generateIndex(&descs[11], []ocispec.Descriptor{descs[8], descs[10]}...) // Blob 12 (root) ctx := context.Background() verifyCopy := func(dst content.Fetcher, copiedIndice []int, uncopiedIndice []int) { for _, i := range copiedIndice { got, err := content.FetchAll(ctx, dst, descs[i]) if err != nil { t.Errorf("content[%d] error = %v, wantErr %v", i, err, false) continue } if want := blobs[i]; !bytes.Equal(got, want) { t.Errorf("content[%d] = %v, want %v", i, got, want) } } for _, i := range uncopiedIndice { if _, err := content.FetchAll(ctx, dst, descs[i]); !errors.Is(err, errdef.ErrNotFound) { t.Errorf("content[%d] error = %v, wantErr %v", i, err, errdef.ErrNotFound) } } } src := memory.New() for i := range blobs { err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } // test extended copy by descs[0] dst := memory.New() if err := oras.ExtendedCopyGraph(ctx, src, dst, descs[0], oras.ExtendedCopyGraphOptions{}); err != nil { t.Fatalf("ExtendedCopyGraph() error = %v, wantErr %v", err, false) } // all blobs should be copied copiedIndice := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12} uncopiedIndice := []int{} verifyCopy(dst, copiedIndice, uncopiedIndice) // test extended copy by descs[2] dst = memory.New() if err := oras.ExtendedCopyGraph(ctx, src, dst, descs[2], oras.ExtendedCopyGraphOptions{}); err != nil { t.Fatalf("ExtendedCopyGraph() error = %v, wantErr %v", err, false) } // all blobs should be copied copiedIndice = []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12} uncopiedIndice = []int{} verifyCopy(dst, copiedIndice, uncopiedIndice) // test extended copy by descs[8] dst = memory.New() if err := oras.ExtendedCopyGraph(ctx, src, dst, descs[8], oras.ExtendedCopyGraphOptions{}); err != nil { t.Fatalf("ExtendedCopyGraph() error = %v, wantErr %v", err, false) } // all blobs should be copied copiedIndice = []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12} uncopiedIndice = []int{} verifyCopy(dst, copiedIndice, uncopiedIndice) // test extended copy by descs[11] dst = memory.New() if err := oras.ExtendedCopyGraph(ctx, src, dst, descs[11], oras.ExtendedCopyGraphOptions{}); err != nil { t.Fatalf("ExtendedCopyGraph() error = %v, wantErr %v", err, false) } // all blobs should be copied copiedIndice = []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12} uncopiedIndice = []int{} verifyCopy(dst, copiedIndice, uncopiedIndice) } func TestExtendedCopyGraph_WithDepthOption(t *testing.T) { // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } generateIndex := func(manifests ...ocispec.Descriptor) { index := ocispec.Index{ Manifests: manifests, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageIndex, indexJSON) } generateArtifactManifest := func(subject ocispec.Descriptor, blobs ...ocispec.Descriptor) { manifest := spec.Artifact{ MediaType: spec.MediaTypeArtifactManifest, Subject: &subject, Blobs: blobs, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(spec.MediaTypeArtifactManifest, manifestJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config_1")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 generateManifest(descs[0], descs[1:3]...) // Blob 3 appendBlob(ocispec.MediaTypeImageLayer, []byte("baz")) // Blob 4 generateManifest(descs[0], descs[4]) // Blob 5 (root) appendBlob(ocispec.MediaTypeImageConfig, []byte("config_2")) // Blob 6 appendBlob(ocispec.MediaTypeImageLayer, []byte("hello")) // Blob 7 generateManifest(descs[6], descs[7]) // Blob 8 appendBlob(ocispec.MediaTypeImageLayer, []byte("sig_1")) // Blob 9 generateArtifactManifest(descs[8], descs[9]) // Blob 10 generateIndex(descs[3], descs[10]) // Blob 11 (root) appendBlob(ocispec.MediaTypeImageLayer, []byte("goodbye")) // Blob 12 appendBlob(ocispec.MediaTypeImageLayer, []byte("sig_2")) // Blob 13 generateArtifactManifest(descs[12], descs[13]) // Blob 14 (root) ctx := context.Background() verifyCopy := func(dst content.Fetcher, copiedIndice []int, uncopiedIndice []int) { for _, i := range copiedIndice { got, err := content.FetchAll(ctx, dst, descs[i]) if err != nil { t.Errorf("content[%d] error = %v, wantErr %v", i, err, false) continue } if want := blobs[i]; !bytes.Equal(got, want) { t.Errorf("content[%d] = %v, want %v", i, got, want) } } for _, i := range uncopiedIndice { if _, err := content.FetchAll(ctx, dst, descs[i]); !errors.Is(err, errdef.ErrNotFound) { t.Errorf("content[%d] error = %v, wantErr %v", i, err, errdef.ErrNotFound) } } } src := memory.New() for i := range blobs { err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } // test extended copy by descs[0] with default depth 0 dst := memory.New() opts := oras.ExtendedCopyGraphOptions{} if err := oras.ExtendedCopyGraph(ctx, src, dst, descs[0], opts); err != nil { t.Fatalf("ExtendedCopyGraph() error = %v, wantErr %v", err, false) } // graph rooted by descs[11] should be copied copiedIndice := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11} uncopiedIndice := []int{12, 13, 14} verifyCopy(dst, copiedIndice, uncopiedIndice) // test extended copy by descs[0] with depth of 1 dst = memory.New() opts = oras.ExtendedCopyGraphOptions{Depth: 1} if err := oras.ExtendedCopyGraph(ctx, src, dst, descs[0], opts); err != nil { t.Fatalf("ExtendedCopyGraph() error = %v, wantErr %v", err, false) } // graph rooted by descs[3] and descs[5] should be copied copiedIndice = []int{0, 1, 2, 3, 4, 5} uncopiedIndice = []int{6, 7, 8, 9, 10, 11, 12, 13, 14} verifyCopy(dst, copiedIndice, uncopiedIndice) // test extended copy by descs[0] with depth of 2 dst = memory.New() opts = oras.ExtendedCopyGraphOptions{Depth: 2} if err := oras.ExtendedCopyGraph(ctx, src, dst, descs[0], opts); err != nil { t.Fatalf("ExtendedCopyGraph() error = %v, wantErr %v", err, false) } // graph rooted by descs[11] should be copied copiedIndice = []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11} uncopiedIndice = []int{12, 13, 14} verifyCopy(dst, copiedIndice, uncopiedIndice) // test extended copy by descs[0] with depth -1 dst = memory.New() opts = oras.ExtendedCopyGraphOptions{Depth: -1} if err := oras.ExtendedCopyGraph(ctx, src, dst, descs[0], opts); err != nil { t.Fatalf("ExtendedCopyGraph() error = %v, wantErr %v", err, false) } // graph rooted by descs[11] should be copied copiedIndice = []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11} uncopiedIndice = []int{12, 13, 14} verifyCopy(dst, copiedIndice, uncopiedIndice) } func TestExtendedCopyGraph_WithFindPredecessorsOption(t *testing.T) { // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } generateIndex := func(manifests ...ocispec.Descriptor) { index := ocispec.Index{ Manifests: manifests, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageIndex, indexJSON) } generateArtifactManifest := func(subject ocispec.Descriptor, blobs ...ocispec.Descriptor) { manifest := spec.Artifact{ MediaType: spec.MediaTypeArtifactManifest, Subject: &subject, Blobs: blobs, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(spec.MediaTypeArtifactManifest, manifestJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config_1")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 generateManifest(descs[0], descs[1:3]...) // Blob 3 appendBlob(ocispec.MediaTypeImageLayer, []byte("sig_1")) // Blob 4 generateArtifactManifest(descs[3], descs[4]) // Blob 5 (root) appendBlob(ocispec.MediaTypeImageLayer, []byte("baz")) // Blob 6 generateArtifactManifest(descs[3], descs[6]) // Blob 7 (root) appendBlob(ocispec.MediaTypeImageConfig, []byte("config_2")) // Blob 8 appendBlob(ocispec.MediaTypeImageLayer, []byte("hello")) // Blob 9 generateManifest(descs[8], descs[9]) // Blob 10 generateIndex(descs[3], descs[10]) // Blob 11 (root) ctx := context.Background() verifyCopy := func(dst content.Fetcher, copiedIndice []int, uncopiedIndice []int) { for _, i := range copiedIndice { got, err := content.FetchAll(ctx, dst, descs[i]) if err != nil { t.Errorf("content[%d] error = %v, wantErr %v", i, err, false) continue } if want := blobs[i]; !bytes.Equal(got, want) { t.Errorf("content[%d] = %v, want %v", i, got, want) } } for _, i := range uncopiedIndice { if _, err := content.FetchAll(ctx, dst, descs[i]); !errors.Is(err, errdef.ErrNotFound) { t.Errorf("content[%d] error = %v, wantErr %v", i, err, errdef.ErrNotFound) } } } src := memory.New() for i := range blobs { err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } // test extended copy by descs[3] with media type filter dst := memory.New() opts := oras.ExtendedCopyGraphOptions{ FindPredecessors: func(ctx context.Context, src content.ReadOnlyGraphStorage, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) { predecessors, err := src.Predecessors(ctx, desc) if err != nil { return nil, err } var filtered []ocispec.Descriptor for _, p := range predecessors { // filter media type switch p.MediaType { case spec.MediaTypeArtifactManifest: filtered = append(filtered, p) } } return filtered, nil }, } if err := oras.ExtendedCopyGraph(ctx, src, dst, descs[3], opts); err != nil { t.Fatalf("ExtendedCopyGraph() error = %v, wantErr %v", err, false) } // graph rooted by descs[5] and decs[7] should be copied copiedIndice := []int{0, 1, 2, 3, 4, 5, 6, 7} uncopiedIndice := []int{8, 9, 10, 11} verifyCopy(dst, copiedIndice, uncopiedIndice) } func TestExtendedCopy_NotFound(t *testing.T) { src := memory.New() dst := memory.New() ref := "foobar" ctx := context.Background() _, err := oras.ExtendedCopy(ctx, src, ref, dst, "", oras.ExtendedCopyOptions{}) if !errors.Is(err, errdef.ErrNotFound) { t.Fatalf("ExtendedCopy() error = %v, wantErr %v", err, errdef.ErrNotFound) } } func TestExtendedCopyGraph_FilterAnnotationWithRegex(t *testing.T) { // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateArtifactManifest := func(subject ocispec.Descriptor, key string, value string) { manifest := spec.Artifact{ MediaType: spec.MediaTypeArtifactManifest, Subject: &subject, Annotations: map[string]string{key: value}, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(spec.MediaTypeArtifactManifest, manifestJSON) } appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // descs[0] generateArtifactManifest(descs[0], "bar", "bluebrown") // descs[1] generateArtifactManifest(descs[0], "bar", "blackred") // descs[2] generateArtifactManifest(descs[0], "bar", "blackviolet") // descs[3] generateArtifactManifest(descs[0], "bar", "greengrey") // descs[4] generateArtifactManifest(descs[0], "bar", "brownblack") // descs[5] ctx := context.Background() verifyCopy := func(dst content.Fetcher, copiedIndice []int, uncopiedIndice []int) { for _, i := range copiedIndice { got, err := content.FetchAll(ctx, dst, descs[i]) if err != nil { t.Errorf("content[%d] error = %v, wantErr %v", i, err, false) continue } if want := blobs[i]; !bytes.Equal(got, want) { t.Errorf("content[%d] = %v, want %v", i, got, want) } } for _, i := range uncopiedIndice { if _, err := content.FetchAll(ctx, dst, descs[i]); !errors.Is(err, errdef.ErrNotFound) { t.Errorf("content[%d] error = %v, wantErr %v", i, err, errdef.ErrNotFound) } } } src := memory.New() for i := range blobs { err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } // test extended copy by descs[0] with annotation filter dst := memory.New() opts := oras.ExtendedCopyGraphOptions{} exp := "black." regex := regexp.MustCompile(exp) opts.FilterAnnotation("bar", regex) if err := oras.ExtendedCopyGraph(ctx, src, dst, descs[0], opts); err != nil { t.Fatalf("ExtendedCopyGraph() error = %v, wantErr %v", err, false) } copiedIndice := []int{0, 2, 3} uncopiedIndice := []int{1, 4, 5} verifyCopy(dst, copiedIndice, uncopiedIndice) // test FilterAnnotation with key unavailable in predecessors' annotation // should return nothing dst = memory.New() opts = oras.ExtendedCopyGraphOptions{} exp = "black." regex = regexp.MustCompile(exp) opts.FilterAnnotation("bar1", regex) if err := oras.ExtendedCopyGraph(ctx, src, dst, descs[0], opts); err != nil { t.Fatalf("ExtendedCopyGraph() error = %v, wantErr %v", err, false) } copiedIndice = []int{0} uncopiedIndice = []int{1, 2, 3, 4, 5} verifyCopy(dst, copiedIndice, uncopiedIndice) //test FilterAnnotation with key available in predecessors' annotation, regex equal to nil //should return all predecessors with the provided key dst = memory.New() opts = oras.ExtendedCopyGraphOptions{} opts.FilterAnnotation("bar", nil) if err := oras.ExtendedCopyGraph(ctx, src, dst, descs[0], opts); err != nil { t.Fatalf("ExtendedCopyGraph() error = %v, wantErr %v", err, false) } copiedIndice = []int{0, 1, 2, 3, 4, 5} uncopiedIndice = []int{} verifyCopy(dst, copiedIndice, uncopiedIndice) } func TestExtendedCopyGraph_FilterAnnotationWithMultipleRegex(t *testing.T) { // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateArtifactManifest := func(subject ocispec.Descriptor, key string, value string) { manifest := spec.Artifact{ MediaType: spec.MediaTypeArtifactManifest, Subject: &subject, Annotations: map[string]string{key: value}, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(spec.MediaTypeArtifactManifest, manifestJSON) } appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // descs[0] generateArtifactManifest(descs[0], "bar", "bluebrown") // descs[1] generateArtifactManifest(descs[0], "bar", "blackred") // descs[2] generateArtifactManifest(descs[0], "bar", "blackviolet") // descs[3] generateArtifactManifest(descs[0], "bar", "greengrey") // descs[4] generateArtifactManifest(descs[0], "bar", "brownblack") // descs[5] generateArtifactManifest(descs[0], "bar", "blackblack") // descs[6] ctx := context.Background() verifyCopy := func(dst content.Fetcher, copiedIndice []int, uncopiedIndice []int) { for _, i := range copiedIndice { got, err := content.FetchAll(ctx, dst, descs[i]) if err != nil { t.Errorf("content[%d] error = %v, wantErr %v", i, err, false) continue } if want := blobs[i]; !bytes.Equal(got, want) { t.Errorf("content[%d] = %v, want %v", i, got, want) } } for _, i := range uncopiedIndice { if _, err := content.FetchAll(ctx, dst, descs[i]); !errors.Is(err, errdef.ErrNotFound) { t.Errorf("content[%d] error = %v, wantErr %v", i, err, errdef.ErrNotFound) } } } src := memory.New() for i := range blobs { err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } // test extended copy by descs[0] with two annotation filters dst := memory.New() opts := oras.ExtendedCopyGraphOptions{} exp1 := "black." exp2 := ".pink|red" regex1 := regexp.MustCompile(exp1) regex2 := regexp.MustCompile(exp2) opts.FilterAnnotation("bar", regex1) opts.FilterAnnotation("bar", regex2) if err := oras.ExtendedCopyGraph(ctx, src, dst, descs[0], opts); err != nil { t.Fatalf("ExtendedCopyGraph() error = %v, wantErr %v", err, false) } copiedIndice := []int{0, 2} uncopiedIndice := []int{1, 3, 4, 5, 6} verifyCopy(dst, copiedIndice, uncopiedIndice) // test extended copy by descs[0] with three annotation filters, nil included dst = memory.New() opts = oras.ExtendedCopyGraphOptions{} exp1 = "black." exp2 = ".pink|red" regex1 = regexp.MustCompile(exp1) regex2 = regexp.MustCompile(exp2) opts.FilterAnnotation("bar", regex1) opts.FilterAnnotation("bar", nil) opts.FilterAnnotation("bar", regex2) if err := oras.ExtendedCopyGraph(ctx, src, dst, descs[0], opts); err != nil { t.Fatalf("ExtendedCopyGraph() error = %v, wantErr %v", err, false) } copiedIndice = []int{0, 2} uncopiedIndice = []int{1, 3, 4, 5, 6} verifyCopy(dst, copiedIndice, uncopiedIndice) // test extended copy by descs[0] with two annotation filters, the second filter has an unavailable key dst = memory.New() opts = oras.ExtendedCopyGraphOptions{} exp1 = "black." exp2 = ".pink|red" regex1 = regexp.MustCompile(exp1) regex2 = regexp.MustCompile(exp2) opts.FilterAnnotation("bar", regex1) opts.FilterAnnotation("test", regex2) if err := oras.ExtendedCopyGraph(ctx, src, dst, descs[0], opts); err != nil { t.Fatalf("ExtendedCopyGraph() error = %v, wantErr %v", err, false) } copiedIndice = []int{0} uncopiedIndice = []int{1, 2, 3, 4, 5, 6} verifyCopy(dst, copiedIndice, uncopiedIndice) } func TestExtendedCopyGraph_FilterAnnotationWithRegex_AnnotationInDescriptor(t *testing.T) { // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType, key, value string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), Annotations: map[string]string{key: value}, }) } generateArtifactManifest := func(subject ocispec.Descriptor, key string, value string) { manifest := spec.Artifact{ MediaType: spec.MediaTypeArtifactManifest, Subject: &subject, Annotations: map[string]string{key: value}, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(spec.MediaTypeArtifactManifest, key, value, manifestJSON) } appendBlob(ocispec.MediaTypeImageLayer, "", "", []byte("foo")) // descs[0] generateArtifactManifest(descs[0], "bar", "bluebrown") // descs[1] generateArtifactManifest(descs[0], "bar", "blackred") // descs[2] generateArtifactManifest(descs[0], "bar", "blackviolet") // descs[3] generateArtifactManifest(descs[0], "bar", "greengrey") // descs[4] generateArtifactManifest(descs[0], "bar", "brownblack") // descs[5] ctx := context.Background() verifyCopy := func(dst content.Fetcher, copiedIndice []int, uncopiedIndice []int) { for _, i := range copiedIndice { got, err := content.FetchAll(ctx, dst, descs[i]) if err != nil { t.Errorf("content[%d] error = %v, wantErr %v", i, err, false) continue } if want := blobs[i]; !bytes.Equal(got, want) { t.Errorf("content[%d] = %v, want %v", i, got, want) } } for _, i := range uncopiedIndice { if _, err := content.FetchAll(ctx, dst, descs[i]); !errors.Is(err, errdef.ErrNotFound) { t.Errorf("content[%d] error = %v, wantErr %v", i, err, errdef.ErrNotFound) } } } src := memory.New() for i := range blobs { err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } // test extended copy by descs[0] with annotation filter dst := memory.New() opts := oras.ExtendedCopyGraphOptions{} exp := "black." regex := regexp.MustCompile(exp) opts.FilterAnnotation("bar", regex) if err := oras.ExtendedCopyGraph(ctx, src, dst, descs[0], opts); err != nil { t.Fatalf("ExtendedCopyGraph() error = %v, wantErr %v", err, false) } copiedIndice := []int{0, 2, 3} uncopiedIndice := []int{1, 4, 5} verifyCopy(dst, copiedIndice, uncopiedIndice) } func TestExtendedCopyGraph_FilterAnnotationWithMultipleRegex_Referrers(t *testing.T) { // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType, key, value string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), Annotations: map[string]string{key: value}, }) } generateArtifactManifest := func(subject ocispec.Descriptor, key string, value string) { manifest := spec.Artifact{ MediaType: spec.MediaTypeArtifactManifest, Subject: &subject, Annotations: map[string]string{key: value}, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(spec.MediaTypeArtifactManifest, key, value, manifestJSON) } appendBlob(ocispec.MediaTypeImageLayer, "", "", []byte("foo")) // descs[0] generateArtifactManifest(descs[0], "bar", "bluebrown") // descs[1] generateArtifactManifest(descs[0], "bar", "blackred") // descs[2] generateArtifactManifest(descs[0], "bar", "blackviolet") // descs[3] generateArtifactManifest(descs[0], "bar", "greengrey") // descs[4] generateArtifactManifest(descs[0], "bar", "brownblack") // descs[5] generateArtifactManifest(descs[0], "bar", "blackblack") // descs[6] // set up test server ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { p := r.URL.Path var manifests []ocispec.Descriptor switch { case p == "/v2/test/referrers/"+descs[0].Digest.String(): manifests = descs[1:] fallthrough case strings.HasPrefix(p, "/v2/test/referrers/"): result := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageIndex, Manifests: manifests, } w.Header().Set("Content-Type", ocispec.MediaTypeImageIndex) if err := json.NewEncoder(w).Encode(result); err != nil { t.Errorf("failed to write response: %v", err) } case strings.Contains(p, descs[0].Digest.String()): w.Header().Set("Content-Type", ocispec.MediaTypeImageLayer) w.Header().Set("Content-Digest", descs[0].Digest.String()) w.Header().Set("Content-Length", strconv.Itoa(len(blobs[0]))) w.Write(blobs[0]) case strings.Contains(p, descs[1].Digest.String()): w.Header().Set("Content-Type", spec.MediaTypeArtifactManifest) w.Header().Set("Content-Digest", descs[1].Digest.String()) w.Header().Set("Content-Length", strconv.Itoa(len(blobs[1]))) w.Write(blobs[1]) case strings.Contains(p, descs[2].Digest.String()): w.Header().Set("Content-Type", spec.MediaTypeArtifactManifest) w.Header().Set("Content-Digest", descs[2].Digest.String()) w.Header().Set("Content-Length", strconv.Itoa(len(blobs[2]))) w.Write(blobs[2]) case strings.Contains(p, descs[3].Digest.String()): w.Header().Set("Content-Type", spec.MediaTypeArtifactManifest) w.Header().Set("Content-Digest", descs[3].Digest.String()) w.Header().Set("Content-Length", strconv.Itoa(len(blobs[3]))) w.Write(blobs[3]) case strings.Contains(p, descs[4].Digest.String()): w.Header().Set("Content-Type", spec.MediaTypeArtifactManifest) w.Header().Set("Content-Digest", descs[4].Digest.String()) w.Header().Set("Content-Length", strconv.Itoa(len(blobs[4]))) w.Write(blobs[4]) case strings.Contains(p, descs[5].Digest.String()): w.Header().Set("Content-Type", spec.MediaTypeArtifactManifest) w.Header().Set("Content-Digest", descs[5].Digest.String()) w.Header().Set("Content-Length", strconv.Itoa(len(blobs[5]))) w.Write(blobs[5]) default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Errorf("invalid test http server: %v", err) } ctx := context.Background() verifyCopy := func(dst content.Fetcher, copiedIndice []int, uncopiedIndice []int) { for _, i := range copiedIndice { got, err := content.FetchAll(ctx, dst, descs[i]) if err != nil { t.Errorf("content[%d] error = %v, wantErr %v", i, err, false) continue } if want := blobs[i]; !bytes.Equal(got, want) { t.Errorf("content[%d] = %v, want %v", i, got, want) } } for _, i := range uncopiedIndice { if _, err := content.FetchAll(ctx, dst, descs[i]); !errors.Is(err, errdef.ErrNotFound) { t.Errorf("content[%d] error = %v, wantErr %v", i, err, errdef.ErrNotFound) } } } src, err := remote.NewRepository(uri.Host + "/test") if err != nil { t.Errorf("NewRepository() error = %v", err) } // test extended copy by descs[0] with two annotation filters dst := memory.New() opts := oras.ExtendedCopyGraphOptions{} exp1 := "black." exp2 := ".pink|red" regex1 := regexp.MustCompile(exp1) regex2 := regexp.MustCompile(exp2) opts.FilterAnnotation("bar", regex1) opts.FilterAnnotation("bar", regex2) if err := oras.ExtendedCopyGraph(ctx, src, dst, descs[0], opts); err != nil { t.Fatalf("ExtendedCopyGraph() error = %v, wantErr %v", err, false) } copiedIndice := []int{0, 2} uncopiedIndice := []int{1, 3, 4, 5, 6} verifyCopy(dst, copiedIndice, uncopiedIndice) // test extended copy by descs[0] with three annotation filters, nil included dst = memory.New() opts = oras.ExtendedCopyGraphOptions{} exp1 = "black." exp2 = ".pink|red" regex1 = regexp.MustCompile(exp1) regex2 = regexp.MustCompile(exp2) opts.FilterAnnotation("bar", regex1) opts.FilterAnnotation("bar", nil) opts.FilterAnnotation("bar", regex2) if err := oras.ExtendedCopyGraph(ctx, src, dst, descs[0], opts); err != nil { t.Fatalf("ExtendedCopyGraph() error = %v, wantErr %v", err, false) } copiedIndice = []int{0, 2} uncopiedIndice = []int{1, 3, 4, 5, 6} verifyCopy(dst, copiedIndice, uncopiedIndice) // test extended copy by descs[0] with two annotation filters, the second filter has an unavailable key dst = memory.New() opts = oras.ExtendedCopyGraphOptions{} exp1 = "black." exp2 = ".pink|red" regex1 = regexp.MustCompile(exp1) regex2 = regexp.MustCompile(exp2) opts.FilterAnnotation("bar", regex1) opts.FilterAnnotation("test", regex2) if err := oras.ExtendedCopyGraph(ctx, src, dst, descs[0], opts); err != nil { t.Fatalf("ExtendedCopyGraph() error = %v, wantErr %v", err, false) } copiedIndice = []int{0} uncopiedIndice = []int{1, 2, 3, 4, 5, 6} verifyCopy(dst, copiedIndice, uncopiedIndice) } func TestExtendedCopyGraph_FilterArtifactTypeWithRegex(t *testing.T) { // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateArtifactManifest := func(subject ocispec.Descriptor, artifactType string) { manifest := spec.Artifact{ MediaType: spec.MediaTypeArtifactManifest, ArtifactType: artifactType, Subject: &subject, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(spec.MediaTypeArtifactManifest, manifestJSON) } appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // descs[0] generateArtifactManifest(descs[0], "good-bar-yellow") // descs[1] generateArtifactManifest(descs[0], "bad-woo-red") // descs[2] generateArtifactManifest(descs[0], "bad-bar-blue") // descs[3] generateArtifactManifest(descs[0], "bad-bar-red") // descs[4] generateArtifactManifest(descs[0], "good-woo-pink") // descs[5] ctx := context.Background() verifyCopy := func(dst content.Fetcher, copiedIndice []int, uncopiedIndice []int) { for _, i := range copiedIndice { got, err := content.FetchAll(ctx, dst, descs[i]) if err != nil { t.Errorf("content[%d] error = %v, wantErr %v", i, err, false) continue } if want := blobs[i]; !bytes.Equal(got, want) { t.Errorf("content[%d] = %v, want %v", i, got, want) } } for _, i := range uncopiedIndice { if _, err := content.FetchAll(ctx, dst, descs[i]); !errors.Is(err, errdef.ErrNotFound) { t.Errorf("content[%d] error = %v, wantErr %v", i, err, errdef.ErrNotFound) } } } src := memory.New() for i := range blobs { err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Errorf("failed to push test content to src: %d: %v", i, err) } } // test extended copy by descs[0], include the predecessors whose artifact // type matches exp. exp := ".bar." dst := memory.New() opts := oras.ExtendedCopyGraphOptions{} regex := regexp.MustCompile(exp) opts.FilterArtifactType(regex) if err := oras.ExtendedCopyGraph(ctx, src, dst, descs[0], opts); err != nil { t.Errorf("ExtendedCopyGraph() error = %v, wantErr %v", err, false) } copiedIndice := []int{0, 1, 3, 4} uncopiedIndice := []int{2, 5} verifyCopy(dst, copiedIndice, uncopiedIndice) // test extended copy by descs[0] with no regex // type matches exp. opts = oras.ExtendedCopyGraphOptions{} opts.FilterArtifactType(nil) if opts.FindPredecessors != nil { t.Fatal("FindPredecessors not nil!") } } func TestExtendedCopyGraph_FilterArtifactTypeWithMultipleRegex(t *testing.T) { // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateArtifactManifest := func(subject ocispec.Descriptor, artifactType string) { manifest := spec.Artifact{ MediaType: spec.MediaTypeArtifactManifest, ArtifactType: artifactType, Subject: &subject, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(spec.MediaTypeArtifactManifest, manifestJSON) } appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // descs[0] generateArtifactManifest(descs[0], "good-bar-yellow") // descs[1] generateArtifactManifest(descs[0], "bad-woo-red") // descs[2] generateArtifactManifest(descs[0], "bad-bar-blue") // descs[3] generateArtifactManifest(descs[0], "bad-bar-red") // descs[4] generateArtifactManifest(descs[0], "good-woo-pink") // descs[5] ctx := context.Background() verifyCopy := func(dst content.Fetcher, copiedIndice []int, uncopiedIndice []int) { for _, i := range copiedIndice { got, err := content.FetchAll(ctx, dst, descs[i]) if err != nil { t.Errorf("content[%d] error = %v, wantErr %v", i, err, false) continue } if want := blobs[i]; !bytes.Equal(got, want) { t.Errorf("content[%d] = %v, want %v", i, got, want) } } for _, i := range uncopiedIndice { if _, err := dst.Fetch(ctx, descs[i]); !errors.Is(err, errdef.ErrNotFound) { t.Errorf("content[%d] error = %v, wantErr %v", i, err, errdef.ErrNotFound) } } } src := memory.New() for i := range blobs { err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Errorf("failed to push test content to src: %d: %v", i, err) } } // test extended copy by descs[0], include the predecessors whose artifact // type matches exp1 and exp2. exp1 := ".foo|bar." exp2 := "bad." dst := memory.New() opts := oras.ExtendedCopyGraphOptions{} regex1 := regexp.MustCompile(exp1) regex2 := regexp.MustCompile(exp2) opts.FilterArtifactType(regex1) opts.FilterArtifactType(regex2) if err := oras.ExtendedCopyGraph(ctx, src, dst, descs[0], opts); err != nil { t.Errorf("ExtendedCopyGraph() error = %v, wantErr %v", err, false) } copiedIndice := []int{0, 3, 4} uncopiedIndice := []int{1, 2, 5} verifyCopy(dst, copiedIndice, uncopiedIndice) // test extended copy by descs[0], include the predecessors whose artifact // type matches exp1 and exp2 and nil exp1 = ".foo|bar." exp2 = "bad." dst = memory.New() opts = oras.ExtendedCopyGraphOptions{} regex1 = regexp.MustCompile(exp1) regex2 = regexp.MustCompile(exp2) opts.FilterArtifactType(regex1) opts.FilterArtifactType(regex2) opts.FilterArtifactType(nil) if err := oras.ExtendedCopyGraph(ctx, src, dst, descs[0], opts); err != nil { t.Errorf("ExtendedCopyGraph() error = %v, wantErr %v", err, false) } copiedIndice = []int{0, 3, 4} uncopiedIndice = []int{1, 2, 5} verifyCopy(dst, copiedIndice, uncopiedIndice) } func TestExtendedCopyGraph_FilterArtifactTypeWithRegex_ArtifactTypeInDescriptor(t *testing.T) { // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, artifactType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, ArtifactType: artifactType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateArtifactManifest := func(subject ocispec.Descriptor, artifactType string) { manifest := spec.Artifact{ MediaType: spec.MediaTypeArtifactManifest, ArtifactType: artifactType, Subject: &subject, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(spec.MediaTypeArtifactManifest, artifactType, manifestJSON) } appendBlob(ocispec.MediaTypeImageLayer, "", []byte("foo")) // descs[0] generateArtifactManifest(descs[0], "good-bar-yellow") // descs[1] generateArtifactManifest(descs[0], "bad-woo-red") // descs[2] generateArtifactManifest(descs[0], "bad-bar-blue") // descs[3] generateArtifactManifest(descs[0], "bad-bar-red") // descs[4] generateArtifactManifest(descs[0], "good-woo-pink") // descs[5] ctx := context.Background() verifyCopy := func(dst content.Fetcher, copiedIndice []int, uncopiedIndice []int) { for _, i := range copiedIndice { got, err := content.FetchAll(ctx, dst, descs[i]) if err != nil { t.Errorf("content[%d] error = %v, wantErr %v", i, err, false) continue } if want := blobs[i]; !bytes.Equal(got, want) { t.Errorf("content[%d] = %v, want %v", i, got, want) } } for _, i := range uncopiedIndice { if _, err := content.FetchAll(ctx, dst, descs[i]); !errors.Is(err, errdef.ErrNotFound) { t.Errorf("content[%d] error = %v, wantErr %v", i, err, errdef.ErrNotFound) } } } src := memory.New() for i := range blobs { err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Errorf("failed to push test content to src: %d: %v", i, err) } } // test extended copy by descs[0], include the predecessors whose artifact // type matches exp. exp := ".bar." dst := memory.New() opts := oras.ExtendedCopyGraphOptions{} regex := regexp.MustCompile(exp) opts.FilterArtifactType(regex) if err := oras.ExtendedCopyGraph(ctx, src, dst, descs[0], opts); err != nil { t.Errorf("ExtendedCopyGraph() error = %v, wantErr %v", err, false) } copiedIndice := []int{0, 1, 3, 4} uncopiedIndice := []int{2, 5} verifyCopy(dst, copiedIndice, uncopiedIndice) // test extended copy by descs[0] with no regex // type matches exp. opts = oras.ExtendedCopyGraphOptions{} opts.FilterArtifactType(nil) if opts.FindPredecessors != nil { t.Fatal("FindPredecessors not nil!") } } func TestExtendedCopyGraph_FilterArtifactTypeWithMultipleRegex_Referrers(t *testing.T) { // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, artifactType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, ArtifactType: artifactType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateArtifactManifest := func(subject ocispec.Descriptor, artifactType string) { manifest := spec.Artifact{ MediaType: spec.MediaTypeArtifactManifest, ArtifactType: artifactType, Subject: &subject, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(spec.MediaTypeArtifactManifest, artifactType, manifestJSON) } appendBlob(ocispec.MediaTypeImageLayer, "", []byte("foo")) // descs[0] generateArtifactManifest(descs[0], "good-bar-yellow") // descs[1] generateArtifactManifest(descs[0], "bad-woo-red") // descs[2] generateArtifactManifest(descs[0], "bad-bar-blue") // descs[3] generateArtifactManifest(descs[0], "bad-bar-red") // descs[4] generateArtifactManifest(descs[0], "good-woo-pink") // descs[5] // set up test server ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { p := r.URL.Path var manifests []ocispec.Descriptor switch { case p == "/v2/test/referrers/"+descs[0].Digest.String(): manifests = descs[1:] fallthrough case strings.HasPrefix(p, "/v2/test/referrers/"): result := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageIndex, Manifests: manifests, } w.Header().Set("Content-Type", ocispec.MediaTypeImageIndex) if err := json.NewEncoder(w).Encode(result); err != nil { t.Errorf("failed to write response: %v", err) } case strings.Contains(p, descs[0].Digest.String()): w.Header().Set("Content-Type", ocispec.MediaTypeImageLayer) w.Header().Set("Content-Digest", descs[0].Digest.String()) w.Header().Set("Content-Length", strconv.Itoa(len(blobs[0]))) w.Write(blobs[0]) case strings.Contains(p, descs[1].Digest.String()): w.Header().Set("Content-Type", spec.MediaTypeArtifactManifest) w.Header().Set("Content-Digest", descs[1].Digest.String()) w.Header().Set("Content-Length", strconv.Itoa(len(blobs[1]))) w.Write(blobs[1]) case strings.Contains(p, descs[2].Digest.String()): w.Header().Set("Content-Type", spec.MediaTypeArtifactManifest) w.Header().Set("Content-Digest", descs[2].Digest.String()) w.Header().Set("Content-Length", strconv.Itoa(len(blobs[2]))) w.Write(blobs[2]) case strings.Contains(p, descs[3].Digest.String()): w.Header().Set("Content-Type", spec.MediaTypeArtifactManifest) w.Header().Set("Content-Digest", descs[3].Digest.String()) w.Header().Set("Content-Length", strconv.Itoa(len(blobs[3]))) w.Write(blobs[3]) case strings.Contains(p, descs[4].Digest.String()): w.Header().Set("Content-Type", spec.MediaTypeArtifactManifest) w.Header().Set("Content-Digest", descs[4].Digest.String()) w.Header().Set("Content-Length", strconv.Itoa(len(blobs[4]))) w.Write(blobs[4]) case strings.Contains(p, descs[5].Digest.String()): w.Header().Set("Content-Type", spec.MediaTypeArtifactManifest) w.Header().Set("Content-Digest", descs[5].Digest.String()) w.Header().Set("Content-Length", strconv.Itoa(len(blobs[5]))) w.Write(blobs[5]) default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Errorf("invalid test http server: %v", err) } ctx := context.Background() verifyCopy := func(dst content.Fetcher, copiedIndice []int, uncopiedIndice []int) { for _, i := range copiedIndice { got, err := content.FetchAll(ctx, dst, descs[i]) if err != nil { t.Errorf("content[%d] error = %v, wantErr %v", i, err, false) continue } if want := blobs[i]; !bytes.Equal(got, want) { t.Errorf("content[%d] = %v, want %v", i, got, want) } } for _, i := range uncopiedIndice { if _, err := content.FetchAll(ctx, dst, descs[i]); !errors.Is(err, errdef.ErrNotFound) { t.Errorf("content[%d] error = %v, wantErr %v", i, err, errdef.ErrNotFound) } } } src, err := remote.NewRepository(uri.Host + "/test") if err != nil { t.Errorf("NewRepository() error = %v", err) } // test extended copy by descs[0], include the predecessors whose artifact // type matches exp1 and exp2. exp1 := ".foo|bar." exp2 := "bad." dst := memory.New() opts := oras.ExtendedCopyGraphOptions{} regex1 := regexp.MustCompile(exp1) regex2 := regexp.MustCompile(exp2) opts.FilterArtifactType(regex1) opts.FilterArtifactType(regex2) if err := oras.ExtendedCopyGraph(ctx, src, dst, descs[0], opts); err != nil { t.Errorf("ExtendedCopyGraph() error = %v, wantErr %v", err, false) } copiedIndice := []int{0, 3, 4} uncopiedIndice := []int{1, 2, 5} verifyCopy(dst, copiedIndice, uncopiedIndice) } func TestExtendedCopyGraph_FilterArtifactTypeAndAnnotationWithMultipleRegex(t *testing.T) { // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateArtifactManifest := func(subject ocispec.Descriptor, artifactType string, value string) { manifest := spec.Artifact{ MediaType: spec.MediaTypeArtifactManifest, ArtifactType: artifactType, Subject: &subject, Annotations: map[string]string{"rank": value}, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(spec.MediaTypeArtifactManifest, manifestJSON) } generateImageManifest := func(subject, config ocispec.Descriptor, value string) { manifest := ocispec.Manifest{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageManifest, Config: config, Subject: &subject, Annotations: map[string]string{"rank": value}, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // descs[0] generateArtifactManifest(descs[0], "good-bar-yellow", "1st") // descs[1] generateArtifactManifest(descs[0], "bad-woo-red", "1st") // descs[2] generateArtifactManifest(descs[0], "bad-bar-blue", "2nd") // descs[3] generateArtifactManifest(descs[0], "bad-bar-red", "3rd") // descs[4] appendBlob("good-woo-pink", []byte("bar")) // descs[5] generateImageManifest(descs[0], descs[5], "3rd") // descs[6] appendBlob("bad-bar-pink", []byte("baz")) // descs[7] generateImageManifest(descs[0], descs[7], "4th") // descs[8] appendBlob("bad-bar-orange", []byte("config!")) // descs[9] generateImageManifest(descs[0], descs[9], "5th") // descs[10] ctx := context.Background() verifyCopy := func(dst content.Fetcher, copiedIndice []int, uncopiedIndice []int) { for _, i := range copiedIndice { got, err := content.FetchAll(ctx, dst, descs[i]) if err != nil { t.Errorf("content[%d] error = %v, wantErr %v", i, err, false) continue } if want := blobs[i]; !bytes.Equal(got, want) { t.Errorf("content[%d] = %v, want %v", i, got, want) } } for _, i := range uncopiedIndice { if _, err := dst.Fetch(ctx, descs[i]); !errors.Is(err, errdef.ErrNotFound) { t.Errorf("content[%d] error = %v, wantErr %v", i, err, errdef.ErrNotFound) } } } src := memory.New() for i := range blobs { err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Errorf("failed to push test content to src: %d: %v", i, err) } } // test extended copy by descs[0], include the predecessors whose artifact // type and annotation match the regular expressions. typeExp1 := ".foo|bar." typeExp2 := "bad." annotationExp1 := "[1-4]." annotationExp2 := "2|4." dst := memory.New() opts := oras.ExtendedCopyGraphOptions{} typeRegex1 := regexp.MustCompile(typeExp1) typeRegex2 := regexp.MustCompile(typeExp2) annotationRegex1 := regexp.MustCompile(annotationExp1) annotationRegex2 := regexp.MustCompile(annotationExp2) opts.FilterAnnotation("rank", annotationRegex1) opts.FilterArtifactType(typeRegex1) opts.FilterAnnotation("rank", annotationRegex2) opts.FilterArtifactType(typeRegex2) if err := oras.ExtendedCopyGraph(ctx, src, dst, descs[0], opts); err != nil { t.Errorf("ExtendedCopyGraph() error = %v, wantErr %v", err, false) } copiedIndice := []int{0, 3, 7, 8} uncopiedIndice := []int{1, 2, 4, 5, 6, 9, 10} verifyCopy(dst, copiedIndice, uncopiedIndice) } func TestExtendedCopyGraph_FilterArtifactTypeAndAnnotationWithMultipleRegex_Referrers(t *testing.T) { // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, artifactType string, blob []byte, value string) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, ArtifactType: artifactType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), Annotations: map[string]string{"rank": value}, }) } generateArtifactManifest := func(subject ocispec.Descriptor, artifactType string, value string) { manifest := spec.Artifact{ MediaType: spec.MediaTypeArtifactManifest, ArtifactType: artifactType, Subject: &subject, Annotations: map[string]string{"rank": value}, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(spec.MediaTypeArtifactManifest, artifactType, manifestJSON, value) } appendBlob(ocispec.MediaTypeImageLayer, "", []byte("foo"), "na") // descs[0] generateArtifactManifest(descs[0], "good-bar-yellow", "1st") // descs[1] generateArtifactManifest(descs[0], "bad-woo-red", "1st") // descs[2] generateArtifactManifest(descs[0], "bad-bar-blue", "2nd") // descs[3] generateArtifactManifest(descs[0], "bad-bar-red", "3rd") // descs[4] generateArtifactManifest(descs[0], "good-woo-pink", "2nd") // descs[5] generateArtifactManifest(descs[0], "good-foo-blue", "3rd") // descs[6] generateArtifactManifest(descs[0], "bad-bar-orange", "4th") // descs[7] generateArtifactManifest(descs[0], "bad-woo-white", "4th") // descs[8] generateArtifactManifest(descs[0], "good-woo-orange", "na") // descs[9] // set up test server ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { p := r.URL.Path var manifests []ocispec.Descriptor switch { case p == "/v2/test/referrers/"+descs[0].Digest.String(): manifests = descs[1:] fallthrough case strings.HasPrefix(p, "/v2/test/referrers/"): result := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageIndex, Manifests: manifests, } w.Header().Set("Content-Type", ocispec.MediaTypeImageIndex) if err := json.NewEncoder(w).Encode(result); err != nil { t.Errorf("failed to write response: %v", err) } case strings.Contains(p, descs[0].Digest.String()): w.Header().Set("Content-Type", ocispec.MediaTypeImageLayer) w.Header().Set("Content-Digest", descs[0].Digest.String()) w.Header().Set("Content-Length", strconv.Itoa(len(blobs[0]))) w.Write(blobs[0]) case strings.Contains(p, descs[1].Digest.String()): w.Header().Set("Content-Type", spec.MediaTypeArtifactManifest) w.Header().Set("Content-Digest", descs[1].Digest.String()) w.Header().Set("Content-Length", strconv.Itoa(len(blobs[1]))) w.Write(blobs[1]) case strings.Contains(p, descs[2].Digest.String()): w.Header().Set("Content-Type", spec.MediaTypeArtifactManifest) w.Header().Set("Content-Digest", descs[2].Digest.String()) w.Header().Set("Content-Length", strconv.Itoa(len(blobs[2]))) w.Write(blobs[2]) case strings.Contains(p, descs[3].Digest.String()): w.Header().Set("Content-Type", spec.MediaTypeArtifactManifest) w.Header().Set("Content-Digest", descs[3].Digest.String()) w.Header().Set("Content-Length", strconv.Itoa(len(blobs[3]))) w.Write(blobs[3]) case strings.Contains(p, descs[4].Digest.String()): w.Header().Set("Content-Type", spec.MediaTypeArtifactManifest) w.Header().Set("Content-Digest", descs[4].Digest.String()) w.Header().Set("Content-Length", strconv.Itoa(len(blobs[4]))) w.Write(blobs[4]) case strings.Contains(p, descs[5].Digest.String()): w.Header().Set("Content-Type", spec.MediaTypeArtifactManifest) w.Header().Set("Content-Digest", descs[5].Digest.String()) w.Header().Set("Content-Length", strconv.Itoa(len(blobs[5]))) w.Write(blobs[5]) case strings.Contains(p, descs[6].Digest.String()): w.Header().Set("Content-Type", spec.MediaTypeArtifactManifest) w.Header().Set("Content-Digest", descs[6].Digest.String()) w.Header().Set("Content-Length", strconv.Itoa(len(blobs[6]))) w.Write(blobs[6]) case strings.Contains(p, descs[7].Digest.String()): w.Header().Set("Content-Type", spec.MediaTypeArtifactManifest) w.Header().Set("Content-Digest", descs[7].Digest.String()) w.Header().Set("Content-Length", strconv.Itoa(len(blobs[7]))) w.Write(blobs[7]) case strings.Contains(p, descs[8].Digest.String()): w.Header().Set("Content-Type", spec.MediaTypeArtifactManifest) w.Header().Set("Content-Digest", descs[8].Digest.String()) w.Header().Set("Content-Length", strconv.Itoa(len(blobs[8]))) w.Write(blobs[8]) case strings.Contains(p, descs[9].Digest.String()): w.Header().Set("Content-Type", spec.MediaTypeArtifactManifest) w.Header().Set("Content-Digest", descs[9].Digest.String()) w.Header().Set("Content-Length", strconv.Itoa(len(blobs[9]))) w.Write(blobs[9]) default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Errorf("invalid test http server: %v", err) } ctx := context.Background() verifyCopy := func(dst content.Fetcher, copiedIndice []int, uncopiedIndice []int) { for _, i := range copiedIndice { got, err := content.FetchAll(ctx, dst, descs[i]) if err != nil { t.Errorf("content[%d] error = %v, wantErr %v", i, err, false) continue } if want := blobs[i]; !bytes.Equal(got, want) { t.Errorf("content[%d] = %v, want %v", i, got, want) } } for _, i := range uncopiedIndice { if _, err := content.FetchAll(ctx, dst, descs[i]); !errors.Is(err, errdef.ErrNotFound) { t.Errorf("content[%d] error = %v, wantErr %v", i, err, errdef.ErrNotFound) } } } src, err := remote.NewRepository(uri.Host + "/test") if err != nil { t.Errorf("NewRepository() error = %v", err) } // test extended copy by descs[0], include the predecessors whose artifact // type and annotation match the regular expressions. typeExp1 := ".foo|bar." typeExp2 := "bad." annotationExp1 := "[1-4]." annotationExp2 := "2|4." dst := memory.New() opts := oras.ExtendedCopyGraphOptions{} typeRegex1 := regexp.MustCompile(typeExp1) typeRegex2 := regexp.MustCompile(typeExp2) annotationRegex1 := regexp.MustCompile(annotationExp1) annotationRegex2 := regexp.MustCompile(annotationExp2) opts.FilterAnnotation("rank", annotationRegex1) opts.FilterArtifactType(typeRegex1) opts.FilterAnnotation("rank", annotationRegex2) opts.FilterArtifactType(typeRegex2) if err := oras.ExtendedCopyGraph(ctx, src, dst, descs[0], opts); err != nil { t.Errorf("ExtendedCopyGraph() error = %v, wantErr %v", err, false) } copiedIndice := []int{0, 3, 7} uncopiedIndice := []int{1, 2, 4, 5, 6, 8, 9} verifyCopy(dst, copiedIndice, uncopiedIndice) } oras-go-2.5.0/go.mod000066400000000000000000000002451457674530300142120ustar00rootroot00000000000000module oras.land/oras-go/v2 go 1.21 require ( github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.0 golang.org/x/sync v0.6.0 ) oras-go-2.5.0/go.sum000066400000000000000000000010251457674530300142340ustar00rootroot00000000000000github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= oras-go-2.5.0/internal/000077500000000000000000000000001457674530300147175ustar00rootroot00000000000000oras-go-2.5.0/internal/cas/000077500000000000000000000000001457674530300154655ustar00rootroot00000000000000oras-go-2.5.0/internal/cas/memory.go000066400000000000000000000053411457674530300173270ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package cas import ( "bytes" "context" "fmt" "io" "sync" ocispec "github.com/opencontainers/image-spec/specs-go/v1" contentpkg "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/errdef" "oras.land/oras-go/v2/internal/descriptor" ) // Memory is a memory based CAS. type Memory struct { content sync.Map // map[descriptor.Descriptor][]byte } // NewMemory creates a new Memory CAS. func NewMemory() *Memory { return &Memory{} } // Fetch fetches the content identified by the descriptor. func (m *Memory) Fetch(_ context.Context, target ocispec.Descriptor) (io.ReadCloser, error) { key := descriptor.FromOCI(target) content, exists := m.content.Load(key) if !exists { return nil, fmt.Errorf("%s: %s: %w", key.Digest, key.MediaType, errdef.ErrNotFound) } return io.NopCloser(bytes.NewReader(content.([]byte))), nil } // Push pushes the content, matching the expected descriptor. func (m *Memory) Push(_ context.Context, expected ocispec.Descriptor, content io.Reader) error { key := descriptor.FromOCI(expected) // check if the content exists in advance to avoid reading from the content. if _, exists := m.content.Load(key); exists { return fmt.Errorf("%s: %s: %w", key.Digest, key.MediaType, errdef.ErrAlreadyExists) } // read and try to store the content. value, err := contentpkg.ReadAll(content, expected) if err != nil { return err } if _, exists := m.content.LoadOrStore(key, value); exists { return fmt.Errorf("%s: %s: %w", key.Digest, key.MediaType, errdef.ErrAlreadyExists) } return nil } // Exists returns true if the described content exists. func (m *Memory) Exists(_ context.Context, target ocispec.Descriptor) (bool, error) { key := descriptor.FromOCI(target) _, exists := m.content.Load(key) return exists, nil } // Map dumps the memory into a built-in map structure. // Like other operations, calling Map() is go-routine safe. However, it does not // necessarily correspond to any consistent snapshot of the storage contents. func (m *Memory) Map() map[descriptor.Descriptor][]byte { res := make(map[descriptor.Descriptor][]byte) m.content.Range(func(key, value interface{}) bool { res[key.(descriptor.Descriptor)] = value.([]byte) return true }) return res } oras-go-2.5.0/internal/cas/memory_test.go000066400000000000000000000063401457674530300203660ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package cas import ( "bytes" "context" _ "crypto/sha256" "errors" "io" "strings" "testing" "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/errdef" ) func TestMemorySuccess(t *testing.T) { content := []byte("hello world") desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), } s := NewMemory() ctx := context.Background() err := s.Push(ctx, desc, bytes.NewReader(content)) if err != nil { t.Fatal("Memory.Push() error =", err) } exists, err := s.Exists(ctx, desc) if err != nil { t.Fatal("Memory.Exists() error =", err) } if !exists { t.Errorf("Memory.Exists() = %v, want %v", exists, true) } rc, err := s.Fetch(ctx, desc) if err != nil { t.Fatal("Memory.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Memory.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Memory.Fetch().Close() error =", err) } if !bytes.Equal(got, content) { t.Errorf("Memory.Fetch() = %v, want %v", got, content) } if got := len(s.Map()); got != 1 { t.Errorf("Memory.Map() = %v, want %v", got, 1) } } func TestMemoryNotFound(t *testing.T) { content := []byte("hello world") desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), } s := NewMemory() ctx := context.Background() exists, err := s.Exists(ctx, desc) if err != nil { t.Error("Memory.Exists() error =", err) } if exists { t.Errorf("Memory.Exists() = %v, want %v", exists, false) } _, err = s.Fetch(ctx, desc) if !errors.Is(err, errdef.ErrNotFound) { t.Errorf("Memory.Fetch() error = %v, want %v", err, errdef.ErrNotFound) } } func TestMemoryAlreadyExists(t *testing.T) { content := []byte("hello world") desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), } s := NewMemory() ctx := context.Background() err := s.Push(ctx, desc, bytes.NewReader(content)) if err != nil { t.Fatal("Memory.Push() error =", err) } err = s.Push(ctx, desc, bytes.NewReader(content)) if !errors.Is(err, errdef.ErrAlreadyExists) { t.Errorf("Memory.Push() error = %v, want %v", err, errdef.ErrAlreadyExists) } } func TestMemoryBadPush(t *testing.T) { content := []byte("hello world") desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), } s := NewMemory() ctx := context.Background() err := s.Push(ctx, desc, strings.NewReader("foobar")) if err == nil { t.Errorf("Memory.Push() error = %v, wantErr %v", err, true) } } oras-go-2.5.0/internal/cas/proxy.go000066400000000000000000000062771457674530300172110ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package cas import ( "context" "io" "sync" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/internal/ioutil" ) // Proxy is a caching proxy for the storage. // The first fetch call of a described content will read from the remote and // cache the fetched content. // The subsequent fetch call will read from the local cache. type Proxy struct { content.ReadOnlyStorage Cache content.Storage StopCaching bool } // NewProxy creates a proxy for the `base` storage, using the `cache` storage as // the cache. func NewProxy(base content.ReadOnlyStorage, cache content.Storage) *Proxy { return &Proxy{ ReadOnlyStorage: base, Cache: cache, } } // NewProxyWithLimit creates a proxy for the `base` storage, using the `cache` // storage with a push size limit as the cache. func NewProxyWithLimit(base content.ReadOnlyStorage, cache content.Storage, pushLimit int64) *Proxy { limitedCache := content.LimitStorage(cache, pushLimit) return &Proxy{ ReadOnlyStorage: base, Cache: limitedCache, } } // Fetch fetches the content identified by the descriptor. func (p *Proxy) Fetch(ctx context.Context, target ocispec.Descriptor) (io.ReadCloser, error) { if p.StopCaching { return p.FetchCached(ctx, target) } rc, err := p.Cache.Fetch(ctx, target) if err == nil { return rc, nil } rc, err = p.ReadOnlyStorage.Fetch(ctx, target) if err != nil { return nil, err } pr, pw := io.Pipe() var wg sync.WaitGroup wg.Add(1) var pushErr error go func() { defer wg.Done() pushErr = p.Cache.Push(ctx, target, pr) if pushErr != nil { pr.CloseWithError(pushErr) } }() closer := ioutil.CloserFunc(func() error { rcErr := rc.Close() if err := pw.Close(); err != nil { return err } wg.Wait() if pushErr != nil { return pushErr } return rcErr }) return struct { io.Reader io.Closer }{ Reader: io.TeeReader(rc, pw), Closer: closer, }, nil } // FetchCached fetches the content identified by the descriptor. // If the content is not cached, it will be fetched from the remote without // caching. func (p *Proxy) FetchCached(ctx context.Context, target ocispec.Descriptor) (io.ReadCloser, error) { exists, err := p.Cache.Exists(ctx, target) if err != nil { return nil, err } if exists { return p.Cache.Fetch(ctx, target) } return p.ReadOnlyStorage.Fetch(ctx, target) } // Exists returns true if the described content exists. func (p *Proxy) Exists(ctx context.Context, target ocispec.Descriptor) (bool, error) { exists, err := p.Cache.Exists(ctx, target) if err == nil && exists { return true, nil } return p.ReadOnlyStorage.Exists(ctx, target) } oras-go-2.5.0/internal/cas/proxy_test.go000066400000000000000000000216141457674530300202400ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package cas import ( "bytes" "context" _ "crypto/sha256" "errors" "io" "testing" "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/errdef" ) func TestProxyCache(t *testing.T) { content := []byte("hello world") desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), } ctx := context.Background() base := NewMemory() err := base.Push(ctx, desc, bytes.NewReader(content)) if err != nil { t.Fatal("Memory.Push() error =", err) } s := NewProxy(base, NewMemory()) // first fetch exists, err := s.Exists(ctx, desc) if err != nil { t.Fatal("Proxy.Exists() error =", err) } if !exists { t.Errorf("Proxy.Exists() = %v, want %v", exists, true) } rc, err := s.Fetch(ctx, desc) if err != nil { t.Fatal("Proxy.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Proxy.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Proxy.Fetch().Close() error =", err) } if !bytes.Equal(got, content) { t.Errorf("Proxy.Fetch() = %v, want %v", got, content) } // repeated fetch should not touch base CAS // nil base will generate panic if the base CAS is touched s.ReadOnlyStorage = nil exists, err = s.Exists(ctx, desc) if err != nil { t.Fatal("Proxy.Exists() error =", err) } if !exists { t.Errorf("Proxy.Exists() = %v, want %v", exists, true) } rc, err = s.Fetch(ctx, desc) if err != nil { t.Fatal("Proxy.Fetch() error =", err) } got, err = io.ReadAll(rc) if err != nil { t.Fatal("Proxy.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Proxy.Fetch().Close() error =", err) } if !bytes.Equal(got, content) { t.Errorf("Proxy.Fetch() = %v, want %v", got, content) } } func TestProxy_FetchCached_NotCachedContent(t *testing.T) { content := []byte("hello world") desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), } ctx := context.Background() base := NewMemory() err := base.Push(ctx, desc, bytes.NewReader(content)) if err != nil { t.Fatal("Memory.Push() error =", err) } s := NewProxy(base, NewMemory()) // FetchCached should fetch from the base CAS exists, err := s.Exists(ctx, desc) if err != nil { t.Fatal("Proxy.Exists() error =", err) } if !exists { t.Errorf("Proxy.Exists() = %v, want %v", exists, true) } rc, err := s.FetchCached(ctx, desc) if err != nil { t.Fatal("Proxy.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Proxy.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Proxy.Fetch().Close() error =", err) } if !bytes.Equal(got, content) { t.Errorf("Proxy.Fetch() = %v, want %v", got, content) } // the content should not exist in the cache exists, err = s.Cache.Exists(ctx, desc) if err != nil { t.Fatal("Proxy.Cache.Exists() error =", err) } if exists { t.Errorf("Proxy.Cache.Exists()() = %v, want %v", exists, false) } } func TestProxy_FetchCached_CachedContent(t *testing.T) { content := []byte("hello world") desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), } ctx := context.Background() base := NewMemory() err := base.Push(ctx, desc, bytes.NewReader(content)) if err != nil { t.Fatal("Memory.Push() error =", err) } s := NewProxy(base, NewMemory()) // first fetch exists, err := s.Exists(ctx, desc) if err != nil { t.Fatal("Proxy.Exists() error =", err) } if !exists { t.Errorf("Proxy.Exists() = %v, want %v", exists, true) } rc, err := s.Fetch(ctx, desc) if err != nil { t.Fatal("Proxy.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Proxy.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Proxy.Fetch().Close() error =", err) } if !bytes.Equal(got, content) { t.Errorf("Proxy.Fetch() = %v, want %v", got, content) } // the subsequent FetchCached should not touch base CAS // nil base will generate panic if the base CAS is touched s.ReadOnlyStorage = nil exists, err = s.Exists(ctx, desc) if err != nil { t.Fatal("Proxy.Exists() error =", err) } if !exists { t.Errorf("Proxy.Exists() = %v, want %v", exists, true) } rc, err = s.FetchCached(ctx, desc) if err != nil { t.Fatal("Proxy.Fetch() error =", err) } got, err = io.ReadAll(rc) if err != nil { t.Fatal("Proxy.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Proxy.Fetch().Close() error =", err) } if !bytes.Equal(got, content) { t.Errorf("Proxy.Fetch() = %v, want %v", got, content) } } func TestProxy_StopCaching(t *testing.T) { content := []byte("hello world") desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), } ctx := context.Background() base := NewMemory() err := base.Push(ctx, desc, bytes.NewReader(content)) if err != nil { t.Fatal("Memory.Push() error =", err) } s := NewProxy(base, NewMemory()) // FetchCached should fetch from the base CAS exists, err := s.Exists(ctx, desc) if err != nil { t.Fatal("Proxy.Exists() error =", err) } if !exists { t.Errorf("Proxy.Exists() = %v, want %v", exists, true) } // test StopCaching s.StopCaching = true rc, err := s.Fetch(ctx, desc) if err != nil { t.Fatal("Proxy.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Proxy.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Proxy.Fetch().Close() error =", err) } if !bytes.Equal(got, content) { t.Errorf("Proxy.Fetch() = %v, want %v", got, content) } // the content should not exist in the cache exists, err = s.Cache.Exists(ctx, desc) if err != nil { t.Fatal("Proxy.Cache.Exists() error =", err) } if exists { t.Errorf("Proxy.Cache.Exists()() = %v, want %v", exists, false) } } func TestProxyWithLimit_WithinLimit(t *testing.T) { content := []byte("hello world") desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), } ctx := context.Background() base := NewMemory() err := base.Push(ctx, desc, bytes.NewReader(content)) if err != nil { t.Fatal("Memory.Push() error =", err) } s := NewProxyWithLimit(base, NewMemory(), 4*1024*1024) // first fetch exists, err := s.Exists(ctx, desc) if err != nil { t.Fatal("Proxy.Exists() error =", err) } if !exists { t.Errorf("Proxy.Exists() = %v, want %v", exists, true) } rc, err := s.Fetch(ctx, desc) if err != nil { t.Fatal("Proxy.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Proxy.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Proxy.Fetch().Close() error =", err) } if !bytes.Equal(got, content) { t.Errorf("Proxy.Fetch() = %v, want %v", got, content) } // repeated fetch should not touch base CAS // nil base will generate panic if the base CAS is touched s.ReadOnlyStorage = nil exists, err = s.Exists(ctx, desc) if err != nil { t.Fatal("Proxy.Exists() error =", err) } if !exists { t.Errorf("Proxy.Exists() = %v, want %v", exists, true) } rc, err = s.Fetch(ctx, desc) if err != nil { t.Fatal("Proxy.Fetch() error =", err) } got, err = io.ReadAll(rc) if err != nil { t.Fatal("Proxy.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Proxy.Fetch().Close() error =", err) } if !bytes.Equal(got, content) { t.Errorf("Proxy.Fetch() = %v, want %v", got, content) } } func TestProxyWithLimit_ExceedsLimit(t *testing.T) { content := []byte("hello world") desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), } ctx := context.Background() base := NewMemory() err := base.Push(ctx, desc, bytes.NewReader(content)) if err != nil { t.Fatal("Memory.Push() error =", err) } s := NewProxyWithLimit(base, NewMemory(), 1) // test fetch exists, err := s.Exists(ctx, desc) if err != nil { t.Fatal("Proxy.Exists() error =", err) } if !exists { t.Errorf("Proxy.Exists() = %v, want %v", exists, true) } rc, err := s.Fetch(ctx, desc) if err != nil { t.Fatal("Proxy.Fetch() error =", err) } _, err = io.ReadAll(rc) if !errors.Is(err, errdef.ErrSizeExceedsLimit) { t.Fatalf("Proxy.Fetch().Read() error = %v, wantErr %v", err, errdef.ErrSizeExceedsLimit) } } oras-go-2.5.0/internal/container/000077500000000000000000000000001457674530300167015ustar00rootroot00000000000000oras-go-2.5.0/internal/container/set/000077500000000000000000000000001457674530300174745ustar00rootroot00000000000000oras-go-2.5.0/internal/container/set/set.go000066400000000000000000000020251457674530300206150ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package set // Set represents a set data structure. type Set[T comparable] map[T]struct{} // New returns an initialized set. func New[T comparable]() Set[T] { return make(Set[T]) } // Add adds item into the set s. func (s Set[T]) Add(item T) { s[item] = struct{}{} } // Contains returns true if the set s contains item. func (s Set[T]) Contains(item T) bool { _, ok := s[item] return ok } // Delete deletes an item from the set. func (s Set[T]) Delete(item T) { delete(s, item) } oras-go-2.5.0/internal/container/set/set_test.go000066400000000000000000000036561457674530300216670ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package set import "testing" func TestSet(t *testing.T) { set := New[string]() // test checking a non-existing key key1 := "foo" if got, want := set.Contains(key1), false; got != want { t.Errorf("Set.Contains(%s) = %v, want %v", key1, got, want) } if got, want := len(set), 0; got != want { t.Errorf("len(Set) = %v, want %v", got, want) } // test adding a new key set.Add(key1) if got, want := set.Contains(key1), true; got != want { t.Errorf("Set.Contains(%s) = %v, want %v", key1, got, want) } if got, want := len(set), 1; got != want { t.Errorf("len(Set) = %v, want %v", got, want) } // test adding an existing key set.Add(key1) if got, want := set.Contains(key1), true; got != want { t.Errorf("Set.Contains(%s) = %v, want %v", key1, got, want) } if got, want := len(set), 1; got != want { t.Errorf("len(Set) = %v, want %v", got, want) } // test adding another key key2 := "bar" set.Add(key2) if got, want := set.Contains(key2), true; got != want { t.Errorf("Set.Contains(%s) = %v, want %v", key2, got, want) } if got, want := len(set), 2; got != want { t.Errorf("len(Set) = %v, want %v", got, want) } // test deleting a key set.Delete(key1) if got, want := set.Contains(key1), false; got != want { t.Errorf("Set.Contains(%s) = %v, want %v", key1, got, want) } if got, want := len(set), 1; got != want { t.Errorf("len(Set) = %v, want %v", got, want) } } oras-go-2.5.0/internal/copyutil/000077500000000000000000000000001457674530300165675ustar00rootroot00000000000000oras-go-2.5.0/internal/copyutil/stack.go000066400000000000000000000026571457674530300202350ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package copyutil import ( ocispec "github.com/opencontainers/image-spec/specs-go/v1" ) // NodeInfo represents information of a node that is being visited in // ExtendedCopy. type NodeInfo struct { // Node represents a node in the graph. Node ocispec.Descriptor // Depth represents the depth of the node in the graph. Depth int } // Stack represents a stack data structure that is used in ExtendedCopy for // storing node information. type Stack []NodeInfo // IsEmpty returns true if the stack is empty, otherwise returns false. func (s *Stack) IsEmpty() bool { return len(*s) == 0 } // Push pushes an item to the stack. func (s *Stack) Push(i NodeInfo) { *s = append(*s, i) } // Pop pops the top item out of the stack. func (s *Stack) Pop() (NodeInfo, bool) { if s.IsEmpty() { return NodeInfo{}, false } last := len(*s) - 1 top := (*s)[last] *s = (*s)[:last] return top, true } oras-go-2.5.0/internal/copyutil/stack_test.go000066400000000000000000000024511457674530300212640ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package copyutil import ( "reflect" "testing" ocispec "github.com/opencontainers/image-spec/specs-go/v1" ) func TestStack(t *testing.T) { var stack Stack isEmpty := stack.IsEmpty() if !isEmpty { t.Errorf("Stack.IsEmpty() = %v, want %v", isEmpty, true) } items := []NodeInfo{ {ocispec.Descriptor{}, 0}, {ocispec.Descriptor{}, 1}, {ocispec.Descriptor{}, 2}, } for _, item := range items { stack.Push(item) } i := len(items) - 1 for !stack.IsEmpty() { got, ok := stack.Pop() if !ok { t.Fatalf("Stack.Pop() = %v, want %v", ok, true) } if !reflect.DeepEqual(got, items[i]) { t.Errorf("Stack.Pop() = %v, want %v", got, items[i]) } i-- } _, ok := stack.Pop() if ok { t.Errorf("Stack.Pop() = %v, want %v", ok, false) } } oras-go-2.5.0/internal/descriptor/000077500000000000000000000000001457674530300170755ustar00rootroot00000000000000oras-go-2.5.0/internal/descriptor/descriptor.go000066400000000000000000000050751457674530300216110ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package descriptor import ( "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/internal/docker" "oras.land/oras-go/v2/internal/spec" ) // DefaultMediaType is the media type used when no media type is specified. const DefaultMediaType string = "application/octet-stream" // Descriptor contains the minimun information to describe the disposition of // targeted content. // Since it only has strings and integers, Descriptor is a comparable struct. type Descriptor struct { // MediaType is the media type of the object this schema refers to. MediaType string `json:"mediaType,omitempty"` // Digest is the digest of the targeted content. Digest digest.Digest `json:"digest"` // Size specifies the size in bytes of the blob. Size int64 `json:"size"` } // Empty is an empty descriptor var Empty Descriptor // FromOCI shrinks the OCI descriptor to the minimum. func FromOCI(desc ocispec.Descriptor) Descriptor { return Descriptor{ MediaType: desc.MediaType, Digest: desc.Digest, Size: desc.Size, } } // IsForeignLayer checks if a descriptor describes a foreign layer. func IsForeignLayer(desc ocispec.Descriptor) bool { switch desc.MediaType { case ocispec.MediaTypeImageLayerNonDistributable, ocispec.MediaTypeImageLayerNonDistributableGzip, ocispec.MediaTypeImageLayerNonDistributableZstd, docker.MediaTypeForeignLayer: return true default: return false } } // IsManifest checks if a descriptor describes a manifest. func IsManifest(desc ocispec.Descriptor) bool { switch desc.MediaType { case docker.MediaTypeManifest, docker.MediaTypeManifestList, ocispec.MediaTypeImageManifest, ocispec.MediaTypeImageIndex, spec.MediaTypeArtifactManifest: return true default: return false } } // Plain returns a plain descriptor that contains only MediaType, Digest and // Size. func Plain(desc ocispec.Descriptor) ocispec.Descriptor { return ocispec.Descriptor{ MediaType: desc.MediaType, Digest: desc.Digest, Size: desc.Size, } } oras-go-2.5.0/internal/docker/000077500000000000000000000000001457674530300161665ustar00rootroot00000000000000oras-go-2.5.0/internal/docker/mediatype.go000066400000000000000000000016371457674530300205050ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package docker // docker media types const ( MediaTypeConfig = "application/vnd.docker.container.image.v1+json" MediaTypeManifestList = "application/vnd.docker.distribution.manifest.list.v2+json" MediaTypeManifest = "application/vnd.docker.distribution.manifest.v2+json" MediaTypeForeignLayer = "application/vnd.docker.image.rootfs.foreign.diff.tar.gzip" ) oras-go-2.5.0/internal/fs/000077500000000000000000000000001457674530300153275ustar00rootroot00000000000000oras-go-2.5.0/internal/fs/tarfs/000077500000000000000000000000001457674530300164465ustar00rootroot00000000000000oras-go-2.5.0/internal/fs/tarfs/tarfs.go000066400000000000000000000075131457674530300201220ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package tarfs import ( "archive/tar" "errors" "fmt" "io" "io/fs" "os" "path/filepath" "oras.land/oras-go/v2/errdef" ) // blockSize is the size of each block in a tar archive. const blockSize int64 = 512 // TarFS represents a file system (an fs.FS) based on a tar archive. type TarFS struct { path string entries map[string]*entry } // entry represents an entry in a tar archive. type entry struct { header *tar.Header pos int64 } // New returns a file system (an fs.FS) for a tar archive located at path. func New(path string) (*TarFS, error) { pathAbs, err := filepath.Abs(path) if err != nil { return nil, fmt.Errorf("failed to resolve absolute path for %s: %w", path, err) } tarfs := &TarFS{ path: pathAbs, entries: make(map[string]*entry), } if err := tarfs.indexEntries(); err != nil { return nil, err } return tarfs, nil } // Open opens the named file. // When Open returns an error, it should be of type *PathError // with the Op field set to "open", the Path field set to name, // and the Err field describing the problem. // // Open should reject attempts to open names that do not satisfy // ValidPath(name), returning a *PathError with Err set to // ErrInvalid or ErrNotExist. func (tfs *TarFS) Open(name string) (file fs.File, openErr error) { entry, err := tfs.getEntry(name) if err != nil { return nil, err } tarFile, err := os.Open(tfs.path) if err != nil { return nil, err } defer func() { if openErr != nil { tarFile.Close() } }() if _, err := tarFile.Seek(entry.pos, io.SeekStart); err != nil { return nil, err } tr := tar.NewReader(tarFile) if _, err := tr.Next(); err != nil { return nil, err } return &entryFile{ Reader: tr, Closer: tarFile, header: entry.header, }, nil } // Stat returns a FileInfo describing the file. // If there is an error, it should be of type *PathError. func (tfs *TarFS) Stat(name string) (fs.FileInfo, error) { entry, err := tfs.getEntry(name) if err != nil { return nil, err } return entry.header.FileInfo(), nil } // getEntry returns the named entry. func (tfs *TarFS) getEntry(name string) (*entry, error) { if !fs.ValidPath(name) { return nil, &fs.PathError{Path: name, Err: fs.ErrInvalid} } entry, ok := tfs.entries[name] if !ok { return nil, &fs.PathError{Path: name, Err: fs.ErrNotExist} } if entry.header.Typeflag != tar.TypeReg { // support regular files only return nil, fmt.Errorf("%s: type flag %c is not supported: %w", name, entry.header.Typeflag, errdef.ErrUnsupported) } return entry, nil } // indexEntries index entries in the tar archive. func (tfs *TarFS) indexEntries() error { tarFile, err := os.Open(tfs.path) if err != nil { return err } defer tarFile.Close() tr := tar.NewReader(tarFile) for { header, err := tr.Next() if err != nil { if errors.Is(err, io.EOF) { break } return err } pos, err := tarFile.Seek(0, io.SeekCurrent) if err != nil { return err } tfs.entries[header.Name] = &entry{ header: header, pos: pos - blockSize, } } return nil } // entryFile represents an entryFile in a tar archive and implements `fs.File`. type entryFile struct { io.Reader io.Closer header *tar.Header } // Stat returns a fs.FileInfo describing e. func (e *entryFile) Stat() (fs.FileInfo, error) { return e.header.FileInfo(), nil } oras-go-2.5.0/internal/fs/tarfs/tarfs_test.go000066400000000000000000000146751457674530300211700ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package tarfs import ( "bytes" "errors" "io" "io/fs" "path/filepath" "testing" "oras.land/oras-go/v2/errdef" ) /* testdata/test.tar contains: foobar foobar_link foobar_symlink dir/ hello subdir/ world */ func TestTarFS_Open_Success(t *testing.T) { testFiles := map[string][]byte{ "foobar": []byte("foobar"), "dir/hello": []byte("hello"), "dir/subdir/world": []byte("world"), } tarPath := "testdata/test.tar" tfs, err := New(tarPath) if err != nil { t.Fatalf("New() error = %v, wantErr %v", err, nil) } tarPathAbs, err := filepath.Abs(tarPath) if err != nil { t.Fatal("error calling filepath.Abs(), error =", err) } if tfs.path != tarPathAbs { t.Fatalf("TarFS.path = %s, want %s", tfs.path, tarPathAbs) } for name, data := range testFiles { f, err := tfs.Open(name) if err != nil { t.Fatalf("TarFS.Open(%s) error = %v, wantErr %v", name, err, nil) continue } got, err := io.ReadAll(f) if err != nil { t.Fatalf("failed to read %s: %v", name, err) } if err = f.Close(); err != nil { t.Errorf("TarFS.Open(%s).Close() error = %v", name, err) } if want := data; !bytes.Equal(got, want) { t.Errorf("TarFS.Open(%s) = %v, want %v", name, string(got), string(want)) } } } func TestTarFS_Open_MoreThanOnce(t *testing.T) { tfs, err := New("testdata/test.tar") if err != nil { t.Fatalf("New() error = %v, wantErr %v", err, nil) } name := "foobar" data := []byte("foobar") // open once f1, err := tfs.Open(name) if err != nil { t.Fatalf("1st: TarFS.Open(%s) error = %v, wantErr %v", name, err, nil) } got, err := io.ReadAll(f1) if err != nil { t.Fatalf("1st: failed to read %s: %v", name, err) } if want := data; !bytes.Equal(got, want) { t.Errorf("1st: TarFS.Open(%s) = %v, want %v", name, string(got), string(want)) } // open twice f2, err := tfs.Open(name) if err != nil { t.Fatalf("2nd: TarFS.Open(%s) error = %v, wantErr %v", name, err, nil) } got, err = io.ReadAll(f2) if err != nil { t.Fatalf("2nd: failed to read %s: %v", name, err) } if want := data; !bytes.Equal(got, want) { t.Errorf("2nd: TarFS.Open(%s) = %v, want %v", name, string(got), string(want)) } // close if err = f1.Close(); err != nil { t.Errorf("1st TarFS.Open(%s).Close() error = %v", name, err) } if err = f2.Close(); err != nil { t.Errorf("2nd TarFS.Open(%s).Close() error = %v", name, err) } } func TestTarFS_Open_NotExist(t *testing.T) { testFiles := []string{ "dir/foo", "subdir/bar", "barfoo", } tfs, err := New("testdata/test.tar") if err != nil { t.Fatalf("New() error = %v, wantErr %v", err, nil) } for _, name := range testFiles { _, err := tfs.Open(name) if want := fs.ErrNotExist; !errors.Is(err, want) { t.Errorf("TarFS.Open(%s) error = %v, wantErr %v", name, err, want) } } } func TestTarFS_Open_InvalidPath(t *testing.T) { testFiles := []string{ "dir/", "subdir/", "dir/subdir/", } tfs, err := New("testdata/test.tar") if err != nil { t.Fatalf("New() error = %v, wantErr %v", err, nil) } for _, name := range testFiles { _, err := tfs.Open(name) if want := fs.ErrInvalid; !errors.Is(err, want) { t.Errorf("TarFS.Open(%s) error = %v, wantErr %v", name, err, want) } } } func TestTarFS_Open_Unsupported(t *testing.T) { testFiles := []string{ "foobar_link", "foobar_symlink", } tfs, err := New("testdata/test.tar") if err != nil { t.Fatalf("New() error = %v, wantErr %v", err, nil) } for _, name := range testFiles { _, err := tfs.Open(name) if want := errdef.ErrUnsupported; !errors.Is(err, want) { t.Errorf("TarFS.Open(%s) error = %v, wantErr %v", name, err, want) } } } func TestTarFS_Stat(t *testing.T) { tfs, err := New("testdata/test.tar") if err != nil { t.Fatalf("New() error = %v, wantErr %v", err, nil) } name := "foobar" fi, err := tfs.Stat(name) if err != nil { t.Fatal("Stat() error =", err) } if got, want := fi.Name(), "foobar"; got != want { t.Errorf("Stat().want() = %v, want %v", got, want) } if got, want := fi.Size(), int64(6); got != want { t.Errorf("Stat().Size() = %v, want %v", got, want) } name = "dir/hello" fi, err = tfs.Stat(name) if err != nil { t.Fatal("Stat() error =", err) } if got, want := fi.Name(), "hello"; got != want { t.Errorf("Stat().want() = %v, want %v", got, want) } if got, want := fi.Size(), int64(5); got != want { t.Errorf("Stat().Size() = %v, want %v", got, want) } name = "dir/subdir/world" fi, err = tfs.Stat(name) if err != nil { t.Fatal("Stat() error =", err) } if got, want := fi.Name(), "world"; got != want { t.Errorf("Stat().want() = %v, want %v", got, want) } if got, want := fi.Size(), int64(5); got != want { t.Errorf("Stat().Size() = %v, want %v", got, want) } } func TestTarFS_Stat_NotExist(t *testing.T) { testFiles := []string{ "dir/foo", "subdir/bar", "barfoo", } tfs, err := New("testdata/test.tar") if err != nil { t.Fatalf("New() error = %v, wantErr %v", err, nil) } for _, name := range testFiles { _, err := tfs.Stat(name) if want := fs.ErrNotExist; !errors.Is(err, want) { t.Errorf("TarFS.Stat(%s) error = %v, wantErr %v", name, err, want) } } } func TestTarFS_Stat_InvalidPath(t *testing.T) { testFiles := []string{ "dir/", "subdir/", "dir/subdir/", } tfs, err := New("testdata/test.tar") if err != nil { t.Fatalf("New() error = %v, wantErr %v", err, nil) } for _, name := range testFiles { _, err := tfs.Stat(name) if want := fs.ErrInvalid; !errors.Is(err, want) { t.Errorf("TarFS.Stat(%s) error = %v, wantErr %v", name, err, want) } } } func TestTarFS_Stat_Unsupported(t *testing.T) { testFiles := []string{ "foobar_link", "foobar_symlink", } tfs, err := New("testdata/test.tar") if err != nil { t.Fatalf("New() error = %v, wantErr %v", err, nil) } for _, name := range testFiles { _, err := tfs.Stat(name) if want := errdef.ErrUnsupported; !errors.Is(err, want) { t.Errorf("TarFS.Stat(%s) error = %v, wantErr %v", name, err, want) } } } oras-go-2.5.0/internal/fs/tarfs/testdata/000077500000000000000000000000001457674530300202575ustar00rootroot00000000000000oras-go-2.5.0/internal/fs/tarfs/testdata/test.tar000066400000000000000000000240001457674530300217420ustar00rootroot00000000000000dir/0000777000175000017500000000000014343624137011205 5ustar lixleilixleidir/hello0000777000175000017500000000000514343624133012225 0ustar lixleilixleihellodir/subdir/0000777000175000017500000000000014343624150012470 5ustar lixleilixleidir/subdir/world0000777000175000017500000000000514343624153013543 0ustar lixleilixleiworldfoobar0000777000175000017500000000000614343623722011620 0ustar lixleilixleifoobarfoobar_link0000777000175000017500000000000014343623722014021 1foobarustar lixleilixleifoobar_symlink0000777000175000017500000000000014343623753014557 2foobarustar lixleilixleioras-go-2.5.0/internal/graph/000077500000000000000000000000001457674530300160205ustar00rootroot00000000000000oras-go-2.5.0/internal/graph/memory.go000066400000000000000000000150311457674530300176570ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package graph import ( "context" "errors" "sync" "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/errdef" "oras.land/oras-go/v2/internal/container/set" "oras.land/oras-go/v2/internal/descriptor" "oras.land/oras-go/v2/internal/status" "oras.land/oras-go/v2/internal/syncutil" ) // Memory is a memory based PredecessorFinder. type Memory struct { // nodes has the following properties and behaviors: // 1. a node exists in Memory.nodes if and only if it exists in the memory // 2. Memory.nodes saves the ocispec.Descriptor map keys, which are used by // the other fields. nodes map[descriptor.Descriptor]ocispec.Descriptor // predecessors has the following properties and behaviors: // 1. a node exists in Memory.predecessors if it has at least one predecessor // in the memory, regardless of whether or not the node itself exists in // the memory. // 2. a node does not exist in Memory.predecessors, if it doesn't have any predecessors // in the memory. predecessors map[descriptor.Descriptor]set.Set[descriptor.Descriptor] // successors has the following properties and behaviors: // 1. a node exists in Memory.successors if and only if it exists in the memory. // 2. a node's entry in Memory.successors is always consistent with the actual // content of the node, regardless of whether or not each successor exists // in the memory. successors map[descriptor.Descriptor]set.Set[descriptor.Descriptor] lock sync.RWMutex } // NewMemory creates a new memory PredecessorFinder. func NewMemory() *Memory { return &Memory{ nodes: make(map[descriptor.Descriptor]ocispec.Descriptor), predecessors: make(map[descriptor.Descriptor]set.Set[descriptor.Descriptor]), successors: make(map[descriptor.Descriptor]set.Set[descriptor.Descriptor]), } } // Index indexes predecessors for each direct successor of the given node. func (m *Memory) Index(ctx context.Context, fetcher content.Fetcher, node ocispec.Descriptor) error { _, err := m.index(ctx, fetcher, node) return err } // Index indexes predecessors for all the successors of the given node. func (m *Memory) IndexAll(ctx context.Context, fetcher content.Fetcher, node ocispec.Descriptor) error { // track content status tracker := status.NewTracker() var fn syncutil.GoFunc[ocispec.Descriptor] fn = func(ctx context.Context, region *syncutil.LimitedRegion, desc ocispec.Descriptor) error { // skip the node if other go routine is working on it _, committed := tracker.TryCommit(desc) if !committed { return nil } successors, err := m.index(ctx, fetcher, desc) if err != nil { if errors.Is(err, errdef.ErrNotFound) { // skip the node if it does not exist return nil } return err } if len(successors) > 0 { // traverse and index successors return syncutil.Go(ctx, nil, fn, successors...) } return nil } return syncutil.Go(ctx, nil, fn, node) } // Predecessors returns the nodes directly pointing to the current node. // Predecessors returns nil without error if the node does not exists in the // store. Like other operations, calling Predecessors() is go-routine safe. // However, it does not necessarily correspond to any consistent snapshot of // the stored contents. func (m *Memory) Predecessors(_ context.Context, node ocispec.Descriptor) ([]ocispec.Descriptor, error) { m.lock.RLock() defer m.lock.RUnlock() key := descriptor.FromOCI(node) set, exists := m.predecessors[key] if !exists { return nil, nil } var res []ocispec.Descriptor for k := range set { res = append(res, m.nodes[k]) } return res, nil } // Remove removes the node from its predecessors and successors, and returns the // dangling root nodes caused by the deletion. func (m *Memory) Remove(node ocispec.Descriptor) []ocispec.Descriptor { m.lock.Lock() defer m.lock.Unlock() nodeKey := descriptor.FromOCI(node) var danglings []ocispec.Descriptor // remove the node from its successors' predecessor list for successorKey := range m.successors[nodeKey] { predecessorEntry := m.predecessors[successorKey] predecessorEntry.Delete(nodeKey) // if none of the predecessors of the node still exists, we remove the // predecessors entry and return it as a dangling node. Otherwise, we do // not remove the entry. if len(predecessorEntry) == 0 { delete(m.predecessors, successorKey) if _, exists := m.nodes[successorKey]; exists { danglings = append(danglings, m.nodes[successorKey]) } } } delete(m.successors, nodeKey) delete(m.nodes, nodeKey) return danglings } // DigestSet returns the set of node digest in memory. func (m *Memory) DigestSet() set.Set[digest.Digest] { m.lock.RLock() defer m.lock.RUnlock() s := set.New[digest.Digest]() for desc := range m.nodes { s.Add(desc.Digest) } return s } // index indexes predecessors for each direct successor of the given node. func (m *Memory) index(ctx context.Context, fetcher content.Fetcher, node ocispec.Descriptor) ([]ocispec.Descriptor, error) { successors, err := content.Successors(ctx, fetcher, node) if err != nil { return nil, err } m.lock.Lock() defer m.lock.Unlock() // index the node nodeKey := descriptor.FromOCI(node) m.nodes[nodeKey] = node // for each successor, put it into the node's successors list, and // put node into the succeesor's predecessors list successorSet := set.New[descriptor.Descriptor]() m.successors[nodeKey] = successorSet for _, successor := range successors { successorKey := descriptor.FromOCI(successor) successorSet.Add(successorKey) predecessorSet, exists := m.predecessors[successorKey] if !exists { predecessorSet = set.New[descriptor.Descriptor]() m.predecessors[successorKey] = predecessorSet } predecessorSet.Add(nodeKey) } return successors, nil } // Exists checks if the node exists in the graph func (m *Memory) Exists(node ocispec.Descriptor) bool { m.lock.RLock() defer m.lock.RUnlock() nodeKey := descriptor.FromOCI(node) _, exists := m.nodes[nodeKey] return exists } oras-go-2.5.0/internal/graph/memory_test.go000066400000000000000000000664731457674530300207360ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package graph import ( "bytes" "context" "encoding/json" "io" "reflect" "testing" "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/internal/cas" "oras.land/oras-go/v2/internal/descriptor" ) // +------------------------------+ // | | // | +-----------+ | // | |A(manifest)| | // | +-----+-----+ | // | | | // | +------------+ | // | | | | // | v v | // | +-----+-----+ +---+----+ | // | |B(manifest)| |C(layer)| | // | +-----+-----+ +--------+ | // | | | // | v | // | +---+----+ | // | |D(layer)| | // | +--------+ | // | | // |------------------------------+ func TestMemory_IndexAndRemove(t *testing.T) { testFetcher := cas.NewMemory() testMemory := NewMemory() ctx := context.Background() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) ocispec.Descriptor { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) return descs[len(descs)-1] } generateManifest := func(layers ...ocispec.Descriptor) ocispec.Descriptor { manifest := ocispec.Manifest{ Config: ocispec.Descriptor{MediaType: "test config"}, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } return appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } descC := appendBlob("layer node C", []byte("Node C is a layer")) // blobs[0], layer "C" descD := appendBlob("layer node D", []byte("Node D is a layer")) // blobs[1], layer "D" descB := generateManifest(descs[0:2]...) // blobs[2], manifest "B" descA := generateManifest(descs[1:3]...) // blobs[3], manifest "A" // prepare the content in the fetcher, so that it can be used to test Index testContents := []ocispec.Descriptor{descC, descD, descB, descA} for i := 0; i < len(blobs); i++ { testFetcher.Push(ctx, testContents[i], bytes.NewReader(blobs[i])) } // make sure that testFetcher works rc, err := testFetcher.Fetch(ctx, descA) if err != nil { t.Errorf("testFetcher.Fetch() error = %v", err) } got, err := io.ReadAll(rc) if err != nil { t.Errorf("testFetcher.Fetch().Read() error = %v", err) } err = rc.Close() if err != nil { t.Errorf("testFetcher.Fetch().Close() error = %v", err) } if !bytes.Equal(got, blobs[3]) { t.Errorf("testFetcher.Fetch() = %v, want %v", got, blobs[4]) } nodeKeyA := descriptor.FromOCI(descA) nodeKeyB := descriptor.FromOCI(descB) nodeKeyC := descriptor.FromOCI(descC) nodeKeyD := descriptor.FromOCI(descD) // index and check the information of node D testMemory.Index(ctx, testFetcher, descD) // 1. verify its existence in testMemory.nodes if _, exists := testMemory.nodes[nodeKeyD]; !exists { t.Errorf("nodes entry of %s should exist", "D") } // 2. verify that the entry of D exists in testMemory.successors and it's empty successorsD, exists := testMemory.successors[nodeKeyD] if !exists { t.Errorf("successor entry of %s should exist", "D") } if successorsD == nil { t.Errorf("successors of %s should be an empty set, not nil", "D") } if len(successorsD) != 0 { t.Errorf("successors of %s should be empty", "D") } // 3. there should be no entry of D in testMemory.predecessors yet _, exists = testMemory.predecessors[nodeKeyD] if exists { t.Errorf("predecessor entry of %s should not exist yet", "D") } // index and check the information of node C testMemory.Index(ctx, testFetcher, descC) // 1. verify its existence in memory.nodes if _, exists := testMemory.nodes[nodeKeyC]; !exists { t.Errorf("nodes entry of %s should exist", "C") } // 2. verify that the entry of C exists in testMemory.successors and it's empty successorsC, exists := testMemory.successors[nodeKeyC] if !exists { t.Errorf("successor entry of %s should exist", "C") } if successorsC == nil { t.Errorf("successors of %s should be an empty set, not nil", "C") } if len(successorsC) != 0 { t.Errorf("successors of %s should be empty", "C") } // 3. there should be no entry of C in testMemory.predecessors yet _, exists = testMemory.predecessors[nodeKeyC] if exists { t.Errorf("predecessor entry of %s should not exist yet", "C") } // index and check the information of node A testMemory.Index(ctx, testFetcher, descA) // 1. verify its existence in testMemory.nodes if _, exists := testMemory.nodes[nodeKeyA]; !exists { t.Errorf("nodes entry of %s should exist", "A") } // 2. verify that the entry of A exists in testMemory.successors and it contains // node B and node D successorsA, exists := testMemory.successors[nodeKeyA] if !exists { t.Errorf("successor entry of %s should exist", "A") } if successorsA == nil { t.Errorf("successors of %s should be a set, not nil", "A") } if !successorsA.Contains(nodeKeyB) { t.Errorf("successors of %s should contain %s", "A", "B") } if !successorsA.Contains(nodeKeyD) { t.Errorf("successors of %s should contain %s", "A", "D") } // 3. verify that node A exists in the predecessors lists of its successors. // there should be an entry of D in testMemory.predecessors by now and it // should contain A but not B predecessorsD, exists := testMemory.predecessors[nodeKeyD] if !exists { t.Errorf("predecessor entry of %s should exist by now", "D") } if !predecessorsD.Contains(nodeKeyA) { t.Errorf("predecessors of %s should contain %s", "D", "A") } if predecessorsD.Contains(nodeKeyB) { t.Errorf("predecessors of %s should not contain %s yet", "D", "B") } // there should be an entry of B in testMemory.predecessors now // and it should contain A predecessorsB, exists := testMemory.predecessors[nodeKeyB] if !exists { t.Errorf("predecessor entry of %s should exist by now", "B") } if !predecessorsB.Contains(nodeKeyA) { t.Errorf("predecessors of %s should contain %s", "B", "A") } // 4. there should be no entry of A in testMemory.predecessors _, exists = testMemory.predecessors[nodeKeyA] if exists { t.Errorf("predecessor entry of %s should not exist", "A") } // index and check the information of node B testMemory.Index(ctx, testFetcher, descB) // 1. verify its existence in testMemory.nodes if _, exists := testMemory.nodes[nodeKeyB]; !exists { t.Errorf("nodes entry of %s should exist", "B") } // 2. verify that the entry of B exists in testMemory.successors and it contains // node C and node D successorsB, exists := testMemory.successors[nodeKeyB] if !exists { t.Errorf("successor entry of %s should exist", "B") } if successorsB == nil { t.Errorf("successors of %s should be a set, not nil", "B") } if !successorsB.Contains(nodeKeyC) { t.Errorf("successors of %s should contain %s", "B", "C") } if !successorsB.Contains(nodeKeyD) { t.Errorf("successors of %s should contain %s", "B", "D") } // 3. verify that node B exists in the predecessors lists of its successors. // there should be an entry of C in testMemory.predecessors by now // and it should contain B predecessorsC, exists := testMemory.predecessors[nodeKeyC] if !exists { t.Errorf("predecessor entry of %s should exist by now", "C") } if !predecessorsC.Contains(nodeKeyB) { t.Errorf("predecessors of %s should contain %s", "C", "B") } // predecessors of D should have been updated now to have node A and B if !predecessorsD.Contains(nodeKeyB) { t.Errorf("predecessors of %s should contain %s", "D", "B") } if !predecessorsD.Contains(nodeKeyA) { t.Errorf("predecessors of %s should contain %s", "D", "A") } // remove node B and check the stored information testMemory.Remove(descB) // 1. verify that node B no longer exists in testMemory.nodes if _, exists := testMemory.nodes[nodeKeyB]; exists { t.Errorf("nodes entry of %s should no longer exist", "B") } // 2. verify B' predecessors info: B's entry in testMemory.predecessors should // still exist, since its predecessor A still exists predecessorsB, exists = testMemory.predecessors[nodeKeyB] if !exists { t.Errorf("testDeletableMemory.predecessors should still contain the entry of %s", "B") } if !predecessorsB.Contains(nodeKeyA) { t.Errorf("predecessors of %s should still contain %s", "B", "A") } // 3. verify B' successors info: B's entry in testMemory.successors should no // longer exist if _, exists := testMemory.successors[nodeKeyB]; exists { t.Errorf("testDeletableMemory.successors should not contain the entry of %s", "B") } // 4. verify B' predecessors' successors info: B should still exist in A's // successors if !successorsA.Contains(nodeKeyB) { t.Errorf("successors of %s should still contain %s", "A", "B") } // 5. verify B' successors' predecessors info: C's entry in testMemory.predecessors // should no longer exist, since C's only predecessor B is already deleted if _, exists = testMemory.predecessors[nodeKeyC]; exists { t.Errorf("predecessor entry of %s should no longer exist by now, since all its predecessors have been deleted", "C") } // B should no longer exist in D's predecessors if predecessorsD.Contains(nodeKeyB) { t.Errorf("predecessors of %s should not contain %s", "D", "B") } // but A still exists in D's predecessors if !predecessorsD.Contains(nodeKeyA) { t.Errorf("predecessors of %s should still contain %s", "D", "A") } // remove node A and check the stored information testMemory.Remove(descA) // 1. verify that node A no longer exists in testMemory.nodes if _, exists := testMemory.nodes[nodeKeyA]; exists { t.Errorf("nodes entry of %s should no longer exist", "A") } // 2. verify A' successors info: A's entry in testMemory.successors should no // longer exist if _, exists := testMemory.successors[nodeKeyA]; exists { t.Errorf("testDeletableMemory.successors should not contain the entry of %s", "A") } // 3. verify A' successors' predecessors info: D's entry in testMemory.predecessors // should no longer exist, since all predecessors of D are already deleted if _, exists = testMemory.predecessors[nodeKeyD]; exists { t.Errorf("predecessor entry of %s should no longer exist by now, since all its predecessors have been deleted", "D") } // B's entry in testMemory.predecessors should no longer exist, since B's only // predecessor A is already deleted if _, exists = testMemory.predecessors[nodeKeyB]; exists { t.Errorf("predecessor entry of %s should no longer exist by now, since all its predecessors have been deleted", "B") } } // +-----------------------------------------------+ // | | // | +--------+ | // | |A(index)| | // | +---+----+ | // | | | // | -+--------------+--------------+- | // | | | | | // | +-----v-----+ +-----v-----+ +-----v-----+ | // | |B(manifest)| |C(manifest)| |D(manifest)| | // | +--------+--+ ++---------++ +--+--------+ | // | | | | | | // | | | | | | // | v v v v | // | ++------++ ++------++ | // | |E(layer)| |F(layer)| | // | +--------+ +--------+ | // | | // +-----------------------------------------------+ func TestMemory_IndexAllAndPredecessors(t *testing.T) { testFetcher := cas.NewMemory() testMemory := NewMemory() ctx := context.Background() // generate test content var blobs [][]byte var descriptors []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) ocispec.Descriptor { blobs = append(blobs, blob) descriptors = append(descriptors, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) return descriptors[len(descriptors)-1] } generateManifest := func(layers ...ocispec.Descriptor) ocispec.Descriptor { manifest := ocispec.Manifest{ Config: ocispec.Descriptor{MediaType: "test config"}, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } return appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } generateIndex := func(manifests ...ocispec.Descriptor) ocispec.Descriptor { index := ocispec.Index{ Manifests: manifests, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatal(err) } return appendBlob(ocispec.MediaTypeImageIndex, indexJSON) } descE := appendBlob("layer node E", []byte("Node E is a layer")) // blobs[0], layer "E" descF := appendBlob("layer node F", []byte("Node F is a layer")) // blobs[1], layer "F" descB := generateManifest(descriptors[0:1]...) // blobs[2], manifest "B" descC := generateManifest(descriptors[0:2]...) // blobs[3], manifest "C" descD := generateManifest(descriptors[1:2]...) // blobs[4], manifest "D" descA := generateIndex(descriptors[2:5]...) // blobs[5], index "A" // prepare the content in the fetcher, so that it can be used to test IndexAll testContents := []ocispec.Descriptor{descE, descF, descB, descC, descD, descA} for i := 0; i < len(blobs); i++ { testFetcher.Push(ctx, testContents[i], bytes.NewReader(blobs[i])) } // make sure that testFetcher works rc, err := testFetcher.Fetch(ctx, descA) if err != nil { t.Errorf("testFetcher.Fetch() error = %v", err) } got, err := io.ReadAll(rc) if err != nil { t.Errorf("testFetcher.Fetch().Read() error = %v", err) } err = rc.Close() if err != nil { t.Errorf("testFetcher.Fetch().Close() error = %v", err) } if !bytes.Equal(got, blobs[5]) { t.Errorf("testFetcher.Fetch() = %v, want %v", got, blobs[4]) } nodeKeyA := descriptor.FromOCI(descA) nodeKeyB := descriptor.FromOCI(descB) nodeKeyC := descriptor.FromOCI(descC) nodeKeyD := descriptor.FromOCI(descD) nodeKeyE := descriptor.FromOCI(descE) nodeKeyF := descriptor.FromOCI(descF) // index node A into testMemory using IndexAll testMemory.IndexAll(ctx, testFetcher, descA) // check the information of node A // 1. verify that node A exists in testMemory.nodes if _, exists := testMemory.nodes[nodeKeyA]; !exists { t.Errorf("nodes entry of %s should exist", "A") } // 2. verify that there is no entry of A in predecessors if _, exists := testMemory.predecessors[nodeKeyA]; exists { t.Errorf("there should be no entry of %s in predecessors", "A") } // 3. verify that A has successors B, C, D successorsA, exists := testMemory.successors[nodeKeyA] if !exists { t.Errorf("there should be an entry of %s in successors", "A") } if !successorsA.Contains(nodeKeyB) { t.Errorf("successors of %s should contain %s", "A", "B") } if !successorsA.Contains(nodeKeyC) { t.Errorf("successors of %s should contain %s", "A", "C") } if !successorsA.Contains(nodeKeyD) { t.Errorf("successors of %s should contain %s", "A", "D") } // check the information of node B // 1. verify that node B exists in testMemory.nodes if _, exists := testMemory.nodes[nodeKeyB]; !exists { t.Errorf("nodes entry of %s should exist", "B") } // 2. verify that B has node A in its predecessors predecessorsB := testMemory.predecessors[nodeKeyB] if !predecessorsB.Contains(nodeKeyA) { t.Errorf("predecessors of %s should contain %s", "B", "A") } // 3. verify that B has node E in its successors successorsB := testMemory.successors[nodeKeyB] if !successorsB.Contains(nodeKeyE) { t.Errorf("successors of %s should contain %s", "B", "E") } // check the information of node C // 1. verify that node C exists in testMemory.nodes if _, exists := testMemory.nodes[nodeKeyC]; !exists { t.Errorf("nodes entry of %s should exist", "C") } // 2. verify that C has node A in its predecessors predecessorsC := testMemory.predecessors[nodeKeyC] if !predecessorsC.Contains(nodeKeyA) { t.Errorf("predecessors of %s should contain %s", "C", "A") } // 3. verify that C has node E and F in its successors successorsC := testMemory.successors[nodeKeyC] if !successorsC.Contains(nodeKeyE) { t.Errorf("successors of %s should contain %s", "C", "E") } if !successorsC.Contains(nodeKeyF) { t.Errorf("successors of %s should contain %s", "C", "F") } // check the information of node D // 1. verify that node D exists in testMemory.nodes if _, exists := testMemory.nodes[nodeKeyD]; !exists { t.Errorf("nodes entry of %s should exist", "D") } // 2. verify that D has node A in its predecessors predecessorsD := testMemory.predecessors[nodeKeyD] if !predecessorsD.Contains(nodeKeyA) { t.Errorf("predecessors of %s should contain %s", "D", "A") } // 3. verify that D has node F in its successors successorsD := testMemory.successors[nodeKeyD] if !successorsD.Contains(nodeKeyF) { t.Errorf("successors of %s should contain %s", "D", "F") } // check the information of node E // 1. verify that node E exists in testMemory.nodes if _, exists := testMemory.nodes[nodeKeyE]; !exists { t.Errorf("nodes entry of %s should exist", "E") } // 2. verify that E has node B and C in its predecessors predecessorsE := testMemory.predecessors[nodeKeyE] if !predecessorsE.Contains(nodeKeyB) { t.Errorf("predecessors of %s should contain %s", "E", "B") } if !predecessorsE.Contains(nodeKeyC) { t.Errorf("predecessors of %s should contain %s", "E", "C") } // 3. verify that E has an entry in successors and it's empty successorsE, exists := testMemory.successors[nodeKeyE] if !exists { t.Errorf("entry %s should exist in testMemory.successors", "E") } if successorsE == nil { t.Errorf("successors of %s should be an empty set, not nil", "E") } if len(successorsE) != 0 { t.Errorf("successors of %s should be empty", "E") } // check the information of node F // 1. verify that node F exists in testMemory.nodes if _, exists := testMemory.nodes[nodeKeyF]; !exists { t.Errorf("nodes entry of %s should exist", "F") } // 2. verify that F has node C and D in its predecessors predecessorsF := testMemory.predecessors[nodeKeyF] if !predecessorsF.Contains(nodeKeyC) { t.Errorf("predecessors of %s should contain %s", "F", "C") } if !predecessorsF.Contains(nodeKeyD) { t.Errorf("predecessors of %s should contain %s", "F", "D") } // 3. verify that F has an entry in successors and it's empty successorsF, exists := testMemory.successors[nodeKeyF] if !exists { t.Errorf("entry %s should exist in testMemory.successors", "F") } if successorsF == nil { t.Errorf("successors of %s should be an empty set, not nil", "F") } if len(successorsF) != 0 { t.Errorf("successors of %s should be empty", "F") } // check that the Predecessors of node C is node A predsC, err := testMemory.Predecessors(ctx, descC) if err != nil { t.Errorf("testFetcher.Predecessors() error = %v", err) } expectedLength := 1 if len(predsC) != expectedLength { t.Errorf("%s should have length %d", "predsC", expectedLength) } if !reflect.DeepEqual(predsC[0], descA) { t.Errorf("incorrect predecessor result") } // check that the Predecessors of node F are node C and node D predsF, err := testMemory.Predecessors(ctx, descF) if err != nil { t.Errorf("testFetcher.Predecessors() error = %v", err) } expectedLength = 2 if len(predsF) != expectedLength { t.Errorf("%s should have length %d", "predsF", expectedLength) } for _, pred := range predsF { if !reflect.DeepEqual(pred, descC) && !reflect.DeepEqual(pred, descD) { t.Errorf("incorrect predecessor results") } } // remove node C and check the stored information testMemory.Remove(descC) if predecessorsE.Contains(nodeKeyC) { t.Errorf("predecessors of %s should not contain %s", "E", "C") } if predecessorsF.Contains(nodeKeyC) { t.Errorf("predecessors of %s should not contain %s", "F", "C") } if !successorsA.Contains(nodeKeyC) { t.Errorf("successors of %s should still contain %s", "A", "C") } if _, exists := testMemory.successors[nodeKeyC]; exists { t.Errorf("testMemory.successors should not contain the entry of %s", "C") } if _, exists := testMemory.predecessors[nodeKeyC]; !exists { t.Errorf("entry %s in predecessors should still exists since it still has at least one predecessor node present", "C") } // remove node A and check the stored information testMemory.Remove(descA) if _, exists := testMemory.predecessors[nodeKeyB]; exists { t.Errorf("entry %s in predecessors should no longer exists", "B") } if _, exists := testMemory.predecessors[nodeKeyC]; exists { t.Errorf("entry %s in predecessors should no longer exists", "C") } if _, exists := testMemory.predecessors[nodeKeyD]; exists { t.Errorf("entry %s in predecessors should no longer exists", "D") } if _, exists := testMemory.successors[nodeKeyA]; exists { t.Errorf("testDeletableMemory.successors should not contain the entry of %s", "A") } // check that the Predecessors of node D is empty predsD, err := testMemory.Predecessors(ctx, descD) if err != nil { t.Errorf("testFetcher.Predecessors() error = %v", err) } if predsD != nil { t.Errorf("%s should be nil", "predsD") } // check that the Predecessors of node E is node B predsE, err := testMemory.Predecessors(ctx, descE) if err != nil { t.Errorf("testFetcher.Predecessors() error = %v", err) } expectedLength = 1 if len(predsE) != expectedLength { t.Errorf("%s should have length %d", "predsE", expectedLength) } if !reflect.DeepEqual(predsE[0], descB) { t.Errorf("incorrect predecessor result") } } func TestMemory_DigestSet(t *testing.T) { testFetcher := cas.NewMemory() testMemory := NewMemory() ctx := context.Background() // generate test content var blobs [][]byte var descriptors []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) ocispec.Descriptor { blobs = append(blobs, blob) descriptors = append(descriptors, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) return descriptors[len(descriptors)-1] } generateManifest := func(layers ...ocispec.Descriptor) ocispec.Descriptor { manifest := ocispec.Manifest{ Config: ocispec.Descriptor{MediaType: "test config"}, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } return appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } generateIndex := func(manifests ...ocispec.Descriptor) ocispec.Descriptor { index := ocispec.Index{ Manifests: manifests, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatal(err) } return appendBlob(ocispec.MediaTypeImageIndex, indexJSON) } descE := appendBlob("layer node E", []byte("Node E is a layer")) // blobs[0], layer "E" descF := appendBlob("layer node F", []byte("Node F is a layer")) // blobs[1], layer "F" descB := generateManifest(descriptors[0:1]...) // blobs[2], manifest "B" descC := generateManifest(descriptors[0:2]...) // blobs[3], manifest "C" descD := generateManifest(descriptors[1:2]...) // blobs[4], manifest "D" descA := generateIndex(descriptors[2:5]...) // blobs[5], index "A" // prepare the content in the fetcher, so that it can be used to test IndexAll testContents := []ocispec.Descriptor{descE, descF, descB, descC, descD, descA} for i := 0; i < len(blobs); i++ { testFetcher.Push(ctx, testContents[i], bytes.NewReader(blobs[i])) } // make sure that testFetcher works rc, err := testFetcher.Fetch(ctx, descA) if err != nil { t.Errorf("testFetcher.Fetch() error = %v", err) } got, err := io.ReadAll(rc) if err != nil { t.Errorf("testFetcher.Fetch().Read() error = %v", err) } err = rc.Close() if err != nil { t.Errorf("testFetcher.Fetch().Close() error = %v", err) } if !bytes.Equal(got, blobs[5]) { t.Errorf("testFetcher.Fetch() = %v, want %v", got, blobs[4]) } // index node A into testMemory using IndexAll testMemory.IndexAll(ctx, testFetcher, descA) digestSet := testMemory.DigestSet() for i := 0; i < len(blobs); i++ { if exists := digestSet.Contains(descriptors[i].Digest); exists != true { t.Errorf("digest of blob[%d] should exist in digestSet", i) } } } func TestMemory_Exists(t *testing.T) { testFetcher := cas.NewMemory() testMemory := NewMemory() ctx := context.Background() // generate test content var blobs [][]byte var descriptors []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) ocispec.Descriptor { blobs = append(blobs, blob) descriptors = append(descriptors, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) return descriptors[len(descriptors)-1] } generateManifest := func(layers ...ocispec.Descriptor) ocispec.Descriptor { manifest := ocispec.Manifest{ Config: ocispec.Descriptor{MediaType: "test config"}, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } return appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } generateIndex := func(manifests ...ocispec.Descriptor) ocispec.Descriptor { index := ocispec.Index{ Manifests: manifests, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatal(err) } return appendBlob(ocispec.MediaTypeImageIndex, indexJSON) } descE := appendBlob("layer node E", []byte("Node E is a layer")) // blobs[0], layer "E" descF := appendBlob("layer node F", []byte("Node F is a layer")) // blobs[1], layer "F" descB := generateManifest(descriptors[0:1]...) // blobs[2], manifest "B" descC := generateManifest(descriptors[0:2]...) // blobs[3], manifest "C" descD := generateManifest(descriptors[1:2]...) // blobs[4], manifest "D" descA := generateIndex(descriptors[2:5]...) // blobs[5], index "A" // prepare the content in the fetcher, so that it can be used to test IndexAll testContents := []ocispec.Descriptor{descE, descF, descB, descC, descD, descA} for i := 0; i < len(blobs); i++ { testFetcher.Push(ctx, testContents[i], bytes.NewReader(blobs[i])) } // make sure that testFetcher works rc, err := testFetcher.Fetch(ctx, descA) if err != nil { t.Errorf("testFetcher.Fetch() error = %v", err) } got, err := io.ReadAll(rc) if err != nil { t.Errorf("testFetcher.Fetch().Read() error = %v", err) } err = rc.Close() if err != nil { t.Errorf("testFetcher.Fetch().Close() error = %v", err) } if !bytes.Equal(got, blobs[5]) { t.Errorf("testFetcher.Fetch() = %v, want %v", got, blobs[4]) } // index node A into testMemory using IndexAll testMemory.IndexAll(ctx, testFetcher, descA) for i := 0; i < len(blobs); i++ { if exists := testMemory.Exists(descriptors[i]); exists != true { t.Errorf("digest of blob[%d] should exist in digestSet", i) } } } oras-go-2.5.0/internal/httputil/000077500000000000000000000000001457674530300165745ustar00rootroot00000000000000oras-go-2.5.0/internal/httputil/seek.go000066400000000000000000000057341457674530300200630ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package httputil import ( "errors" "fmt" "io" "net/http" ) // Client is an interface for a HTTP client. // This interface is defined inside this package to prevent potential import // loop. type Client interface { // Do sends an HTTP request and returns an HTTP response. Do(*http.Request) (*http.Response, error) } // readSeekCloser seeks http body by starting new connections. type readSeekCloser struct { client Client req *http.Request rc io.ReadCloser size int64 offset int64 closed bool } // NewReadSeekCloser returns a seeker to make the HTTP response seekable. // Callers should ensure that the server supports Range request. func NewReadSeekCloser(client Client, req *http.Request, respBody io.ReadCloser, size int64) io.ReadSeekCloser { return &readSeekCloser{ client: client, req: req, rc: respBody, size: size, } } // Read reads the content body and counts offset. func (rsc *readSeekCloser) Read(p []byte) (n int, err error) { if rsc.closed { return 0, errors.New("read: already closed") } n, err = rsc.rc.Read(p) rsc.offset += int64(n) return } // Seek starts a new connection to the remote for reading if position changes. func (rsc *readSeekCloser) Seek(offset int64, whence int) (int64, error) { if rsc.closed { return 0, errors.New("seek: already closed") } switch whence { case io.SeekCurrent: offset += rsc.offset case io.SeekStart: // no-op case io.SeekEnd: offset += rsc.size default: return 0, errors.New("seek: invalid whence") } if offset < 0 { return 0, errors.New("seek: an attempt was made to move the pointer before the beginning of the content") } if offset == rsc.offset { return offset, nil } if offset >= rsc.size { rsc.rc.Close() rsc.rc = http.NoBody rsc.offset = offset return offset, nil } req := rsc.req.Clone(rsc.req.Context()) req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", offset, rsc.size-1)) resp, err := rsc.client.Do(req) if err != nil { return 0, fmt.Errorf("seek: %s %q: %w", req.Method, req.URL, err) } if resp.StatusCode != http.StatusPartialContent { resp.Body.Close() return 0, fmt.Errorf("seek: %s %q: unexpected status code %d", resp.Request.Method, resp.Request.URL, resp.StatusCode) } rsc.rc.Close() rsc.rc = resp.Body rsc.offset = offset return offset, nil } // Close closes the content body. func (rsc *readSeekCloser) Close() error { if rsc.closed { return nil } rsc.closed = true return rsc.rc.Close() } oras-go-2.5.0/internal/httputil/seek_test.go000066400000000000000000000121441457674530300211130ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package httputil import ( "bytes" "fmt" "io" "math" "net/http" "net/http/httptest" "testing" ) func Test_readSeekCloser_Read(t *testing.T) { content := []byte("hello world") path := "/testpath" ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != path { w.WriteHeader(http.StatusNotFound) return } if _, err := w.Write(content); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } })) defer ts.Close() client := ts.Client() resp, err := client.Get(ts.URL + path) if err != nil { t.Fatalf("failed to do request: %v", err) } rsc := NewReadSeekCloser(client, resp.Request, resp.Body, int64(len(content))) buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(rsc); err != nil { t.Errorf("fail to read: %v", err) } if got := buf.Bytes(); !bytes.Equal(got, content) { t.Errorf("readSeekCloser.Read() = %v, want %v", got, content) } if err := rsc.Close(); err != nil { t.Errorf("fail to close: %v", err) } if !rsc.(*readSeekCloser).closed { t.Errorf("readSeekCloser not closed") } } func Test_readSeekCloser_Seek(t *testing.T) { content := []byte("hello world") path := "/testpath" ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != path { w.WriteHeader(http.StatusNotFound) return } rangeHeader := r.Header.Get("Range") if rangeHeader == "" { w.WriteHeader(http.StatusOK) if _, err := w.Write(content); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } return } var start, end int _, err := fmt.Sscanf(rangeHeader, "bytes=%d-%d", &start, &end) if err != nil { t.Errorf("invalid range header: %s", rangeHeader) w.WriteHeader(http.StatusRequestedRangeNotSatisfiable) return } if start < 0 || start > end || start >= len(content) { t.Errorf("invalid range: %s", rangeHeader) w.WriteHeader(http.StatusRequestedRangeNotSatisfiable) return } end++ if end > len(content) { end = len(content) } w.WriteHeader(http.StatusPartialContent) if _, err := w.Write(content[start:end]); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } })) defer ts.Close() client := ts.Client() resp, err := client.Get(ts.URL + path) if err != nil { t.Fatalf("failed to do request: %v", err) } rsc := NewReadSeekCloser(client, resp.Request, resp.Body, int64(len(content))) tests := []struct { name string offset int64 whence int wantOffset int64 n int64 want []byte skipSeek bool }{ { name: "read from initial response", n: 3, want: []byte("hel"), skipSeek: true, }, { name: "seek to skip", offset: 2, whence: io.SeekCurrent, wantOffset: 5, n: 4, want: []byte(" wor"), }, { name: "seek to the beginning", offset: 0, whence: io.SeekStart, wantOffset: 0, n: 5, want: []byte("hello"), }, { name: "seek to middle", offset: 6, whence: io.SeekStart, wantOffset: 6, n: math.MaxInt64, want: []byte("world"), }, { name: "seek from end", offset: -4, whence: io.SeekEnd, wantOffset: 7, n: 3, want: []byte("orl"), }, { name: "seek to the end", offset: 0, whence: io.SeekEnd, wantOffset: 11, n: 5, want: nil, }, { name: "seek beyond the end", offset: 42, whence: io.SeekStart, wantOffset: 42, n: 10, want: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if !tt.skipSeek { got, err := rsc.Seek(tt.offset, tt.whence) if err != nil { t.Errorf("readSeekCloser.Seek() error = %v", err) } if got != tt.wantOffset { t.Errorf("readSeekCloser.Read() = %v, want %v", got, tt.wantOffset) } } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(io.LimitReader(rsc, tt.n)); err != nil { t.Errorf("fail to read: %v", err) } if got := buf.Bytes(); !bytes.Equal(got, tt.want) { t.Errorf("readSeekCloser.Read() = %v, want %v", got, tt.want) } }) } _, err = rsc.Seek(-1, io.SeekStart) if err == nil { t.Errorf("readSeekCloser.Seek() error = %v, wantErr %v", err, true) } if err := rsc.Close(); err != nil { t.Errorf("fail to close: %v", err) } if !rsc.(*readSeekCloser).closed { t.Errorf("readSeekCloser not closed") } _, err = rsc.Seek(0, io.SeekStart) if err == nil { t.Errorf("readSeekCloser.Seek() error = %v, wantErr %v", err, true) } } oras-go-2.5.0/internal/interfaces/000077500000000000000000000000001457674530300170425ustar00rootroot00000000000000oras-go-2.5.0/internal/interfaces/registry.go000066400000000000000000000014751457674530300212500ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package interfaces import "oras.land/oras-go/v2/registry" // ReferenceParser provides reference parsing. type ReferenceParser interface { // ParseReference parses a reference to a fully qualified reference. ParseReference(reference string) (registry.Reference, error) } oras-go-2.5.0/internal/ioutil/000077500000000000000000000000001457674530300162245ustar00rootroot00000000000000oras-go-2.5.0/internal/ioutil/io.go000066400000000000000000000037401457674530300171660ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package ioutil import ( "fmt" "io" "reflect" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/content" ) // CloserFunc is the basic Close method defined in io.Closer. type CloserFunc func() error // Close performs close operation by the CloserFunc. func (fn CloserFunc) Close() error { return fn() } // CopyBuffer copies from src to dst through the provided buffer // until either EOF is reached on src, or an error occurs. // The copied content is verified against the size and the digest. func CopyBuffer(dst io.Writer, src io.Reader, buf []byte, desc ocispec.Descriptor) error { // verify while copying vr := content.NewVerifyReader(src, desc) if _, err := io.CopyBuffer(dst, vr, buf); err != nil { return fmt.Errorf("copy failed: %w", err) } return vr.Verify() } // Types returned by `io.NopCloser()`. var ( nopCloserType = reflect.TypeOf(io.NopCloser(nil)) nopCloserWriterToType = reflect.TypeOf(io.NopCloser(struct { io.Reader io.WriterTo }{})) ) // UnwrapNopCloser unwraps the reader wrapped by `io.NopCloser()`. // Similar implementation can be found in the built-in package `net/http`. // Reference: https://github.com/golang/go/blob/go1.22.1/src/net/http/transfer.go#L1090-L1105 func UnwrapNopCloser(r io.Reader) io.Reader { switch reflect.TypeOf(r) { case nopCloserType, nopCloserWriterToType: return reflect.ValueOf(r).Field(0).Interface().(io.Reader) default: return r } } oras-go-2.5.0/internal/ioutil/io_test.go000066400000000000000000000064751457674530300202350ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package ioutil import ( "bytes" "errors" "io" "os" "reflect" "testing" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/content" ) func TestUnwrapNopCloser(t *testing.T) { var reader struct { io.Reader } var readerWithWriterTo struct { io.Reader io.WriterTo } tests := []struct { name string rc io.Reader want io.Reader }{ { name: "nil", }, { name: "no-op closer with plain io.Reader", rc: io.NopCloser(reader), want: reader, }, { name: "no-op closer with io.WriteTo", rc: io.NopCloser(readerWithWriterTo), want: readerWithWriterTo, }, { name: "any ReadCloser", rc: os.Stdin, want: os.Stdin, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := UnwrapNopCloser(tt.rc); !reflect.DeepEqual(got, tt.want) { t.Errorf("UnwrapNopCloser() = %v, want %v", got, tt.want) } }) } } func TestCopyBuffer(t *testing.T) { blob := []byte("foo") type args struct { src io.Reader buf []byte desc ocispec.Descriptor } tests := []struct { name string args args wantDst string wantErr error }{ { name: "exact buffer size, no errors", args: args{bytes.NewReader(blob), make([]byte, 3), content.NewDescriptorFromBytes("test", blob)}, wantDst: "foo", wantErr: nil, }, { name: "small buffer size, no errors", args: args{bytes.NewReader(blob), make([]byte, 1), content.NewDescriptorFromBytes("test", blob)}, wantDst: "foo", wantErr: nil, }, { name: "big buffer size, no errors", args: args{bytes.NewReader(blob), make([]byte, 5), content.NewDescriptorFromBytes("test", blob)}, wantDst: "foo", wantErr: nil, }, { name: "wrong digest", args: args{bytes.NewReader(blob), make([]byte, 3), content.NewDescriptorFromBytes("test", []byte("bar"))}, wantDst: "foo", wantErr: content.ErrMismatchedDigest, }, { name: "wrong size, descriptor size is smaller", args: args{bytes.NewReader(blob), make([]byte, 3), content.NewDescriptorFromBytes("test", []byte("fo"))}, wantDst: "foo", wantErr: content.ErrTrailingData, }, { name: "wrong size, descriptor size is larger", args: args{bytes.NewReader(blob), make([]byte, 3), content.NewDescriptorFromBytes("test", []byte("fooo"))}, wantDst: "foo", wantErr: io.ErrUnexpectedEOF, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { dst := &bytes.Buffer{} err := CopyBuffer(dst, tt.args.src, tt.args.buf, tt.args.desc) if !errors.Is(err, tt.wantErr) { t.Errorf("CopyBuffer() error = %v, wantErr %v", err, tt.wantErr) return } gotDst := dst.String() if err == nil && gotDst != tt.wantDst { t.Errorf("CopyBuffer() = %v, want %v", gotDst, tt.wantDst) } }) } } oras-go-2.5.0/internal/manifestutil/000077500000000000000000000000001457674530300174235ustar00rootroot00000000000000oras-go-2.5.0/internal/manifestutil/parser.go000066400000000000000000000050601457674530300212470ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package manifestutil import ( "context" "encoding/json" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/internal/docker" "oras.land/oras-go/v2/internal/spec" ) // Config returns the config of desc, if present. func Config(ctx context.Context, fetcher content.Fetcher, desc ocispec.Descriptor) (*ocispec.Descriptor, error) { switch desc.MediaType { case docker.MediaTypeManifest, ocispec.MediaTypeImageManifest: content, err := content.FetchAll(ctx, fetcher, desc) if err != nil { return nil, err } // OCI manifest schema can be used to marshal docker manifest var manifest ocispec.Manifest if err := json.Unmarshal(content, &manifest); err != nil { return nil, err } return &manifest.Config, nil default: return nil, nil } } // Manifest returns the manifests of desc, if present. func Manifests(ctx context.Context, fetcher content.Fetcher, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) { switch desc.MediaType { case docker.MediaTypeManifestList, ocispec.MediaTypeImageIndex: content, err := content.FetchAll(ctx, fetcher, desc) if err != nil { return nil, err } // OCI manifest index schema can be used to marshal docker manifest list var index ocispec.Index if err := json.Unmarshal(content, &index); err != nil { return nil, err } return index.Manifests, nil default: return nil, nil } } // Subject returns the subject of desc, if present. func Subject(ctx context.Context, fetcher content.Fetcher, desc ocispec.Descriptor) (*ocispec.Descriptor, error) { switch desc.MediaType { case ocispec.MediaTypeImageManifest, ocispec.MediaTypeImageIndex, spec.MediaTypeArtifactManifest: content, err := content.FetchAll(ctx, fetcher, desc) if err != nil { return nil, err } var manifest struct { Subject *ocispec.Descriptor `json:"subject,omitempty"` } if err := json.Unmarshal(content, &manifest); err != nil { return nil, err } return manifest.Subject, nil default: return nil, nil } } oras-go-2.5.0/internal/manifestutil/parser_test.go000066400000000000000000000243401457674530300223100ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package manifestutil import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "reflect" "testing" "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "golang.org/x/sync/errgroup" "oras.land/oras-go/v2/content/memory" "oras.land/oras-go/v2/internal/cas" "oras.land/oras-go/v2/internal/container/set" "oras.land/oras-go/v2/internal/docker" ) var ErrBadFetch = errors.New("bad fetch error") // testStorage implements Fetcher type testStorage struct { store *memory.Store badFetch set.Set[digest.Digest] } func (s *testStorage) Push(ctx context.Context, expected ocispec.Descriptor, reader io.Reader) error { return s.store.Push(ctx, expected, reader) } func (s *testStorage) Fetch(ctx context.Context, target ocispec.Descriptor) (io.ReadCloser, error) { if s.badFetch.Contains(target.Digest) { return nil, ErrBadFetch } return s.store.Fetch(ctx, target) } // func (s *testStorage) Exists(ctx context.Context, target ocispec.Descriptor) (bool, error) { // return s.store.Exists(ctx, target) // } // func (s *testStorage) Predecessors(ctx context.Context, node ocispec.Descriptor) ([]ocispec.Descriptor, error) { // return s.store.Predecessors(ctx, node) // } func TestConfig(t *testing.T) { storage := cas.NewMemory() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(mediaType string, config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(mediaType, manifestJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 generateManifest(ocispec.MediaTypeImageManifest, descs[0], descs[1]) // Blob 2 generateManifest(docker.MediaTypeManifest, descs[0], descs[1]) // Blob 3 generateManifest("whatever", descs[0], descs[1]) // Blob 4 ctx := context.Background() for i := range blobs { err := storage.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } tests := []struct { name string desc ocispec.Descriptor want *ocispec.Descriptor wantErr bool }{ { name: "OCI Image Manifest", desc: descs[2], want: &descs[0], }, { name: "Docker Manifest", desc: descs[3], want: &descs[0], wantErr: false, }, { name: "Other media type", desc: descs[4], want: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := Config(ctx, storage, tt.desc) if (err != nil) != tt.wantErr { t.Errorf("Config() error = %v, wantErr %v", err, tt.wantErr) return } if !reflect.DeepEqual(got, tt.want) { t.Errorf("Config() = %v, want %v", got, tt.want) } }) } } func TestManifests(t *testing.T) { storage := cas.NewMemory() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(subject *ocispec.Descriptor, config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Subject: subject, Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } generateIndex := func(mediaType string, subject *ocispec.Descriptor, manifests ...ocispec.Descriptor) { index := ocispec.Index{ Subject: subject, Manifests: manifests, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatal(err) } appendBlob(mediaType, indexJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 appendBlob(ocispec.MediaTypeImageLayer, []byte("hello")) // Blob 3 generateManifest(nil, descs[0], descs[1:3]...) // Blob 4 generateManifest(nil, descs[0], descs[3]) // Blob 5 appendBlob(ocispec.MediaTypeImageConfig, []byte("{}")) // Blob 6 appendBlob("test/sig", []byte("sig")) // Blob 7 generateManifest(&descs[4], descs[5], descs[6]) // Blob 8 generateIndex(ocispec.MediaTypeImageIndex, &descs[8], descs[4:6]...) // Blob 9 generateIndex(docker.MediaTypeManifestList, nil, descs[4:6]...) // Blob 10 generateIndex("whatever", &descs[8], descs[4:6]...) // Blob 11 ctx := context.Background() for i := range blobs { err := storage.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } tests := []struct { name string desc ocispec.Descriptor want []ocispec.Descriptor wantErr bool }{ { name: "OCI Image Index", desc: descs[9], want: descs[4:6], }, { name: "Docker Manifest List", desc: descs[10], want: descs[4:6], wantErr: false, }, { name: "Other media type", desc: descs[11], want: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := Manifests(ctx, storage, tt.desc) if (err != nil) != tt.wantErr { t.Errorf("Manifests() error = %v, wantErr %v", err, tt.wantErr) return } if !reflect.DeepEqual(got, tt.want) { t.Errorf("Manifests() = %v, want %v", got, tt.want) } }) } } func TestSubject(t *testing.T) { storage := cas.NewMemory() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateManifest := func(config ocispec.Descriptor, subject *ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Subject: subject, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifestJSON) } appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, []byte("blob")) // Blob 1 generateManifest(descs[0], nil, descs[1]) // Blob 2, manifest generateManifest(descs[0], &descs[2], descs[1]) // Blob 3, referrer of blob 2 ctx := context.Background() for i := range blobs { err := storage.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } got, err := Subject(ctx, storage, descs[3]) if err != nil { t.Fatalf("error when getting subject: %v", err) } if !reflect.DeepEqual(*got, descs[2]) { t.Errorf("Subject() = %v, want %v", got, descs[2]) } got, err = Subject(ctx, storage, descs[0]) if err != nil { t.Fatalf("error when getting subject: %v", err) } if got != nil { t.Errorf("Subject() = %v, want %v", got, nil) } } func TestSubject_ErrorPath(t *testing.T) { s := testStorage{ store: memory.New(), badFetch: set.New[digest.Digest](), } ctx := context.Background() // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, artifactType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, ArtifactType: artifactType, Annotations: map[string]string{"test": "content"}, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } generateImageManifest := func(config ocispec.Descriptor, subject *ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ MediaType: ocispec.MediaTypeImageManifest, Config: config, Subject: subject, Layers: layers, Annotations: map[string]string{"test": "content"}, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageManifest, manifest.Config.MediaType, manifestJSON) } appendBlob("image manifest", "image config", []byte("config")) // Blob 0 appendBlob(ocispec.MediaTypeImageLayer, "layer", []byte("foo")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, "layer", []byte("bar")) // Blob 2 appendBlob(ocispec.MediaTypeImageLayer, "layer", []byte("hello")) // Blob 3 generateImageManifest(descs[0], nil, descs[1]) // Blob 4 generateImageManifest(descs[0], &descs[4], descs[2]) // Blob 5 s.badFetch.Add(descs[5].Digest) eg, egCtx := errgroup.WithContext(ctx) for i := range blobs { eg.Go(func(i int) func() error { return func() error { err := s.Push(egCtx, descs[i], bytes.NewReader(blobs[i])) if err != nil { return fmt.Errorf("failed to push test content to src: %d: %v", i, err) } return nil } }(i)) } if err := eg.Wait(); err != nil { t.Fatal(err) } _, err := Subject(ctx, &s, descs[5]) if !errors.Is(err, ErrBadFetch) { t.Errorf("Store.Referrers() error = %v, want %v", err, ErrBadFetch) } } oras-go-2.5.0/internal/platform/000077500000000000000000000000001457674530300165435ustar00rootroot00000000000000oras-go-2.5.0/internal/platform/platform.go000066400000000000000000000106521457674530300207220ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package platform import ( "context" "encoding/json" "fmt" "io" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/errdef" "oras.land/oras-go/v2/internal/docker" "oras.land/oras-go/v2/internal/manifestutil" ) // Match checks whether the current platform matches the target platform. // Match will return true if all of the following conditions are met. // - Architecture and OS exactly match. // - Variant and OSVersion exactly match if target platform provided. // - OSFeatures of the target platform are the subsets of the OSFeatures // array of the current platform. // // Note: Variant, OSVersion and OSFeatures are optional fields, will skip // the comparison if the target platform does not provide specific value. func Match(got *ocispec.Platform, want *ocispec.Platform) bool { if got == nil && want == nil { return true } if got == nil || want == nil { return false } if got.Architecture != want.Architecture || got.OS != want.OS { return false } if want.OSVersion != "" && got.OSVersion != want.OSVersion { return false } if want.Variant != "" && got.Variant != want.Variant { return false } if len(want.OSFeatures) != 0 && !isSubset(want.OSFeatures, got.OSFeatures) { return false } return true } // isSubset returns true if all items in slice A are present in slice B. func isSubset(a, b []string) bool { set := make(map[string]bool, len(b)) for _, v := range b { set[v] = true } for _, v := range a { if _, ok := set[v]; !ok { return false } } return true } // SelectManifest implements platform filter and returns the descriptor of the // first matched manifest if the root is a manifest list. If the root is a // manifest, then return the root descriptor if platform matches. func SelectManifest(ctx context.Context, src content.ReadOnlyStorage, root ocispec.Descriptor, p *ocispec.Platform) (ocispec.Descriptor, error) { switch root.MediaType { case docker.MediaTypeManifestList, ocispec.MediaTypeImageIndex: manifests, err := manifestutil.Manifests(ctx, src, root) if err != nil { return ocispec.Descriptor{}, err } // platform filter for _, m := range manifests { if Match(m.Platform, p) { return m, nil } } return ocispec.Descriptor{}, fmt.Errorf("%s: %w: no matching manifest was found in the manifest list", root.Digest, errdef.ErrNotFound) case docker.MediaTypeManifest, ocispec.MediaTypeImageManifest: // config will be non-nil for docker manifest and OCI image manifest config, err := manifestutil.Config(ctx, src, root) if err != nil { return ocispec.Descriptor{}, err } configMediaType := docker.MediaTypeConfig if root.MediaType == ocispec.MediaTypeImageManifest { configMediaType = ocispec.MediaTypeImageConfig } cfgPlatform, err := getPlatformFromConfig(ctx, src, *config, configMediaType) if err != nil { return ocispec.Descriptor{}, err } if Match(cfgPlatform, p) { return root, nil } return ocispec.Descriptor{}, fmt.Errorf("%s: %w: platform in manifest does not match target platform", root.Digest, errdef.ErrNotFound) default: return ocispec.Descriptor{}, fmt.Errorf("%s: %s: %w", root.Digest, root.MediaType, errdef.ErrUnsupported) } } // getPlatformFromConfig returns a platform object which is made up from the // fields in config blob. func getPlatformFromConfig(ctx context.Context, src content.ReadOnlyStorage, desc ocispec.Descriptor, targetConfigMediaType string) (*ocispec.Platform, error) { if desc.MediaType != targetConfigMediaType { return nil, fmt.Errorf("fail to recognize platform from unknown config %s: expect %s", desc.MediaType, targetConfigMediaType) } rc, err := src.Fetch(ctx, desc) if err != nil { return nil, err } defer rc.Close() var platform ocispec.Platform if err = json.NewDecoder(rc).Decode(&platform); err != nil && err != io.EOF { return nil, err } return &platform, nil } oras-go-2.5.0/internal/platform/platform_test.go000066400000000000000000000345721457674530300217700ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package platform import ( "bytes" "context" _ "crypto/sha256" "encoding/json" "errors" "fmt" "reflect" "testing" "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/errdef" "oras.land/oras-go/v2/internal/cas" "oras.land/oras-go/v2/internal/docker" ) func TestMatch(t *testing.T) { tests := []struct { got ocispec.Platform want ocispec.Platform isMatched bool }{{ ocispec.Platform{Architecture: "amd64", OS: "linux"}, ocispec.Platform{Architecture: "amd64", OS: "linux"}, true, }, { ocispec.Platform{Architecture: "amd64", OS: "linux"}, ocispec.Platform{Architecture: "amd64", OS: "LINUX"}, false, }, { ocispec.Platform{Architecture: "amd64", OS: "linux"}, ocispec.Platform{Architecture: "arm64", OS: "linux"}, false, }, { ocispec.Platform{Architecture: "arm", OS: "linux"}, ocispec.Platform{Architecture: "arm", OS: "linux", Variant: "v7"}, false, }, { ocispec.Platform{Architecture: "arm", OS: "linux", Variant: "v7"}, ocispec.Platform{Architecture: "arm", OS: "linux"}, true, }, { ocispec.Platform{Architecture: "arm", OS: "linux", Variant: "v7"}, ocispec.Platform{Architecture: "arm", OS: "linux", Variant: "v7"}, true, }, { ocispec.Platform{Architecture: "amd64", OS: "windows", OSVersion: "10.0.20348.768"}, ocispec.Platform{Architecture: "amd64", OS: "windows", OSVersion: "10.0.20348.700"}, false, }, { ocispec.Platform{Architecture: "amd64", OS: "windows"}, ocispec.Platform{Architecture: "amd64", OS: "windows", OSVersion: "10.0.20348.768"}, false, }, { ocispec.Platform{Architecture: "amd64", OS: "windows", OSVersion: "10.0.20348.768"}, ocispec.Platform{Architecture: "amd64", OS: "windows"}, true, }, { ocispec.Platform{Architecture: "amd64", OS: "windows", OSVersion: "10.0.20348.768"}, ocispec.Platform{Architecture: "amd64", OS: "windows", OSVersion: "10.0.20348.768"}, true, }, { ocispec.Platform{Architecture: "arm", OS: "linux", OSFeatures: []string{"a", "d"}}, ocispec.Platform{Architecture: "arm", OS: "linux", OSFeatures: []string{"a", "c"}}, false, }, { ocispec.Platform{Architecture: "arm", OS: "linux"}, ocispec.Platform{Architecture: "arm", OS: "linux", OSFeatures: []string{"a"}}, false, }, { ocispec.Platform{Architecture: "arm", OS: "linux", OSFeatures: []string{"a"}}, ocispec.Platform{Architecture: "arm", OS: "linux"}, true, }, { ocispec.Platform{Architecture: "arm", OS: "linux", OSFeatures: []string{"a", "b"}}, ocispec.Platform{Architecture: "arm", OS: "linux", OSFeatures: []string{"a", "b"}}, true, }, { ocispec.Platform{Architecture: "arm", OS: "linux", OSFeatures: []string{"a", "d", "c", "b"}}, ocispec.Platform{Architecture: "arm", OS: "linux", OSFeatures: []string{"d", "c", "a", "b"}}, true, }} for _, tt := range tests { gotPlatformJSON, _ := json.Marshal(tt.got) wantPlatformJSON, _ := json.Marshal(tt.want) name := string(gotPlatformJSON) + string(wantPlatformJSON) t.Run(name, func(t *testing.T) { if actual := Match(&tt.got, &tt.want); actual != tt.isMatched { t.Errorf("Match() = %v, want %v", actual, tt.isMatched) } }) } } func TestSelectManifest(t *testing.T) { storage := cas.NewMemory() arc_1 := "test-arc-1" os_1 := "test-os-1" variant_1 := "v1" arc_2 := "test-arc-2" os_2 := "test-os-2" variant_2 := "v2" // generate test content var blobs [][]byte var descs []ocispec.Descriptor appendBlob := func(mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), }) } appendManifest := func(arc, os, variant string, hasPlatform bool, mediaType string, blob []byte) { blobs = append(blobs, blob) desc := ocispec.Descriptor{ MediaType: mediaType, Digest: digest.FromBytes(blob), Size: int64(len(blob)), } if hasPlatform { desc.Platform = &ocispec.Platform{ Architecture: arc, OS: os, Variant: variant, } } descs = append(descs, desc) } generateManifest := func(arc, os, variant string, hasPlatform bool, subject *ocispec.Descriptor, config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Subject: subject, Config: config, Layers: layers, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } appendManifest(arc, os, variant, hasPlatform, ocispec.MediaTypeImageManifest, manifestJSON) } generateIndex := func(subject *ocispec.Descriptor, manifests ...ocispec.Descriptor) { index := ocispec.Index{ Subject: subject, Manifests: manifests, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatal(err) } appendBlob(ocispec.MediaTypeImageIndex, indexJSON) } appendBlob("test/subject", []byte("dummy subject")) // Blob 0 appendBlob(ocispec.MediaTypeImageConfig, []byte(`{"mediaType":"application/vnd.oci.image.config.v1+json", "created":"2022-07-29T08:13:55Z", "author":"test author", "architecture":"test-arc-1", "os":"test-os-1", "variant":"v1"}`)) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 2 appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 3 generateManifest(arc_1, os_1, variant_1, true, &descs[0], descs[1], descs[2:4]...) // Blob 4 appendBlob(ocispec.MediaTypeImageLayer, []byte("hello1")) // Blob 5 generateManifest(arc_2, os_2, variant_1, true, nil, descs[1], descs[5]) // Blob 6 appendBlob(ocispec.MediaTypeImageLayer, []byte("hello2")) // Blob 7 generateManifest(arc_1, os_1, variant_2, true, nil, descs[1], descs[7]) // Blob 8 generateIndex(&descs[0], descs[4], descs[6], descs[8]) // Blob 9 ctx := context.Background() for i := range blobs { err := storage.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } // test SelectManifest on image index, only one matching manifest found root := descs[9] targetPlatform := ocispec.Platform{ Architecture: arc_2, OS: os_2, } wantDesc := descs[6] gotDesc, err := SelectManifest(ctx, storage, root, &targetPlatform) if err != nil { t.Fatalf("SelectManifest() error = %v, wantErr %v", err, false) } if !reflect.DeepEqual(gotDesc, wantDesc) { t.Errorf("SelectManifest() = %v, want %v", gotDesc, wantDesc) } // test SelectManifest on image index, // and multiple manifests match the required platform. // Should return the first matching entry. targetPlatform = ocispec.Platform{ Architecture: arc_1, OS: os_1, } wantDesc = descs[4] gotDesc, err = SelectManifest(ctx, storage, root, &targetPlatform) if err != nil { t.Fatalf("SelectManifest() error = %v, wantErr %v", err, false) } if !reflect.DeepEqual(gotDesc, wantDesc) { t.Errorf("SelectManifest() = %v, want %v", gotDesc, wantDesc) } // test SelectManifest on manifest root = descs[8] targetPlatform = ocispec.Platform{ Architecture: arc_1, OS: os_1, } wantDesc = descs[8] gotDesc, err = SelectManifest(ctx, storage, root, &targetPlatform) if err != nil { t.Fatalf("SelectManifest() error = %v, wantErr %v", err, false) } if !reflect.DeepEqual(gotDesc, wantDesc) { t.Errorf("SelectManifest() = %v, want %v", gotDesc, wantDesc) } // test SelectManifest on manifest, but there is no matching node. // Should return not found error. root = descs[8] targetPlatform = ocispec.Platform{ Architecture: arc_1, OS: os_1, Variant: variant_2, } _, err = SelectManifest(ctx, storage, root, &targetPlatform) expected := fmt.Sprintf("%s: %v: platform in manifest does not match target platform", root.Digest, errdef.ErrNotFound) if err.Error() != expected { t.Fatalf("SelectManifest() error = %v, wantErr %v", err, expected) } // test SelectManifest on manifest, but the node's media type is not // supported. Should return unsupported error targetPlatform = ocispec.Platform{ Architecture: arc_1, OS: os_1, } root = descs[2] _, err = SelectManifest(ctx, storage, root, &targetPlatform) if !errors.Is(err, errdef.ErrUnsupported) { t.Fatalf("SelectManifest() error = %v, wantErr %v", err, errdef.ErrUnsupported) } // generate test content without platform storage = cas.NewMemory() blobs = nil descs = nil appendBlob("test/subject", []byte("dummy subject")) // Blob 0 appendBlob(ocispec.MediaTypeImageConfig, []byte(`{"mediaType":"application/vnd.oci.image.config.v1+json", "created":"2022-07-29T08:13:55Z", "author":"test author", "architecture":"test-arc-1", "os":"test-os-1", "variant":"v1"}`)) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 2 generateManifest(arc_1, os_1, variant_1, false, &descs[0], descs[1], descs[2]) // Blob 3 generateIndex(&descs[0], descs[3]) // Blob 4 ctx = context.Background() for i := range blobs { err := storage.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } // Test SelectManifest on an image index when no platform exists in the manifest list and a target platform is provided root = descs[4] targetPlatform = ocispec.Platform{ Architecture: arc_1, OS: os_1, } _, err = SelectManifest(ctx, storage, root, &targetPlatform) expected = fmt.Sprintf("%s: %v: no matching manifest was found in the manifest list", root.Digest, errdef.ErrNotFound) if err.Error() != expected { t.Fatalf("SelectManifest() error = %v, wantErr %v", err, expected) } // Test SelectManifest on an image index when no platform exists in the manifest list and no target platform is provided wantDesc = descs[3] gotDesc, err = SelectManifest(ctx, storage, root, nil) if err != nil { t.Fatalf("SelectManifest() error = %v, wantErr %v", err, false) } if !reflect.DeepEqual(gotDesc, wantDesc) { t.Errorf("SelectManifest() = %v, want %v", gotDesc, wantDesc) } // generate incorrect test content storage = cas.NewMemory() blobs = nil descs = nil appendBlob("test/subject", []byte("dummy subject")) // Blob 0 appendBlob(docker.MediaTypeConfig, []byte(`{"mediaType":"application/vnd.oci.image.config.v1+json", "created":"2022-07-29T08:13:55Z", "author":"test author 1", "architecture":"test-arc-1", "os":"test-os-1", "variant":"v1"}`)) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo1")) // Blob 2 generateManifest(arc_1, os_1, variant_1, true, &descs[0], descs[1], descs[2]) // Blob 3 generateIndex(&descs[0], descs[3]) // Blob 4 ctx = context.Background() for i := range blobs { err := storage.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } // test SelectManifest on manifest, but the manifest is // invalid by having docker mediaType config in the manifest and oci // mediaType in the image config. Should return error. root = descs[3] targetPlatform = ocispec.Platform{ Architecture: arc_1, OS: os_1, } _, err = SelectManifest(ctx, storage, root, &targetPlatform) expected = fmt.Sprintf("fail to recognize platform from unknown config %s: expect %s", docker.MediaTypeConfig, ocispec.MediaTypeImageConfig) if err.Error() != expected { t.Fatalf("SelectManifest() error = %v, wantErr %v", err, expected) } // generate test content with null config blob storage = cas.NewMemory() blobs = nil descs = nil appendBlob("test/subject", []byte("dummy subject")) // Blob 0 appendBlob(ocispec.MediaTypeImageConfig, []byte("null")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo2")) // Blob 2 generateManifest(arc_1, os_1, variant_1, true, &descs[0], descs[1], descs[2]) // Blob 3 generateIndex(nil, descs[3]) // Blob 4 ctx = context.Background() for i := range blobs { err := storage.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } // test SelectManifest on manifest with null config blob, // should return not found error. root = descs[3] targetPlatform = ocispec.Platform{ Architecture: arc_1, OS: os_1, } _, err = SelectManifest(ctx, storage, root, &targetPlatform) expected = fmt.Sprintf("%s: %v: platform in manifest does not match target platform", root.Digest, errdef.ErrNotFound) if err.Error() != expected { t.Fatalf("SelectManifest() error = %v, wantErr %v", err, expected) } // generate test content with empty config blob storage = cas.NewMemory() blobs = nil descs = nil appendBlob("test/subject", []byte("dummy subject")) // Blob 0 appendBlob(ocispec.MediaTypeImageConfig, []byte("")) // Blob 1 appendBlob(ocispec.MediaTypeImageLayer, []byte("foo3")) // Blob 2 generateManifest(arc_1, os_1, variant_1, true, nil, descs[1], descs[2]) // Blob 3 generateIndex(&descs[0], descs[3]) // Blob 4 ctx = context.Background() for i := range blobs { err := storage.Push(ctx, descs[i], bytes.NewReader(blobs[i])) if err != nil { t.Fatalf("failed to push test content to src: %d: %v", i, err) } } // test SelectManifest on manifest with empty config blob // should return not found error root = descs[3] targetPlatform = ocispec.Platform{ Architecture: arc_1, OS: os_1, } _, err = SelectManifest(ctx, storage, root, &targetPlatform) expected = fmt.Sprintf("%s: %v: platform in manifest does not match target platform", root.Digest, errdef.ErrNotFound) if err.Error() != expected { t.Fatalf("SelectManifest() error = %v, wantErr %v", err, expected) } } oras-go-2.5.0/internal/registryutil/000077500000000000000000000000001457674530300174655ustar00rootroot00000000000000oras-go-2.5.0/internal/registryutil/proxy.go000066400000000000000000000051511457674530300211770ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package registryutil import ( "context" "io" "sync" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/internal/cas" "oras.land/oras-go/v2/internal/ioutil" "oras.land/oras-go/v2/registry" ) // ReferenceStorage represents a CAS that supports registry.ReferenceFetcher. type ReferenceStorage interface { content.ReadOnlyStorage registry.ReferenceFetcher } // Proxy is a caching proxy dedicated for registry.ReferenceFetcher. // The first fetch call of a described content will read from the remote and // cache the fetched content. // The subsequent fetch call will read from the local cache. type Proxy struct { registry.ReferenceFetcher *cas.Proxy } // NewProxy creates a proxy for the `base` ReferenceStorage, using the `cache` // storage as the cache. func NewProxy(base ReferenceStorage, cache content.Storage) *Proxy { return &Proxy{ ReferenceFetcher: base, Proxy: cas.NewProxy(base, cache), } } // FetchReference fetches the content identified by the reference from the // remote and cache the fetched content. func (p *Proxy) FetchReference(ctx context.Context, reference string) (ocispec.Descriptor, io.ReadCloser, error) { target, rc, err := p.ReferenceFetcher.FetchReference(ctx, reference) if err != nil { return ocispec.Descriptor{}, nil, err } // skip caching if the content already exists in cache exists, err := p.Cache.Exists(ctx, target) if err != nil { return ocispec.Descriptor{}, nil, err } if exists { return target, rc, nil } // cache content while reading pr, pw := io.Pipe() var wg sync.WaitGroup wg.Add(1) var pushErr error go func() { defer wg.Done() pushErr = p.Cache.Push(ctx, target, pr) if pushErr != nil { pr.CloseWithError(pushErr) } }() closer := ioutil.CloserFunc(func() error { rcErr := rc.Close() if err := pw.Close(); err != nil { return err } wg.Wait() if pushErr != nil { return pushErr } return rcErr }) return target, struct { io.Reader io.Closer }{ Reader: io.TeeReader(rc, pw), Closer: closer, }, nil } oras-go-2.5.0/internal/registryutil/proxy_test.go000066400000000000000000000111321457674530300222320ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package registryutil_test import ( "bytes" "context" "io" "net/http" "net/http/httptest" "net/url" "reflect" "strconv" "strings" "testing" "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/internal/cas" "oras.land/oras-go/v2/internal/registryutil" "oras.land/oras-go/v2/registry/remote" ) func TestProxy_FetchReference(t *testing.T) { content := []byte(`{"manifests":[]}`) desc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageIndex, Digest: digest.FromBytes(content), Size: int64(len(content)), } ref := "foobar" // prepare repository server ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodHead && r.Method != http.MethodGet { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusMethodNotAllowed) return } switch r.URL.Path { case "/v2/test/manifests/" + desc.Digest.String(), "/v2/test/manifests/" + ref: if accept := r.Header.Get("Accept"); !strings.Contains(accept, desc.MediaType) { t.Errorf("manifest not convertable: %s", accept) w.WriteHeader(http.StatusBadRequest) return } w.Header().Set("Content-Type", desc.MediaType) w.Header().Set("Docker-Content-Digest", desc.Digest.String()) w.Header().Set("Content-Length", strconv.Itoa(int(desc.Size))) if r.Method == http.MethodGet { if _, err := w.Write(content); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } } default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repoName := uri.Host + "/test" repo, err := remote.NewRepository(repoName) if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true s := registryutil.NewProxy(repo, cas.NewMemory()) ctx := context.Background() // first FetchReference exists, err := s.Exists(ctx, desc) if err != nil { t.Fatal("Proxy.Exists() error =", err) } if !exists { t.Errorf("Proxy.Exists() = %v, want %v", exists, true) } gotDesc, rc, err := s.FetchReference(ctx, ref) if err != nil { t.Fatal("Proxy.FetchReference() error =", err) } if !reflect.DeepEqual(gotDesc, desc) { t.Errorf("Proxy.FetchReference() = %v, want %v", gotDesc, desc) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Proxy.FetchReference().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Proxy.FetchReference().Close() error =", err) } if !bytes.Equal(got, content) { t.Errorf("Proxy.FetchReference() = %v, want %v", got, content) } // the subsequent fetch should not touch base CAS // nil base will generate panic if the base CAS is touched s.ReadOnlyStorage = nil exists, err = s.Exists(ctx, desc) if err != nil { t.Fatal("Proxy.Exists() error =", err) } if !exists { t.Errorf("Proxy.Exists() = %v, want %v", exists, true) } rc, err = s.Fetch(ctx, desc) if err != nil { t.Fatal("Proxy.Fetch() error =", err) } got, err = io.ReadAll(rc) if err != nil { t.Fatal("Proxy.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Proxy.Fetch().Close() error =", err) } if !bytes.Equal(got, content) { t.Errorf("Proxy.Fetch() = %v, want %v", got, content) } // repeated FetchReference should succeed exists, err = s.Exists(ctx, desc) if err != nil { t.Fatal("Proxy.Exists() error =", err) } if !exists { t.Errorf("Proxy.Exists() = %v, want %v", exists, true) } gotDesc, rc, err = s.FetchReference(ctx, ref) if err != nil { t.Fatal("Proxy.FetchReference() error =", err) } if !reflect.DeepEqual(gotDesc, desc) { t.Errorf("Proxy.FetchReference() = %v, want %v", gotDesc, desc) } got, err = io.ReadAll(rc) if err != nil { t.Fatal("Proxy.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Proxy.Fetch().Close() error =", err) } if !bytes.Equal(got, content) { t.Errorf("Proxy.Fetch() = %v, want %v", got, content) } } oras-go-2.5.0/internal/resolver/000077500000000000000000000000001457674530300165605ustar00rootroot00000000000000oras-go-2.5.0/internal/resolver/memory.go000066400000000000000000000050231457674530300204170ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package resolver import ( "context" "maps" "sync" "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/errdef" "oras.land/oras-go/v2/internal/container/set" ) // Memory is a memory based resolver. type Memory struct { lock sync.RWMutex index map[string]ocispec.Descriptor tags map[digest.Digest]set.Set[string] } // NewMemory creates a new Memory resolver. func NewMemory() *Memory { return &Memory{ index: make(map[string]ocispec.Descriptor), tags: make(map[digest.Digest]set.Set[string]), } } // Resolve resolves a reference to a descriptor. func (m *Memory) Resolve(_ context.Context, reference string) (ocispec.Descriptor, error) { m.lock.RLock() defer m.lock.RUnlock() desc, ok := m.index[reference] if !ok { return ocispec.Descriptor{}, errdef.ErrNotFound } return desc, nil } // Tag tags a descriptor with a reference string. func (m *Memory) Tag(_ context.Context, desc ocispec.Descriptor, reference string) error { m.lock.Lock() defer m.lock.Unlock() m.index[reference] = desc tagSet, ok := m.tags[desc.Digest] if !ok { tagSet = set.New[string]() m.tags[desc.Digest] = tagSet } tagSet.Add(reference) return nil } // Untag removes a reference from index map. func (m *Memory) Untag(reference string) { m.lock.Lock() defer m.lock.Unlock() desc, ok := m.index[reference] if !ok { return } delete(m.index, reference) tagSet := m.tags[desc.Digest] tagSet.Delete(reference) if len(tagSet) == 0 { delete(m.tags, desc.Digest) } } // Map dumps the memory into a built-in map structure. // Like other operations, calling Map() is go-routine safe. func (m *Memory) Map() map[string]ocispec.Descriptor { m.lock.RLock() defer m.lock.RUnlock() return maps.Clone(m.index) } // TagSet returns the set of tags of the descriptor. func (m *Memory) TagSet(desc ocispec.Descriptor) set.Set[string] { m.lock.RLock() defer m.lock.RUnlock() tagSet := m.tags[desc.Digest] return maps.Clone(tagSet) } oras-go-2.5.0/internal/resolver/memory_test.go000066400000000000000000000047451457674530300214700ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package resolver import ( "context" _ "crypto/sha256" "errors" "reflect" "testing" "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/errdef" ) func TestMemorySuccess(t *testing.T) { content := []byte("hello world") desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), } ref := "foobar" s := NewMemory() ctx := context.Background() err := s.Tag(ctx, desc, ref) if err != nil { t.Fatal("Memory.Tag() error =", err) } got, err := s.Resolve(ctx, ref) if err != nil { t.Fatal("Memory.Resolve() error =", err) } if !reflect.DeepEqual(got, desc) { t.Errorf("Memory.Resolve() = %v, want %v", got, desc) } if got := len(s.Map()); got != 1 { t.Errorf("Memory.Map() = %v, want %v", got, 1) } s.Untag(ref) _, err = s.Resolve(ctx, ref) if !errors.Is(err, errdef.ErrNotFound) { t.Errorf("Memory.Resolve() error = %v, want %v", err, errdef.ErrNotFound) } if got := len(s.Map()); got != 0 { t.Errorf("Memory.Map() = %v, want %v", got, 0) } } func TestMemoryNotFound(t *testing.T) { ref := "foobar" s := NewMemory() ctx := context.Background() _, err := s.Resolve(ctx, ref) if !errors.Is(err, errdef.ErrNotFound) { t.Errorf("Memory.Resolve() error = %v, want %v", err, errdef.ErrNotFound) } } func TestTagSet(t *testing.T) { refFoo := "foo" refBar := "bar" s := NewMemory() ctx := context.Background() content := []byte("hello world") desc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), } s.Tag(ctx, desc, refFoo) s.Tag(ctx, desc, refBar) tagSet := s.TagSet(desc) if !tagSet.Contains(refFoo) { t.Fatalf("tagSet should contain %s", refFoo) } if !tagSet.Contains(refBar) { t.Fatalf("tagSet should contain %s", refFoo) } if len(tagSet) != 2 { t.Fatalf("expect size = %d, got %d", 2, len(tagSet)) } } oras-go-2.5.0/internal/spec/000077500000000000000000000000001457674530300156515ustar00rootroot00000000000000oras-go-2.5.0/internal/spec/artifact.go000066400000000000000000000047761457674530300200130ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package spec import ocispec "github.com/opencontainers/image-spec/specs-go/v1" const ( // AnnotationArtifactCreated is the annotation key for the date and time on which the artifact was built, conforming to RFC 3339. AnnotationArtifactCreated = "org.opencontainers.artifact.created" // AnnotationArtifactDescription is the annotation key for the human readable description for the artifact. AnnotationArtifactDescription = "org.opencontainers.artifact.description" // AnnotationReferrersFiltersApplied is the annotation key for the comma separated list of filters applied by the registry in the referrers listing. AnnotationReferrersFiltersApplied = "org.opencontainers.referrers.filtersApplied" ) // MediaTypeArtifactManifest specifies the media type for a content descriptor. const MediaTypeArtifactManifest = "application/vnd.oci.artifact.manifest.v1+json" // Artifact describes an artifact manifest. // This structure provides `application/vnd.oci.artifact.manifest.v1+json` mediatype when marshalled to JSON. // // This manifest type was introduced in image-spec v1.1.0-rc1 and was removed in // image-spec v1.1.0-rc3. It is not part of the current image-spec and is kept // here for Go compatibility. // // Reference: https://github.com/opencontainers/image-spec/pull/999 type Artifact struct { // MediaType is the media type of the object this schema refers to. MediaType string `json:"mediaType"` // ArtifactType is the IANA media type of the artifact this schema refers to. ArtifactType string `json:"artifactType"` // Blobs is a collection of blobs referenced by this manifest. Blobs []ocispec.Descriptor `json:"blobs,omitempty"` // Subject (reference) is an optional link from the artifact to another manifest forming an association between the artifact and the other manifest. Subject *ocispec.Descriptor `json:"subject,omitempty"` // Annotations contains arbitrary metadata for the artifact manifest. Annotations map[string]string `json:"annotations,omitempty"` } oras-go-2.5.0/internal/status/000077500000000000000000000000001457674530300162425ustar00rootroot00000000000000oras-go-2.5.0/internal/status/tracker.go000066400000000000000000000026261457674530300202320ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package status import ( "sync" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/internal/descriptor" ) // Tracker tracks content status described by a descriptor. type Tracker struct { status sync.Map // map[descriptor.Descriptor]chan struct{} } // NewTracker creates a new content status tracker. func NewTracker() *Tracker { return &Tracker{} } // TryCommit tries to commit the work for the target descriptor. // Returns true if committed. A channel is also returned for sending // notifications. Once the work is done, the channel should be closed. // Returns false if the work is done or still in progress. func (t *Tracker) TryCommit(target ocispec.Descriptor) (chan struct{}, bool) { key := descriptor.FromOCI(target) status, exists := t.status.LoadOrStore(key, make(chan struct{})) return status.(chan struct{}), !exists } oras-go-2.5.0/internal/status/tracker_test.go000066400000000000000000000030001457674530300212540ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package status import ( "testing" ocispec "github.com/opencontainers/image-spec/specs-go/v1" ) func TestTracker_TryCommit(t *testing.T) { tracker := NewTracker() var desc ocispec.Descriptor notify, committed := tracker.TryCommit(desc) if !committed { t.Fatalf("Tracker.TryCommit() got = %v, want %v", committed, true) } done, committed := tracker.TryCommit(desc) if committed { t.Fatalf("Tracker.TryCommit() got = %v, want %v", committed, false) } done2, committed := tracker.TryCommit(desc) if committed { t.Fatalf("Tracker.TryCommit() got = %v, want %v", committed, false) } // status: working in progress select { case <-done: t.Fatalf("unexpected done") default: } select { case <-done2: t.Fatalf("unexpected done") default: } // mark status as done close(notify) // status: done select { case <-done: default: t.Fatalf("unexpected in progress") } select { case <-done2: default: t.Fatalf("unexpected in progress") } } oras-go-2.5.0/internal/syncutil/000077500000000000000000000000001457674530300165715ustar00rootroot00000000000000oras-go-2.5.0/internal/syncutil/limit.go000066400000000000000000000040421457674530300202360ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package syncutil import ( "context" "golang.org/x/sync/errgroup" "golang.org/x/sync/semaphore" ) // LimitedRegion provides a way to bound concurrent access to a code block. type LimitedRegion struct { ctx context.Context limiter *semaphore.Weighted ended bool } // LimitRegion creates a new LimitedRegion. func LimitRegion(ctx context.Context, limiter *semaphore.Weighted) *LimitedRegion { if limiter == nil { return nil } return &LimitedRegion{ ctx: ctx, limiter: limiter, ended: true, } } // Start starts the region with concurrency limit. func (lr *LimitedRegion) Start() error { if lr == nil || !lr.ended { return nil } if err := lr.limiter.Acquire(lr.ctx, 1); err != nil { return err } lr.ended = false return nil } // End ends the region with concurrency limit. func (lr *LimitedRegion) End() { if lr == nil || lr.ended { return } lr.limiter.Release(1) lr.ended = true } // GoFunc represents a function that can be invoked by Go. type GoFunc[T any] func(ctx context.Context, region *LimitedRegion, t T) error // Go concurrently invokes fn on items. func Go[T any](ctx context.Context, limiter *semaphore.Weighted, fn GoFunc[T], items ...T) error { eg, egCtx := errgroup.WithContext(ctx) for _, item := range items { region := LimitRegion(ctx, limiter) if err := region.Start(); err != nil { return err } eg.Go(func(t T) func() error { return func() error { defer region.End() return fn(egCtx, region, t) } }(item)) } return eg.Wait() } oras-go-2.5.0/internal/syncutil/limitgroup.go000066400000000000000000000041041457674530300213120ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package syncutil import ( "context" "golang.org/x/sync/errgroup" ) // A LimitedGroup is a collection of goroutines working on subtasks that are part of // the same overall task. type LimitedGroup struct { grp *errgroup.Group ctx context.Context } // LimitGroup returns a new LimitedGroup and an associated Context derived from ctx. // // The number of active goroutines in this group is limited to the given limit. // A negative value indicates no limit. // // The derived Context is canceled the first time a function passed to Go // returns a non-nil error or the first time Wait returns, whichever occurs // first. func LimitGroup(ctx context.Context, limit int) (*LimitedGroup, context.Context) { grp, ctx := errgroup.WithContext(ctx) grp.SetLimit(limit) return &LimitedGroup{grp: grp, ctx: ctx}, ctx } // Go calls the given function in a new goroutine. // It blocks until the new goroutine can be added without the number of // active goroutines in the group exceeding the configured limit. // // The first call to return a non-nil error cancels the group's context. // After which, any subsequent calls to Go will not execute their given function. // The error will be returned by Wait. func (g *LimitedGroup) Go(f func() error) { g.grp.Go(func() error { select { case <-g.ctx.Done(): return g.ctx.Err() default: return f() } }) } // Wait blocks until all function calls from the Go method have returned, then // returns the first non-nil error (if any) from them. func (g *LimitedGroup) Wait() error { return g.grp.Wait() } oras-go-2.5.0/internal/syncutil/merge.go000066400000000000000000000073701457674530300202260ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package syncutil import "sync" // mergeStatus represents the merge status of an item. type mergeStatus struct { // main indicates if items are being merged by the current go-routine. main bool // err represents the error of the merge operation. err error } // Merge represents merge operations on items. // The state transfer is shown as below: // // +----------+ // | Start +--------+-------------+ // +----+-----+ | | // | | | // v v v // +----+-----+ +----+----+ +----+----+ // +-------+ Prepare +<--+ Pending +-->+ Waiting | // | +----+-----+ +---------+ +----+----+ // | | | // | v | // | + ---+---- + | // On Error | Resolve | | // | + ---+---- + | // | | | // | v | // | +----+-----+ | // +------>+ Complete +<---------------------+ // +----+-----+ // | // v // +----+-----+ // | End | // +----------+ type Merge[T any] struct { lock sync.Mutex committed bool items []T status chan mergeStatus pending []T pendingStatus chan mergeStatus } // Do merges concurrent operations of items into a single call of prepare and // resolve. // If Do is called multiple times concurrently, only one of the calls will be // selected to invoke prepare and resolve. func (m *Merge[T]) Do(item T, prepare func() error, resolve func(items []T) error) error { status := <-m.assign(item) if status.main { err := prepare() items := m.commit() if err == nil { err = resolve(items) } m.complete(err) return err } return status.err } // assign adds a new item into the item list. func (m *Merge[T]) assign(item T) <-chan mergeStatus { m.lock.Lock() defer m.lock.Unlock() if m.committed { if m.pendingStatus == nil { m.pendingStatus = make(chan mergeStatus, 1) } m.pending = append(m.pending, item) return m.pendingStatus } if m.status == nil { m.status = make(chan mergeStatus, 1) m.status <- mergeStatus{main: true} } m.items = append(m.items, item) return m.status } // commit closes the assignment window, and the assigned items will be ready // for resolve. func (m *Merge[T]) commit() []T { m.lock.Lock() defer m.lock.Unlock() m.committed = true return m.items } // complete completes the previous merge, and moves the pending items to the // stage for the next merge. func (m *Merge[T]) complete(err error) { // notify results if err == nil { close(m.status) } else { remaining := len(m.items) - 1 status := m.status for remaining > 0 { status <- mergeStatus{err: err} remaining-- } } // move pending items to the stage m.lock.Lock() defer m.lock.Unlock() m.committed = false m.items = m.pending m.status = m.pendingStatus m.pending = nil m.pendingStatus = nil if m.status != nil { m.status <- mergeStatus{main: true} } } oras-go-2.5.0/internal/syncutil/merge_test.go000066400000000000000000000027001457674530300212550ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package syncutil import ( "context" "testing" "golang.org/x/sync/errgroup" ) func TestMerge(t *testing.T) { var merge Merge[int] // generate expected result size := 100 factor := -1 var expected int for i := 1; i < size; i++ { expected += i * factor } // test merge ctx := context.Background() eg, _ := errgroup.WithContext(ctx) var result int for i := 1; i < size; i++ { eg.Go(func(num int) func() error { return func() error { var f int getFactor := func() error { f = factor return nil } calculate := func(items []int) error { for _, item := range items { result += item * f } return nil } return merge.Do(num, getFactor, calculate) } }(i)) } if err := eg.Wait(); err != nil { t.Errorf("Merge.Do() error = %v, wantErr %v", err, nil) } if result != expected { t.Errorf("Merge.Do() = %v, want %v", result, expected) } } oras-go-2.5.0/internal/syncutil/once.go000066400000000000000000000051311457674530300200440ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package syncutil import ( "context" "sync" "sync/atomic" ) // Once is an object that will perform exactly one action. // Unlike sync.Once, this Once allows the action to have return values. type Once struct { result interface{} err error status chan bool } // NewOnce creates a new Once instance. func NewOnce() *Once { status := make(chan bool, 1) status <- true return &Once{ status: status, } } // Do calls the function f if and only if Do is being called first time or all // previous function calls are cancelled, deadline exceeded, or panicking. // When `once.Do(ctx, f)` is called multiple times, the return value of the // first call of the function f is stored, and is directly returned for other // calls. // Besides the return value of the function f, including the error, Do returns // true if the function f passed is called first and is not cancelled, deadline // exceeded, or panicking. Otherwise, returns false. func (o *Once) Do(ctx context.Context, f func() (interface{}, error)) (bool, interface{}, error) { defer func() { if r := recover(); r != nil { o.status <- true panic(r) } }() for { select { case inProgress := <-o.status: if !inProgress { return false, o.result, o.err } result, err := f() if err == context.Canceled || err == context.DeadlineExceeded { o.status <- true return false, nil, err } o.result, o.err = result, err close(o.status) return true, result, err case <-ctx.Done(): return false, nil, ctx.Err() } } } // OnceOrRetry is an object that will perform exactly one success action. type OnceOrRetry struct { done atomic.Bool lock sync.Mutex } // OnceOrRetry calls the function f if and only if Do is being called for the // first time for this instance of Once or all previous calls to Do are failed. func (o *OnceOrRetry) Do(f func() error) error { // fast path if o.done.Load() { return nil } // slow path o.lock.Lock() defer o.lock.Unlock() if o.done.Load() { return nil } if err := f(); err != nil { return err } o.done.Store(true) return nil } oras-go-2.5.0/internal/syncutil/once_test.go000066400000000000000000000154311457674530300211070ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package syncutil import ( "context" "errors" "io" "reflect" "strconv" "sync" "sync/atomic" "testing" "time" ) func TestOnce_Do(t *testing.T) { var f []func() (interface{}, error) for i := 0; i < 100; i++ { f = append(f, func(i int) func() (interface{}, error) { return func() (interface{}, error) { return i + 1, errors.New(strconv.Itoa(i)) } }(i)) } once := NewOnce() first := make([]bool, len(f)) result := make([]interface{}, len(f)) err := make([]error, len(f)) var wg sync.WaitGroup for i := 0; i < len(f); i++ { wg.Add(1) go func(i int) { defer wg.Done() ctx := context.Background() first[i], result[i], err[i] = once.Do(ctx, f[i]) }(i) } wg.Wait() target := 0 for i := 0; i < len(f); i++ { if first[i] { target = i break } } targetErr := err[target] if targetErr == nil || targetErr.Error() != strconv.Itoa(target) { t.Errorf("Once.Do(%d) error = %v, wantErr %v", target, targetErr, strconv.Itoa(target)) } wantResult := target + 1 wantErr := targetErr for i := 0; i < len(f); i++ { wantFirst := false if i == target { wantFirst = true } if first[i] != wantFirst { t.Errorf("Once.Do(%d) first = %v, want %v", i, first[i], wantFirst) } if err[i] != wantErr { t.Errorf("Once.Do(%d) error = %v, wantErr %v", i, err[i], wantErr) } if !reflect.DeepEqual(result[i], wantResult) { t.Errorf("Once.Do(%d) result = %v, want %v", i, result[i], wantResult) } } } func TestOnce_Do_Cancel_Context(t *testing.T) { once := NewOnce() var wg sync.WaitGroup var ( first bool result interface{} err error ) wg.Add(1) go func() { defer wg.Done() ctx := context.Background() first, result, err = once.Do(ctx, func() (interface{}, error) { time.Sleep(200 * time.Millisecond) return "foo", io.EOF }) }() time.Sleep(100 * time.Millisecond) ctx := context.Background() ctx, cancel := context.WithCancel(ctx) cancel() first2, result2, err2 := once.Do(ctx, func() (interface{}, error) { return "bar", nil }) wg.Wait() if wantFirst := true; first != wantFirst { t.Fatalf("Once.Do() first = %v, want %v", first, wantFirst) } if wantErr := io.EOF; err != wantErr { t.Fatalf("Once.Do() error = %v, wantErr %v", err, wantErr) } if wantResult := "foo"; !reflect.DeepEqual(result, wantResult) { t.Fatalf("Once.Do() result = %v, want %v", result, wantResult) } if wantFirst := false; first2 != wantFirst { t.Fatalf("Once.Do() first = %v, want %v", first2, wantFirst) } if wantErr := context.Canceled; err2 != wantErr { t.Fatalf("Once.Do() error = %v, wantErr %v", err2, wantErr) } if wantResult := interface{}(nil); !reflect.DeepEqual(result2, wantResult) { t.Fatalf("Once.Do() result = %v, want %v", result2, wantResult) } } func TestOnce_Do_Cancel_Function(t *testing.T) { ctx := context.Background() once := NewOnce() first, result, err := once.Do(ctx, func() (interface{}, error) { return "foo", context.Canceled }) if wantFirst := false; first != wantFirst { t.Fatalf("Once.Do() first = %v, want %v", first, wantFirst) } if wantErr := context.Canceled; err != wantErr { t.Fatalf("Once.Do() error = %v, wantErr %v", err, wantErr) } if wantResult := interface{}(nil); !reflect.DeepEqual(result, wantResult) { t.Fatalf("Once.Do() result = %v, want %v", result, wantResult) } first, result, err = once.Do(ctx, func() (interface{}, error) { return "bar", io.EOF }) if wantFirst := true; first != wantFirst { t.Fatalf("Once.Do() first = %v, want %v", first, wantFirst) } if wantErr := io.EOF; err != wantErr { t.Fatalf("Once.Do() error = %v, wantErr %v", err, wantErr) } if wantResult := "bar"; !reflect.DeepEqual(result, wantResult) { t.Fatalf("Once.Do() result = %v, want %v", result, wantResult) } } func TestOnce_Do_Cancel_Panic(t *testing.T) { ctx := context.Background() once := NewOnce() func() { defer func() { got := recover() want := "foo" if got != want { t.Fatalf("Once.Do() panic = %v, want %v", got, want) } }() once.Do(ctx, func() (interface{}, error) { panic("foo") }) }() first, result, err := once.Do(ctx, func() (interface{}, error) { return "bar", io.EOF }) if wantFirst := true; first != wantFirst { t.Fatalf("Once.Do() first = %v, want %v", first, wantFirst) } if wantErr := io.EOF; err != wantErr { t.Fatalf("Once.Do() error = %v, wantErr %v", err, wantErr) } if wantResult := "bar"; !reflect.DeepEqual(result, wantResult) { t.Fatalf("Once.Do() result = %v, want %v", result, wantResult) } } func TestOnceOrRetry_Do(t *testing.T) { var once OnceOrRetry var count atomic.Int32 var wg sync.WaitGroup for i := 0; i < 100; i++ { wg.Add(1) go func() { defer wg.Done() err := once.Do(func() error { count.Add(1) return nil }) if err != nil { t.Errorf("OnceOrRetry.Do() error = %v, wantErr %v", err, nil) } }() } wg.Wait() if got := count.Load(); got != 1 { t.Fatal("OnceOrRetry.Do() called more than once") } } func TestOnceOrRetry_Do_Fail(t *testing.T) { var once OnceOrRetry var wg sync.WaitGroup // test failure for i := 0; i < 100; i++ { wg.Add(1) go func(wantErr error) { defer wg.Done() err := once.Do(func() error { return wantErr }) if err != wantErr { t.Errorf("OnceOrRetry.Do() error = %v, wantErr %v", err, wantErr) } }(errors.New(strconv.Itoa(i))) } wg.Wait() // retry after failure err := once.Do(func() error { return nil }) if err != nil { t.Fatalf("OnceOrRetry.Do() error = %v, wantErr %v", err, nil) } // no retry after success err = once.Do(func() error { t.Fatal("OnceOrRetry.Do() called twice") return nil }) if err != nil { t.Fatalf("OnceOrRetry.Do() error = %v, wantErr %v", err, nil) } } func TestOnceOrRetry_Do_Panic(t *testing.T) { var once OnceOrRetry // test panic func() { defer func() { if r := recover(); r == nil { t.Fatal("OnceOrRetry.Do() did not panic") } }() _ = once.Do(func() error { panic("failed") }) }() // retry after panic err := once.Do(func() error { return nil }) if err != nil { t.Fatalf("OnceOrRetry.Do() error = %v, wantErr %v", err, nil) } // no retry after success err = once.Do(func() error { t.Fatal("OnceOrRetry.Do() called twice") return nil }) if err != nil { t.Fatalf("OnceOrRetry.Do() error = %v, wantErr %v", err, nil) } } oras-go-2.5.0/internal/syncutil/pool.go000066400000000000000000000030351457674530300200720ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package syncutil import "sync" // poolItem represents an item in Pool. type poolItem[T any] struct { value T refCount int } // Pool is a scalable pool with items identified by keys. type Pool[T any] struct { // New optionally specifies a function to generate a value when Get would // otherwise return nil. // It may not be changed concurrently with calls to Get. New func() T lock sync.Mutex items map[any]*poolItem[T] } // Get gets the value identified by key. // The caller should invoke the returned function after using the returned item. func (p *Pool[T]) Get(key any) (*T, func()) { p.lock.Lock() defer p.lock.Unlock() item, ok := p.items[key] if !ok { if p.items == nil { p.items = make(map[any]*poolItem[T]) } item = &poolItem[T]{} if p.New != nil { item.value = p.New() } p.items[key] = item } item.refCount++ return &item.value, func() { p.lock.Lock() defer p.lock.Unlock() item.refCount-- if item.refCount <= 0 { delete(p.items, key) } } } oras-go-2.5.0/internal/syncutil/pool_test.go000066400000000000000000000031521457674530300211310ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package syncutil import ( "sync" "sync/atomic" "testing" ) func TestPool(t *testing.T) { var pool Pool[int64] numbers := [][]int{ {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, {-1, -2, -3, -4, -5, -6, -7, -8, -9, -10}, } // generate expected result expected := make([]int, len(numbers)) for i, nums := range numbers { for _, num := range nums { expected[i] += num } } // test pool for i, nums := range numbers { val, done := pool.Get(i) *val = 0 var wg sync.WaitGroup for _, num := range nums { wg.Add(1) go func(n int) { defer wg.Done() val, done := pool.Get(i) defer done() atomic.AddInt64(val, int64(n)) }(num) } wg.Wait() item := pool.items[i] if got := item.value; got != int64(expected[i]) { t.Errorf("Pool.Get(%v).value = %v, want %v", i, got, expected[i]) } if got := item.refCount; got != 1 { t.Errorf("Pool.Get(%v).refCount = %v, want %v", i, got, 1) } // item should be cleaned up after done done() got := pool.items[i] if got != nil { t.Errorf("Pool.Get(%v) = %v, want %v", i, got, nil) } } } oras-go-2.5.0/pack.go000066400000000000000000000424731457674530300143620ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package oras import ( "bytes" "context" "encoding/json" "errors" "fmt" "maps" "regexp" "time" specs "github.com/opencontainers/image-spec/specs-go" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/errdef" "oras.land/oras-go/v2/internal/spec" ) const ( // MediaTypeUnknownConfig is the default config mediaType used // - for [Pack] when PackOptions.PackImageManifest is true and // PackOptions.ConfigDescriptor is not specified. // - for [PackManifest] when packManifestVersion is PackManifestVersion1_0 // and PackManifestOptions.ConfigDescriptor is not specified. MediaTypeUnknownConfig = "application/vnd.unknown.config.v1+json" // MediaTypeUnknownArtifact is the default artifactType used for [Pack] // when PackOptions.PackImageManifest is false and artifactType is // not specified. MediaTypeUnknownArtifact = "application/vnd.unknown.artifact.v1" ) var ( // ErrInvalidDateTimeFormat is returned by [Pack] and [PackManifest] when // AnnotationArtifactCreated or AnnotationCreated is provided, but its value // is not in RFC 3339 format. // Reference: https://www.rfc-editor.org/rfc/rfc3339#section-5.6 ErrInvalidDateTimeFormat = errors.New("invalid date and time format") // ErrMissingArtifactType is returned by [PackManifest] when // packManifestVersion is PackManifestVersion1_1 and artifactType is // empty and the config media type is set to // "application/vnd.oci.empty.v1+json". ErrMissingArtifactType = errors.New("missing artifact type") ) // PackManifestVersion represents the manifest version used for [PackManifest]. type PackManifestVersion int const ( // PackManifestVersion1_0 represents the OCI Image Manifest defined in // image-spec v1.0.2. // Reference: https://github.com/opencontainers/image-spec/blob/v1.0.2/manifest.md PackManifestVersion1_0 PackManifestVersion = 1 // PackManifestVersion1_1_RC4 represents the OCI Image Manifest defined // in image-spec v1.1.0-rc4. // Reference: https://github.com/opencontainers/image-spec/blob/v1.1.0-rc4/manifest.md // // Deprecated: This constant is deprecated and not recommended for future use. // Use [PackManifestVersion1_1] instead. PackManifestVersion1_1_RC4 PackManifestVersion = PackManifestVersion1_1 // PackManifestVersion1_1 represents the OCI Image Manifest defined in // image-spec v1.1.0. // Reference: https://github.com/opencontainers/image-spec/blob/v1.1.0/manifest.md PackManifestVersion1_1 PackManifestVersion = 2 ) // PackManifestOptions contains optional parameters for [PackManifest]. type PackManifestOptions struct { // Subject is the subject of the manifest. // This option is only valid when PackManifestVersion is // NOT PackManifestVersion1_0. Subject *ocispec.Descriptor // Layers is the layers of the manifest. Layers []ocispec.Descriptor // ManifestAnnotations is the annotation map of the manifest. ManifestAnnotations map[string]string // ConfigDescriptor is a pointer to the descriptor of the config blob. // If not nil, ConfigAnnotations will be ignored. ConfigDescriptor *ocispec.Descriptor // ConfigAnnotations is the annotation map of the config descriptor. // This option is valid only when ConfigDescriptor is nil. ConfigAnnotations map[string]string } // mediaTypeRegexp checks the format of media types. // References: // - https://github.com/opencontainers/image-spec/blob/v1.1.0/schema/defs-descriptor.json#L7 // - https://datatracker.ietf.org/doc/html/rfc6838#section-4.2 var mediaTypeRegexp = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9!#$&-^_.+]{0,126}/[A-Za-z0-9][A-Za-z0-9!#$&-^_.+]{0,126}$`) // PackManifest generates an OCI Image Manifest based on the given parameters // and pushes the packed manifest to a content storage using pusher. The version // of the manifest to be packed is determined by packManifestVersion // (Recommended value: PackManifestVersion1_1). // // - If packManifestVersion is [PackManifestVersion1_1]: // artifactType MUST NOT be empty unless opts.ConfigDescriptor is specified. // - If packManifestVersion is [PackManifestVersion1_0]: // if opts.ConfigDescriptor is nil, artifactType will be used as the // config media type; if artifactType is empty, // "application/vnd.unknown.config.v1+json" will be used. // if opts.ConfigDescriptor is NOT nil, artifactType will be ignored. // // artifactType and opts.ConfigDescriptor.MediaType MUST comply with RFC 6838. // // If succeeded, returns a descriptor of the packed manifest. func PackManifest(ctx context.Context, pusher content.Pusher, packManifestVersion PackManifestVersion, artifactType string, opts PackManifestOptions) (ocispec.Descriptor, error) { switch packManifestVersion { case PackManifestVersion1_0: return packManifestV1_0(ctx, pusher, artifactType, opts) case PackManifestVersion1_1: return packManifestV1_1(ctx, pusher, artifactType, opts) default: return ocispec.Descriptor{}, fmt.Errorf("PackManifestVersion(%v): %w", packManifestVersion, errdef.ErrUnsupported) } } // PackOptions contains optional parameters for [Pack]. // // Deprecated: This type is deprecated and not recommended for future use. // Use [PackManifestOptions] instead. type PackOptions struct { // Subject is the subject of the manifest. Subject *ocispec.Descriptor // ManifestAnnotations is the annotation map of the manifest. ManifestAnnotations map[string]string // PackImageManifest controls whether to pack an OCI Image Manifest or not. // - If true, pack an OCI Image Manifest. // - If false, pack an OCI Artifact Manifest (deprecated). // // Default value: false. PackImageManifest bool // ConfigDescriptor is a pointer to the descriptor of the config blob. // If not nil, artifactType will be implied by the mediaType of the // specified ConfigDescriptor, and ConfigAnnotations will be ignored. // This option is valid only when PackImageManifest is true. ConfigDescriptor *ocispec.Descriptor // ConfigAnnotations is the annotation map of the config descriptor. // This option is valid only when PackImageManifest is true // and ConfigDescriptor is nil. ConfigAnnotations map[string]string } // Pack packs the given blobs, generates a manifest for the pack, // and pushes it to a content storage. // // When opts.PackImageManifest is true, artifactType will be used as the // the config descriptor mediaType of the image manifest. // // If succeeded, returns a descriptor of the manifest. // // Deprecated: This method is deprecated and not recommended for future use. // Use [PackManifest] instead. func Pack(ctx context.Context, pusher content.Pusher, artifactType string, blobs []ocispec.Descriptor, opts PackOptions) (ocispec.Descriptor, error) { if opts.PackImageManifest { return packManifestV1_1_RC2(ctx, pusher, artifactType, blobs, opts) } return packArtifact(ctx, pusher, artifactType, blobs, opts) } // packArtifact packs an Artifact manifest as defined in image-spec v1.1.0-rc2. // Reference: https://github.com/opencontainers/image-spec/blob/v1.1.0-rc2/artifact.md func packArtifact(ctx context.Context, pusher content.Pusher, artifactType string, blobs []ocispec.Descriptor, opts PackOptions) (ocispec.Descriptor, error) { if artifactType == "" { artifactType = MediaTypeUnknownArtifact } annotations, err := ensureAnnotationCreated(opts.ManifestAnnotations, spec.AnnotationArtifactCreated) if err != nil { return ocispec.Descriptor{}, err } manifest := spec.Artifact{ MediaType: spec.MediaTypeArtifactManifest, ArtifactType: artifactType, Blobs: blobs, Subject: opts.Subject, Annotations: annotations, } return pushManifest(ctx, pusher, manifest, manifest.MediaType, manifest.ArtifactType, manifest.Annotations) } // packManifestV1_0 packs an image manifest defined in image-spec v1.0.2. // Reference: https://github.com/opencontainers/image-spec/blob/v1.0.2/manifest.md func packManifestV1_0(ctx context.Context, pusher content.Pusher, artifactType string, opts PackManifestOptions) (ocispec.Descriptor, error) { if opts.Subject != nil { return ocispec.Descriptor{}, fmt.Errorf("subject is not supported for manifest version %v: %w", PackManifestVersion1_0, errdef.ErrUnsupported) } // prepare config var configDesc ocispec.Descriptor if opts.ConfigDescriptor != nil { if err := validateMediaType(opts.ConfigDescriptor.MediaType); err != nil { return ocispec.Descriptor{}, fmt.Errorf("invalid config mediaType format: %w", err) } configDesc = *opts.ConfigDescriptor } else { if artifactType == "" { artifactType = MediaTypeUnknownConfig } else if err := validateMediaType(artifactType); err != nil { return ocispec.Descriptor{}, fmt.Errorf("invalid artifactType format: %w", err) } var err error configDesc, err = pushCustomEmptyConfig(ctx, pusher, artifactType, opts.ConfigAnnotations) if err != nil { return ocispec.Descriptor{}, err } } annotations, err := ensureAnnotationCreated(opts.ManifestAnnotations, ocispec.AnnotationCreated) if err != nil { return ocispec.Descriptor{}, err } if opts.Layers == nil { opts.Layers = []ocispec.Descriptor{} // make it an empty array to prevent potential server-side bugs } manifest := ocispec.Manifest{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, Config: configDesc, MediaType: ocispec.MediaTypeImageManifest, Layers: opts.Layers, Annotations: annotations, } return pushManifest(ctx, pusher, manifest, manifest.MediaType, manifest.Config.MediaType, manifest.Annotations) } // packManifestV1_1_RC2 packs an image manifest as defined in image-spec // v1.1.0-rc2. // Reference: https://github.com/opencontainers/image-spec/blob/v1.1.0-rc2/manifest.md func packManifestV1_1_RC2(ctx context.Context, pusher content.Pusher, configMediaType string, layers []ocispec.Descriptor, opts PackOptions) (ocispec.Descriptor, error) { if configMediaType == "" { configMediaType = MediaTypeUnknownConfig } // prepare config var configDesc ocispec.Descriptor if opts.ConfigDescriptor != nil { configDesc = *opts.ConfigDescriptor } else { var err error configDesc, err = pushCustomEmptyConfig(ctx, pusher, configMediaType, opts.ConfigAnnotations) if err != nil { return ocispec.Descriptor{}, err } } annotations, err := ensureAnnotationCreated(opts.ManifestAnnotations, ocispec.AnnotationCreated) if err != nil { return ocispec.Descriptor{}, err } if layers == nil { layers = []ocispec.Descriptor{} // make it an empty array to prevent potential server-side bugs } manifest := ocispec.Manifest{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, Config: configDesc, MediaType: ocispec.MediaTypeImageManifest, Layers: layers, Subject: opts.Subject, Annotations: annotations, } return pushManifest(ctx, pusher, manifest, manifest.MediaType, manifest.Config.MediaType, manifest.Annotations) } // packManifestV1_1 packs an image manifest defined in image-spec v1.1.0. // Reference: https://github.com/opencontainers/image-spec/blob/v1.1.0/manifest.md#guidelines-for-artifact-usage func packManifestV1_1(ctx context.Context, pusher content.Pusher, artifactType string, opts PackManifestOptions) (ocispec.Descriptor, error) { if artifactType == "" && (opts.ConfigDescriptor == nil || opts.ConfigDescriptor.MediaType == ocispec.MediaTypeEmptyJSON) { // artifactType MUST be set when config.mediaType is set to the empty value return ocispec.Descriptor{}, ErrMissingArtifactType } if artifactType != "" { if err := validateMediaType(artifactType); err != nil { return ocispec.Descriptor{}, fmt.Errorf("invalid artifactType format: %w", err) } } // prepare config var emptyBlobExists bool var configDesc ocispec.Descriptor if opts.ConfigDescriptor != nil { if err := validateMediaType(opts.ConfigDescriptor.MediaType); err != nil { return ocispec.Descriptor{}, fmt.Errorf("invalid config mediaType format: %w", err) } configDesc = *opts.ConfigDescriptor } else { // use the empty descriptor for config configDesc = ocispec.DescriptorEmptyJSON configDesc.Annotations = opts.ConfigAnnotations configBytes := ocispec.DescriptorEmptyJSON.Data // push config if err := pushIfNotExist(ctx, pusher, configDesc, configBytes); err != nil { return ocispec.Descriptor{}, fmt.Errorf("failed to push config: %w", err) } emptyBlobExists = true } annotations, err := ensureAnnotationCreated(opts.ManifestAnnotations, ocispec.AnnotationCreated) if err != nil { return ocispec.Descriptor{}, err } if len(opts.Layers) == 0 { // use the empty descriptor as the single layer layerDesc := ocispec.DescriptorEmptyJSON layerData := ocispec.DescriptorEmptyJSON.Data if !emptyBlobExists { if err := pushIfNotExist(ctx, pusher, layerDesc, layerData); err != nil { return ocispec.Descriptor{}, fmt.Errorf("failed to push layer: %w", err) } } opts.Layers = []ocispec.Descriptor{layerDesc} } manifest := ocispec.Manifest{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, Config: configDesc, MediaType: ocispec.MediaTypeImageManifest, Layers: opts.Layers, Subject: opts.Subject, ArtifactType: artifactType, Annotations: annotations, } return pushManifest(ctx, pusher, manifest, manifest.MediaType, manifest.ArtifactType, manifest.Annotations) } // pushIfNotExist pushes data described by desc if it does not exist in the // target. func pushIfNotExist(ctx context.Context, pusher content.Pusher, desc ocispec.Descriptor, data []byte) error { if ros, ok := pusher.(content.ReadOnlyStorage); ok { exists, err := ros.Exists(ctx, desc) if err != nil { return fmt.Errorf("failed to check existence: %s: %s: %w", desc.Digest.String(), desc.MediaType, err) } if exists { return nil } } if err := pusher.Push(ctx, desc, bytes.NewReader(data)); err != nil && !errors.Is(err, errdef.ErrAlreadyExists) { return fmt.Errorf("failed to push: %s: %s: %w", desc.Digest.String(), desc.MediaType, err) } return nil } // pushManifest marshals manifest into JSON bytes and pushes it. func pushManifest(ctx context.Context, pusher content.Pusher, manifest any, mediaType string, artifactType string, annotations map[string]string) (ocispec.Descriptor, error) { manifestJSON, err := json.Marshal(manifest) if err != nil { return ocispec.Descriptor{}, fmt.Errorf("failed to marshal manifest: %w", err) } manifestDesc := content.NewDescriptorFromBytes(mediaType, manifestJSON) // populate ArtifactType and Annotations of the manifest into manifestDesc manifestDesc.ArtifactType = artifactType manifestDesc.Annotations = annotations // push manifest if err := pusher.Push(ctx, manifestDesc, bytes.NewReader(manifestJSON)); err != nil && !errors.Is(err, errdef.ErrAlreadyExists) { return ocispec.Descriptor{}, fmt.Errorf("failed to push manifest: %w", err) } return manifestDesc, nil } // pushCustomEmptyConfig generates and pushes an empty config blob. func pushCustomEmptyConfig(ctx context.Context, pusher content.Pusher, mediaType string, annotations map[string]string) (ocispec.Descriptor, error) { // Use an empty JSON object here, because some registries may not accept // empty config blob. // As of September 2022, GAR is known to return 400 on empty blob upload. // See https://github.com/oras-project/oras-go/issues/294 for details. configBytes := []byte("{}") configDesc := content.NewDescriptorFromBytes(mediaType, configBytes) configDesc.Annotations = annotations // push config if err := pushIfNotExist(ctx, pusher, configDesc, configBytes); err != nil { return ocispec.Descriptor{}, fmt.Errorf("failed to push config: %w", err) } return configDesc, nil } // ensureAnnotationCreated ensures that annotationCreatedKey is in annotations, // and that its value conforms to RFC 3339. Otherwise returns a new annotation // map with annotationCreatedKey created. func ensureAnnotationCreated(annotations map[string]string, annotationCreatedKey string) (map[string]string, error) { if createdTime, ok := annotations[annotationCreatedKey]; ok { // if annotationCreatedKey is provided, validate its format if _, err := time.Parse(time.RFC3339, createdTime); err != nil { return nil, fmt.Errorf("%w: %v", ErrInvalidDateTimeFormat, err) } return annotations, nil } // copy the original annotation map copied := make(map[string]string, len(annotations)+1) maps.Copy(copied, annotations) // set creation time in RFC 3339 format // reference: https://github.com/opencontainers/image-spec/blob/v1.1.0-rc2/annotations.md#pre-defined-annotation-keys now := time.Now().UTC() copied[annotationCreatedKey] = now.Format(time.RFC3339) return copied, nil } // validateMediaType validates the format of mediaType. func validateMediaType(mediaType string) error { if !mediaTypeRegexp.MatchString(mediaType) { return fmt.Errorf("%s: %w", mediaType, errdef.ErrInvalidMediaType) } return nil } oras-go-2.5.0/pack_test.go000066400000000000000000001036411457674530300154140ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package oras import ( "bytes" "context" "encoding/json" "errors" "io" "reflect" "testing" "time" "github.com/opencontainers/go-digest" specs "github.com/opencontainers/image-spec/specs-go" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/content/memory" "oras.land/oras-go/v2/errdef" "oras.land/oras-go/v2/internal/spec" ) func Test_Pack_Artifact_NoOption(t *testing.T) { s := memory.New() // prepare test content blobs := []ocispec.Descriptor{ content.NewDescriptorFromBytes("test", []byte("hello world")), content.NewDescriptorFromBytes("test", []byte("goodbye world")), } artifactType := "application/vnd.test" // test Pack ctx := context.Background() manifestDesc, err := Pack(ctx, s, artifactType, blobs, PackOptions{}) if err != nil { t.Fatal("Oras.Pack() error =", err) } // verify blobs var manifest spec.Artifact rc, err := s.Fetch(ctx, manifestDesc) if err != nil { t.Fatal("Store.Fetch() error =", err) } if err := json.NewDecoder(rc).Decode(&manifest); err != nil { t.Fatal("error decoding manifest, error =", err) } if err := rc.Close(); err != nil { t.Fatal("Store.Fetch().Close() error =", err) } if !reflect.DeepEqual(manifest.Blobs, blobs) { t.Errorf("Store.Fetch() = %v, want %v", manifest.Blobs, blobs) } // verify media type if got := manifest.MediaType; got != spec.MediaTypeArtifactManifest { t.Fatalf("got media type = %s, want %s", got, spec.MediaTypeArtifactManifest) } // verify artifact type if got := manifest.ArtifactType; got != artifactType { t.Fatalf("got artifact type = %s, want %s", got, artifactType) } // verify created time annotation createdTime, ok := manifest.Annotations[spec.AnnotationArtifactCreated] if !ok { t.Errorf("Annotation %s = %v, want %v", spec.AnnotationArtifactCreated, ok, true) } _, err = time.Parse(time.RFC3339, createdTime) if err != nil { t.Errorf("error parsing created time: %s, error = %v", createdTime, err) } // verify descriptor artifact type if want := manifest.ArtifactType; !reflect.DeepEqual(manifestDesc.ArtifactType, want) { t.Errorf("got descriptor artifactType = %v, want %v", manifestDesc.ArtifactType, want) } // verify descriptor annotations if want := manifest.Annotations; !reflect.DeepEqual(manifestDesc.Annotations, want) { t.Errorf("got descriptor annotations = %v, want %v", manifestDesc.Annotations, want) } } func Test_Pack_Artifact_WithOptions(t *testing.T) { s := memory.New() // prepare test content blobs := []ocispec.Descriptor{ content.NewDescriptorFromBytes("test", []byte("hello world")), content.NewDescriptorFromBytes("test", []byte("goodbye world")), } artifactType := "application/vnd.test" annotations := map[string]string{ spec.AnnotationArtifactCreated: "2000-01-01T00:00:00Z", "foo": "bar", } subjectManifest := []byte(`{"layers":[]}`) subjectDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageManifest, Digest: digest.FromBytes(subjectManifest), Size: int64(len(subjectManifest)), ArtifactType: artifactType, Annotations: annotations, } configBytes := []byte("{}") configDesc := content.NewDescriptorFromBytes("application/vnd.test.config", configBytes) configAnnotations := map[string]string{"foo": "bar"} // test Pack ctx := context.Background() opts := PackOptions{ Subject: &subjectDesc, ManifestAnnotations: annotations, ConfigDescriptor: &configDesc, // should not work ConfigAnnotations: configAnnotations, // should not work } manifestDesc, err := Pack(ctx, s, artifactType, blobs, opts) if err != nil { t.Fatal("Oras.Pack() error =", err) } expectedManifest := spec.Artifact{ MediaType: spec.MediaTypeArtifactManifest, ArtifactType: artifactType, Blobs: blobs, Subject: opts.Subject, Annotations: annotations, } expectedManifestBytes, err := json.Marshal(expectedManifest) if err != nil { t.Fatal("failed to marshal manifest:", err) } // verify manifest rc, err := s.Fetch(ctx, manifestDesc) if err != nil { t.Fatal("Store.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Store.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got, expectedManifestBytes) { t.Errorf("Store.Fetch() = %v, want %v", got, expectedManifestBytes) } // verify descriptor expectedManifestDesc := content.NewDescriptorFromBytes(expectedManifest.MediaType, expectedManifestBytes) expectedManifestDesc.ArtifactType = expectedManifest.ArtifactType expectedManifestDesc.Annotations = expectedManifest.Annotations if !reflect.DeepEqual(manifestDesc, expectedManifestDesc) { t.Errorf("Pack() = %v, want %v", manifestDesc, expectedManifestDesc) } } func Test_Pack_Artifact_NoBlob(t *testing.T) { s := memory.New() // test Pack ctx := context.Background() artifactType := "application/vnd.test" manifestDesc, err := Pack(ctx, s, artifactType, nil, PackOptions{}) if err != nil { t.Fatal("Oras.Pack() error =", err) } var manifest spec.Artifact rc, err := s.Fetch(ctx, manifestDesc) if err != nil { t.Fatal("Store.Fetch() error =", err) } if err := json.NewDecoder(rc).Decode(&manifest); err != nil { t.Fatal("error decoding manifest, error =", err) } if err := rc.Close(); err != nil { t.Fatal("Store.Fetch().Close() error =", err) } // verify blobs var expectedBlobs []ocispec.Descriptor if !reflect.DeepEqual(manifest.Blobs, expectedBlobs) { t.Errorf("Store.Fetch() = %v, want %v", manifest.Blobs, expectedBlobs) } } func Test_Pack_Artifact_NoArtifactType(t *testing.T) { s := memory.New() ctx := context.Background() manifestDesc, err := Pack(ctx, s, "", nil, PackOptions{}) if err != nil { t.Fatal("Oras.Pack() error =", err) } var manifest spec.Artifact rc, err := s.Fetch(ctx, manifestDesc) if err != nil { t.Fatal("Store.Fetch() error =", err) } if err := json.NewDecoder(rc).Decode(&manifest); err != nil { t.Fatal("error decoding manifest, error =", err) } if err := rc.Close(); err != nil { t.Fatal("Store.Fetch().Close() error =", err) } // verify artifact type if manifestDesc.ArtifactType != MediaTypeUnknownArtifact { t.Fatalf("got artifact type = %s, want %s", manifestDesc.ArtifactType, MediaTypeUnknownArtifact) } if manifest.ArtifactType != MediaTypeUnknownArtifact { t.Fatalf("got artifact type = %s, want %s", manifest.ArtifactType, MediaTypeUnknownArtifact) } } func Test_Pack_Artifact_InvalidDateTimeFormat(t *testing.T) { s := memory.New() ctx := context.Background() opts := PackOptions{ ManifestAnnotations: map[string]string{ spec.AnnotationArtifactCreated: "2000/01/01 00:00:00", }, } artifactType := "application/vnd.test" _, err := Pack(ctx, s, artifactType, nil, opts) if wantErr := ErrInvalidDateTimeFormat; !errors.Is(err, wantErr) { t.Errorf("Oras.Pack() error = %v, wantErr = %v", err, wantErr) } } func Test_Pack_ImageV1_1_RC2(t *testing.T) { s := memory.New() // prepare test content layers := []ocispec.Descriptor{ content.NewDescriptorFromBytes("test", []byte("hello world")), content.NewDescriptorFromBytes("test", []byte("goodbye world")), } // test Pack ctx := context.Background() artifactType := "application/vnd.test" manifestDesc, err := Pack(ctx, s, artifactType, layers, PackOptions{PackImageManifest: true}) if err != nil { t.Fatal("Oras.Pack() error =", err) } var manifest ocispec.Manifest rc, err := s.Fetch(ctx, manifestDesc) if err != nil { t.Fatal("Store.Fetch() error =", err) } if err := json.NewDecoder(rc).Decode(&manifest); err != nil { t.Fatal("error decoding manifest, error =", err) } if err := rc.Close(); err != nil { t.Fatal("Store.Fetch().Close() error =", err) } // verify media type got := manifest.MediaType if got != ocispec.MediaTypeImageManifest { t.Fatalf("got media type = %s, want %s", got, ocispec.MediaTypeImageManifest) } // verify config expectedConfigBytes := []byte("{}") expectedConfig := ocispec.Descriptor{ MediaType: artifactType, Digest: digest.FromBytes(expectedConfigBytes), Size: int64(len(expectedConfigBytes)), } if !reflect.DeepEqual(manifest.Config, expectedConfig) { t.Errorf("got config = %v, want %v", manifest.Config, expectedConfig) } // verify layers if !reflect.DeepEqual(manifest.Layers, layers) { t.Errorf("got layers = %v, want %v", manifest.Layers, layers) } // verify created time annotation createdTime, ok := manifest.Annotations[ocispec.AnnotationCreated] if !ok { t.Errorf("Annotation %s = %v, want %v", ocispec.AnnotationCreated, ok, true) } _, err = time.Parse(time.RFC3339, createdTime) if err != nil { t.Errorf("error parsing created time: %s, error = %v", createdTime, err) } // verify descriptor annotations if want := manifest.Annotations; !reflect.DeepEqual(manifestDesc.Annotations, want) { t.Errorf("got descriptor annotations = %v, want %v", manifestDesc.Annotations, want) } } func Test_Pack_ImageV1_1_RC2_WithOptions(t *testing.T) { s := memory.New() // prepare test content layers := []ocispec.Descriptor{ content.NewDescriptorFromBytes("test", []byte("hello world")), content.NewDescriptorFromBytes("test", []byte("goodbye world")), } configBytes := []byte("{}") configDesc := content.NewDescriptorFromBytes("application/vnd.test.config", configBytes) configAnnotations := map[string]string{"foo": "bar"} annotations := map[string]string{ ocispec.AnnotationCreated: "2000-01-01T00:00:00Z", "foo": "bar", } artifactType := "application/vnd.test" subjectManifest := []byte(`{"layers":[]}`) subjectDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageManifest, Digest: digest.FromBytes(subjectManifest), Size: int64(len(subjectManifest)), } // test Pack with ConfigDescriptor ctx := context.Background() opts := PackOptions{ PackImageManifest: true, Subject: &subjectDesc, ConfigDescriptor: &configDesc, ConfigAnnotations: configAnnotations, ManifestAnnotations: annotations, } manifestDesc, err := Pack(ctx, s, artifactType, layers, opts) if err != nil { t.Fatal("Oras.Pack() error =", err) } expectedManifest := ocispec.Manifest{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageManifest, Subject: &subjectDesc, Config: configDesc, Layers: layers, Annotations: annotations, } expectedManifestBytes, err := json.Marshal(expectedManifest) if err != nil { t.Fatal("failed to marshal manifest:", err) } rc, err := s.Fetch(ctx, manifestDesc) if err != nil { t.Fatal("Store.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Store.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got, expectedManifestBytes) { t.Errorf("Store.Fetch() = %v, want %v", string(got), string(expectedManifestBytes)) } // verify descriptor expectedManifestDesc := content.NewDescriptorFromBytes(expectedManifest.MediaType, expectedManifestBytes) expectedManifestDesc.ArtifactType = expectedManifest.Config.MediaType expectedManifestDesc.Annotations = expectedManifest.Annotations if !reflect.DeepEqual(manifestDesc, expectedManifestDesc) { t.Errorf("Pack() = %v, want %v", manifestDesc, expectedManifestDesc) } // test Pack without ConfigDescriptor opts = PackOptions{ PackImageManifest: true, Subject: &subjectDesc, ConfigAnnotations: configAnnotations, ManifestAnnotations: annotations, } manifestDesc, err = Pack(ctx, s, artifactType, layers, opts) if err != nil { t.Fatal("Oras.Pack() error =", err) } expectedConfigDesc := content.NewDescriptorFromBytes(artifactType, configBytes) expectedConfigDesc.Annotations = configAnnotations expectedManifest = ocispec.Manifest{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageManifest, Subject: &subjectDesc, Config: expectedConfigDesc, Layers: layers, Annotations: annotations, } expectedManifestBytes, err = json.Marshal(expectedManifest) if err != nil { t.Fatal("failed to marshal manifest:", err) } rc, err = s.Fetch(ctx, manifestDesc) if err != nil { t.Fatal("Store.Fetch() error =", err) } got, err = io.ReadAll(rc) if err != nil { t.Fatal("Store.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got, expectedManifestBytes) { t.Errorf("Store.Fetch() = %v, want %v", string(got), string(expectedManifestBytes)) } // verify descriptor expectedManifestDesc = content.NewDescriptorFromBytes(expectedManifest.MediaType, expectedManifestBytes) expectedManifestDesc.ArtifactType = expectedManifest.Config.MediaType expectedManifestDesc.Annotations = expectedManifest.Annotations if !reflect.DeepEqual(manifestDesc, expectedManifestDesc) { t.Errorf("Pack() = %v, want %v", manifestDesc, expectedManifestDesc) } } func Test_Pack_ImageV1_1_RC2_NoArtifactType(t *testing.T) { s := memory.New() ctx := context.Background() manifestDesc, err := Pack(ctx, s, "", nil, PackOptions{PackImageManifest: true}) if err != nil { t.Fatal("Oras.Pack() error =", err) } var manifest ocispec.Manifest rc, err := s.Fetch(ctx, manifestDesc) if err != nil { t.Fatal("Store.Fetch() error =", err) } if err := json.NewDecoder(rc).Decode(&manifest); err != nil { t.Fatal("error decoding manifest, error =", err) } if err := rc.Close(); err != nil { t.Fatal("Store.Fetch().Close() error =", err) } // verify artifact type and config media type if manifestDesc.ArtifactType != MediaTypeUnknownConfig { t.Fatalf("got artifact type = %s, want %s", manifestDesc.ArtifactType, MediaTypeUnknownConfig) } if manifest.Config.MediaType != MediaTypeUnknownConfig { t.Fatalf("got artifact type = %s, want %s", manifest.Config.MediaType, MediaTypeUnknownConfig) } } func Test_Pack_ImageV1_1_RC2_NoLayer(t *testing.T) { s := memory.New() // test Pack ctx := context.Background() manifestDesc, err := Pack(ctx, s, "", nil, PackOptions{PackImageManifest: true}) if err != nil { t.Fatal("Oras.Pack() error =", err) } var manifest ocispec.Manifest rc, err := s.Fetch(ctx, manifestDesc) if err != nil { t.Fatal("Store.Fetch() error =", err) } if err := json.NewDecoder(rc).Decode(&manifest); err != nil { t.Fatal("error decoding manifest, error =", err) } if err := rc.Close(); err != nil { t.Fatal("Store.Fetch().Close() error =", err) } // verify layers expectedLayers := []ocispec.Descriptor{} if !reflect.DeepEqual(manifest.Layers, expectedLayers) { t.Errorf("got layers = %v, want %v", manifest.Layers, expectedLayers) } } func Test_Pack_ImageV1_1_RC2_InvalidDateTimeFormat(t *testing.T) { s := memory.New() ctx := context.Background() opts := PackOptions{ PackImageManifest: true, ManifestAnnotations: map[string]string{ ocispec.AnnotationCreated: "2000/01/01 00:00:00", }, } _, err := Pack(ctx, s, "", nil, opts) if wantErr := ErrInvalidDateTimeFormat; !errors.Is(err, wantErr) { t.Errorf("Oras.Pack() error = %v, wantErr = %v", err, wantErr) } } func Test_PackManifest_ImageV1_0(t *testing.T) { s := memory.New() // test Pack ctx := context.Background() artifactType := "application/vnd.test" manifestDesc, err := PackManifest(ctx, s, PackManifestVersion1_0, artifactType, PackManifestOptions{}) if err != nil { t.Fatal("Oras.PackManifest() error =", err) } var manifest ocispec.Manifest rc, err := s.Fetch(ctx, manifestDesc) if err != nil { t.Fatal("Store.Fetch() error =", err) } if err := json.NewDecoder(rc).Decode(&manifest); err != nil { t.Fatal("error decoding manifest, error =", err) } if err := rc.Close(); err != nil { t.Fatal("Store.Fetch().Close() error =", err) } // verify media type got := manifest.MediaType if got != ocispec.MediaTypeImageManifest { t.Fatalf("got media type = %s, want %s", got, ocispec.MediaTypeImageManifest) } // verify config expectedConfigBytes := []byte("{}") expectedConfig := ocispec.Descriptor{ MediaType: artifactType, Digest: digest.FromBytes(expectedConfigBytes), Size: int64(len(expectedConfigBytes)), } if !reflect.DeepEqual(manifest.Config, expectedConfig) { t.Errorf("got config = %v, want %v", manifest.Config, expectedConfig) } // verify layers expectedLayers := []ocispec.Descriptor{} if !reflect.DeepEqual(manifest.Layers, expectedLayers) { t.Errorf("got layers = %v, want %v", manifest.Layers, expectedLayers) } // verify created time annotation createdTime, ok := manifest.Annotations[ocispec.AnnotationCreated] if !ok { t.Errorf("Annotation %s = %v, want %v", ocispec.AnnotationCreated, ok, true) } _, err = time.Parse(time.RFC3339, createdTime) if err != nil { t.Errorf("error parsing created time: %s, error = %v", createdTime, err) } // verify descriptor annotations if want := manifest.Annotations; !reflect.DeepEqual(manifestDesc.Annotations, want) { t.Errorf("got descriptor annotations = %v, want %v", manifestDesc.Annotations, want) } } func Test_PackManifest_ImageV1_0_WithOptions(t *testing.T) { s := memory.New() // prepare test content layers := []ocispec.Descriptor{ content.NewDescriptorFromBytes("test", []byte("hello world")), content.NewDescriptorFromBytes("test", []byte("goodbye world")), } configBytes := []byte("{}") configDesc := content.NewDescriptorFromBytes("application/vnd.test.config", configBytes) configAnnotations := map[string]string{"foo": "bar"} annotations := map[string]string{ ocispec.AnnotationCreated: "2000-01-01T00:00:00Z", "foo": "bar", } artifactType := "application/vnd.test" // test PackManifest with ConfigDescriptor ctx := context.Background() opts := PackManifestOptions{ Layers: layers, ConfigDescriptor: &configDesc, ConfigAnnotations: configAnnotations, ManifestAnnotations: annotations, } manifestDesc, err := PackManifest(ctx, s, PackManifestVersion1_0, artifactType, opts) if err != nil { t.Fatal("Oras.PackManifest() error =", err) } expectedManifest := ocispec.Manifest{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageManifest, Config: configDesc, Layers: layers, Annotations: annotations, } expectedManifestBytes, err := json.Marshal(expectedManifest) if err != nil { t.Fatal("failed to marshal manifest:", err) } rc, err := s.Fetch(ctx, manifestDesc) if err != nil { t.Fatal("Store.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Store.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got, expectedManifestBytes) { t.Errorf("Store.Fetch() = %v, want %v", string(got), string(expectedManifestBytes)) } // verify descriptor expectedManifestDesc := content.NewDescriptorFromBytes(expectedManifest.MediaType, expectedManifestBytes) expectedManifestDesc.ArtifactType = expectedManifest.Config.MediaType expectedManifestDesc.Annotations = expectedManifest.Annotations if !reflect.DeepEqual(manifestDesc, expectedManifestDesc) { t.Errorf("Pack() = %v, want %v", manifestDesc, expectedManifestDesc) } // test PackManifest without ConfigDescriptor opts = PackManifestOptions{ Layers: layers, ConfigAnnotations: configAnnotations, ManifestAnnotations: annotations, } manifestDesc, err = PackManifest(ctx, s, PackManifestVersion1_0, artifactType, opts) if err != nil { t.Fatal("Oras.PackManifest() error =", err) } expectedConfigDesc := content.NewDescriptorFromBytes(artifactType, configBytes) expectedConfigDesc.Annotations = configAnnotations expectedManifest = ocispec.Manifest{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageManifest, Config: expectedConfigDesc, Layers: layers, Annotations: annotations, } expectedManifestBytes, err = json.Marshal(expectedManifest) if err != nil { t.Fatal("failed to marshal manifest:", err) } rc, err = s.Fetch(ctx, manifestDesc) if err != nil { t.Fatal("Store.Fetch() error =", err) } got, err = io.ReadAll(rc) if err != nil { t.Fatal("Store.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got, expectedManifestBytes) { t.Errorf("Store.Fetch() = %v, want %v", string(got), string(expectedManifestBytes)) } // verify descriptor expectedManifestDesc = content.NewDescriptorFromBytes(expectedManifest.MediaType, expectedManifestBytes) expectedManifestDesc.ArtifactType = expectedManifest.Config.MediaType expectedManifestDesc.Annotations = expectedManifest.Annotations if !reflect.DeepEqual(manifestDesc, expectedManifestDesc) { t.Errorf("PackManifest() = %v, want %v", manifestDesc, expectedManifestDesc) } } func Test_PackManifest_ImageV1_0_SubjectUnsupported(t *testing.T) { s := memory.New() // prepare test content artifactType := "application/vnd.test" subjectManifest := []byte(`{"layers":[]}`) subjectDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageManifest, Digest: digest.FromBytes(subjectManifest), Size: int64(len(subjectManifest)), } // test Pack with ConfigDescriptor ctx := context.Background() opts := PackManifestOptions{ Subject: &subjectDesc, } _, err := PackManifest(ctx, s, PackManifestVersion1_0, artifactType, opts) if wantErr := errdef.ErrUnsupported; !errors.Is(err, wantErr) { t.Errorf("Oras.PackManifest() error = %v, wantErr %v", err, wantErr) } } func Test_PackManifest_ImageV1_0_NoArtifactType(t *testing.T) { s := memory.New() ctx := context.Background() manifestDesc, err := PackManifest(ctx, s, PackManifestVersion1_0, "", PackManifestOptions{}) if err != nil { t.Fatal("Oras.PackManifest() error =", err) } var manifest ocispec.Manifest rc, err := s.Fetch(ctx, manifestDesc) if err != nil { t.Fatal("Store.Fetch() error =", err) } if err := json.NewDecoder(rc).Decode(&manifest); err != nil { t.Fatal("error decoding manifest, error =", err) } if err := rc.Close(); err != nil { t.Fatal("Store.Fetch().Close() error =", err) } // verify artifact type and config media type if manifestDesc.ArtifactType != MediaTypeUnknownConfig { t.Fatalf("got artifact type = %s, want %s", manifestDesc.ArtifactType, MediaTypeUnknownConfig) } if manifest.Config.MediaType != MediaTypeUnknownConfig { t.Fatalf("got artifact type = %s, want %s", manifest.Config.MediaType, MediaTypeUnknownConfig) } } func Test_PackManifest_ImageV1_0_InvalidMediaType(t *testing.T) { s := memory.New() ctx := context.Background() // test invalid artifact type + valid config media type artifactType := "random" configBytes := []byte("{}") configDesc := content.NewDescriptorFromBytes("application/vnd.test.config", configBytes) opts := PackManifestOptions{ ConfigDescriptor: &configDesc, } _, err := PackManifest(ctx, s, PackManifestVersion1_0, artifactType, opts) if err != nil { t.Error("Oras.PackManifest() error =", err) } // test invalid config media type + valid artifact type artifactType = "application/vnd.test" configDesc = content.NewDescriptorFromBytes("random", configBytes) opts = PackManifestOptions{ ConfigDescriptor: &configDesc, } _, err = PackManifest(ctx, s, PackManifestVersion1_0, artifactType, opts) if wantErr := errdef.ErrInvalidMediaType; !errors.Is(err, wantErr) { t.Errorf("Oras.PackManifest() error = %v, wantErr = %v", err, wantErr) } } func Test_PackManifest_ImageV1_0_InvalidDateTimeFormat(t *testing.T) { s := memory.New() ctx := context.Background() opts := PackManifestOptions{ ManifestAnnotations: map[string]string{ ocispec.AnnotationCreated: "2000/01/01 00:00:00", }, } _, err := PackManifest(ctx, s, PackManifestVersion1_0, "", opts) if wantErr := ErrInvalidDateTimeFormat; !errors.Is(err, wantErr) { t.Errorf("Oras.PackManifest() error = %v, wantErr = %v", err, wantErr) } } func Test_PackManifest_ImageV1_1(t *testing.T) { s := memory.New() // test PackManifest ctx := context.Background() artifactType := "application/vnd.test" manifestDesc, err := PackManifest(ctx, s, PackManifestVersion1_1, artifactType, PackManifestOptions{}) if err != nil { t.Fatal("Oras.PackManifest() error =", err) } var manifest ocispec.Manifest rc, err := s.Fetch(ctx, manifestDesc) if err != nil { t.Fatal("Store.Fetch() error =", err) } if err := json.NewDecoder(rc).Decode(&manifest); err != nil { t.Fatal("error decoding manifest, error =", err) } if err := rc.Close(); err != nil { t.Fatal("Store.Fetch().Close() error =", err) } // verify layers expectedLayers := []ocispec.Descriptor{ocispec.DescriptorEmptyJSON} if !reflect.DeepEqual(manifest.Layers, expectedLayers) { t.Errorf("got layers = %v, want %v", manifest.Layers, expectedLayers) } } func Test_PackManifest_ImageV1_1_WithOptions(t *testing.T) { s := memory.New() // prepare test content layers := []ocispec.Descriptor{ content.NewDescriptorFromBytes("test", []byte("hello world")), content.NewDescriptorFromBytes("test", []byte("goodbye world")), } configBytes := []byte("config") configDesc := content.NewDescriptorFromBytes("application/vnd.test.config", configBytes) configAnnotations := map[string]string{"foo": "bar"} annotations := map[string]string{ ocispec.AnnotationCreated: "2000-01-01T00:00:00Z", "foo": "bar", } artifactType := "application/vnd.test" subjectManifest := []byte(`{"layers":[]}`) subjectDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageManifest, Digest: digest.FromBytes(subjectManifest), Size: int64(len(subjectManifest)), } // test PackManifest with ConfigDescriptor ctx := context.Background() opts := PackManifestOptions{ Subject: &subjectDesc, Layers: layers, ConfigDescriptor: &configDesc, ConfigAnnotations: configAnnotations, ManifestAnnotations: annotations, } manifestDesc, err := PackManifest(ctx, s, PackManifestVersion1_1, artifactType, opts) if err != nil { t.Fatal("Oras.PackManifest() error =", err) } expectedManifest := ocispec.Manifest{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageManifest, ArtifactType: artifactType, Subject: &subjectDesc, Config: configDesc, Layers: layers, Annotations: annotations, } expectedManifestBytes, err := json.Marshal(expectedManifest) if err != nil { t.Fatal("failed to marshal manifest:", err) } rc, err := s.Fetch(ctx, manifestDesc) if err != nil { t.Fatal("Store.Fetch() error =", err) } got, err := io.ReadAll(rc) if err != nil { t.Fatal("Store.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got, expectedManifestBytes) { t.Errorf("Store.Fetch() = %v, want %v", string(got), string(expectedManifestBytes)) } // verify descriptor expectedManifestDesc := content.NewDescriptorFromBytes(expectedManifest.MediaType, expectedManifestBytes) expectedManifestDesc.ArtifactType = expectedManifest.ArtifactType expectedManifestDesc.Annotations = expectedManifest.Annotations if !reflect.DeepEqual(manifestDesc, expectedManifestDesc) { t.Errorf("PackManifest() = %v, want %v", manifestDesc, expectedManifestDesc) } // test PackManifest with ConfigDescriptor, but without artifactType opts = PackManifestOptions{ Subject: &subjectDesc, Layers: layers, ConfigDescriptor: &configDesc, ConfigAnnotations: configAnnotations, ManifestAnnotations: annotations, } manifestDesc, err = PackManifest(ctx, s, PackManifestVersion1_1, "", opts) if err != nil { t.Fatal("Oras.PackManifest() error =", err) } expectedManifest = ocispec.Manifest{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageManifest, Subject: &subjectDesc, Config: configDesc, Layers: layers, Annotations: annotations, } expectedManifestBytes, err = json.Marshal(expectedManifest) if err != nil { t.Fatal("failed to marshal manifest:", err) } rc, err = s.Fetch(ctx, manifestDesc) if err != nil { t.Fatal("Store.Fetch() error =", err) } got, err = io.ReadAll(rc) if err != nil { t.Fatal("Store.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got, expectedManifestBytes) { t.Errorf("Store.Fetch() = %v, want %v", string(got), string(expectedManifestBytes)) } // verify descriptor expectedManifestDesc = content.NewDescriptorFromBytes(expectedManifest.MediaType, expectedManifestBytes) expectedManifestDesc.ArtifactType = expectedManifest.ArtifactType expectedManifestDesc.Annotations = expectedManifest.Annotations if !reflect.DeepEqual(manifestDesc, expectedManifestDesc) { t.Errorf("PackManifest() = %v, want %v", manifestDesc, expectedManifestDesc) } // test Pack without ConfigDescriptor opts = PackManifestOptions{ Subject: &subjectDesc, Layers: layers, ConfigAnnotations: configAnnotations, ManifestAnnotations: annotations, } manifestDesc, err = PackManifest(ctx, s, PackManifestVersion1_1, artifactType, opts) if err != nil { t.Fatal("Oras.PackManifest() error =", err) } expectedConfigDesc := ocispec.DescriptorEmptyJSON expectedConfigDesc.Annotations = configAnnotations expectedManifest = ocispec.Manifest{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageManifest, ArtifactType: artifactType, Subject: &subjectDesc, Config: expectedConfigDesc, Layers: layers, Annotations: annotations, } expectedManifestBytes, err = json.Marshal(expectedManifest) if err != nil { t.Fatal("failed to marshal manifest:", err) } rc, err = s.Fetch(ctx, manifestDesc) if err != nil { t.Fatal("Store.Fetch() error =", err) } got, err = io.ReadAll(rc) if err != nil { t.Fatal("Store.Fetch().Read() error =", err) } err = rc.Close() if err != nil { t.Error("Store.Fetch().Close() error =", err) } if !bytes.Equal(got, expectedManifestBytes) { t.Errorf("Store.Fetch() = %v, want %v", string(got), string(expectedManifestBytes)) } // verify descriptor expectedManifestDesc = content.NewDescriptorFromBytes(expectedManifest.MediaType, expectedManifestBytes) expectedManifestDesc.ArtifactType = expectedManifest.ArtifactType expectedManifestDesc.Annotations = expectedManifest.Annotations if !reflect.DeepEqual(manifestDesc, expectedManifestDesc) { t.Errorf("PackManifest() = %v, want %v", manifestDesc, expectedManifestDesc) } } func Test_PackManifest_ImageV1_1_NoArtifactType(t *testing.T) { s := memory.New() ctx := context.Background() // test no artifact type and no config _, err := PackManifest(ctx, s, PackManifestVersion1_1, "", PackManifestOptions{}) if wantErr := ErrMissingArtifactType; !errors.Is(err, wantErr) { t.Errorf("Oras.PackManifest() error = %v, wantErr = %v", err, wantErr) } // test no artifact type and config with empty media type opts := PackManifestOptions{ ConfigDescriptor: &ocispec.Descriptor{ MediaType: ocispec.DescriptorEmptyJSON.MediaType, }, } _, err = PackManifest(ctx, s, PackManifestVersion1_1, "", opts) if wantErr := ErrMissingArtifactType; !errors.Is(err, wantErr) { t.Errorf("Oras.PackManifest() error = %v, wantErr = %v", err, wantErr) } } func Test_PackManifest_ImageV1_1_InvalidMediaType(t *testing.T) { s := memory.New() ctx := context.Background() // test invalid artifact type + valid config media type artifactType := "random" configBytes := []byte("{}") configDesc := content.NewDescriptorFromBytes("application/vnd.test.config", configBytes) opts := PackManifestOptions{ ConfigDescriptor: &configDesc, } _, err := PackManifest(ctx, s, PackManifestVersion1_1, artifactType, opts) if wantErr := errdef.ErrInvalidMediaType; !errors.Is(err, wantErr) { t.Errorf("Oras.PackManifest() error = %v, wantErr = %v", err, wantErr) } // test invalid config media type + invalid artifact type artifactType = "application/vnd.test" configDesc = content.NewDescriptorFromBytes("random", configBytes) opts = PackManifestOptions{ ConfigDescriptor: &configDesc, } _, err = PackManifest(ctx, s, PackManifestVersion1_1, artifactType, opts) if wantErr := errdef.ErrInvalidMediaType; !errors.Is(err, wantErr) { t.Errorf("Oras.PackManifest() error = %v, wantErr = %v", err, wantErr) } } func Test_PackManifest_ImageV1_1_InvalidDateTimeFormat(t *testing.T) { s := memory.New() ctx := context.Background() opts := PackManifestOptions{ ManifestAnnotations: map[string]string{ ocispec.AnnotationCreated: "2000/01/01 00:00:00", }, } artifactType := "application/vnd.test" _, err := PackManifest(ctx, s, PackManifestVersion1_1, artifactType, opts) if wantErr := ErrInvalidDateTimeFormat; !errors.Is(err, wantErr) { t.Errorf("Oras.PackManifest() error = %v, wantErr = %v", err, wantErr) } } func Test_PackManifest_UnsupportedPackManifestVersion(t *testing.T) { s := memory.New() ctx := context.Background() _, err := PackManifest(ctx, s, -1, "", PackManifestOptions{}) if wantErr := errdef.ErrUnsupported; !errors.Is(err, wantErr) { t.Errorf("Oras.PackManifest() error = %v, wantErr = %v", err, wantErr) } } oras-go-2.5.0/registry/000077500000000000000000000000001457674530300147535ustar00rootroot00000000000000oras-go-2.5.0/registry/example_reference_test.go000066400000000000000000000025561457674530300220220ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package registry_test import ( _ "crypto/sha256" // required to parse sha256 digest. See [Reference.Digest] "fmt" "oras.land/oras-go/v2/registry" ) // ExampleParseReference_digest demonstrates parsing a reference string with // digest and print its components. func ExampleParseReference_digest() { rawRef := "ghcr.io/oras-project/oras-go@sha256:601d05a48832e7946dab8f49b14953549bebf42e42f4d7973b1a5a287d77ab76" ref, err := registry.ParseReference(rawRef) if err != nil { panic(err) } fmt.Println("Registry:", ref.Registry) fmt.Println("Repository:", ref.Repository) digest, err := ref.Digest() if err != nil { panic(err) } fmt.Println("Digest:", digest) // Output: // Registry: ghcr.io // Repository: oras-project/oras-go // Digest: sha256:601d05a48832e7946dab8f49b14953549bebf42e42f4d7973b1a5a287d77ab76 } oras-go-2.5.0/registry/example_test.go000066400000000000000000000052511457674530300177770ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // Package registry_test gives examples code of functions in the registry package. package registry_test import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "net/url" "os" "testing" "oras.land/oras-go/v2/registry" . "oras.land/oras-go/v2/registry/internal/doc" "oras.land/oras-go/v2/registry/remote" ) const _ = ExampleUnplayable const exampleRepositoryName = "example" var host string func TestMain(m *testing.M) { // Setup a local HTTPS registry ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { p := r.URL.Path m := r.Method switch { case p == "/v2/_catalog" && m == "GET": result := struct { Repositories []string `json:"repositories"` }{ Repositories: []string{"public/repo1", "public/repo2", "internal/repo3"}, } json.NewEncoder(w).Encode(result) case p == fmt.Sprintf("/v2/%s/tags/list", exampleRepositoryName) && m == "GET": result := struct { Tags []string `json:"tags"` }{ Tags: []string{"tag1", "tag2"}, } json.NewEncoder(w).Encode(result) } })) defer ts.Close() u, err := url.Parse(ts.URL) if err != nil { panic(err) } host = u.Host http.DefaultTransport = ts.Client().Transport os.Exit(m.Run()) } // ExampleRepositories gives example snippets for listing repositories in the registry without pagination. func ExampleRepositories() { reg, err := remote.NewRegistry(host) if err != nil { panic(err) // Handle error } ctx := context.Background() repos, err := registry.Repositories(ctx, reg) if err != nil { panic(err) // Handle error } for _, repo := range repos { fmt.Println(repo) } // Output: // public/repo1 // public/repo2 // internal/repo3 } // ExampleTags gives example snippets for listing tags in the repository without pagination. func ExampleTags() { repo, err := remote.NewRepository(fmt.Sprintf("%s/%s", host, exampleRepositoryName)) if err != nil { panic(err) // Handle error } ctx := context.Background() tags, err := registry.Tags(ctx, repo) if err != nil { panic(err) // Handle error } for _, tag := range tags { fmt.Println(tag) } // Output: // tag1 // tag2 } oras-go-2.5.0/registry/internal/000077500000000000000000000000001457674530300165675ustar00rootroot00000000000000oras-go-2.5.0/registry/internal/doc/000077500000000000000000000000001457674530300173345ustar00rootroot00000000000000oras-go-2.5.0/registry/internal/doc/doc.go000066400000000000000000000021141457674530300204260ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // Package doc provides the constant can be used in example test files. Most of // the example tests in this repo are fail to run on the GoDoc website because // of the missing variables. In order to avoid confusing users, these tests // should be unplayable on the GoDoc website, and this can be achieved by // leveraging a dot import in the test files. // Reference: https://pkg.go.dev/go/doc#Example package doc // ExampleUnplayable is used in examples test files in order to make example // tests unplayable. const ExampleUnplayable = true oras-go-2.5.0/registry/reference.go000066400000000000000000000217411457674530300172450ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package registry import ( "fmt" "net/url" "regexp" "strings" "github.com/opencontainers/go-digest" "oras.land/oras-go/v2/errdef" ) // regular expressions for components. var ( // repositoryRegexp is adapted from the distribution implementation. The // repository name set under OCI distribution spec is a subset of the docker // spec. For maximum compatability, the docker spec is verified client-side. // Further checks are left to the server-side. // // References: // - https://github.com/distribution/distribution/blob/v2.7.1/reference/regexp.go#L53 // - https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#pulling-manifests repositoryRegexp = regexp.MustCompile(`^[a-z0-9]+(?:(?:[._]|__|[-]*)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|[-]*)[a-z0-9]+)*)*$`) // tagRegexp checks the tag name. // The docker and OCI spec have the same regular expression. // // Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#pulling-manifests tagRegexp = regexp.MustCompile(`^[\w][\w.-]{0,127}$`) ) // Reference references either a resource descriptor (where Reference.Reference // is a tag or a digest), or a resource repository (where Reference.Reference // is the empty string). type Reference struct { // Registry is the name of the registry. It is usually the domain name of // the registry optionally with a port. Registry string // Repository is the name of the repository. Repository string // Reference is the reference of the object in the repository. This field // can take any one of the four valid forms (see ParseReference). In the // case where it's the empty string, it necessarily implies valid form D, // and where it is non-empty, then it is either a tag, or a digest // (implying one of valid forms A, B, or C). Reference string } // ParseReference parses a string (artifact) into an `artifact reference`. // Corresponding cryptographic hash implementations are required to be imported // as specified by https://pkg.go.dev/github.com/opencontainers/go-digest#readme-usage // if the string contains a digest. // // Note: An "image" is an "artifact", however, an "artifact" is not necessarily // an "image". // // The token `artifact` is composed of other tokens, and those in turn are // composed of others. This definition recursivity requires a notation capable // of recursion, thus the following two forms have been adopted: // // 1. Backus–Naur Form (BNF) has been adopted to address the recursive nature // of the definition. // 2. Token opacity is revealed via its label letter-casing. That is, "opaque" // tokens (i.e., tokens that are not final, and must therefore be further // broken down into their constituents) are denoted in *lowercase*, while // final tokens (i.e., leaf-node tokens that are final) are denoted in // *uppercase*. // // Finally, note that a number of the opaque tokens are polymorphic in nature; // that is, they can take on one of numerous forms, not restricted to a single // defining form. // // The top-level token, `artifact`, is composed of two (opaque) tokens; namely // `socketaddr` and `path`: // // ::= "/" // // The former is described as follows: // // ::= | ":" // ::= | // ::= | // // The latter, which is of greater interest here, is described as follows: // // ::= | // ::= "@" | ":" "@" | ":" // ::= ":" // // This second token--`path`--can take on exactly four forms, each of which will // now be illustrated: // // <--- path --------------------------------------------> | - Decode `path` // <=== REPOSITORY ===> <--- reference ------------------> | - Decode `reference` // <=== REPOSITORY ===> @ <=================== digest ===> | - Valid Form A // <=== REPOSITORY ===> : @ <=== digest ===> | - Valid Form B (tag is dropped) // <=== REPOSITORY ===> : <=== TAG ======================> | - Valid Form C // <=== REPOSITORY ======================================> | - Valid Form D // // Note: In the case of Valid Form B, TAG is dropped without any validation or // further consideration. func ParseReference(artifact string) (Reference, error) { parts := strings.SplitN(artifact, "/", 2) if len(parts) == 1 { // Invalid Form return Reference{}, fmt.Errorf("%w: missing registry or repository", errdef.ErrInvalidReference) } registry, path := parts[0], parts[1] var isTag bool var repository string var reference string if index := strings.Index(path, "@"); index != -1 { // `digest` found; Valid Form A (if not B) isTag = false repository = path[:index] reference = path[index+1:] if index = strings.Index(repository, ":"); index != -1 { // `tag` found (and now dropped without validation) since `the // `digest` already present; Valid Form B repository = repository[:index] } } else if index = strings.Index(path, ":"); index != -1 { // `tag` found; Valid Form C isTag = true repository = path[:index] reference = path[index+1:] } else { // empty `reference`; Valid Form D repository = path } ref := Reference{ Registry: registry, Repository: repository, Reference: reference, } if err := ref.ValidateRegistry(); err != nil { return Reference{}, err } if err := ref.ValidateRepository(); err != nil { return Reference{}, err } if len(ref.Reference) == 0 { return ref, nil } validator := ref.ValidateReferenceAsDigest if isTag { validator = ref.ValidateReferenceAsTag } if err := validator(); err != nil { return Reference{}, err } return ref, nil } // Validate the entire reference object; the registry, the repository, and the // reference. func (r Reference) Validate() error { if err := r.ValidateRegistry(); err != nil { return err } if err := r.ValidateRepository(); err != nil { return err } return r.ValidateReference() } // ValidateRegistry validates the registry. func (r Reference) ValidateRegistry() error { if uri, err := url.ParseRequestURI("dummy://" + r.Registry); err != nil || uri.Host == "" || uri.Host != r.Registry { return fmt.Errorf("%w: invalid registry %q", errdef.ErrInvalidReference, r.Registry) } return nil } // ValidateRepository validates the repository. func (r Reference) ValidateRepository() error { if !repositoryRegexp.MatchString(r.Repository) { return fmt.Errorf("%w: invalid repository %q", errdef.ErrInvalidReference, r.Repository) } return nil } // ValidateReferenceAsTag validates the reference as a tag. func (r Reference) ValidateReferenceAsTag() error { if !tagRegexp.MatchString(r.Reference) { return fmt.Errorf("%w: invalid tag %q", errdef.ErrInvalidReference, r.Reference) } return nil } // ValidateReferenceAsDigest validates the reference as a digest. func (r Reference) ValidateReferenceAsDigest() error { if _, err := r.Digest(); err != nil { return fmt.Errorf("%w: invalid digest %q: %v", errdef.ErrInvalidReference, r.Reference, err) } return nil } // ValidateReference where the reference is first tried as an ampty string, then // as a digest, and if that fails, as a tag. func (r Reference) ValidateReference() error { if len(r.Reference) == 0 { return nil } if index := strings.IndexByte(r.Reference, ':'); index != -1 { return r.ValidateReferenceAsDigest() } return r.ValidateReferenceAsTag() } // Host returns the host name of the registry. func (r Reference) Host() string { if r.Registry == "docker.io" { return "registry-1.docker.io" } return r.Registry } // ReferenceOrDefault returns the reference or the default reference if empty. func (r Reference) ReferenceOrDefault() string { if r.Reference == "" { return "latest" } return r.Reference } // Digest returns the reference as a digest. // Corresponding cryptographic hash implementations are required to be imported // as specified by https://pkg.go.dev/github.com/opencontainers/go-digest#readme-usage func (r Reference) Digest() (digest.Digest, error) { return digest.Parse(r.Reference) } // String implements `fmt.Stringer` and returns the reference string. // The resulted string is meaningful only if the reference is valid. func (r Reference) String() string { if r.Repository == "" { return r.Registry } ref := r.Registry + "/" + r.Repository if r.Reference == "" { return ref } if d, err := r.Digest(); err == nil { return ref + "@" + d.String() } return ref + ":" + r.Reference } oras-go-2.5.0/registry/reference_test.go000066400000000000000000000072721457674530300203070ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package registry import ( _ "crypto/sha256" "fmt" "reflect" "testing" ) const ValidDigest = "sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" const InvalidDigest = "sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde" // For a definition of what a "valid form [ABCD]" means, see reference.go. func TestParseReferenceGoodies(t *testing.T) { tests := []struct { name string image string wantTemplate Reference }{ { name: "digest reference (valid form A)", image: fmt.Sprintf("hello-world@%s", ValidDigest), wantTemplate: Reference{ Repository: "hello-world", Reference: ValidDigest, }, }, { name: "tag with digest (valid form B)", image: fmt.Sprintf("hello-world:v2@%s", ValidDigest), wantTemplate: Reference{ Repository: "hello-world", Reference: ValidDigest, }, }, { name: "empty tag with digest (valid form B)", image: fmt.Sprintf("hello-world:@%s", ValidDigest), wantTemplate: Reference{ Repository: "hello-world", Reference: ValidDigest, }, }, { name: "tag reference (valid form C)", image: "hello-world:v1", wantTemplate: Reference{ Repository: "hello-world", Reference: "v1", }, }, { name: "basic reference (valid form D)", image: "hello-world", wantTemplate: Reference{ Repository: "hello-world", }, }, } registries := []string{ "localhost", "registry.example.com", "localhost:5000", "127.0.0.1:5000", "[::1]:5000", } for _, tt := range tests { want := tt.wantTemplate for _, registry := range registries { want.Registry = registry t.Run(tt.name, func(t *testing.T) { got, err := ParseReference(fmt.Sprintf("%s/%s", registry, tt.image)) if err != nil { t.Errorf("ParseReference() encountered unexpected error: %v", err) return } if !reflect.DeepEqual(got, want) { t.Errorf("ParseReference() = %v, want %v", got, tt.wantTemplate) } }) } } } func TestParseReferenceUglies(t *testing.T) { tests := []struct { name string raw string want Reference }{ { name: "no repo name", raw: "localhost", }, { name: "missing registry", raw: "hello-world:linux", }, { name: "missing registry (issue #698)", raw: "/hello-world:linux", }, { name: "invalid repo name", raw: "localhost/UPPERCASE/test", }, { name: "invalid port", raw: "localhost:v1/hello-world", }, { name: "invalid digest", raw: fmt.Sprintf("registry.example.com/foobar@%s", InvalidDigest), }, { name: "invalid digest prefix: colon instead of the at sign", raw: fmt.Sprintf("registry.example.com/hello-world:foobar:%s", ValidDigest), }, { name: "invalid digest prefix: double at sign", raw: fmt.Sprintf("registry.example.com/hello-world@@%s", ValidDigest), }, { name: "invalid digest prefix: space", raw: fmt.Sprintf("registry.example.com/hello-world @%s", ValidDigest), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if _, err := ParseReference(tt.raw); err == nil { t.Errorf("ParseReference() expected an error, but got none") return } }) } } oras-go-2.5.0/registry/registry.go000066400000000000000000000040211457674530300171470ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // Package registry provides high-level operations to manage registries. package registry import "context" // Registry represents a collection of repositories. type Registry interface { // Repositories lists the name of repositories available in the registry. // Since the returned repositories may be paginated by the underlying // implementation, a function should be passed in to process the paginated // repository list. // `last` argument is the `last` parameter when invoking the catalog API. // If `last` is NOT empty, the entries in the response start after the // repo specified by `last`. Otherwise, the response starts from the top // of the Repositories list. // Note: When implemented by a remote registry, the catalog API is called. // However, not all registries supports pagination or conforms the // specification. // Reference: https://docs.docker.com/registry/spec/api/#catalog // See also `Repositories()` in this package. Repositories(ctx context.Context, last string, fn func(repos []string) error) error // Repository returns a repository reference by the given name. Repository(ctx context.Context, name string) (Repository, error) } // Repositories lists the name of repositories available in the registry. func Repositories(ctx context.Context, reg Registry) ([]string, error) { var res []string if err := reg.Repositories(ctx, "", func(repos []string) error { res = append(res, repos...) return nil }); err != nil { return nil, err } return res, nil } oras-go-2.5.0/registry/remote/000077500000000000000000000000001457674530300162465ustar00rootroot00000000000000oras-go-2.5.0/registry/remote/auth/000077500000000000000000000000001457674530300172075ustar00rootroot00000000000000oras-go-2.5.0/registry/remote/auth/cache.go000066400000000000000000000174511457674530300206110ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package auth import ( "context" "strings" "sync" "oras.land/oras-go/v2/errdef" "oras.land/oras-go/v2/internal/syncutil" ) // DefaultCache is the sharable cache used by DefaultClient. var DefaultCache Cache = NewCache() // Cache caches the auth-scheme and auth-token for the "Authorization" header in // accessing the remote registry. // Precisely, the header is `Authorization: auth-scheme auth-token`. // The `auth-token` is a generic term as `token68` in RFC 7235 section 2.1. type Cache interface { // GetScheme returns the auth-scheme part cached for the given registry. // A single registry is assumed to have a consistent scheme. // If a registry has different schemes per path, the auth client is still // workable. However, the cache may not be effective as the cache cannot // correctly guess the scheme. GetScheme(ctx context.Context, registry string) (Scheme, error) // GetToken returns the auth-token part cached for the given registry of a // given scheme. // The underlying implementation MAY cache the token for all schemes for the // given registry. GetToken(ctx context.Context, registry string, scheme Scheme, key string) (string, error) // Set fetches the token using the given fetch function and caches the token // for the given scheme with the given key for the given registry. // The return values of the fetch function is returned by this function. // The underlying implementation MAY combine the fetch operation if the Set // function is invoked multiple times at the same time. Set(ctx context.Context, registry string, scheme Scheme, key string, fetch func(context.Context) (string, error)) (string, error) } // cacheEntry is a cache entry for a single registry. type cacheEntry struct { scheme Scheme tokens sync.Map // map[string]string } // concurrentCache is a cache suitable for concurrent invocation. type concurrentCache struct { status sync.Map // map[string]*syncutil.Once cache sync.Map // map[string]*cacheEntry } // NewCache creates a new go-routine safe cache instance. func NewCache() Cache { return &concurrentCache{} } // GetScheme returns the auth-scheme part cached for the given registry. func (cc *concurrentCache) GetScheme(ctx context.Context, registry string) (Scheme, error) { entry, ok := cc.cache.Load(registry) if !ok { return SchemeUnknown, errdef.ErrNotFound } return entry.(*cacheEntry).scheme, nil } // GetToken returns the auth-token part cached for the given registry of a given // scheme. func (cc *concurrentCache) GetToken(ctx context.Context, registry string, scheme Scheme, key string) (string, error) { entryValue, ok := cc.cache.Load(registry) if !ok { return "", errdef.ErrNotFound } entry := entryValue.(*cacheEntry) if entry.scheme != scheme { return "", errdef.ErrNotFound } if token, ok := entry.tokens.Load(key); ok { return token.(string), nil } return "", errdef.ErrNotFound } // Set fetches the token using the given fetch function and caches the token // for the given scheme with the given key for the given registry. // Set combines the fetch operation if the Set is invoked multiple times at the // same time. func (cc *concurrentCache) Set(ctx context.Context, registry string, scheme Scheme, key string, fetch func(context.Context) (string, error)) (string, error) { // fetch token statusKey := strings.Join([]string{ registry, scheme.String(), key, }, " ") statusValue, _ := cc.status.LoadOrStore(statusKey, syncutil.NewOnce()) fetchOnce := statusValue.(*syncutil.Once) fetchedFirst, result, err := fetchOnce.Do(ctx, func() (interface{}, error) { return fetch(ctx) }) if fetchedFirst { cc.status.Delete(statusKey) } if err != nil { return "", err } token := result.(string) if !fetchedFirst { return token, nil } // cache token newEntry := &cacheEntry{ scheme: scheme, } entryValue, exists := cc.cache.LoadOrStore(registry, newEntry) entry := entryValue.(*cacheEntry) if exists && entry.scheme != scheme { // there is a scheme change, which is not expected in most scenarios. // force invalidating all previous cache. entry = newEntry cc.cache.Store(registry, entry) } entry.tokens.Store(key, token) return token, nil } // noCache is a cache implementation that does not do cache at all. type noCache struct{} // GetScheme always returns not found error as it has no cache. func (noCache) GetScheme(ctx context.Context, registry string) (Scheme, error) { return SchemeUnknown, errdef.ErrNotFound } // GetToken always returns not found error as it has no cache. func (noCache) GetToken(ctx context.Context, registry string, scheme Scheme, key string) (string, error) { return "", errdef.ErrNotFound } // Set calls fetch directly without caching. func (noCache) Set(ctx context.Context, registry string, scheme Scheme, key string, fetch func(context.Context) (string, error)) (string, error) { return fetch(ctx) } // hostCache is an auth cache that ignores scopes. Uses only the registry's hostname to find a token. type hostCache struct { Cache } // GetToken implements Cache. func (c *hostCache) GetToken(ctx context.Context, registry string, scheme Scheme, key string) (string, error) { return c.Cache.GetToken(ctx, registry, scheme, "") } // Set implements Cache. func (c *hostCache) Set(ctx context.Context, registry string, scheme Scheme, key string, fetch func(context.Context) (string, error)) (string, error) { return c.Cache.Set(ctx, registry, scheme, "", fetch) } // fallbackCache tries the primary cache then falls back to the secondary cache. type fallbackCache struct { primary Cache secondary Cache } // GetScheme implements Cache. func (fc *fallbackCache) GetScheme(ctx context.Context, registry string) (Scheme, error) { scheme, err := fc.primary.GetScheme(ctx, registry) if err == nil { return scheme, nil } // fallback return fc.secondary.GetScheme(ctx, registry) } // GetToken implements Cache. func (fc *fallbackCache) GetToken(ctx context.Context, registry string, scheme Scheme, key string) (string, error) { token, err := fc.primary.GetToken(ctx, registry, scheme, key) if err == nil { return token, nil } // fallback return fc.secondary.GetToken(ctx, registry, scheme, key) } // Set implements Cache. func (fc *fallbackCache) Set(ctx context.Context, registry string, scheme Scheme, key string, fetch func(context.Context) (string, error)) (string, error) { token, err := fc.primary.Set(ctx, registry, scheme, key, fetch) if err != nil { return "", err } return fc.secondary.Set(ctx, registry, scheme, key, func(ctx context.Context) (string, error) { return token, nil }) } // NewSingleContextCache creates a host-based cache for optimizing the auth flow for non-compliant registries. // It is intended to be used in a single context, such as pulling from a single repository. // This cache should not be shared. // // Note: [NewCache] should be used for compliant registries as it can be shared // across context and will generally make less re-authentication requests. func NewSingleContextCache() Cache { cache := NewCache() return &fallbackCache{ primary: cache, // We can re-use the came concurrentCache here because the key space is different // (keys are always empty for the hostCache) so there is no collision. // Even if there is a collision it is not an issue. // Re-using saves a little memory. secondary: &hostCache{cache}, } } oras-go-2.5.0/registry/remote/auth/cache_test.go000066400000000000000000000433061457674530300216460ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package auth import ( "context" "errors" "strconv" "sync" "sync/atomic" "testing" "time" "oras.land/oras-go/v2/errdef" ) func Test_concurrentCache_GetScheme(t *testing.T) { cache := NewCache() // no entry in the cache ctx := context.Background() registry := "localhost:5000" got, err := cache.GetScheme(ctx, registry) if want := errdef.ErrNotFound; err != want { t.Fatalf("concurrentCache.GetScheme() error = %v, wantErr %v", err, want) } if got != SchemeUnknown { t.Errorf("concurrentCache.GetScheme() = %v, want %v", got, SchemeUnknown) } // set an cache entry scheme := SchemeBasic _, err = cache.Set(ctx, registry, scheme, "", func(c context.Context) (string, error) { return "foo", nil }) if err != nil { t.Fatalf("failed to set cache: %v", err) } // verify cache got, err = cache.GetScheme(ctx, registry) if err != nil { t.Fatalf("concurrentCache.GetScheme() error = %v", err) } if got != scheme { t.Errorf("concurrentCache.GetScheme() = %v, want %v", got, scheme) } // set cache entry again scheme = SchemeBearer _, err = cache.Set(ctx, registry, scheme, "", func(c context.Context) (string, error) { return "bar", nil }) if err != nil { t.Fatalf("failed to set cache: %v", err) } // verify cache got, err = cache.GetScheme(ctx, registry) if err != nil { t.Fatalf("concurrentCache.GetScheme() error = %v", err) } if got != scheme { t.Errorf("concurrentCache.GetScheme() = %v, want %v", got, scheme) } // test other registry registry = "localhost:5001" got, err = cache.GetScheme(ctx, registry) if want := errdef.ErrNotFound; err != want { t.Fatalf("concurrentCache.GetScheme() error = %v, wantErr %v", err, want) } if got != SchemeUnknown { t.Errorf("concurrentCache.GetScheme() = %v, want %v", got, SchemeUnknown) } } func Test_concurrentCache_GetToken(t *testing.T) { cache := NewCache() // no entry in the cache ctx := context.Background() registry := "localhost:5000" scheme := SchemeBearer key := "1st key" got, err := cache.GetToken(ctx, registry, scheme, key) if want := errdef.ErrNotFound; err != want { t.Fatalf("concurrentCache.GetToken() error = %v, wantErr %v", err, want) } if got != "" { t.Errorf("concurrentCache.GetToken() = %v, want %v", got, "") } // set an cache entry _, err = cache.Set(ctx, registry, scheme, key, func(c context.Context) (string, error) { return "foo", nil }) if err != nil { t.Fatalf("failed to set cache: %v", err) } // verify cache got, err = cache.GetToken(ctx, registry, scheme, key) if err != nil { t.Fatalf("concurrentCache.GetToken() error = %v", err) } if want := "foo"; got != want { t.Errorf("concurrentCache.GetToken() = %v, want %v", got, want) } // set cache entry again _, err = cache.Set(ctx, registry, scheme, key, func(c context.Context) (string, error) { return "bar", nil }) if err != nil { t.Fatalf("failed to set cache: %v", err) } // verify cache got, err = cache.GetToken(ctx, registry, scheme, key) if err != nil { t.Fatalf("concurrentCache.GetToken() error = %v", err) } if want := "bar"; got != want { t.Errorf("concurrentCache.GetToken() = %v, want %v", got, want) } // test other key key = "2nd key" got, err = cache.GetToken(ctx, registry, scheme, key) if want := errdef.ErrNotFound; err != want { t.Fatalf("concurrentCache.GetToken() error = %v, wantErr %v", err, want) } if got != "" { t.Errorf("concurrentCache.GetToken() = %v, want %v", got, "") } // set an cache entry _, err = cache.Set(ctx, registry, scheme, key, func(c context.Context) (string, error) { return "hello world", nil }) if err != nil { t.Fatalf("failed to set cache: %v", err) } // verify cache got, err = cache.GetToken(ctx, registry, scheme, key) if err != nil { t.Fatalf("concurrentCache.GetToken() error = %v", err) } if want := "hello world"; got != want { t.Errorf("concurrentCache.GetToken() = %v, want %v", got, want) } // verify cache of the previous key as keys should not interference each // other key = "1st key" got, err = cache.GetToken(ctx, registry, scheme, key) if err != nil { t.Fatalf("concurrentCache.GetToken() error = %v", err) } if want := "bar"; got != want { t.Errorf("concurrentCache.GetToken() = %v, want %v", got, want) } // test other registry registry = "localhost:5001" got, err = cache.GetToken(ctx, registry, scheme, key) if want := errdef.ErrNotFound; err != want { t.Fatalf("concurrentCache.GetToken() error = %v, wantErr %v", err, want) } if got != "" { t.Errorf("concurrentCache.GetToken() = %v, want %v", got, "") } // set an cache entry _, err = cache.Set(ctx, registry, scheme, key, func(c context.Context) (string, error) { return "foobar", nil }) if err != nil { t.Fatalf("failed to set cache: %v", err) } // verify cache got, err = cache.GetToken(ctx, registry, scheme, key) if err != nil { t.Fatalf("concurrentCache.GetToken() error = %v", err) } if want := "foobar"; got != want { t.Errorf("concurrentCache.GetToken() = %v, want %v", got, want) } // verify cache of the previous registry as registries should not // interference each other registry = "localhost:5000" got, err = cache.GetToken(ctx, registry, scheme, key) if err != nil { t.Fatalf("concurrentCache.GetToken() error = %v", err) } if want := "bar"; got != want { t.Errorf("concurrentCache.GetToken() = %v, want %v", got, want) } // test other scheme scheme = SchemeBasic got, err = cache.GetToken(ctx, registry, scheme, key) if want := errdef.ErrNotFound; err != want { t.Fatalf("concurrentCache.GetToken() error = %v, wantErr %v", err, want) } if got != "" { t.Errorf("concurrentCache.GetToken() = %v, want %v", got, "") } // set an cache entry _, err = cache.Set(ctx, registry, scheme, key, func(c context.Context) (string, error) { return "new scheme", nil }) if err != nil { t.Fatalf("failed to set cache: %v", err) } // verify cache got, err = cache.GetToken(ctx, registry, scheme, key) if err != nil { t.Fatalf("concurrentCache.GetToken() error = %v", err) } if want := "new scheme"; got != want { t.Errorf("concurrentCache.GetToken() = %v, want %v", got, want) } // cache of the previous scheme should be invalidated due to scheme change. got, err = cache.GetToken(ctx, registry, SchemeBearer, key) if want := errdef.ErrNotFound; err != want { t.Fatalf("concurrentCache.GetToken() error = %v, wantErr %v", err, want) } if got != "" { t.Errorf("concurrentCache.GetToken() = %v, want %v", got, "") } } func Test_concurrentCache_Set(t *testing.T) { registries := []string{ "localhost:5000", "localhost:5001", } scheme := SchemeBearer keys := []string{ "foo", "bar", } count := len(registries) * len(keys) ctx := context.Background() cache := NewCache() // first round of fetch fetch := func(i int) func(context.Context) (string, error) { return func(context.Context) (string, error) { return strconv.Itoa(i), nil } } var wg sync.WaitGroup for i := 0; i < 10; i++ { for j := 0; j < count; j++ { wg.Add(1) go func(i int) { defer wg.Done() registry := registries[i&1] key := keys[(i>>1)&1] got, err := cache.Set(ctx, registry, scheme, key, fetch(i)) if err != nil { t.Errorf("concurrentCache.Set() error = %v", err) } if want := strconv.Itoa(i); got != want { t.Errorf("concurrentCache.Set() = %v, want %v", got, want) } }(j) } } wg.Wait() for i := 0; i < count; i++ { registry := registries[i&1] key := keys[(i>>1)&1] gotScheme, err := cache.GetScheme(ctx, registry) if err != nil { t.Fatalf("concurrentCache.GetScheme() error = %v", err) } if want := scheme; gotScheme != want { t.Errorf("concurrentCache.GetScheme() = %v, want %v", gotScheme, want) } gotToken, err := cache.GetToken(ctx, registry, scheme, key) if err != nil { t.Fatalf("concurrentCache.GetToken() error = %v", err) } if want := strconv.Itoa(i); gotToken != want { t.Errorf("concurrentCache.GetToken() = %v, want %v", gotToken, want) } } // repeated fetch fetch = func(i int) func(context.Context) (string, error) { return func(context.Context) (string, error) { return strconv.Itoa(i) + " repeated", nil } } for i := 0; i < 10; i++ { for j := 0; j < count; j++ { wg.Add(1) go func(i int) { defer wg.Done() registry := registries[i&1] key := keys[(i>>1)&1] got, err := cache.Set(ctx, registry, scheme, key, fetch(i)) if err != nil { t.Errorf("concurrentCache.Set() error = %v", err) } if want := strconv.Itoa(i) + " repeated"; got != want { t.Errorf("concurrentCache.Set() = %v, want %v", got, want) } }(j) } } wg.Wait() for i := 0; i < count; i++ { registry := registries[i&1] key := keys[(i>>1)&1] gotScheme, err := cache.GetScheme(ctx, registry) if err != nil { t.Fatalf("concurrentCache.GetScheme() error = %v", err) } if want := scheme; gotScheme != want { t.Errorf("concurrentCache.GetScheme() = %v, want %v", gotScheme, want) } gotToken, err := cache.GetToken(ctx, registry, scheme, key) if err != nil { t.Fatalf("concurrentCache.GetToken() error = %v", err) } if want := strconv.Itoa(i) + " repeated"; gotToken != want { t.Errorf("concurrentCache.GetToken() = %v, want %v", gotToken, want) } } } func Test_concurrentCache_Set_Fetch_Once(t *testing.T) { registries := []string{ "localhost:5000", "localhost:5001", } schemes := []Scheme{ SchemeBasic, SchemeBearer, } keys := []string{ "foo", "bar", } count := make([]int64, len(registries)*len(schemes)*len(keys)) fetch := func(i int) func(context.Context) (string, error) { return func(context.Context) (string, error) { time.Sleep(500 * time.Millisecond) atomic.AddInt64(&count[i], 1) return strconv.Itoa(i), nil } } ctx := context.Background() cache := NewCache() // first round of fetch var wg sync.WaitGroup for i := 0; i < 10; i++ { for j := 0; j < len(count); j++ { wg.Add(1) go func(i int) { defer wg.Done() registry := registries[i&1] scheme := schemes[(i>>1)&1] key := keys[(i>>2)&1] got, err := cache.Set(ctx, registry, scheme, key, fetch(i)) if err != nil { t.Errorf("concurrentCache.Set() error = %v", err) } if want := strconv.Itoa(i); got != want { t.Errorf("concurrentCache.Set() = %v, want %v", got, want) } }(j) } } wg.Wait() for i := 0; i < len(count); i++ { if got := count[i]; got != 1 { t.Errorf("fetch is called more than once: %d", got) } } // repeated fetch for i := 0; i < 10; i++ { for j := 0; j < len(count); j++ { wg.Add(1) go func(i int) { defer wg.Done() registry := registries[i&1] scheme := schemes[(i>>1)&1] key := keys[(i>>2)&1] got, err := cache.Set(ctx, registry, scheme, key, fetch(i)) if err != nil { t.Errorf("concurrentCache.Set() error = %v", err) } if want := strconv.Itoa(i); got != want { t.Errorf("concurrentCache.Set() = %v, want %v", got, want) } }(j) } } wg.Wait() for i := 0; i < len(count); i++ { if got := count[i]; got != 2 { t.Errorf("fetch is called more than once: %d", got) } } } func Test_concurrentCache_Set_Fetch_Failure(t *testing.T) { registries := []string{ "localhost:5000", "localhost:5001", } scheme := SchemeBearer keys := []string{ "foo", "bar", } count := len(registries) * len(keys) ctx := context.Background() cache := NewCache() // first round of fetch fetch := func(i int) func(context.Context) (string, error) { return func(context.Context) (string, error) { return "", errors.New(strconv.Itoa(i)) } } var wg sync.WaitGroup for i := 0; i < 10; i++ { for j := 0; j < count; j++ { wg.Add(1) go func(i int) { defer wg.Done() registry := registries[i&1] key := keys[(i>>1)&1] _, err := cache.Set(ctx, registry, scheme, key, fetch(i)) if want := strconv.Itoa(i); err == nil || err.Error() != want { t.Errorf("concurrentCache.Set() error = %v, wantErr %v", err, want) } }(j) } } wg.Wait() for i := 0; i < count; i++ { registry := registries[i&1] key := keys[(i>>1)&1] _, err := cache.GetScheme(ctx, registry) if want := errdef.ErrNotFound; err != want { t.Fatalf("concurrentCache.GetScheme() error = %v, wantErr %v", err, want) } _, err = cache.GetToken(ctx, registry, scheme, key) if want := errdef.ErrNotFound; err != want { t.Errorf("concurrentCache.GetToken() error = %v, wantErr %v", err, want) } } // repeated fetch fetch = func(i int) func(context.Context) (string, error) { return func(context.Context) (string, error) { return strconv.Itoa(i), nil } } for i := 0; i < 10; i++ { for j := 0; j < count; j++ { wg.Add(1) go func(i int) { defer wg.Done() registry := registries[i&1] key := keys[(i>>1)&1] got, err := cache.Set(ctx, registry, scheme, key, fetch(i)) if err != nil { t.Errorf("concurrentCache.Set() error = %v", err) } if want := strconv.Itoa(i); got != want { t.Errorf("concurrentCache.Set() = %v, want %v", got, want) } }(j) } } wg.Wait() for i := 0; i < count; i++ { registry := registries[i&1] key := keys[(i>>1)&1] gotScheme, err := cache.GetScheme(ctx, registry) if err != nil { t.Fatalf("concurrentCache.GetScheme() error = %v", err) } if want := scheme; gotScheme != want { t.Errorf("concurrentCache.GetScheme() = %v, want %v", gotScheme, want) } gotToken, err := cache.GetToken(ctx, registry, scheme, key) if err != nil { t.Fatalf("concurrentCache.GetToken() error = %v", err) } if want := strconv.Itoa(i); gotToken != want { t.Errorf("concurrentCache.GetToken() = %v, want %v", gotToken, want) } } } func Test_hostCache(t *testing.T) { base := NewCache() // no entry in the cache ctx := context.Background() hc := hostCache{base} fetch := func(i int) func(context.Context) (string, error) { return func(context.Context) (string, error) { return strconv.Itoa(i), nil } } // The key is ignored in the hostCache implementation. { // Set the token to 100 gotToken, err := hc.Set(ctx, "reg.example.com", SchemeBearer, "key1", fetch(100)) if err != nil { t.Fatalf("hostCache.Set() error = %v", err) } if want := strconv.Itoa(100); gotToken != want { t.Errorf("hostCache.Set() = %v, want %v", gotToken, want) } } { // Overwrite the token entry to 101 gotToken, err := hc.Set(ctx, "reg.example.com", SchemeBearer, "key2", fetch(101)) if err != nil { t.Fatalf("hostCache.Set() error = %v", err) } if want := strconv.Itoa(101); gotToken != want { t.Errorf("hostCache.Set() = %v, want %v", gotToken, want) } } { // Add entry for another host gotToken, err := hc.Set(ctx, "reg2.example.com", SchemeBearer, "key3", fetch(102)) if err != nil { t.Fatalf("hostCache.Set() error = %v", err) } if want := strconv.Itoa(102); gotToken != want { t.Errorf("hostCache.Set() = %v, want %v", gotToken, want) } } { // Ensure the token for key1 is 101 now gotToken, err := hc.GetToken(ctx, "reg.example.com", SchemeBearer, "key1") if err != nil { t.Fatalf("hostCache.GetToken() error = %v", err) } if want := strconv.Itoa(101); gotToken != want { t.Errorf("hostCache.GetToken() = %v, want %v", gotToken, want) } } { // Make sure GetScheme still works gotScheme, err := hc.GetScheme(ctx, "reg.example.com") if err != nil { t.Fatalf("hostCache.GetScheme() error = %v", err) } if want := SchemeBearer; gotScheme != want { t.Errorf("hostCache.GetScheme() = %v, want %v", gotScheme, want) } } } func Test_fallbackCache(t *testing.T) { // no entry in the cache ctx := context.Background() scc := NewSingleContextCache() fetch := func(i int) func(context.Context) (string, error) { return func(context.Context) (string, error) { return strconv.Itoa(i), nil } } // Test that fallback works { // Set the token to 100 gotToken, err := scc.Set(ctx, "reg.example.com", SchemeBearer, "key1", fetch(100)) if err != nil { t.Fatalf("hostCache.Set() error = %v", err) } if want := strconv.Itoa(100); gotToken != want { t.Errorf("hostCache.Set() = %v, want %v", gotToken, want) } } { // Ensure the token for key2 falls back to 100 gotToken, err := scc.GetToken(ctx, "reg.example.com", SchemeBearer, "key2") if err != nil { t.Fatalf("hostCache.GetToken() error = %v", err) } if want := strconv.Itoa(100); gotToken != want { t.Errorf("hostCache.GetToken() = %v, want %v", gotToken, want) } } { // Make sure GetScheme works as expected gotScheme, err := scc.GetScheme(ctx, "reg.example.com") if err != nil { t.Fatalf("hostCache.GetScheme() error = %v", err) } if want := SchemeBearer; gotScheme != want { t.Errorf("hostCache.GetScheme() = %v, want %v", gotScheme, want) } } { // Make sure GetScheme falls back gotScheme, err := scc.GetScheme(ctx, "reg.example.com") if err != nil { t.Fatalf("hostCache.GetScheme() error = %v", err) } if want := SchemeBearer; gotScheme != want { t.Errorf("hostCache.GetScheme() = %v, want %v", gotScheme, want) } } { // Check GetScheme fallback // scc.(*fallbackCache).primary = NewCache() gotScheme, err := scc.GetScheme(ctx, "reg2.example.com") if !errors.Is(err, errdef.ErrNotFound) { t.Fatalf("hostCache.GetScheme() error = %v", err) } if want := SchemeUnknown; gotScheme != want { t.Errorf("hostCache.GetScheme() = %v, want %v", gotScheme, want) } } } oras-go-2.5.0/registry/remote/auth/challenge.go000066400000000000000000000104541457674530300214640ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package auth import ( "strconv" "strings" ) // Scheme define the authentication method. type Scheme byte const ( // SchemeUnknown represents unknown or unsupported schemes SchemeUnknown Scheme = iota // SchemeBasic represents the "Basic" HTTP authentication scheme. // Reference: https://tools.ietf.org/html/rfc7617 SchemeBasic // SchemeBearer represents the Bearer token in OAuth 2.0. // Reference: https://tools.ietf.org/html/rfc6750 SchemeBearer ) // parseScheme parse the authentication scheme from the given string // case-insensitively. func parseScheme(scheme string) Scheme { switch { case strings.EqualFold(scheme, "basic"): return SchemeBasic case strings.EqualFold(scheme, "bearer"): return SchemeBearer } return SchemeUnknown } // String return the string for the scheme. func (s Scheme) String() string { switch s { case SchemeBasic: return "Basic" case SchemeBearer: return "Bearer" } return "Unknown" } // parseChallenge parses the "WWW-Authenticate" header returned by the remote // registry, and extracts parameters if scheme is Bearer. // References: // - https://docs.docker.com/registry/spec/auth/token/#how-to-authenticate // - https://tools.ietf.org/html/rfc7235#section-2.1 func parseChallenge(header string) (scheme Scheme, params map[string]string) { // as defined in RFC 7235 section 2.1, we have // challenge = auth-scheme [ 1*SP ( token68 / #auth-param ) ] // auth-scheme = token // auth-param = token BWS "=" BWS ( token / quoted-string ) // // since we focus parameters only on Bearer, we have // challenge = auth-scheme [ 1*SP #auth-param ] schemeString, rest := parseToken(header) scheme = parseScheme(schemeString) // fast path for non bearer challenge if scheme != SchemeBearer { return } // parse params for bearer auth. // combining RFC 7235 section 2.1 with RFC 7230 section 7, we have // #auth-param => auth-param *( OWS "," OWS auth-param ) var key, value string for { key, rest = parseToken(skipSpace(rest)) if key == "" { return } rest = skipSpace(rest) if rest == "" || rest[0] != '=' { return } rest = skipSpace(rest[1:]) if rest == "" { return } if rest[0] == '"' { prefix, err := strconv.QuotedPrefix(rest) if err != nil { return } value, err = strconv.Unquote(prefix) if err != nil { return } rest = rest[len(prefix):] } else { value, rest = parseToken(rest) if value == "" { return } } if params == nil { params = map[string]string{ key: value, } } else { params[key] = value } rest = skipSpace(rest) if rest == "" || rest[0] != ',' { return } rest = rest[1:] } } // isNotTokenChar reports whether rune is not a `tchar` defined in RFC 7230 // section 3.2.6. func isNotTokenChar(r rune) bool { // tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" // / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" // / DIGIT / ALPHA // ; any VCHAR, except delimiters return (r < 'A' || r > 'Z') && (r < 'a' || r > 'z') && (r < '0' || r > '9') && !strings.ContainsRune("!#$%&'*+-.^_`|~", r) } // parseToken finds the next token from the given string. If no token found, // an empty token is returned and the whole of the input is returned in rest. // Note: Since token = 1*tchar, empty string is not a valid token. func parseToken(s string) (token, rest string) { if i := strings.IndexFunc(s, isNotTokenChar); i != -1 { return s[:i], s[i:] } return s, "" } // skipSpace skips "bad" whitespace (BWS) defined in RFC 7230 section 3.2.3. func skipSpace(s string) string { // OWS = *( SP / HTAB ) // ; optional whitespace // BWS = OWS // ; "bad" whitespace if i := strings.IndexFunc(s, func(r rune) bool { return r != ' ' && r != '\t' }); i != -1 { return s[i:] } return s } oras-go-2.5.0/registry/remote/auth/challenge_test.go000066400000000000000000000115471457674530300225270ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package auth import ( "reflect" "testing" ) func Test_parseChallenge(t *testing.T) { tests := []struct { name string header string wantScheme Scheme wantParams map[string]string }{ { name: "empty header", }, { name: "unknown scheme", header: "foo bar", wantScheme: SchemeUnknown, }, { name: "basic challenge", header: `Basic realm="Test Registry"`, wantScheme: SchemeBasic, }, { name: "basic challenge with no parameters", header: "Basic", wantScheme: SchemeBasic, }, { name: "basic challenge with no parameters but spaces", header: "Basic ", wantScheme: SchemeBasic, }, { name: "bearer challenge", header: `Bearer realm="https://auth.example.io/token",service="registry.example.io",scope="repository:library/hello-world:pull,push"`, wantScheme: SchemeBearer, wantParams: map[string]string{ "realm": "https://auth.example.io/token", "service": "registry.example.io", "scope": "repository:library/hello-world:pull,push", }, }, { name: "bearer challenge with multiple scopes", header: `Bearer realm="https://auth.example.io/token",service="registry.example.io",scope="repository:library/alpine:pull,push repository:ubuntu:pull"`, wantScheme: SchemeBearer, wantParams: map[string]string{ "realm": "https://auth.example.io/token", "service": "registry.example.io", "scope": "repository:library/alpine:pull,push repository:ubuntu:pull", }, }, { name: "bearer challenge with no parameters", header: "Bearer", wantScheme: SchemeBearer, }, { name: "bearer challenge with no parameters but spaces", header: "Bearer ", wantScheme: SchemeBearer, }, { name: "bearer challenge with white spaces", header: `Bearer realm = "https://auth.example.io/token" ,service=registry.example.io, scope ="repository:library/hello-world:pull,push" `, wantScheme: SchemeBearer, wantParams: map[string]string{ "realm": "https://auth.example.io/token", "service": "registry.example.io", "scope": "repository:library/hello-world:pull,push", }, }, { name: "bad bearer challenge (incomplete parameter with spaces)", header: `Bearer realm="https://auth.example.io/token",service`, wantScheme: SchemeBearer, wantParams: map[string]string{ "realm": "https://auth.example.io/token", }, }, { name: "bad bearer challenge (incomplete parameter with no value)", header: `Bearer realm="https://auth.example.io/token",service=`, wantScheme: SchemeBearer, wantParams: map[string]string{ "realm": "https://auth.example.io/token", }, }, { name: "bad bearer challenge (incomplete parameter with spaces)", header: `Bearer realm="https://auth.example.io/token",service= `, wantScheme: SchemeBearer, wantParams: map[string]string{ "realm": "https://auth.example.io/token", }, }, { name: "bad bearer challenge (incomplete quote)", header: `Bearer realm="https://auth.example.io/token",service="registry`, wantScheme: SchemeBearer, wantParams: map[string]string{ "realm": "https://auth.example.io/token", }, }, { name: "bearer challenge with empty parameter value", header: `Bearer realm="https://auth.example.io/token",empty="",service="registry.example.io",scope="repository:library/hello-world:pull,push"`, wantScheme: SchemeBearer, wantParams: map[string]string{ "realm": "https://auth.example.io/token", "empty": "", "service": "registry.example.io", "scope": "repository:library/hello-world:pull,push", }, }, { name: "bearer challenge with escaping parameter value", header: `Bearer foo="foo\"bar",hello="\"hello world\""`, wantScheme: SchemeBearer, wantParams: map[string]string{ "foo": `foo"bar`, "hello": `"hello world"`, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gotScheme, gotParams := parseChallenge(tt.header) if gotScheme != tt.wantScheme { t.Errorf("parseChallenge() gotScheme = %v, want %v", gotScheme, tt.wantScheme) } if !reflect.DeepEqual(gotParams, tt.wantParams) { t.Errorf("parseChallenge() gotParams = %v, want %v", gotParams, tt.wantParams) } }) } } oras-go-2.5.0/registry/remote/auth/client.go000066400000000000000000000330541457674530300210210ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // Package auth provides authentication for a client to a remote registry. package auth import ( "context" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "oras.land/oras-go/v2/registry/remote/internal/errutil" "oras.land/oras-go/v2/registry/remote/retry" ) // ErrBasicCredentialNotFound is returned when the credential is not found for // basic auth. var ErrBasicCredentialNotFound = errors.New("basic credential not found") // DefaultClient is the default auth-decorated client. var DefaultClient = &Client{ Client: retry.DefaultClient, Header: http.Header{ "User-Agent": {"oras-go"}, }, Cache: DefaultCache, } // maxResponseBytes specifies the default limit on how many response bytes are // allowed in the server's response from authorization service servers. // A typical response message from authorization service servers is around 1 to // 4 KiB. Since the size of a token must be smaller than the HTTP header size // limit, which is usually 16 KiB. As specified by the distribution, the // response may contain 2 identical tokens, that is, 16 x 2 = 32 KiB. // Hence, 128 KiB should be sufficient. // References: https://docs.docker.com/registry/spec/auth/token/ var maxResponseBytes int64 = 128 * 1024 // 128 KiB // defaultClientID specifies the default client ID used in OAuth2. // See also ClientID. var defaultClientID = "oras-go" // CredentialFunc represents a function that resolves the credential for the // given registry (i.e. host:port). // // [EmptyCredential] is a valid return value and should not be considered as // an error. type CredentialFunc func(ctx context.Context, hostport string) (Credential, error) // StaticCredential specifies static credentials for the given host. func StaticCredential(registry string, cred Credential) CredentialFunc { if registry == "docker.io" { // it is expected that traffic targeting "docker.io" will be redirected // to "registry-1.docker.io" // reference: https://github.com/moby/moby/blob/v24.0.0-beta.2/registry/config.go#L25-L48 registry = "registry-1.docker.io" } return func(_ context.Context, hostport string) (Credential, error) { if hostport == registry { return cred, nil } return EmptyCredential, nil } } // Client is an auth-decorated HTTP client. // Its zero value is a usable client that uses http.DefaultClient with no cache. type Client struct { // Client is the underlying HTTP client used to access the remote // server. // If nil, http.DefaultClient is used. // It is possible to use the default retry client from the package // `oras.land/oras-go/v2/registry/remote/retry`. That client is already available // in the DefaultClient. // It is also possible to use a custom client. For example, github.com/hashicorp/go-retryablehttp // is a popular HTTP client that supports retries. Client *http.Client // Header contains the custom headers to be added to each request. Header http.Header // Credential specifies the function for resolving the credential for the // given registry (i.e. host:port). // EmptyCredential is a valid return value and should not be considered as // an error. // If nil, the credential is always resolved to EmptyCredential. Credential CredentialFunc // Cache caches credentials for direct accessing the remote registry. // If nil, no cache is used. Cache Cache // ClientID used in fetching OAuth2 token as a required field. // If empty, a default client ID is used. // Reference: https://docs.docker.com/registry/spec/auth/oauth/#getting-a-token ClientID string // ForceAttemptOAuth2 controls whether to follow OAuth2 with password grant // instead the distribution spec when authenticating using username and // password. // References: // - https://docs.docker.com/registry/spec/auth/jwt/ // - https://docs.docker.com/registry/spec/auth/oauth/ ForceAttemptOAuth2 bool } // client returns an HTTP client used to access the remote registry. // http.DefaultClient is return if the client is not configured. func (c *Client) client() *http.Client { if c.Client == nil { return http.DefaultClient } return c.Client } // send adds headers to the request and sends the request to the remote server. func (c *Client) send(req *http.Request) (*http.Response, error) { for key, values := range c.Header { req.Header[key] = append(req.Header[key], values...) } return c.client().Do(req) } // credential resolves the credential for the given registry. func (c *Client) credential(ctx context.Context, reg string) (Credential, error) { if c.Credential == nil { return EmptyCredential, nil } return c.Credential(ctx, reg) } // cache resolves the cache. // noCache is return if the cache is not configured. func (c *Client) cache() Cache { if c.Cache == nil { return noCache{} } return c.Cache } // SetUserAgent sets the user agent for all out-going requests. func (c *Client) SetUserAgent(userAgent string) { if c.Header == nil { c.Header = http.Header{} } c.Header.Set("User-Agent", userAgent) } // Do sends the request to the remote server, attempting to resolve // authentication if 'Authorization' header is not set. // // On authentication failure due to bad credential, // - Do returns error if it fails to fetch token for bearer auth. // - Do returns the registry response without error for basic auth. func (c *Client) Do(originalReq *http.Request) (*http.Response, error) { if auth := originalReq.Header.Get("Authorization"); auth != "" { return c.send(originalReq) } ctx := originalReq.Context() req := originalReq.Clone(ctx) // attempt cached auth token var attemptedKey string cache := c.cache() host := originalReq.Host scheme, err := cache.GetScheme(ctx, host) if err == nil { switch scheme { case SchemeBasic: token, err := cache.GetToken(ctx, host, SchemeBasic, "") if err == nil { req.Header.Set("Authorization", "Basic "+token) } case SchemeBearer: scopes := GetAllScopesForHost(ctx, host) attemptedKey = strings.Join(scopes, " ") token, err := cache.GetToken(ctx, host, SchemeBearer, attemptedKey) if err == nil { req.Header.Set("Authorization", "Bearer "+token) } } } resp, err := c.send(req) if err != nil { return nil, err } if resp.StatusCode != http.StatusUnauthorized { return resp, nil } // attempt again with credentials for recognized schemes challenge := resp.Header.Get("Www-Authenticate") scheme, params := parseChallenge(challenge) switch scheme { case SchemeBasic: resp.Body.Close() token, err := cache.Set(ctx, host, SchemeBasic, "", func(ctx context.Context) (string, error) { return c.fetchBasicAuth(ctx, host) }) if err != nil { return nil, fmt.Errorf("%s %q: %w", resp.Request.Method, resp.Request.URL, err) } req = originalReq.Clone(ctx) req.Header.Set("Authorization", "Basic "+token) case SchemeBearer: resp.Body.Close() scopes := GetAllScopesForHost(ctx, host) if paramScope := params["scope"]; paramScope != "" { // merge hinted scopes with challenged scopes scopes = append(scopes, strings.Split(paramScope, " ")...) scopes = CleanScopes(scopes) } key := strings.Join(scopes, " ") // attempt the cache again if there is a scope change if key != attemptedKey { if token, err := cache.GetToken(ctx, host, SchemeBearer, key); err == nil { req = originalReq.Clone(ctx) req.Header.Set("Authorization", "Bearer "+token) if err := rewindRequestBody(req); err != nil { return nil, err } resp, err := c.send(req) if err != nil { return nil, err } if resp.StatusCode != http.StatusUnauthorized { return resp, nil } resp.Body.Close() } } // attempt with credentials realm := params["realm"] service := params["service"] token, err := cache.Set(ctx, host, SchemeBearer, key, func(ctx context.Context) (string, error) { return c.fetchBearerToken(ctx, host, realm, service, scopes) }) if err != nil { return nil, fmt.Errorf("%s %q: %w", resp.Request.Method, resp.Request.URL, err) } req = originalReq.Clone(ctx) req.Header.Set("Authorization", "Bearer "+token) default: return resp, nil } if err := rewindRequestBody(req); err != nil { return nil, err } return c.send(req) } // fetchBasicAuth fetches a basic auth token for the basic challenge. func (c *Client) fetchBasicAuth(ctx context.Context, registry string) (string, error) { cred, err := c.credential(ctx, registry) if err != nil { return "", fmt.Errorf("failed to resolve credential: %w", err) } if cred == EmptyCredential { return "", ErrBasicCredentialNotFound } if cred.Username == "" || cred.Password == "" { return "", errors.New("missing username or password for basic auth") } auth := cred.Username + ":" + cred.Password return base64.StdEncoding.EncodeToString([]byte(auth)), nil } // fetchBearerToken fetches an access token for the bearer challenge. func (c *Client) fetchBearerToken(ctx context.Context, registry, realm, service string, scopes []string) (string, error) { cred, err := c.credential(ctx, registry) if err != nil { return "", err } if cred.AccessToken != "" { return cred.AccessToken, nil } if cred == EmptyCredential || (cred.RefreshToken == "" && !c.ForceAttemptOAuth2) { return c.fetchDistributionToken(ctx, realm, service, scopes, cred.Username, cred.Password) } return c.fetchOAuth2Token(ctx, realm, service, scopes, cred) } // fetchDistributionToken fetches an access token as defined by the distribution // specification. // It fetches anonymous tokens if no credential is provided. // References: // - https://docs.docker.com/registry/spec/auth/jwt/ // - https://docs.docker.com/registry/spec/auth/token/ func (c *Client) fetchDistributionToken(ctx context.Context, realm, service string, scopes []string, username, password string) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, realm, nil) if err != nil { return "", err } if username != "" || password != "" { req.SetBasicAuth(username, password) } q := req.URL.Query() if service != "" { q.Set("service", service) } for _, scope := range scopes { q.Add("scope", scope) } req.URL.RawQuery = q.Encode() resp, err := c.send(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", errutil.ParseErrorResponse(resp) } // As specified in https://docs.docker.com/registry/spec/auth/token/ section // "Token Response Fields", the token is either in `token` or // `access_token`. If both present, they are identical. var result struct { Token string `json:"token"` AccessToken string `json:"access_token"` } lr := io.LimitReader(resp.Body, maxResponseBytes) if err := json.NewDecoder(lr).Decode(&result); err != nil { return "", fmt.Errorf("%s %q: failed to decode response: %w", resp.Request.Method, resp.Request.URL, err) } if result.AccessToken != "" { return result.AccessToken, nil } if result.Token != "" { return result.Token, nil } return "", fmt.Errorf("%s %q: empty token returned", resp.Request.Method, resp.Request.URL) } // fetchOAuth2Token fetches an OAuth2 access token. // Reference: https://docs.docker.com/registry/spec/auth/oauth/ func (c *Client) fetchOAuth2Token(ctx context.Context, realm, service string, scopes []string, cred Credential) (string, error) { form := url.Values{} if cred.RefreshToken != "" { form.Set("grant_type", "refresh_token") form.Set("refresh_token", cred.RefreshToken) } else if cred.Username != "" && cred.Password != "" { form.Set("grant_type", "password") form.Set("username", cred.Username) form.Set("password", cred.Password) } else { return "", errors.New("missing username or password for bearer auth") } form.Set("service", service) clientID := c.ClientID if clientID == "" { clientID = defaultClientID } form.Set("client_id", clientID) if len(scopes) != 0 { form.Set("scope", strings.Join(scopes, " ")) } body := strings.NewReader(form.Encode()) req, err := http.NewRequestWithContext(ctx, http.MethodPost, realm, body) if err != nil { return "", err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := c.send(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", errutil.ParseErrorResponse(resp) } var result struct { AccessToken string `json:"access_token"` } lr := io.LimitReader(resp.Body, maxResponseBytes) if err := json.NewDecoder(lr).Decode(&result); err != nil { return "", fmt.Errorf("%s %q: failed to decode response: %w", resp.Request.Method, resp.Request.URL, err) } if result.AccessToken != "" { return result.AccessToken, nil } return "", fmt.Errorf("%s %q: empty token returned", resp.Request.Method, resp.Request.URL) } // rewindRequestBody tries to rewind the request body if exists. func rewindRequestBody(req *http.Request) error { if req.Body == nil || req.Body == http.NoBody { return nil } if req.GetBody == nil { return fmt.Errorf("%s %q: request body is not rewindable", req.Method, req.URL) } body, err := req.GetBody() if err != nil { return fmt.Errorf("%s %q: failed to get request body: %w", req.Method, req.URL, err) } req.Body = body return nil } oras-go-2.5.0/registry/remote/auth/client_test.go000066400000000000000000004134141457674530300220620ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package auth import ( "context" "encoding/base64" "errors" "fmt" "net/http" "net/http/httptest" "net/url" "reflect" "strings" "sync/atomic" "testing" "oras.land/oras-go/v2/registry/remote/errcode" ) func TestClient_SetUserAgent(t *testing.T) { wantUserAgent := "test agent" var requestCount, wantRequestCount int64 var successCount, wantSuccessCount int64 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&requestCount, 1) if r.Method != http.MethodGet || r.URL.Path != "/" { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } if userAgent := r.UserAgent(); userAgent != wantUserAgent { t.Errorf("unexpected User-Agent: %v, want %v", userAgent, wantUserAgent) return } atomic.AddInt64(&successCount, 1) })) defer ts.Close() var client Client client.SetUserAgent(wantUserAgent) req, err := http.NewRequest(http.MethodGet, ts.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp, err := client.Do(req) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp.StatusCode, http.StatusOK) } if wantRequestCount++; requestCount != wantRequestCount { t.Errorf("unexpected number of requests: %d, want %d", requestCount, wantRequestCount) } if wantSuccessCount++; successCount != wantSuccessCount { t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount) } } func TestClient_Do_Basic_Auth(t *testing.T) { username := "test_user" password := "test_password" var requestCount, wantRequestCount int64 var successCount, wantSuccessCount int64 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&requestCount, 1) if r.Method != http.MethodGet || r.URL.Path != "/" { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } header := "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password)) if auth := r.Header.Get("Authorization"); auth != header { w.Header().Set("Www-Authenticate", `Basic realm="Test Server"`) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&successCount, 1) })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } client := &Client{ Credential: func(ctx context.Context, reg string) (Credential, error) { if reg != uri.Host { err := fmt.Errorf("registry mismatch: got %v, want %v", reg, uri.Host) t.Error(err) return EmptyCredential, err } return Credential{ Username: username, Password: password, }, nil }, } // first request req, err := http.NewRequest(http.MethodGet, ts.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp, err := client.Do(req) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp.StatusCode, http.StatusOK) } if wantRequestCount += 2; requestCount != wantRequestCount { t.Errorf("unexpected number of requests: %d, want %d", requestCount, wantRequestCount) } if wantSuccessCount++; successCount != wantSuccessCount { t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount) } // credential change username = "test_user2" password = "test_password2" req, err = http.NewRequest(http.MethodGet, ts.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp, err = client.Do(req) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp.StatusCode, http.StatusOK) } if wantRequestCount += 2; requestCount != wantRequestCount { t.Errorf("unexpected number of requests: %d, want %d", requestCount, wantRequestCount) } if wantSuccessCount++; successCount != wantSuccessCount { t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount) } } func TestClient_Do_Basic_Auth_Cached(t *testing.T) { username := "test_user" password := "test_password" var requestCount, wantRequestCount int64 var successCount, wantSuccessCount int64 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&requestCount, 1) if r.Method != http.MethodGet || r.URL.Path != "/" { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } header := "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password)) if auth := r.Header.Get("Authorization"); auth != header { w.Header().Set("Www-Authenticate", `Basic realm="Test Server"`) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&successCount, 1) })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } client := &Client{ Credential: func(ctx context.Context, reg string) (Credential, error) { if reg != uri.Host { err := fmt.Errorf("registry mismatch: got %v, want %v", reg, uri.Host) t.Error(err) return EmptyCredential, err } return Credential{ Username: username, Password: password, }, nil }, Cache: NewCache(), } // first request req, err := http.NewRequest(http.MethodGet, ts.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp, err := client.Do(req) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp.StatusCode, http.StatusOK) } if wantRequestCount += 2; requestCount != wantRequestCount { t.Errorf("unexpected number of requests: %d, want %d", requestCount, wantRequestCount) } if wantSuccessCount++; successCount != wantSuccessCount { t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount) } // repeated request req, err = http.NewRequest(http.MethodGet, ts.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp, err = client.Do(req) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp.StatusCode, http.StatusOK) } if wantRequestCount++; requestCount != wantRequestCount { t.Errorf("unexpected number of requests: %d, want %d", requestCount, wantRequestCount) } if wantSuccessCount++; successCount != wantSuccessCount { t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount) } // credential change username = "test_user2" password = "test_password2" req, err = http.NewRequest(http.MethodGet, ts.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp, err = client.Do(req) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp.StatusCode, http.StatusOK) } if wantRequestCount += 2; requestCount != wantRequestCount { t.Errorf("unexpected number of requests: %d, want %d", requestCount, wantRequestCount) } if wantSuccessCount++; successCount != wantSuccessCount { t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount) } } func TestClient_Do_Bearer_AccessToken(t *testing.T) { accessToken := "test/access/token" var requestCount, wantRequestCount int64 var successCount, wantSuccessCount int64 as := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("unexecuted attempt of authorization service") w.WriteHeader(http.StatusUnauthorized) })) defer as.Close() var service string scope := "repository:test:pull,push" ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&requestCount, 1) if r.Method != http.MethodGet || r.URL.Path != "/" { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } header := "Bearer " + accessToken if auth := r.Header.Get("Authorization"); auth != header { challenge := fmt.Sprintf("Bearer realm=%q,service=%q,scope=%q", as.URL, service, scope) w.Header().Set("Www-Authenticate", challenge) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&successCount, 1) })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } service = uri.Host client := &Client{ Credential: func(ctx context.Context, reg string) (Credential, error) { if reg != uri.Host { err := fmt.Errorf("registry mismatch: got %v, want %v", reg, uri.Host) t.Error(err) return EmptyCredential, err } return Credential{ AccessToken: accessToken, }, nil }, } // first request req, err := http.NewRequest(http.MethodGet, ts.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp, err := client.Do(req) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp.StatusCode, http.StatusOK) } if wantRequestCount += 2; requestCount != wantRequestCount { t.Errorf("unexpected number of requests: %d, want %d", requestCount, wantRequestCount) } if wantSuccessCount++; successCount != wantSuccessCount { t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount) } // credential change accessToken = "test/access/token/2" req, err = http.NewRequest(http.MethodGet, ts.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp, err = client.Do(req) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp.StatusCode, http.StatusOK) } if wantRequestCount += 2; requestCount != wantRequestCount { t.Errorf("unexpected number of requests: %d, want %d", requestCount, wantRequestCount) } if wantSuccessCount++; successCount != wantSuccessCount { t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount) } } func TestClient_Do_Bearer_AccessToken_Cached(t *testing.T) { accessToken := "test/access/token" var requestCount, wantRequestCount int64 var successCount, wantSuccessCount int64 as := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("unexecuted attempt of authorization service") w.WriteHeader(http.StatusUnauthorized) })) defer as.Close() var service string scope := "repository:test:pull,push" ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&requestCount, 1) if r.Method != http.MethodGet || r.URL.Path != "/" { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } header := "Bearer " + accessToken if auth := r.Header.Get("Authorization"); auth != header { challenge := fmt.Sprintf("Bearer realm=%q,service=%q,scope=%q", as.URL, service, scope) w.Header().Set("Www-Authenticate", challenge) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&successCount, 1) })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } service = uri.Host client := &Client{ Credential: func(ctx context.Context, reg string) (Credential, error) { if reg != uri.Host { err := fmt.Errorf("registry mismatch: got %v, want %v", reg, uri.Host) t.Error(err) return EmptyCredential, err } return Credential{ AccessToken: accessToken, }, nil }, Cache: NewCache(), } // first request ctx := WithScopes(context.Background(), scope) req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp, err := client.Do(req) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp.StatusCode, http.StatusOK) } if wantRequestCount += 2; requestCount != wantRequestCount { t.Errorf("unexpected number of requests: %d, want %d", requestCount, wantRequestCount) } if wantSuccessCount++; successCount != wantSuccessCount { t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount) } // repeated request req, err = http.NewRequestWithContext(ctx, http.MethodGet, ts.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp, err = client.Do(req) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp.StatusCode, http.StatusOK) } if wantRequestCount++; requestCount != wantRequestCount { t.Errorf("unexpected number of requests: %d, want %d", requestCount, wantRequestCount) } if wantSuccessCount++; successCount != wantSuccessCount { t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount) } // credential change accessToken = "test/access/token/2" req, err = http.NewRequestWithContext(ctx, http.MethodGet, ts.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp, err = client.Do(req) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp.StatusCode, http.StatusOK) } if wantRequestCount += 2; requestCount != wantRequestCount { t.Errorf("unexpected number of requests: %d, want %d", requestCount, wantRequestCount) } if wantSuccessCount++; successCount != wantSuccessCount { t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount) } } func TestClient_Do_Bearer_AccessToken_Cached_PerHost(t *testing.T) { as := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("unexecuted attempt of authorization service") w.WriteHeader(http.StatusUnauthorized) })) defer as.Close() // set up server 1 var requestCount1, wantRequestCount1 int64 var successCount1, wantSuccessCount1 int64 var service1 string scope1 := "repository:test:pull" accessToken1 := "test/access/token/1" ts1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&requestCount1, 1) if r.Method != http.MethodGet || r.URL.Path != "/" { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } header := "Bearer " + accessToken1 if auth := r.Header.Get("Authorization"); auth != header { challenge := fmt.Sprintf("Bearer realm=%q,service=%q,scope=%q", as.URL, service1, scope1) w.Header().Set("Www-Authenticate", challenge) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&successCount1, 1) })) defer ts1.Close() uri1, err := url.Parse(ts1.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } service1 = uri1.Host client1 := &Client{ Credential: StaticCredential(uri1.Host, Credential{ AccessToken: accessToken1, }), Cache: NewCache(), } // set up server 2 var requestCount2, wantRequestCount2 int64 var successCount2, wantSuccessCount2 int64 var service2 string scope2 := "repository:test:pull,push" accessToken2 := "test/access/token/2" ts2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&requestCount2, 1) if r.Method != http.MethodGet || r.URL.Path != "/" { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } header := "Bearer " + accessToken2 if auth := r.Header.Get("Authorization"); auth != header { challenge := fmt.Sprintf("Bearer realm=%q,service=%q,scope=%q", as.URL, service2, scope2) w.Header().Set("Www-Authenticate", challenge) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&successCount2, 1) })) defer ts2.Close() uri2, err := url.Parse(ts2.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } service2 = uri2.Host client2 := &Client{ Credential: StaticCredential(uri2.Host, Credential{ AccessToken: accessToken2, }), Cache: NewCache(), } ctx := context.Background() ctx = WithScopesForHost(ctx, uri1.Host, scope1) ctx = WithScopesForHost(ctx, uri2.Host, scope2) // first request to server 1 req1, err := http.NewRequestWithContext(ctx, http.MethodGet, ts1.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp1, err := client1.Do(req1) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp1.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp1.StatusCode, http.StatusOK) } if wantRequestCount1 += 2; requestCount1 != wantRequestCount1 { t.Errorf("unexpected number of requests: %d, want %d", requestCount1, wantRequestCount1) } if wantSuccessCount1++; successCount1 != wantSuccessCount1 { t.Errorf("unexpected number of successful requests: %d, want %d", successCount1, wantSuccessCount1) } // first request to server 2 req2, err := http.NewRequestWithContext(ctx, http.MethodGet, ts2.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp2, err := client2.Do(req2) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp2.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp2.StatusCode, http.StatusOK) } if wantRequestCount2 += 2; requestCount1 != wantRequestCount2 { t.Errorf("unexpected number of requests: %d, want %d", requestCount1, wantRequestCount1) } if wantSuccessCount2++; successCount2 != wantSuccessCount2 { t.Errorf("unexpected number of successful requests: %d, want %d", successCount2, wantSuccessCount2) } // repeated request to server 1 req1, err = http.NewRequestWithContext(ctx, http.MethodGet, ts1.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp1, err = client1.Do(req1) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp1.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp1.StatusCode, http.StatusOK) } if wantRequestCount1++; requestCount1 != wantRequestCount1 { t.Errorf("unexpected number of requests: %d, want %d", requestCount1, wantRequestCount1) } if wantSuccessCount1++; successCount1 != wantSuccessCount1 { t.Errorf("unexpected number of successful requests: %d, want %d", successCount1, wantSuccessCount1) } // repeated request to server 2 req2, err = http.NewRequestWithContext(ctx, http.MethodGet, ts2.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp2, err = client2.Do(req2) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp2.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp2.StatusCode, http.StatusOK) } if wantRequestCount2++; requestCount2 != wantRequestCount2 { t.Errorf("unexpected number of requests: %d, want %d", requestCount2, wantRequestCount2) } if wantSuccessCount2++; successCount2 != wantSuccessCount2 { t.Errorf("unexpected number of successful requests: %d, want %d", successCount2, wantSuccessCount2) } // credential change for server 1 accessToken1 = "test/access/token/1/new" req1, err = http.NewRequestWithContext(ctx, http.MethodGet, ts1.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } client1.Credential = StaticCredential(uri1.Host, Credential{ AccessToken: accessToken1, }) resp1, err = client1.Do(req1) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp1.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp1.StatusCode, http.StatusOK) } if wantRequestCount1 += 2; requestCount1 != wantRequestCount1 { t.Errorf("unexpected number of requests: %d, want %d", requestCount1, wantRequestCount1) } if wantSuccessCount1++; successCount1 != wantSuccessCount1 { t.Errorf("unexpected number of successful requests: %d, want %d", successCount1, wantSuccessCount1) } // credential change for server 2 accessToken2 = "test/access/token/2/new" req2, err = http.NewRequestWithContext(ctx, http.MethodGet, ts2.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } client2.Credential = StaticCredential(uri2.Host, Credential{ AccessToken: accessToken2, }) resp2, err = client2.Do(req2) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp2.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp2.StatusCode, http.StatusOK) } if wantRequestCount2 += 2; requestCount2 != wantRequestCount2 { t.Errorf("unexpected number of requests: %d, want %d", requestCount2, wantRequestCount2) } if wantSuccessCount2++; successCount2 != wantSuccessCount2 { t.Errorf("unexpected number of successful requests: %d, want %d", successCount2, wantSuccessCount2) } } func TestClient_Do_Bearer_Auth(t *testing.T) { username := "test_user" password := "test_password" accessToken := "test/access/token" var requestCount, wantRequestCount int64 var successCount, wantSuccessCount int64 var authCount, wantAuthCount int64 var service string scopes := []string{ "repository:dst:pull,push", "repository:src:pull", } as := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || r.URL.Path != "/" { t.Error("unexecuted attempt of authorization service") w.WriteHeader(http.StatusUnauthorized) return } header := "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password)) if auth := r.Header.Get("Authorization"); auth != header { t.Errorf("unexpected auth: got %s, want %s", auth, header) w.WriteHeader(http.StatusUnauthorized) return } if got := r.URL.Query().Get("service"); got != service { t.Errorf("unexpected service: got %s, want %s", got, service) w.WriteHeader(http.StatusUnauthorized) return } if got := r.URL.Query()["scope"]; !reflect.DeepEqual(got, scopes) { t.Errorf("unexpected scope: got %s, want %s", got, scopes) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&authCount, 1) if _, err := fmt.Fprintf(w, `{"access_token":%q}`, accessToken); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } })) defer as.Close() ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&requestCount, 1) if r.Method != http.MethodGet || r.URL.Path != "/" { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } header := "Bearer " + accessToken if auth := r.Header.Get("Authorization"); auth != header { challenge := fmt.Sprintf("Bearer realm=%q,service=%q,scope=%q", as.URL, service, strings.Join(scopes, " ")) w.Header().Set("Www-Authenticate", challenge) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&successCount, 1) })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } service = uri.Host client := &Client{ Credential: func(ctx context.Context, reg string) (Credential, error) { if reg != uri.Host { err := fmt.Errorf("registry mismatch: got %v, want %v", reg, uri.Host) t.Error(err) return EmptyCredential, err } return Credential{ Username: username, Password: password, }, nil }, } // first request req, err := http.NewRequest(http.MethodGet, ts.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp, err := client.Do(req) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp.StatusCode, http.StatusOK) } if wantRequestCount += 2; requestCount != wantRequestCount { t.Errorf("unexpected number of requests: %d, want %d", requestCount, wantRequestCount) } if wantSuccessCount++; successCount != wantSuccessCount { t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount) } if wantAuthCount++; authCount != wantAuthCount { t.Errorf("unexpected number of auth requests: %d, want %d", authCount, wantAuthCount) } // credential change username = "test_user2" password = "test_password2" accessToken = "test/access/token/2" req, err = http.NewRequest(http.MethodGet, ts.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp, err = client.Do(req) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp.StatusCode, http.StatusOK) } if wantRequestCount += 2; requestCount != wantRequestCount { t.Errorf("unexpected number of requests: %d, want %d", requestCount, wantRequestCount) } if wantSuccessCount++; successCount != wantSuccessCount { t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount) } if wantAuthCount++; authCount != wantAuthCount { t.Errorf("unexpected number of auth requests: %d, want %d", authCount, wantAuthCount) } } func TestClient_Do_Bearer_Auth_Cached(t *testing.T) { username := "test_user" password := "test_password" accessToken := "test/access/token" var requestCount, wantRequestCount int64 var successCount, wantSuccessCount int64 var authCount, wantAuthCount int64 var service string scopes := []string{ "repository:dst:pull,push", "repository:src:pull", } as := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || r.URL.Path != "/" { t.Error("unexecuted attempt of authorization service") w.WriteHeader(http.StatusUnauthorized) return } header := "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password)) if auth := r.Header.Get("Authorization"); auth != header { t.Errorf("unexpected auth: got %s, want %s", auth, header) w.WriteHeader(http.StatusUnauthorized) return } if got := r.URL.Query().Get("service"); got != service { t.Errorf("unexpected service: got %s, want %s", got, service) w.WriteHeader(http.StatusUnauthorized) return } if got := r.URL.Query()["scope"]; !reflect.DeepEqual(got, scopes) { t.Errorf("unexpected scope: got %s, want %s", got, scopes) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&authCount, 1) if _, err := fmt.Fprintf(w, `{"access_token":%q}`, accessToken); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } })) defer as.Close() ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&requestCount, 1) if r.Method != http.MethodGet || r.URL.Path != "/" { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } header := "Bearer " + accessToken if auth := r.Header.Get("Authorization"); auth != header { challenge := fmt.Sprintf("Bearer realm=%q,service=%q,scope=%q", as.URL, service, strings.Join(scopes, " ")) w.Header().Set("Www-Authenticate", challenge) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&successCount, 1) })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } service = uri.Host client := &Client{ Credential: func(ctx context.Context, reg string) (Credential, error) { if reg != uri.Host { err := fmt.Errorf("registry mismatch: got %v, want %v", reg, uri.Host) t.Error(err) return EmptyCredential, err } return Credential{ Username: username, Password: password, }, nil }, Cache: NewCache(), } // first request ctx := WithScopes(context.Background(), scopes...) req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp, err := client.Do(req) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp.StatusCode, http.StatusOK) } if wantRequestCount += 2; requestCount != wantRequestCount { t.Errorf("unexpected number of requests: %d, want %d", requestCount, wantRequestCount) } if wantSuccessCount++; successCount != wantSuccessCount { t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount) } if wantAuthCount++; authCount != wantAuthCount { t.Errorf("unexpected number of auth requests: %d, want %d", authCount, wantAuthCount) } // repeated request req, err = http.NewRequestWithContext(ctx, http.MethodGet, ts.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp, err = client.Do(req) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp.StatusCode, http.StatusOK) } if wantRequestCount++; requestCount != wantRequestCount { t.Errorf("unexpected number of requests: %d, want %d", requestCount, wantRequestCount) } if wantSuccessCount++; successCount != wantSuccessCount { t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount) } if authCount != wantAuthCount { t.Errorf("unexpected number of auth requests: %d, want %d", authCount, wantAuthCount) } // credential change username = "test_user2" password = "test_password2" accessToken = "test/access/token/2" req, err = http.NewRequestWithContext(ctx, http.MethodGet, ts.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp, err = client.Do(req) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp.StatusCode, http.StatusOK) } if wantRequestCount += 2; requestCount != wantRequestCount { t.Errorf("unexpected number of requests: %d, want %d", requestCount, wantRequestCount) } if wantSuccessCount++; successCount != wantSuccessCount { t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount) } if wantAuthCount++; authCount != wantAuthCount { t.Errorf("unexpected number of auth requests: %d, want %d", authCount, wantAuthCount) } } func TestClient_Do_Bearer_Auth_Cached_PerHost(t *testing.T) { // set up server 1 username1 := "test_user1" password1 := "test_password1" accessToken1 := "test/access/token/1" var requestCount1, wantRequestCount1 int64 var successCount1, wantSuccessCount1 int64 var authCount1, wantAuthCount1 int64 var service1 string scopes1 := []string{ "repository:src:pull", } as1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || r.URL.Path != "/" { t.Error("unexecuted attempt of authorization service") w.WriteHeader(http.StatusUnauthorized) return } header := "Basic " + base64.StdEncoding.EncodeToString([]byte(username1+":"+password1)) if auth := r.Header.Get("Authorization"); auth != header { t.Errorf("unexpected auth: got %s, want %s", auth, header) w.WriteHeader(http.StatusUnauthorized) return } if got := r.URL.Query().Get("service"); got != service1 { t.Errorf("unexpected service: got %s, want %s", got, service1) w.WriteHeader(http.StatusUnauthorized) return } if got := r.URL.Query()["scope"]; !reflect.DeepEqual(got, scopes1) { t.Errorf("unexpected scope: got %s, want %s", got, scopes1) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&authCount1, 1) if _, err := fmt.Fprintf(w, `{"access_token":%q}`, accessToken1); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } })) defer as1.Close() ts1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&requestCount1, 1) if r.Method != http.MethodGet || r.URL.Path != "/" { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } header := "Bearer " + accessToken1 if auth := r.Header.Get("Authorization"); auth != header { challenge := fmt.Sprintf("Bearer realm=%q,service=%q,scope=%q", as1.URL, service1, strings.Join(scopes1, " ")) w.Header().Set("Www-Authenticate", challenge) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&successCount1, 1) })) defer ts1.Close() uri1, err := url.Parse(ts1.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } service1 = uri1.Host client1 := &Client{ Credential: StaticCredential(uri1.Host, Credential{ Username: username1, Password: password1, }), Cache: NewCache(), } // set up server 2 username2 := "test_user2" password2 := "test_password2" accessToken2 := "test/access/token/1" var requestCount2, wantRequestCount2 int64 var successCount2, wantSuccessCount2 int64 var authCount2, wantAuthCount2 int64 var service2 string scopes2 := []string{ "repository:dst:pull,push", } as2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || r.URL.Path != "/" { t.Error("unexecuted attempt of authorization service") w.WriteHeader(http.StatusUnauthorized) return } header := "Basic " + base64.StdEncoding.EncodeToString([]byte(username2+":"+password2)) if auth := r.Header.Get("Authorization"); auth != header { t.Errorf("unexpected auth: got %s, want %s", auth, header) w.WriteHeader(http.StatusUnauthorized) return } if got := r.URL.Query().Get("service"); got != service2 { t.Errorf("unexpected service: got %s, want %s", got, service2) w.WriteHeader(http.StatusUnauthorized) return } if got := r.URL.Query()["scope"]; !reflect.DeepEqual(got, scopes2) { t.Errorf("unexpected scope: got %s, want %s", got, scopes2) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&authCount2, 1) if _, err := fmt.Fprintf(w, `{"access_token":%q}`, accessToken2); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } })) defer as1.Close() ts2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&requestCount2, 1) if r.Method != http.MethodGet || r.URL.Path != "/" { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } header := "Bearer " + accessToken2 if auth := r.Header.Get("Authorization"); auth != header { challenge := fmt.Sprintf("Bearer realm=%q,service=%q,scope=%q", as2.URL, service2, strings.Join(scopes2, " ")) w.Header().Set("Www-Authenticate", challenge) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&successCount2, 1) })) defer ts2.Close() uri2, err := url.Parse(ts2.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } service2 = uri2.Host client2 := &Client{ Credential: StaticCredential(uri2.Host, Credential{ Username: username2, Password: password2, }), Cache: NewCache(), } ctx := context.Background() ctx = WithScopesForHost(ctx, uri1.Host, scopes1...) ctx = WithScopesForHost(ctx, uri2.Host, scopes2...) // first request to server 1 req1, err := http.NewRequestWithContext(ctx, http.MethodGet, ts1.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp1, err := client1.Do(req1) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp1.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp1.StatusCode, http.StatusOK) } if wantRequestCount1 += 2; requestCount1 != wantRequestCount1 { t.Errorf("unexpected number of requests: %d, want %d", requestCount1, wantRequestCount1) } if wantSuccessCount1++; successCount1 != wantSuccessCount1 { t.Errorf("unexpected number of successful requests: %d, want %d", successCount1, wantSuccessCount1) } if wantAuthCount1++; authCount1 != wantAuthCount1 { t.Errorf("unexpected number of auth requests: %d, want %d", authCount1, wantAuthCount1) } // first request to server 2 req2, err := http.NewRequestWithContext(ctx, http.MethodGet, ts2.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp2, err := client2.Do(req2) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp2.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp2.StatusCode, http.StatusOK) } if wantRequestCount2 += 2; requestCount2 != wantRequestCount2 { t.Errorf("unexpected number of requests: %d, want %d", requestCount2, wantRequestCount2) } if wantSuccessCount2++; successCount2 != wantSuccessCount2 { t.Errorf("unexpected number of successful requests: %d, want %d", successCount2, wantSuccessCount2) } if wantAuthCount2++; authCount2 != wantAuthCount2 { t.Errorf("unexpected number of auth requests: %d, want %d", authCount2, wantAuthCount2) } // repeated request to server 1 req1, err = http.NewRequestWithContext(ctx, http.MethodGet, ts1.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp1, err = client1.Do(req1) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp1.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp1.StatusCode, http.StatusOK) } if wantRequestCount1++; requestCount1 != wantRequestCount1 { t.Errorf("unexpected number of requests: %d, want %d", requestCount1, wantRequestCount1) } if wantSuccessCount1++; successCount1 != wantSuccessCount1 { t.Errorf("unexpected number of successful requests: %d, want %d", successCount1, wantSuccessCount1) } if authCount1 != wantAuthCount1 { t.Errorf("unexpected number of auth requests: %d, want %d", authCount1, wantAuthCount1) } // repeated request to server 2 req2, err = http.NewRequestWithContext(ctx, http.MethodGet, ts2.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp2, err = client2.Do(req2) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp2.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp2.StatusCode, http.StatusOK) } if wantRequestCount2++; requestCount2 != wantRequestCount2 { t.Errorf("unexpected number of requests: %d, want %d", requestCount2, wantRequestCount2) } if wantSuccessCount2++; successCount2 != wantSuccessCount2 { t.Errorf("unexpected number of successful requests: %d, want %d", successCount2, wantSuccessCount2) } if authCount2 != wantAuthCount2 { t.Errorf("unexpected number of auth requests: %d, want %d", authCount2, wantAuthCount2) } // credential change for server 1 username1 = "test_user1_new" password1 = "test_password1_new" accessToken1 = "test/access/token/1/new" req1, err = http.NewRequestWithContext(ctx, http.MethodGet, ts1.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } client1.Credential = StaticCredential(uri1.Host, Credential{ Username: username1, Password: password1, }) resp1, err = client1.Do(req1) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp1.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp1.StatusCode, http.StatusOK) } if wantRequestCount1 += 2; requestCount1 != wantRequestCount1 { t.Errorf("unexpected number of requests: %d, want %d", requestCount1, wantRequestCount1) } if wantSuccessCount1++; successCount1 != wantSuccessCount1 { t.Errorf("unexpected number of successful requests: %d, want %d", successCount1, wantSuccessCount1) } if wantAuthCount1++; authCount1 != wantAuthCount1 { t.Errorf("unexpected number of auth requests: %d, want %d", authCount1, wantAuthCount1) } // credential change for server 2 username2 = "test_user2_new" password2 = "test_password2_new" accessToken2 = "test/access/token/2/new" req2, err = http.NewRequestWithContext(ctx, http.MethodGet, ts2.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } client2.Credential = StaticCredential(uri2.Host, Credential{ Username: username2, Password: password2, }) resp2, err = client2.Do(req2) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp2.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp2.StatusCode, http.StatusOK) } if wantRequestCount2 += 2; requestCount2 != wantRequestCount2 { t.Errorf("unexpected number of requests: %d, want %d", requestCount2, wantRequestCount2) } if wantSuccessCount2++; successCount2 != wantSuccessCount2 { t.Errorf("unexpected number of successful requests: %d, want %d", successCount2, wantSuccessCount2) } if wantAuthCount2++; authCount2 != wantAuthCount2 { t.Errorf("unexpected number of auth requests: %d, want %d", authCount2, wantAuthCount2) } } func TestClient_Do_Bearer_OAuth2_Password(t *testing.T) { username := "test_user" password := "test_password" accessToken := "test/access/token" var requestCount, wantRequestCount int64 var successCount, wantSuccessCount int64 var authCount, wantAuthCount int64 var service string scopes := []string{ "repository:dst:pull,push", "repository:src:pull", } as := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.Path != "/" { t.Error("unexecuted attempt of authorization service") w.WriteHeader(http.StatusUnauthorized) return } if err := r.ParseForm(); err != nil { t.Errorf("failed to parse form: %v", err) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("grant_type"); got != "password" { t.Errorf("unexpected grant type: %v, want %v", got, "password") w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("service"); got != service { t.Errorf("unexpected service: %v, want %v", got, service) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("client_id"); got != defaultClientID { t.Errorf("unexpected client id: %v, want %v", got, defaultClientID) w.WriteHeader(http.StatusUnauthorized) return } scope := strings.Join(scopes, " ") if got := r.PostForm.Get("scope"); got != scope { t.Errorf("unexpected scope: %v, want %v", got, scope) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("username"); got != username { t.Errorf("unexpected username: %v, want %v", got, username) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("password"); got != password { t.Errorf("unexpected password: %v, want %v", got, password) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&authCount, 1) if _, err := fmt.Fprintf(w, `{"access_token":%q}`, accessToken); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } })) defer as.Close() ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&requestCount, 1) if r.Method != http.MethodGet || r.URL.Path != "/" { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } header := "Bearer " + accessToken if auth := r.Header.Get("Authorization"); auth != header { challenge := fmt.Sprintf("Bearer realm=%q,service=%q,scope=%q", as.URL, service, strings.Join(scopes, " ")) w.Header().Set("Www-Authenticate", challenge) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&successCount, 1) })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } service = uri.Host client := &Client{ Credential: func(ctx context.Context, reg string) (Credential, error) { if reg != uri.Host { err := fmt.Errorf("registry mismatch: got %v, want %v", reg, uri.Host) t.Error(err) return EmptyCredential, err } return Credential{ Username: username, Password: password, }, nil }, ForceAttemptOAuth2: true, } // first request req, err := http.NewRequest(http.MethodGet, ts.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp, err := client.Do(req) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp.StatusCode, http.StatusOK) } if wantRequestCount += 2; requestCount != wantRequestCount { t.Errorf("unexpected number of requests: %d, want %d", requestCount, wantRequestCount) } if wantSuccessCount++; successCount != wantSuccessCount { t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount) } if wantAuthCount++; authCount != wantAuthCount { t.Errorf("unexpected number of auth requests: %d, want %d", authCount, wantAuthCount) } // credential change username = "test_user2" password = "test_password2" accessToken = "test/access/token/2" req, err = http.NewRequest(http.MethodGet, ts.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp, err = client.Do(req) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp.StatusCode, http.StatusOK) } if wantRequestCount += 2; requestCount != wantRequestCount { t.Errorf("unexpected number of requests: %d, want %d", requestCount, wantRequestCount) } if wantSuccessCount++; successCount != wantSuccessCount { t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount) } if wantAuthCount++; authCount != wantAuthCount { t.Errorf("unexpected number of auth requests: %d, want %d", authCount, wantAuthCount) } } func TestClient_Do_Bearer_OAuth2_Password_Cached(t *testing.T) { username := "test_user" password := "test_password" accessToken := "test/access/token" var requestCount, wantRequestCount int64 var successCount, wantSuccessCount int64 var authCount, wantAuthCount int64 var service string scopes := []string{ "repository:dst:pull,push", "repository:src:pull", } as := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.Path != "/" { t.Error("unexecuted attempt of authorization service") w.WriteHeader(http.StatusUnauthorized) return } if err := r.ParseForm(); err != nil { t.Errorf("failed to parse form: %v", err) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("grant_type"); got != "password" { t.Errorf("unexpected grant type: %v, want %v", got, "password") w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("service"); got != service { t.Errorf("unexpected service: %v, want %v", got, service) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("client_id"); got != defaultClientID { t.Errorf("unexpected client id: %v, want %v", got, defaultClientID) w.WriteHeader(http.StatusUnauthorized) return } scope := strings.Join(scopes, " ") if got := r.PostForm.Get("scope"); got != scope { t.Errorf("unexpected scope: %v, want %v", got, scope) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("username"); got != username { t.Errorf("unexpected username: %v, want %v", got, username) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("password"); got != password { t.Errorf("unexpected password: %v, want %v", got, password) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&authCount, 1) if _, err := fmt.Fprintf(w, `{"access_token":%q}`, accessToken); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } })) defer as.Close() ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&requestCount, 1) if r.Method != http.MethodGet || r.URL.Path != "/" { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } header := "Bearer " + accessToken if auth := r.Header.Get("Authorization"); auth != header { challenge := fmt.Sprintf("Bearer realm=%q,service=%q,scope=%q", as.URL, service, strings.Join(scopes, " ")) w.Header().Set("Www-Authenticate", challenge) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&successCount, 1) })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } service = uri.Host client := &Client{ Credential: func(ctx context.Context, reg string) (Credential, error) { if reg != uri.Host { err := fmt.Errorf("registry mismatch: got %v, want %v", reg, uri.Host) t.Error(err) return EmptyCredential, err } return Credential{ Username: username, Password: password, }, nil }, ForceAttemptOAuth2: true, Cache: NewCache(), } // first request ctx := WithScopes(context.Background(), scopes...) req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp, err := client.Do(req) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp.StatusCode, http.StatusOK) } if wantRequestCount += 2; requestCount != wantRequestCount { t.Errorf("unexpected number of requests: %d, want %d", requestCount, wantRequestCount) } if wantSuccessCount++; successCount != wantSuccessCount { t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount) } if wantAuthCount++; authCount != wantAuthCount { t.Errorf("unexpected number of auth requests: %d, want %d", authCount, wantAuthCount) } // repeated request req, err = http.NewRequestWithContext(ctx, http.MethodGet, ts.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp, err = client.Do(req) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp.StatusCode, http.StatusOK) } if wantRequestCount++; requestCount != wantRequestCount { t.Errorf("unexpected number of requests: %d, want %d", requestCount, wantRequestCount) } if wantSuccessCount++; successCount != wantSuccessCount { t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount) } if authCount != wantAuthCount { t.Errorf("unexpected number of auth requests: %d, want %d", authCount, wantAuthCount) } // credential change username = "test_user2" password = "test_password2" accessToken = "test/access/token/2" req, err = http.NewRequestWithContext(ctx, http.MethodGet, ts.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp, err = client.Do(req) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp.StatusCode, http.StatusOK) } if wantRequestCount += 2; requestCount != wantRequestCount { t.Errorf("unexpected number of requests: %d, want %d", requestCount, wantRequestCount) } if wantSuccessCount++; successCount != wantSuccessCount { t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount) } if wantAuthCount++; authCount != wantAuthCount { t.Errorf("unexpected number of auth requests: %d, want %d", authCount, wantAuthCount) } } func TestClient_Do_Bearer_OAuth2_Password_Cached_PerHost(t *testing.T) { // set up server 1 username1 := "test_user1" password1 := "test_password1" accessToken1 := "test/access/token/1" var requestCount1, wantRequestCount1 int64 var successCount1, wantSuccessCount1 int64 var authCount1, wantAuthCount1 int64 var service1 string scopes1 := []string{ "repository:src:pull", } as1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.Path != "/" { t.Error("unexecuted attempt of authorization service") w.WriteHeader(http.StatusUnauthorized) return } if err := r.ParseForm(); err != nil { t.Errorf("failed to parse form: %v", err) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("grant_type"); got != "password" { t.Errorf("unexpected grant type: %v, want %v", got, "password") w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("service"); got != service1 { t.Errorf("unexpected service: %v, want %v", got, service1) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("client_id"); got != defaultClientID { t.Errorf("unexpected client id: %v, want %v", got, defaultClientID) w.WriteHeader(http.StatusUnauthorized) return } scope := strings.Join(scopes1, " ") if got := r.PostForm.Get("scope"); got != scope { t.Errorf("unexpected scope: %v, want %v", got, scope) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("username"); got != username1 { t.Errorf("unexpected username: %v, want %v", got, username1) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("password"); got != password1 { t.Errorf("unexpected password: %v, want %v", got, password1) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&authCount1, 1) if _, err := fmt.Fprintf(w, `{"access_token":%q}`, accessToken1); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } })) defer as1.Close() ts1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&requestCount1, 1) if r.Method != http.MethodGet || r.URL.Path != "/" { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } header := "Bearer " + accessToken1 if auth := r.Header.Get("Authorization"); auth != header { challenge := fmt.Sprintf("Bearer realm=%q,service=%q,scope=%q", as1.URL, service1, strings.Join(scopes1, " ")) w.Header().Set("Www-Authenticate", challenge) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&successCount1, 1) })) defer ts1.Close() uri1, err := url.Parse(ts1.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } service1 = uri1.Host client1 := &Client{ Credential: StaticCredential(uri1.Host, Credential{ Username: username1, Password: password1, }), ForceAttemptOAuth2: true, Cache: NewCache(), } // set up server 2 username2 := "test_user2" password2 := "test_password2" accessToken2 := "test/access/token/2" var requestCount2, wantRequestCount2 int64 var successCount2, wantSuccessCount2 int64 var authCount2, wantAuthCount2 int64 var service2 string scopes2 := []string{ "repository:dst:pull,push", } as2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.Path != "/" { t.Error("unexecuted attempt of authorization service") w.WriteHeader(http.StatusUnauthorized) return } if err := r.ParseForm(); err != nil { t.Errorf("failed to parse form: %v", err) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("grant_type"); got != "password" { t.Errorf("unexpected grant type: %v, want %v", got, "password") w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("service"); got != service2 { t.Errorf("unexpected service: %v, want %v", got, service2) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("client_id"); got != defaultClientID { t.Errorf("unexpected client id: %v, want %v", got, defaultClientID) w.WriteHeader(http.StatusUnauthorized) return } scope := strings.Join(scopes2, " ") if got := r.PostForm.Get("scope"); got != scope { t.Errorf("unexpected scope: %v, want %v", got, scope) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("username"); got != username2 { t.Errorf("unexpected username: %v, want %v", got, username2) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("password"); got != password2 { t.Errorf("unexpected password: %v, want %v", got, password2) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&authCount2, 1) if _, err := fmt.Fprintf(w, `{"access_token":%q}`, accessToken2); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } })) defer as2.Close() ts2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&requestCount2, 1) if r.Method != http.MethodGet || r.URL.Path != "/" { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } header := "Bearer " + accessToken2 if auth := r.Header.Get("Authorization"); auth != header { challenge := fmt.Sprintf("Bearer realm=%q,service=%q,scope=%q", as2.URL, service2, strings.Join(scopes2, " ")) w.Header().Set("Www-Authenticate", challenge) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&successCount2, 1) })) defer ts2.Close() uri2, err := url.Parse(ts2.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } service2 = uri2.Host client2 := &Client{ Credential: StaticCredential(uri2.Host, Credential{ Username: username2, Password: password2, }), ForceAttemptOAuth2: true, Cache: NewCache(), } ctx := context.Background() ctx = WithScopesForHost(ctx, uri1.Host, scopes1...) ctx = WithScopesForHost(ctx, uri2.Host, scopes2...) // first request to server 1 req1, err := http.NewRequestWithContext(ctx, http.MethodGet, ts1.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp1, err := client1.Do(req1) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp1.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp1.StatusCode, http.StatusOK) } if wantRequestCount1 += 2; requestCount1 != wantRequestCount1 { t.Errorf("unexpected number of requests: %d, want %d", requestCount1, wantRequestCount1) } if wantSuccessCount1++; successCount1 != wantSuccessCount1 { t.Errorf("unexpected number of successful requests: %d, want %d", successCount1, wantSuccessCount1) } if wantAuthCount1++; authCount1 != wantAuthCount1 { t.Errorf("unexpected number of auth requests: %d, want %d", authCount1, wantAuthCount1) } // first request to server 2 req2, err := http.NewRequestWithContext(ctx, http.MethodGet, ts2.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp2, err := client2.Do(req2) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp2.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp2.StatusCode, http.StatusOK) } if wantRequestCount2 += 2; requestCount2 != wantRequestCount2 { t.Errorf("unexpected number of requests: %d, want %d", requestCount2, wantRequestCount2) } if wantSuccessCount2++; successCount2 != wantSuccessCount2 { t.Errorf("unexpected number of successful requests: %d, want %d", successCount2, wantSuccessCount2) } if wantAuthCount2++; authCount2 != wantAuthCount2 { t.Errorf("unexpected number of auth requests: %d, want %d", authCount2, wantAuthCount2) } // repeated request to server 1 req1, err = http.NewRequestWithContext(ctx, http.MethodGet, ts1.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp1, err = client1.Do(req1) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp1.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp1.StatusCode, http.StatusOK) } if wantRequestCount1++; requestCount1 != wantRequestCount1 { t.Errorf("unexpected number of requests: %d, want %d", requestCount1, wantRequestCount1) } if wantSuccessCount1++; successCount1 != wantSuccessCount1 { t.Errorf("unexpected number of successful requests: %d, want %d", successCount1, wantSuccessCount1) } if authCount1 != wantAuthCount1 { t.Errorf("unexpected number of auth requests: %d, want %d", authCount1, wantAuthCount1) } // repeated request to server 2 req2, err = http.NewRequestWithContext(ctx, http.MethodGet, ts2.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp2, err = client2.Do(req2) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp2.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp2.StatusCode, http.StatusOK) } if wantRequestCount2++; requestCount2 != wantRequestCount2 { t.Errorf("unexpected number of requests: %d, want %d", requestCount2, wantRequestCount2) } if wantSuccessCount2++; successCount2 != wantSuccessCount2 { t.Errorf("unexpected number of successful requests: %d, want %d", successCount2, wantSuccessCount2) } if authCount2 != wantAuthCount2 { t.Errorf("unexpected number of auth requests: %d, want %d", authCount2, wantAuthCount2) } // credential change for server 1 username1 = "test_user1_new" password1 = "test_password1_new" accessToken1 = "test/access/token/1/new" req1, err = http.NewRequestWithContext(ctx, http.MethodGet, ts1.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } client1.Credential = StaticCredential(uri1.Host, Credential{ Username: username1, Password: password1, }) resp1, err = client1.Do(req1) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp1.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp1.StatusCode, http.StatusOK) } if wantRequestCount1 += 2; requestCount1 != wantRequestCount1 { t.Errorf("unexpected number of requests: %d, want %d", requestCount1, wantRequestCount1) } if wantSuccessCount1++; successCount1 != wantSuccessCount1 { t.Errorf("unexpected number of successful requests: %d, want %d", successCount1, wantSuccessCount1) } if wantAuthCount1++; authCount1 != wantAuthCount1 { t.Errorf("unexpected number of auth requests: %d, want %d", authCount1, wantAuthCount1) } // credential change for server 2 username2 = "test_user2_new" password2 = "test_password2_new" accessToken2 = "test/access/token/2/new" req2, err = http.NewRequestWithContext(ctx, http.MethodGet, ts2.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } client2.Credential = StaticCredential(uri2.Host, Credential{ Username: username2, Password: password2, }) resp2, err = client2.Do(req2) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp2.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp2.StatusCode, http.StatusOK) } if wantRequestCount2 += 2; requestCount2 != wantRequestCount2 { t.Errorf("unexpected number of requests: %d, want %d", requestCount2, wantRequestCount2) } if wantSuccessCount2++; successCount2 != wantSuccessCount2 { t.Errorf("unexpected number of successful requests: %d, want %d", successCount2, wantSuccessCount2) } if wantAuthCount2++; authCount2 != wantAuthCount2 { t.Errorf("unexpected number of auth requests: %d, want %d", authCount2, wantAuthCount2) } } func TestClient_Do_Bearer_OAuth2_RefreshToken(t *testing.T) { refreshToken := "test/refresh/token" accessToken := "test/access/token" var requestCount, wantRequestCount int64 var successCount, wantSuccessCount int64 var authCount, wantAuthCount int64 var service string scopes := []string{ "repository:dst:pull,push", "repository:src:pull", } as := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.Path != "/" { t.Error("unexecuted attempt of authorization service") w.WriteHeader(http.StatusUnauthorized) return } if err := r.ParseForm(); err != nil { t.Errorf("failed to parse form: %v", err) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("grant_type"); got != "refresh_token" { t.Errorf("unexpected grant type: %v, want %v", got, "refresh_token") w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("service"); got != service { t.Errorf("unexpected service: %v, want %v", got, service) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("client_id"); got != defaultClientID { t.Errorf("unexpected client id: %v, want %v", got, defaultClientID) w.WriteHeader(http.StatusUnauthorized) return } scope := strings.Join(scopes, " ") if got := r.PostForm.Get("scope"); got != scope { t.Errorf("unexpected scope: %v, want %v", got, scope) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("refresh_token"); got != refreshToken { t.Errorf("unexpected refresh token: %v, want %v", got, refreshToken) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&authCount, 1) if _, err := fmt.Fprintf(w, `{"access_token":%q}`, accessToken); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } })) defer as.Close() ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&requestCount, 1) if r.Method != http.MethodGet || r.URL.Path != "/" { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } header := "Bearer " + accessToken if auth := r.Header.Get("Authorization"); auth != header { challenge := fmt.Sprintf("Bearer realm=%q,service=%q,scope=%q", as.URL, service, strings.Join(scopes, " ")) w.Header().Set("Www-Authenticate", challenge) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&successCount, 1) })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } service = uri.Host client := &Client{ Credential: func(ctx context.Context, reg string) (Credential, error) { if reg != uri.Host { err := fmt.Errorf("registry mismatch: got %v, want %v", reg, uri.Host) t.Error(err) return EmptyCredential, err } return Credential{ RefreshToken: refreshToken, }, nil }, } // first request req, err := http.NewRequest(http.MethodGet, ts.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp, err := client.Do(req) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp.StatusCode, http.StatusOK) } if wantRequestCount += 2; requestCount != wantRequestCount { t.Errorf("unexpected number of requests: %d, want %d", requestCount, wantRequestCount) } if wantSuccessCount++; successCount != wantSuccessCount { t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount) } if wantAuthCount++; authCount != wantAuthCount { t.Errorf("unexpected number of auth requests: %d, want %d", authCount, wantAuthCount) } // credential change refreshToken = "test/refresh/token/2" accessToken = "test/access/token/2" req, err = http.NewRequest(http.MethodGet, ts.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp, err = client.Do(req) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp.StatusCode, http.StatusOK) } if wantRequestCount += 2; requestCount != wantRequestCount { t.Errorf("unexpected number of requests: %d, want %d", requestCount, wantRequestCount) } if wantSuccessCount++; successCount != wantSuccessCount { t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount) } if wantAuthCount++; authCount != wantAuthCount { t.Errorf("unexpected number of auth requests: %d, want %d", authCount, wantAuthCount) } } func TestClient_Do_Bearer_OAuth2_RefreshToken_Cached(t *testing.T) { refreshToken := "test/refresh/token" accessToken := "test/access/token" var requestCount, wantRequestCount int64 var successCount, wantSuccessCount int64 var authCount, wantAuthCount int64 var service string scopes := []string{ "repository:dst:pull,push", "repository:src:pull", } as := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.Path != "/" { t.Error("unexecuted attempt of authorization service") w.WriteHeader(http.StatusUnauthorized) return } if err := r.ParseForm(); err != nil { t.Errorf("failed to parse form: %v", err) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("grant_type"); got != "refresh_token" { t.Errorf("unexpected grant type: %v, want %v", got, "refresh_token") w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("service"); got != service { t.Errorf("unexpected service: %v, want %v", got, service) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("client_id"); got != defaultClientID { t.Errorf("unexpected client id: %v, want %v", got, defaultClientID) w.WriteHeader(http.StatusUnauthorized) return } scope := strings.Join(scopes, " ") if got := r.PostForm.Get("scope"); got != scope { t.Errorf("unexpected scope: %v, want %v", got, scope) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("refresh_token"); got != refreshToken { t.Errorf("unexpected refresh token: %v, want %v", got, refreshToken) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&authCount, 1) if _, err := fmt.Fprintf(w, `{"access_token":%q}`, accessToken); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } })) defer as.Close() ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&requestCount, 1) if r.Method != http.MethodGet || r.URL.Path != "/" { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } header := "Bearer " + accessToken if auth := r.Header.Get("Authorization"); auth != header { challenge := fmt.Sprintf("Bearer realm=%q,service=%q,scope=%q", as.URL, service, strings.Join(scopes, " ")) w.Header().Set("Www-Authenticate", challenge) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&successCount, 1) })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } service = uri.Host client := &Client{ Credential: func(ctx context.Context, reg string) (Credential, error) { if reg != uri.Host { err := fmt.Errorf("registry mismatch: got %v, want %v", reg, uri.Host) t.Error(err) return EmptyCredential, err } return Credential{ RefreshToken: refreshToken, }, nil }, Cache: NewCache(), } // first request ctx := WithScopes(context.Background(), scopes...) req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp, err := client.Do(req) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp.StatusCode, http.StatusOK) } if wantRequestCount += 2; requestCount != wantRequestCount { t.Errorf("unexpected number of requests: %d, want %d", requestCount, wantRequestCount) } if wantSuccessCount++; successCount != wantSuccessCount { t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount) } if wantAuthCount++; authCount != wantAuthCount { t.Errorf("unexpected number of auth requests: %d, want %d", authCount, wantAuthCount) } // repeated request req, err = http.NewRequestWithContext(ctx, http.MethodGet, ts.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp, err = client.Do(req) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp.StatusCode, http.StatusOK) } if wantRequestCount++; requestCount != wantRequestCount { t.Errorf("unexpected number of requests: %d, want %d", requestCount, wantRequestCount) } if wantSuccessCount++; successCount != wantSuccessCount { t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount) } if authCount != wantAuthCount { t.Errorf("unexpected number of auth requests: %d, want %d", authCount, wantAuthCount) } // credential change refreshToken = "test/refresh/token/2" accessToken = "test/access/token/2" req, err = http.NewRequestWithContext(ctx, http.MethodGet, ts.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp, err = client.Do(req) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp.StatusCode, http.StatusOK) } if wantRequestCount += 2; requestCount != wantRequestCount { t.Errorf("unexpected number of requests: %d, want %d", requestCount, wantRequestCount) } if wantSuccessCount++; successCount != wantSuccessCount { t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount) } if wantAuthCount++; authCount != wantAuthCount { t.Errorf("unexpected number of auth requests: %d, want %d", authCount, wantAuthCount) } } func TestClient_Do_Bearer_OAuth2_RefreshToken_Cached_PerHost(t *testing.T) { // set up server 1 refreshToken1 := "test/refresh/token/1" accessToken1 := "test/access/token/1" var requestCount1, wantRequestCount1 int64 var successCount1, wantSuccessCount1 int64 var authCount1, wantAuthCount1 int64 var service1 string scopes1 := []string{ "repository:src:pull", } as1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.Path != "/" { t.Error("unexecuted attempt of authorization service") w.WriteHeader(http.StatusUnauthorized) return } if err := r.ParseForm(); err != nil { t.Errorf("failed to parse form: %v", err) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("grant_type"); got != "refresh_token" { t.Errorf("unexpected grant type: %v, want %v", got, "refresh_token") w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("service"); got != service1 { t.Errorf("unexpected service: %v, want %v", got, service1) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("client_id"); got != defaultClientID { t.Errorf("unexpected client id: %v, want %v", got, defaultClientID) w.WriteHeader(http.StatusUnauthorized) return } scope := strings.Join(scopes1, " ") if got := r.PostForm.Get("scope"); got != scope { t.Errorf("unexpected scope: %v, want %v", got, scope) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("refresh_token"); got != refreshToken1 { t.Errorf("unexpected refresh token: %v, want %v", got, refreshToken1) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&authCount1, 1) if _, err := fmt.Fprintf(w, `{"access_token":%q}`, accessToken1); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } })) defer as1.Close() ts1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&requestCount1, 1) if r.Method != http.MethodGet || r.URL.Path != "/" { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } header := "Bearer " + accessToken1 if auth := r.Header.Get("Authorization"); auth != header { challenge := fmt.Sprintf("Bearer realm=%q,service=%q,scope=%q", as1.URL, service1, strings.Join(scopes1, " ")) w.Header().Set("Www-Authenticate", challenge) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&successCount1, 1) })) defer ts1.Close() uri1, err := url.Parse(ts1.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } service1 = uri1.Host client1 := &Client{ Credential: StaticCredential(uri1.Host, Credential{ RefreshToken: refreshToken1, }), Cache: NewCache(), } // set up server 2 refreshToken2 := "test/refresh/token/1" accessToken2 := "test/access/token/1" var requestCount2, wantRequestCount2 int64 var successCount2, wantSuccessCount2 int64 var authCount2, wantAuthCount2 int64 var service2 string scopes2 := []string{ "repository:dst:pull,push", } as2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.Path != "/" { t.Error("unexecuted attempt of authorization service") w.WriteHeader(http.StatusUnauthorized) return } if err := r.ParseForm(); err != nil { t.Errorf("failed to parse form: %v", err) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("grant_type"); got != "refresh_token" { t.Errorf("unexpected grant type: %v, want %v", got, "refresh_token") w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("service"); got != service2 { t.Errorf("unexpected service: %v, want %v", got, service2) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("client_id"); got != defaultClientID { t.Errorf("unexpected client id: %v, want %v", got, defaultClientID) w.WriteHeader(http.StatusUnauthorized) return } scope := strings.Join(scopes2, " ") if got := r.PostForm.Get("scope"); got != scope { t.Errorf("unexpected scope: %v, want %v", got, scope) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("refresh_token"); got != refreshToken2 { t.Errorf("unexpected refresh token: %v, want %v", got, refreshToken2) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&authCount2, 1) if _, err := fmt.Fprintf(w, `{"access_token":%q}`, accessToken2); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } })) defer as2.Close() ts2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&requestCount2, 1) if r.Method != http.MethodGet || r.URL.Path != "/" { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } header := "Bearer " + accessToken2 if auth := r.Header.Get("Authorization"); auth != header { challenge := fmt.Sprintf("Bearer realm=%q,service=%q,scope=%q", as2.URL, service2, strings.Join(scopes2, " ")) w.Header().Set("Www-Authenticate", challenge) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&successCount2, 1) })) defer ts2.Close() uri2, err := url.Parse(ts2.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } service2 = uri2.Host client2 := &Client{ Credential: StaticCredential(uri2.Host, Credential{ RefreshToken: refreshToken2, }), Cache: NewCache(), } ctx := context.Background() ctx = WithScopesForHost(ctx, uri1.Host, scopes1...) ctx = WithScopesForHost(ctx, uri2.Host, scopes2...) // first request to server 1 req1, err := http.NewRequestWithContext(ctx, http.MethodGet, ts1.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp1, err := client1.Do(req1) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp1.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp1.StatusCode, http.StatusOK) } if wantRequestCount1 += 2; requestCount1 != wantRequestCount1 { t.Errorf("unexpected number of requests: %d, want %d", requestCount1, wantRequestCount1) } if wantSuccessCount1++; successCount1 != wantSuccessCount1 { t.Errorf("unexpected number of successful requests: %d, want %d", successCount1, wantSuccessCount1) } if wantAuthCount1++; authCount1 != wantAuthCount1 { t.Errorf("unexpected number of auth requests: %d, want %d", authCount1, wantAuthCount1) } // first request to server 2 req2, err := http.NewRequestWithContext(ctx, http.MethodGet, ts2.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp2, err := client2.Do(req2) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp2.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp2.StatusCode, http.StatusOK) } if wantRequestCount2 += 2; requestCount2 != wantRequestCount2 { t.Errorf("unexpected number of requests: %d, want %d", requestCount2, wantRequestCount2) } if wantSuccessCount2++; successCount2 != wantSuccessCount2 { t.Errorf("unexpected number of successful requests: %d, want %d", successCount2, wantSuccessCount2) } if wantAuthCount2++; authCount2 != wantAuthCount2 { t.Errorf("unexpected number of auth requests: %d, want %d", authCount2, wantAuthCount2) } // repeated request to server 1 req1, err = http.NewRequestWithContext(ctx, http.MethodGet, ts1.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp1, err = client1.Do(req1) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp1.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp1.StatusCode, http.StatusOK) } if wantRequestCount1++; requestCount1 != wantRequestCount1 { t.Errorf("unexpected number of requests: %d, want %d", requestCount1, wantRequestCount1) } if wantSuccessCount1++; successCount1 != wantSuccessCount1 { t.Errorf("unexpected number of successful requests: %d, want %d", successCount1, wantSuccessCount1) } if authCount1 != wantAuthCount1 { t.Errorf("unexpected number of auth requests: %d, want %d", authCount1, wantAuthCount1) } // repeated request to server 2 req2, err = http.NewRequestWithContext(ctx, http.MethodGet, ts2.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp2, err = client2.Do(req2) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp2.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp2.StatusCode, http.StatusOK) } if wantRequestCount2++; requestCount2 != wantRequestCount2 { t.Errorf("unexpected number of requests: %d, want %d", requestCount2, wantRequestCount2) } if wantSuccessCount2++; successCount2 != wantSuccessCount2 { t.Errorf("unexpected number of successful requests: %d, want %d", successCount2, wantSuccessCount2) } if authCount2 != wantAuthCount2 { t.Errorf("unexpected number of auth requests: %d, want %d", authCount2, wantAuthCount2) } // credential change to server 1 refreshToken1 = "test/refresh/token/1/new" accessToken1 = "test/access/token/1/new" req1, err = http.NewRequestWithContext(ctx, http.MethodGet, ts1.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } client1.Credential = StaticCredential(uri1.Host, Credential{ RefreshToken: refreshToken1, }) resp1, err = client1.Do(req1) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp1.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp1.StatusCode, http.StatusOK) } if wantRequestCount1 += 2; requestCount1 != wantRequestCount1 { t.Errorf("unexpected number of requests: %d, want %d", requestCount1, wantRequestCount1) } if wantSuccessCount1++; successCount1 != wantSuccessCount1 { t.Errorf("unexpected number of successful requests: %d, want %d", successCount1, wantSuccessCount1) } if wantAuthCount1++; authCount1 != wantAuthCount1 { t.Errorf("unexpected number of auth requests: %d, want %d", authCount1, wantAuthCount1) } // credential change to server 2 refreshToken2 = "test/refresh/token/2/new" accessToken2 = "test/access/token/2/new" req2, err = http.NewRequestWithContext(ctx, http.MethodGet, ts2.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } client2.Credential = StaticCredential(uri2.Host, Credential{ RefreshToken: refreshToken2, }) resp2, err = client2.Do(req2) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp2.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp2.StatusCode, http.StatusOK) } if wantRequestCount2 += 2; requestCount2 != wantRequestCount2 { t.Errorf("unexpected number of requests: %d, want %d", requestCount2, wantRequestCount2) } if wantSuccessCount2++; successCount2 != wantSuccessCount2 { t.Errorf("unexpected number of successful requests: %d, want %d", successCount2, wantSuccessCount2) } if wantAuthCount2++; authCount2 != wantAuthCount2 { t.Errorf("unexpected number of auth requests: %d, want %d", authCount2, wantAuthCount2) } } func TestClient_Do_Token_Expire(t *testing.T) { refreshToken := "test/refresh/token" accessToken := "test/access/token" var requestCount, wantRequestCount int64 var successCount, wantSuccessCount int64 var authCount, wantAuthCount int64 var service string scopes := []string{ "repository:dst:pull,push", "repository:src:pull", } as := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.Path != "/" { t.Error("unexecuted attempt of authorization service") w.WriteHeader(http.StatusUnauthorized) return } if err := r.ParseForm(); err != nil { t.Errorf("failed to parse form: %v", err) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("grant_type"); got != "refresh_token" { t.Errorf("unexpected grant type: %v, want %v", got, "refresh_token") w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("service"); got != service { t.Errorf("unexpected service: %v, want %v", got, service) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("client_id"); got != defaultClientID { t.Errorf("unexpected client id: %v, want %v", got, defaultClientID) w.WriteHeader(http.StatusUnauthorized) return } scope := strings.Join(scopes, " ") if got := r.PostForm.Get("scope"); got != scope { t.Errorf("unexpected scope: %v, want %v", got, scope) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("refresh_token"); got != refreshToken { t.Errorf("unexpected refresh token: %v, want %v", got, refreshToken) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&authCount, 1) if _, err := fmt.Fprintf(w, `{"access_token":%q}`, accessToken); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } })) defer as.Close() ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&requestCount, 1) if r.Method != http.MethodGet || r.URL.Path != "/" { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } header := "Bearer " + accessToken if auth := r.Header.Get("Authorization"); auth != header { challenge := fmt.Sprintf("Bearer realm=%q,service=%q,scope=%q", as.URL, service, strings.Join(scopes, " ")) w.Header().Set("Www-Authenticate", challenge) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&successCount, 1) })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } service = uri.Host client := &Client{ Credential: func(ctx context.Context, reg string) (Credential, error) { if reg != uri.Host { err := fmt.Errorf("registry mismatch: got %v, want %v", reg, uri.Host) t.Error(err) return EmptyCredential, err } return Credential{ RefreshToken: refreshToken, }, nil }, Cache: NewCache(), } // first request ctx := WithScopes(context.Background(), scopes...) req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp, err := client.Do(req) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp.StatusCode, http.StatusOK) } if wantRequestCount += 2; requestCount != wantRequestCount { t.Errorf("unexpected number of requests: %d, want %d", requestCount, wantRequestCount) } if wantSuccessCount++; successCount != wantSuccessCount { t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount) } if wantAuthCount++; authCount != wantAuthCount { t.Errorf("unexpected number of auth requests: %d, want %d", authCount, wantAuthCount) } // invalidate the access token and request again accessToken = "test/access/token/2" req, err = http.NewRequestWithContext(ctx, http.MethodGet, ts.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp, err = client.Do(req) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp.StatusCode, http.StatusOK) } if wantRequestCount += 2; requestCount != wantRequestCount { t.Errorf("unexpected number of requests: %d, want %d", requestCount, wantRequestCount) } if wantSuccessCount++; successCount != wantSuccessCount { t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount) } if wantAuthCount++; authCount != wantAuthCount { t.Errorf("unexpected number of auth requests: %d, want %d", authCount, wantAuthCount) } } func TestClient_Do_Token_Expire_PerHost(t *testing.T) { // set up server 1 refreshToken1 := "test/refresh/token/1" accessToken1 := "test/access/token/1" var requestCount1, wantRequestCount1 int64 var successCount1, wantSuccessCount1 int64 var authCount1, wantAuthCount1 int64 var service1 string scopes1 := []string{ "repository:src:pull", } as1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.Path != "/" { t.Error("unexecuted attempt of authorization service") w.WriteHeader(http.StatusUnauthorized) return } if err := r.ParseForm(); err != nil { t.Errorf("failed to parse form: %v", err) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("grant_type"); got != "refresh_token" { t.Errorf("unexpected grant type: %v, want %v", got, "refresh_token") w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("service"); got != service1 { t.Errorf("unexpected service: %v, want %v", got, service1) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("client_id"); got != defaultClientID { t.Errorf("unexpected client id: %v, want %v", got, defaultClientID) w.WriteHeader(http.StatusUnauthorized) return } scope := strings.Join(scopes1, " ") if got := r.PostForm.Get("scope"); got != scope { t.Errorf("unexpected scope: %v, want %v", got, scope) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("refresh_token"); got != refreshToken1 { t.Errorf("unexpected refresh token: %v, want %v", got, refreshToken1) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&authCount1, 1) if _, err := fmt.Fprintf(w, `{"access_token":%q}`, accessToken1); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } })) defer as1.Close() ts1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&requestCount1, 1) if r.Method != http.MethodGet || r.URL.Path != "/" { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } header := "Bearer " + accessToken1 if auth := r.Header.Get("Authorization"); auth != header { challenge := fmt.Sprintf("Bearer realm=%q,service=%q,scope=%q", as1.URL, service1, strings.Join(scopes1, " ")) w.Header().Set("Www-Authenticate", challenge) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&successCount1, 1) })) defer ts1.Close() uri1, err := url.Parse(ts1.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } service1 = uri1.Host client1 := &Client{ Credential: StaticCredential(uri1.Host, Credential{ RefreshToken: refreshToken1, }), Cache: NewCache(), } // set up server 2 refreshToken2 := "test/refresh/token/2" accessToken2 := "test/access/token/2" var requestCount2, wantRequestCount2 int64 var successCount2, wantSuccessCount2 int64 var authCount2, wantAuthCount2 int64 var service2 string scopes2 := []string{ "repository:dst:pull,push", } as2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.Path != "/" { t.Error("unexecuted attempt of authorization service") w.WriteHeader(http.StatusUnauthorized) return } if err := r.ParseForm(); err != nil { t.Errorf("failed to parse form: %v", err) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("grant_type"); got != "refresh_token" { t.Errorf("unexpected grant type: %v, want %v", got, "refresh_token") w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("service"); got != service2 { t.Errorf("unexpected service: %v, want %v", got, service2) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("client_id"); got != defaultClientID { t.Errorf("unexpected client id: %v, want %v", got, defaultClientID) w.WriteHeader(http.StatusUnauthorized) return } scope := strings.Join(scopes2, " ") if got := r.PostForm.Get("scope"); got != scope { t.Errorf("unexpected scope: %v, want %v", got, scope) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("refresh_token"); got != refreshToken2 { t.Errorf("unexpected refresh token: %v, want %v", got, refreshToken2) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&authCount2, 1) if _, err := fmt.Fprintf(w, `{"access_token":%q}`, accessToken2); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } })) defer as2.Close() ts2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&requestCount2, 1) if r.Method != http.MethodGet || r.URL.Path != "/" { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } header := "Bearer " + accessToken2 if auth := r.Header.Get("Authorization"); auth != header { challenge := fmt.Sprintf("Bearer realm=%q,service=%q,scope=%q", as2.URL, service2, strings.Join(scopes2, " ")) w.Header().Set("Www-Authenticate", challenge) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&successCount2, 1) })) defer ts2.Close() uri2, err := url.Parse(ts2.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } service2 = uri2.Host client2 := &Client{ Credential: StaticCredential(uri2.Host, Credential{ RefreshToken: refreshToken2, }), Cache: NewCache(), } ctx := context.Background() ctx = WithScopesForHost(ctx, uri1.Host, scopes1...) ctx = WithScopesForHost(ctx, uri2.Host, scopes2...) // first request to server 1 req1, err := http.NewRequestWithContext(ctx, http.MethodGet, ts1.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp1, err := client1.Do(req1) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp1.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp1.StatusCode, http.StatusOK) } if wantRequestCount1 += 2; requestCount1 != wantRequestCount1 { t.Errorf("unexpected number of requests: %d, want %d", requestCount1, wantRequestCount1) } if wantSuccessCount1++; successCount1 != wantSuccessCount1 { t.Errorf("unexpected number of successful requests: %d, want %d", successCount1, wantSuccessCount1) } if wantAuthCount1++; authCount1 != wantAuthCount1 { t.Errorf("unexpected number of auth requests: %d, want %d", authCount1, wantAuthCount1) } // first request to server 2 req2, err := http.NewRequestWithContext(ctx, http.MethodGet, ts2.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp2, err := client2.Do(req2) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp2.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp2.StatusCode, http.StatusOK) } if wantRequestCount2 += 2; requestCount2 != wantRequestCount2 { t.Errorf("unexpected number of requests: %d, want %d", requestCount2, wantRequestCount2) } if wantSuccessCount2++; successCount2 != wantSuccessCount2 { t.Errorf("unexpected number of successful requests: %d, want %d", successCount2, wantSuccessCount2) } if wantAuthCount2++; authCount2 != wantAuthCount2 { t.Errorf("unexpected number of auth requests: %d, want %d", authCount2, wantAuthCount2) } // invalidate the access token and request again to server 1 accessToken1 = "test/access/token/1/new" req1, err = http.NewRequestWithContext(ctx, http.MethodGet, ts1.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp1, err = client1.Do(req1) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp1.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp1.StatusCode, http.StatusOK) } if wantRequestCount1 += 2; requestCount1 != wantRequestCount1 { t.Errorf("unexpected number of requests: %d, want %d", requestCount1, wantRequestCount1) } if wantSuccessCount1++; successCount1 != wantSuccessCount1 { t.Errorf("unexpected number of successful requests: %d, want %d", successCount1, wantSuccessCount1) } if wantAuthCount1++; authCount1 != wantAuthCount1 { t.Errorf("unexpected number of auth requests: %d, want %d", authCount1, wantAuthCount1) } // invalidate the access token and request again to server 2 accessToken2 = "test/access/token/2/new" req2, err = http.NewRequestWithContext(ctx, http.MethodGet, ts2.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp2, err = client2.Do(req2) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp2.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp2.StatusCode, http.StatusOK) } if wantRequestCount2 += 2; requestCount2 != wantRequestCount2 { t.Errorf("unexpected number of requests: %d, want %d", requestCount2, wantRequestCount2) } if wantSuccessCount2++; successCount2 != wantSuccessCount2 { t.Errorf("unexpected number of successful requests: %d, want %d", successCount2, wantSuccessCount2) } if wantAuthCount2++; authCount2 != wantAuthCount2 { t.Errorf("unexpected number of auth requests: %d, want %d", authCount2, wantAuthCount2) } } func TestClient_Do_Scope_Hint_Mismatch(t *testing.T) { username := "test_user" password := "test_password" accessToken := "test/access/token" var requestCount, wantRequestCount int64 var successCount, wantSuccessCount int64 var authCount, wantAuthCount int64 var service string scopes := []string{ "repository:dst:pull,push", "repository:src:pull", } scope := "repository:test:delete" as := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.Path != "/" { t.Error("unexecuted attempt of authorization service") w.WriteHeader(http.StatusUnauthorized) return } if err := r.ParseForm(); err != nil { t.Errorf("failed to parse form: %v", err) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("grant_type"); got != "password" { t.Errorf("unexpected grant type: %v, want %v", got, "password") w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("service"); got != service { t.Errorf("unexpected service: %v, want %v", got, service) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("client_id"); got != defaultClientID { t.Errorf("unexpected client id: %v, want %v", got, defaultClientID) w.WriteHeader(http.StatusUnauthorized) return } scopes := CleanScopes(append([]string{scope}, scopes...)) scope := strings.Join(scopes, " ") if got := r.PostForm.Get("scope"); got != scope { t.Errorf("unexpected scope: %v, want %v", got, scope) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("username"); got != username { t.Errorf("unexpected username: %v, want %v", got, username) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("password"); got != password { t.Errorf("unexpected password: %v, want %v", got, password) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&authCount, 1) if _, err := fmt.Fprintf(w, `{"access_token":%q}`, accessToken); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } })) defer as.Close() ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&requestCount, 1) if r.Method != http.MethodGet || r.URL.Path != "/" { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } header := "Bearer " + accessToken if auth := r.Header.Get("Authorization"); auth != header { challenge := fmt.Sprintf("Bearer realm=%q,service=%q,scope=%q", as.URL, service, scope) w.Header().Set("Www-Authenticate", challenge) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&successCount, 1) })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } service = uri.Host client := &Client{ Credential: func(ctx context.Context, reg string) (Credential, error) { if reg != uri.Host { err := fmt.Errorf("registry mismatch: got %v, want %v", reg, uri.Host) t.Error(err) return EmptyCredential, err } return Credential{ Username: username, Password: password, }, nil }, ForceAttemptOAuth2: true, Cache: NewCache(), } // first request ctx := WithScopes(context.Background(), scopes...) req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp, err := client.Do(req) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp.StatusCode, http.StatusOK) } if wantRequestCount += 2; requestCount != wantRequestCount { t.Errorf("unexpected number of requests: %d, want %d", requestCount, wantRequestCount) } if wantSuccessCount++; successCount != wantSuccessCount { t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount) } if wantAuthCount++; authCount != wantAuthCount { t.Errorf("unexpected number of auth requests: %d, want %d", authCount, wantAuthCount) } // repeated request // although the actual scope does not match the hinted scopes, the client // with cache cannot avoid a request to obtain a challenge but can prevent // a repeated call to the authorization server. req, err = http.NewRequestWithContext(ctx, http.MethodGet, ts.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp, err = client.Do(req) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp.StatusCode, http.StatusOK) } if wantRequestCount += 2; requestCount != wantRequestCount { t.Errorf("unexpected number of requests: %d, want %d", requestCount, wantRequestCount) } if wantSuccessCount++; successCount != wantSuccessCount { t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount) } if authCount != wantAuthCount { t.Errorf("unexpected number of auth requests: %d, want %d", authCount, wantAuthCount) } } func TestClient_Do_Scope_Hint_Mismatch_PerHost(t *testing.T) { // set up server 1 username1 := "test_user1" password1 := "test_password1" accessToken1 := "test/access/token/1" var requestCount1, wantRequestCount1 int64 var successCount1, wantSuccessCount1 int64 var authCount1, wantAuthCount1 int64 var service1 string scopes1 := []string{ "repository:dst:pull,push", "repository:src:pull", } scope1 := "repository:test1:delete" as1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.Path != "/" { t.Error("unexecuted attempt of authorization service") w.WriteHeader(http.StatusUnauthorized) return } if err := r.ParseForm(); err != nil { t.Errorf("failed to parse form: %v", err) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("grant_type"); got != "password" { t.Errorf("unexpected grant type: %v, want %v", got, "password") w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("service"); got != service1 { t.Errorf("unexpected service: %v, want %v", got, service1) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("client_id"); got != defaultClientID { t.Errorf("unexpected client id: %v, want %v", got, defaultClientID) w.WriteHeader(http.StatusUnauthorized) return } scopes := CleanScopes(append([]string{scope1}, scopes1...)) scope := strings.Join(scopes, " ") if got := r.PostForm.Get("scope"); got != scope { t.Errorf("unexpected scope: %v, want %v", got, scope) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("username"); got != username1 { t.Errorf("unexpected username: %v, want %v", got, username1) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("password"); got != password1 { t.Errorf("unexpected password: %v, want %v", got, password1) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&authCount1, 1) if _, err := fmt.Fprintf(w, `{"access_token":%q}`, accessToken1); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } })) defer as1.Close() ts1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&requestCount1, 1) if r.Method != http.MethodGet || r.URL.Path != "/" { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } header := "Bearer " + accessToken1 if auth := r.Header.Get("Authorization"); auth != header { challenge := fmt.Sprintf("Bearer realm=%q,service=%q,scope=%q", as1.URL, service1, scope1) w.Header().Set("Www-Authenticate", challenge) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&successCount1, 1) })) defer ts1.Close() uri1, err := url.Parse(ts1.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } service1 = uri1.Host client1 := &Client{ Credential: StaticCredential(uri1.Host, Credential{ Username: username1, Password: password1, }), ForceAttemptOAuth2: true, Cache: NewCache(), } // set up server 1 username2 := "test_user2" password2 := "test_password2" accessToken2 := "test/access/token/2" var requestCount2, wantRequestCount2 int64 var successCount2, wantSuccessCount2 int64 var authCount2, wantAuthCount2 int64 var service2 string scopes2 := []string{ "repository:dst:pull,push", "repository:src:pull", } scope2 := "repository:test2:delete" as2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.Path != "/" { t.Error("unexecuted attempt of authorization service") w.WriteHeader(http.StatusUnauthorized) return } if err := r.ParseForm(); err != nil { t.Errorf("failed to parse form: %v", err) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("grant_type"); got != "password" { t.Errorf("unexpected grant type: %v, want %v", got, "password") w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("service"); got != service2 { t.Errorf("unexpected service: %v, want %v", got, service2) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("client_id"); got != defaultClientID { t.Errorf("unexpected client id: %v, want %v", got, defaultClientID) w.WriteHeader(http.StatusUnauthorized) return } scopes := CleanScopes(append([]string{scope2}, scopes2...)) scope := strings.Join(scopes, " ") if got := r.PostForm.Get("scope"); got != scope { t.Errorf("unexpected scope: %v, want %v", got, scope) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("username"); got != username2 { t.Errorf("unexpected username: %v, want %v", got, username2) w.WriteHeader(http.StatusUnauthorized) return } if got := r.PostForm.Get("password"); got != password2 { t.Errorf("unexpected password: %v, want %v", got, password2) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&authCount2, 1) if _, err := fmt.Fprintf(w, `{"access_token":%q}`, accessToken2); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } })) defer as2.Close() ts2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&requestCount2, 1) if r.Method != http.MethodGet || r.URL.Path != "/" { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } header := "Bearer " + accessToken2 if auth := r.Header.Get("Authorization"); auth != header { challenge := fmt.Sprintf("Bearer realm=%q,service=%q,scope=%q", as2.URL, service2, scope2) w.Header().Set("Www-Authenticate", challenge) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&successCount2, 1) })) defer ts1.Close() uri2, err := url.Parse(ts2.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } service2 = uri2.Host client2 := &Client{ Credential: StaticCredential(uri2.Host, Credential{ Username: username2, Password: password2, }), ForceAttemptOAuth2: true, Cache: NewCache(), } ctx := context.Background() ctx = WithScopesForHost(ctx, uri1.Host, scopes1...) ctx = WithScopesForHost(ctx, uri2.Host, scopes2...) // first request to server 1 req1, err := http.NewRequestWithContext(ctx, http.MethodGet, ts1.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp1, err := client1.Do(req1) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp1.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp1.StatusCode, http.StatusOK) } if wantRequestCount1 += 2; requestCount1 != wantRequestCount1 { t.Errorf("unexpected number of requests: %d, want %d", requestCount1, wantRequestCount1) } if wantSuccessCount1++; successCount1 != wantSuccessCount1 { t.Errorf("unexpected number of successful requests: %d, want %d", successCount1, wantSuccessCount1) } if wantAuthCount1++; authCount1 != wantAuthCount1 { t.Errorf("unexpected number of auth requests: %d, want %d", authCount1, wantAuthCount1) } // first request to server 1 req2, err := http.NewRequestWithContext(ctx, http.MethodGet, ts2.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp2, err := client2.Do(req2) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp2.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp2.StatusCode, http.StatusOK) } if wantRequestCount2 += 2; requestCount2 != wantRequestCount2 { t.Errorf("unexpected number of requests: %d, want %d", requestCount2, wantRequestCount2) } if wantSuccessCount2++; successCount2 != wantSuccessCount2 { t.Errorf("unexpected number of successful requests: %d, want %d", successCount2, wantSuccessCount2) } if wantAuthCount2++; authCount2 != wantAuthCount2 { t.Errorf("unexpected number of auth requests: %d, want %d", authCount2, wantAuthCount2) } // repeated request to server 1 // although the actual scope does not match the hinted scopes, the client // with cache cannot avoid a request to obtain a challenge but can prevent // a repeated call to the authorization server. req1, err = http.NewRequestWithContext(ctx, http.MethodGet, ts1.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp1, err = client1.Do(req1) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp1.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp1.StatusCode, http.StatusOK) } if wantRequestCount1 += 2; requestCount1 != wantRequestCount1 { t.Errorf("unexpected number of requests: %d, want %d", requestCount1, wantRequestCount1) } if wantSuccessCount1++; successCount1 != wantSuccessCount1 { t.Errorf("unexpected number of successful requests: %d, want %d", successCount1, wantSuccessCount1) } if authCount1 != wantAuthCount1 { t.Errorf("unexpected number of auth requests: %d, want %d", authCount1, wantAuthCount1) } // repeated request to server 2 // although the actual scope does not match the hinted scopes, the client // with cache cannot avoid a request to obtain a challenge but can prevent // a repeated call to the authorization server. req2, err = http.NewRequestWithContext(ctx, http.MethodGet, ts2.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp2, err = client2.Do(req2) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp2.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp2.StatusCode, http.StatusOK) } if wantRequestCount2 += 2; requestCount2 != wantRequestCount2 { t.Errorf("unexpected number of requests: %d, want %d", requestCount2, wantRequestCount2) } if wantSuccessCount2++; successCount2 != wantSuccessCount2 { t.Errorf("unexpected number of successful requests: %d, want %d", successCount2, wantSuccessCount2) } if authCount2 != wantAuthCount2 { t.Errorf("unexpected number of auth requests: %d, want %d", authCount2, wantAuthCount2) } } func TestClient_Do_Invalid_Credential_Basic(t *testing.T) { username := "test_user" password := "test_password" var requestCount, wantRequestCount int64 var successCount, wantSuccessCount int64 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&requestCount, 1) if r.Method != http.MethodGet || r.URL.Path != "/" { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } header := "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password)) if auth := r.Header.Get("Authorization"); auth != header { w.Header().Set("Www-Authenticate", `Basic realm="Test Server"`) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&successCount, 1) t.Error("authentication should fail but succeeded") })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } client := &Client{ Credential: func(ctx context.Context, reg string) (Credential, error) { if reg != uri.Host { err := fmt.Errorf("registry mismatch: got %v, want %v", reg, uri.Host) t.Error(err) return EmptyCredential, err } return Credential{ Username: username, Password: "bad credential", }, nil }, } // request should fail req, err := http.NewRequest(http.MethodGet, ts.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp, err := client.Do(req) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp.StatusCode != http.StatusUnauthorized { t.Errorf("Client.Do() = %v, want %v", resp.StatusCode, http.StatusUnauthorized) } if wantRequestCount += 2; requestCount != wantRequestCount { t.Errorf("unexpected number of requests: %d, want %d", requestCount, wantRequestCount) } if successCount != wantSuccessCount { t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount) } } func TestClient_Do_Invalid_Credential_Bearer(t *testing.T) { username := "test_user" password := "test_password" accessToken := "test/access/token" var requestCount, wantRequestCount int64 var successCount, wantSuccessCount int64 var authCount, wantAuthCount int64 var service string scopes := []string{ "repository:dst:pull,push", "repository:src:pull", } as := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || r.URL.Path != "/" { t.Error("unexecuted attempt of authorization service") w.WriteHeader(http.StatusUnauthorized) return } header := "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password)) if auth := r.Header.Get("Authorization"); auth != header { atomic.AddInt64(&authCount, 1) w.WriteHeader(http.StatusUnauthorized) return } t.Error("authentication should fail but succeeded") })) defer as.Close() ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&requestCount, 1) if r.Method != http.MethodGet || r.URL.Path != "/" { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } header := "Bearer " + accessToken if auth := r.Header.Get("Authorization"); auth != header { challenge := fmt.Sprintf("Bearer realm=%q,service=%q,scope=%q", as.URL, service, strings.Join(scopes, " ")) w.Header().Set("Www-Authenticate", challenge) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&successCount, 1) t.Error("authentication should fail but succeeded") })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } service = uri.Host client := &Client{ Credential: func(ctx context.Context, reg string) (Credential, error) { if reg != uri.Host { err := fmt.Errorf("registry mismatch: got %v, want %v", reg, uri.Host) t.Error(err) return EmptyCredential, err } return Credential{ Username: username, Password: "bad credential", }, nil }, } // request should fail req, err := http.NewRequest(http.MethodGet, ts.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } _, err = client.Do(req) if err == nil { t.Fatalf("Client.Do() error = %v, wantErr %v", err, true) } if wantRequestCount++; requestCount != wantRequestCount { t.Errorf("unexpected number of requests: %d, want %d", requestCount, wantRequestCount) } if successCount != wantSuccessCount { t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount) } if wantAuthCount++; authCount != wantAuthCount { t.Errorf("unexpected number of auth requests: %d, want %d", authCount, wantAuthCount) } } func TestClient_Do_Anonymous_Pull(t *testing.T) { accessToken := "test/access/token" var requestCount, wantRequestCount int64 var successCount, wantSuccessCount int64 var authCount, wantAuthCount int64 var service string scope := "repository:test:pull" as := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || r.URL.Path != "/" { t.Error("unexecuted attempt of authorization service") w.WriteHeader(http.StatusUnauthorized) return } if auth := r.Header.Get("Authorization"); auth != "" { t.Errorf("unexpected auth: got %s, want %s", auth, "") w.WriteHeader(http.StatusUnauthorized) return } if got := r.URL.Query().Get("service"); got != service { t.Errorf("unexpected service: got %s, want %s", got, service) w.WriteHeader(http.StatusUnauthorized) return } if got := r.URL.Query().Get("scope"); got != scope { t.Errorf("unexpected scope: got %s, want %s", got, scope) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&authCount, 1) if _, err := fmt.Fprintf(w, `{"access_token":%q}`, accessToken); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } })) defer as.Close() ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&requestCount, 1) if r.Method != http.MethodGet || r.URL.Path != "/" { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } header := "Bearer " + accessToken if auth := r.Header.Get("Authorization"); auth != header { challenge := fmt.Sprintf("Bearer realm=%q,service=%q,scope=%q", as.URL, service, scope) w.Header().Set("Www-Authenticate", challenge) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&successCount, 1) })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } service = uri.Host // request with the default client req, err := http.NewRequest(http.MethodGet, ts.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp, err := DefaultClient.Do(req) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp.StatusCode, http.StatusOK) } if wantRequestCount += 2; requestCount != wantRequestCount { t.Errorf("unexpected number of requests: %d, want %d", requestCount, wantRequestCount) } if wantSuccessCount++; successCount != wantSuccessCount { t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount) } if wantAuthCount++; authCount != wantAuthCount { t.Errorf("unexpected number of auth requests: %d, want %d", authCount, wantAuthCount) } } func TestClient_Do_Scheme_Change(t *testing.T) { username := "test_user" password := "test_password" accessToken := "test/access/token" var requestCount, wantRequestCount int64 var successCount, wantSuccessCount int64 var authCount, wantAuthCount int64 var service string scope := "repository:test:pull" challengeBearerAuth := true as := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || r.URL.Path != "/" { t.Error("unexecuted attempt of authorization service") w.WriteHeader(http.StatusUnauthorized) return } header := "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password)) if auth := r.Header.Get("Authorization"); auth != header { t.Errorf("unexpected auth: got %s, want %s", auth, header) w.WriteHeader(http.StatusUnauthorized) return } if got := r.URL.Query().Get("service"); got != service { t.Errorf("unexpected service: got %s, want %s", got, service) w.WriteHeader(http.StatusUnauthorized) return } if got := r.URL.Query().Get("scope"); got != scope { t.Errorf("unexpected scope: got %s, want %s", got, scope) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&authCount, 1) if _, err := fmt.Fprintf(w, `{"access_token":%q}`, accessToken); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } })) defer as.Close() ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&requestCount, 1) if r.Method != http.MethodGet || r.URL.Path != "/" { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } bearerHeader := "Bearer " + accessToken basicHeader := "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password)) header := r.Header.Get("Authorization") if (challengeBearerAuth && header != bearerHeader) || (!challengeBearerAuth && header != basicHeader) { var challenge string if challengeBearerAuth { challenge = fmt.Sprintf("Bearer realm=%q,service=%q,scope=%q", as.URL, service, scope) } else { challenge = `Basic realm="Test Server"` } w.Header().Set("Www-Authenticate", challenge) w.WriteHeader(http.StatusUnauthorized) return } atomic.AddInt64(&successCount, 1) })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } service = uri.Host client := &Client{ Credential: func(ctx context.Context, reg string) (Credential, error) { if reg != uri.Host { err := fmt.Errorf("registry mismatch: got %v, want %v", reg, uri.Host) t.Error(err) return EmptyCredential, err } return Credential{ Username: username, Password: password, }, nil }, Cache: NewCache(), } // request with bearer auth req, err := http.NewRequest(http.MethodGet, ts.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp, err := client.Do(req) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp.StatusCode, http.StatusOK) } if wantRequestCount += 2; requestCount != wantRequestCount { t.Errorf("unexpected number of requests: %d, want %d", requestCount, wantRequestCount) } if wantSuccessCount++; successCount != wantSuccessCount { t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount) } if wantAuthCount++; authCount != wantAuthCount { t.Errorf("unexpected number of auth requests: %d, want %d", authCount, wantAuthCount) } // change to basic auth challengeBearerAuth = false req, err = http.NewRequest(http.MethodGet, ts.URL, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp, err = client.Do(req) if err != nil { t.Fatalf("Client.Do() error = %v", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Client.Do() = %v, want %v", resp.StatusCode, http.StatusOK) } if wantRequestCount += 2; requestCount != wantRequestCount { t.Errorf("unexpected number of requests: %d, want %d", requestCount, wantRequestCount) } if wantSuccessCount++; successCount != wantSuccessCount { t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount) } if authCount != wantAuthCount { t.Errorf("unexpected number of auth requests: %d, want %d", authCount, wantAuthCount) } } func TestStaticCredential(t *testing.T) { tests := []struct { name string registry string target string cred Credential want Credential }{ { name: "Matched credential for regular registry", registry: "registry.example.com", target: "registry.example.com", cred: Credential{ Username: "username", Password: "password", }, want: Credential{ Username: "username", Password: "password", }, }, { name: "Matched credential for docker.io", registry: "docker.io", target: "registry-1.docker.io", cred: Credential{ Username: "username", Password: "password", }, want: Credential{ Username: "username", Password: "password", }, }, { name: "Mismatched credential for regular registry", registry: "registry.example.com", target: "whatever.example.com", cred: Credential{ Username: "username", Password: "password", }, want: EmptyCredential, }, { name: "Mismatched credential for docker.io", registry: "docker.io", target: "whatever.docker.io", cred: Credential{ Username: "username", Password: "password", }, want: EmptyCredential, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { client := &Client{ Credential: StaticCredential(tt.registry, tt.cred), } ctx := context.Background() got, err := client.Credential(ctx, tt.target) if err != nil { t.Fatal("Client.Credential() error =", err) } if !reflect.DeepEqual(got, tt.want) { t.Errorf("Client.Credential() = %v, want %v", got, tt.want) } }) } } func TestClient_StaticCredential_basicAuth(t *testing.T) { testUsername := "username" testPassword := "password" // create a test server ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { path := r.URL.Path if r.Method != http.MethodGet { w.WriteHeader(http.StatusNotFound) t.Fatal("unexpected access") } switch path { case "/basicAuth": wantedAuthHeader := "Basic " + base64.StdEncoding.EncodeToString([]byte(testUsername+":"+testPassword)) authHeader := r.Header.Get("Authorization") if authHeader != wantedAuthHeader { w.Header().Set("Www-Authenticate", `Basic realm="Test Server"`) w.WriteHeader(http.StatusUnauthorized) } default: w.WriteHeader(http.StatusNotAcceptable) } })) defer ts.Close() host := ts.URL uri, _ := url.Parse(host) hostAddress := uri.Host basicAuthURL := fmt.Sprintf("%s/basicAuth", host) // create a test client with the correct credentials clientValid := &Client{ Credential: StaticCredential(hostAddress, Credential{ Username: testUsername, Password: testPassword, }), } req, err := http.NewRequest(http.MethodGet, basicAuthURL, nil) if err != nil { t.Fatalf("could not create request, err = %v", err) } respValid, err := clientValid.Do(req) if err != nil { t.Fatalf("could not send request, err = %v", err) } if respValid.StatusCode != 200 { t.Errorf("incorrect status code: %d, expected 200", respValid.StatusCode) } // create a test client with incorrect credentials clientInvalid := &Client{ Credential: StaticCredential(hostAddress, Credential{ Username: "foo", Password: "bar", }), } respInvalid, err := clientInvalid.Do(req) if err != nil { t.Fatalf("could not send request, err = %v", err) } if respInvalid.StatusCode != 401 { t.Errorf("incorrect status code: %d, expected 401", respInvalid.StatusCode) } } func TestClient_StaticCredential_withAccessToken(t *testing.T) { var host string testAccessToken := "test/access/token" scope := "repository:test:pull,push" // create an authorization server as := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) t.Error("unexecuted attempt of authorization service") })) defer as.Close() // create a test server ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { path := r.URL.Path if r.Method != http.MethodGet { w.WriteHeader(http.StatusNotFound) t.Fatal("unexpected access") } switch path { case "/accessToken": wantedAuthHeader := "Bearer " + testAccessToken if auth := r.Header.Get("Authorization"); auth != wantedAuthHeader { challenge := fmt.Sprintf("Bearer realm=%q,service=%q,scope=%q", as.URL, host, scope) w.Header().Set("Www-Authenticate", challenge) w.WriteHeader(http.StatusUnauthorized) } default: w.WriteHeader(http.StatusNotAcceptable) } })) defer ts.Close() host = ts.URL uri, _ := url.Parse(host) hostAddress := uri.Host accessTokenURL := fmt.Sprintf("%s/accessToken", host) // create a test client with the correct credentials clientValid := &Client{ Credential: StaticCredential(hostAddress, Credential{ AccessToken: testAccessToken, }), } req, err := http.NewRequest(http.MethodGet, accessTokenURL, nil) if err != nil { t.Fatalf("could not create request, err = %v", err) } respValid, err := clientValid.Do(req) if err != nil { t.Fatalf("could not send request, err = %v", err) } if respValid.StatusCode != 200 { t.Errorf("incorrect status code: %d, expected 200", respValid.StatusCode) } // create a test client with incorrect credentials clientInvalid := &Client{ Credential: StaticCredential(hostAddress, Credential{ AccessToken: "foo", }), } respInvalid, err := clientInvalid.Do(req) if err != nil { t.Fatalf("could not send request, err = %v", err) } if respInvalid.StatusCode != 401 { t.Errorf("incorrect status code: %d, expected 401", respInvalid.StatusCode) } } func TestClient_StaticCredential_withRefreshToken(t *testing.T) { var host string testAccessToken := "test/access/token" testRefreshToken := "test/refresh/token" scope := "repository:test:pull,push" // create an authorization server as := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodPost { w.WriteHeader(http.StatusUnauthorized) t.Error("unexecuted attempt of authorization service") } if err := r.ParseForm(); err != nil { w.WriteHeader(http.StatusUnauthorized) t.Error("failed to parse form") } if got := r.PostForm.Get("service"); got != host { w.WriteHeader(http.StatusUnauthorized) } // handles refresh token requests if got := r.PostForm.Get("grant_type"); got != "refresh_token" { w.WriteHeader(http.StatusUnauthorized) } if got := r.PostForm.Get("scope"); got != scope { w.WriteHeader(http.StatusUnauthorized) } if got := r.PostForm.Get("refresh_token"); got != testRefreshToken { w.WriteHeader(http.StatusUnauthorized) } // writes back access token if _, err := fmt.Fprintf(w, `{"access_token":%q}`, testAccessToken); err != nil { t.Fatalf("could not write back access token, error = %v", err) } })) defer as.Close() // create a test server ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { path := r.URL.Path if r.Method != http.MethodGet { w.WriteHeader(http.StatusNotFound) panic("unexpected access") } switch path { case "/refreshToken": wantedAuthHeader := "Bearer " + testAccessToken if auth := r.Header.Get("Authorization"); auth != wantedAuthHeader { challenge := fmt.Sprintf("Bearer realm=%q,service=%q,scope=%q", as.URL, host, scope) w.Header().Set("Www-Authenticate", challenge) w.WriteHeader(http.StatusUnauthorized) } default: w.WriteHeader(http.StatusNotAcceptable) } })) defer ts.Close() host = ts.URL uri, _ := url.Parse(host) hostAddress := uri.Host refreshTokenURL := fmt.Sprintf("%s/refreshToken", host) // create a test client with the correct credentials clientValid := &Client{ Credential: StaticCredential(hostAddress, Credential{ RefreshToken: testRefreshToken, }), } req, err := http.NewRequest(http.MethodGet, refreshTokenURL, nil) if err != nil { t.Fatalf("could not create request, err = %v", err) } respValid, err := clientValid.Do(req) if err != nil { t.Fatalf("could not send request, err = %v", err) } if respValid.StatusCode != 200 { t.Errorf("incorrect status code: %d, expected 200", respValid.StatusCode) } // create a test client with incorrect credentials clientInvalid := &Client{ Credential: StaticCredential(hostAddress, Credential{ RefreshToken: "bar", }), } _, err = clientInvalid.Do(req) var expectedError *errcode.ErrorResponse if !errors.As(err, &expectedError) || expectedError.StatusCode != http.StatusUnauthorized { t.Errorf("incorrect error: %v, expected %v", err, expectedError) } } func TestClient_fetchBasicAuth(t *testing.T) { c := &Client{ Credential: func(ctx context.Context, registry string) (Credential, error) { return EmptyCredential, nil }, } _, err := c.fetchBasicAuth(context.Background(), "") if err != ErrBasicCredentialNotFound { t.Errorf("incorrect error: %v, expected %v", err, ErrBasicCredentialNotFound) } } oras-go-2.5.0/registry/remote/auth/credential.go000066400000000000000000000025451457674530300216560ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package auth // EmptyCredential represents an empty credential. var EmptyCredential Credential // Credential contains authentication credentials used to access remote // registries. type Credential struct { // Username is the name of the user for the remote registry. Username string // Password is the secret associated with the username. Password string // RefreshToken is a bearer token to be sent to the authorization service // for fetching access tokens. // A refresh token is often referred as an identity token. // Reference: https://docs.docker.com/registry/spec/auth/oauth/ RefreshToken string // AccessToken is a bearer token to be sent to the registry. // An access token is often referred as a registry token. // Reference: https://docs.docker.com/registry/spec/auth/token/ AccessToken string } oras-go-2.5.0/registry/remote/auth/example_test.go000066400000000000000000000202721457674530300222330ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // Package auth_test includes the testable examples for the http client. package auth_test import ( "context" "encoding/base64" "fmt" "net/http" "net/http/httptest" "net/url" "os" "strings" "testing" . "oras.land/oras-go/v2/registry/internal/doc" "oras.land/oras-go/v2/registry/remote/auth" ) const ( username = "test_user" password = "test_password" accessToken = "test/access/token" refreshToken = "test/refresh/token" _ = ExampleUnplayable ) var ( host string expectedHostAddress string targetURL string clientConfigTargetURL string basicAuthTargetURL string accessTokenTargetURL string refreshTokenTargetURL string tokenScopes = []string{ "repository:dst:pull,push", "repository:src:pull", } ) func TestMain(m *testing.M) { // create an authorization server as := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodPost { w.WriteHeader(http.StatusUnauthorized) panic("unexecuted attempt of authorization service") } if err := r.ParseForm(); err != nil { w.WriteHeader(http.StatusUnauthorized) panic("failed to parse form") } if got := r.PostForm.Get("service"); got != host { w.WriteHeader(http.StatusUnauthorized) } // handles refresh token requests if got := r.PostForm.Get("grant_type"); got != "refresh_token" { w.WriteHeader(http.StatusUnauthorized) } scope := strings.Join(tokenScopes, " ") if got := r.PostForm.Get("scope"); got != scope { w.WriteHeader(http.StatusUnauthorized) } if got := r.PostForm.Get("refresh_token"); got != refreshToken { w.WriteHeader(http.StatusUnauthorized) } // writes back access token if _, err := fmt.Fprintf(w, `{"access_token":%q}`, accessToken); err != nil { panic(err) } })) defer as.Close() // create a test server ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { path := r.URL.Path if r.Method != http.MethodGet { w.WriteHeader(http.StatusNotFound) panic("unexpected access") } switch path { case "/basicAuth": wantedAuthHeader := "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password)) authHeader := r.Header.Get("Authorization") if authHeader != wantedAuthHeader { w.Header().Set("Www-Authenticate", `Basic realm="Test Server"`) w.WriteHeader(http.StatusUnauthorized) } case "/clientConfig": wantedAuthHeader := "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password)) authHeader := r.Header.Get("Authorization") if authHeader != wantedAuthHeader { w.Header().Set("Www-Authenticate", `Basic realm="Test Server"`) w.WriteHeader(http.StatusUnauthorized) } case "/accessToken": wantedAuthHeader := "Bearer " + accessToken if auth := r.Header.Get("Authorization"); auth != wantedAuthHeader { challenge := fmt.Sprintf("Bearer realm=%q,service=%q,scope=%q", as.URL, host, strings.Join(tokenScopes, " ")) w.Header().Set("Www-Authenticate", challenge) w.WriteHeader(http.StatusUnauthorized) } case "/refreshToken": wantedAuthHeader := "Bearer " + accessToken if auth := r.Header.Get("Authorization"); auth != wantedAuthHeader { challenge := fmt.Sprintf("Bearer realm=%q,service=%q,scope=%q", as.URL, host, strings.Join(tokenScopes, " ")) w.Header().Set("Www-Authenticate", challenge) w.WriteHeader(http.StatusUnauthorized) } case "/simple": w.WriteHeader(http.StatusOK) default: w.WriteHeader(http.StatusNotAcceptable) } })) defer ts.Close() host = ts.URL uri, _ := url.Parse(host) expectedHostAddress = uri.Host targetURL = fmt.Sprintf("%s/simple", host) basicAuthTargetURL = fmt.Sprintf("%s/basicAuth", host) clientConfigTargetURL = fmt.Sprintf("%s/clientConfig", host) accessTokenTargetURL = fmt.Sprintf("%s/accessToken", host) refreshTokenTargetURL = fmt.Sprintf("%s/refreshToken", host) http.DefaultClient = ts.Client() os.Exit(m.Run()) } // ExampleClient_Do_minimalClient gives an example of a minimal working client. func ExampleClient_Do_minimalClient() { var client auth.Client // targetURL can be any URL. For example, https://registry.wabbit-networks.io/v2/ req, err := http.NewRequest(http.MethodGet, targetURL, nil) if err != nil { panic(err) } resp, err := client.Do(req) if err != nil { panic(err) } fmt.Println(resp.StatusCode) // Output: // 200 } // ExampleClient_Do_basicAuth gives an example of using client with credentials. func ExampleClient_Do_basicAuth() { client := &auth.Client{ // expectedHostAddress is of form ipaddr:port Credential: auth.StaticCredential(expectedHostAddress, auth.Credential{ Username: username, Password: password, }), } // basicAuthTargetURL can be any URL. For example, https://registry.wabbit-networks.io/v2/ req, err := http.NewRequest(http.MethodGet, basicAuthTargetURL, nil) if err != nil { panic(err) } resp, err := client.Do(req) if err != nil { panic(err) } fmt.Println(resp.StatusCode) // Output: // 200 } // ExampleClient_Do_clientConfigurations shows the client configurations available, // including using cache, setting user agent and configuring OAuth2. func ExampleClient_Do_clientConfigurations() { client := &auth.Client{ // expectedHostAddress is of form ipaddr:port Credential: auth.StaticCredential(expectedHostAddress, auth.Credential{ Username: username, Password: password, }), // ForceAttemptOAuth2 controls whether to follow OAuth2 with password grant. ForceAttemptOAuth2: true, // Cache caches credentials for accessing the remote registry. Cache: auth.NewCache(), } // SetUserAgent sets the user agent for all out-going requests. client.SetUserAgent("example user agent") // Tokens carry restrictions about what resources they can access and how. // Such restrictions are represented and enforced as Scopes. // Reference: https://docs.docker.com/registry/spec/auth/scope/ scopes := []string{ "repository:dst:pull,push", "repository:src:pull", } // WithScopes returns a context with scopes added. ctx := auth.WithScopes(context.Background(), scopes...) // clientConfigTargetURL can be any URL. For example, https://registry.wabbit-networks.io/v2/ req, err := http.NewRequestWithContext(ctx, http.MethodGet, clientConfigTargetURL, nil) if err != nil { panic(err) } resp, err := client.Do(req) if err != nil { panic(err) } fmt.Println(resp.StatusCode) // Output: // 200 } // ExampleClient_Do_withAccessToken gives an example of using client with an access token. func ExampleClient_Do_withAccessToken() { client := &auth.Client{ // expectedHostAddress is of form ipaddr:port Credential: auth.StaticCredential(expectedHostAddress, auth.Credential{ AccessToken: accessToken, }), } // accessTokenTargetURL can be any URL. For example, https://registry.wabbit-networks.io/v2/ req, err := http.NewRequest(http.MethodGet, accessTokenTargetURL, nil) if err != nil { panic(err) } resp, err := client.Do(req) if err != nil { panic(err) } fmt.Println(resp.StatusCode) // Output: // 200 } // ExampleClient_Do_withRefreshToken gives an example of using client with a refresh token. func ExampleClient_Do_withRefreshToken() { client := &auth.Client{ // expectedHostAddress is of form ipaddr:port Credential: auth.StaticCredential(expectedHostAddress, auth.Credential{ RefreshToken: refreshToken, }), } // refreshTokenTargetURL can be any URL. For example, https://registry.wabbit-networks.io/v2/ req, err := http.NewRequest(http.MethodGet, refreshTokenTargetURL, nil) if err != nil { panic(err) } resp, err := client.Do(req) if err != nil { panic(err) } fmt.Println(resp.StatusCode) // Output: // 200 } oras-go-2.5.0/registry/remote/auth/scope.go000066400000000000000000000243371457674530300206600ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package auth import ( "context" "slices" "strings" "oras.land/oras-go/v2/registry" ) // Actions used in scopes. // Reference: https://docs.docker.com/registry/spec/auth/scope/ const ( // ActionPull represents generic read access for resources of the repository // type. ActionPull = "pull" // ActionPush represents generic write access for resources of the // repository type. ActionPush = "push" // ActionDelete represents the delete permission for resources of the // repository type. ActionDelete = "delete" ) // ScopeRegistryCatalog is the scope for registry catalog access. const ScopeRegistryCatalog = "registry:catalog:*" // ScopeRepository returns a repository scope with given actions. // Reference: https://docs.docker.com/registry/spec/auth/scope/ func ScopeRepository(repository string, actions ...string) string { actions = cleanActions(actions) if repository == "" || len(actions) == 0 { return "" } return strings.Join([]string{ "repository", repository, strings.Join(actions, ","), }, ":") } // AppendRepositoryScope returns a new context containing scope hints for the // auth client to fetch bearer tokens with the given actions on the repository. // If called multiple times, the new scopes will be appended to the existing // scopes. The resulted scopes are de-duplicated. // // For example, uploading blob to the repository "hello-world" does HEAD request // first then POST and PUT. The HEAD request will return a challenge for scope // `repository:hello-world:pull`, and the auth client will fetch a token for // that challenge. Later, the POST request will return a challenge for scope // `repository:hello-world:push`, and the auth client will fetch a token for // that challenge again. By invoking AppendRepositoryScope with the actions // [ActionPull] and [ActionPush] for the repository `hello-world`, // the auth client with cache is hinted to fetch a token via a single token // fetch request for all the HEAD, POST, PUT requests. func AppendRepositoryScope(ctx context.Context, ref registry.Reference, actions ...string) context.Context { if len(actions) == 0 { return ctx } scope := ScopeRepository(ref.Repository, actions...) return AppendScopesForHost(ctx, ref.Host(), scope) } // scopesContextKey is the context key for scopes. type scopesContextKey struct{} // WithScopes returns a context with scopes added. Scopes are de-duplicated. // Scopes are used as hints for the auth client to fetch bearer tokens with // larger scopes. // // For example, uploading blob to the repository "hello-world" does HEAD request // first then POST and PUT. The HEAD request will return a challenge for scope // `repository:hello-world:pull`, and the auth client will fetch a token for // that challenge. Later, the POST request will return a challenge for scope // `repository:hello-world:push`, and the auth client will fetch a token for // that challenge again. By invoking WithScopes with the scope // `repository:hello-world:pull,push`, the auth client with cache is hinted to // fetch a token via a single token fetch request for all the HEAD, POST, PUT // requests. // // Passing an empty list of scopes will virtually remove the scope hints in the // context. // // Reference: https://docs.docker.com/registry/spec/auth/scope/ func WithScopes(ctx context.Context, scopes ...string) context.Context { scopes = CleanScopes(scopes) return context.WithValue(ctx, scopesContextKey{}, scopes) } // AppendScopes appends additional scopes to the existing scopes in the context // and returns a new context. The resulted scopes are de-duplicated. // The append operation does modify the existing scope in the context passed in. func AppendScopes(ctx context.Context, scopes ...string) context.Context { if len(scopes) == 0 { return ctx } return WithScopes(ctx, append(GetScopes(ctx), scopes...)...) } // GetScopes returns the scopes in the context. func GetScopes(ctx context.Context) []string { if scopes, ok := ctx.Value(scopesContextKey{}).([]string); ok { return slices.Clone(scopes) } return nil } // scopesForHostContextKey is the context key for per-host scopes. type scopesForHostContextKey string // WithScopesForHost returns a context with per-host scopes added. // Scopes are de-duplicated. // Scopes are used as hints for the auth client to fetch bearer tokens with // larger scopes. // // For example, uploading blob to the repository "hello-world" does HEAD request // first then POST and PUT. The HEAD request will return a challenge for scope // `repository:hello-world:pull`, and the auth client will fetch a token for // that challenge. Later, the POST request will return a challenge for scope // `repository:hello-world:push`, and the auth client will fetch a token for // that challenge again. By invoking WithScopesForHost with the scope // `repository:hello-world:pull,push`, the auth client with cache is hinted to // fetch a token via a single token fetch request for all the HEAD, POST, PUT // requests. // // Passing an empty list of scopes will virtually remove the scope hints in the // context for the given host. // // Reference: https://docs.docker.com/registry/spec/auth/scope/ func WithScopesForHost(ctx context.Context, host string, scopes ...string) context.Context { scopes = CleanScopes(scopes) return context.WithValue(ctx, scopesForHostContextKey(host), scopes) } // AppendScopesForHost appends additional scopes to the existing scopes // in the context for the given host and returns a new context. // The resulted scopes are de-duplicated. // The append operation does modify the existing scope in the context passed in. func AppendScopesForHost(ctx context.Context, host string, scopes ...string) context.Context { if len(scopes) == 0 { return ctx } oldScopes := GetScopesForHost(ctx, host) return WithScopesForHost(ctx, host, append(oldScopes, scopes...)...) } // GetScopesForHost returns the scopes in the context for the given host, // excluding global scopes added by [WithScopes] and [AppendScopes]. func GetScopesForHost(ctx context.Context, host string) []string { if scopes, ok := ctx.Value(scopesForHostContextKey(host)).([]string); ok { return slices.Clone(scopes) } return nil } // GetAllScopesForHost returns the scopes in the context for the given host, // including global scopes added by [WithScopes] and [AppendScopes]. func GetAllScopesForHost(ctx context.Context, host string) []string { scopes := GetScopesForHost(ctx, host) globalScopes := GetScopes(ctx) if len(scopes) == 0 { return globalScopes } if len(globalScopes) == 0 { return scopes } // re-clean the scopes allScopes := append(scopes, globalScopes...) return CleanScopes(allScopes) } // CleanScopes merges and sort the actions in ascending order if the scopes have // the same resource type and name. The final scopes are sorted in ascending // order. In other words, the scopes passed in are de-duplicated and sorted. // Therefore, the output of this function is deterministic. // // If there is a wildcard `*` in the action, other actions in the same resource // type and name are ignored. func CleanScopes(scopes []string) []string { // fast paths switch len(scopes) { case 0: return nil case 1: scope := scopes[0] i := strings.LastIndex(scope, ":") if i == -1 { return []string{scope} } actionList := strings.Split(scope[i+1:], ",") actionList = cleanActions(actionList) if len(actionList) == 0 { return nil } actions := strings.Join(actionList, ",") scope = scope[:i+1] + actions return []string{scope} } // slow path var result []string // merge recognizable scopes resourceTypes := make(map[string]map[string]map[string]struct{}) for _, scope := range scopes { // extract resource type i := strings.Index(scope, ":") if i == -1 { result = append(result, scope) continue } resourceType := scope[:i] // extract resource name and actions rest := scope[i+1:] i = strings.LastIndex(rest, ":") if i == -1 { result = append(result, scope) continue } resourceName := rest[:i] actions := rest[i+1:] if actions == "" { // drop scope since no action found continue } // add to the intermediate map for de-duplication namedActions := resourceTypes[resourceType] if namedActions == nil { namedActions = make(map[string]map[string]struct{}) resourceTypes[resourceType] = namedActions } actionSet := namedActions[resourceName] if actionSet == nil { actionSet = make(map[string]struct{}) namedActions[resourceName] = actionSet } for _, action := range strings.Split(actions, ",") { if action != "" { actionSet[action] = struct{}{} } } } // reconstruct scopes for resourceType, namedActions := range resourceTypes { for resourceName, actionSet := range namedActions { if len(actionSet) == 0 { continue } var actions []string for action := range actionSet { if action == "*" { actions = []string{"*"} break } actions = append(actions, action) } slices.Sort(actions) scope := resourceType + ":" + resourceName + ":" + strings.Join(actions, ",") result = append(result, scope) } } // sort and return slices.Sort(result) return result } // cleanActions removes the duplicated actions and sort in ascending order. // If there is a wildcard `*` in the action, other actions are ignored. func cleanActions(actions []string) []string { // fast paths switch len(actions) { case 0: return nil case 1: if actions[0] == "" { return nil } return actions } // slow path slices.Sort(actions) n := 0 for i := 0; i < len(actions); i++ { if actions[i] == "*" { return []string{"*"} } if actions[i] != actions[n] { n++ if n != i { actions[n] = actions[i] } } } n++ if actions[0] == "" { if n == 1 { return nil } return actions[1:n] } return actions[:n] } oras-go-2.5.0/registry/remote/auth/scope_test.go000066400000000000000000000411521457674530300217110ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package auth import ( "context" "reflect" "testing" "oras.land/oras-go/v2/registry" ) func TestScopeRepository(t *testing.T) { tests := []struct { name string repository string actions []string want string }{ { name: "empty repository", actions: []string{ "pull", }, }, { name: "nil actions", repository: "foo", }, { name: "empty actions", repository: "foo", actions: []string{}, }, { name: "empty actions list", repository: "foo", actions: []string{}, }, { name: "empty actions", repository: "foo", actions: []string{ "", }, }, { name: "single action", repository: "foo", actions: []string{ "pull", }, want: "repository:foo:pull", }, { name: "multiple actions", repository: "foo", actions: []string{ "pull", "push", }, want: "repository:foo:pull,push", }, { name: "unordered actions", repository: "foo", actions: []string{ "push", "pull", }, want: "repository:foo:pull,push", }, { name: "duplicated actions", repository: "foo", actions: []string{ "push", "pull", "pull", "delete", "push", }, want: "repository:foo:delete,pull,push", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := ScopeRepository(tt.repository, tt.actions...); got != tt.want { t.Errorf("ScopeRepository() = %v, want %v", got, tt.want) } }) } } func TestWithScopeHints(t *testing.T) { ctx := context.Background() ref1, err := registry.ParseReference("registry.example.com/foo") if err != nil { t.Fatal("registry.ParseReference() error =", err) } ref2, err := registry.ParseReference("docker.io/foo") if err != nil { t.Fatal("registry.ParseReference() error =", err) } // with single scope want1 := []string{ "repository:foo:pull", } want2 := []string{ "repository:foo:push", } ctx = AppendRepositoryScope(ctx, ref1, ActionPull) ctx = AppendRepositoryScope(ctx, ref2, ActionPush) if got := GetScopesForHost(ctx, ref1.Host()); !reflect.DeepEqual(got, want1) { t.Errorf("GetScopesPerRegistry(WithScopeHints()) = %v, want %v", got, want1) } if got := GetScopesForHost(ctx, ref2.Host()); !reflect.DeepEqual(got, want2) { t.Errorf("GetScopesPerRegistry(WithScopeHints()) = %v, want %v", got, want2) } // with duplicated scopes scopes1 := []string{ ActionDelete, ActionDelete, ActionPull, } want1 = []string{ "repository:foo:delete,pull", } scopes2 := []string{ ActionPush, ActionPush, ActionDelete, } want2 = []string{ "repository:foo:delete,push", } ctx = AppendRepositoryScope(ctx, ref1, scopes1...) ctx = AppendRepositoryScope(ctx, ref2, scopes2...) if got := GetScopesForHost(ctx, ref1.Host()); !reflect.DeepEqual(got, want1) { t.Errorf("GetScopesPerRegistry(WithScopeHints()) = %v, want %v", got, want1) } if got := GetScopesForHost(ctx, ref2.Host()); !reflect.DeepEqual(got, want2) { t.Errorf("GetScopesPerRegistry(WithScopeHints()) = %v, want %v", got, want2) } // append empty scopes ctx = AppendRepositoryScope(ctx, ref1) ctx = AppendRepositoryScope(ctx, ref2) if got := GetScopesForHost(ctx, ref1.Host()); !reflect.DeepEqual(got, want1) { t.Errorf("GetScopesPerRegistry(WithScopeHints()) = %v, want %v", got, want1) } if got := GetScopesForHost(ctx, ref2.Host()); !reflect.DeepEqual(got, want2) { t.Errorf("GetScopesPerRegistry(WithScopeHints()) = %v, want %v", got, want2) } } func TestWithScopes(t *testing.T) { ctx := context.Background() // with single scope want := []string{ "repository:foo:pull", } ctx = WithScopes(ctx, want...) if got := GetScopes(ctx); !reflect.DeepEqual(got, want) { t.Errorf("GetScopes(WithScopes()) = %v, want %v", got, want) } // overwrite scopes want = []string{ "repository:bar:push", } ctx = WithScopes(ctx, want...) if got := GetScopes(ctx); !reflect.DeepEqual(got, want) { t.Errorf("GetScopes(WithScopes()) = %v, want %v", got, want) } // overwrite scopes with de-duplication scopes := []string{ "repository:hello-world:push", "repository:alpine:delete", "repository:hello-world:pull", "repository:alpine:delete", } want = []string{ "repository:alpine:delete", "repository:hello-world:pull,push", } ctx = WithScopes(ctx, scopes...) if got := GetScopes(ctx); !reflect.DeepEqual(got, want) { t.Errorf("GetScopes(WithScopes()) = %v, want %v", got, want) } // clean scopes want = nil ctx = WithScopes(ctx, want...) if got := GetScopes(ctx); !reflect.DeepEqual(got, want) { t.Errorf("GetScopes(WithScopes()) = %v, want %v", got, want) } } func TestAppendScopes(t *testing.T) { ctx := context.Background() // append single scope want := []string{ "repository:foo:pull", } ctx = AppendScopes(ctx, want...) if got := GetScopes(ctx); !reflect.DeepEqual(got, want) { t.Errorf("GetScopes(AppendScopes()) = %v, want %v", got, want) } // append scopes with de-duplication scopes := []string{ "repository:hello-world:push", "repository:alpine:delete", "repository:hello-world:pull", "repository:alpine:delete", } want = []string{ "repository:alpine:delete", "repository:foo:pull", "repository:hello-world:pull,push", } ctx = AppendScopes(ctx, scopes...) if got := GetScopes(ctx); !reflect.DeepEqual(got, want) { t.Errorf("GetScopes(AppendScopes()) = %v, want %v", got, want) } // append empty scopes ctx = AppendScopes(ctx) if got := GetScopes(ctx); !reflect.DeepEqual(got, want) { t.Errorf("GetScopes(AppendScopes()) = %v, want %v", got, want) } } func TestWithScopesPerHost(t *testing.T) { ctx := context.Background() reg1 := "registry1.example.com" reg2 := "registry2.example.com" // with single scope want1 := []string{ "repository:foo:pull", } want2 := []string{ "repository:foo:push", } ctx = WithScopesForHost(ctx, reg1, want1...) ctx = WithScopesForHost(ctx, reg2, want2...) if got := GetScopesForHost(ctx, reg1); !reflect.DeepEqual(got, want1) { t.Errorf("GetScopesPerRegistry(WithScopesPerRegistry()) = %v, want %v", got, want1) } if got := GetScopesForHost(ctx, reg2); !reflect.DeepEqual(got, want2) { t.Errorf("GetScopesPerRegistry(WithScopesPerRegistry()) = %v, want %v", got, want2) } // overwrite scopes want1 = []string{ "repository:bar:push", } want2 = []string{ "repository:bar:pull", } ctx = WithScopesForHost(ctx, reg1, want1...) ctx = WithScopesForHost(ctx, reg2, want2...) if got := GetScopesForHost(ctx, reg1); !reflect.DeepEqual(got, want1) { t.Errorf("GetScopesPerRegistry(WithScopesPerRegistry()) = %v, want %v", got, want1) } if got := GetScopesForHost(ctx, reg2); !reflect.DeepEqual(got, want2) { t.Errorf("GetScopesPerRegistry(WithScopesPerRegistry()) = %v, want %v", got, want2) } // overwrite scopes with de-duplication scopes1 := []string{ "repository:hello-world:push", "repository:alpine:delete", "repository:hello-world:pull", "repository:alpine:delete", } want1 = []string{ "repository:alpine:delete", "repository:hello-world:pull,push", } scopes2 := []string{ "repository:goodbye-world:push", "repository:nginx:delete", "repository:goodbye-world:pull", "repository:nginx:delete", } want2 = []string{ "repository:goodbye-world:pull,push", "repository:nginx:delete", } ctx = WithScopesForHost(ctx, reg1, scopes1...) ctx = WithScopesForHost(ctx, reg2, scopes2...) if got := GetScopesForHost(ctx, reg1); !reflect.DeepEqual(got, want1) { t.Errorf("GetScopesPerRegistry(WithScopesPerRegistry()) = %v, want %v", got, want1) } if got := GetScopesForHost(ctx, reg2); !reflect.DeepEqual(got, want2) { t.Errorf("GetScopesPerRegistry(WithScopesPerRegistry()) = %v, want %v", got, want2) } // clean scopes var want []string ctx = WithScopesForHost(ctx, reg1, want...) ctx = WithScopesForHost(ctx, reg2, want...) if got := GetScopesForHost(ctx, reg1); !reflect.DeepEqual(got, want) { t.Errorf("GetScopesPerRegistry(WithScopesPerRegistry()) = %v, want %v", got, want) } if got := GetScopesForHost(ctx, reg2); !reflect.DeepEqual(got, want) { t.Errorf("GetScopesPerRegistry(WithScopesPerRegistry()) = %v, want %v", got, want) } } func TestAppendScopesPerHost(t *testing.T) { ctx := context.Background() reg1 := "registry1.example.com" reg2 := "registry2.example.com" // with single scope want1 := []string{ "repository:foo:pull", } want2 := []string{ "repository:foo:push", } ctx = AppendScopesForHost(ctx, reg1, want1...) ctx = AppendScopesForHost(ctx, reg2, want2...) if got := GetScopesForHost(ctx, reg1); !reflect.DeepEqual(got, want1) { t.Errorf("GetScopesPerRegistry(AppendScopesPerRegistry()) = %v, want %v", got, want1) } if got := GetScopesForHost(ctx, reg2); !reflect.DeepEqual(got, want2) { t.Errorf("GetScopesPerRegistry(AppendScopesPerRegistry()) = %v, want %v", got, want2) } // append scopes with de-duplication scopes1 := []string{ "repository:hello-world:push", "repository:alpine:delete", "repository:hello-world:pull", "repository:alpine:delete", } want1 = []string{ "repository:alpine:delete", "repository:foo:pull", "repository:hello-world:pull,push", } scopes2 := []string{ "repository:goodbye-world:push", "repository:nginx:delete", "repository:goodbye-world:pull", "repository:nginx:delete", } want2 = []string{ "repository:foo:push", "repository:goodbye-world:pull,push", "repository:nginx:delete", } ctx = AppendScopesForHost(ctx, reg1, scopes1...) ctx = AppendScopesForHost(ctx, reg2, scopes2...) if got := GetScopesForHost(ctx, reg1); !reflect.DeepEqual(got, want1) { t.Errorf("GetScopesPerRegistry(AppendScopesPerRegistry()) = %v, want %v", got, want1) } if got := GetScopesForHost(ctx, reg2); !reflect.DeepEqual(got, want2) { t.Errorf("GetScopesPerRegistry(AppendScopesPerRegistry()) = %v, want %v", got, want2) } // append empty scopes ctx = AppendScopesForHost(ctx, reg1) ctx = AppendScopesForHost(ctx, reg2) if got := GetScopesForHost(ctx, reg1); !reflect.DeepEqual(got, want1) { t.Errorf("GetScopesPerRegistry(AppendScopesPerRegistry()) = %v, want %v", got, want1) } if got := GetScopesForHost(ctx, reg2); !reflect.DeepEqual(got, want2) { t.Errorf("GetScopesPerRegistry(AppendScopesPerRegistry()) = %v, want %v", got, want2) } } func TestCleanScopes(t *testing.T) { tests := []struct { name string scopes []string want []string }{ { name: "nil scope", }, { name: "empty scope", scopes: []string{}, }, { name: "single scope", scopes: []string{ "repository:foo:pull", }, want: []string{ "repository:foo:pull", }, }, { name: "single scope with unordered actions", scopes: []string{ "repository:foo:push,pull,delete", }, want: []string{ "repository:foo:delete,pull,push", }, }, { name: "single scope with duplicated actions", scopes: []string{ "repository:foo:push,pull,push,pull,push,push,pull", }, want: []string{ "repository:foo:pull,push", }, }, { name: "single scope with wild cards", scopes: []string{ "repository:foo:pull,*,push", }, want: []string{ "repository:foo:*", }, }, { name: "single scope with no actions", scopes: []string{ "repository:foo:,", }, want: nil, }, { name: "multiple scopes", scopes: []string{ "repository:bar:push", "repository:foo:pull", }, want: []string{ "repository:bar:push", "repository:foo:pull", }, }, { name: "multiple unordered scopes", scopes: []string{ "repository:foo:pull", "repository:bar:push", }, want: []string{ "repository:bar:push", "repository:foo:pull", }, }, { name: "multiple scopes with duplicates", scopes: []string{ "repository:foo:pull", "repository:bar:push", "repository:foo:push", "repository:bar:push,delete,pull", "repository:bar:delete,pull", "repository:foo:pull", "registry:catalog:*", "registry:catalog:pull", }, want: []string{ "registry:catalog:*", "repository:bar:delete,pull,push", "repository:foo:pull,push", }, }, { name: "multiple scopes with no actions", scopes: []string{ "repository:foo:,", "repository:bar:,", }, want: nil, }, { name: "single unknown or invalid scope", scopes: []string{ "unknown", }, want: []string{ "unknown", }, }, { name: "multiple unknown or invalid scopes", scopes: []string{ "repository:foo:pull", "unknown", "invalid:scope", "no:actions:", "repository:foo:push", }, want: []string{ "invalid:scope", "repository:foo:pull,push", "unknown", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := CleanScopes(tt.scopes); !reflect.DeepEqual(got, tt.want) { t.Errorf("CleanScopes() = %v, want %v", got, tt.want) } }) } } func Test_cleanActions(t *testing.T) { tests := []struct { name string actions []string want []string }{ { name: "nil action", }, { name: "empty action", actions: []string{}, }, { name: "single action", actions: []string{ "pull", }, want: []string{ "pull", }, }, { name: "single empty action", actions: []string{ "", }, }, { name: "multiple actions", actions: []string{ "pull", "push", }, want: []string{ "pull", "push", }, }, { name: "multiple actions with empty action", actions: []string{ "pull", "", "push", }, want: []string{ "pull", "push", }, }, { name: "multiple actions with all empty action", actions: []string{ "", "", "", }, want: nil, }, { name: "unordered actions", actions: []string{ "push", "pull", "delete", }, want: []string{ "delete", "pull", "push", }, }, { name: "wildcard", actions: []string{ "*", }, want: []string{ "*", }, }, { name: "wildcard at the begining", actions: []string{ "*", "push", "pull", "delete", }, want: []string{ "*", }, }, { name: "wildcard in the middle", actions: []string{ "push", "pull", "*", "delete", }, want: []string{ "*", }, }, { name: "wildcard at the end", actions: []string{ "push", "pull", "delete", "*", }, want: []string{ "*", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := cleanActions(tt.actions); !reflect.DeepEqual(got, tt.want) { t.Errorf("cleanActions() = %v, want %v", got, tt.want) } }) } } func Test_getAllScopesForHost(t *testing.T) { host := "registry.example.com" tests := []struct { name string scopes []string globalScopes []string want []string }{ { name: "Empty per-host scopes", scopes: []string{}, globalScopes: []string{ "repository:hello-world:push", "repository:alpine:delete", "repository:hello-world:pull", "repository:alpine:delete", }, want: []string{ "repository:alpine:delete", "repository:hello-world:pull,push", }, }, { name: "Empty global scopes", scopes: []string{ "repository:hello-world:push", "repository:alpine:delete", "repository:hello-world:pull", "repository:alpine:delete", }, globalScopes: []string{}, want: []string{ "repository:alpine:delete", "repository:hello-world:pull,push", }, }, { name: "Per-host scopes + global scopes", scopes: []string{ "repository:hello-world:push", "repository:alpine:delete", "repository:hello-world:pull", "repository:alpine:delete", }, globalScopes: []string{ "repository:foo:pull", "repository:hello-world:pull", "repository:alpine:pull", }, want: []string{ "repository:alpine:delete,pull", "repository:foo:pull", "repository:hello-world:pull,push", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := context.Background() ctx = WithScopesForHost(ctx, host, tt.scopes...) ctx = WithScopes(ctx, tt.globalScopes...) if got := GetAllScopesForHost(ctx, host); !reflect.DeepEqual(got, tt.want) { t.Errorf("getAllScopesForHost() = %v, want %v", got, tt.want) } }) } } oras-go-2.5.0/registry/remote/credentials/000077500000000000000000000000001457674530300205435ustar00rootroot00000000000000oras-go-2.5.0/registry/remote/credentials/example_test.go000066400000000000000000000130271457674530300235670ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package credentials_test import ( "context" "fmt" "net/http" "oras.land/oras-go/v2/registry/remote" "oras.land/oras-go/v2/registry/remote/auth" credentials "oras.land/oras-go/v2/registry/remote/credentials" ) func ExampleNewNativeStore() { ns := credentials.NewNativeStore("pass") ctx := context.Background() // save credentials into the store err := ns.Put(ctx, "localhost:5000", auth.Credential{ Username: "username-example", Password: "password-example", }) if err != nil { panic(err) } // get credentials from the store cred, err := ns.Get(ctx, "localhost:5000") if err != nil { panic(err) } fmt.Println(cred) // delete the credentials from the store err = ns.Delete(ctx, "localhost:5000") if err != nil { panic(err) } } func ExampleNewFileStore() { fs, err := credentials.NewFileStore("example/path/config.json") if err != nil { panic(err) } ctx := context.Background() // save credentials into the store err = fs.Put(ctx, "localhost:5000", auth.Credential{ Username: "username-example", Password: "password-example", }) if err != nil { panic(err) } // get credentials from the store cred, err := fs.Get(ctx, "localhost:5000") if err != nil { panic(err) } fmt.Println(cred) // delete the credentials from the store err = fs.Delete(ctx, "localhost:5000") if err != nil { panic(err) } } func ExampleNewStore() { // NewStore returns a Store based on the given configuration file. It will // automatically determine which Store (file store or native store) to use. // If the native store is not available, you can save your credentials in // the configuration file by specifying AllowPlaintextPut: true, but keep // in mind that this is an unsafe workaround. // See the documentation for details. store, err := credentials.NewStore("example/path/config.json", credentials.StoreOptions{ AllowPlaintextPut: true, }) if err != nil { panic(err) } ctx := context.Background() // save credentials into the store err = store.Put(ctx, "localhost:5000", auth.Credential{ Username: "username-example", Password: "password-example", }) if err != nil { panic(err) } // get credentials from the store cred, err := store.Get(ctx, "localhost:5000") if err != nil { panic(err) } fmt.Println(cred) // delete the credentials from the store err = store.Delete(ctx, "localhost:5000") if err != nil { panic(err) } } func ExampleNewStoreFromDocker() { ds, err := credentials.NewStoreFromDocker(credentials.StoreOptions{ AllowPlaintextPut: true, }) if err != nil { panic(err) } ctx := context.Background() // save credentials into the store err = ds.Put(ctx, "localhost:5000", auth.Credential{ Username: "username-example", Password: "password-example", }) if err != nil { panic(err) } // get credentials from the store cred, err := ds.Get(ctx, "localhost:5000") if err != nil { panic(err) } fmt.Println(cred) // delete the credentials from the store err = ds.Delete(ctx, "localhost:5000") if err != nil { panic(err) } } func ExampleNewStoreWithFallbacks_configAsPrimaryStoreDockerAsFallback() { primaryStore, err := credentials.NewStore("example/path/config.json", credentials.StoreOptions{ AllowPlaintextPut: true, }) if err != nil { panic(err) } fallbackStore, err := credentials.NewStoreFromDocker(credentials.StoreOptions{}) sf := credentials.NewStoreWithFallbacks(primaryStore, fallbackStore) ctx := context.Background() // save credentials into the store err = sf.Put(ctx, "localhost:5000", auth.Credential{ Username: "username-example", Password: "password-example", }) if err != nil { panic(err) } // get credentials from the store cred, err := sf.Get(ctx, "localhost:5000") if err != nil { panic(err) } fmt.Println(cred) // delete the credentials from the store err = sf.Delete(ctx, "localhost:5000") if err != nil { panic(err) } } func ExampleLogin() { store, err := credentials.NewStore("example/path/config.json", credentials.StoreOptions{ AllowPlaintextPut: true, }) if err != nil { panic(err) } registry, err := remote.NewRegistry("localhost:5000") if err != nil { panic(err) } cred := auth.Credential{ Username: "username-example", Password: "password-example", } err = credentials.Login(context.Background(), store, registry, cred) if err != nil { panic(err) } fmt.Println("Login succeeded") } func ExampleLogout() { store, err := credentials.NewStore("example/path/config.json", credentials.StoreOptions{}) if err != nil { panic(err) } err = credentials.Logout(context.Background(), store, "localhost:5000") if err != nil { panic(err) } fmt.Println("Logout succeeded") } func ExampleCredential() { store, err := credentials.NewStore("example/path/config.json", credentials.StoreOptions{}) if err != nil { panic(err) } client := auth.DefaultClient client.Credential = credentials.Credential(store) request, err := http.NewRequest(http.MethodGet, "localhost:5000", nil) if err != nil { panic(err) } _, err = client.Do(request) if err != nil { panic(err) } } oras-go-2.5.0/registry/remote/credentials/file_store.go000066400000000000000000000063711457674530300232340ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package credentials import ( "context" "errors" "fmt" "strings" "oras.land/oras-go/v2/registry/remote/auth" "oras.land/oras-go/v2/registry/remote/credentials/internal/config" ) // FileStore implements a credentials store using the docker configuration file // to keep the credentials in plain-text. // // Reference: https://docs.docker.com/engine/reference/commandline/cli/#docker-cli-configuration-file-configjson-properties type FileStore struct { // DisablePut disables putting credentials in plaintext. // If DisablePut is set to true, Put() will return ErrPlaintextPutDisabled. DisablePut bool config *config.Config } var ( // ErrPlaintextPutDisabled is returned by Put() when DisablePut is set // to true. ErrPlaintextPutDisabled = errors.New("putting plaintext credentials is disabled") // ErrBadCredentialFormat is returned by Put() when the credential format // is bad. ErrBadCredentialFormat = errors.New("bad credential format") ) // NewFileStore creates a new file credentials store. // // Reference: https://docs.docker.com/engine/reference/commandline/cli/#docker-cli-configuration-file-configjson-properties func NewFileStore(configPath string) (*FileStore, error) { cfg, err := config.Load(configPath) if err != nil { return nil, err } return newFileStore(cfg), nil } // newFileStore creates a file credentials store based on the given config instance. func newFileStore(cfg *config.Config) *FileStore { return &FileStore{config: cfg} } // Get retrieves credentials from the store for the given server address. func (fs *FileStore) Get(_ context.Context, serverAddress string) (auth.Credential, error) { return fs.config.GetCredential(serverAddress) } // Put saves credentials into the store for the given server address. // Returns ErrPlaintextPutDisabled if fs.DisablePut is set to true. func (fs *FileStore) Put(_ context.Context, serverAddress string, cred auth.Credential) error { if fs.DisablePut { return ErrPlaintextPutDisabled } if err := validateCredentialFormat(cred); err != nil { return err } return fs.config.PutCredential(serverAddress, cred) } // Delete removes credentials from the store for the given server address. func (fs *FileStore) Delete(_ context.Context, serverAddress string) error { return fs.config.DeleteCredential(serverAddress) } // validateCredentialFormat validates the format of cred. func validateCredentialFormat(cred auth.Credential) error { if strings.ContainsRune(cred.Username, ':') { // Username and password will be encoded in the base64(username:password) // format in the file. The decoded result will be wrong if username // contains colon(s). return fmt.Errorf("%w: colons(:) are not allowed in username", ErrBadCredentialFormat) } return nil } oras-go-2.5.0/registry/remote/credentials/file_store_test.go000066400000000000000000000552271457674530300242770ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package credentials import ( "context" "encoding/json" "errors" "os" "path/filepath" "reflect" "testing" "oras.land/oras-go/v2/registry/remote/auth" "oras.land/oras-go/v2/registry/remote/credentials/internal/config/configtest" ) func TestNewFileStore_badPath(t *testing.T) { tempDir := t.TempDir() tests := []struct { name string configPath string wantErr bool }{ { name: "Path is a directory", configPath: tempDir, wantErr: true, }, { name: "Empty file name", configPath: filepath.Join(tempDir, ""), wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := NewFileStore(tt.configPath) if (err != nil) != tt.wantErr { t.Errorf("NewFileStore() error = %v, wantErr %v", err, tt.wantErr) return } }) } } func TestNewFileStore_badFormat(t *testing.T) { tests := []struct { name string configPath string wantErr bool }{ { name: "Bad JSON format", configPath: "testdata/bad_config", wantErr: true, }, { name: "Invalid auths format", configPath: "testdata/invalid_auths_config.json", wantErr: true, }, { name: "No auths field", configPath: "testdata/no_auths_config.json", wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := NewFileStore(tt.configPath) if (err != nil) != tt.wantErr { t.Errorf("NewFileStore() error = %v, wantErr %v", err, tt.wantErr) return } }) } } func TestFileStore_Get_validConfig(t *testing.T) { ctx := context.Background() fs, err := NewFileStore("testdata/valid_auths_config.json") if err != nil { t.Fatal("NewFileStore() error =", err) } tests := []struct { name string serverAddress string want auth.Credential wantErr bool }{ { name: "Username and password", serverAddress: "registry1.example.com", want: auth.Credential{ Username: "username", Password: "password", }, }, { name: "Identity token", serverAddress: "registry2.example.com", want: auth.Credential{ RefreshToken: "identity_token", }, }, { name: "Registry token", serverAddress: "registry3.example.com", want: auth.Credential{ AccessToken: "registry_token", }, }, { name: "Username and password, identity token and registry token", serverAddress: "registry4.example.com", want: auth.Credential{ Username: "username", Password: "password", RefreshToken: "identity_token", AccessToken: "registry_token", }, }, { name: "Empty credential", serverAddress: "registry5.example.com", want: auth.EmptyCredential, }, { name: "Username and password, no auth", serverAddress: "registry6.example.com", want: auth.Credential{ Username: "username", Password: "password", }, }, { name: "Auth overriding Username and password", serverAddress: "registry7.example.com", want: auth.Credential{ Username: "username", Password: "password", }, }, { name: "Not in auths", serverAddress: "foo.example.com", want: auth.EmptyCredential, }, { name: "No record", serverAddress: "registry999.example.com", want: auth.EmptyCredential, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := fs.Get(ctx, tt.serverAddress) if (err != nil) != tt.wantErr { t.Errorf("FileStore.Get() error = %v, wantErr %v", err, tt.wantErr) return } if !reflect.DeepEqual(got, tt.want) { t.Errorf("FileStore.Get() = %v, want %v", got, tt.want) } }) } } func TestFileStore_Get_invalidConfig(t *testing.T) { ctx := context.Background() fs, err := NewFileStore("testdata/invalid_auths_entry_config.json") if err != nil { t.Fatal("NewFileStore() error =", err) } tests := []struct { name string serverAddress string want auth.Credential wantErr bool }{ { name: "Invalid auth encode", serverAddress: "registry1.example.com", want: auth.EmptyCredential, wantErr: true, }, { name: "Invalid auths format", serverAddress: "registry2.example.com", want: auth.EmptyCredential, wantErr: true, }, { name: "Invalid type", serverAddress: "registry3.example.com", want: auth.EmptyCredential, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := fs.Get(ctx, tt.serverAddress) if (err != nil) != tt.wantErr { t.Errorf("FileStore.Get() error = %v, wantErr %v", err, tt.wantErr) return } if !reflect.DeepEqual(got, tt.want) { t.Errorf("FileStore.Get() = %v, want %v", got, tt.want) } }) } } func TestFileStore_Get_emptyConfig(t *testing.T) { ctx := context.Background() fs, err := NewFileStore("testdata/empty_config.json") if err != nil { t.Fatal("NewFileStore() error =", err) } tests := []struct { name string serverAddress string want auth.Credential wantErr error }{ { name: "Not found", serverAddress: "registry.example.com", want: auth.EmptyCredential, wantErr: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := fs.Get(ctx, tt.serverAddress) if !errors.Is(err, tt.wantErr) { t.Errorf("FileStore.Get() error = %v, wantErr %v", err, tt.wantErr) return } if !reflect.DeepEqual(got, tt.want) { t.Errorf("FileStore.Get() = %v, want %v", got, tt.want) } }) } } func TestFileStore_Get_notExistConfig(t *testing.T) { ctx := context.Background() fs, err := NewFileStore("whatever") if err != nil { t.Fatal("NewFileStore() error =", err) } tests := []struct { name string serverAddress string want auth.Credential wantErr error }{ { name: "Not found", serverAddress: "registry.example.com", want: auth.EmptyCredential, wantErr: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := fs.Get(ctx, tt.serverAddress) if !errors.Is(err, tt.wantErr) { t.Errorf("FileStore.Get() error = %v, wantErr %v", err, tt.wantErr) return } if !reflect.DeepEqual(got, tt.want) { t.Errorf("FileStore.Get() = %v, want %v", got, tt.want) } }) } } func TestFileStore_Put_notExistConfig(t *testing.T) { tempDir := t.TempDir() configPath := filepath.Join(tempDir, "config.json") ctx := context.Background() fs, err := NewFileStore(configPath) if err != nil { t.Fatal("NewFileStore() error =", err) } server := "test.example.com" cred := auth.Credential{ Username: "username", Password: "password", RefreshToken: "refresh_token", AccessToken: "access_token", } // test put if err := fs.Put(ctx, server, cred); err != nil { t.Fatalf("FileStore.Put() error = %v", err) } // verify config file configFile, err := os.Open(configPath) if err != nil { t.Fatalf("failed to open config file: %v", err) } defer configFile.Close() var cfg configtest.Config if err := json.NewDecoder(configFile).Decode(&cfg); err != nil { t.Fatalf("failed to decode config file: %v", err) } want := configtest.Config{ AuthConfigs: map[string]configtest.AuthConfig{ server: { Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", IdentityToken: "refresh_token", RegistryToken: "access_token", }, }, } if !reflect.DeepEqual(cfg, want) { t.Errorf("Decoded config = %v, want %v", cfg, want) } // verify get got, err := fs.Get(ctx, server) if err != nil { t.Fatalf("FileStore.Get() error = %v", err) } if want := cred; !reflect.DeepEqual(got, want) { t.Errorf("FileStore.Get() = %v, want %v", got, want) } } func TestFileStore_Put_addNew(t *testing.T) { tempDir := t.TempDir() configPath := filepath.Join(tempDir, "config.json") ctx := context.Background() // prepare test content server1 := "registry1.example.com" cred1 := auth.Credential{ Username: "username", Password: "password", RefreshToken: "refresh_token", AccessToken: "access_token", } cfg := configtest.Config{ AuthConfigs: map[string]configtest.AuthConfig{ server1: { SomeAuthField: "whatever", Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", IdentityToken: cred1.RefreshToken, RegistryToken: cred1.AccessToken, }, }, SomeConfigField: 123, } jsonStr, err := json.Marshal(cfg) if err != nil { t.Fatalf("failed to marshal config: %v", err) } if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { t.Fatalf("failed to write config file: %v", err) } // test put fs, err := NewFileStore(configPath) if err != nil { t.Fatal("NewFileStore() error =", err) } server2 := "registry2.example.com" cred2 := auth.Credential{ Username: "username_2", Password: "password_2", RefreshToken: "refresh_token_2", AccessToken: "access_token_2", } if err := fs.Put(ctx, server2, cred2); err != nil { t.Fatalf("FileStore.Put() error = %v", err) } // verify config file configFile, err := os.Open(configPath) if err != nil { t.Fatalf("failed to open config file: %v", err) } defer configFile.Close() var gotCfg configtest.Config if err := json.NewDecoder(configFile).Decode(&gotCfg); err != nil { t.Fatalf("failed to decode config file: %v", err) } wantCfg := configtest.Config{ AuthConfigs: map[string]configtest.AuthConfig{ server1: { SomeAuthField: "whatever", Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", IdentityToken: cred1.RefreshToken, RegistryToken: cred1.AccessToken, }, server2: { Auth: "dXNlcm5hbWVfMjpwYXNzd29yZF8y", IdentityToken: "refresh_token_2", RegistryToken: "access_token_2", }, }, SomeConfigField: cfg.SomeConfigField, } if !reflect.DeepEqual(gotCfg, wantCfg) { t.Errorf("Decoded config = %v, want %v", gotCfg, wantCfg) } // verify get got, err := fs.Get(ctx, server1) if err != nil { t.Fatalf("FileStore.Get() error = %v", err) } if want := cred1; !reflect.DeepEqual(got, want) { t.Errorf("FileStore.Get(%s) = %v, want %v", server1, got, want) } got, err = fs.Get(ctx, server2) if err != nil { t.Fatalf("FileStore.Get() error = %v", err) } if want := cred2; !reflect.DeepEqual(got, want) { t.Errorf("FileStore.Get(%s) = %v, want %v", server2, got, want) } } func TestFileStore_Put_updateOld(t *testing.T) { tempDir := t.TempDir() configPath := filepath.Join(tempDir, "config.json") ctx := context.Background() // prepare test content server := "registry.example.com" cfg := configtest.Config{ AuthConfigs: map[string]configtest.AuthConfig{ server: { SomeAuthField: "whatever", Username: "foo", Password: "bar", IdentityToken: "refresh_token", }, }, SomeConfigField: 123, } jsonStr, err := json.Marshal(cfg) if err != nil { t.Fatalf("failed to marshal config: %v", err) } if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { t.Fatalf("failed to write config file: %v", err) } // test put fs, err := NewFileStore(configPath) if err != nil { t.Fatal("NewFileStore() error =", err) } cred := auth.Credential{ Username: "username", Password: "password", AccessToken: "access_token", } if err := fs.Put(ctx, server, cred); err != nil { t.Fatalf("FileStore.Put() error = %v", err) } // verify config file configFile, err := os.Open(configPath) if err != nil { t.Fatalf("failed to open config file: %v", err) } defer configFile.Close() var gotCfg configtest.Config if err := json.NewDecoder(configFile).Decode(&gotCfg); err != nil { t.Fatalf("failed to decode config file: %v", err) } wantCfg := configtest.Config{ AuthConfigs: map[string]configtest.AuthConfig{ server: { Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", RegistryToken: "access_token", }, }, SomeConfigField: cfg.SomeConfigField, } if !reflect.DeepEqual(gotCfg, wantCfg) { t.Errorf("Decoded config = %v, want %v", gotCfg, wantCfg) } // verify get got, err := fs.Get(ctx, server) if err != nil { t.Fatalf("FileStore.Get() error = %v", err) } if want := cred; !reflect.DeepEqual(got, want) { t.Errorf("FileStore.Get(%s) = %v, want %v", server, got, want) } } func TestFileStore_Put_disablePut(t *testing.T) { tempDir := t.TempDir() configPath := filepath.Join(tempDir, "config.json") ctx := context.Background() fs, err := NewFileStore(configPath) if err != nil { t.Fatal("NewFileStore() error =", err) } fs.DisablePut = true server := "test.example.com" cred := auth.Credential{ Username: "username", Password: "password", RefreshToken: "refresh_token", AccessToken: "access_token", } err = fs.Put(ctx, server, cred) if wantErr := ErrPlaintextPutDisabled; !errors.Is(err, wantErr) { t.Errorf("FileStore.Put() error = %v, wantErr %v", err, wantErr) } } func TestFileStore_Put_usernameContainsColon(t *testing.T) { tempDir := t.TempDir() configPath := filepath.Join(tempDir, "config.json") ctx := context.Background() fs, err := NewFileStore(configPath) if err != nil { t.Fatal("NewFileStore() error =", err) } serverAddr := "test.example.com" cred := auth.Credential{ Username: "x:y", Password: "z", } if err := fs.Put(ctx, serverAddr, cred); err == nil { t.Fatal("FileStore.Put() error is nil, want", ErrBadCredentialFormat) } } func TestFileStore_Put_passwordContainsColon(t *testing.T) { tempDir := t.TempDir() configPath := filepath.Join(tempDir, "config.json") ctx := context.Background() fs, err := NewFileStore(configPath) if err != nil { t.Fatal("NewFileStore() error =", err) } serverAddr := "test.example.com" cred := auth.Credential{ Username: "y", Password: "y:z", } if err := fs.Put(ctx, serverAddr, cred); err != nil { t.Fatal("FileStore.Put() error =", err) } got, err := fs.Get(ctx, serverAddr) if err != nil { t.Fatal("FileStore.Get() error =", err) } if !reflect.DeepEqual(got, cred) { t.Errorf("FileStore.Get() = %v, want %v", got, cred) } } func TestFileStore_Delete(t *testing.T) { tempDir := t.TempDir() configPath := filepath.Join(tempDir, "config.json") ctx := context.Background() // prepare test content server1 := "registry1.example.com" cred1 := auth.Credential{ Username: "username", Password: "password", RefreshToken: "refresh_token", AccessToken: "access_token", } server2 := "registry2.example.com" cred2 := auth.Credential{ Username: "username_2", Password: "password_2", RefreshToken: "refresh_token_2", AccessToken: "access_token_2", } cfg := configtest.Config{ AuthConfigs: map[string]configtest.AuthConfig{ server1: { Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", IdentityToken: cred1.RefreshToken, RegistryToken: cred1.AccessToken, }, server2: { Auth: "dXNlcm5hbWVfMjpwYXNzd29yZF8y", IdentityToken: "refresh_token_2", RegistryToken: "access_token_2", }, }, SomeConfigField: 123, } jsonStr, err := json.Marshal(cfg) if err != nil { t.Fatalf("failed to marshal config: %v", err) } if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { t.Fatalf("failed to write config file: %v", err) } fs, err := NewFileStore(configPath) if err != nil { t.Fatal("NewFileStore() error =", err) } // test get got, err := fs.Get(ctx, server1) if err != nil { t.Fatalf("FileStore.Get() error = %v", err) } if want := cred1; !reflect.DeepEqual(got, want) { t.Errorf("FileStore.Get(%s) = %v, want %v", server1, got, want) } got, err = fs.Get(ctx, server2) if err != nil { t.Fatalf("FileStore.Get() error = %v", err) } if want := cred2; !reflect.DeepEqual(got, want) { t.Errorf("FileStore.Get(%s) = %v, want %v", server2, got, want) } // test delete if err := fs.Delete(ctx, server1); err != nil { t.Fatalf("FileStore.Delete() error = %v", err) } // verify config file configFile, err := os.Open(configPath) if err != nil { t.Fatalf("failed to open config file: %v", err) } defer configFile.Close() var gotCfg configtest.Config if err := json.NewDecoder(configFile).Decode(&gotCfg); err != nil { t.Fatalf("failed to decode config file: %v", err) } wantCfg := configtest.Config{ AuthConfigs: map[string]configtest.AuthConfig{ server2: cfg.AuthConfigs[server2], }, SomeConfigField: cfg.SomeConfigField, } if !reflect.DeepEqual(gotCfg, wantCfg) { t.Errorf("Decoded config = %v, want %v", gotCfg, wantCfg) } // test get again got, err = fs.Get(ctx, server1) if err != nil { t.Fatalf("FileStore.Get() error = %v", err) } if want := auth.EmptyCredential; !reflect.DeepEqual(got, want) { t.Errorf("FileStore.Get(%s) = %v, want %v", server1, got, want) } got, err = fs.Get(ctx, server2) if err != nil { t.Fatalf("FileStore.Get() error = %v", err) } if want := cred2; !reflect.DeepEqual(got, want) { t.Errorf("FileStore.Get(%s) = %v, want %v", server2, got, want) } } func TestFileStore_Delete_lastConfig(t *testing.T) { tempDir := t.TempDir() configPath := filepath.Join(tempDir, "config.json") ctx := context.Background() // prepare test content server := "registry1.example.com" cred := auth.Credential{ Username: "username", Password: "password", RefreshToken: "refresh_token", AccessToken: "access_token", } cfg := configtest.Config{ AuthConfigs: map[string]configtest.AuthConfig{ server: { Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", IdentityToken: cred.RefreshToken, RegistryToken: cred.AccessToken, }, }, SomeConfigField: 123, } jsonStr, err := json.Marshal(cfg) if err != nil { t.Fatalf("failed to marshal config: %v", err) } if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { t.Fatalf("failed to write config file: %v", err) } fs, err := NewFileStore(configPath) if err != nil { t.Fatal("NewFileStore() error =", err) } // test get got, err := fs.Get(ctx, server) if err != nil { t.Fatalf("FileStore.Get() error = %v", err) } if want := cred; !reflect.DeepEqual(got, want) { t.Errorf("FileStore.Get(%s) = %v, want %v", server, got, want) } // test delete if err := fs.Delete(ctx, server); err != nil { t.Fatalf("FileStore.Delete() error = %v", err) } // verify config file configFile, err := os.Open(configPath) if err != nil { t.Fatalf("failed to open config file: %v", err) } defer configFile.Close() var gotCfg configtest.Config if err := json.NewDecoder(configFile).Decode(&gotCfg); err != nil { t.Fatalf("failed to decode config file: %v", err) } wantCfg := configtest.Config{ AuthConfigs: map[string]configtest.AuthConfig{}, SomeConfigField: cfg.SomeConfigField, } if !reflect.DeepEqual(gotCfg, wantCfg) { t.Errorf("Decoded config = %v, want %v", gotCfg, wantCfg) } // test get again got, err = fs.Get(ctx, server) if err != nil { t.Fatalf("FileStore.Get() error = %v", err) } if want := auth.EmptyCredential; !reflect.DeepEqual(got, want) { t.Errorf("FileStore.Get(%s) = %v, want %v", server, got, want) } } func TestFileStore_Delete_notExistRecord(t *testing.T) { tempDir := t.TempDir() configPath := filepath.Join(tempDir, "config.json") ctx := context.Background() // prepare test content server := "registry1.example.com" cred := auth.Credential{ Username: "username", Password: "password", RefreshToken: "refresh_token", AccessToken: "access_token", } cfg := configtest.Config{ AuthConfigs: map[string]configtest.AuthConfig{ server: { Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", IdentityToken: cred.RefreshToken, RegistryToken: cred.AccessToken, }, }, SomeConfigField: 123, } jsonStr, err := json.Marshal(cfg) if err != nil { t.Fatalf("failed to marshal config: %v", err) } if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { t.Fatalf("failed to write config file: %v", err) } fs, err := NewFileStore(configPath) if err != nil { t.Fatal("NewFileStore() error =", err) } // test get got, err := fs.Get(ctx, server) if err != nil { t.Fatalf("FileStore.Get() error = %v", err) } if want := cred; !reflect.DeepEqual(got, want) { t.Errorf("FileStore.Get(%s) = %v, want %v", server, got, want) } // test delete if err := fs.Delete(ctx, "test.example.com"); err != nil { t.Fatalf("FileStore.Delete() error = %v", err) } // verify config file configFile, err := os.Open(configPath) if err != nil { t.Fatalf("failed to open config file: %v", err) } defer configFile.Close() var gotCfg configtest.Config if err := json.NewDecoder(configFile).Decode(&gotCfg); err != nil { t.Fatalf("failed to decode config file: %v", err) } wantCfg := configtest.Config{ AuthConfigs: map[string]configtest.AuthConfig{ server: cfg.AuthConfigs[server], }, SomeConfigField: cfg.SomeConfigField, } if !reflect.DeepEqual(gotCfg, wantCfg) { t.Errorf("Decoded config = %v, want %v", gotCfg, wantCfg) } // test get again got, err = fs.Get(ctx, server) if err != nil { t.Fatalf("FileStore.Get() error = %v", err) } if want := cred; !reflect.DeepEqual(got, want) { t.Errorf("FileStore.Get(%s) = %v, want %v", server, got, want) } } func TestFileStore_Delete_notExistConfig(t *testing.T) { tempDir := t.TempDir() configPath := filepath.Join(tempDir, "config.json") ctx := context.Background() fs, err := NewFileStore(configPath) if err != nil { t.Fatal("NewFileStore() error =", err) } server := "test.example.com" // test delete if err := fs.Delete(ctx, server); err != nil { t.Fatalf("FileStore.Delete() error = %v", err) } // verify config file is not created _, err = os.Stat(configPath) if wantErr := os.ErrNotExist; !errors.Is(err, wantErr) { t.Errorf("Stat(%s) error = %v, wantErr %v", configPath, err, wantErr) } } func Test_validateCredentialFormat(t *testing.T) { tests := []struct { name string cred auth.Credential wantErr error }{ { name: "Username contains colon", cred: auth.Credential{ Username: "x:y", Password: "z", }, wantErr: ErrBadCredentialFormat, }, { name: "Password contains colon", cred: auth.Credential{ Username: "x", Password: "y:z", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := validateCredentialFormat(tt.cred); !errors.Is(err, tt.wantErr) { t.Errorf("validateCredentialFormat() error = %v, wantErr %v", err, tt.wantErr) } }) } } oras-go-2.5.0/registry/remote/credentials/internal/000077500000000000000000000000001457674530300223575ustar00rootroot00000000000000oras-go-2.5.0/registry/remote/credentials/internal/config/000077500000000000000000000000001457674530300236245ustar00rootroot00000000000000oras-go-2.5.0/registry/remote/credentials/internal/config/config.go000066400000000000000000000260431457674530300254250ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package config import ( "bytes" "encoding/base64" "encoding/json" "errors" "fmt" "os" "path/filepath" "strings" "sync" "oras.land/oras-go/v2/registry/remote/auth" "oras.land/oras-go/v2/registry/remote/credentials/internal/ioutil" ) const ( // configFieldAuths is the "auths" field in the config file. // Reference: https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L19 configFieldAuths = "auths" // configFieldCredentialsStore is the "credsStore" field in the config file. configFieldCredentialsStore = "credsStore" // configFieldCredentialHelpers is the "credHelpers" field in the config file. configFieldCredentialHelpers = "credHelpers" ) // ErrInvalidConfigFormat is returned when the config format is invalid. var ErrInvalidConfigFormat = errors.New("invalid config format") // AuthConfig contains authorization information for connecting to a Registry. // References: // - https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L17-L45 // - https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/types/authconfig.go#L3-L22 type AuthConfig struct { // Auth is a base64-encoded string of "{username}:{password}". Auth string `json:"auth,omitempty"` // IdentityToken is used to authenticate the user and get an access token // for the registry. IdentityToken string `json:"identitytoken,omitempty"` // RegistryToken is a bearer token to be sent to a registry. RegistryToken string `json:"registrytoken,omitempty"` Username string `json:"username,omitempty"` // legacy field for compatibility Password string `json:"password,omitempty"` // legacy field for compatibility } // NewAuthConfig creates an authConfig based on cred. func NewAuthConfig(cred auth.Credential) AuthConfig { return AuthConfig{ Auth: encodeAuth(cred.Username, cred.Password), IdentityToken: cred.RefreshToken, RegistryToken: cred.AccessToken, } } // Credential returns an auth.Credential based on ac. func (ac AuthConfig) Credential() (auth.Credential, error) { cred := auth.Credential{ Username: ac.Username, Password: ac.Password, RefreshToken: ac.IdentityToken, AccessToken: ac.RegistryToken, } if ac.Auth != "" { var err error // override username and password cred.Username, cred.Password, err = decodeAuth(ac.Auth) if err != nil { return auth.EmptyCredential, fmt.Errorf("failed to decode auth field: %w: %v", ErrInvalidConfigFormat, err) } } return cred, nil } // Config represents a docker configuration file. // References: // - https://docs.docker.com/engine/reference/commandline/cli/#docker-cli-configuration-file-configjson-properties // - https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L17-L44 type Config struct { // path is the path to the config file. path string // rwLock is a read-write-lock for the file store. rwLock sync.RWMutex // content is the content of the config file. // Reference: https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L17-L44 content map[string]json.RawMessage // authsCache is a cache of the auths field of the config. // Reference: https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L19 authsCache map[string]json.RawMessage // credentialsStore is the credsStore field of the config. // Reference: https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L28 credentialsStore string // credentialHelpers is the credHelpers field of the config. // Reference: https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L29 credentialHelpers map[string]string } // Load loads Config from the given config path. func Load(configPath string) (*Config, error) { cfg := &Config{path: configPath} configFile, err := os.Open(configPath) if err != nil { if os.IsNotExist(err) { // init content and caches if the content file does not exist cfg.content = make(map[string]json.RawMessage) cfg.authsCache = make(map[string]json.RawMessage) return cfg, nil } return nil, fmt.Errorf("failed to open config file at %s: %w", configPath, err) } defer configFile.Close() // decode config content if the config file exists if err := json.NewDecoder(configFile).Decode(&cfg.content); err != nil { return nil, fmt.Errorf("failed to decode config file at %s: %w: %v", configPath, ErrInvalidConfigFormat, err) } if credsStoreBytes, ok := cfg.content[configFieldCredentialsStore]; ok { if err := json.Unmarshal(credsStoreBytes, &cfg.credentialsStore); err != nil { return nil, fmt.Errorf("failed to unmarshal creds store field: %w: %v", ErrInvalidConfigFormat, err) } } if credHelpersBytes, ok := cfg.content[configFieldCredentialHelpers]; ok { if err := json.Unmarshal(credHelpersBytes, &cfg.credentialHelpers); err != nil { return nil, fmt.Errorf("failed to unmarshal cred helpers field: %w: %v", ErrInvalidConfigFormat, err) } } if authsBytes, ok := cfg.content[configFieldAuths]; ok { if err := json.Unmarshal(authsBytes, &cfg.authsCache); err != nil { return nil, fmt.Errorf("failed to unmarshal auths field: %w: %v", ErrInvalidConfigFormat, err) } } if cfg.authsCache == nil { cfg.authsCache = make(map[string]json.RawMessage) } return cfg, nil } // GetAuthConfig returns an auth.Credential for serverAddress. func (cfg *Config) GetCredential(serverAddress string) (auth.Credential, error) { cfg.rwLock.RLock() defer cfg.rwLock.RUnlock() authCfgBytes, ok := cfg.authsCache[serverAddress] if !ok { // NOTE: the auth key for the server address may have been stored with // a http/https prefix in legacy config files, e.g. "registry.example.com" // can be stored as "https://registry.example.com/". var matched bool for addr, auth := range cfg.authsCache { if toHostname(addr) == serverAddress { matched = true authCfgBytes = auth break } } if !matched { return auth.EmptyCredential, nil } } var authCfg AuthConfig if err := json.Unmarshal(authCfgBytes, &authCfg); err != nil { return auth.EmptyCredential, fmt.Errorf("failed to unmarshal auth field: %w: %v", ErrInvalidConfigFormat, err) } return authCfg.Credential() } // PutAuthConfig puts cred for serverAddress. func (cfg *Config) PutCredential(serverAddress string, cred auth.Credential) error { cfg.rwLock.Lock() defer cfg.rwLock.Unlock() authCfg := NewAuthConfig(cred) authCfgBytes, err := json.Marshal(authCfg) if err != nil { return fmt.Errorf("failed to marshal auth field: %w", err) } cfg.authsCache[serverAddress] = authCfgBytes return cfg.saveFile() } // DeleteAuthConfig deletes the corresponding credential for serverAddress. func (cfg *Config) DeleteCredential(serverAddress string) error { cfg.rwLock.Lock() defer cfg.rwLock.Unlock() if _, ok := cfg.authsCache[serverAddress]; !ok { // no ops return nil } delete(cfg.authsCache, serverAddress) return cfg.saveFile() } // GetCredentialHelper returns the credential helpers for serverAddress. func (cfg *Config) GetCredentialHelper(serverAddress string) string { return cfg.credentialHelpers[serverAddress] } // CredentialsStore returns the configured credentials store. func (cfg *Config) CredentialsStore() string { cfg.rwLock.RLock() defer cfg.rwLock.RUnlock() return cfg.credentialsStore } // Path returns the path to the config file. func (cfg *Config) Path() string { return cfg.path } // SetCredentialsStore puts the configured credentials store. func (cfg *Config) SetCredentialsStore(credsStore string) error { cfg.rwLock.Lock() defer cfg.rwLock.Unlock() cfg.credentialsStore = credsStore return cfg.saveFile() } // IsAuthConfigured returns whether there is authentication configured in this // config file or not. func (cfg *Config) IsAuthConfigured() bool { return cfg.credentialsStore != "" || len(cfg.credentialHelpers) > 0 || len(cfg.authsCache) > 0 } // saveFile saves Config into the file. func (cfg *Config) saveFile() (returnErr error) { // marshal content // credentialHelpers is skipped as it's never set if cfg.credentialsStore != "" { credsStoreBytes, err := json.Marshal(cfg.credentialsStore) if err != nil { return fmt.Errorf("failed to marshal creds store: %w", err) } cfg.content[configFieldCredentialsStore] = credsStoreBytes } else { // omit empty delete(cfg.content, configFieldCredentialsStore) } authsBytes, err := json.Marshal(cfg.authsCache) if err != nil { return fmt.Errorf("failed to marshal credentials: %w", err) } cfg.content[configFieldAuths] = authsBytes jsonBytes, err := json.MarshalIndent(cfg.content, "", "\t") if err != nil { return fmt.Errorf("failed to marshal config: %w", err) } // write the content to a ingest file for atomicity configDir := filepath.Dir(cfg.path) if err := os.MkdirAll(configDir, 0700); err != nil { return fmt.Errorf("failed to make directory %s: %w", configDir, err) } ingest, err := ioutil.Ingest(configDir, bytes.NewReader(jsonBytes)) if err != nil { return fmt.Errorf("failed to save config file: %w", err) } defer func() { if returnErr != nil { // clean up the ingest file in case of error os.Remove(ingest) } }() // overwrite the config file if err := os.Rename(ingest, cfg.path); err != nil { return fmt.Errorf("failed to save config file: %w", err) } return nil } // encodeAuth base64-encodes username and password into base64(username:password). func encodeAuth(username, password string) string { if username == "" && password == "" { return "" } return base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) } // decodeAuth decodes a base64 encoded string and returns username and password. func decodeAuth(authStr string) (username string, password string, err error) { if authStr == "" { return "", "", nil } decoded, err := base64.StdEncoding.DecodeString(authStr) if err != nil { return "", "", err } decodedStr := string(decoded) username, password, ok := strings.Cut(decodedStr, ":") if !ok { return "", "", fmt.Errorf("auth '%s' does not conform the base64(username:password) format", decodedStr) } return username, password, nil } // toHostname normalizes a server address to just its hostname, removing // the scheme and the path parts. // It is used to match keys in the auths map, which may be either stored as // hostname or as hostname including scheme (in legacy docker config files). // Reference: https://github.com/docker/cli/blob/v24.0.6/cli/config/credentials/file_store.go#L71 func toHostname(addr string) string { addr = strings.TrimPrefix(addr, "http://") addr = strings.TrimPrefix(addr, "https://") addr, _, _ = strings.Cut(addr, "/") return addr } oras-go-2.5.0/registry/remote/credentials/internal/config/config_test.go000066400000000000000000001111571457674530300264650ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package config import ( "encoding/json" "errors" "os" "path/filepath" "reflect" "testing" "oras.land/oras-go/v2/registry/remote/auth" "oras.land/oras-go/v2/registry/remote/credentials/internal/config/configtest" ) func TestLoad_badPath(t *testing.T) { tempDir := t.TempDir() tests := []struct { name string configPath string wantErr bool }{ { name: "Path is a directory", configPath: tempDir, wantErr: true, }, { name: "Empty file name", configPath: filepath.Join(tempDir, ""), wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := Load(tt.configPath) if (err != nil) != tt.wantErr { t.Errorf("Load() error = %v, wantErr %v", err, tt.wantErr) return } }) } } func TestLoad_badFormat(t *testing.T) { tests := []struct { name string configPath string wantErr bool }{ { name: "Bad JSON format", configPath: "../../testdata/bad_config", wantErr: true, }, { name: "Invalid auths format", configPath: "../../testdata/invalid_auths_config.json", wantErr: true, }, { name: "No auths field", configPath: "../../testdata/no_auths_config.json", wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := Load(tt.configPath) if (err != nil) != tt.wantErr { t.Errorf("Load() error = %v, wantErr %v", err, tt.wantErr) return } }) } } func TestConfig_GetCredential_validConfig(t *testing.T) { cfg, err := Load("../../testdata/valid_auths_config.json") if err != nil { t.Fatal("Load() error =", err) } tests := []struct { name string serverAddress string want auth.Credential wantErr bool }{ { name: "Username and password", serverAddress: "registry1.example.com", want: auth.Credential{ Username: "username", Password: "password", }, }, { name: "Identity token", serverAddress: "registry2.example.com", want: auth.Credential{ RefreshToken: "identity_token", }, }, { name: "Registry token", serverAddress: "registry3.example.com", want: auth.Credential{ AccessToken: "registry_token", }, }, { name: "Username and password, identity token and registry token", serverAddress: "registry4.example.com", want: auth.Credential{ Username: "username", Password: "password", RefreshToken: "identity_token", AccessToken: "registry_token", }, }, { name: "Empty credential", serverAddress: "registry5.example.com", want: auth.EmptyCredential, }, { name: "Username and password, no auth", serverAddress: "registry6.example.com", want: auth.Credential{ Username: "username", Password: "password", }, }, { name: "Auth overriding Username and password", serverAddress: "registry7.example.com", want: auth.Credential{ Username: "username", Password: "password", }, }, { name: "Not in auths", serverAddress: "foo.example.com", want: auth.EmptyCredential, }, { name: "No record", serverAddress: "registry999.example.com", want: auth.EmptyCredential, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := cfg.GetCredential(tt.serverAddress) if (err != nil) != tt.wantErr { t.Errorf("Config.GetCredential() error = %v, wantErr %v", err, tt.wantErr) return } if !reflect.DeepEqual(got, tt.want) { t.Errorf("Config.GetCredential() = %v, want %v", got, tt.want) } }) } } func TestConfig_GetCredential_legacyConfig(t *testing.T) { cfg, err := Load("../../testdata/legacy_auths_config.json") if err != nil { t.Fatal("Load() error =", err) } tests := []struct { name string serverAddress string want auth.Credential wantErr bool }{ { name: "Regular address matched", serverAddress: "registry1.example.com", want: auth.Credential{ Username: "username1", Password: "password1", }, }, { name: "Another entry for the same address matched", serverAddress: "https://registry1.example.com/", want: auth.Credential{ Username: "foo", Password: "bar", }, }, { name: "Address with different scheme unmached", serverAddress: "http://registry1.example.com/", want: auth.EmptyCredential, }, { name: "Address with http prefix matched", serverAddress: "registry2.example.com", want: auth.Credential{ Username: "username2", Password: "password2", }, }, { name: "Address with https prefix matched", serverAddress: "registry3.example.com", want: auth.Credential{ Username: "username3", Password: "password3", }, }, { name: "Address with http prefix and / suffix matched", serverAddress: "registry4.example.com", want: auth.Credential{ Username: "username4", Password: "password4", }, }, { name: "Address with https prefix and / suffix matched", serverAddress: "registry5.example.com", want: auth.Credential{ Username: "username5", Password: "password5", }, }, { name: "Address with https prefix and path suffix matched", serverAddress: "registry6.example.com", want: auth.Credential{ Username: "username6", Password: "password6", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := cfg.GetCredential(tt.serverAddress) if (err != nil) != tt.wantErr { t.Errorf("Config.GetCredential() error = %v, wantErr %v", err, tt.wantErr) return } if !reflect.DeepEqual(got, tt.want) { t.Errorf("Config.GetCredential() = %v, want %v", got, tt.want) } }) } } func TestConfig_GetCredential_invalidConfig(t *testing.T) { cfg, err := Load("../../testdata/invalid_auths_entry_config.json") if err != nil { t.Fatal("Load() error =", err) } tests := []struct { name string serverAddress string want auth.Credential wantErr bool }{ { name: "Invalid auth encode", serverAddress: "registry1.example.com", want: auth.EmptyCredential, wantErr: true, }, { name: "Invalid auths format", serverAddress: "registry2.example.com", want: auth.EmptyCredential, wantErr: true, }, { name: "Invalid type", serverAddress: "registry3.example.com", want: auth.EmptyCredential, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := cfg.GetCredential(tt.serverAddress) if (err != nil) != tt.wantErr { t.Errorf("Config.GetCredential() error = %v, wantErr %v", err, tt.wantErr) return } if !reflect.DeepEqual(got, tt.want) { t.Errorf("Config.GetCredential() = %v, want %v", got, tt.want) } }) } } func TestConfig_GetCredential_emptyConfig(t *testing.T) { cfg, err := Load("../../testdata/empty_config.json") if err != nil { t.Fatal("Load() error =", err) } tests := []struct { name string serverAddress string want auth.Credential wantErr error }{ { name: "Not found", serverAddress: "registry.example.com", want: auth.EmptyCredential, wantErr: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := cfg.GetCredential(tt.serverAddress) if !errors.Is(err, tt.wantErr) { t.Errorf("Config.GetCredential() error = %v, wantErr %v", err, tt.wantErr) return } if !reflect.DeepEqual(got, tt.want) { t.Errorf("Config.GetCredential() = %v, want %v", got, tt.want) } }) } } func TestConfig_GetCredential_notExistConfig(t *testing.T) { cfg, err := Load("whatever") if err != nil { t.Fatal("Load() error =", err) } tests := []struct { name string serverAddress string want auth.Credential wantErr error }{ { name: "Not found", serverAddress: "registry.example.com", want: auth.EmptyCredential, wantErr: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := cfg.GetCredential(tt.serverAddress) if !errors.Is(err, tt.wantErr) { t.Errorf("Config.GetCredential() error = %v, wantErr %v", err, tt.wantErr) return } if !reflect.DeepEqual(got, tt.want) { t.Errorf("Config.GetCredential() = %v, want %v", got, tt.want) } }) } } func TestConfig_PutCredential_notExistConfig(t *testing.T) { tempDir := t.TempDir() configPath := filepath.Join(tempDir, "config.json") cfg, err := Load(configPath) if err != nil { t.Fatal("Load() error =", err) } server := "test.example.com" cred := auth.Credential{ Username: "username", Password: "password", RefreshToken: "refresh_token", AccessToken: "access_token", } // test put if err := cfg.PutCredential(server, cred); err != nil { t.Fatalf("Config.PutCredential() error = %v", err) } // verify config file configFile, err := os.Open(configPath) if err != nil { t.Fatalf("failed to open config file: %v", err) } defer configFile.Close() var testCfg configtest.Config if err := json.NewDecoder(configFile).Decode(&testCfg); err != nil { t.Fatalf("failed to decode config file: %v", err) } want := configtest.Config{ AuthConfigs: map[string]configtest.AuthConfig{ server: { Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", IdentityToken: "refresh_token", RegistryToken: "access_token", }, }, } if !reflect.DeepEqual(testCfg, want) { t.Errorf("Decoded config = %v, want %v", testCfg, want) } // verify get got, err := cfg.GetCredential(server) if err != nil { t.Fatalf("Config.GetCredential() error = %v", err) } if want := cred; !reflect.DeepEqual(got, want) { t.Errorf("Config.GetCredential() = %v, want %v", got, want) } } func TestConfig_PutCredential_addNew(t *testing.T) { tempDir := t.TempDir() configPath := filepath.Join(tempDir, "config.json") // prepare test content server1 := "registry1.example.com" cred1 := auth.Credential{ Username: "username", Password: "password", RefreshToken: "refresh_token", AccessToken: "access_token", } testCfg := configtest.Config{ AuthConfigs: map[string]configtest.AuthConfig{ server1: { SomeAuthField: "whatever", Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", IdentityToken: cred1.RefreshToken, RegistryToken: cred1.AccessToken, }, }, SomeConfigField: 123, } jsonStr, err := json.Marshal(testCfg) if err != nil { t.Fatalf("failed to marshal config: %v", err) } if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { t.Fatalf("failed to write config file: %v", err) } // test put cfg, err := Load(configPath) if err != nil { t.Fatal("Load() error =", err) } server2 := "registry2.example.com" cred2 := auth.Credential{ Username: "username_2", Password: "password_2", RefreshToken: "refresh_token_2", AccessToken: "access_token_2", } if err := cfg.PutCredential(server2, cred2); err != nil { t.Fatalf("Config.PutCredential() error = %v", err) } // verify config file configFile, err := os.Open(configPath) if err != nil { t.Fatalf("failed to open config file: %v", err) } defer configFile.Close() var gotCfg configtest.Config if err := json.NewDecoder(configFile).Decode(&gotCfg); err != nil { t.Fatalf("failed to decode config file: %v", err) } wantTestCfg := configtest.Config{ AuthConfigs: map[string]configtest.AuthConfig{ server1: { SomeAuthField: "whatever", Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", IdentityToken: cred1.RefreshToken, RegistryToken: cred1.AccessToken, }, server2: { Auth: "dXNlcm5hbWVfMjpwYXNzd29yZF8y", IdentityToken: "refresh_token_2", RegistryToken: "access_token_2", }, }, SomeConfigField: testCfg.SomeConfigField, } if !reflect.DeepEqual(gotCfg, wantTestCfg) { t.Errorf("Decoded config = %v, want %v", gotCfg, wantTestCfg) } // verify get got, err := cfg.GetCredential(server1) if err != nil { t.Fatalf("Config.GetCredential() error = %v", err) } if want := cred1; !reflect.DeepEqual(got, want) { t.Errorf("Config.GetCredential(%s) = %v, want %v", server1, got, want) } got, err = cfg.GetCredential(server2) if err != nil { t.Fatalf("Config.GetCredential() error = %v", err) } if want := cred2; !reflect.DeepEqual(got, want) { t.Errorf("Config.GetCredential(%s) = %v, want %v", server2, got, want) } } func TestConfig_PutCredential_updateOld(t *testing.T) { tempDir := t.TempDir() configPath := filepath.Join(tempDir, "config.json") // prepare test content server := "registry.example.com" testCfg := configtest.Config{ AuthConfigs: map[string]configtest.AuthConfig{ server: { SomeAuthField: "whatever", Username: "foo", Password: "bar", IdentityToken: "refresh_token", }, }, SomeConfigField: 123, } jsonStr, err := json.Marshal(testCfg) if err != nil { t.Fatalf("failed to marshal config: %v", err) } if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { t.Fatalf("failed to write config file: %v", err) } // test put cfg, err := Load(configPath) if err != nil { t.Fatal("Load() error =", err) } cred := auth.Credential{ Username: "username", Password: "password", AccessToken: "access_token", } if err := cfg.PutCredential(server, cred); err != nil { t.Fatalf("Config.PutCredential() error = %v", err) } // verify config file configFile, err := os.Open(configPath) if err != nil { t.Fatalf("failed to open config file: %v", err) } defer configFile.Close() var gotCfg configtest.Config if err := json.NewDecoder(configFile).Decode(&gotCfg); err != nil { t.Fatalf("failed to decode config file: %v", err) } wantCfg := configtest.Config{ AuthConfigs: map[string]configtest.AuthConfig{ server: { Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", RegistryToken: "access_token", }, }, SomeConfigField: testCfg.SomeConfigField, } if !reflect.DeepEqual(gotCfg, wantCfg) { t.Errorf("Decoded config = %v, want %v", gotCfg, wantCfg) } // verify get got, err := cfg.GetCredential(server) if err != nil { t.Fatalf("Config.GetCredential() error = %v", err) } if want := cred; !reflect.DeepEqual(got, want) { t.Errorf("Config.GetCredential(%s) = %v, want %v", server, got, want) } } func TestConfig_DeleteCredential(t *testing.T) { tempDir := t.TempDir() configPath := filepath.Join(tempDir, "config.json") // prepare test content server1 := "registry1.example.com" cred1 := auth.Credential{ Username: "username", Password: "password", RefreshToken: "refresh_token", AccessToken: "access_token", } server2 := "registry2.example.com" cred2 := auth.Credential{ Username: "username_2", Password: "password_2", RefreshToken: "refresh_token_2", AccessToken: "access_token_2", } testCfg := configtest.Config{ AuthConfigs: map[string]configtest.AuthConfig{ server1: { Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", IdentityToken: cred1.RefreshToken, RegistryToken: cred1.AccessToken, }, server2: { Auth: "dXNlcm5hbWVfMjpwYXNzd29yZF8y", IdentityToken: "refresh_token_2", RegistryToken: "access_token_2", }, }, SomeConfigField: 123, } jsonStr, err := json.Marshal(testCfg) if err != nil { t.Fatalf("failed to marshal config: %v", err) } if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { t.Fatalf("failed to write config file: %v", err) } cfg, err := Load(configPath) if err != nil { t.Fatal("Load() error =", err) } // test get got, err := cfg.GetCredential(server1) if err != nil { t.Fatalf("FileStore.GetCredential() error = %v", err) } if want := cred1; !reflect.DeepEqual(got, want) { t.Errorf("FileStore.GetCredential(%s) = %v, want %v", server1, got, want) } got, err = cfg.GetCredential(server2) if err != nil { t.Fatalf("FileStore.GetCredential() error = %v", err) } if want := cred2; !reflect.DeepEqual(got, want) { t.Errorf("FileStore.Get(%s) = %v, want %v", server2, got, want) } // test delete if err := cfg.DeleteCredential(server1); err != nil { t.Fatalf("Config.DeleteCredential() error = %v", err) } // verify config file configFile, err := os.Open(configPath) if err != nil { t.Fatalf("failed to open config file: %v", err) } defer configFile.Close() var gotTestCfg configtest.Config if err := json.NewDecoder(configFile).Decode(&gotTestCfg); err != nil { t.Fatalf("failed to decode config file: %v", err) } wantTestCfg := configtest.Config{ AuthConfigs: map[string]configtest.AuthConfig{ server2: testCfg.AuthConfigs[server2], }, SomeConfigField: testCfg.SomeConfigField, } if !reflect.DeepEqual(gotTestCfg, wantTestCfg) { t.Errorf("Decoded config = %v, want %v", gotTestCfg, wantTestCfg) } // test get again got, err = cfg.GetCredential(server1) if err != nil { t.Fatalf("Config.GetCredential() error = %v", err) } if want := auth.EmptyCredential; !reflect.DeepEqual(got, want) { t.Errorf("Config.GetCredential(%s) = %v, want %v", server1, got, want) } got, err = cfg.GetCredential(server2) if err != nil { t.Fatalf("Config.GetCredential() error = %v", err) } if want := cred2; !reflect.DeepEqual(got, want) { t.Errorf("Config.GetCredential(%s) = %v, want %v", server2, got, want) } } func TestConfig_DeleteCredential_lastConfig(t *testing.T) { tempDir := t.TempDir() configPath := filepath.Join(tempDir, "config.json") // prepare test content server := "registry1.example.com" cred := auth.Credential{ Username: "username", Password: "password", RefreshToken: "refresh_token", AccessToken: "access_token", } testCfg := configtest.Config{ AuthConfigs: map[string]configtest.AuthConfig{ server: { Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", IdentityToken: cred.RefreshToken, RegistryToken: cred.AccessToken, }, }, SomeConfigField: 123, } jsonStr, err := json.Marshal(testCfg) if err != nil { t.Fatalf("failed to marshal config: %v", err) } if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { t.Fatalf("failed to write config file: %v", err) } cfg, err := Load(configPath) if err != nil { t.Fatal("Load() error =", err) } // test get got, err := cfg.GetCredential(server) if err != nil { t.Fatalf("Config.GetCredential() error = %v", err) } if want := cred; !reflect.DeepEqual(got, want) { t.Errorf("Config.GetCredential(%s) = %v, want %v", server, got, want) } // test delete if err := cfg.DeleteCredential(server); err != nil { t.Fatalf("Config.DeleteCredential() error = %v", err) } // verify config file configFile, err := os.Open(configPath) if err != nil { t.Fatalf("failed to open config file: %v", err) } defer configFile.Close() var gotTestCfg configtest.Config if err := json.NewDecoder(configFile).Decode(&gotTestCfg); err != nil { t.Fatalf("failed to decode config file: %v", err) } wantTestCfg := configtest.Config{ AuthConfigs: map[string]configtest.AuthConfig{}, SomeConfigField: testCfg.SomeConfigField, } if !reflect.DeepEqual(gotTestCfg, wantTestCfg) { t.Errorf("Decoded config = %v, want %v", gotTestCfg, wantTestCfg) } // test get again got, err = cfg.GetCredential(server) if err != nil { t.Fatalf("Config.GetCredential() error = %v", err) } if want := auth.EmptyCredential; !reflect.DeepEqual(got, want) { t.Errorf("Config.GetCredential(%s) = %v, want %v", server, got, want) } } func TestConfig_DeleteCredential_notExistRecord(t *testing.T) { tempDir := t.TempDir() configPath := filepath.Join(tempDir, "config.json") // prepare test content server := "registry1.example.com" cred := auth.Credential{ Username: "username", Password: "password", RefreshToken: "refresh_token", AccessToken: "access_token", } testCfg := configtest.Config{ AuthConfigs: map[string]configtest.AuthConfig{ server: { Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", IdentityToken: cred.RefreshToken, RegistryToken: cred.AccessToken, }, }, SomeConfigField: 123, } jsonStr, err := json.Marshal(testCfg) if err != nil { t.Fatalf("failed to marshal config: %v", err) } if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { t.Fatalf("failed to write config file: %v", err) } cfg, err := Load(configPath) if err != nil { t.Fatal("Load() error =", err) } // test get got, err := cfg.GetCredential(server) if err != nil { t.Fatalf("Config.GetCredential() error = %v", err) } if want := cred; !reflect.DeepEqual(got, want) { t.Errorf("Config.GetCredential(%s) = %v, want %v", server, got, want) } // test delete if err := cfg.DeleteCredential("test.example.com"); err != nil { t.Fatalf("Config.DeleteCredential() error = %v", err) } // verify config file configFile, err := os.Open(configPath) if err != nil { t.Fatalf("failed to open config file: %v", err) } defer configFile.Close() var gotTestCfg configtest.Config if err := json.NewDecoder(configFile).Decode(&gotTestCfg); err != nil { t.Fatalf("failed to decode config file: %v", err) } wantTestCfg := configtest.Config{ AuthConfigs: map[string]configtest.AuthConfig{ server: testCfg.AuthConfigs[server], }, SomeConfigField: testCfg.SomeConfigField, } if !reflect.DeepEqual(gotTestCfg, wantTestCfg) { t.Errorf("Decoded config = %v, want %v", gotTestCfg, wantTestCfg) } // test get again got, err = cfg.GetCredential(server) if err != nil { t.Fatalf("Config.GetCredential() error = %v", err) } if want := cred; !reflect.DeepEqual(got, want) { t.Errorf("Config.GetCredential(%s) = %v, want %v", server, got, want) } } func TestConfig_DeleteCredential_notExistConfig(t *testing.T) { tempDir := t.TempDir() configPath := filepath.Join(tempDir, "config.json") cfg, err := Load(configPath) if err != nil { t.Fatal("Load() error =", err) } server := "test.example.com" // test delete if err := cfg.DeleteCredential(server); err != nil { t.Fatalf("Config.DeleteCredential() error = %v", err) } // verify config file is not created _, err = os.Stat(configPath) if wantErr := os.ErrNotExist; !errors.Is(err, wantErr) { t.Errorf("Stat(%s) error = %v, wantErr %v", configPath, err, wantErr) } } func TestConfig_GetCredentialHelper(t *testing.T) { cfg, err := Load("../../testdata/credHelpers_config.json") if err != nil { t.Fatal("Load() error =", err) } tests := []struct { name string serverAddress string want string }{ { name: "Get cred helper: registry_helper1", serverAddress: "registry1.example.com", want: "registry1-helper", }, { name: "Get cred helper: registry_helper2", serverAddress: "registry2.example.com", want: "registry2-helper", }, { name: "Empty cred helper configured", serverAddress: "registry3.example.com", want: "", }, { name: "No cred helper configured", serverAddress: "whatever.example.com", want: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := cfg.GetCredentialHelper(tt.serverAddress); got != tt.want { t.Errorf("Config.GetCredentialHelper() = %v, want %v", got, tt.want) } }) } } func TestConfig_CredentialsStore(t *testing.T) { tests := []struct { name string configPath string want string }{ { name: "creds store configured", configPath: "../../testdata/credsStore_config.json", want: "teststore", }, { name: "No creds store configured", configPath: "../../testdata/credsHelpers_config.json", want: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg, err := Load(tt.configPath) if err != nil { t.Fatal("Load() error =", err) } if got := cfg.CredentialsStore(); got != tt.want { t.Errorf("Config.CredentialsStore() = %v, want %v", got, tt.want) } }) } } func TestConfig_SetCredentialsStore(t *testing.T) { // prepare test content tempDir := t.TempDir() configPath := filepath.Join(tempDir, "config.json") testCfg := configtest.Config{ SomeConfigField: 123, } jsonStr, err := json.Marshal(testCfg) if err != nil { t.Fatalf("failed to marshal config: %v", err) } if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { t.Fatalf("failed to write config file: %v", err) } // test SetCredentialsStore cfg, err := Load(configPath) if err != nil { t.Fatal("Load() error =", err) } credsStore := "testStore" if err := cfg.SetCredentialsStore(credsStore); err != nil { t.Fatal("Config.SetCredentialsStore() error =", err) } // verify if got := cfg.credentialsStore; got != credsStore { t.Errorf("Config.credentialsStore = %v, want %v", got, credsStore) } // verify config file configFile, err := os.Open(configPath) if err != nil { t.Fatalf("failed to open config file: %v", err) } var gotTestCfg1 configtest.Config if err := json.NewDecoder(configFile).Decode(&gotTestCfg1); err != nil { t.Fatalf("failed to decode config file: %v", err) } if err := configFile.Close(); err != nil { t.Fatal("failed to close config file:", err) } wantTestCfg1 := configtest.Config{ AuthConfigs: make(map[string]configtest.AuthConfig), CredentialsStore: credsStore, SomeConfigField: testCfg.SomeConfigField, } if !reflect.DeepEqual(gotTestCfg1, wantTestCfg1) { t.Errorf("Decoded config = %v, want %v", gotTestCfg1, wantTestCfg1) } // test SetCredentialsStore: set as empty if err := cfg.SetCredentialsStore(""); err != nil { t.Fatal("Config.SetCredentialsStore() error =", err) } // verify if got := cfg.credentialsStore; got != "" { t.Errorf("Config.credentialsStore = %v, want empty", got) } // verify config file configFile, err = os.Open(configPath) if err != nil { t.Fatalf("failed to open config file: %v", err) } var gotTestCfg2 configtest.Config if err := json.NewDecoder(configFile).Decode(&gotTestCfg2); err != nil { t.Fatalf("failed to decode config file: %v", err) } if err := configFile.Close(); err != nil { t.Fatal("failed to close config file:", err) } wantTestCfg2 := configtest.Config{ AuthConfigs: make(map[string]configtest.AuthConfig), SomeConfigField: testCfg.SomeConfigField, } if !reflect.DeepEqual(gotTestCfg2, wantTestCfg2) { t.Errorf("Decoded config = %v, want %v", gotTestCfg2, wantTestCfg2) } } func TestConfig_IsAuthConfigured(t *testing.T) { tempDir := t.TempDir() tests := []struct { name string fileName string shouldCreateFile bool cfg configtest.Config want bool }{ { name: "not existing file", fileName: "config.json", shouldCreateFile: false, cfg: configtest.Config{}, want: false, }, { name: "no auth", fileName: "config.json", shouldCreateFile: true, cfg: configtest.Config{ SomeConfigField: 123, }, want: false, }, { name: "empty auths exist", fileName: "empty_auths.json", shouldCreateFile: true, cfg: configtest.Config{ AuthConfigs: map[string]configtest.AuthConfig{}, }, want: false, }, { name: "auths exist, but no credential", fileName: "no_cred_auths.json", shouldCreateFile: true, cfg: configtest.Config{ AuthConfigs: map[string]configtest.AuthConfig{ "test.example.com": {}, }, }, want: true, }, { name: "auths exist", fileName: "auths.json", shouldCreateFile: true, cfg: configtest.Config{ AuthConfigs: map[string]configtest.AuthConfig{ "test.example.com": { Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", }, }, }, want: true, }, { name: "credsStore exists", fileName: "credsStore.json", shouldCreateFile: true, cfg: configtest.Config{ CredentialsStore: "teststore", }, want: true, }, { name: "empty credHelpers exist", fileName: "empty_credsStore.json", shouldCreateFile: true, cfg: configtest.Config{ CredentialHelpers: map[string]string{}, }, want: false, }, { name: "credHelpers exist", fileName: "credsStore.json", shouldCreateFile: true, cfg: configtest.Config{ CredentialHelpers: map[string]string{ "test.example.com": "testhelper", }, }, want: true, }, { name: "all exist", fileName: "credsStore.json", shouldCreateFile: true, cfg: configtest.Config{ SomeConfigField: 123, AuthConfigs: map[string]configtest.AuthConfig{ "test.example.com": {}, }, CredentialsStore: "teststore", CredentialHelpers: map[string]string{ "test.example.com": "testhelper", }, }, want: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // prepare test content configPath := filepath.Join(tempDir, tt.fileName) if tt.shouldCreateFile { jsonStr, err := json.Marshal(tt.cfg) if err != nil { t.Fatalf("failed to marshal config: %v", err) } if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { t.Fatalf("failed to write config file: %v", err) } } cfg, err := Load(configPath) if err != nil { t.Fatal("Load() error =", err) } if got := cfg.IsAuthConfigured(); got != tt.want { t.Errorf("IsAuthConfigured() = %v, want %v", got, tt.want) } }) } } func TestConfig_saveFile(t *testing.T) { tempDir := t.TempDir() tests := []struct { name string fileName string shouldCreateFile bool oldCfg configtest.Config newCfg configtest.Config wantCfg configtest.Config }{ { name: "set credsStore in a non-existing file", fileName: "config.json", oldCfg: configtest.Config{}, newCfg: configtest.Config{ CredentialsStore: "teststore", }, wantCfg: configtest.Config{ AuthConfigs: make(map[string]configtest.AuthConfig), CredentialsStore: "teststore", }, shouldCreateFile: false, }, { name: "set credsStore in empty file", fileName: "empty.json", oldCfg: configtest.Config{}, newCfg: configtest.Config{ CredentialsStore: "teststore", }, wantCfg: configtest.Config{ AuthConfigs: make(map[string]configtest.AuthConfig), CredentialsStore: "teststore", }, shouldCreateFile: true, }, { name: "set credsStore in a no-auth-configured file", fileName: "empty.json", oldCfg: configtest.Config{ SomeConfigField: 123, }, newCfg: configtest.Config{ CredentialsStore: "teststore", }, wantCfg: configtest.Config{ SomeConfigField: 123, AuthConfigs: make(map[string]configtest.AuthConfig), CredentialsStore: "teststore", }, shouldCreateFile: true, }, { name: "Set credsStore and credHelpers in an auth-configured file", fileName: "auth_configured.json", oldCfg: configtest.Config{ SomeConfigField: 123, AuthConfigs: map[string]configtest.AuthConfig{ "registry1.example.com": { SomeAuthField: "something", Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", }, }, CredentialsStore: "oldstore", CredentialHelpers: map[string]string{ "registry2.example.com": "testhelper", }, }, newCfg: configtest.Config{ AuthConfigs: make(map[string]configtest.AuthConfig), SomeConfigField: 123, CredentialsStore: "newstore", CredentialHelpers: map[string]string{ "xxx": "yyy", }, }, wantCfg: configtest.Config{ SomeConfigField: 123, AuthConfigs: map[string]configtest.AuthConfig{ "registry1.example.com": { SomeAuthField: "something", Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", }, }, CredentialsStore: "newstore", CredentialHelpers: map[string]string{ "registry2.example.com": "testhelper", // cred helpers will not be updated }, }, shouldCreateFile: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // prepare test content configPath := filepath.Join(tempDir, tt.fileName) if tt.shouldCreateFile { jsonStr, err := json.Marshal(tt.oldCfg) if err != nil { t.Fatalf("failed to marshal config: %v", err) } if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { t.Fatalf("failed to write config file: %v", err) } } cfg, err := Load(configPath) if err != nil { t.Fatal("Load() error =", err) } cfg.credentialsStore = tt.newCfg.CredentialsStore cfg.credentialHelpers = tt.newCfg.CredentialHelpers if err := cfg.saveFile(); err != nil { t.Fatal("saveFile() error =", err) } // verify config file configFile, err := os.Open(configPath) if err != nil { t.Fatalf("failed to open config file: %v", err) } defer configFile.Close() var gotCfg configtest.Config if err := json.NewDecoder(configFile).Decode(&gotCfg); err != nil { t.Fatalf("failed to decode config file: %v", err) } if !reflect.DeepEqual(gotCfg, tt.wantCfg) { t.Errorf("Decoded config = %v, want %v", gotCfg, tt.wantCfg) } }) } } func Test_encodeAuth(t *testing.T) { tests := []struct { name string username string password string want string }{ { name: "Username and password", username: "username", password: "password", want: "dXNlcm5hbWU6cGFzc3dvcmQ=", }, { name: "Username only", username: "username", password: "", want: "dXNlcm5hbWU6", }, { name: "Password only", username: "", password: "password", want: "OnBhc3N3b3Jk", }, { name: "Empty username and empty password", username: "", password: "", want: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := encodeAuth(tt.username, tt.password); got != tt.want { t.Errorf("encodeAuth() = %v, want %v", got, tt.want) } }) } } func Test_decodeAuth(t *testing.T) { tests := []struct { name string authStr string username string password string wantErr bool }{ { name: "Valid base64", authStr: "dXNlcm5hbWU6cGFzc3dvcmQ=", // username:password username: "username", password: "password", }, { name: "Valid base64, username only", authStr: "dXNlcm5hbWU6", // username: username: "username", }, { name: "Valid base64, password only", authStr: "OnBhc3N3b3Jk", // :password password: "password", }, { name: "Valid base64, bad format", authStr: "d2hhdGV2ZXI=", // whatever username: "", password: "", wantErr: true, }, { name: "Invalid base64", authStr: "whatever", username: "", password: "", wantErr: true, }, { name: "Empty string", authStr: "", username: "", password: "", wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gotUsername, gotPassword, err := decodeAuth(tt.authStr) if (err != nil) != tt.wantErr { t.Errorf("decodeAuth() error = %v, wantErr %v", err, tt.wantErr) return } if gotUsername != tt.username { t.Errorf("decodeAuth() got = %v, want %v", gotUsername, tt.username) } if gotPassword != tt.password { t.Errorf("decodeAuth() got1 = %v, want %v", gotPassword, tt.password) } }) } } func Test_toHostname(t *testing.T) { tests := []struct { name string addr string want string }{ { addr: "http://test.example.com", want: "test.example.com", }, { addr: "http://test.example.com/", want: "test.example.com", }, { addr: "http://test.example.com/foo/bar", want: "test.example.com", }, { addr: "https://test.example.com", want: "test.example.com", }, { addr: "https://test.example.com/", want: "test.example.com", }, { addr: "http://test.example.com/foo/bar", want: "test.example.com", }, { addr: "test.example.com", want: "test.example.com", }, { addr: "test.example.com/foo/bar/", want: "test.example.com", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := toHostname(tt.addr); got != tt.want { t.Errorf("toHostname() = %v, want %v", got, tt.want) } }) } } func TestConfig_Path(t *testing.T) { mockedPath := "/path/to/config.json" config := Config{ path: mockedPath, } if got := config.Path(); got != mockedPath { t.Errorf("Config.Path() = %v, want %v", got, mockedPath) } } oras-go-2.5.0/registry/remote/credentials/internal/config/configtest/000077500000000000000000000000001457674530300257715ustar00rootroot00000000000000oras-go-2.5.0/registry/remote/credentials/internal/config/configtest/config.go000066400000000000000000000030031457674530300275610ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package configtest // Config represents the structure of a config file for testing purpose. type Config struct { AuthConfigs map[string]AuthConfig `json:"auths"` CredentialsStore string `json:"credsStore,omitempty"` CredentialHelpers map[string]string `json:"credHelpers,omitempty"` SomeConfigField int `json:"some_config_field"` } // AuthConfig represents the structure of the "auths" field of a config file // for testing purpose. type AuthConfig struct { SomeAuthField string `json:"some_auth_field,omitempty"` Username string `json:"username,omitempty"` Password string `json:"password,omitempty"` Auth string `json:"auth,omitempty"` // IdentityToken is used to authenticate the user and get // an access token for the registry. IdentityToken string `json:"identitytoken,omitempty"` // RegistryToken is a bearer token to be sent to a registry RegistryToken string `json:"registrytoken,omitempty"` } oras-go-2.5.0/registry/remote/credentials/internal/executer/000077500000000000000000000000001457674530300242035ustar00rootroot00000000000000oras-go-2.5.0/registry/remote/credentials/internal/executer/executer.go000066400000000000000000000045541457674530300263660ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // Package executer is an abstraction for the docker credential helper protocol // binaries. It is used by nativeStore to interact with installed binaries. package executer import ( "bytes" "context" "errors" "io" "os" "os/exec" "oras.land/oras-go/v2/registry/remote/credentials/trace" ) // dockerDesktopHelperName is the name of the docker credentials helper // execuatable. const dockerDesktopHelperName = "docker-credential-desktop.exe" // Executer is an interface that simulates an executable binary. type Executer interface { Execute(ctx context.Context, input io.Reader, action string) ([]byte, error) } // executable implements the Executer interface. type executable struct { name string } // New returns a new Executer instance. func New(name string) Executer { return &executable{ name: name, } } // Execute operates on an executable binary and supports context. func (c *executable) Execute(ctx context.Context, input io.Reader, action string) ([]byte, error) { cmd := exec.CommandContext(ctx, c.name, action) cmd.Stdin = input cmd.Stderr = os.Stderr trace := trace.ContextExecutableTrace(ctx) if trace != nil && trace.ExecuteStart != nil { trace.ExecuteStart(c.name, action) } output, err := cmd.Output() if trace != nil && trace.ExecuteDone != nil { trace.ExecuteDone(c.name, action, err) } if err != nil { switch execErr := err.(type) { case *exec.ExitError: if errMessage := string(bytes.TrimSpace(output)); errMessage != "" { return nil, errors.New(errMessage) } case *exec.Error: // check if the error is caused by Docker Desktop not running if execErr.Err == exec.ErrNotFound && c.name == dockerDesktopHelperName { return nil, errors.New("credentials store is configured to `desktop.exe` but Docker Desktop seems not running") } } return nil, err } return output, nil } oras-go-2.5.0/registry/remote/credentials/internal/ioutil/000077500000000000000000000000001457674530300236645ustar00rootroot00000000000000oras-go-2.5.0/registry/remote/credentials/internal/ioutil/ioutil.go000066400000000000000000000026521457674530300255250ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package ioutil import ( "fmt" "io" "os" ) // Ingest writes content into a temporary ingest file with the file name format // "oras_credstore_temp_{randomString}". func Ingest(dir string, content io.Reader) (path string, ingestErr error) { tempFile, err := os.CreateTemp(dir, "oras_credstore_temp_*") if err != nil { return "", fmt.Errorf("failed to create ingest file: %w", err) } path = tempFile.Name() defer func() { if err := tempFile.Close(); err != nil && ingestErr == nil { ingestErr = fmt.Errorf("failed to close ingest file: %w", err) } // remove the temp file in case of error. if ingestErr != nil { os.Remove(path) } }() if err := tempFile.Chmod(0600); err != nil { return "", fmt.Errorf("failed to ensure permission: %w", err) } if _, err := io.Copy(tempFile, content); err != nil { return "", fmt.Errorf("failed to ingest: %w", err) } return } oras-go-2.5.0/registry/remote/credentials/memory_store.go000066400000000000000000000031351457674530300236200ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package credentials import ( "context" "sync" "oras.land/oras-go/v2/registry/remote/auth" ) // memoryStore is a store that keeps credentials in memory. type memoryStore struct { store sync.Map } // NewMemoryStore creates a new in-memory credentials store. func NewMemoryStore() Store { return &memoryStore{} } // Get retrieves credentials from the store for the given server address. func (ms *memoryStore) Get(_ context.Context, serverAddress string) (auth.Credential, error) { cred, found := ms.store.Load(serverAddress) if !found { return auth.EmptyCredential, nil } return cred.(auth.Credential), nil } // Put saves credentials into the store for the given server address. func (ms *memoryStore) Put(_ context.Context, serverAddress string, cred auth.Credential) error { ms.store.Store(serverAddress, cred) return nil } // Delete removes credentials from the store for the given server address. func (ms *memoryStore) Delete(_ context.Context, serverAddress string) error { ms.store.Delete(serverAddress) return nil } oras-go-2.5.0/registry/remote/credentials/memory_store_test.go000066400000000000000000000130331457674530300246550ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package credentials import ( "context" "reflect" "testing" "oras.land/oras-go/v2/registry/remote/auth" ) func TestMemoryStore_Get_notExistRecord(t *testing.T) { ctx := context.Background() ms := NewMemoryStore() serverAddress := "registry.example.com" got, err := ms.Get(ctx, serverAddress) if err != nil { t.Errorf("MemoryStore.Get() error = %v", err) return } if !reflect.DeepEqual(got, auth.EmptyCredential) { t.Errorf("MemoryStore.Get() = %v, want %v", got, auth.EmptyCredential) } } func TestMemoryStore_Get_validRecord(t *testing.T) { ctx := context.Background() ms := NewMemoryStore().(*memoryStore) serverAddress := "registry.example.com" want := auth.Credential{ Username: "username", Password: "password", RefreshToken: "identity_token", AccessToken: "registry_token", } ms.store.Store(serverAddress, want) got, err := ms.Get(ctx, serverAddress) if err != nil { t.Errorf("MemoryStore.Get() error = %v", err) return } if !reflect.DeepEqual(got, want) { t.Errorf("MemoryStore.Get() = %v, want %v", got, want) } } func TestMemoryStore_Put_addNew(t *testing.T) { ctx := context.Background() ms := NewMemoryStore() // Test Put server1 := "registry.example.com" cred1 := auth.Credential{ Username: "username", Password: "password", RefreshToken: "identity_token", AccessToken: "registry_token", } if err := ms.Put(ctx, server1, cred1); err != nil { t.Errorf("MemoryStore.Put() error = %v", err) return } server2 := "registry2.example.com" cred2 := auth.Credential{ Username: "username2", Password: "password2", RefreshToken: "identity_token2", AccessToken: "registry_token2", } if err := ms.Put(ctx, server2, cred2); err != nil { t.Errorf("MemoryStore.Put() error = %v", err) return } // Verify Content got1, err := ms.Get(ctx, server1) if err != nil { t.Errorf("MemoryStore.Get() error = %v", err) return } if !reflect.DeepEqual(got1, cred1) { t.Errorf("MemoryStore.Get() = %v, want %v", got1, cred1) return } got2, err := ms.Get(ctx, server2) if err != nil { t.Errorf("MemoryStore.Get() error = %v", err) return } if !reflect.DeepEqual(got2, cred2) { t.Errorf("MemoryStore.Get() = %v, want %v", got2, cred2) return } } func TestMemoryStore_Put_update(t *testing.T) { ctx := context.Background() ms := NewMemoryStore() // Test Put serverAddress := "registry.example.com" cred1 := auth.Credential{ Username: "username", Password: "password", RefreshToken: "identity_token", AccessToken: "registry_token", } if err := ms.Put(ctx, serverAddress, cred1); err != nil { t.Errorf("MemoryStore.Put() error = %v", err) return } cred2 := auth.Credential{ Username: "username2", Password: "password2", RefreshToken: "identity_token2", AccessToken: "registry_token2", } if err := ms.Put(ctx, serverAddress, cred2); err != nil { t.Errorf("MemoryStore.Put() error = %v", err) return } got, err := ms.Get(ctx, serverAddress) if err != nil { t.Errorf("MemoryStore.Get() error = %v", err) return } if !reflect.DeepEqual(got, cred2) { t.Errorf("MemoryStore.Get() = %v, want %v", got, cred2) return } } func TestMemoryStore_Delete_existRecord(t *testing.T) { ctx := context.Background() ms := NewMemoryStore() // Test Put serverAddress := "registry.example.com" cred := auth.Credential{ Username: "username", Password: "password", RefreshToken: "identity_token", AccessToken: "registry_token", } if err := ms.Put(ctx, serverAddress, cred); err != nil { t.Errorf("MemoryStore.Put() error = %v", err) return } // Test Get got, err := ms.Get(ctx, serverAddress) if err != nil { t.Errorf("MemoryStore.Get() error = %v", err) return } if !reflect.DeepEqual(got, cred) { t.Errorf("MemoryStore.Get(%s) = %v, want %v", serverAddress, got, cred) return } // Test Delete if err := ms.Delete(ctx, serverAddress); err != nil { t.Errorf("MemoryStore.Delete() error = %v", err) return } // Test Get again got, err = ms.Get(ctx, serverAddress) if err != nil { t.Errorf("MemoryStore.Get() error = %v", err) return } if !reflect.DeepEqual(got, auth.EmptyCredential) { t.Errorf("MemoryStore.Get() = %v, want %v", got, auth.EmptyCredential) return } } func TestMemoryStore_Delete_notExistRecord(t *testing.T) { ctx := context.Background() ms := NewMemoryStore() // Test Put serverAddress := "registry.example.com" cred := auth.Credential{ Username: "username", Password: "password", RefreshToken: "identity_token", AccessToken: "registry_token", } if err := ms.Put(ctx, serverAddress, cred); err != nil { t.Errorf("MemoryStore.Put() error = %v", err) return } // Test Delete if err := ms.Delete(ctx, serverAddress); err != nil { t.Errorf("MemoryStore.Delete() error = %v", err) return } // Test Delete again // Expect no error if target record does not exist if err := ms.Delete(ctx, serverAddress); err != nil { t.Errorf("MemoryStore.Delete() error = %v", err) return } } oras-go-2.5.0/registry/remote/credentials/native_store.go000066400000000000000000000105361457674530300236010ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package credentials import ( "bytes" "context" "encoding/json" "os/exec" "strings" "oras.land/oras-go/v2/registry/remote/auth" "oras.land/oras-go/v2/registry/remote/credentials/internal/executer" ) const ( remoteCredentialsPrefix = "docker-credential-" emptyUsername = "" errCredentialsNotFoundMessage = "credentials not found in native keychain" ) // dockerCredentials mimics how docker credential helper binaries store // credential information. // Reference: // - https://docs.docker.com/engine/reference/commandline/login/#credential-helper-protocol type dockerCredentials struct { ServerURL string `json:"ServerURL"` Username string `json:"Username"` Secret string `json:"Secret"` } // nativeStore implements a credentials store using native keychain to keep // credentials secure. type nativeStore struct { exec executer.Executer } // NewNativeStore creates a new native store that uses a remote helper program to // manage credentials. // // The argument of NewNativeStore can be the native keychains // ("wincred" for Windows, "pass" for linux and "osxkeychain" for macOS), // or any program that follows the docker-credentials-helper protocol. // // Reference: // - https://docs.docker.com/engine/reference/commandline/login#credentials-store func NewNativeStore(helperSuffix string) Store { return &nativeStore{ exec: executer.New(remoteCredentialsPrefix + helperSuffix), } } // NewDefaultNativeStore returns a native store based on the platform-default // docker credentials helper and a bool indicating if the native store is // available. // - Windows: "wincred" // - Linux: "pass" or "secretservice" // - macOS: "osxkeychain" // // Reference: // - https://docs.docker.com/engine/reference/commandline/login/#credentials-store func NewDefaultNativeStore() (Store, bool) { if helper := getDefaultHelperSuffix(); helper != "" { return NewNativeStore(helper), true } return nil, false } // Get retrieves credentials from the store for the given server. func (ns *nativeStore) Get(ctx context.Context, serverAddress string) (auth.Credential, error) { var cred auth.Credential out, err := ns.exec.Execute(ctx, strings.NewReader(serverAddress), "get") if err != nil { if err.Error() == errCredentialsNotFoundMessage { // do not return an error if the credentials are not in the keychain. return auth.EmptyCredential, nil } return auth.EmptyCredential, err } var dockerCred dockerCredentials if err := json.Unmarshal(out, &dockerCred); err != nil { return auth.EmptyCredential, err } // bearer auth is used if the username is "" if dockerCred.Username == emptyUsername { cred.RefreshToken = dockerCred.Secret } else { cred.Username = dockerCred.Username cred.Password = dockerCred.Secret } return cred, nil } // Put saves credentials into the store. func (ns *nativeStore) Put(ctx context.Context, serverAddress string, cred auth.Credential) error { dockerCred := &dockerCredentials{ ServerURL: serverAddress, Username: cred.Username, Secret: cred.Password, } if cred.RefreshToken != "" { dockerCred.Username = emptyUsername dockerCred.Secret = cred.RefreshToken } credJSON, err := json.Marshal(dockerCred) if err != nil { return err } _, err = ns.exec.Execute(ctx, bytes.NewReader(credJSON), "store") return err } // Delete removes credentials from the store for the given server. func (ns *nativeStore) Delete(ctx context.Context, serverAddress string) error { _, err := ns.exec.Execute(ctx, strings.NewReader(serverAddress), "erase") return err } // getDefaultHelperSuffix returns the default credential helper suffix. func getDefaultHelperSuffix() string { platformDefault := getPlatformDefaultHelperSuffix() if _, err := exec.LookPath(remoteCredentialsPrefix + platformDefault); err == nil { return platformDefault } return "" } oras-go-2.5.0/registry/remote/credentials/native_store_darwin.go000066400000000000000000000014761457674530300251500ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package credentials // getPlatformDefaultHelperSuffix returns the platform default credential // helper suffix. // Reference: https://docs.docker.com/engine/reference/commandline/login/#default-behavior func getPlatformDefaultHelperSuffix() string { return "osxkeychain" } oras-go-2.5.0/registry/remote/credentials/native_store_generic.go000066400000000000000000000015351457674530300252740ustar00rootroot00000000000000//go:build !windows && !darwin && !linux /* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package credentials // getPlatformDefaultHelperSuffix returns the platform default credential // helper suffix. // Reference: https://docs.docker.com/engine/reference/commandline/login/#default-behavior func getPlatformDefaultHelperSuffix() string { return "" } oras-go-2.5.0/registry/remote/credentials/native_store_linux.go000066400000000000000000000016301457674530300250130ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package credentials import "os/exec" // getPlatformDefaultHelperSuffix returns the platform default credential // helper suffix. // Reference: https://docs.docker.com/engine/reference/commandline/login/#default-behavior func getPlatformDefaultHelperSuffix() string { if _, err := exec.LookPath("pass"); err == nil { return "pass" } return "secretservice" } oras-go-2.5.0/registry/remote/credentials/native_store_test.go000066400000000000000000000301611457674530300246340ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package credentials import ( "bytes" "context" "encoding/json" "fmt" "io" "strings" "testing" "oras.land/oras-go/v2/registry/remote/auth" "oras.land/oras-go/v2/registry/remote/credentials/trace" ) const ( basicAuthHost = "localhost:2333" bearerAuthHost = "localhost:666" exeErrorHost = "localhost:500/exeError" jsonErrorHost = "localhost:500/jsonError" noCredentialsHost = "localhost:404" traceHost = "localhost:808" testUsername = "test_username" testPassword = "test_password" testRefreshToken = "test_token" ) var ( errCommandExited = fmt.Errorf("exited with error") errExecute = fmt.Errorf("Execute failed") errCredentialsNotFound = fmt.Errorf(errCredentialsNotFoundMessage) ) // testExecuter implements the Executer interface for testing purpose. // It simulates interactions between the docker client and a remote // credentials helper. type testExecuter struct{} // Execute mocks the behavior of a credential helper binary. It returns responses // and errors based on the input. func (e *testExecuter) Execute(ctx context.Context, input io.Reader, action string) ([]byte, error) { in, err := io.ReadAll(input) if err != nil { return nil, err } inS := string(in) switch action { case "get": switch inS { case basicAuthHost: return []byte(`{"Username": "test_username", "Secret": "test_password"}`), nil case bearerAuthHost: return []byte(`{"Username": "", "Secret": "test_token"}`), nil case exeErrorHost: return []byte("Execute failed"), errExecute case jsonErrorHost: return []byte("json.Unmarshal failed"), nil case noCredentialsHost: return []byte("credentials not found"), errCredentialsNotFound case traceHost: traceHook := trace.ContextExecutableTrace(ctx) if traceHook != nil { if traceHook.ExecuteStart != nil { traceHook.ExecuteStart("testExecuter", "get") } if traceHook.ExecuteDone != nil { traceHook.ExecuteDone("testExecuter", "get", nil) } } return []byte(`{"Username": "test_username", "Secret": "test_password"}`), nil default: return []byte("program failed"), errCommandExited } case "store": var c dockerCredentials err := json.NewDecoder(strings.NewReader(inS)).Decode(&c) if err != nil { return []byte("program failed"), errCommandExited } switch c.ServerURL { case basicAuthHost, bearerAuthHost, exeErrorHost: return nil, nil case traceHost: traceHook := trace.ContextExecutableTrace(ctx) if traceHook != nil { if traceHook.ExecuteStart != nil { traceHook.ExecuteStart("testExecuter", "store") } if traceHook.ExecuteDone != nil { traceHook.ExecuteDone("testExecuter", "store", nil) } } return nil, nil default: return []byte("program failed"), errCommandExited } case "erase": switch inS { case basicAuthHost, bearerAuthHost: return nil, nil case traceHost: traceHook := trace.ContextExecutableTrace(ctx) if traceHook != nil { if traceHook.ExecuteStart != nil { traceHook.ExecuteStart("testExecuter", "erase") } if traceHook.ExecuteDone != nil { traceHook.ExecuteDone("testExecuter", "erase", nil) } } return nil, nil default: return []byte("program failed"), errCommandExited } } return []byte(fmt.Sprintf("unknown argument %q with %q", action, inS)), errCommandExited } func TestNativeStore_interface(t *testing.T) { var ns interface{} = &nativeStore{} if _, ok := ns.(Store); !ok { t.Error("&NativeStore{} does not conform Store") } } func TestNativeStore_basicAuth(t *testing.T) { ns := &nativeStore{ &testExecuter{}, } // Put err := ns.Put(context.Background(), basicAuthHost, auth.Credential{Username: testUsername, Password: testPassword}) if err != nil { t.Fatalf("basic auth test ns.Put fails: %v", err) } // Get cred, err := ns.Get(context.Background(), basicAuthHost) if err != nil { t.Fatalf("basic auth test ns.Get fails: %v", err) } if cred.Username != testUsername { t.Fatal("incorrect username") } if cred.Password != testPassword { t.Fatal("incorrect password") } // Delete err = ns.Delete(context.Background(), basicAuthHost) if err != nil { t.Fatalf("basic auth test ns.Delete fails: %v", err) } } func TestNativeStore_refreshToken(t *testing.T) { ns := &nativeStore{ &testExecuter{}, } // Put err := ns.Put(context.Background(), bearerAuthHost, auth.Credential{RefreshToken: testRefreshToken}) if err != nil { t.Fatalf("refresh token test ns.Put fails: %v", err) } // Get cred, err := ns.Get(context.Background(), bearerAuthHost) if err != nil { t.Fatalf("refresh token test ns.Get fails: %v", err) } if cred.Username != "" { t.Fatalf("expect username to be empty, got %s", cred.Username) } if cred.RefreshToken != testRefreshToken { t.Fatal("incorrect refresh token") } // Delete err = ns.Delete(context.Background(), basicAuthHost) if err != nil { t.Fatalf("refresh token test ns.Delete fails: %v", err) } } func TestNativeStore_errorHandling(t *testing.T) { ns := &nativeStore{ &testExecuter{}, } // Get Error: Execute error _, err := ns.Get(context.Background(), exeErrorHost) if err != errExecute { t.Fatalf("got error: %v, should get exeErr", err) } // Get Error: json.Unmarshal _, err = ns.Get(context.Background(), jsonErrorHost) if err == nil { t.Fatalf("should get error from json.Unmarshal") } // Get: Should not return error when credentials are not found _, err = ns.Get(context.Background(), noCredentialsHost) if err != nil { t.Fatalf("should not get error when no credentials are found") } } func TestNewDefaultNativeStore(t *testing.T) { defaultHelper := getDefaultHelperSuffix() wantOK := (defaultHelper != "") if _, ok := NewDefaultNativeStore(); ok != wantOK { t.Errorf("NewDefaultNativeStore() = %v, want %v", ok, wantOK) } } func TestNativeStore_trace(t *testing.T) { ns := &nativeStore{ &testExecuter{}, } // create trace hooks that write to buffer buffer := bytes.Buffer{} traceHook := &trace.ExecutableTrace{ ExecuteStart: func(executableName string, action string) { buffer.WriteString(fmt.Sprintf("test trace, start the execution of executable %s with action %s ", executableName, action)) }, ExecuteDone: func(executableName string, action string, err error) { buffer.WriteString(fmt.Sprintf("test trace, completed the execution of executable %s with action %s and got err %v", executableName, action, err)) }, } ctx := trace.WithExecutableTrace(context.Background(), traceHook) // Test ns.Put trace err := ns.Put(ctx, traceHost, auth.Credential{Username: testUsername, Password: testPassword}) if err != nil { t.Fatalf("trace test ns.Put fails: %v", err) } bufferContent := buffer.String() if bufferContent != "test trace, start the execution of executable testExecuter with action store test trace, completed the execution of executable testExecuter with action store and got err " { t.Fatalf("incorrect buffer content: %s", bufferContent) } buffer.Reset() // Test ns.Get trace _, err = ns.Get(ctx, traceHost) if err != nil { t.Fatalf("trace test ns.Get fails: %v", err) } bufferContent = buffer.String() if bufferContent != "test trace, start the execution of executable testExecuter with action get test trace, completed the execution of executable testExecuter with action get and got err " { t.Fatalf("incorrect buffer content: %s", bufferContent) } buffer.Reset() // Test ns.Delete trace err = ns.Delete(ctx, traceHost) if err != nil { t.Fatalf("trace test ns.Delete fails: %v", err) } bufferContent = buffer.String() if bufferContent != "test trace, start the execution of executable testExecuter with action erase test trace, completed the execution of executable testExecuter with action erase and got err " { t.Fatalf("incorrect buffer content: %s", bufferContent) } } // This test ensures that a nil trace will not cause an error. func TestNativeStore_noTrace(t *testing.T) { ns := &nativeStore{ &testExecuter{}, } // Put err := ns.Put(context.Background(), traceHost, auth.Credential{Username: testUsername, Password: testPassword}) if err != nil { t.Fatalf("basic auth test ns.Put fails: %v", err) } // Get cred, err := ns.Get(context.Background(), traceHost) if err != nil { t.Fatalf("basic auth test ns.Get fails: %v", err) } if cred.Username != testUsername { t.Fatal("incorrect username") } if cred.Password != testPassword { t.Fatal("incorrect password") } // Delete err = ns.Delete(context.Background(), traceHost) if err != nil { t.Fatalf("basic auth test ns.Delete fails: %v", err) } } // This test ensures that an empty trace will not cause an error. func TestNativeStore_emptyTrace(t *testing.T) { ns := &nativeStore{ &testExecuter{}, } traceHook := &trace.ExecutableTrace{} ctx := trace.WithExecutableTrace(context.Background(), traceHook) // Put err := ns.Put(ctx, traceHost, auth.Credential{Username: testUsername, Password: testPassword}) if err != nil { t.Fatalf("basic auth test ns.Put fails: %v", err) } // Get cred, err := ns.Get(ctx, traceHost) if err != nil { t.Fatalf("basic auth test ns.Get fails: %v", err) } if cred.Username != testUsername { t.Fatal("incorrect username") } if cred.Password != testPassword { t.Fatal("incorrect password") } // Delete err = ns.Delete(ctx, traceHost) if err != nil { t.Fatalf("basic auth test ns.Delete fails: %v", err) } } func TestNativeStore_multipleTrace(t *testing.T) { ns := &nativeStore{ &testExecuter{}, } // create trace hooks that write to buffer buffer := bytes.Buffer{} trace1 := &trace.ExecutableTrace{ ExecuteStart: func(executableName string, action string) { buffer.WriteString(fmt.Sprintf("trace 1 start %s, %s ", executableName, action)) }, ExecuteDone: func(executableName string, action string, err error) { buffer.WriteString(fmt.Sprintf("trace 1 done %s, %s, %v ", executableName, action, err)) }, } ctx := context.Background() ctx = trace.WithExecutableTrace(ctx, trace1) trace2 := &trace.ExecutableTrace{ ExecuteStart: func(executableName string, action string) { buffer.WriteString(fmt.Sprintf("trace 2 start %s, %s ", executableName, action)) }, ExecuteDone: func(executableName string, action string, err error) { buffer.WriteString(fmt.Sprintf("trace 2 done %s, %s, %v ", executableName, action, err)) }, } ctx = trace.WithExecutableTrace(ctx, trace2) trace3 := &trace.ExecutableTrace{} ctx = trace.WithExecutableTrace(ctx, trace3) // Test ns.Put trace err := ns.Put(ctx, traceHost, auth.Credential{Username: testUsername, Password: testPassword}) if err != nil { t.Fatalf("trace test ns.Put fails: %v", err) } bufferContent := buffer.String() if bufferContent != "trace 2 start testExecuter, store trace 1 start testExecuter, store trace 2 done testExecuter, store, trace 1 done testExecuter, store, " { t.Fatalf("incorrect buffer content: %s", bufferContent) } buffer.Reset() // Test ns.Get trace _, err = ns.Get(ctx, traceHost) if err != nil { t.Fatalf("trace test ns.Get fails: %v", err) } bufferContent = buffer.String() if bufferContent != "trace 2 start testExecuter, get trace 1 start testExecuter, get trace 2 done testExecuter, get, trace 1 done testExecuter, get, " { t.Fatalf("incorrect buffer content: %s", bufferContent) } buffer.Reset() // Test ns.Delete trace err = ns.Delete(ctx, traceHost) if err != nil { t.Fatalf("trace test ns.Delete fails: %v", err) } bufferContent = buffer.String() if bufferContent != "trace 2 start testExecuter, erase trace 1 start testExecuter, erase trace 2 done testExecuter, erase, trace 1 done testExecuter, erase, " { t.Fatalf("incorrect buffer content: %s", bufferContent) } } oras-go-2.5.0/registry/remote/credentials/native_store_windows.go000066400000000000000000000014721457674530300253520ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package credentials // getPlatformDefaultHelperSuffix returns the platform default credential // helper suffix. // Reference: https://docs.docker.com/engine/reference/commandline/login/#default-behavior func getPlatformDefaultHelperSuffix() string { return "wincred" } oras-go-2.5.0/registry/remote/credentials/registry.go000066400000000000000000000074251457674530300227520ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package credentials import ( "context" "errors" "fmt" "oras.land/oras-go/v2/registry/remote" "oras.land/oras-go/v2/registry/remote/auth" ) // ErrClientTypeUnsupported is thrown by Login() when the registry's client type // is not supported. var ErrClientTypeUnsupported = errors.New("client type not supported") // Login provides the login functionality with the given credentials. The target // registry's client should be nil or of type *auth.Client. Login uses // a client local to the function and will not modify the original client of // the registry. func Login(ctx context.Context, store Store, reg *remote.Registry, cred auth.Credential) error { // create a clone of the original registry for login purpose regClone := *reg // we use the original client if applicable, otherwise use a default client var authClient auth.Client if reg.Client == nil { authClient = *auth.DefaultClient authClient.Cache = nil // no cache } else if client, ok := reg.Client.(*auth.Client); ok { authClient = *client } else { return ErrClientTypeUnsupported } regClone.Client = &authClient // update credentials with the client authClient.Credential = auth.StaticCredential(reg.Reference.Registry, cred) // validate and store the credential if err := regClone.Ping(ctx); err != nil { return fmt.Errorf("failed to validate the credentials for %s: %w", regClone.Reference.Registry, err) } hostname := ServerAddressFromRegistry(regClone.Reference.Registry) if err := store.Put(ctx, hostname, cred); err != nil { return fmt.Errorf("failed to store the credentials for %s: %w", hostname, err) } return nil } // Logout provides the logout functionality given the registry name. func Logout(ctx context.Context, store Store, registryName string) error { registryName = ServerAddressFromRegistry(registryName) if err := store.Delete(ctx, registryName); err != nil { return fmt.Errorf("failed to delete the credential for %s: %w", registryName, err) } return nil } // Credential returns a Credential() function that can be used by auth.Client. func Credential(store Store) auth.CredentialFunc { return func(ctx context.Context, hostport string) (auth.Credential, error) { hostport = ServerAddressFromHostname(hostport) if hostport == "" { return auth.EmptyCredential, nil } return store.Get(ctx, hostport) } } // ServerAddressFromRegistry maps a registry to a server address, which is used as // a key for credentials store. The Docker CLI expects that the credentials of // the registry 'docker.io' will be added under the key "https://index.docker.io/v1/". // See: https://github.com/moby/moby/blob/v24.0.2/registry/config.go#L25-L48 func ServerAddressFromRegistry(registry string) string { if registry == "docker.io" { return "https://index.docker.io/v1/" } return registry } // ServerAddressFromHostname maps a hostname to a server address, which is used as // a key for credentials store. It is expected that the traffic targetting the // host "registry-1.docker.io" will be redirected to "https://index.docker.io/v1/". // See: https://github.com/moby/moby/blob/v24.0.2/registry/config.go#L25-L48 func ServerAddressFromHostname(hostname string) string { if hostname == "registry-1.docker.io" { return "https://index.docker.io/v1/" } return hostname } oras-go-2.5.0/registry/remote/credentials/registry_test.go000066400000000000000000000152271457674530300240100ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package credentials import ( "context" "encoding/base64" "errors" "net/http" "net/http/httptest" "net/url" "reflect" "testing" "oras.land/oras-go/v2/registry/remote" "oras.land/oras-go/v2/registry/remote/auth" ) // testStore implements the Store interface, used for testing purpose. type testStore struct { storage map[string]auth.Credential } func (t *testStore) Get(ctx context.Context, serverAddress string) (auth.Credential, error) { return t.storage[serverAddress], nil } func (t *testStore) Put(ctx context.Context, serverAddress string, cred auth.Credential) error { if len(t.storage) == 0 { t.storage = make(map[string]auth.Credential) } t.storage[serverAddress] = cred return nil } func (t *testStore) Delete(ctx context.Context, serverAddress string) error { delete(t.storage, serverAddress) return nil } func TestLogin(t *testing.T) { // create a test registry ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { wantedAuthHeader := "Basic " + base64.StdEncoding.EncodeToString([]byte(testUsername+":"+testPassword)) authHeader := r.Header.Get("Authorization") if authHeader != wantedAuthHeader { w.Header().Set("Www-Authenticate", `Basic realm="Test Server"`) w.WriteHeader(http.StatusUnauthorized) } })) defer ts.Close() uri, _ := url.Parse(ts.URL) reg, err := remote.NewRegistry(uri.Host) if err != nil { t.Fatalf("cannot create test registry: %v", err) } reg.PlainHTTP = true // create a test store s := &testStore{} tests := []struct { name string ctx context.Context registry *remote.Registry cred auth.Credential wantErr bool }{ { name: "login succeeds", ctx: context.Background(), cred: auth.Credential{Username: testUsername, Password: testPassword}, wantErr: false, }, { name: "login fails (incorrect password)", ctx: context.Background(), cred: auth.Credential{Username: testUsername, Password: "whatever"}, wantErr: true, }, { name: "login fails (nil context makes remote.Ping fails)", ctx: nil, cred: auth.Credential{Username: testUsername, Password: testPassword}, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // login to test registry err := Login(tt.ctx, s, reg, tt.cred) if (err != nil) != tt.wantErr { t.Fatalf("Login() error = %v, wantErr %v", err, tt.wantErr) } if err != nil { return } if got := s.storage[reg.Reference.Registry]; !reflect.DeepEqual(got, tt.cred) { t.Fatalf("Stored credential = %v, want %v", got, tt.cred) } s.Delete(tt.ctx, reg.Reference.Registry) }) } } func TestLogin_unsupportedClient(t *testing.T) { var testClient http.Client reg, err := remote.NewRegistry("whatever") if err != nil { t.Fatalf("cannot create test registry: %v", err) } reg.PlainHTTP = true reg.Client = &testClient ctx := context.Background() s := &testStore{} cred := auth.EmptyCredential err = Login(ctx, s, reg, cred) if wantErr := ErrClientTypeUnsupported; !errors.Is(err, wantErr) { t.Errorf("Login() error = %v, wantErr %v", err, wantErr) } } func TestLogout(t *testing.T) { // create a test store s := &testStore{} s.storage = map[string]auth.Credential{ "localhost:2333": {Username: "test_user", Password: "test_word"}, "https://index.docker.io/v1/": {Username: "user", Password: "word"}, } tests := []struct { name string ctx context.Context store Store registryName string wantErr bool }{ { name: "logout of regular registry", ctx: context.Background(), registryName: "localhost:2333", wantErr: false, }, { name: "logout of docker.io", ctx: context.Background(), registryName: "docker.io", wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := Logout(tt.ctx, s, tt.registryName); (err != nil) != tt.wantErr { t.Fatalf("Logout() error = %v, wantErr %v", err, tt.wantErr) } if s.storage[tt.registryName] != auth.EmptyCredential { t.Error("Credentials are not deleted") } }) } } func Test_mapHostname(t *testing.T) { tests := []struct { name string host string want string }{ { name: "map docker.io to https://index.docker.io/v1/", host: "docker.io", want: "https://index.docker.io/v1/", }, { name: "do not map other host names", host: "localhost:2333", want: "localhost:2333", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := ServerAddressFromRegistry(tt.host); got != tt.want { t.Errorf("mapHostname() = %v, want %v", got, tt.want) } }) } } func TestCredential(t *testing.T) { // create a test store s := &testStore{} s.storage = map[string]auth.Credential{ "localhost:2333": {Username: "test_user", Password: "test_word"}, "https://index.docker.io/v1/": {Username: "user", Password: "word"}, } // create a test client using Credential testClient := &auth.Client{} testClient.Credential = Credential(s) tests := []struct { name string registry string wantCredential auth.Credential }{ { name: "get credentials for localhost:2333", registry: "localhost:2333", wantCredential: auth.Credential{Username: "test_user", Password: "test_word"}, }, { name: "get credentials for registry-1.docker.io", registry: "registry-1.docker.io", wantCredential: auth.Credential{Username: "user", Password: "word"}, }, { name: "get credentials for a registry not stored", registry: "localhost:6666", wantCredential: auth.EmptyCredential, }, { name: "get credentials for an empty string", registry: "", wantCredential: auth.EmptyCredential, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := testClient.Credential(context.Background(), tt.registry) if err != nil { t.Errorf("could not get credential: %v", err) } if !reflect.DeepEqual(got, tt.wantCredential) { t.Errorf("Credential() = %v, want %v", got, tt.wantCredential) } }) } } oras-go-2.5.0/registry/remote/credentials/store.go000066400000000000000000000227241457674530300222350ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // Package credentials supports reading, saving, and removing credentials from // Docker configuration files and external credential stores that follow // the Docker credential helper protocol. // // Reference: https://docs.docker.com/engine/reference/commandline/login/#credential-stores package credentials import ( "context" "fmt" "os" "path/filepath" "oras.land/oras-go/v2/internal/syncutil" "oras.land/oras-go/v2/registry/remote/auth" "oras.land/oras-go/v2/registry/remote/credentials/internal/config" ) const ( dockerConfigDirEnv = "DOCKER_CONFIG" dockerConfigFileDir = ".docker" dockerConfigFileName = "config.json" ) // Store is the interface that any credentials store must implement. type Store interface { // Get retrieves credentials from the store for the given server address. Get(ctx context.Context, serverAddress string) (auth.Credential, error) // Put saves credentials into the store for the given server address. Put(ctx context.Context, serverAddress string, cred auth.Credential) error // Delete removes credentials from the store for the given server address. Delete(ctx context.Context, serverAddress string) error } // DynamicStore dynamically determines which store to use based on the settings // in the config file. type DynamicStore struct { config *config.Config options StoreOptions detectedCredsStore string setCredsStoreOnce syncutil.OnceOrRetry } // StoreOptions provides options for NewStore. type StoreOptions struct { // AllowPlaintextPut allows saving credentials in plaintext in the config // file. // - If AllowPlaintextPut is set to false (default value), Put() will // return an error when native store is not available. // - If AllowPlaintextPut is set to true, Put() will save credentials in // plaintext in the config file when native store is not available. AllowPlaintextPut bool // DetectDefaultNativeStore enables detecting the platform-default native // credentials store when the config file has no authentication information. // // If DetectDefaultNativeStore is set to true, the store will detect and set // the default native credentials store in the "credsStore" field of the // config file. // - Windows: "wincred" // - Linux: "pass" or "secretservice" // - macOS: "osxkeychain" // // References: // - https://docs.docker.com/engine/reference/commandline/login/#credentials-store // - https://docs.docker.com/engine/reference/commandline/cli/#docker-cli-configuration-file-configjson-properties DetectDefaultNativeStore bool } // NewStore returns a Store based on the given configuration file. // // For Get(), Put() and Delete(), the returned Store will dynamically determine // which underlying credentials store to use for the given server address. // The underlying credentials store is determined in the following order: // 1. Native server-specific credential helper // 2. Native credentials store // 3. The plain-text config file itself // // References: // - https://docs.docker.com/engine/reference/commandline/login/#credentials-store // - https://docs.docker.com/engine/reference/commandline/cli/#docker-cli-configuration-file-configjson-properties func NewStore(configPath string, opts StoreOptions) (*DynamicStore, error) { cfg, err := config.Load(configPath) if err != nil { return nil, err } ds := &DynamicStore{ config: cfg, options: opts, } if opts.DetectDefaultNativeStore && !cfg.IsAuthConfigured() { // no authentication configured, detect the default credentials store ds.detectedCredsStore = getDefaultHelperSuffix() } return ds, nil } // NewStoreFromDocker returns a Store based on the default docker config file. // - If the $DOCKER_CONFIG environment variable is set, // $DOCKER_CONFIG/config.json will be used. // - Otherwise, the default location $HOME/.docker/config.json will be used. // // NewStoreFromDocker internally calls [NewStore]. // // References: // - https://docs.docker.com/engine/reference/commandline/cli/#configuration-files // - https://docs.docker.com/engine/reference/commandline/cli/#change-the-docker-directory func NewStoreFromDocker(opt StoreOptions) (*DynamicStore, error) { configPath, err := getDockerConfigPath() if err != nil { return nil, err } return NewStore(configPath, opt) } // Get retrieves credentials from the store for the given server address. func (ds *DynamicStore) Get(ctx context.Context, serverAddress string) (auth.Credential, error) { return ds.getStore(serverAddress).Get(ctx, serverAddress) } // Put saves credentials into the store for the given server address. // Put returns ErrPlaintextPutDisabled if native store is not available and // [StoreOptions].AllowPlaintextPut is set to false. func (ds *DynamicStore) Put(ctx context.Context, serverAddress string, cred auth.Credential) error { if err := ds.getStore(serverAddress).Put(ctx, serverAddress, cred); err != nil { return err } // save the detected creds store back to the config file on first put return ds.setCredsStoreOnce.Do(func() error { if ds.detectedCredsStore != "" { if err := ds.config.SetCredentialsStore(ds.detectedCredsStore); err != nil { return fmt.Errorf("failed to set credsStore: %w", err) } } return nil }) } // Delete removes credentials from the store for the given server address. func (ds *DynamicStore) Delete(ctx context.Context, serverAddress string) error { return ds.getStore(serverAddress).Delete(ctx, serverAddress) } // IsAuthConfigured returns whether there is authentication configured in the // config file or not. // // IsAuthConfigured returns true when: // - The "credsStore" field is not empty // - Or the "credHelpers" field is not empty // - Or there is any entry in the "auths" field func (ds *DynamicStore) IsAuthConfigured() bool { return ds.config.IsAuthConfigured() } // ConfigPath returns the path to the config file. func (ds *DynamicStore) ConfigPath() string { return ds.config.Path() } // getHelperSuffix returns the credential helper suffix for the given server // address. func (ds *DynamicStore) getHelperSuffix(serverAddress string) string { // 1. Look for a server-specific credential helper first if helper := ds.config.GetCredentialHelper(serverAddress); helper != "" { return helper } // 2. Then look for the configured native store if credsStore := ds.config.CredentialsStore(); credsStore != "" { return credsStore } // 3. Use the detected default store return ds.detectedCredsStore } // getStore returns a store for the given server address. func (ds *DynamicStore) getStore(serverAddress string) Store { if helper := ds.getHelperSuffix(serverAddress); helper != "" { return NewNativeStore(helper) } fs := newFileStore(ds.config) fs.DisablePut = !ds.options.AllowPlaintextPut return fs } // getDockerConfigPath returns the path to the default docker config file. func getDockerConfigPath() (string, error) { // first try the environment variable configDir := os.Getenv(dockerConfigDirEnv) if configDir == "" { // then try home directory homeDir, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("failed to get user home directory: %w", err) } configDir = filepath.Join(homeDir, dockerConfigFileDir) } return filepath.Join(configDir, dockerConfigFileName), nil } // storeWithFallbacks is a store that has multiple fallback stores. type storeWithFallbacks struct { stores []Store } // NewStoreWithFallbacks returns a new store based on the given stores. // - Get() searches the primary and the fallback stores // for the credentials and returns when it finds the // credentials in any of the stores. // - Put() saves the credentials into the primary store. // - Delete() deletes the credentials from the primary store. func NewStoreWithFallbacks(primary Store, fallbacks ...Store) Store { if len(fallbacks) == 0 { return primary } return &storeWithFallbacks{ stores: append([]Store{primary}, fallbacks...), } } // Get retrieves credentials from the StoreWithFallbacks for the given server. // It searches the primary and the fallback stores for the credentials of serverAddress // and returns when it finds the credentials in any of the stores. func (sf *storeWithFallbacks) Get(ctx context.Context, serverAddress string) (auth.Credential, error) { for _, s := range sf.stores { cred, err := s.Get(ctx, serverAddress) if err != nil { return auth.EmptyCredential, err } if cred != auth.EmptyCredential { return cred, nil } } return auth.EmptyCredential, nil } // Put saves credentials into the StoreWithFallbacks. It puts // the credentials into the primary store. func (sf *storeWithFallbacks) Put(ctx context.Context, serverAddress string, cred auth.Credential) error { return sf.stores[0].Put(ctx, serverAddress, cred) } // Delete removes credentials from the StoreWithFallbacks for the given server. // It deletes the credentials from the primary store. func (sf *storeWithFallbacks) Delete(ctx context.Context, serverAddress string) error { return sf.stores[0].Delete(ctx, serverAddress) } oras-go-2.5.0/registry/remote/credentials/store_test.go000066400000000000000000000644341457674530300233000ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package credentials import ( "context" "encoding/json" "errors" "os" "path/filepath" "reflect" "testing" "oras.land/oras-go/v2/registry/remote/auth" "oras.land/oras-go/v2/registry/remote/credentials/internal/config/configtest" ) type badStore struct{} var errBadStore = errors.New("bad store!") // Get retrieves credentials from the store for the given server address. func (s *badStore) Get(ctx context.Context, serverAddress string) (auth.Credential, error) { return auth.EmptyCredential, errBadStore } // Put saves credentials into the store for the given server address. func (s *badStore) Put(ctx context.Context, serverAddress string, cred auth.Credential) error { return errBadStore } // Delete removes credentials from the store for the given server address. func (s *badStore) Delete(ctx context.Context, serverAddress string) error { return errBadStore } func Test_DynamicStore_IsAuthConfigured(t *testing.T) { tempDir := t.TempDir() tests := []struct { name string fileName string shouldCreateFile bool cfg configtest.Config want bool }{ { name: "not existing file", fileName: "config.json", shouldCreateFile: false, cfg: configtest.Config{}, want: false, }, { name: "no auth", fileName: "config.json", shouldCreateFile: true, cfg: configtest.Config{ SomeConfigField: 123, }, want: false, }, { name: "empty auths exist", fileName: "empty_auths.json", shouldCreateFile: true, cfg: configtest.Config{ AuthConfigs: map[string]configtest.AuthConfig{}, }, want: false, }, { name: "auths exist, but no credential", fileName: "no_cred_auths.json", shouldCreateFile: true, cfg: configtest.Config{ AuthConfigs: map[string]configtest.AuthConfig{ "test.example.com": {}, }, }, want: true, }, { name: "auths exist", fileName: "auths.json", shouldCreateFile: true, cfg: configtest.Config{ AuthConfigs: map[string]configtest.AuthConfig{ "test.example.com": { Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", }, }, }, want: true, }, { name: "credsStore exists", fileName: "credsStore.json", shouldCreateFile: true, cfg: configtest.Config{ CredentialsStore: "teststore", }, want: true, }, { name: "empty credHelpers exist", fileName: "empty_credsStore.json", shouldCreateFile: true, cfg: configtest.Config{ CredentialHelpers: map[string]string{}, }, want: false, }, { name: "credHelpers exist", fileName: "credsStore.json", shouldCreateFile: true, cfg: configtest.Config{ CredentialHelpers: map[string]string{ "test.example.com": "testhelper", }, }, want: true, }, { name: "all exist", fileName: "credsStore.json", shouldCreateFile: true, cfg: configtest.Config{ SomeConfigField: 123, AuthConfigs: map[string]configtest.AuthConfig{ "test.example.com": {}, }, CredentialsStore: "teststore", CredentialHelpers: map[string]string{ "test.example.com": "testhelper", }, }, want: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // prepare test content configPath := filepath.Join(tempDir, tt.fileName) if tt.shouldCreateFile { jsonStr, err := json.Marshal(tt.cfg) if err != nil { t.Fatalf("failed to marshal config: %v", err) } if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { t.Fatalf("failed to write config file: %v", err) } } ds, err := NewStore(configPath, StoreOptions{}) if err != nil { t.Fatal("newStore() error =", err) } if got := ds.IsAuthConfigured(); got != tt.want { t.Errorf("DynamicStore.IsAuthConfigured() = %v, want %v", got, tt.want) } }) } } func Test_DynamicStore_authConfigured(t *testing.T) { // prepare test content tempDir := t.TempDir() configPath := filepath.Join(tempDir, "auth_configured.json") config := configtest.Config{ AuthConfigs: map[string]configtest.AuthConfig{ "xxx": {}, }, SomeConfigField: 123, } jsonStr, err := json.Marshal(config) if err != nil { t.Fatalf("failed to marshal config: %v", err) } if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { t.Fatalf("failed to write config file: %v", err) } ds, err := NewStore(configPath, StoreOptions{AllowPlaintextPut: true}) if err != nil { t.Fatal("NewStore() error =", err) } // test IsAuthConfigured authConfigured := ds.IsAuthConfigured() if want := true; authConfigured != want { t.Errorf("DynamicStore.IsAuthConfigured() = %v, want %v", authConfigured, want) } serverAddr := "test.example.com" cred := auth.Credential{ Username: "username", Password: "password", } ctx := context.Background() // test put if err := ds.Put(ctx, serverAddr, cred); err != nil { t.Fatal("DynamicStore.Get() error =", err) } // Put() should not set detected store back to config if got := ds.detectedCredsStore; got != "" { t.Errorf("ds.detectedCredsStore = %v, want empty", got) } if got := ds.config.CredentialsStore(); got != "" { t.Errorf("ds.config.CredentialsStore() = %v, want empty", got) } // test get got, err := ds.Get(ctx, serverAddr) if err != nil { t.Fatal("DynamicStore.Get() error =", err) } if want := cred; got != want { t.Errorf("DynamicStore.Get() = %v, want %v", got, want) } // test delete err = ds.Delete(ctx, serverAddr) if err != nil { t.Fatal("DynamicStore.Delete() error =", err) } // verify delete got, err = ds.Get(ctx, serverAddr) if err != nil { t.Fatal("DynamicStore.Get() error =", err) } if want := auth.EmptyCredential; got != want { t.Errorf("DynamicStore.Get() = %v, want %v", got, want) } } func Test_DynamicStore_authConfigured_DetectDefaultNativeStore(t *testing.T) { // prepare test content tempDir := t.TempDir() configPath := filepath.Join(tempDir, "auth_configured.json") config := configtest.Config{ AuthConfigs: map[string]configtest.AuthConfig{ "xxx": {}, }, SomeConfigField: 123, } jsonStr, err := json.Marshal(config) if err != nil { t.Fatalf("failed to marshal config: %v", err) } if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { t.Fatalf("failed to write config file: %v", err) } opts := StoreOptions{ AllowPlaintextPut: true, DetectDefaultNativeStore: true, } ds, err := NewStore(configPath, opts) if err != nil { t.Fatal("NewStore() error =", err) } // test IsAuthConfigured authConfigured := ds.IsAuthConfigured() if want := true; authConfigured != want { t.Errorf("DynamicStore.IsAuthConfigured() = %v, want %v", authConfigured, want) } serverAddr := "test.example.com" cred := auth.Credential{ Username: "username", Password: "password", } ctx := context.Background() // test put if err := ds.Put(ctx, serverAddr, cred); err != nil { t.Fatal("DynamicStore.Get() error =", err) } // Put() should not set detected store back to config if got := ds.detectedCredsStore; got != "" { t.Errorf("ds.detectedCredsStore = %v, want empty", got) } if got := ds.config.CredentialsStore(); got != "" { t.Errorf("ds.config.CredentialsStore() = %v, want empty", got) } // test get got, err := ds.Get(ctx, serverAddr) if err != nil { t.Fatal("DynamicStore.Get() error =", err) } if want := cred; got != want { t.Errorf("DynamicStore.Get() = %v, want %v", got, want) } // test delete err = ds.Delete(ctx, serverAddr) if err != nil { t.Fatal("DynamicStore.Delete() error =", err) } // verify delete got, err = ds.Get(ctx, serverAddr) if err != nil { t.Fatal("DynamicStore.Get() error =", err) } if want := auth.EmptyCredential; got != want { t.Errorf("DynamicStore.Get() = %v, want %v", got, want) } } func Test_DynamicStore_noAuthConfigured(t *testing.T) { // prepare test content tempDir := t.TempDir() configPath := filepath.Join(tempDir, "no_auth_configured.json") cfg := configtest.Config{ SomeConfigField: 123, } jsonStr, err := json.Marshal(cfg) if err != nil { t.Fatalf("failed to marshal config: %v", err) } if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { t.Fatalf("failed to write config file: %v", err) } ds, err := NewStore(configPath, StoreOptions{AllowPlaintextPut: true}) if err != nil { t.Fatal("NewStore() error =", err) } // test IsAuthConfigured authConfigured := ds.IsAuthConfigured() if want := false; authConfigured != want { t.Errorf("DynamicStore.IsAuthConfigured() = %v, want %v", authConfigured, want) } serverAddr := "test.example.com" cred := auth.Credential{ Username: "username", Password: "password", } ctx := context.Background() // Get() should not set detected store back to config if _, err := ds.Get(ctx, serverAddr); err != nil { t.Fatal("DynamicStore.Get() error =", err) } // test put if err := ds.Put(ctx, serverAddr, cred); err != nil { t.Fatal("DynamicStore.Put() error =", err) } // Put() should not set detected store back to config if got := ds.detectedCredsStore; got != "" { t.Errorf("ds.detectedCredsStore = %v, want empty", got) } if got := ds.config.CredentialsStore(); got != "" { t.Errorf("ds.config.CredentialsStore() = %v, want empty", got) } // test get got, err := ds.Get(ctx, serverAddr) if err != nil { t.Fatal("DynamicStore.Get() error =", err) } if want := cred; got != want { t.Errorf("DynamicStore.Get() = %v, want %v", got, want) } // test delete err = ds.Delete(ctx, serverAddr) if err != nil { t.Fatal("DynamicStore.Delete() error =", err) } // verify delete got, err = ds.Get(ctx, serverAddr) if err != nil { t.Fatal("DynamicStore.Get() error =", err) } if want := auth.EmptyCredential; got != want { t.Errorf("DynamicStore.Get() = %v, want %v", got, want) } } func Test_DynamicStore_noAuthConfigured_DetectDefaultNativeStore(t *testing.T) { // prepare test content tempDir := t.TempDir() configPath := filepath.Join(tempDir, "no_auth_configured.json") cfg := configtest.Config{ SomeConfigField: 123, } jsonStr, err := json.Marshal(cfg) if err != nil { t.Fatalf("failed to marshal config: %v", err) } if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { t.Fatalf("failed to write config file: %v", err) } opts := StoreOptions{ AllowPlaintextPut: true, DetectDefaultNativeStore: true, } ds, err := NewStore(configPath, opts) if err != nil { t.Fatal("NewStore() error =", err) } // test IsAuthConfigured authConfigured := ds.IsAuthConfigured() if want := false; authConfigured != want { t.Errorf("DynamicStore.IsAuthConfigured() = %v, want %v", authConfigured, want) } serverAddr := "test.example.com" cred := auth.Credential{ Username: "username", Password: "password", } ctx := context.Background() // Get() should set detectedCredsStore only, but should not save it back to config if _, err := ds.Get(ctx, serverAddr); err != nil { t.Fatal("DynamicStore.Get() error =", err) } if defaultStore := getDefaultHelperSuffix(); defaultStore != "" { if got := ds.detectedCredsStore; got != defaultStore { t.Errorf("ds.detectedCredsStore = %v, want %v", got, defaultStore) } } if got := ds.config.CredentialsStore(); got != "" { t.Errorf("ds.config.CredentialsStore() = %v, want empty", got) } // test put if err := ds.Put(ctx, serverAddr, cred); err != nil { t.Fatal("DynamicStore.Put() error =", err) } // Put() should set the detected store back to config if got := ds.config.CredentialsStore(); got != ds.detectedCredsStore { t.Errorf("ds.config.CredentialsStore() = %v, want %v", got, ds.detectedCredsStore) } // test get got, err := ds.Get(ctx, serverAddr) if err != nil { t.Fatal("DynamicStore.Get() error =", err) } if want := cred; got != want { t.Errorf("DynamicStore.Get() = %v, want %v", got, want) } // test delete err = ds.Delete(ctx, serverAddr) if err != nil { t.Fatal("DynamicStore.Delete() error =", err) } // verify delete got, err = ds.Get(ctx, serverAddr) if err != nil { t.Fatal("DynamicStore.Get() error =", err) } if want := auth.EmptyCredential; got != want { t.Errorf("DynamicStore.Get() = %v, want %v", got, want) } } func Test_DynamicStore_fileStore_AllowPlainTextPut(t *testing.T) { // prepare test content tempDir := t.TempDir() configPath := filepath.Join(tempDir, "config.json") serverAddr := "newtest.example.com" cred := auth.Credential{ Username: "username", Password: "password", } ctx := context.Background() cfg := configtest.Config{ AuthConfigs: map[string]configtest.AuthConfig{ "test.example.com": {}, }, SomeConfigField: 123, } jsonStr, err := json.Marshal(cfg) if err != nil { t.Fatalf("failed to marshal config: %v", err) } if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { t.Fatalf("failed to write config file: %v", err) } // test default option ds, err := NewStore(configPath, StoreOptions{}) if err != nil { t.Fatal("NewStore() error =", err) } err = ds.Put(ctx, serverAddr, cred) if wantErr := ErrPlaintextPutDisabled; !errors.Is(err, wantErr) { t.Errorf("DynamicStore.Put() error = %v, wantErr %v", err, wantErr) } // test AllowPlainTextPut = true ds, err = NewStore(configPath, StoreOptions{AllowPlaintextPut: true}) if err != nil { t.Fatal("NewStore() error =", err) } if err := ds.Put(ctx, serverAddr, cred); err != nil { t.Error("DynamicStore.Put() error =", err) } // verify config file configFile, err := os.Open(configPath) if err != nil { t.Fatalf("failed to open config file: %v", err) } defer configFile.Close() var gotCfg configtest.Config if err := json.NewDecoder(configFile).Decode(&gotCfg); err != nil { t.Fatalf("failed to decode config file: %v", err) } wantCfg := configtest.Config{ AuthConfigs: map[string]configtest.AuthConfig{ "test.example.com": {}, serverAddr: { Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", }, }, SomeConfigField: cfg.SomeConfigField, } if !reflect.DeepEqual(gotCfg, wantCfg) { t.Errorf("Decoded config = %v, want %v", gotCfg, wantCfg) } } func Test_DynamicStore_getHelperSuffix(t *testing.T) { tests := []struct { name string configPath string serverAddress string want string }{ { name: "Get cred helper: registry_helper1", configPath: "testdata/credHelpers_config.json", serverAddress: "registry1.example.com", want: "registry1-helper", }, { name: "Get cred helper: registry_helper2", configPath: "testdata/credHelpers_config.json", serverAddress: "registry2.example.com", want: "registry2-helper", }, { name: "Empty cred helper configured", configPath: "testdata/credHelpers_config.json", serverAddress: "registry3.example.com", want: "", }, { name: "No cred helper and creds store configured", configPath: "testdata/credHelpers_config.json", serverAddress: "whatever.example.com", want: "", }, { name: "Choose cred helper over creds store", configPath: "testdata/credsStore_config.json", serverAddress: "test.example.com", want: "test-helper", }, { name: "No cred helper configured, choose cred store", configPath: "testdata/credsStore_config.json", serverAddress: "whatever.example.com", want: "teststore", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ds, err := NewStore(tt.configPath, StoreOptions{}) if err != nil { t.Fatal("NewStore() error =", err) } if got := ds.getHelperSuffix(tt.serverAddress); got != tt.want { t.Errorf("DynamicStore.getHelperSuffix() = %v, want %v", got, tt.want) } }) } } func Test_DynamicStore_ConfigPath(t *testing.T) { path := "../../testdata/credsStore_config.json" var err error store, err := NewStore(path, StoreOptions{}) if err != nil { t.Fatal("NewFileStore() error =", err) } got := store.ConfigPath() if got != path { t.Errorf("Config.GetPath() = %v, want %v", got, path) } } func Test_DynamicStore_getStore_nativeStore(t *testing.T) { tests := []struct { name string configPath string serverAddress string }{ { name: "Cred helper configured for registry1.example.com", configPath: "testdata/credHelpers_config.json", serverAddress: "registry1.example.com", }, { name: "Cred helper configured for registry2.example.com", configPath: "testdata/credHelpers_config.json", serverAddress: "registry2.example.com", }, { name: "Cred helper configured for test.example.com", configPath: "testdata/credsStore_config.json", serverAddress: "test.example.com", }, { name: "No cred helper configured, use creds store", configPath: "testdata/credsStore_config.json", serverAddress: "whaterver.example.com", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ds, err := NewStore(tt.configPath, StoreOptions{}) if err != nil { t.Fatal("NewStore() error =", err) } gotStore := ds.getStore(tt.serverAddress) if _, ok := gotStore.(*nativeStore); !ok { t.Errorf("gotStore is not a native store") } }) } } func Test_DynamicStore_getStore_fileStore(t *testing.T) { tests := []struct { name string configPath string serverAddress string }{ { name: "Empty cred helper configured for registry3.example.com", configPath: "testdata/credHelpers_config.json", serverAddress: "registry3.example.com", }, { name: "No cred helper configured", configPath: "testdata/credHelpers_config.json", serverAddress: "whatever.example.com", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ds, err := NewStore(tt.configPath, StoreOptions{}) if err != nil { t.Fatal("NewStore() error =", err) } gotStore := ds.getStore(tt.serverAddress) gotFS1, ok := gotStore.(*FileStore) if !ok { t.Errorf("gotStore is not a file store") } // get again, the two file stores should be based on the same config instance gotStore = ds.getStore(tt.serverAddress) gotFS2, ok := gotStore.(*FileStore) if !ok { t.Errorf("gotStore is not a file store") } if gotFS1.config != gotFS2.config { t.Errorf("gotFS1 and gotFS2 are not based on the same config") } }) } } func Test_storeWithFallbacks_Get(t *testing.T) { // prepare test content server1 := "foo.registry.com" cred1 := auth.Credential{ Username: "username", Password: "password", } server2 := "bar.registry.com" cred2 := auth.Credential{ RefreshToken: "identity_token", } primaryStore := &testStore{} fallbackStore1 := &testStore{ storage: map[string]auth.Credential{ server1: cred1, }, } fallbackStore2 := &testStore{ storage: map[string]auth.Credential{ server2: cred2, }, } sf := NewStoreWithFallbacks(primaryStore, fallbackStore1, fallbackStore2) ctx := context.Background() // test Get() got1, err := sf.Get(ctx, server1) if err != nil { t.Fatalf("storeWithFallbacks.Get(%s) error = %v", server1, err) } if want := cred1; got1 != cred1 { t.Errorf("storeWithFallbacks.Get(%s) = %v, want %v", server1, got1, want) } got2, err := sf.Get(ctx, server2) if err != nil { t.Fatalf("storeWithFallbacks.Get(%s) error = %v", server2, err) } if want := cred2; got2 != cred2 { t.Errorf("storeWithFallbacks.Get(%s) = %v, want %v", server2, got2, want) } // test Get(): no credential found got, err := sf.Get(ctx, "whaterver") if err != nil { t.Fatal("storeWithFallbacks.Get() error =", err) } if want := auth.EmptyCredential; got != want { t.Errorf("storeWithFallbacks.Get() = %v, want %v", got, want) } } func Test_storeWithFallbacks_Get_throwError(t *testing.T) { badStore := &badStore{} goodStore := &testStore{} sf := NewStoreWithFallbacks(badStore, goodStore) ctx := context.Background() // test Get(): should throw error _, err := sf.Get(ctx, "whatever") if wantErr := errBadStore; !errors.Is(err, wantErr) { t.Errorf("storeWithFallback.Get() error = %v, wantErr %v", err, wantErr) } } func Test_storeWithFallbacks_Put(t *testing.T) { // prepare test content cfg := configtest.Config{ SomeConfigField: 123, } jsonStr, err := json.Marshal(cfg) if err != nil { t.Fatalf("failed to marshal config: %v", err) } tempDir := t.TempDir() configPath := filepath.Join(tempDir, "no_auth_configured.json") if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { t.Fatalf("failed to write config file: %v", err) } opts := StoreOptions{ AllowPlaintextPut: true, } primaryStore, err := NewStore(configPath, opts) // plaintext enabled if err != nil { t.Fatalf("NewStore(%s) error = %v", configPath, err) } badStore := &badStore{} // bad store sf := NewStoreWithFallbacks(primaryStore, badStore) ctx := context.Background() server := "example.registry.com" cred := auth.Credential{ Username: "username", Password: "password", } // test Put() if err := sf.Put(ctx, server, cred); err != nil { t.Fatal("storeWithFallbacks.Put() error =", err) } // verify Get() got, err := sf.Get(ctx, server) if err != nil { t.Fatal("storeWithFallbacks.Get() error =", err) } if want := cred; got != want { t.Errorf("storeWithFallbacks.Get() = %v, want %v", got, want) } } func Test_storeWithFallbacks_Put_throwError(t *testing.T) { badStore := &badStore{} goodStore := &testStore{} sf := NewStoreWithFallbacks(badStore, goodStore) ctx := context.Background() // test Put(): should thrown error err := sf.Put(ctx, "whatever", auth.Credential{}) if wantErr := errBadStore; !errors.Is(err, wantErr) { t.Errorf("storeWithFallback.Put() error = %v, wantErr %v", err, wantErr) } } func Test_storeWithFallbacks_Delete(t *testing.T) { // prepare test content server1 := "foo.registry.com" cred1 := auth.Credential{ Username: "username", Password: "password", } server2 := "bar.registry.com" cred2 := auth.Credential{ RefreshToken: "identity_token", } primaryStore := &testStore{ storage: map[string]auth.Credential{ server1: cred1, server2: cred2, }, } badStore := &badStore{} sf := NewStoreWithFallbacks(primaryStore, badStore) ctx := context.Background() // test Delete(): server1 if err := sf.Delete(ctx, server1); err != nil { t.Fatal("storeWithFallback.Delete()") } // verify primary store if want := map[string]auth.Credential{server2: cred2}; !reflect.DeepEqual(primaryStore.storage, want) { t.Errorf("primaryStore.storage = %v, want %v", primaryStore.storage, want) } // test Delete(): server2 if err := sf.Delete(ctx, server2); err != nil { t.Fatal("storeWithFallback.Delete()") } // verify primary store if want := map[string]auth.Credential{}; !reflect.DeepEqual(primaryStore.storage, want) { t.Errorf("primaryStore.storage = %v, want %v", primaryStore.storage, want) } } func Test_storeWithFallbacks_Delete_throwError(t *testing.T) { badStore := &badStore{} goodStore := &testStore{} sf := NewStoreWithFallbacks(badStore, goodStore) ctx := context.Background() // test Delete(): should throw error err := sf.Delete(ctx, "whatever") if wantErr := errBadStore; !errors.Is(err, wantErr) { t.Errorf("storeWithFallback.Delete() error = %v, wantErr %v", err, wantErr) } } func Test_getDockerConfigPath_env(t *testing.T) { dir, err := os.Getwd() if err != nil { t.Fatal("os.Getwd() error =", err) } t.Setenv("DOCKER_CONFIG", dir) got, err := getDockerConfigPath() if err != nil { t.Fatal("getDockerConfigPath() error =", err) } if want := filepath.Join(dir, "config.json"); got != want { t.Errorf("getDockerConfigPath() = %v, want %v", got, want) } } func Test_getDockerConfigPath_homeDir(t *testing.T) { t.Setenv("DOCKER_CONFIG", "") got, err := getDockerConfigPath() if err != nil { t.Fatal("getDockerConfigPath() error =", err) } homeDir, err := os.UserHomeDir() if err != nil { t.Fatal("os.UserHomeDir()") } if want := filepath.Join(homeDir, ".docker", "config.json"); got != want { t.Errorf("getDockerConfigPath() = %v, want %v", got, want) } } func TestNewStoreFromDocker(t *testing.T) { // prepare test content tempDir := t.TempDir() configPath := filepath.Join(tempDir, "config.json") t.Setenv("DOCKER_CONFIG", tempDir) serverAddr1 := "test.example.com" cred1 := auth.Credential{ Username: "foo", Password: "bar", } config := configtest.Config{ AuthConfigs: map[string]configtest.AuthConfig{ serverAddr1: { Auth: "Zm9vOmJhcg==", }, }, SomeConfigField: 123, } jsonStr, err := json.Marshal(config) if err != nil { t.Fatalf("failed to marshal config: %v", err) } if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { t.Fatalf("failed to write config file: %v", err) } ctx := context.Background() ds, err := NewStoreFromDocker(StoreOptions{AllowPlaintextPut: true}) if err != nil { t.Fatal("NewStoreFromDocker() error =", err) } // test getting an existing credential got, err := ds.Get(ctx, serverAddr1) if err != nil { t.Fatal("DynamicStore.Get() error =", err) } if want := cred1; got != want { t.Errorf("DynamicStore.Get() = %v, want %v", got, want) } // test putting a new credential serverAddr2 := "newtest.example.com" cred2 := auth.Credential{ Username: "username", Password: "password", } if err := ds.Put(ctx, serverAddr2, cred2); err != nil { t.Fatal("DynamicStore.Get() error =", err) } // test getting the new credential got, err = ds.Get(ctx, serverAddr2) if err != nil { t.Fatal("DynamicStore.Get() error =", err) } if want := cred2; got != want { t.Errorf("DynamicStore.Get() = %v, want %v", got, want) } // test deleting the old credential err = ds.Delete(ctx, serverAddr1) if err != nil { t.Fatal("DynamicStore.Delete() error =", err) } // verify delete got, err = ds.Get(ctx, serverAddr1) if err != nil { t.Fatal("DynamicStore.Get() error =", err) } if want := auth.EmptyCredential; got != want { t.Errorf("DynamicStore.Get() = %v, want %v", got, want) } } oras-go-2.5.0/registry/remote/credentials/testdata/000077500000000000000000000000001457674530300223545ustar00rootroot00000000000000oras-go-2.5.0/registry/remote/credentials/testdata/bad_config000066400000000000000000000000051457674530300243450ustar00rootroot00000000000000bad oras-go-2.5.0/registry/remote/credentials/testdata/credHelpers_config.json000066400000000000000000000005601457674530300270350ustar00rootroot00000000000000{ "auths": { "registry1.example.com": { "auth": "dXNlcm5hbWU6cGFzc3dvcmQ=" }, "registry3.example.com": { "auth": "Zm9vOmJhcg==" } }, "credHelpers": { "registry1.example.com": "registry1-helper", "registry2.example.com": "registry2-helper", "registry3.example.com": "" } } oras-go-2.5.0/registry/remote/credentials/testdata/credsStore_config.json000066400000000000000000000001501457674530300267050ustar00rootroot00000000000000{ "credHelpers": { "test.example.com": "test-helper" }, "credsStore": "teststore" } oras-go-2.5.0/registry/remote/credentials/testdata/empty.json000066400000000000000000000000001457674530300243730ustar00rootroot00000000000000oras-go-2.5.0/registry/remote/credentials/testdata/invalid_auths_config.json000066400000000000000000000000401457674530300274200ustar00rootroot00000000000000{ "auths": "whaterver" } oras-go-2.5.0/registry/remote/credentials/testdata/invalid_auths_entry_config.json000066400000000000000000000003521457674530300306470ustar00rootroot00000000000000{ "auths": { "registry1.example.com": { "auth": "username:password" }, "registry2.example.com": "whatever", "registry3.example.com": { "identitytoken": 123 } } } oras-go-2.5.0/registry/remote/credentials/testdata/legacy_auths_config.json000066400000000000000000000013461457674530300272500ustar00rootroot00000000000000{ "auths": { "registry1.example.com": { "auth": "dXNlcm5hbWUxOnBhc3N3b3JkMQ==" }, "http://registry2.example.com": { "auth": "dXNlcm5hbWUyOnBhc3N3b3JkMg==" }, "https://registry3.example.com": { "auth": "dXNlcm5hbWUzOnBhc3N3b3JkMw==" }, "http://registry4.example.com/": { "auth": "dXNlcm5hbWU0OnBhc3N3b3JkNA==" }, "https://registry5.example.com/": { "auth": "dXNlcm5hbWU1OnBhc3N3b3JkNQ==" }, "https://registry6.example.com/path/": { "auth": "dXNlcm5hbWU2OnBhc3N3b3JkNg==" }, "https://registry1.example.com/": { "auth": "Zm9vOmJhcg==" } } } oras-go-2.5.0/registry/remote/credentials/testdata/no_auths_config.json000066400000000000000000000000251457674530300264110ustar00rootroot00000000000000{ "key": "val" } oras-go-2.5.0/registry/remote/credentials/testdata/valid_auths_config.json000066400000000000000000000014351457674530300271020ustar00rootroot00000000000000{ "auths": { "registry1.example.com": { "auth": "dXNlcm5hbWU6cGFzc3dvcmQ=" }, "registry2.example.com": { "identitytoken": "identity_token" }, "registry3.example.com": { "registrytoken": "registry_token" }, "registry4.example.com": { "auth": "dXNlcm5hbWU6cGFzc3dvcmQ=", "identitytoken": "identity_token", "registrytoken": "registry_token" }, "registry5.example.com": {}, "registry6.example.com": { "username": "username", "password": "password" }, "registry7.example.com": { "auth": "dXNlcm5hbWU6cGFzc3dvcmQ=", "username": "foo", "password": "bar" } } } oras-go-2.5.0/registry/remote/credentials/trace/000077500000000000000000000000001457674530300216415ustar00rootroot00000000000000oras-go-2.5.0/registry/remote/credentials/trace/example_test.go000066400000000000000000000042561457674530300246710ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package trace_test import ( "context" "fmt" "oras.land/oras-go/v2/registry/remote/auth" "oras.land/oras-go/v2/registry/remote/credentials" "oras.land/oras-go/v2/registry/remote/credentials/trace" ) // An example on how to use ExecutableTrace with Stores. func Example() { // ExecutableTrace works with all Stores that may invoke executables, for // example the Store returned from NewStore and NewNativeStore. store, err := credentials.NewStore("example/path/config.json", credentials.StoreOptions{}) if err != nil { panic(err) } // Define ExecutableTrace and add it to the context. The 'action' argument // refers to one of 'store', 'get' and 'erase' defined by the docker // credential helper protocol. // Reference: https://docs.docker.com/engine/reference/commandline/login/#credential-helper-protocol traceHooks := &trace.ExecutableTrace{ ExecuteStart: func(executableName string, action string) { fmt.Printf("executable %s, action %s started", executableName, action) }, ExecuteDone: func(executableName string, action string, err error) { fmt.Printf("executable %s, action %s finished", executableName, action) }, } ctx := trace.WithExecutableTrace(context.Background(), traceHooks) // Get, Put and Delete credentials from store. If any credential helper // executable is run, traceHooks is executed. err = store.Put(ctx, "localhost:5000", auth.Credential{Username: "testUsername", Password: "testPassword"}) if err != nil { panic(err) } cred, err := store.Get(ctx, "localhost:5000") if err != nil { panic(err) } fmt.Println(cred) err = store.Delete(ctx, "localhost:5000") if err != nil { panic(err) } } oras-go-2.5.0/registry/remote/credentials/trace/trace.go000066400000000000000000000066121457674530300232730ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package trace import "context" // executableTraceContextKey is a value key used to retrieve the ExecutableTrace // from Context. type executableTraceContextKey struct{} // ExecutableTrace is a set of hooks used to trace the execution of binary // executables. Any particular hook may be nil. type ExecutableTrace struct { // ExecuteStart is called before the execution of the executable. The // executableName parameter is the name of the credential helper executable // used with NativeStore. The action parameter is one of "store", "get" and // "erase". // // Reference: // - https://docs.docker.com/engine/reference/commandline/login#credentials-store ExecuteStart func(executableName string, action string) // ExecuteDone is called after the execution of an executable completes. // The executableName parameter is the name of the credential helper // executable used with NativeStore. The action parameter is one of "store", // "get" and "erase". The err parameter is the error (if any) returned from // the execution. // // Reference: // - https://docs.docker.com/engine/reference/commandline/login#credentials-store ExecuteDone func(executableName string, action string, err error) } // ContextExecutableTrace returns the ExecutableTrace associated with the // context. If none, it returns nil. func ContextExecutableTrace(ctx context.Context) *ExecutableTrace { trace, _ := ctx.Value(executableTraceContextKey{}).(*ExecutableTrace) return trace } // WithExecutableTrace takes a Context and an ExecutableTrace, and returns a // Context with the ExecutableTrace added as a Value. If the Context has a // previously added trace, the hooks defined in the new trace will be added // in addition to the previous ones. The recent hooks will be called first. func WithExecutableTrace(ctx context.Context, trace *ExecutableTrace) context.Context { if trace == nil { return ctx } if oldTrace := ContextExecutableTrace(ctx); oldTrace != nil { trace.compose(oldTrace) } return context.WithValue(ctx, executableTraceContextKey{}, trace) } // compose takes an oldTrace and modifies the existing trace to include // the hooks defined in the oldTrace. The hooks in the existing trace will // be called first. func (trace *ExecutableTrace) compose(oldTrace *ExecutableTrace) { if oldStart := oldTrace.ExecuteStart; oldStart != nil { start := trace.ExecuteStart if start != nil { trace.ExecuteStart = func(executableName, action string) { start(executableName, action) oldStart(executableName, action) } } else { trace.ExecuteStart = oldStart } } if oldDone := oldTrace.ExecuteDone; oldDone != nil { done := trace.ExecuteDone if done != nil { trace.ExecuteDone = func(executableName, action string, err error) { done(executableName, action, err) oldDone(executableName, action, err) } } else { trace.ExecuteDone = oldDone } } } oras-go-2.5.0/registry/remote/errcode/000077500000000000000000000000001457674530300176715ustar00rootroot00000000000000oras-go-2.5.0/registry/remote/errcode/errors.go000066400000000000000000000070041457674530300215350ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package errcode import ( "fmt" "net/http" "net/url" "strings" "unicode" ) // References: // - https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#error-codes // - https://docs.docker.com/registry/spec/api/#errors-2 const ( ErrorCodeBlobUnknown = "BLOB_UNKNOWN" ErrorCodeBlobUploadInvalid = "BLOB_UPLOAD_INVALID" ErrorCodeBlobUploadUnknown = "BLOB_UPLOAD_UNKNOWN" ErrorCodeDigestInvalid = "DIGEST_INVALID" ErrorCodeManifestBlobUnknown = "MANIFEST_BLOB_UNKNOWN" ErrorCodeManifestInvalid = "MANIFEST_INVALID" ErrorCodeManifestUnknown = "MANIFEST_UNKNOWN" ErrorCodeNameInvalid = "NAME_INVALID" ErrorCodeNameUnknown = "NAME_UNKNOWN" ErrorCodeSizeInvalid = "SIZE_INVALID" ErrorCodeUnauthorized = "UNAUTHORIZED" ErrorCodeDenied = "DENIED" ErrorCodeUnsupported = "UNSUPPORTED" ) // Error represents a response inner error returned by the remote // registry. // References: // - https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#error-codes // - https://docs.docker.com/registry/spec/api/#errors-2 type Error struct { Code string `json:"code"` Message string `json:"message"` Detail any `json:"detail,omitempty"` } // Error returns a error string describing the error. func (e Error) Error() string { code := strings.Map(func(r rune) rune { if r == '_' { return ' ' } return unicode.ToLower(r) }, e.Code) if e.Message == "" { return code } if e.Detail == nil { return fmt.Sprintf("%s: %s", code, e.Message) } return fmt.Sprintf("%s: %s: %v", code, e.Message, e.Detail) } // Errors represents a list of response inner errors returned by the remote // server. // References: // - https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#error-codes // - https://docs.docker.com/registry/spec/api/#errors-2 type Errors []Error // Error returns a error string describing the error. func (errs Errors) Error() string { switch len(errs) { case 0: return "" case 1: return errs[0].Error() } var errmsgs []string for _, err := range errs { errmsgs = append(errmsgs, err.Error()) } return strings.Join(errmsgs, "; ") } // Unwrap returns the inner error only when there is exactly one error. func (errs Errors) Unwrap() error { if len(errs) == 1 { return errs[0] } return nil } // ErrorResponse represents an error response. type ErrorResponse struct { Method string URL *url.URL StatusCode int Errors Errors } // Error returns a error string describing the error. func (err *ErrorResponse) Error() string { var errmsg string if len(err.Errors) > 0 { errmsg = err.Errors.Error() } else { errmsg = http.StatusText(err.StatusCode) } return fmt.Sprintf("%s %q: response status code %d: %s", err.Method, err.URL, err.StatusCode, errmsg) } // Unwrap returns the internal errors of err if any. func (err *ErrorResponse) Unwrap() error { if len(err.Errors) == 0 { return nil } return err.Errors } oras-go-2.5.0/registry/remote/example_test.go000066400000000000000000000777021457674530300213040ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // Package remote_test includes all the testable examples for remote repository type package remote_test import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/httptest" "net/url" "os" "strconv" "strings" "testing" "github.com/opencontainers/go-digest" "github.com/opencontainers/image-spec/specs-go" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2" "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/internal/spec" . "oras.land/oras-go/v2/registry/internal/doc" "oras.land/oras-go/v2/registry/remote" ) const ( _ = ExampleUnplayable exampleRepositoryName = "example" exampleTag = "latest" exampleConfig = "Example config content" exampleLayer = "Example layer content" exampleUploadUUid = "0bc84d80-837c-41d9-824e-1907463c53b3" // For ExampleRepository_Push_artifactReferenceManifest: ManifestDigest = "sha256:a3f9d449466b9b7194c3a76ca4890d792e11eb4e62e59aa8b4c3cce0a56f129d" ReferenceManifestDigest = "sha256:2d30397701742b04550891851529abe6b071e4fae920a91897d34612662a3bf6" // For Example_pushAndIgnoreReferrersIndexError: referrersAPIUnavailableRepositoryName = "no-referrers-api" referrerDigest = "sha256:4caba1e18385eb152bd92e9fee1dc01e47c436e594123b3c2833acfcad9883e2" referrersTag = "sha256-c824a9aa7d2e3471306648c6d4baa1abbcb97ff0276181ab4722ca27127cdba0" referrerIndexDigest = "sha256:7baac5147dd58d56fdbaad5a888fa919235a3a90cb71aaa8b56ee5d19f4cd838" ) var ( exampleLayerDescriptor = content.NewDescriptorFromBytes(ocispec.MediaTypeImageLayer, []byte(exampleLayer)) exampleLayerDigest = exampleLayerDescriptor.Digest.String() exampleManifest, _ = json.Marshal(ocispec.Manifest{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, Config: content.NewDescriptorFromBytes(ocispec.MediaTypeImageConfig, []byte(exampleConfig)), Layers: []ocispec.Descriptor{ exampleLayerDescriptor, }, }) exampleManifestDescriptor = content.NewDescriptorFromBytes(ocispec.MediaTypeImageManifest, exampleManifest) exampleManifestDigest = exampleManifestDescriptor.Digest.String() exampleSignatureManifest, _ = json.Marshal(spec.Artifact{ MediaType: spec.MediaTypeArtifactManifest, ArtifactType: "example/signature", Subject: &exampleManifestDescriptor}) exampleSignatureManifestDescriptor = ocispec.Descriptor{ MediaType: spec.MediaTypeArtifactManifest, ArtifactType: "example/signature", Digest: digest.FromBytes(exampleSignatureManifest), Size: int64(len(exampleSignatureManifest))} exampleSBoMManifest, _ = json.Marshal(spec.Artifact{ MediaType: spec.MediaTypeArtifactManifest, ArtifactType: "example/SBoM", Subject: &exampleManifestDescriptor}) exampleSBoMManifestDescriptor = ocispec.Descriptor{ MediaType: spec.MediaTypeArtifactManifest, ArtifactType: "example/SBoM", Digest: digest.FromBytes(exampleSBoMManifest), Size: int64(len(exampleSBoMManifest))} exampleReferrerDescriptors = [][]ocispec.Descriptor{ {exampleSBoMManifestDescriptor}, // page 0 {exampleSignatureManifestDescriptor}, // page 1 } blobContent = "example blob content" blobDescriptor = ocispec.Descriptor{ MediaType: "application/tar", Digest: digest.FromBytes([]byte(blobContent)), Size: int64(len(blobContent))} exampleManifestWithBlobs, _ = json.Marshal(spec.Artifact{ MediaType: spec.MediaTypeArtifactManifest, ArtifactType: "example/manifest", Blobs: []ocispec.Descriptor{blobDescriptor}, Subject: &exampleManifestDescriptor}) exampleManifestWithBlobsDescriptor = ocispec.Descriptor{ MediaType: spec.MediaTypeArtifactManifest, ArtifactType: "example/manifest", Digest: digest.FromBytes(exampleManifestWithBlobs), Size: int64(len(exampleManifestWithBlobs))} subjectDescriptor = content.NewDescriptorFromBytes(ocispec.MediaTypeImageManifest, []byte(`{"layers":[]}`)) referrerManifestContent, _ = json.Marshal(ocispec.Manifest{ Versioned: specs.Versioned{SchemaVersion: 2}, MediaType: ocispec.MediaTypeImageManifest, Subject: &subjectDescriptor, Config: ocispec.DescriptorEmptyJSON, }) referrerDescriptor = content.NewDescriptorFromBytes(ocispec.MediaTypeImageManifest, referrerManifestContent) referrerIndex, _ = json.Marshal(ocispec.Index{ Manifests: []ocispec.Descriptor{}, }) ) var host string func TestMain(m *testing.M) { // Setup a local HTTPS registry ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { p := r.URL.Path m := r.Method switch { case p == "/v2/_catalog" && m == "GET": result := struct { Repositories []string `json:"repositories"` }{ Repositories: []string{"public/repo1", "public/repo2", "internal/repo3"}, } json.NewEncoder(w).Encode(result) case p == fmt.Sprintf("/v2/%s/tags/list", exampleRepositoryName) && m == "GET": result := struct { Tags []string `json:"tags"` }{ Tags: []string{"tag1", "tag2"}, } json.NewEncoder(w).Encode(result) case p == fmt.Sprintf("/v2/%s/blobs/uploads/", exampleRepositoryName): w.Header().Set("Location", p+exampleUploadUUid) w.Header().Set("Docker-Upload-UUID", exampleUploadUUid) w.WriteHeader(http.StatusAccepted) case p == fmt.Sprintf("/v2/%s/blobs/uploads/%s", exampleRepositoryName, exampleUploadUUid): w.WriteHeader(http.StatusCreated) case p == fmt.Sprintf("/v2/%s/manifests/%s", exampleRepositoryName, exampleTag) && m == "PUT": w.WriteHeader(http.StatusCreated) case p == fmt.Sprintf("/v2/%s/manifests/%s", exampleRepositoryName, ManifestDigest) && m == "PUT": w.WriteHeader(http.StatusCreated) case p == fmt.Sprintf("/v2/%s/manifests/%s", exampleRepositoryName, ReferenceManifestDigest) && m == "PUT": w.Header().Set("OCI-Subject", "sha256:a3f9d449466b9b7194c3a76ca4890d792e11eb4e62e59aa8b4c3cce0a56f129d") w.WriteHeader(http.StatusCreated) case p == fmt.Sprintf("/v2/%s/manifests/%s", exampleRepositoryName, exampleSignatureManifestDescriptor.Digest) && m == "GET": w.Header().Set("Content-Type", spec.MediaTypeArtifactManifest) w.Header().Set("Content-Digest", string(exampleSignatureManifestDescriptor.Digest)) w.Header().Set("Content-Length", strconv.Itoa(len(exampleSignatureManifest))) w.Write(exampleSignatureManifest) case p == fmt.Sprintf("/v2/%s/manifests/%s", exampleRepositoryName, exampleSBoMManifestDescriptor.Digest) && m == "GET": w.Header().Set("Content-Type", spec.MediaTypeArtifactManifest) w.Header().Set("Content-Digest", string(exampleSBoMManifestDescriptor.Digest)) w.Header().Set("Content-Length", strconv.Itoa(len(exampleSBoMManifest))) w.Write(exampleSBoMManifest) case p == fmt.Sprintf("/v2/%s/manifests/%s", exampleRepositoryName, exampleManifestWithBlobsDescriptor.Digest) && m == "GET": w.Header().Set("Content-Type", spec.MediaTypeArtifactManifest) w.Header().Set("Content-Digest", string(exampleManifestWithBlobsDescriptor.Digest)) w.Header().Set("Content-Length", strconv.Itoa(len(exampleManifestWithBlobs))) w.Write(exampleManifestWithBlobs) case p == fmt.Sprintf("/v2/%s/blobs/%s", exampleRepositoryName, blobDescriptor.Digest) && m == "GET": w.Header().Set("Content-Type", spec.MediaTypeArtifactManifest) w.Header().Set("Content-Digest", string(blobDescriptor.Digest)) w.Header().Set("Content-Length", strconv.Itoa(len(blobContent))) w.Write([]byte(blobContent)) case p == fmt.Sprintf("/v2/%s/referrers/%s", exampleRepositoryName, "sha256:0000000000000000000000000000000000000000000000000000000000000000"): result := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageIndex, } w.Header().Set("Content-Type", ocispec.MediaTypeImageIndex) if err := json.NewEncoder(w).Encode(result); err != nil { panic(err) } case p == fmt.Sprintf("/v2/%s/referrers/%s", exampleRepositoryName, exampleManifestDescriptor.Digest.String()): q := r.URL.Query() var referrers []ocispec.Descriptor switch q.Get("test") { case "page1": referrers = exampleReferrerDescriptors[1] default: referrers = exampleReferrerDescriptors[0] w.Header().Set("Link", fmt.Sprintf(`<%s?n=1&test=page1>; rel="next"`, p)) } result := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageIndex, Manifests: referrers, } w.Header().Set("Content-Type", ocispec.MediaTypeImageIndex) if err := json.NewEncoder(w).Encode(result); err != nil { panic(err) } case p == fmt.Sprintf("/v2/%s/manifests/%s", exampleRepositoryName, exampleTag) || p == fmt.Sprintf("/v2/%s/manifests/%s", exampleRepositoryName, exampleManifestDigest): w.Header().Set("Content-Type", ocispec.MediaTypeImageManifest) w.Header().Set("Docker-Content-Digest", exampleManifestDigest) w.Header().Set("Content-Length", strconv.Itoa(len([]byte(exampleManifest)))) w.Header().Set("Warning", `299 - "This image is deprecated and will be removed soon."`) if m == "GET" { w.Write([]byte(exampleManifest)) } case p == fmt.Sprintf("/v2/%s/blobs/%s", exampleRepositoryName, exampleLayerDigest): w.Header().Set("Content-Type", ocispec.MediaTypeImageLayer) w.Header().Set("Docker-Content-Digest", string(exampleLayerDigest)) w.Header().Set("Accept-Ranges", "bytes") var start, end = 0, len(exampleLayer) - 1 if h := r.Header.Get("Range"); h != "" { w.WriteHeader(http.StatusPartialContent) indices := strings.Split(strings.Split(h, "=")[1], "-") var err error start, err = strconv.Atoi(indices[0]) if err != nil { panic(err) } end, err = strconv.Atoi(indices[1]) if err != nil { panic(err) } } resultBlob := exampleLayer[start : end+1] w.Header().Set("Content-Length", strconv.Itoa(len([]byte(resultBlob)))) if m == "GET" { w.Write([]byte(resultBlob)) } case p == fmt.Sprintf("/v2/%s/referrers/%s", referrersAPIUnavailableRepositoryName, "sha256:0000000000000000000000000000000000000000000000000000000000000000"): w.WriteHeader(http.StatusNotFound) case p == fmt.Sprintf("/v2/%s/manifests/%s", referrersAPIUnavailableRepositoryName, referrerDigest) && m == http.MethodPut: w.WriteHeader(http.StatusCreated) case p == fmt.Sprintf("/v2/%s/manifests/%s", referrersAPIUnavailableRepositoryName, referrersTag) && m == http.MethodGet: w.Write(referrerIndex) w.Header().Set("Content-Type", ocispec.MediaTypeImageIndex) w.Header().Set("Content-Length", strconv.Itoa(len(referrerIndex))) w.Header().Set("Docker-Content-Digest", digest.Digest(string(referrerIndex)).String()) w.WriteHeader(http.StatusCreated) case p == fmt.Sprintf("/v2/%s/manifests/%s", referrersAPIUnavailableRepositoryName, referrersTag) && m == http.MethodPut: w.WriteHeader(http.StatusCreated) case p == fmt.Sprintf("/v2/%s/manifests/%s", referrersAPIUnavailableRepositoryName, referrerIndexDigest) && m == http.MethodDelete: w.WriteHeader(http.StatusMethodNotAllowed) } })) defer ts.Close() u, err := url.Parse(ts.URL) if err != nil { panic(err) } host = u.Host http.DefaultTransport = ts.Client().Transport os.Exit(m.Run()) } // ExampleRepository_Tags gives example snippets for listing tags in a repository. func ExampleRepository_Tags() { repo, err := remote.NewRepository(fmt.Sprintf("%s/%s", host, exampleRepositoryName)) if err != nil { panic(err) } ctx := context.Background() err = repo.Tags(ctx, "", func(tags []string) error { for _, tag := range tags { fmt.Println(tag) } return nil }) if err != nil { panic(err) } // Output: // tag1 // tag2 } // ExampleRepository_Push gives example snippets for pushing a layer. func ExampleRepository_Push() { repo, err := remote.NewRepository(fmt.Sprintf("%s/%s", host, exampleRepositoryName)) if err != nil { panic(err) } ctx := context.Background() // 1. assemble a descriptor layer := []byte("Example layer content") descriptor := content.NewDescriptorFromBytes(ocispec.MediaTypeImageLayer, layer) // 2. push the descriptor and blob content err = repo.Push(ctx, descriptor, bytes.NewReader(layer)) if err != nil { panic(err) } fmt.Println("Push finished") // Output: // Push finished } // ExampleRepository_Push_artifactReferenceManifest gives an example snippet for pushing a reference manifest. func ExampleRepository_Push_artifactReferenceManifest() { repo, err := remote.NewRepository(fmt.Sprintf("%s/%s", host, exampleRepositoryName)) if err != nil { panic(err) } ctx := context.Background() // 1. assemble the referenced artifact manifest manifest := ocispec.Manifest{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageManifest, Config: content.NewDescriptorFromBytes(ocispec.MediaTypeImageConfig, []byte("config bytes")), } manifestContent, err := json.Marshal(manifest) if err != nil { panic(err) } manifestDescriptor := content.NewDescriptorFromBytes(ocispec.MediaTypeImageManifest, manifestContent) // 2. push the manifest descriptor and content err = repo.Push(ctx, manifestDescriptor, bytes.NewReader(manifestContent)) if err != nil { panic(err) } // 3. assemble the reference artifact manifest referenceManifest := spec.Artifact{ MediaType: spec.MediaTypeArtifactManifest, ArtifactType: "sbom/example", Subject: &manifestDescriptor, } referenceManifestContent, err := json.Marshal(referenceManifest) if err != nil { panic(err) } referenceManifestDescriptor := content.NewDescriptorFromBytes(spec.MediaTypeArtifactManifest, referenceManifestContent) // 4. push the reference manifest descriptor and content err = repo.Push(ctx, referenceManifestDescriptor, bytes.NewReader(referenceManifestContent)) if err != nil { panic(err) } fmt.Println("Push finished") // Output: // Push finished } // ExampleRepository_Resolve_byTag gives example snippets for resolving a tag to a manifest descriptor. func ExampleRepository_Resolve_byTag() { repo, err := remote.NewRepository(fmt.Sprintf("%s/%s", host, exampleRepositoryName)) if err != nil { panic(err) } ctx := context.Background() tag := "latest" descriptor, err := repo.Resolve(ctx, tag) if err != nil { panic(err) } fmt.Println(descriptor.MediaType) fmt.Println(descriptor.Digest) fmt.Println(descriptor.Size) // Output: // application/vnd.oci.image.manifest.v1+json // sha256:b53dc03a49f383ba230d8ac2b78a9c4aec132e4a9f36cc96524df98163202cc7 // 337 } // ExampleRepository_Resolve_byDigest gives example snippets for resolving a digest to a manifest descriptor. func ExampleRepository_Resolve_byDigest() { repo, err := remote.NewRepository(fmt.Sprintf("%s/%s", host, exampleRepositoryName)) if err != nil { panic(err) } ctx := context.Background() exampleDigest := "sha256:b53dc03a49f383ba230d8ac2b78a9c4aec132e4a9f36cc96524df98163202cc7" descriptor, err := repo.Resolve(ctx, exampleDigest) if err != nil { panic(err) } fmt.Println(descriptor.MediaType) fmt.Println(descriptor.Digest) fmt.Println(descriptor.Size) // Output: // application/vnd.oci.image.manifest.v1+json // sha256:b53dc03a49f383ba230d8ac2b78a9c4aec132e4a9f36cc96524df98163202cc7 // 337 } // ExampleRepository_Fetch_byTag gives example snippets for downloading a manifest by tag. func ExampleRepository_Fetch_manifestByTag() { repo, err := remote.NewRepository(fmt.Sprintf("%s/%s", host, exampleRepositoryName)) if err != nil { panic(err) } ctx := context.Background() tag := "latest" descriptor, err := repo.Resolve(ctx, tag) if err != nil { panic(err) } rc, err := repo.Fetch(ctx, descriptor) if err != nil { panic(err) } defer rc.Close() // don't forget to close pulledBlob, err := content.ReadAll(rc, descriptor) if err != nil { panic(err) } fmt.Println(string(pulledBlob)) // Output: // {"schemaVersion":2,"config":{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"sha256:569224ae188c06e97b9fcadaeb2358fb0fb7c4eb105d49aee2620b2719abea43","size":22},"layers":[{"mediaType":"application/vnd.oci.image.layer.v1.tar","digest":"sha256:ef79e47691ad1bc702d7a256da6323ec369a8fc3159b4f1798a47136f3b38c10","size":21}]} } // ExampleRepository_Fetch_manifestByDigest gives example snippets for downloading a manifest by digest. func ExampleRepository_Fetch_manifestByDigest() { repo, err := remote.NewRepository(fmt.Sprintf("%s/%s", host, exampleRepositoryName)) if err != nil { panic(err) } ctx := context.Background() exampleDigest := "sha256:b53dc03a49f383ba230d8ac2b78a9c4aec132e4a9f36cc96524df98163202cc7" // resolve the blob descriptor to obtain the size of the blob descriptor, err := repo.Resolve(ctx, exampleDigest) if err != nil { panic(err) } rc, err := repo.Fetch(ctx, descriptor) if err != nil { panic(err) } defer rc.Close() // don't forget to close pulled, err := content.ReadAll(rc, descriptor) if err != nil { panic(err) } fmt.Println(string(pulled)) // Output: // {"schemaVersion":2,"config":{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"sha256:569224ae188c06e97b9fcadaeb2358fb0fb7c4eb105d49aee2620b2719abea43","size":22},"layers":[{"mediaType":"application/vnd.oci.image.layer.v1.tar","digest":"sha256:ef79e47691ad1bc702d7a256da6323ec369a8fc3159b4f1798a47136f3b38c10","size":21}]} } // ExampleRepository_Fetch_artifactReferenceManifest gives an example of fetching // the referrers of a given manifest by using the Referrers API. func ExampleRepository_Fetch_artifactReferenceManifest() { repo, err := remote.NewRepository(fmt.Sprintf("%s/%s", host, exampleRepositoryName)) if err != nil { panic(err) } ctx := context.Background() // resolve a manifest by tag tag := "latest" descriptor, err := repo.Resolve(ctx, tag) if err != nil { panic(err) } // find its referrers by calling Referrers if err := repo.Referrers(ctx, descriptor, "", func(referrers []ocispec.Descriptor) error { // for each page of the results, do the following: for _, referrer := range referrers { // for each item in this page, pull the manifest and verify its content rc, err := repo.Fetch(ctx, referrer) if err != nil { panic(err) } defer rc.Close() // don't forget to close pulledBlob, err := content.ReadAll(rc, referrer) if err != nil { panic(err) } fmt.Println(string(pulledBlob)) } return nil }); err != nil { panic(err) } // Output: // {"mediaType":"application/vnd.oci.artifact.manifest.v1+json","artifactType":"example/SBoM","subject":{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:b53dc03a49f383ba230d8ac2b78a9c4aec132e4a9f36cc96524df98163202cc7","size":337}} // {"mediaType":"application/vnd.oci.artifact.manifest.v1+json","artifactType":"example/signature","subject":{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:b53dc03a49f383ba230d8ac2b78a9c4aec132e4a9f36cc96524df98163202cc7","size":337}} } // ExampleRepository_fetchArtifactBlobs gives an example of pulling the blobs // of an artifact manifest. func ExampleRepository_fetchArtifactBlobs() { repo, err := remote.NewRepository(fmt.Sprintf("%s/%s", host, exampleRepositoryName)) if err != nil { panic(err) } ctx := context.Background() // 1. Fetch the artifact manifest by digest. exampleDigest := "sha256:f3550fd0947402d140fd0470702abc92c69f7e9b08d5ca2438f42f8a0ea3fd97" descriptor, rc, err := repo.FetchReference(ctx, exampleDigest) if err != nil { panic(err) } defer rc.Close() pulledContent, err := content.ReadAll(rc, descriptor) if err != nil { panic(err) } fmt.Println(string(pulledContent)) // 2. Parse the pulled manifest and fetch its blobs. var pulledManifest spec.Artifact if err := json.Unmarshal(pulledContent, &pulledManifest); err != nil { panic(err) } for _, blob := range pulledManifest.Blobs { content, err := content.FetchAll(ctx, repo, blob) if err != nil { panic(err) } fmt.Println(string(content)) } // Output: // {"mediaType":"application/vnd.oci.artifact.manifest.v1+json","artifactType":"example/manifest","blobs":[{"mediaType":"application/tar","digest":"sha256:8d6497c94694a292c04f85cd055d8b5c03eda835dd311e20dfbbf029ff9748cc","size":20}],"subject":{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:b53dc03a49f383ba230d8ac2b78a9c4aec132e4a9f36cc96524df98163202cc7","size":337}} // example blob content } // ExampleRepository_FetchReference_manifestByTag gives example snippets for downloading a manifest by tag with only one API call. func ExampleRepository_FetchReference_manifestByTag() { repo, err := remote.NewRepository(fmt.Sprintf("%s/%s", host, exampleRepositoryName)) if err != nil { panic(err) } ctx := context.Background() tag := "latest" descriptor, rc, err := repo.FetchReference(ctx, tag) if err != nil { panic(err) } defer rc.Close() // don't forget to close pulledBlob, err := content.ReadAll(rc, descriptor) if err != nil { panic(err) } fmt.Println(string(pulledBlob)) // Output: // {"schemaVersion":2,"config":{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"sha256:569224ae188c06e97b9fcadaeb2358fb0fb7c4eb105d49aee2620b2719abea43","size":22},"layers":[{"mediaType":"application/vnd.oci.image.layer.v1.tar","digest":"sha256:ef79e47691ad1bc702d7a256da6323ec369a8fc3159b4f1798a47136f3b38c10","size":21}]} } // ExampleRepository_FetchReference_manifestByDigest gives example snippets for downloading a manifest by digest. func ExampleRepository_FetchReference_manifestByDigest() { repo, err := remote.NewRepository(fmt.Sprintf("%s/%s", host, exampleRepositoryName)) if err != nil { panic(err) } ctx := context.Background() exampleDigest := "sha256:b53dc03a49f383ba230d8ac2b78a9c4aec132e4a9f36cc96524df98163202cc7" descriptor, rc, err := repo.FetchReference(ctx, exampleDigest) if err != nil { panic(err) } defer rc.Close() // don't forget to close pulled, err := content.ReadAll(rc, descriptor) if err != nil { panic(err) } fmt.Println(string(pulled)) // Output: // {"schemaVersion":2,"config":{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"sha256:569224ae188c06e97b9fcadaeb2358fb0fb7c4eb105d49aee2620b2719abea43","size":22},"layers":[{"mediaType":"application/vnd.oci.image.layer.v1.tar","digest":"sha256:ef79e47691ad1bc702d7a256da6323ec369a8fc3159b4f1798a47136f3b38c10","size":21}]} } // ExampleRepository_Fetch_layer gives example snippets for downloading a layer blob by digest. func ExampleRepository_Fetch_layer() { repo, err := remote.NewRepository(fmt.Sprintf("%s/%s", host, exampleRepositoryName)) if err != nil { panic(err) } ctx := context.Background() descriptor, err := repo.Blobs().Resolve(ctx, exampleLayerDigest) if err != nil { panic(err) } rc, err := repo.Fetch(ctx, descriptor) if err != nil { panic(err) } defer rc.Close() // don't forget to close // option 1: sequential fetch pulledBlob, err := content.ReadAll(rc, descriptor) if err != nil { panic(err) } fmt.Println(string(pulledBlob)) // option 2: random access, if the remote registry supports if seeker, ok := rc.(io.ReadSeeker); ok { offset := int64(8) _, err = seeker.Seek(offset, io.SeekStart) if err != nil { panic(err) } pulledBlob, err := io.ReadAll(rc) if err != nil { panic(err) } if descriptor.Size-offset != int64(len(pulledBlob)) { panic("wrong content") } fmt.Println(string(pulledBlob)) } // Output: // Example layer content // layer content } // ExampleRepository_Tag gives example snippets for tagging a descriptor. func ExampleRepository_Tag() { repo, err := remote.NewRepository(fmt.Sprintf("%s/%s", host, exampleRepositoryName)) if err != nil { panic(err) } ctx := context.Background() exampleDigest := "sha256:b53dc03a49f383ba230d8ac2b78a9c4aec132e4a9f36cc96524df98163202cc7" descriptor, err := repo.Resolve(ctx, exampleDigest) if err != nil { panic(err) } tag := "latest" err = repo.Tag(ctx, descriptor, tag) if err != nil { panic(err) } fmt.Println("Succeed") // Output: // Succeed } // ExampleRegistry_Repositories gives example snippets for listing respositories in a HTTPS registry with pagination. func ExampleRegistry_Repositories() { reg, err := remote.NewRegistry(host) if err != nil { panic(err) } // Override the `host` variable to play with local registry. // Uncomment below line to reset HTTP option: // reg.PlainHTTP = true ctx := context.Background() err = reg.Repositories(ctx, "", func(repos []string) error { for _, repo := range repos { fmt.Println(repo) } return nil }) if err != nil { panic(err) } // Output: // public/repo1 // public/repo2 // internal/repo3 } func Example_pullByTag() { repo, err := remote.NewRepository(fmt.Sprintf("%s/%s", host, exampleRepositoryName)) if err != nil { panic(err) } ctx := context.Background() // 1. resolve the descriptor tag := "latest" descriptor, err := repo.Resolve(ctx, tag) if err != nil { panic(err) } fmt.Println(descriptor.Digest) fmt.Println(descriptor.Size) // 2. fetch the content byte[] from the repository pulledBlob, err := content.FetchAll(ctx, repo, descriptor) if err != nil { panic(err) } fmt.Println(string(pulledBlob)) // Output: // sha256:b53dc03a49f383ba230d8ac2b78a9c4aec132e4a9f36cc96524df98163202cc7 // 337 // {"schemaVersion":2,"config":{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"sha256:569224ae188c06e97b9fcadaeb2358fb0fb7c4eb105d49aee2620b2719abea43","size":22},"layers":[{"mediaType":"application/vnd.oci.image.layer.v1.tar","digest":"sha256:ef79e47691ad1bc702d7a256da6323ec369a8fc3159b4f1798a47136f3b38c10","size":21}]} } func Example_pullByDigest() { repo, err := remote.NewRepository(fmt.Sprintf("%s/%s", host, exampleRepositoryName)) if err != nil { panic(err) } ctx := context.Background() exampleDigest := "sha256:b53dc03a49f383ba230d8ac2b78a9c4aec132e4a9f36cc96524df98163202cc7" // 1. resolve the descriptor descriptor, err := repo.Resolve(ctx, exampleDigest) if err != nil { panic(err) } fmt.Println(descriptor.Digest) fmt.Println(descriptor.Size) // 2. fetch the content byte[] from the repository pulledBlob, err := content.FetchAll(ctx, repo, descriptor) if err != nil { panic(err) } fmt.Println(string(pulledBlob)) // Output: // sha256:b53dc03a49f383ba230d8ac2b78a9c4aec132e4a9f36cc96524df98163202cc7 // 337 // {"schemaVersion":2,"config":{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"sha256:569224ae188c06e97b9fcadaeb2358fb0fb7c4eb105d49aee2620b2719abea43","size":22},"layers":[{"mediaType":"application/vnd.oci.image.layer.v1.tar","digest":"sha256:ef79e47691ad1bc702d7a256da6323ec369a8fc3159b4f1798a47136f3b38c10","size":21}]} } func Example_handleWarning() { repo, err := remote.NewRepository(fmt.Sprintf("%s/%s", host, exampleRepositoryName)) if err != nil { panic(err) } // 1. specify HandleWarning repo.HandleWarning = func(warning remote.Warning) { fmt.Printf("Warning from %s: %s\n", repo.Reference.Repository, warning.Text) } ctx := context.Background() exampleDigest := "sha256:b53dc03a49f383ba230d8ac2b78a9c4aec132e4a9f36cc96524df98163202cc7" // 2. resolve the descriptor descriptor, err := repo.Resolve(ctx, exampleDigest) if err != nil { panic(err) } fmt.Println(descriptor.Digest) fmt.Println(descriptor.Size) // 3. fetch the content byte[] from the repository pulledBlob, err := content.FetchAll(ctx, repo, descriptor) if err != nil { panic(err) } fmt.Println(string(pulledBlob)) // Output: // Warning from example: This image is deprecated and will be removed soon. // sha256:b53dc03a49f383ba230d8ac2b78a9c4aec132e4a9f36cc96524df98163202cc7 // 337 // Warning from example: This image is deprecated and will be removed soon. // {"schemaVersion":2,"config":{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"sha256:569224ae188c06e97b9fcadaeb2358fb0fb7c4eb105d49aee2620b2719abea43","size":22},"layers":[{"mediaType":"application/vnd.oci.image.layer.v1.tar","digest":"sha256:ef79e47691ad1bc702d7a256da6323ec369a8fc3159b4f1798a47136f3b38c10","size":21}]} } // Example_pushAndTag gives example snippet of pushing an OCI image with a tag. func Example_pushAndTag() { repo, err := remote.NewRepository(fmt.Sprintf("%s/%s", host, exampleRepositoryName)) if err != nil { panic(err) } ctx := context.Background() // Assemble the below OCI image, push and tag it // +---------------------------------------------------+ // | +----------------+ | // | +--> "Hello Config" | | // | +-------------+ | +---+ Config +---+ | // | (latest)+--> ... +--+ | // | ++ Manifest ++ | +----------------+ | // | +--> "Hello Layer" | | // | +---+ Layer +---+ | // | | // +--------+ localhost:5000/example/registry +--------+ generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) ([]byte, error) { content := ocispec.Manifest{ Config: config, Layers: layers, Versioned: specs.Versioned{SchemaVersion: 2}, } return json.Marshal(content) } // 1. assemble descriptors and manifest layerBlob := []byte("Hello layer") layerDesc := content.NewDescriptorFromBytes(ocispec.MediaTypeImageLayer, layerBlob) configBlob := []byte("Hello config") configDesc := content.NewDescriptorFromBytes(ocispec.MediaTypeImageConfig, configBlob) manifestBlob, err := generateManifest(configDesc, layerDesc) if err != nil { panic(err) } manifestDesc := content.NewDescriptorFromBytes(ocispec.MediaTypeImageManifest, manifestBlob) // 2. push and tag err = repo.Push(ctx, layerDesc, bytes.NewReader(layerBlob)) if err != nil { panic(err) } err = repo.Push(ctx, configDesc, bytes.NewReader(configBlob)) if err != nil { panic(err) } err = repo.PushReference(ctx, manifestDesc, bytes.NewReader(manifestBlob), "latest") if err != nil { panic(err) } fmt.Println("Succeed") // Output: // Succeed } // Example_tagReference gives example snippets for tagging // a manifest. func Example_tagReference() { reg, err := remote.NewRegistry(host) if err != nil { panic(err) } ctx := context.Background() repo, err := reg.Repository(ctx, exampleRepositoryName) if err != nil { panic(err) } // tag a manifest referenced by the exampleDigest below exampleDigest := "sha256:b53dc03a49f383ba230d8ac2b78a9c4aec132e4a9f36cc96524df98163202cc7" tag := "latest" desc, err := oras.Tag(ctx, repo, exampleDigest, tag) if err != nil { panic(err) } fmt.Println("Tagged", desc.Digest, "as", tag) // Output: // Tagged sha256:b53dc03a49f383ba230d8ac2b78a9c4aec132e4a9f36cc96524df98163202cc7 as latest } // Example_pushAndIgnoreReferrersIndexError gives example snippets on how to // ignore referrer index deletion error during push a referrer manifest. func Example_pushAndIgnoreReferrersIndexError() { repo, err := remote.NewRepository(fmt.Sprintf("%s/%s", host, referrersAPIUnavailableRepositoryName)) if err != nil { panic(err) } ctx := context.Background() // push a referrer manifest and ignore cleaning up error err = repo.Push(ctx, referrerDescriptor, bytes.NewReader(referrerManifestContent)) if err != nil { var re *remote.ReferrersError if !errors.As(err, &re) || !re.IsReferrersIndexDelete() { panic(err) } fmt.Println("ignoring error occurred during cleaning obsolete referrers index") } fmt.Println("Push finished") // Output: // ignoring error occurred during cleaning obsolete referrers index // Push finished } oras-go-2.5.0/registry/remote/interface_test.go000066400000000000000000000022371457674530300216000ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package remote_test import ( "testing" "oras.land/oras-go/v2" "oras.land/oras-go/v2/internal/interfaces" "oras.land/oras-go/v2/registry" "oras.land/oras-go/v2/registry/remote" ) func TestRepositoryInterface(t *testing.T) { var repo interface{} = &remote.Repository{} if _, ok := repo.(registry.Repository); !ok { t.Error("&Repository{} does not conform registry.Repository") } if _, ok := repo.(oras.GraphTarget); !ok { t.Error("&Repository{} does not conform oras.GraphTarget") } if _, ok := repo.(interfaces.ReferenceParser); !ok { t.Error("&Repository{} does not conform interfaces.ReferenceParser") } } oras-go-2.5.0/registry/remote/internal/000077500000000000000000000000001457674530300200625ustar00rootroot00000000000000oras-go-2.5.0/registry/remote/internal/errutil/000077500000000000000000000000001457674530300215505ustar00rootroot00000000000000oras-go-2.5.0/registry/remote/internal/errutil/errutil.go000066400000000000000000000031171457674530300235670ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package errutil import ( "encoding/json" "errors" "io" "net/http" "oras.land/oras-go/v2/registry/remote/errcode" ) // maxErrorBytes specifies the default limit on how many response bytes are // allowed in the server's error response. // A typical error message is around 200 bytes. Hence, 8 KiB should be // sufficient. const maxErrorBytes int64 = 8 * 1024 // 8 KiB // ParseErrorResponse parses the error returned by the remote registry. func ParseErrorResponse(resp *http.Response) error { resultErr := &errcode.ErrorResponse{ Method: resp.Request.Method, URL: resp.Request.URL, StatusCode: resp.StatusCode, } var body struct { Errors errcode.Errors `json:"errors"` } lr := io.LimitReader(resp.Body, maxErrorBytes) if err := json.NewDecoder(lr).Decode(&body); err == nil { resultErr.Errors = body.Errors } return resultErr } // IsErrorCode returns true if err is an Error and its Code equals to code. func IsErrorCode(err error, code string) bool { var ec errcode.Error return errors.As(err, &ec) && ec.Code == code } oras-go-2.5.0/registry/remote/internal/errutil/errutil_test.go000066400000000000000000000151321457674530300246260ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package errutil import ( "errors" "net/http" "net/http/httptest" "strings" "testing" "oras.land/oras-go/v2/registry/remote/errcode" ) func Test_ParseErrorResponse(t *testing.T) { path := "/test" expectedErrs := errcode.Errors{ { Code: "UNAUTHORIZED", Message: "authentication required", }, { Code: "NAME_UNKNOWN", Message: "repository name not known to registry", }, } ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case path: msg := `{ "errors": [ { "code": "UNAUTHORIZED", "message": "authentication required", "detail": [ { "Type": "repository", "Class": "", "Name": "library/hello-world", "Action": "pull" } ] }, { "code": "NAME_UNKNOWN", "message": "repository name not known to registry" } ] }` w.WriteHeader(http.StatusUnauthorized) if _, err := w.Write([]byte(msg)); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() resp, err := http.Get(ts.URL + path) if err != nil { t.Fatalf("failed to do request: %v", err) } err = ParseErrorResponse(resp) if err == nil { t.Errorf("ParseErrorResponse() error = %v, wantErr %v", err, true) } var errResp *errcode.ErrorResponse if ok := errors.As(err, &errResp); !ok { t.Errorf("errors.As(err, &UnexpectedStatusCodeError) = %v, want %v", ok, true) } if want := http.MethodGet; errResp.Method != want { t.Errorf("ParseErrorResponse() Method = %v, want Method %v", errResp.Method, want) } if want := http.StatusUnauthorized; errResp.StatusCode != want { t.Errorf("ParseErrorResponse() StatusCode = %v, want StatusCode %v", errResp.StatusCode, want) } if want := path; errResp.URL.Path != want { t.Errorf("ParseErrorResponse() URL = %v, want URL %v", errResp.URL.Path, want) } for i, e := range errResp.Errors { if want := expectedErrs[i].Code; e.Code != expectedErrs[i].Code { t.Errorf("ParseErrorResponse() Code = %v, want Code %v", e.Code, want) } if want := expectedErrs[i].Message; e.Message != want { t.Errorf("ParseErrorResponse() Message = %v, want Code %v", e.Code, want) } } errmsg := err.Error() if want := "401"; !strings.Contains(errmsg, want) { t.Errorf("ParseErrorResponse() error = %v, want err message %v", err, want) } // first error if want := "unauthorized"; !strings.Contains(errmsg, want) { t.Errorf("ParseErrorResponse() error = %v, want err message %v", err, want) } if want := "authentication required"; !strings.Contains(errmsg, want) { t.Errorf("ParseErrorResponse() error = %v, want err message %v", err, want) } // second error if want := "name unknown"; !strings.Contains(errmsg, want) { t.Errorf("ParseErrorResponse() error = %v, want err message %v", err, want) } if want := "repository name not known to registry"; !strings.Contains(errmsg, want) { t.Errorf("ParseErrorResponse() error = %v, want err message %v", err, want) } } func Test_ParseErrorResponse_plain(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) })) defer ts.Close() resp, err := http.Get(ts.URL) if err != nil { t.Fatalf("failed to do request: %v", err) } err = ParseErrorResponse(resp) if err == nil { t.Errorf("ParseErrorResponse() error = %v, wantErr %v", err, true) } errmsg := err.Error() if want := "401"; !strings.Contains(errmsg, want) { t.Errorf("ParseErrorResponse() error = %v, want err message %v", err, want) } if want := http.StatusText(http.StatusUnauthorized); !strings.Contains(errmsg, want) { t.Errorf("ParseErrorResponse() error = %v, want err message %v", err, want) } } func TestIsErrorCode(t *testing.T) { tests := []struct { name string err error code string want bool }{ { name: "test errcode.Error, same code", err: errcode.Error{ Code: errcode.ErrorCodeNameUnknown, }, code: errcode.ErrorCodeNameUnknown, want: true, }, { name: "test errcode.Error, different code", err: errcode.Error{ Code: errcode.ErrorCodeUnauthorized, }, code: errcode.ErrorCodeNameUnknown, want: false, }, { name: "test errcode.Errors containing single error, same code", err: errcode.Errors{ { Code: errcode.ErrorCodeNameUnknown, }, }, code: errcode.ErrorCodeNameUnknown, want: true, }, { name: "test errcode.Errors containing single error, different code", err: errcode.Errors{ { Code: errcode.ErrorCodeNameUnknown, }, }, code: errcode.ErrorCodeNameUnknown, want: true, }, { name: "test errcode.Errors containing multiple errors, same code", err: errcode.Errors{ { Code: errcode.ErrorCodeNameUnknown, }, { Code: errcode.ErrorCodeUnauthorized, }, }, code: errcode.ErrorCodeNameUnknown, want: false, }, { name: "test errcode.ErrorResponse containing single error, same code", err: &errcode.ErrorResponse{ Errors: errcode.Errors{ { Code: errcode.ErrorCodeNameUnknown, }, }, }, code: errcode.ErrorCodeNameUnknown, want: true, }, { name: "test errcode.ErrorResponse containing single error, different code", err: &errcode.ErrorResponse{ Errors: errcode.Errors{ { Code: errcode.ErrorCodeUnauthorized, }, }, }, code: errcode.ErrorCodeNameUnknown, want: false, }, { name: "test errcode.ErrorResponse containing multiple errors, same code", err: &errcode.ErrorResponse{ Errors: errcode.Errors{ { Code: errcode.ErrorCodeNameUnknown, }, { Code: errcode.ErrorCodeUnauthorized, }, }, }, code: errcode.ErrorCodeNameUnknown, want: false, }, { name: "test unstructured error", err: errors.New(errcode.ErrorCodeNameUnknown), code: errcode.ErrorCodeNameUnknown, want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := IsErrorCode(tt.err, tt.code); got != tt.want { t.Errorf("IsErrorCode() = %v, want %v", got, tt.want) } }) } } oras-go-2.5.0/registry/remote/manifest.go000066400000000000000000000034741457674530300204130ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package remote import ( "strings" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/internal/docker" "oras.land/oras-go/v2/internal/spec" ) // defaultManifestMediaTypes contains the default set of manifests media types. var defaultManifestMediaTypes = []string{ docker.MediaTypeManifest, docker.MediaTypeManifestList, ocispec.MediaTypeImageManifest, ocispec.MediaTypeImageIndex, spec.MediaTypeArtifactManifest, } // defaultManifestAcceptHeader is the default set in the `Accept` header for // resolving manifests from tags. var defaultManifestAcceptHeader = strings.Join(defaultManifestMediaTypes, ", ") // isManifest determines if the given descriptor points to a manifest. func isManifest(manifestMediaTypes []string, desc ocispec.Descriptor) bool { if len(manifestMediaTypes) == 0 { manifestMediaTypes = defaultManifestMediaTypes } for _, mediaType := range manifestMediaTypes { if desc.MediaType == mediaType { return true } } return false } // manifestAcceptHeader generates the set in the `Accept` header for resolving // manifests from tags. func manifestAcceptHeader(manifestMediaTypes []string) string { if len(manifestMediaTypes) == 0 { return defaultManifestAcceptHeader } return strings.Join(manifestMediaTypes, ", ") } oras-go-2.5.0/registry/remote/referrers.go000066400000000000000000000147421457674530300206040ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package remote import ( "errors" "strings" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/internal/descriptor" ) // zeroDigest represents a digest that consists of zeros. zeroDigest is used // for pinging Referrers API. const zeroDigest = "sha256:0000000000000000000000000000000000000000000000000000000000000000" // referrersState represents the state of Referrers API. type referrersState = int32 const ( // referrersStateUnknown represents an unknown state of Referrers API. referrersStateUnknown referrersState = iota // referrersStateSupported represents that the repository is known to // support Referrers API. referrersStateSupported // referrersStateUnsupported represents that the repository is known to // not support Referrers API. referrersStateUnsupported ) // referrerOperation represents an operation on a referrer. type referrerOperation = int32 const ( // referrerOperationAdd represents an addition operation on a referrer. referrerOperationAdd referrerOperation = iota // referrerOperationRemove represents a removal operation on a referrer. referrerOperationRemove ) // referrerChange represents a change on a referrer. type referrerChange struct { referrer ocispec.Descriptor operation referrerOperation } var ( // ErrReferrersCapabilityAlreadySet is returned by SetReferrersCapability() // when the Referrers API capability has been already set. ErrReferrersCapabilityAlreadySet = errors.New("referrers capability cannot be changed once set") // errNoReferrerUpdate is returned by applyReferrerChanges() when there // is no any referrer update. errNoReferrerUpdate = errors.New("no referrer update") ) const ( // opDeleteReferrersIndex represents the operation for deleting a // referrers index. opDeleteReferrersIndex = "DeleteReferrersIndex" ) // ReferrersError records an error and the operation and the subject descriptor. type ReferrersError struct { // Op represents the failing operation. Op string // Subject is the descriptor of referenced artifact. Subject ocispec.Descriptor // Err is the entity of referrers error. Err error } // Error returns error msg of IgnorableError. func (e *ReferrersError) Error() string { return e.Err.Error() } // Unwrap returns the inner error of IgnorableError. func (e *ReferrersError) Unwrap() error { return errors.Unwrap(e.Err) } // IsIndexDelete tells if e is kind of error related to referrers // index deletion. func (e *ReferrersError) IsReferrersIndexDelete() bool { return e.Op == opDeleteReferrersIndex } // buildReferrersTag builds the referrers tag for the given manifest descriptor. // Format: - // Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#unavailable-referrers-api func buildReferrersTag(desc ocispec.Descriptor) string { alg := desc.Digest.Algorithm().String() encoded := desc.Digest.Encoded() return alg + "-" + encoded } // isReferrersFilterApplied checks if requsted is in the applied filter list. func isReferrersFilterApplied(applied, requested string) bool { if applied == "" || requested == "" { return false } filters := strings.Split(applied, ",") for _, f := range filters { if f == requested { return true } } return false } // filterReferrers filters a slice of referrers by artifactType in place. // The returned slice contains matching referrers. func filterReferrers(refs []ocispec.Descriptor, artifactType string) []ocispec.Descriptor { if artifactType == "" { return refs } var j int for i, ref := range refs { if ref.ArtifactType == artifactType { if i != j { refs[j] = ref } j++ } } return refs[:j] } // applyReferrerChanges applies referrerChanges on referrers and returns the // updated referrers. // Returns errNoReferrerUpdate if there is no any referrers updates. func applyReferrerChanges(referrers []ocispec.Descriptor, referrerChanges []referrerChange) ([]ocispec.Descriptor, error) { referrersMap := make(map[descriptor.Descriptor]int, len(referrers)+len(referrerChanges)) updatedReferrers := make([]ocispec.Descriptor, 0, len(referrers)+len(referrerChanges)) var updateRequired bool for _, r := range referrers { if content.Equal(r, ocispec.Descriptor{}) { // skip bad entry updateRequired = true continue } key := descriptor.FromOCI(r) if _, ok := referrersMap[key]; ok { // skip duplicates updateRequired = true continue } updatedReferrers = append(updatedReferrers, r) referrersMap[key] = len(updatedReferrers) - 1 } // apply changes for _, change := range referrerChanges { key := descriptor.FromOCI(change.referrer) switch change.operation { case referrerOperationAdd: if _, ok := referrersMap[key]; !ok { // add distinct referrers updatedReferrers = append(updatedReferrers, change.referrer) referrersMap[key] = len(updatedReferrers) - 1 } case referrerOperationRemove: if pos, ok := referrersMap[key]; ok { // remove referrers that are already in the map updatedReferrers[pos] = ocispec.Descriptor{} delete(referrersMap, key) } } } // skip unnecessary update if !updateRequired && len(referrersMap) == len(referrers) { // if the result referrer map contains the same content as the // original referrers, consider that there is no update on the // referrers. for _, r := range referrers { key := descriptor.FromOCI(r) if _, ok := referrersMap[key]; !ok { updateRequired = true } } if !updateRequired { return nil, errNoReferrerUpdate } } return removeEmptyDescriptors(updatedReferrers, len(referrersMap)), nil } // removeEmptyDescriptors in-place removes empty items from descs, given a hint // of the number of non-empty descriptors. func removeEmptyDescriptors(descs []ocispec.Descriptor, hint int) []ocispec.Descriptor { j := 0 for i, r := range descs { if !content.Equal(r, ocispec.Descriptor{}) { if i > j { descs[j] = r } j++ } if j == hint { break } } return descs[:j] } oras-go-2.5.0/registry/remote/referrers_test.go000066400000000000000000000355671457674530300216530ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package remote import ( "reflect" "testing" "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/internal/spec" ) func Test_buildReferrersTag(t *testing.T) { tests := []struct { name string desc ocispec.Descriptor want string }{ { name: "zero digest", desc: ocispec.Descriptor{ Digest: "sha256:0000000000000000000000000000000000000000000000000000000000000000", }, want: "sha256-0000000000000000000000000000000000000000000000000000000000000000", }, { name: "sha256", desc: ocispec.Descriptor{ Digest: "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", }, want: "sha256-9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", }, { name: "sha512", desc: ocispec.Descriptor{ Digest: "sha512:ee26b0dd4af7e749aa1a8ee3c10ae9923f618980772e473f8819a5d4940e0db27ac185f8a0e1d5f84f88bc887fd67b143732c304cc5fa9ad8e6f57f50028a8ff", }, want: "sha512-ee26b0dd4af7e749aa1a8ee3c10ae9923f618980772e473f8819a5d4940e0db27ac185f8a0e1d5f84f88bc887fd67b143732c304cc5fa9ad8e6f57f50028a8ff", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := buildReferrersTag(tt.desc); got != tt.want { t.Errorf("getReferrersTag() = %v, want %v", got, tt.want) } }) } } func Test_isReferrersFilterApplied(t *testing.T) { tests := []struct { name string applied string requested string want bool }{ { name: "single filter applied, specified filter matches", applied: "artifactType", requested: "artifactType", want: true, }, { name: "single filter applied, specified filter does not match", applied: "foo", requested: "artifactType", want: false, }, { name: "multiple filters applied, specified filter matches", applied: "foo,artifactType", requested: "artifactType", want: true, }, { name: "multiple filters applied, specified filter does not match", applied: "foo,bar", requested: "artifactType", want: false, }, { name: "single filter applied, no specified filter", applied: "foo", requested: "", want: false, }, { name: "no filter applied, specified filter does not match", applied: "", requested: "artifactType", want: false, }, { name: "no filter applied, no specified filter", applied: "", requested: "", want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := isReferrersFilterApplied(tt.applied, tt.requested); got != tt.want { t.Errorf("isReferrersFilterApplied() = %v, want %v", got, tt.want) } }) } } func Test_filterReferrers(t *testing.T) { refs := []ocispec.Descriptor{ { MediaType: spec.MediaTypeArtifactManifest, Size: 1, Digest: digest.FromString("1"), ArtifactType: "application/vnd.test", }, { MediaType: spec.MediaTypeArtifactManifest, Size: 2, Digest: digest.FromString("2"), ArtifactType: "application/vnd.foo", }, { MediaType: spec.MediaTypeArtifactManifest, Size: 3, Digest: digest.FromString("3"), ArtifactType: "application/vnd.bar", }, { MediaType: spec.MediaTypeArtifactManifest, Size: 4, Digest: digest.FromString("4"), ArtifactType: "application/vnd.test", }, { MediaType: spec.MediaTypeArtifactManifest, Size: 5, Digest: digest.FromString("5"), ArtifactType: "application/vnd.baz", }, } got := filterReferrers(refs, "application/vnd.test") want := []ocispec.Descriptor{ { MediaType: spec.MediaTypeArtifactManifest, Size: 1, Digest: digest.FromString("1"), ArtifactType: "application/vnd.test", }, { MediaType: spec.MediaTypeArtifactManifest, Size: 4, Digest: digest.FromString("4"), ArtifactType: "application/vnd.test", }, } if !reflect.DeepEqual(got, want) { t.Errorf("filterReferrers() = %v, want %v", got, want) } } func Test_filterReferrers_allMatch(t *testing.T) { refs := []ocispec.Descriptor{ { MediaType: spec.MediaTypeArtifactManifest, Size: 1, Digest: digest.FromString("1"), ArtifactType: "application/vnd.test", }, { MediaType: spec.MediaTypeArtifactManifest, Size: 4, Digest: digest.FromString("2"), ArtifactType: "application/vnd.test", }, { MediaType: spec.MediaTypeArtifactManifest, Size: 5, Digest: digest.FromString("3"), ArtifactType: "application/vnd.test", }, } got := filterReferrers(refs, "application/vnd.test") if !reflect.DeepEqual(got, refs) { t.Errorf("filterReferrers() = %v, want %v", got, refs) } } func Test_applyReferrerChanges(t *testing.T) { descs := []ocispec.Descriptor{ { MediaType: ocispec.MediaTypeDescriptor, Digest: "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae", Size: 3, ArtifactType: "foo", Annotations: map[string]string{"name": "foo"}, }, { MediaType: ocispec.MediaTypeDescriptor, Digest: "sha256:fcde2b2edba56bf408601fb721fe9b5c338d10ee429ea04fae5511b68fbf8fb9", Size: 3, ArtifactType: "bar", Annotations: map[string]string{"name": "bar"}, }, { MediaType: ocispec.MediaTypeDescriptor, Digest: "sha256:baa5a0964d3320fbc0c6a922140453c8513ea24ab8fd0577034804a967248096", Size: 3, ArtifactType: "baz", Annotations: map[string]string{"name": "baz"}, }, { MediaType: ocispec.MediaTypeDescriptor, Digest: "sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", Size: 5, ArtifactType: "hello", Annotations: map[string]string{"name": "hello"}, }, { MediaType: ocispec.MediaTypeDescriptor, Digest: "sha256:82e35a63ceba37e9646434c5dd412ea577147f1e4a41ccde1614253187e3dbf9", Size: 7, ArtifactType: "goodbye", Annotations: map[string]string{"name": "goodbye"}, }, } tests := []struct { name string referrers []ocispec.Descriptor referrerChanges []referrerChange want []ocispec.Descriptor wantErr error }{ { name: "add to an empty list", referrers: []ocispec.Descriptor{}, referrerChanges: []referrerChange{ {descs[0], referrerOperationAdd}, // add new {descs[1], referrerOperationAdd}, // add new {descs[2], referrerOperationAdd}, // add new }, want: []ocispec.Descriptor{ descs[0], descs[1], descs[2], }, wantErr: nil, }, { name: "add to a non-empty list", referrers: []ocispec.Descriptor{ descs[0], descs[1], }, referrerChanges: []referrerChange{ {descs[2], referrerOperationAdd}, // add new {descs[1], referrerOperationAdd}, // add existing {descs[1], referrerOperationAdd}, // add duplicate existing {descs[3], referrerOperationAdd}, // add new {descs[2], referrerOperationAdd}, // add duplicate new }, want: []ocispec.Descriptor{ descs[0], descs[1], descs[2], descs[3], }, wantErr: nil, }, { name: "partially remove", referrers: []ocispec.Descriptor{ descs[0], descs[1], descs[2], }, referrerChanges: []referrerChange{ {descs[2], referrerOperationRemove}, // remove existing {descs[1], referrerOperationRemove}, // remove existing {descs[3], referrerOperationRemove}, // remove non-existing {descs[2], referrerOperationRemove}, // remove duplicate existing {descs[4], referrerOperationRemove}, // remove non-existing }, want: []ocispec.Descriptor{ descs[0], }, wantErr: nil, }, { name: "remove all", referrers: []ocispec.Descriptor{ descs[0], descs[1], descs[2], }, referrerChanges: []referrerChange{ {descs[2], referrerOperationRemove}, // remove existing {descs[0], referrerOperationRemove}, // remove existing {descs[1], referrerOperationRemove}, // remove existing }, want: []ocispec.Descriptor{}, wantErr: nil, }, { name: "add a new one and remove it", referrers: []ocispec.Descriptor{ descs[0], descs[1], descs[2], }, referrerChanges: []referrerChange{ {descs[1], referrerOperationAdd}, // add existing {descs[3], referrerOperationAdd}, // add new {descs[3], referrerOperationAdd}, // add duplicate new {descs[3], referrerOperationRemove}, // remove new {descs[4], referrerOperationAdd}, // add new }, want: []ocispec.Descriptor{ descs[0], descs[1], descs[2], descs[4], }, wantErr: nil, }, { name: "remove a new one and add it back", referrers: []ocispec.Descriptor{ descs[0], descs[1], descs[2], }, referrerChanges: []referrerChange{ {descs[1], referrerOperationAdd}, // add existing {descs[3], referrerOperationAdd}, // add new {descs[3], referrerOperationRemove}, // remove new, {descs[3], referrerOperationAdd}, // add new back {descs[4], referrerOperationAdd}, // add new }, want: []ocispec.Descriptor{ descs[0], descs[1], descs[2], descs[3], descs[4], }, wantErr: nil, }, { name: "remove an existing one and add it back", referrers: []ocispec.Descriptor{ descs[0], descs[1], descs[2], }, referrerChanges: []referrerChange{ {descs[2], referrerOperationRemove}, // remove existing {descs[3], referrerOperationAdd}, // add new {descs[2], referrerOperationAdd}, // add existing back }, want: []ocispec.Descriptor{ descs[0], descs[1], descs[3], descs[2], }, wantErr: nil, }, { name: "list containing duplicate entries", referrers: []ocispec.Descriptor{ descs[0], descs[1], descs[0], // duplicate descs[2], descs[3], descs[1], // duplicate }, referrerChanges: []referrerChange{ {descs[2], referrerOperationAdd}, // add new {descs[2], referrerOperationAdd}, // add duplicate new {descs[3], referrerOperationRemove}, // remove existing }, want: []ocispec.Descriptor{ descs[0], descs[1], descs[2], }, wantErr: nil, }, { name: "list containing bad entries", referrers: []ocispec.Descriptor{ descs[0], {}, descs[1], }, referrerChanges: []referrerChange{ {descs[2], referrerOperationAdd}, // add new {descs[1], referrerOperationRemove}, // remove existing }, want: []ocispec.Descriptor{ descs[0], descs[2], }, wantErr: nil, }, { name: "no update: same order", referrers: []ocispec.Descriptor{ descs[0], descs[1], descs[2], }, referrerChanges: []referrerChange{ {descs[3], referrerOperationAdd}, // add new {descs[2], referrerOperationRemove}, // remove existing {descs[4], referrerOperationAdd}, // add new {descs[4], referrerOperationRemove}, // remove new {descs[2], referrerOperationAdd}, // add existing back {descs[3], referrerOperationRemove}, // remove new }, want: nil, wantErr: errNoReferrerUpdate, }, { name: "no update: different order", referrers: []ocispec.Descriptor{ descs[0], descs[1], descs[2], }, referrerChanges: []referrerChange{ {descs[2], referrerOperationRemove}, // remove existing {descs[0], referrerOperationRemove}, // remove existing {descs[0], referrerOperationAdd}, // add existing back {descs[2], referrerOperationAdd}, // add existing back }, want: nil, wantErr: errNoReferrerUpdate, // internal result: 2, 1, 0 }, { name: "no update: list containing duplicate entries", referrers: []ocispec.Descriptor{ descs[0], descs[1], descs[0], // duplicate descs[2], descs[1], // duplicate }, referrerChanges: []referrerChange{ {descs[2], referrerOperationRemove}, // remove existing {descs[0], referrerOperationRemove}, // remove existing {descs[0], referrerOperationAdd}, // add existing back {descs[2], referrerOperationAdd}, // add existing back }, want: []ocispec.Descriptor{ descs[1], descs[0], descs[2], }, wantErr: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := applyReferrerChanges(tt.referrers, tt.referrerChanges) if err != tt.wantErr { t.Errorf("applyReferrerChanges() error = %v, wantErr %v", err, tt.wantErr) } if !reflect.DeepEqual(got, tt.want) { t.Errorf("applyReferrerChanges() = %v, want %v", got, tt.want) } }) } } func Test_removeEmptyDescriptors(t *testing.T) { descs := []ocispec.Descriptor{ { MediaType: ocispec.MediaTypeDescriptor, Digest: "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae", Size: 3, ArtifactType: "foo", Annotations: map[string]string{"name": "foo"}, }, { MediaType: ocispec.MediaTypeDescriptor, Digest: "sha256:fcde2b2edba56bf408601fb721fe9b5c338d10ee429ea04fae5511b68fbf8fb9", Size: 3, ArtifactType: "bar", Annotations: map[string]string{"name": "bar"}, }, { MediaType: ocispec.MediaTypeDescriptor, Digest: "sha256:baa5a0964d3320fbc0c6a922140453c8513ea24ab8fd0577034804a967248096", Size: 3, ArtifactType: "baz", Annotations: map[string]string{"name": "baz"}, }, } tests := []struct { name string descs []ocispec.Descriptor hint int want []ocispec.Descriptor }{ { name: "empty list", descs: []ocispec.Descriptor{}, hint: 0, want: []ocispec.Descriptor{}, }, { name: "all non-empty", descs: descs, hint: len(descs), want: descs, }, { name: "all empty", descs: []ocispec.Descriptor{ {}, {}, {}, }, hint: 0, want: []ocispec.Descriptor{}, }, { name: "empty rear", descs: []ocispec.Descriptor{ descs[0], {}, descs[2], {}, {}, }, hint: 2, want: []ocispec.Descriptor{ descs[0], descs[2], }, }, { name: "empty head", descs: []ocispec.Descriptor{ {}, descs[0], descs[1], {}, {}, descs[2], }, hint: 3, want: []ocispec.Descriptor{ descs[0], descs[1], descs[2], }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := removeEmptyDescriptors(tt.descs, tt.hint); !reflect.DeepEqual(got, tt.want) { t.Errorf("removeEmptyDescriptors() = %v, want %v", got, tt.want) } }) } } oras-go-2.5.0/registry/remote/registry.go000066400000000000000000000127761457674530300204620ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // Package remote provides a client to the remote registry. // Reference: https://github.com/distribution/distribution package remote import ( "context" "encoding/json" "fmt" "net/http" "strconv" "oras.land/oras-go/v2/errdef" "oras.land/oras-go/v2/registry" "oras.land/oras-go/v2/registry/remote/auth" "oras.land/oras-go/v2/registry/remote/internal/errutil" ) // RepositoryOptions is an alias of Repository to avoid name conflicts. // It also hides all methods associated with Repository. type RepositoryOptions Repository // Registry is an HTTP client to a remote registry. type Registry struct { // RepositoryOptions contains common options for Registry and Repository. // It is also used as a template for derived repositories. RepositoryOptions // RepositoryListPageSize specifies the page size when invoking the catalog // API. // If zero, the page size is determined by the remote registry. // Reference: https://docs.docker.com/registry/spec/api/#catalog RepositoryListPageSize int } // NewRegistry creates a client to the remote registry with the specified domain // name. // Example: localhost:5000 func NewRegistry(name string) (*Registry, error) { ref := registry.Reference{ Registry: name, } if err := ref.ValidateRegistry(); err != nil { return nil, err } return &Registry{ RepositoryOptions: RepositoryOptions{ Reference: ref, }, }, nil } // client returns an HTTP client used to access the remote registry. // A default HTTP client is return if the client is not configured. func (r *Registry) client() Client { if r.Client == nil { return auth.DefaultClient } return r.Client } // do sends an HTTP request and returns an HTTP response using the HTTP client // returned by r.client(). func (r *Registry) do(req *http.Request) (*http.Response, error) { if r.HandleWarning == nil { return r.client().Do(req) } resp, err := r.client().Do(req) if err != nil { return nil, err } handleWarningHeaders(resp.Header.Values(headerWarning), r.HandleWarning) return resp, nil } // Ping checks whether or not the registry implement Docker Registry API V2 or // OCI Distribution Specification. // Ping can be used to check authentication when an auth client is configured. // // References: // - https://docs.docker.com/registry/spec/api/#base // - https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#api func (r *Registry) Ping(ctx context.Context) error { url := buildRegistryBaseURL(r.PlainHTTP, r.Reference) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return err } resp, err := r.do(req) if err != nil { return err } defer resp.Body.Close() switch resp.StatusCode { case http.StatusOK: return nil case http.StatusNotFound: return errdef.ErrNotFound default: return errutil.ParseErrorResponse(resp) } } // Repositories lists the name of repositories available in the registry. // See also `RepositoryListPageSize`. // // If `last` is NOT empty, the entries in the response start after the // repo specified by `last`. Otherwise, the response starts from the top // of the Repositories list. // // Reference: https://docs.docker.com/registry/spec/api/#catalog func (r *Registry) Repositories(ctx context.Context, last string, fn func(repos []string) error) error { ctx = auth.AppendScopesForHost(ctx, r.Reference.Host(), auth.ScopeRegistryCatalog) url := buildRegistryCatalogURL(r.PlainHTTP, r.Reference) var err error for err == nil { url, err = r.repositories(ctx, last, fn, url) // clear `last` for subsequent pages last = "" } if err != errNoLink { return err } return nil } // repositories returns a single page of repository list with the next link. func (r *Registry) repositories(ctx context.Context, last string, fn func(repos []string) error, url string) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return "", err } if r.RepositoryListPageSize > 0 || last != "" { q := req.URL.Query() if r.RepositoryListPageSize > 0 { q.Set("n", strconv.Itoa(r.RepositoryListPageSize)) } if last != "" { q.Set("last", last) } req.URL.RawQuery = q.Encode() } resp, err := r.do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", errutil.ParseErrorResponse(resp) } var page struct { Repositories []string `json:"repositories"` } lr := limitReader(resp.Body, r.MaxMetadataBytes) if err := json.NewDecoder(lr).Decode(&page); err != nil { return "", fmt.Errorf("%s %q: failed to decode response: %w", resp.Request.Method, resp.Request.URL, err) } if err := fn(page.Repositories); err != nil { return "", err } return parseLink(resp) } // Repository returns a repository reference by the given name. func (r *Registry) Repository(ctx context.Context, name string) (registry.Repository, error) { ref := registry.Reference{ Registry: r.Reference.Registry, Repository: name, } return newRepositoryWithOptions(ref, &r.RepositoryOptions) } oras-go-2.5.0/registry/remote/registry_test.go000066400000000000000000000245051457674530300215120ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package remote import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "net/url" "reflect" "strconv" "strings" "testing" "oras.land/oras-go/v2/errdef" "oras.land/oras-go/v2/registry" ) func TestRegistryInterface(t *testing.T) { var reg interface{} = &Registry{} if _, ok := reg.(registry.Registry); !ok { t.Error("&Registry{} does not conform registry.Registry") } } func TestRegistry_TLS(t *testing.T) { ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || r.URL.Path != "/v2/" { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } reg, err := NewRegistry(uri.Host) if err != nil { t.Fatalf("NewRegistry() error = %v", err) } reg.Client = ts.Client() ctx := context.Background() if err := reg.Ping(ctx); err != nil { t.Errorf("Registry.Ping() error = %v", err) } } func TestRegistry_Ping(t *testing.T) { v2Implemented := true ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || r.URL.Path != "/v2/" { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } if v2Implemented { w.WriteHeader(http.StatusOK) } else { w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } reg, err := NewRegistry(uri.Host) if err != nil { t.Fatalf("NewRegistry() error = %v", err) } reg.PlainHTTP = true ctx := context.Background() if err := reg.Ping(ctx); err != nil { t.Errorf("Registry.Ping() error = %v", err) } v2Implemented = false if err := reg.Ping(ctx); err == nil { t.Errorf("Registry.Ping() error = %v, wantErr %v", err, errdef.ErrNotFound) } } func TestRegistry_Repositories(t *testing.T) { repoSet := [][]string{ {"the", "quick", "brown", "fox"}, {"jumps", "over", "the", "lazy"}, {"dog"}, } var ts *httptest.Server ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || r.URL.Path != "/v2/_catalog" { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } q := r.URL.Query() n, err := strconv.Atoi(q.Get("n")) if err != nil || n != 4 { t.Errorf("bad page size: %s", q.Get("n")) w.WriteHeader(http.StatusBadRequest) return } var repos []string switch q.Get("test") { case "foo": repos = repoSet[1] w.Header().Set("Link", fmt.Sprintf(`<%s/v2/_catalog?n=4&test=bar>; rel="next"`, ts.URL)) case "bar": repos = repoSet[2] default: repos = repoSet[0] w.Header().Set("Link", `; rel="next"`) } result := struct { Repositories []string `json:"repositories"` }{ Repositories: repos, } if err := json.NewEncoder(w).Encode(result); err != nil { t.Errorf("failed to write response: %v", err) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } reg, err := NewRegistry(uri.Host) if err != nil { t.Fatalf("NewRegistry() error = %v", err) } reg.PlainHTTP = true reg.RepositoryListPageSize = 4 ctx := context.Background() index := 0 if err := reg.Repositories(ctx, "", func(got []string) error { if index > 2 { t.Fatalf("out of index bound: %d", index) } repos := repoSet[index] index++ if !reflect.DeepEqual(got, repos) { t.Errorf("Registry.Repositories() = %v, want %v", got, repos) } return nil }); err != nil { t.Fatalf("Registry.Repositories() error = %v", err) } } func TestRegistry_Repository(t *testing.T) { reg, err := NewRegistry("localhost:5000") if err != nil { t.Fatalf("NewRegistry() error = %v", err) } reg.PlainHTTP = true reg.SkipReferrersGC = true reg.RepositoryListPageSize = 50 reg.TagListPageSize = 100 reg.ReferrerListPageSize = 10 reg.MaxMetadataBytes = 8 * 1024 * 1024 ctx := context.Background() got, err := reg.Repository(ctx, "hello-world") if err != nil { t.Fatalf("Registry.Repository() error = %v", err) } reg.Reference.Repository = "hello-world" want := (*Repository)(®.RepositoryOptions) if !reflect.DeepEqual(got, want) { t.Errorf("Registry.Repository() = %v, want %v", got, want) } } // Testing `last` parameter for Repositories list func TestRegistry_Repositories_WithLastParam(t *testing.T) { repoSet := strings.Split("abcdefghijklmnopqrstuvwxyz", "") var offset int var ts *httptest.Server ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || r.URL.Path != "/v2/_catalog" { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } q := r.URL.Query() n, err := strconv.Atoi(q.Get("n")) if err != nil || n != 4 { t.Errorf("bad page size: %s", q.Get("n")) w.WriteHeader(http.StatusBadRequest) return } last := q.Get("last") if last != "" { offset = indexOf(last, repoSet) + 1 } var repos []string switch q.Get("test") { case "foo": repos = repoSet[offset : offset+n] w.Header().Set("Link", fmt.Sprintf(`<%s/v2/_catalog?n=4&last=v&test=bar>; rel="next"`, ts.URL)) case "bar": repos = repoSet[offset : offset+n] default: repos = repoSet[offset : offset+n] w.Header().Set("Link", fmt.Sprintf(`<%s/v2/_catalog?n=4&last=r&test=foo>; rel="next"`, ts.URL)) } result := struct { Repositories []string `json:"repositories"` }{ Repositories: repos, } if err := json.NewEncoder(w).Encode(result); err != nil { t.Errorf("failed to write response: %v", err) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } reg, err := NewRegistry(uri.Host) if err != nil { t.Fatalf("NewRegistry() error = %v", err) } reg.PlainHTTP = true reg.RepositoryListPageSize = 4 last := "n" startInd := indexOf(last, repoSet) + 1 ctx := context.Background() if err := reg.Repositories(ctx, last, func(got []string) error { want := repoSet[startInd : startInd+reg.RepositoryListPageSize] startInd += reg.RepositoryListPageSize if !reflect.DeepEqual(got, want) { t.Errorf("Registry.Repositories() = %v, want %v", got, want) } return nil }); err != nil { t.Fatalf("Registry.Repositories() error = %v", err) } } func TestRegistry_do(t *testing.T) { data := []byte(`hello world!`) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || r.URL.Path != "/test" { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } w.Header().Add("Warning", `299 - "Test 1: Good warning."`) w.Header().Add("Warning", `199 - "Test 2: Warning with a non-299 code."`) w.Header().Add("Warning", `299 - "Test 3: Good warning."`) w.Header().Add("Warning", `299 myregistry.example.com "Test 4: Warning with a non-unknown agent"`) w.Header().Add("Warning", `299 - "Test 5: Warning with a date." "Sat, 25 Aug 2012 23:34:45 GMT"`) w.Header().Add("wArnIng", `299 - "Test 6: Good warning."`) w.Write(data) })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } testURL := ts.URL + "/test" // test do() without HandleWarning reg, err := NewRegistry(uri.Host) if err != nil { t.Fatal("NewRegistry() error =", err) } req, err := http.NewRequest(http.MethodGet, testURL, nil) if err != nil { t.Fatal("failed to create test request:", err) } resp, err := reg.do(req) if err != nil { t.Fatal("Registry.do() error =", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Registry.do() status code = %v, want %v", resp.StatusCode, http.StatusOK) } if got := len(resp.Header["Warning"]); got != 6 { t.Errorf("Registry.do() warning header len = %v, want %v", got, 6) } got, err := io.ReadAll(resp.Body) if err != nil { t.Fatal("io.ReadAll() error =", err) } resp.Body.Close() if !bytes.Equal(got, data) { t.Errorf("Registry.do() = %v, want %v", got, data) } // test do() with HandleWarning reg, err = NewRegistry(uri.Host) if err != nil { t.Fatal("NewRegistry() error =", err) } var gotWarnings []Warning reg.HandleWarning = func(warning Warning) { gotWarnings = append(gotWarnings, warning) } req, err = http.NewRequest(http.MethodGet, testURL, nil) if err != nil { t.Fatal("failed to create test request:", err) } resp, err = reg.do(req) if err != nil { t.Fatal("Registry.do() error =", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Registry.do() status code = %v, want %v", resp.StatusCode, http.StatusOK) } if got := len(resp.Header["Warning"]); got != 6 { t.Errorf("Registry.do() warning header len = %v, want %v", got, 6) } got, err = io.ReadAll(resp.Body) if err != nil { t.Errorf("Registry.do() = %v, want %v", got, data) } resp.Body.Close() if !bytes.Equal(got, data) { t.Errorf("Registry.do() = %v, want %v", got, data) } wantWarnings := []Warning{ { WarningValue: WarningValue{ Code: 299, Agent: "-", Text: "Test 1: Good warning.", }, }, { WarningValue: WarningValue{ Code: 299, Agent: "-", Text: "Test 3: Good warning.", }, }, { WarningValue: WarningValue{ Code: 299, Agent: "-", Text: "Test 6: Good warning.", }, }, } if !reflect.DeepEqual(gotWarnings, wantWarnings) { t.Errorf("Registry.do() = %v, want %v", gotWarnings, wantWarnings) } } // indexOf returns the index of an element within a slice func indexOf(element string, data []string) int { for ind, val := range data { if element == val { return ind } } return -1 //not found. } oras-go-2.5.0/registry/remote/repository.go000066400000000000000000001632401457674530300210220ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package remote import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "mime" "net/http" "slices" "strconv" "strings" "sync" "sync/atomic" "github.com/opencontainers/go-digest" specs "github.com/opencontainers/image-spec/specs-go" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/errdef" "oras.land/oras-go/v2/internal/cas" "oras.land/oras-go/v2/internal/httputil" "oras.land/oras-go/v2/internal/ioutil" "oras.land/oras-go/v2/internal/spec" "oras.land/oras-go/v2/internal/syncutil" "oras.land/oras-go/v2/registry" "oras.land/oras-go/v2/registry/remote/auth" "oras.land/oras-go/v2/registry/remote/errcode" "oras.land/oras-go/v2/registry/remote/internal/errutil" ) const ( // headerDockerContentDigest is the "Docker-Content-Digest" header. // If present on the response, it contains the canonical digest of the // uploaded blob. // // References: // - https://docs.docker.com/registry/spec/api/#digest-header // - https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#pull headerDockerContentDigest = "Docker-Content-Digest" // headerOCIFiltersApplied is the "OCI-Filters-Applied" header. // If present on the response, it contains a comma-separated list of the // applied filters. // // Reference: // - https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#listing-referrers headerOCIFiltersApplied = "OCI-Filters-Applied" // headerOCISubject is the "OCI-Subject" header. // If present on the response, it contains the digest of the subject, // indicating that Referrers API is supported by the registry. headerOCISubject = "OCI-Subject" ) // filterTypeArtifactType is the "artifactType" filter applied on the list of // referrers. // // References: // - Latest spec: https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#listing-referrers // - Compatible spec: https://github.com/opencontainers/distribution-spec/blob/v1.1.0-rc1/spec.md#listing-referrers const filterTypeArtifactType = "artifactType" // Client is an interface for a HTTP client. type Client interface { // Do sends an HTTP request and returns an HTTP response. // // Unlike http.RoundTripper, Client can attempt to interpret the response // and handle higher-level protocol details such as redirects and // authentication. // // Like http.RoundTripper, Client should not modify the request, and must // always close the request body. Do(*http.Request) (*http.Response, error) } // Repository is an HTTP client to a remote repository. type Repository struct { // Client is the underlying HTTP client used to access the remote registry. // If nil, auth.DefaultClient is used. Client Client // Reference references the remote repository. Reference registry.Reference // PlainHTTP signals the transport to access the remote repository via HTTP // instead of HTTPS. PlainHTTP bool // ManifestMediaTypes is used in `Accept` header for resolving manifests // from references. It is also used in identifying manifests and blobs from // descriptors. If an empty list is present, default manifest media types // are used. ManifestMediaTypes []string // TagListPageSize specifies the page size when invoking the tag list API. // If zero, the page size is determined by the remote registry. // Reference: https://docs.docker.com/registry/spec/api/#tags TagListPageSize int // ReferrerListPageSize specifies the page size when invoking the Referrers // API. // If zero, the page size is determined by the remote registry. // Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#listing-referrers ReferrerListPageSize int // MaxMetadataBytes specifies a limit on how many response bytes are allowed // in the server's response to the metadata APIs, such as catalog list, tag // list, and referrers list. // If less than or equal to zero, a default (currently 4MiB) is used. MaxMetadataBytes int64 // SkipReferrersGC specifies whether to delete the dangling referrers // index when referrers tag schema is utilized. // - If false, the old referrers index will be deleted after the new one // is successfully uploaded. // - If true, the old referrers index is kept. // By default, it is disabled (set to false). See also: // - https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#referrers-tag-schema // - https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#pushing-manifests-with-subject // - https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#deleting-manifests SkipReferrersGC bool // HandleWarning handles the warning returned by the remote server. // Callers SHOULD deduplicate warnings from multiple associated responses. // // References: // - https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#warnings // - https://www.rfc-editor.org/rfc/rfc7234#section-5.5 HandleWarning func(warning Warning) // NOTE: Must keep fields in sync with clone(). // referrersState represents that if the repository supports Referrers API. // default: referrersStateUnknown referrersState referrersState // referrersPingLock locks the pingReferrers() method and allows only // one go-routine to send the request. referrersPingLock sync.Mutex // referrersMergePool provides a way to manage concurrent updates to a // referrers index tagged by referrers tag schema. referrersMergePool syncutil.Pool[syncutil.Merge[referrerChange]] } // NewRepository creates a client to the remote repository identified by a // reference. // Example: localhost:5000/hello-world func NewRepository(reference string) (*Repository, error) { ref, err := registry.ParseReference(reference) if err != nil { return nil, err } return &Repository{ Reference: ref, }, nil } // newRepositoryWithOptions returns a Repository with the given Reference and // RepositoryOptions. // // RepositoryOptions are part of the Registry struct and set its defaults. // RepositoryOptions shares the same struct definition as Repository, which // contains unexported state that must not be copied to multiple Repositories. // To handle this we explicitly copy only the fields that we want to reproduce. func newRepositoryWithOptions(ref registry.Reference, opts *RepositoryOptions) (*Repository, error) { if err := ref.ValidateRepository(); err != nil { return nil, err } repo := (*Repository)(opts).clone() repo.Reference = ref return repo, nil } // clone makes a copy of the Repository being careful not to copy non-copyable fields (sync.Mutex and syncutil.Pool types) func (r *Repository) clone() *Repository { return &Repository{ Client: r.Client, Reference: r.Reference, PlainHTTP: r.PlainHTTP, ManifestMediaTypes: slices.Clone(r.ManifestMediaTypes), TagListPageSize: r.TagListPageSize, ReferrerListPageSize: r.ReferrerListPageSize, MaxMetadataBytes: r.MaxMetadataBytes, SkipReferrersGC: r.SkipReferrersGC, HandleWarning: r.HandleWarning, } } // SetReferrersCapability indicates the Referrers API capability of the remote // repository. true: capable; false: not capable. // // SetReferrersCapability is valid only when it is called for the first time. // SetReferrersCapability returns ErrReferrersCapabilityAlreadySet if the // Referrers API capability has been already set. // - When the capability is set to true, the Referrers() function will always // request the Referrers API. Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#listing-referrers // - When the capability is set to false, the Referrers() function will always // request the Referrers Tag. Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#referrers-tag-schema // - When the capability is not set, the Referrers() function will automatically // determine which API to use. func (r *Repository) SetReferrersCapability(capable bool) error { var state referrersState if capable { state = referrersStateSupported } else { state = referrersStateUnsupported } if swapped := atomic.CompareAndSwapInt32(&r.referrersState, referrersStateUnknown, state); !swapped { if fact := r.loadReferrersState(); fact != state { return fmt.Errorf("%w: current capability = %v, new capability = %v", ErrReferrersCapabilityAlreadySet, fact == referrersStateSupported, capable) } } return nil } // setReferrersState atomically loads r.referrersState. func (r *Repository) loadReferrersState() referrersState { return atomic.LoadInt32(&r.referrersState) } // client returns an HTTP client used to access the remote repository. // A default HTTP client is return if the client is not configured. func (r *Repository) client() Client { if r.Client == nil { return auth.DefaultClient } return r.Client } // do sends an HTTP request and returns an HTTP response using the HTTP client // returned by r.client(). func (r *Repository) do(req *http.Request) (*http.Response, error) { if r.HandleWarning == nil { return r.client().Do(req) } resp, err := r.client().Do(req) if err != nil { return nil, err } handleWarningHeaders(resp.Header.Values(headerWarning), r.HandleWarning) return resp, nil } // blobStore detects the blob store for the given descriptor. func (r *Repository) blobStore(desc ocispec.Descriptor) registry.BlobStore { if isManifest(r.ManifestMediaTypes, desc) { return r.Manifests() } return r.Blobs() } // Fetch fetches the content identified by the descriptor. func (r *Repository) Fetch(ctx context.Context, target ocispec.Descriptor) (io.ReadCloser, error) { return r.blobStore(target).Fetch(ctx, target) } // Push pushes the content, matching the expected descriptor. func (r *Repository) Push(ctx context.Context, expected ocispec.Descriptor, content io.Reader) error { return r.blobStore(expected).Push(ctx, expected, content) } // Mount makes the blob with the given digest in fromRepo // available in the repository signified by the receiver. // // This avoids the need to pull content down from fromRepo only to push it to r. // // If the registry does not implement mounting, getContent will be used to get the // content to push. If getContent is nil, the content will be pulled from the source // repository. If getContent returns an error, it will be wrapped inside the error // returned from Mount. func (r *Repository) Mount(ctx context.Context, desc ocispec.Descriptor, fromRepo string, getContent func() (io.ReadCloser, error)) error { return r.Blobs().(registry.Mounter).Mount(ctx, desc, fromRepo, getContent) } // Exists returns true if the described content exists. func (r *Repository) Exists(ctx context.Context, target ocispec.Descriptor) (bool, error) { return r.blobStore(target).Exists(ctx, target) } // Delete removes the content identified by the descriptor. func (r *Repository) Delete(ctx context.Context, target ocispec.Descriptor) error { return r.blobStore(target).Delete(ctx, target) } // Blobs provides access to the blob CAS only, which contains config blobs, // layers, and other generic blobs. func (r *Repository) Blobs() registry.BlobStore { return &blobStore{repo: r} } // Manifests provides access to the manifest CAS only. func (r *Repository) Manifests() registry.ManifestStore { return &manifestStore{repo: r} } // Resolve resolves a reference to a manifest descriptor. // See also `ManifestMediaTypes`. func (r *Repository) Resolve(ctx context.Context, reference string) (ocispec.Descriptor, error) { return r.Manifests().Resolve(ctx, reference) } // Tag tags a manifest descriptor with a reference string. func (r *Repository) Tag(ctx context.Context, desc ocispec.Descriptor, reference string) error { return r.Manifests().Tag(ctx, desc, reference) } // PushReference pushes the manifest with a reference tag. func (r *Repository) PushReference(ctx context.Context, expected ocispec.Descriptor, content io.Reader, reference string) error { return r.Manifests().PushReference(ctx, expected, content, reference) } // FetchReference fetches the manifest identified by the reference. // The reference can be a tag or digest. func (r *Repository) FetchReference(ctx context.Context, reference string) (ocispec.Descriptor, io.ReadCloser, error) { return r.Manifests().FetchReference(ctx, reference) } // ParseReference resolves a tag or a digest reference to a fully qualified // reference from a base reference r.Reference. // Tag, digest, or fully qualified references are accepted as input. // // If reference is a fully qualified reference, then ParseReference parses it // and returns the parsed reference. If the parsed reference does not share // the same base reference with the Repository r, ParseReference returns a // wrapped error ErrInvalidReference. func (r *Repository) ParseReference(reference string) (registry.Reference, error) { ref, err := registry.ParseReference(reference) if err != nil { ref = registry.Reference{ Registry: r.Reference.Registry, Repository: r.Reference.Repository, Reference: reference, } // reference is not a FQDN if index := strings.IndexByte(reference, '@'); index != -1 { // `@` implies *digest*, so drop the *tag* (irrespective of what it is). ref.Reference = reference[index+1:] err = ref.ValidateReferenceAsDigest() } else { err = ref.ValidateReference() } if err != nil { return registry.Reference{}, err } } else if ref.Registry != r.Reference.Registry || ref.Repository != r.Reference.Repository { return registry.Reference{}, fmt.Errorf( "%w: mismatch between received %q and expected %q", errdef.ErrInvalidReference, ref, r.Reference, ) } if len(ref.Reference) == 0 { return registry.Reference{}, errdef.ErrInvalidReference } return ref, nil } // Tags lists the tags available in the repository. // See also `TagListPageSize`. // If `last` is NOT empty, the entries in the response start after the // tag specified by `last`. Otherwise, the response starts from the top // of the Tags list. // // References: // - https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#content-discovery // - https://docs.docker.com/registry/spec/api/#tags func (r *Repository) Tags(ctx context.Context, last string, fn func(tags []string) error) error { ctx = auth.AppendRepositoryScope(ctx, r.Reference, auth.ActionPull) url := buildRepositoryTagListURL(r.PlainHTTP, r.Reference) var err error for err == nil { url, err = r.tags(ctx, last, fn, url) // clear `last` for subsequent pages last = "" } if err != errNoLink { return err } return nil } // tags returns a single page of tag list with the next link. func (r *Repository) tags(ctx context.Context, last string, fn func(tags []string) error, url string) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return "", err } if r.TagListPageSize > 0 || last != "" { q := req.URL.Query() if r.TagListPageSize > 0 { q.Set("n", strconv.Itoa(r.TagListPageSize)) } if last != "" { q.Set("last", last) } req.URL.RawQuery = q.Encode() } resp, err := r.do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", errutil.ParseErrorResponse(resp) } var page struct { Tags []string `json:"tags"` } lr := limitReader(resp.Body, r.MaxMetadataBytes) if err := json.NewDecoder(lr).Decode(&page); err != nil { return "", fmt.Errorf("%s %q: failed to decode response: %w", resp.Request.Method, resp.Request.URL, err) } if err := fn(page.Tags); err != nil { return "", err } return parseLink(resp) } // Predecessors returns the descriptors of image or artifact manifests directly // referencing the given manifest descriptor. // Predecessors internally leverages Referrers. // Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#listing-referrers func (r *Repository) Predecessors(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) { var res []ocispec.Descriptor if err := r.Referrers(ctx, desc, "", func(referrers []ocispec.Descriptor) error { res = append(res, referrers...) return nil }); err != nil { return nil, err } return res, nil } // Referrers lists the descriptors of image or artifact manifests directly // referencing the given manifest descriptor. // // fn is called for each page of the referrers result. // If artifactType is not empty, only referrers of the same artifact type are // fed to fn. // // Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#listing-referrers func (r *Repository) Referrers(ctx context.Context, desc ocispec.Descriptor, artifactType string, fn func(referrers []ocispec.Descriptor) error) error { state := r.loadReferrersState() if state == referrersStateUnsupported { // The repository is known to not support Referrers API, fallback to // referrers tag schema. return r.referrersByTagSchema(ctx, desc, artifactType, fn) } err := r.referrersByAPI(ctx, desc, artifactType, fn) if state == referrersStateSupported { // The repository is known to support Referrers API, no fallback. return err } // The referrers state is unknown. if err != nil { if errors.Is(err, errdef.ErrUnsupported) { // Referrers API is not supported, fallback to referrers tag schema. r.SetReferrersCapability(false) return r.referrersByTagSchema(ctx, desc, artifactType, fn) } return err } r.SetReferrersCapability(true) return nil } // referrersByAPI lists the descriptors of manifests directly referencing // the given manifest descriptor by requesting Referrers API. // fn is called for the referrers result. If artifactType is not empty, // only referrers of the same artifact type are fed to fn. func (r *Repository) referrersByAPI(ctx context.Context, desc ocispec.Descriptor, artifactType string, fn func(referrers []ocispec.Descriptor) error) error { ref := r.Reference ref.Reference = desc.Digest.String() ctx = auth.AppendRepositoryScope(ctx, ref, auth.ActionPull) url := buildReferrersURL(r.PlainHTTP, ref, artifactType) var err error for err == nil { url, err = r.referrersPageByAPI(ctx, artifactType, fn, url) } if err == errNoLink { return nil } return err } // referrersPageByAPI lists a single page of the descriptors of manifests // directly referencing the given manifest descriptor. fn is called for // a page of referrersPageByAPI result. // If artifactType is not empty, only referrersPageByAPI of the same // artifact type are fed to fn. // referrersPageByAPI returns the link url for the next page. func (r *Repository) referrersPageByAPI(ctx context.Context, artifactType string, fn func(referrers []ocispec.Descriptor) error, url string) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return "", err } if r.ReferrerListPageSize > 0 { q := req.URL.Query() q.Set("n", strconv.Itoa(r.ReferrerListPageSize)) req.URL.RawQuery = q.Encode() } resp, err := r.do(req) if err != nil { return "", err } defer resp.Body.Close() switch resp.StatusCode { case http.StatusOK: case http.StatusNotFound: if errResp := errutil.ParseErrorResponse(resp); errutil.IsErrorCode(errResp, errcode.ErrorCodeNameUnknown) { // The repository is not found, Referrers API status is unknown return "", errResp } // Referrers API is not supported. return "", fmt.Errorf("failed to query referrers API: %w", errdef.ErrUnsupported) default: return "", errutil.ParseErrorResponse(resp) } // also check the content type if ct := resp.Header.Get("Content-Type"); ct != ocispec.MediaTypeImageIndex { return "", fmt.Errorf("unknown content returned (%s), expecting image index: %w", ct, errdef.ErrUnsupported) } var index ocispec.Index lr := limitReader(resp.Body, r.MaxMetadataBytes) if err := json.NewDecoder(lr).Decode(&index); err != nil { return "", fmt.Errorf("%s %q: failed to decode response: %w", resp.Request.Method, resp.Request.URL, err) } referrers := index.Manifests if artifactType != "" { // check both filters header and filters annotations for compatibility // latest spec for filters header: https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#listing-referrers // older spec for filters annotations: https://github.com/opencontainers/distribution-spec/blob/v1.1.0-rc1/spec.md#listing-referrers filtersHeader := resp.Header.Get(headerOCIFiltersApplied) filtersAnnotation := index.Annotations[spec.AnnotationReferrersFiltersApplied] if !isReferrersFilterApplied(filtersHeader, filterTypeArtifactType) && !isReferrersFilterApplied(filtersAnnotation, filterTypeArtifactType) { // perform client side filtering if the filter is not applied on the server side referrers = filterReferrers(referrers, artifactType) } } if len(referrers) > 0 { if err := fn(referrers); err != nil { return "", err } } return parseLink(resp) } // referrersByTagSchema lists the descriptors of manifests directly // referencing the given manifest descriptor by requesting referrers tag. // fn is called for the referrers result. If artifactType is not empty, // only referrers of the same artifact type are fed to fn. // reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#backwards-compatibility func (r *Repository) referrersByTagSchema(ctx context.Context, desc ocispec.Descriptor, artifactType string, fn func(referrers []ocispec.Descriptor) error) error { referrersTag := buildReferrersTag(desc) _, referrers, err := r.referrersFromIndex(ctx, referrersTag) if err != nil { if errors.Is(err, errdef.ErrNotFound) { // no referrers to the manifest return nil } return err } filtered := filterReferrers(referrers, artifactType) if len(filtered) == 0 { return nil } return fn(filtered) } // referrersFromIndex queries the referrers index using the the given referrers // tag. If Succeeded, returns the descriptor of referrers index and the // referrers list. func (r *Repository) referrersFromIndex(ctx context.Context, referrersTag string) (ocispec.Descriptor, []ocispec.Descriptor, error) { desc, rc, err := r.FetchReference(ctx, referrersTag) if err != nil { return ocispec.Descriptor{}, nil, err } defer rc.Close() if err := limitSize(desc, r.MaxMetadataBytes); err != nil { return ocispec.Descriptor{}, nil, fmt.Errorf("failed to read referrers index from referrers tag %s: %w", referrersTag, err) } var index ocispec.Index if err := decodeJSON(rc, desc, &index); err != nil { return ocispec.Descriptor{}, nil, fmt.Errorf("failed to decode referrers index from referrers tag %s: %w", referrersTag, err) } return desc, index.Manifests, nil } // pingReferrers returns true if the Referrers API is available for r. func (r *Repository) pingReferrers(ctx context.Context) (bool, error) { switch r.loadReferrersState() { case referrersStateSupported: return true, nil case referrersStateUnsupported: return false, nil } // referrers state is unknown // limit the rate of pinging referrers API r.referrersPingLock.Lock() defer r.referrersPingLock.Unlock() switch r.loadReferrersState() { case referrersStateSupported: return true, nil case referrersStateUnsupported: return false, nil } ref := r.Reference ref.Reference = zeroDigest ctx = auth.AppendRepositoryScope(ctx, ref, auth.ActionPull) url := buildReferrersURL(r.PlainHTTP, ref, "") req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return false, err } resp, err := r.do(req) if err != nil { return false, err } defer resp.Body.Close() switch resp.StatusCode { case http.StatusOK: supported := resp.Header.Get("Content-Type") == ocispec.MediaTypeImageIndex r.SetReferrersCapability(supported) return supported, nil case http.StatusNotFound: if err := errutil.ParseErrorResponse(resp); errutil.IsErrorCode(err, errcode.ErrorCodeNameUnknown) { // repository not found return false, err } r.SetReferrersCapability(false) return false, nil default: return false, errutil.ParseErrorResponse(resp) } } // delete removes the content identified by the descriptor in the entity "blobs" // or "manifests". func (r *Repository) delete(ctx context.Context, target ocispec.Descriptor, isManifest bool) error { ref := r.Reference ref.Reference = target.Digest.String() ctx = auth.AppendRepositoryScope(ctx, ref, auth.ActionDelete) buildURL := buildRepositoryBlobURL if isManifest { buildURL = buildRepositoryManifestURL } url := buildURL(r.PlainHTTP, ref) req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil) if err != nil { return err } resp, err := r.do(req) if err != nil { return err } defer resp.Body.Close() switch resp.StatusCode { case http.StatusAccepted: return verifyContentDigest(resp, target.Digest) case http.StatusNotFound: return fmt.Errorf("%s: %w", target.Digest, errdef.ErrNotFound) default: return errutil.ParseErrorResponse(resp) } } // blobStore accesses the blob part of the repository. type blobStore struct { repo *Repository } // Fetch fetches the content identified by the descriptor. func (s *blobStore) Fetch(ctx context.Context, target ocispec.Descriptor) (rc io.ReadCloser, err error) { ref := s.repo.Reference ref.Reference = target.Digest.String() ctx = auth.AppendRepositoryScope(ctx, ref, auth.ActionPull) url := buildRepositoryBlobURL(s.repo.PlainHTTP, ref) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } resp, err := s.repo.do(req) if err != nil { return nil, err } defer func() { if err != nil { resp.Body.Close() } }() switch resp.StatusCode { case http.StatusOK: // server does not support seek as `Range` was ignored. if size := resp.ContentLength; size != -1 && size != target.Size { return nil, fmt.Errorf("%s %q: mismatch Content-Length", resp.Request.Method, resp.Request.URL) } // check server range request capability. // Docker spec allows range header form of "Range: bytes=-". // However, the remote server may still not RFC 7233 compliant. // Reference: https://docs.docker.com/registry/spec/api/#blob if rangeUnit := resp.Header.Get("Accept-Ranges"); rangeUnit == "bytes" { return httputil.NewReadSeekCloser(s.repo.client(), req, resp.Body, target.Size), nil } return resp.Body, nil case http.StatusNotFound: return nil, fmt.Errorf("%s: %w", target.Digest, errdef.ErrNotFound) default: return nil, errutil.ParseErrorResponse(resp) } } // Mount mounts the given descriptor from fromRepo into s. func (s *blobStore) Mount(ctx context.Context, desc ocispec.Descriptor, fromRepo string, getContent func() (io.ReadCloser, error)) error { // pushing usually requires both pull and push actions. // Reference: https://github.com/distribution/distribution/blob/v2.7.1/registry/handlers/app.go#L921-L930 ctx = auth.AppendRepositoryScope(ctx, s.repo.Reference, auth.ActionPull, auth.ActionPush) // We also need pull access to the source repo. fromRef := s.repo.Reference fromRef.Repository = fromRepo ctx = auth.AppendRepositoryScope(ctx, fromRef, auth.ActionPull) url := buildRepositoryBlobMountURL(s.repo.PlainHTTP, s.repo.Reference, desc.Digest, fromRepo) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) if err != nil { return err } resp, err := s.repo.do(req) if err != nil { return err } if resp.StatusCode == http.StatusCreated { defer resp.Body.Close() // Check the server seems to be behaving. return verifyContentDigest(resp, desc.Digest) } if resp.StatusCode != http.StatusAccepted { defer resp.Body.Close() return errutil.ParseErrorResponse(resp) } resp.Body.Close() // From the [spec]: // // "If a registry does not support cross-repository mounting // or is unable to mount the requested blob, // it SHOULD return a 202. // This indicates that the upload session has begun // and that the client MAY proceed with the upload." // // So we need to get the content from somewhere in order to // push it. If the caller has provided a getContent function, we // can use that, otherwise pull the content from the source repository. // // [spec]: https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#mounting-a-blob-from-another-repository var r io.ReadCloser if getContent != nil { r, err = getContent() } else { r, err = s.sibling(fromRepo).Fetch(ctx, desc) } if err != nil { return fmt.Errorf("cannot read source blob: %w", err) } defer r.Close() return s.completePushAfterInitialPost(ctx, req, resp, desc, r) } // sibling returns a blob store for another repository in the same // registry. func (s *blobStore) sibling(otherRepoName string) *blobStore { otherRepo := s.repo.clone() otherRepo.Reference.Repository = otherRepoName return &blobStore{ repo: otherRepo, } } // Push pushes the content, matching the expected descriptor. // Existing content is not checked by Push() to minimize the number of out-going // requests. // Push is done by conventional 2-step monolithic upload instead of a single // `POST` request for better overall performance. It also allows early fail on // authentication errors. // // References: // - https://docs.docker.com/registry/spec/api/#pushing-an-image // - https://docs.docker.com/registry/spec/api/#initiate-blob-upload // - https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#pushing-a-blob-monolithically func (s *blobStore) Push(ctx context.Context, expected ocispec.Descriptor, content io.Reader) error { // start an upload // pushing usually requires both pull and push actions. // Reference: https://github.com/distribution/distribution/blob/v2.7.1/registry/handlers/app.go#L921-L930 ctx = auth.AppendRepositoryScope(ctx, s.repo.Reference, auth.ActionPull, auth.ActionPush) url := buildRepositoryBlobUploadURL(s.repo.PlainHTTP, s.repo.Reference) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) if err != nil { return err } resp, err := s.repo.do(req) if err != nil { return err } if resp.StatusCode != http.StatusAccepted { defer resp.Body.Close() return errutil.ParseErrorResponse(resp) } resp.Body.Close() return s.completePushAfterInitialPost(ctx, req, resp, expected, content) } // completePushAfterInitialPost implements step 2 of the push protocol. This can be invoked either by // Push or by Mount when the receiving repository does not implement the // mount endpoint. func (s *blobStore) completePushAfterInitialPost(ctx context.Context, req *http.Request, resp *http.Response, expected ocispec.Descriptor, content io.Reader) error { reqHostname := req.URL.Hostname() reqPort := req.URL.Port() // monolithic upload location, err := resp.Location() if err != nil { return err } // work-around solution for https://github.com/oras-project/oras-go/issues/177 // For some registries, if the port 443 is explicitly set to the hostname // like registry.wabbit-networks.io:443/myrepo, blob push will fail since // the hostname of the Location header in the response is set to // registry.wabbit-networks.io instead of registry.wabbit-networks.io:443. locationHostname := location.Hostname() locationPort := location.Port() // if location port 443 is missing, add it back if reqPort == "443" && locationHostname == reqHostname && locationPort == "" { location.Host = locationHostname + ":" + reqPort } url := location.String() req, err = http.NewRequestWithContext(ctx, http.MethodPut, url, content) if err != nil { return err } if req.GetBody != nil && req.ContentLength != expected.Size { // short circuit a size mismatch for built-in types. return fmt.Errorf("mismatch content length %d: expect %d", req.ContentLength, expected.Size) } req.ContentLength = expected.Size // the expected media type is ignored as in the API doc. req.Header.Set("Content-Type", "application/octet-stream") q := req.URL.Query() q.Set("digest", expected.Digest.String()) req.URL.RawQuery = q.Encode() // reuse credential from previous POST request if auth := resp.Request.Header.Get("Authorization"); auth != "" { req.Header.Set("Authorization", auth) } resp, err = s.repo.do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusCreated { return errutil.ParseErrorResponse(resp) } return nil } // Exists returns true if the described content exists. func (s *blobStore) Exists(ctx context.Context, target ocispec.Descriptor) (bool, error) { _, err := s.Resolve(ctx, target.Digest.String()) if err == nil { return true, nil } if errors.Is(err, errdef.ErrNotFound) { return false, nil } return false, err } // Delete removes the content identified by the descriptor. func (s *blobStore) Delete(ctx context.Context, target ocispec.Descriptor) error { return s.repo.delete(ctx, target, false) } // Resolve resolves a reference to a descriptor. func (s *blobStore) Resolve(ctx context.Context, reference string) (ocispec.Descriptor, error) { ref, err := s.repo.ParseReference(reference) if err != nil { return ocispec.Descriptor{}, err } refDigest, err := ref.Digest() if err != nil { return ocispec.Descriptor{}, err } ctx = auth.AppendRepositoryScope(ctx, ref, auth.ActionPull) url := buildRepositoryBlobURL(s.repo.PlainHTTP, ref) req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil) if err != nil { return ocispec.Descriptor{}, err } resp, err := s.repo.do(req) if err != nil { return ocispec.Descriptor{}, err } defer resp.Body.Close() switch resp.StatusCode { case http.StatusOK: return generateBlobDescriptor(resp, refDigest) case http.StatusNotFound: return ocispec.Descriptor{}, fmt.Errorf("%s: %w", ref, errdef.ErrNotFound) default: return ocispec.Descriptor{}, errutil.ParseErrorResponse(resp) } } // FetchReference fetches the blob identified by the reference. // The reference must be a digest. func (s *blobStore) FetchReference(ctx context.Context, reference string) (desc ocispec.Descriptor, rc io.ReadCloser, err error) { ref, err := s.repo.ParseReference(reference) if err != nil { return ocispec.Descriptor{}, nil, err } refDigest, err := ref.Digest() if err != nil { return ocispec.Descriptor{}, nil, err } ctx = auth.AppendRepositoryScope(ctx, ref, auth.ActionPull) url := buildRepositoryBlobURL(s.repo.PlainHTTP, ref) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return ocispec.Descriptor{}, nil, err } resp, err := s.repo.do(req) if err != nil { return ocispec.Descriptor{}, nil, err } defer func() { if err != nil { resp.Body.Close() } }() switch resp.StatusCode { case http.StatusOK: // server does not support seek as `Range` was ignored. if resp.ContentLength == -1 { desc, err = s.Resolve(ctx, reference) } else { desc, err = generateBlobDescriptor(resp, refDigest) } if err != nil { return ocispec.Descriptor{}, nil, err } // check server range request capability. // Docker spec allows range header form of "Range: bytes=-". // However, the remote server may still not RFC 7233 compliant. // Reference: https://docs.docker.com/registry/spec/api/#blob if rangeUnit := resp.Header.Get("Accept-Ranges"); rangeUnit == "bytes" { return desc, httputil.NewReadSeekCloser(s.repo.client(), req, resp.Body, desc.Size), nil } return desc, resp.Body, nil case http.StatusNotFound: return ocispec.Descriptor{}, nil, fmt.Errorf("%s: %w", ref, errdef.ErrNotFound) default: return ocispec.Descriptor{}, nil, errutil.ParseErrorResponse(resp) } } // generateBlobDescriptor returns a descriptor generated from the response. func generateBlobDescriptor(resp *http.Response, refDigest digest.Digest) (ocispec.Descriptor, error) { mediaType, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type")) if mediaType == "" { mediaType = "application/octet-stream" } size := resp.ContentLength if size == -1 { return ocispec.Descriptor{}, fmt.Errorf("%s %q: unknown response Content-Length", resp.Request.Method, resp.Request.URL) } if err := verifyContentDigest(resp, refDigest); err != nil { return ocispec.Descriptor{}, err } return ocispec.Descriptor{ MediaType: mediaType, Digest: refDigest, Size: size, }, nil } // manifestStore accesses the manifest part of the repository. type manifestStore struct { repo *Repository } // Fetch fetches the content identified by the descriptor. func (s *manifestStore) Fetch(ctx context.Context, target ocispec.Descriptor) (rc io.ReadCloser, err error) { ref := s.repo.Reference ref.Reference = target.Digest.String() ctx = auth.AppendRepositoryScope(ctx, ref, auth.ActionPull) url := buildRepositoryManifestURL(s.repo.PlainHTTP, ref) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } req.Header.Set("Accept", target.MediaType) resp, err := s.repo.do(req) if err != nil { return nil, err } defer func() { if err != nil { resp.Body.Close() } }() switch resp.StatusCode { case http.StatusOK: // no-op case http.StatusNotFound: return nil, fmt.Errorf("%s: %w", target.Digest, errdef.ErrNotFound) default: return nil, errutil.ParseErrorResponse(resp) } mediaType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) if err != nil { return nil, fmt.Errorf("%s %q: invalid response Content-Type: %w", resp.Request.Method, resp.Request.URL, err) } if mediaType != target.MediaType { return nil, fmt.Errorf("%s %q: mismatch response Content-Type %q: expect %q", resp.Request.Method, resp.Request.URL, mediaType, target.MediaType) } if size := resp.ContentLength; size != -1 && size != target.Size { return nil, fmt.Errorf("%s %q: mismatch Content-Length", resp.Request.Method, resp.Request.URL) } if err := verifyContentDigest(resp, target.Digest); err != nil { return nil, err } return resp.Body, nil } // Push pushes the content, matching the expected descriptor. func (s *manifestStore) Push(ctx context.Context, expected ocispec.Descriptor, content io.Reader) error { return s.pushWithIndexing(ctx, expected, content, expected.Digest.String()) } // Exists returns true if the described content exists. func (s *manifestStore) Exists(ctx context.Context, target ocispec.Descriptor) (bool, error) { _, err := s.Resolve(ctx, target.Digest.String()) if err == nil { return true, nil } if errors.Is(err, errdef.ErrNotFound) { return false, nil } return false, err } // Delete removes the manifest content identified by the descriptor. func (s *manifestStore) Delete(ctx context.Context, target ocispec.Descriptor) error { return s.deleteWithIndexing(ctx, target) } // deleteWithIndexing removes the manifest content identified by the descriptor, // and indexes referrers for the manifest when needed. func (s *manifestStore) deleteWithIndexing(ctx context.Context, target ocispec.Descriptor) error { switch target.MediaType { case spec.MediaTypeArtifactManifest, ocispec.MediaTypeImageManifest, ocispec.MediaTypeImageIndex: if state := s.repo.loadReferrersState(); state == referrersStateSupported { // referrers API is available, no client-side indexing needed return s.repo.delete(ctx, target, true) } if err := limitSize(target, s.repo.MaxMetadataBytes); err != nil { return err } ctx = auth.AppendRepositoryScope(ctx, s.repo.Reference, auth.ActionPull, auth.ActionDelete) manifestJSON, err := content.FetchAll(ctx, s, target) if err != nil { return err } if err := s.indexReferrersForDelete(ctx, target, manifestJSON); err != nil { return err } } return s.repo.delete(ctx, target, true) } // indexReferrersForDelete indexes referrers for manifests with a subject field // on manifest delete. // // References: // - Latest spec: https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#deleting-manifests // - Compatible spec: https://github.com/opencontainers/distribution-spec/blob/v1.1.0-rc1/spec.md#deleting-manifests func (s *manifestStore) indexReferrersForDelete(ctx context.Context, desc ocispec.Descriptor, manifestJSON []byte) error { var manifest struct { Subject *ocispec.Descriptor `json:"subject"` } if err := json.Unmarshal(manifestJSON, &manifest); err != nil { return fmt.Errorf("failed to decode manifest: %s: %s: %w", desc.Digest, desc.MediaType, err) } if manifest.Subject == nil { // no subject, no indexing needed return nil } subject := *manifest.Subject ok, err := s.repo.pingReferrers(ctx) if err != nil { return err } if ok { // referrers API is available, no client-side indexing needed return nil } return s.updateReferrersIndex(ctx, subject, referrerChange{desc, referrerOperationRemove}) } // Resolve resolves a reference to a descriptor. // See also `ManifestMediaTypes`. func (s *manifestStore) Resolve(ctx context.Context, reference string) (ocispec.Descriptor, error) { ref, err := s.repo.ParseReference(reference) if err != nil { return ocispec.Descriptor{}, err } ctx = auth.AppendRepositoryScope(ctx, ref, auth.ActionPull) url := buildRepositoryManifestURL(s.repo.PlainHTTP, ref) req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil) if err != nil { return ocispec.Descriptor{}, err } req.Header.Set("Accept", manifestAcceptHeader(s.repo.ManifestMediaTypes)) resp, err := s.repo.do(req) if err != nil { return ocispec.Descriptor{}, err } defer resp.Body.Close() switch resp.StatusCode { case http.StatusOK: return s.generateDescriptor(resp, ref, req.Method) case http.StatusNotFound: return ocispec.Descriptor{}, fmt.Errorf("%s: %w", ref, errdef.ErrNotFound) default: return ocispec.Descriptor{}, errutil.ParseErrorResponse(resp) } } // FetchReference fetches the manifest identified by the reference. // The reference can be a tag or digest. func (s *manifestStore) FetchReference(ctx context.Context, reference string) (desc ocispec.Descriptor, rc io.ReadCloser, err error) { ref, err := s.repo.ParseReference(reference) if err != nil { return ocispec.Descriptor{}, nil, err } ctx = auth.AppendRepositoryScope(ctx, ref, auth.ActionPull) url := buildRepositoryManifestURL(s.repo.PlainHTTP, ref) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return ocispec.Descriptor{}, nil, err } req.Header.Set("Accept", manifestAcceptHeader(s.repo.ManifestMediaTypes)) resp, err := s.repo.do(req) if err != nil { return ocispec.Descriptor{}, nil, err } defer func() { if err != nil { resp.Body.Close() } }() switch resp.StatusCode { case http.StatusOK: if resp.ContentLength == -1 { desc, err = s.Resolve(ctx, reference) } else { desc, err = s.generateDescriptor(resp, ref, req.Method) } if err != nil { return ocispec.Descriptor{}, nil, err } return desc, resp.Body, nil case http.StatusNotFound: return ocispec.Descriptor{}, nil, fmt.Errorf("%s: %w", ref, errdef.ErrNotFound) default: return ocispec.Descriptor{}, nil, errutil.ParseErrorResponse(resp) } } // Tag tags a manifest descriptor with a reference string. func (s *manifestStore) Tag(ctx context.Context, desc ocispec.Descriptor, reference string) error { ref, err := s.repo.ParseReference(reference) if err != nil { return err } ctx = auth.AppendRepositoryScope(ctx, ref, auth.ActionPull, auth.ActionPush) rc, err := s.Fetch(ctx, desc) if err != nil { return err } defer rc.Close() return s.push(ctx, desc, rc, ref.Reference) } // PushReference pushes the manifest with a reference tag. func (s *manifestStore) PushReference(ctx context.Context, expected ocispec.Descriptor, content io.Reader, reference string) error { ref, err := s.repo.ParseReference(reference) if err != nil { return err } return s.pushWithIndexing(ctx, expected, content, ref.Reference) } // push pushes the manifest content, matching the expected descriptor. func (s *manifestStore) push(ctx context.Context, expected ocispec.Descriptor, content io.Reader, reference string) error { ref := s.repo.Reference ref.Reference = reference // pushing usually requires both pull and push actions. // Reference: https://github.com/distribution/distribution/blob/v2.7.1/registry/handlers/app.go#L921-L930 ctx = auth.AppendRepositoryScope(ctx, ref, auth.ActionPull, auth.ActionPush) url := buildRepositoryManifestURL(s.repo.PlainHTTP, ref) // unwrap the content for optimizations of built-in types. body := ioutil.UnwrapNopCloser(content) if _, ok := body.(io.ReadCloser); ok { // undo unwrap if the nopCloser is intended. body = content } req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, body) if err != nil { return err } if req.GetBody != nil && req.ContentLength != expected.Size { // short circuit a size mismatch for built-in types. return fmt.Errorf("mismatch content length %d: expect %d", req.ContentLength, expected.Size) } req.ContentLength = expected.Size req.Header.Set("Content-Type", expected.MediaType) // if the underlying client is an auth client, the content might be read // more than once for obtaining the auth challenge and the actual request. // To prevent double reading, the manifest is read and stored in the memory, // and serve from the memory. client := s.repo.client() if _, ok := client.(*auth.Client); ok && req.GetBody == nil { store := cas.NewMemory() err := store.Push(ctx, expected, content) if err != nil { return err } req.GetBody = func() (io.ReadCloser, error) { return store.Fetch(ctx, expected) } req.Body, err = req.GetBody() if err != nil { return err } } resp, err := s.repo.do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusCreated { return errutil.ParseErrorResponse(resp) } s.checkOCISubjectHeader(resp) return verifyContentDigest(resp, expected.Digest) } // checkOCISubjectHeader checks the "OCI-Subject" header in the response and // sets referrers capability accordingly. // Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#pushing-manifests-with-subject func (s *manifestStore) checkOCISubjectHeader(resp *http.Response) { // If the "OCI-Subject" header is set, it indicates that the registry // supports the Referrers API and has processed the subject of the manifest. if subjectHeader := resp.Header.Get(headerOCISubject); subjectHeader != "" { s.repo.SetReferrersCapability(true) } // If the "OCI-Subject" header is NOT set, it means that either the manifest // has no subject OR the referrers API is NOT supported by the registry. // // Since we don't know whether the pushed manifest has a subject or not, // we do not set the referrers capability to false at here. } // pushWithIndexing pushes the manifest content matching the expected descriptor, // and indexes referrers for the manifest when needed. func (s *manifestStore) pushWithIndexing(ctx context.Context, expected ocispec.Descriptor, r io.Reader, reference string) error { switch expected.MediaType { case spec.MediaTypeArtifactManifest, ocispec.MediaTypeImageManifest, ocispec.MediaTypeImageIndex: if state := s.repo.loadReferrersState(); state == referrersStateSupported { // referrers API is available, no client-side indexing needed return s.push(ctx, expected, r, reference) } if err := limitSize(expected, s.repo.MaxMetadataBytes); err != nil { return err } manifestJSON, err := content.ReadAll(r, expected) if err != nil { return err } if err := s.push(ctx, expected, bytes.NewReader(manifestJSON), reference); err != nil { return err } // check referrers API availability again after push if state := s.repo.loadReferrersState(); state == referrersStateSupported { // the subject has been processed the registry, no client-side // indexing needed return nil } return s.indexReferrersForPush(ctx, expected, manifestJSON) default: return s.push(ctx, expected, r, reference) } } // indexReferrersForPush indexes referrers for manifests with a subject field // on manifest push. // // References: // - Latest spec: https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#pushing-manifests-with-subject // - Compatible spec: https://github.com/opencontainers/distribution-spec/blob/v1.1.0-rc1/spec.md#pushing-manifests-with-subject func (s *manifestStore) indexReferrersForPush(ctx context.Context, desc ocispec.Descriptor, manifestJSON []byte) error { var subject ocispec.Descriptor switch desc.MediaType { case spec.MediaTypeArtifactManifest: var manifest spec.Artifact if err := json.Unmarshal(manifestJSON, &manifest); err != nil { return fmt.Errorf("failed to decode manifest: %s: %s: %w", desc.Digest, desc.MediaType, err) } if manifest.Subject == nil { // no subject, no indexing needed return nil } subject = *manifest.Subject desc.ArtifactType = manifest.ArtifactType desc.Annotations = manifest.Annotations case ocispec.MediaTypeImageManifest: var manifest ocispec.Manifest if err := json.Unmarshal(manifestJSON, &manifest); err != nil { return fmt.Errorf("failed to decode manifest: %s: %s: %w", desc.Digest, desc.MediaType, err) } if manifest.Subject == nil { // no subject, no indexing needed return nil } subject = *manifest.Subject desc.ArtifactType = manifest.ArtifactType if desc.ArtifactType == "" { desc.ArtifactType = manifest.Config.MediaType } desc.Annotations = manifest.Annotations case ocispec.MediaTypeImageIndex: var manifest ocispec.Index if err := json.Unmarshal(manifestJSON, &manifest); err != nil { return fmt.Errorf("failed to decode manifest: %s: %s: %w", desc.Digest, desc.MediaType, err) } if manifest.Subject == nil { // no subject, no indexing needed return nil } subject = *manifest.Subject desc.ArtifactType = manifest.ArtifactType desc.Annotations = manifest.Annotations default: return nil } // if the manifest has a subject but the remote registry does not process it, // it means that the Referrers API is not supported by the registry. s.repo.SetReferrersCapability(false) return s.updateReferrersIndex(ctx, subject, referrerChange{desc, referrerOperationAdd}) } // updateReferrersIndex updates the referrers index for desc referencing subject // on manifest push and manifest delete. // References: // - https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#pushing-manifests-with-subject // - https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#deleting-manifests func (s *manifestStore) updateReferrersIndex(ctx context.Context, subject ocispec.Descriptor, change referrerChange) (err error) { referrersTag := buildReferrersTag(subject) var oldIndexDesc *ocispec.Descriptor var oldReferrers []ocispec.Descriptor prepare := func() error { // 1. pull the original referrers list using the referrers tag schema indexDesc, referrers, err := s.repo.referrersFromIndex(ctx, referrersTag) if err != nil { if errors.Is(err, errdef.ErrNotFound) { // valid case: no old referrers index return nil } return err } oldIndexDesc = &indexDesc oldReferrers = referrers return nil } update := func(referrerChanges []referrerChange) error { // 2. apply the referrer changes on the referrers list updatedReferrers, err := applyReferrerChanges(oldReferrers, referrerChanges) if err != nil { if err == errNoReferrerUpdate { return nil } return err } // 3. push the updated referrers list using referrers tag schema if len(updatedReferrers) > 0 || s.repo.SkipReferrersGC { // push a new index in either case: // 1. the referrers list has been updated with a non-zero size // 2. OR the updated referrers list is empty but referrers GC // is skipped, in this case an empty index should still be pushed // as the old index won't get deleted newIndexDesc, newIndex, err := generateIndex(updatedReferrers) if err != nil { return fmt.Errorf("failed to generate referrers index for referrers tag %s: %w", referrersTag, err) } if err := s.push(ctx, newIndexDesc, bytes.NewReader(newIndex), referrersTag); err != nil { return fmt.Errorf("failed to push referrers index tagged by %s: %w", referrersTag, err) } } // 4. delete the dangling original referrers index, if applicable if s.repo.SkipReferrersGC || oldIndexDesc == nil { return nil } if err := s.repo.delete(ctx, *oldIndexDesc, true); err != nil { return &ReferrersError{ Op: opDeleteReferrersIndex, Err: fmt.Errorf("failed to delete dangling referrers index %s for referrers tag %s: %w", oldIndexDesc.Digest.String(), referrersTag, err), Subject: subject, } } return nil } merge, done := s.repo.referrersMergePool.Get(referrersTag) defer done() return merge.Do(change, prepare, update) } // ParseReference parses a reference to a fully qualified reference. func (s *manifestStore) ParseReference(reference string) (registry.Reference, error) { return s.repo.ParseReference(reference) } // generateDescriptor returns a descriptor generated from the response. // See the truth table at the top of `repository_test.go` func (s *manifestStore) generateDescriptor(resp *http.Response, ref registry.Reference, httpMethod string) (ocispec.Descriptor, error) { // 1. Validate Content-Type mediaType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) if err != nil { return ocispec.Descriptor{}, fmt.Errorf( "%s %q: invalid response `Content-Type` header; %w", resp.Request.Method, resp.Request.URL, err, ) } // 2. Validate Size if resp.ContentLength == -1 { return ocispec.Descriptor{}, fmt.Errorf( "%s %q: unknown response Content-Length", resp.Request.Method, resp.Request.URL, ) } // 3. Validate Client Reference var refDigest digest.Digest if d, err := ref.Digest(); err == nil { refDigest = d } // 4. Validate Server Digest (if present) var serverHeaderDigest digest.Digest if serverHeaderDigestStr := resp.Header.Get(headerDockerContentDigest); serverHeaderDigestStr != "" { if serverHeaderDigest, err = digest.Parse(serverHeaderDigestStr); err != nil { return ocispec.Descriptor{}, fmt.Errorf( "%s %q: invalid response header value: `%s: %s`; %w", resp.Request.Method, resp.Request.URL, headerDockerContentDigest, serverHeaderDigestStr, err, ) } } /* 5. Now, look for specific error conditions; see truth table in method docstring */ var contentDigest digest.Digest if len(serverHeaderDigest) == 0 { if httpMethod == http.MethodHead { if len(refDigest) == 0 { // HEAD without server `Docker-Content-Digest` header is an // immediate fail return ocispec.Descriptor{}, fmt.Errorf( "HTTP %s request missing required header %q", httpMethod, headerDockerContentDigest, ) } // Otherwise, just trust the client-supplied digest contentDigest = refDigest } else { // GET without server `Docker-Content-Digest` header forces the // expensive calculation var calculatedDigest digest.Digest if calculatedDigest, err = calculateDigestFromResponse(resp, s.repo.MaxMetadataBytes); err != nil { return ocispec.Descriptor{}, fmt.Errorf("failed to calculate digest on response body; %w", err) } contentDigest = calculatedDigest } } else { contentDigest = serverHeaderDigest } if len(refDigest) > 0 && refDigest != contentDigest { return ocispec.Descriptor{}, fmt.Errorf( "%s %q: invalid response; digest mismatch in %s: received %q when expecting %q", resp.Request.Method, resp.Request.URL, headerDockerContentDigest, contentDigest, refDigest, ) } // 6. Finally, if we made it this far, then all is good; return. return ocispec.Descriptor{ MediaType: mediaType, Digest: contentDigest, Size: resp.ContentLength, }, nil } // calculateDigestFromResponse calculates the actual digest of the response body // taking care not to destroy it in the process. func calculateDigestFromResponse(resp *http.Response, maxMetadataBytes int64) (digest.Digest, error) { defer resp.Body.Close() body := limitReader(resp.Body, maxMetadataBytes) content, err := io.ReadAll(body) if err != nil { return "", fmt.Errorf("%s %q: failed to read response body: %w", resp.Request.Method, resp.Request.URL, err) } resp.Body = io.NopCloser(bytes.NewReader(content)) return digest.FromBytes(content), nil } // verifyContentDigest verifies "Docker-Content-Digest" header if present. // OCI distribution-spec states the Docker-Content-Digest header is optional. // Reference: https://github.com/opencontainers/distribution-spec/blob/v1.0.1/spec.md#legacy-docker-support-http-headers func verifyContentDigest(resp *http.Response, expected digest.Digest) error { digestStr := resp.Header.Get(headerDockerContentDigest) if len(digestStr) == 0 { return nil } contentDigest, err := digest.Parse(digestStr) if err != nil { return fmt.Errorf( "%s %q: invalid response header: `%s: %s`", resp.Request.Method, resp.Request.URL, headerDockerContentDigest, digestStr, ) } if contentDigest != expected { return fmt.Errorf( "%s %q: invalid response; digest mismatch in %s: received %q when expecting %q", resp.Request.Method, resp.Request.URL, headerDockerContentDigest, contentDigest, expected, ) } return nil } // generateIndex generates an image index containing the given manifests list. func generateIndex(manifests []ocispec.Descriptor) (ocispec.Descriptor, []byte, error) { if manifests == nil { manifests = []ocispec.Descriptor{} // make it an empty array to prevent potential server-side bugs } index := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageIndex, Manifests: manifests, } indexJSON, err := json.Marshal(index) if err != nil { return ocispec.Descriptor{}, nil, err } indexDesc := content.NewDescriptorFromBytes(index.MediaType, indexJSON) return indexDesc, indexJSON, nil } oras-go-2.5.0/registry/remote/repository_test.go000066400000000000000000007313331457674530300220650ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package remote import ( "bytes" "context" "crypto/tls" "encoding/json" "errors" "fmt" "io" "net" "net/http" "net/http/httptest" "net/url" "reflect" "strconv" "strings" "sync/atomic" "testing" "github.com/opencontainers/go-digest" specs "github.com/opencontainers/image-spec/specs-go" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "golang.org/x/sync/errgroup" "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/errdef" "oras.land/oras-go/v2/internal/interfaces" "oras.land/oras-go/v2/internal/spec" "oras.land/oras-go/v2/registry" "oras.land/oras-go/v2/registry/remote/auth" "oras.land/oras-go/v2/registry/remote/errcode" ) type testIOStruct struct { isTag bool clientSuppliedReference string serverCalculatedDigest digest.Digest // for non-HEAD (body-containing) requests only errExpectedOnHEAD bool errExpectedOnGET bool } const theAmazingBanClan = "Ban Gu, Ban Chao, Ban Zhao" const theAmazingBanDigest = "b526a4f2be963a2f9b0990c001255669eab8a254ab1a6e3f84f1820212ac7078" // The following truth table aims to cover the expected GET/HEAD request outcome // for all possible permutations of the client/server "containing a digest", for // both Manifests and Blobs. Where the results between the two differ, the index // of the first column has an exclamation mark. // // The client is said to "contain a digest" if the user-supplied reference string // is of the form that contains a digest rather than a tag. The server, on the // other hand, is said to "contain a digest" if the server responded with the // special header `Docker-Content-Digest`. // // In this table, anything denoted with an asterisk indicates that the true // response should actually be the opposite of what's expected; for example, // `*PASS` means we will get a `PASS`, even though the true answer would be its // diametric opposite--a `FAIL`. This may seem odd, and deserves an explanation. // This function has blind-spots, and while it can expend power to gain sight, // i.e., perform the expensive validation, we chose not to. The reason is two- // fold: a) we "know" that even if we say "!PASS", it will eventually fail later // when checks are performed, and with that assumption, we have the luxury for // the second point, which is b) performance. // // _______________________________________________________________________________________________________________ // | ID | CLIENT | SERVER | Manifest.GET | Blob.GET | Manifest.HEAD | Blob.HEAD | // |----+-----------------+------------------+-----------------------+-----------+---------------------+-----------+ // | 1 | tag | missing | CALCULATE,PASS | n/a | FAIL | n/a | // | 2 | tag | presentCorrect | TRUST,PASS | n/a | TRUST,PASS | n/a | // | 3 | tag | presentIncorrect | TRUST,*PASS | n/a | TRUST,*PASS | n/a | // | 4 | correctDigest | missing | TRUST,PASS | PASS | TRUST,PASS | PASS | // | 5 | correctDigest | presentCorrect | TRUST,COMPARE,PASS | PASS | TRUST,COMPARE,PASS | PASS | // | 6 | correctDigest | presentIncorrect | TRUST,COMPARE,FAIL | FAIL | TRUST,COMPARE,FAIL | FAIL | // --------------------------------------------------------------------------------------------------------------- func getTestIOStructMapForGetDescriptorClass() map[string]testIOStruct { correctDigest := fmt.Sprintf("sha256:%v", theAmazingBanDigest) incorrectDigest := fmt.Sprintf("sha256:%v", "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff") return map[string]testIOStruct{ "1. Client:Tag & Server:DigestMissing": { isTag: true, errExpectedOnHEAD: true, }, "2. Client:Tag & Server:DigestValid": { isTag: true, serverCalculatedDigest: digest.Digest(correctDigest), }, "3. Client:Tag & Server:DigestWrongButSyntacticallyValid": { isTag: true, serverCalculatedDigest: digest.Digest(incorrectDigest), }, "4. Client:DigestValid & Server:DigestMissing": { clientSuppliedReference: correctDigest, }, "5. Client:DigestValid & Server:DigestValid": { clientSuppliedReference: correctDigest, serverCalculatedDigest: digest.Digest(correctDigest), }, "6. Client:DigestValid & Server:DigestWrongButSyntacticallyValid": { clientSuppliedReference: correctDigest, serverCalculatedDigest: digest.Digest(incorrectDigest), errExpectedOnHEAD: true, errExpectedOnGET: true, }, } } func TestRepository_Fetch(t *testing.T) { blob := []byte("hello world") blobDesc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(blob), Size: int64(len(blob)), } index := []byte(`{"manifests":[]}`) indexDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageIndex, Digest: digest.FromBytes(index), Size: int64(len(index)), } ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusMethodNotAllowed) return } switch r.URL.Path { case "/v2/test/blobs/" + blobDesc.Digest.String(): w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Docker-Content-Digest", blobDesc.Digest.String()) if _, err := w.Write(blob); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } case "/v2/test/manifests/" + indexDesc.Digest.String(): if accept := r.Header.Get("Accept"); !strings.Contains(accept, indexDesc.MediaType) { t.Errorf("manifest not convertable: %s", accept) w.WriteHeader(http.StatusBadRequest) return } w.Header().Set("Content-Type", indexDesc.MediaType) w.Header().Set("Docker-Content-Digest", indexDesc.Digest.String()) if _, err := w.Write(index); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true ctx := context.Background() rc, err := repo.Fetch(ctx, blobDesc) if err != nil { t.Fatalf("Repository.Fetch() error = %v", err) } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(rc); err != nil { t.Errorf("fail to read: %v", err) } if err := rc.Close(); err != nil { t.Errorf("fail to close: %v", err) } if got := buf.Bytes(); !bytes.Equal(got, blob) { t.Errorf("Repository.Fetch() = %v, want %v", got, blob) } rc, err = repo.Fetch(ctx, indexDesc) if err != nil { t.Fatalf("Repository.Fetch() error = %v", err) } buf.Reset() if _, err := buf.ReadFrom(rc); err != nil { t.Errorf("fail to read: %v", err) } if err := rc.Close(); err != nil { t.Errorf("fail to close: %v", err) } if got := buf.Bytes(); !bytes.Equal(got, index) { t.Errorf("Repository.Fetch() = %v, want %v", got, index) } } func TestRepository_Push(t *testing.T) { blob := []byte("hello world") blobDesc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(blob), Size: int64(len(blob)), } var gotBlob []byte index := []byte(`{"manifests":[]}`) indexDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageIndex, Digest: digest.FromBytes(index), Size: int64(len(index)), } var gotIndex []byte uuid := "4fd53bc9-565d-4527-ab80-3e051ac4880c" ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPost && r.URL.Path == "/v2/test/blobs/uploads/": w.Header().Set("Location", "/v2/test/blobs/uploads/"+uuid) w.WriteHeader(http.StatusAccepted) return case r.Method == http.MethodPut && r.URL.Path == "/v2/test/blobs/uploads/"+uuid: if contentType := r.Header.Get("Content-Type"); contentType != "application/octet-stream" { w.WriteHeader(http.StatusBadRequest) break } if contentDigest := r.URL.Query().Get("digest"); contentDigest != blobDesc.Digest.String() { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotBlob = buf.Bytes() w.Header().Set("Docker-Content-Digest", blobDesc.Digest.String()) w.WriteHeader(http.StatusCreated) return case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+indexDesc.Digest.String(): if contentType := r.Header.Get("Content-Type"); contentType != indexDesc.MediaType { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotIndex = buf.Bytes() w.Header().Set("Docker-Content-Digest", indexDesc.Digest.String()) w.WriteHeader(http.StatusCreated) return default: w.WriteHeader(http.StatusForbidden) } t.Errorf("unexpected access: %s %s", r.Method, r.URL) })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true ctx := context.Background() err = repo.Push(ctx, blobDesc, bytes.NewReader(blob)) if err != nil { t.Fatalf("Repository.Push() error = %v", err) } if !bytes.Equal(gotBlob, blob) { t.Errorf("Repository.Push() = %v, want %v", gotBlob, blob) } err = repo.Push(ctx, indexDesc, bytes.NewReader(index)) if err != nil { t.Fatalf("Repository.Push() error = %v", err) } if !bytes.Equal(gotIndex, index) { t.Errorf("Repository.Push() = %v, want %v", gotIndex, index) } } func TestRepository_Mount(t *testing.T) { blob := []byte("hello world") blobDesc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(blob), Size: int64(len(blob)), } gotMount := 0 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got, want := r.Method, "POST"; got != want { t.Errorf("unexpected HTTP method; got %q want %q", got, want) w.WriteHeader(http.StatusInternalServerError) return } if err := r.ParseForm(); err != nil { t.Errorf("invalid form in HTTP request: %v", err) w.WriteHeader(http.StatusInternalServerError) return } switch r.URL.Path { case "/v2/test2/blobs/uploads/": if got, want := r.Form.Get("mount"), blobDesc.Digest; digest.Digest(got) != want { t.Errorf("unexpected value for 'mount' parameter; got %q want %q", got, want) } if got, want := r.Form.Get("from"), "test"; got != want { t.Errorf("unexpected value for 'from' parameter; got %q want %q", got, want) } gotMount++ w.Header().Set(headerDockerContentDigest, blobDesc.Digest.String()) w.WriteHeader(201) return default: t.Errorf("unexpected URL for mount request %q", r.URL) w.WriteHeader(http.StatusInternalServerError) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err := NewRepository(uri.Host + "/test2") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true ctx := context.Background() err = repo.Mount(ctx, blobDesc, "test", nil) if err != nil { t.Fatalf("Repository.Push() error = %v", err) } if gotMount != 1 { t.Errorf("did not get expected mount request") } } func TestRepository_Mount_Fallback(t *testing.T) { // This test checks the case where the server does not know // about the mount query parameters, so the call falls back to // the regular push flow. This test is thus very similar to TestPush, // except that it doesn't push a manifest because mounts aren't // documented to be supported for manifests. blob := []byte("hello world") blobDesc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(blob), Size: int64(len(blob)), } var sequence string var gotBlob []byte uuid := "4fd53bc9-565d-4527-ab80-3e051ac4880c" ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPost && r.URL.Path == "/v2/test2/blobs/uploads/": w.Header().Set("Location", "/v2/test2/blobs/uploads/"+uuid) w.WriteHeader(http.StatusAccepted) sequence += "post " return case r.Method == http.MethodGet && r.URL.Path == "/v2/test/blobs/"+blobDesc.Digest.String(): w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Docker-Content-Digest", blobDesc.Digest.String()) if _, err := w.Write(blob); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } sequence += "get " return case r.Method == http.MethodPut && r.URL.Path == "/v2/test2/blobs/uploads/"+uuid: if got, want := r.Header.Get("Content-Type"), "application/octet-stream"; got != want { t.Errorf("unexpected content type; got %q want %q", got, want) w.WriteHeader(http.StatusBadRequest) return } if got, want := r.URL.Query().Get("digest"), blobDesc.Digest.String(); got != want { t.Errorf("unexpected content digest; got %q want %q", got, want) w.WriteHeader(http.StatusBadRequest) return } data, err := io.ReadAll(r.Body) if err != nil { t.Errorf("error reading body: %v", err) w.WriteHeader(http.StatusInternalServerError) return } gotBlob = data w.Header().Set("Docker-Content-Digest", blobDesc.Digest.String()) w.WriteHeader(http.StatusCreated) sequence += "put " return default: w.WriteHeader(http.StatusForbidden) } t.Errorf("unexpected access: %s %s", r.Method, r.URL) })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err := NewRepository(uri.Host + "/test2") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true ctx := context.Background() t.Run("getContent is nil", func(t *testing.T) { sequence = "" err = repo.Mount(ctx, blobDesc, "test", nil) if err != nil { t.Fatalf("Repository.Push() error = %v", err) } if !bytes.Equal(gotBlob, blob) { t.Errorf("Repository.Mount() = %v, want %v", gotBlob, blob) } if got, want := sequence, "post get put "; got != want { t.Errorf("unexpected request sequence; got %q want %q", got, want) } }) t.Run("getContent is non nil", func(t *testing.T) { sequence = "" err = repo.Mount(ctx, blobDesc, "test", func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(blob)), nil }) if err != nil { t.Fatalf("Repository.Push() error = %v", err) } if !bytes.Equal(gotBlob, blob) { t.Errorf("Repository.Mount() = %v, want %v", gotBlob, blob) } if got, want := sequence, "post put "; got != want { t.Errorf("unexpected request sequence; got %q want %q", got, want) } }) } func TestRepository_Mount_Error(t *testing.T) { blob := []byte("hello world") blobDesc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(blob), Size: int64(len(blob)), } ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got, want := r.Method, "POST"; got != want { t.Errorf("unexpected HTTP method; got %q want %q", got, want) w.WriteHeader(http.StatusInternalServerError) return } if err := r.ParseForm(); err != nil { t.Errorf("invalid form in HTTP request: %v", err) w.WriteHeader(http.StatusInternalServerError) return } switch r.URL.Path { case "/v2/test/blobs/uploads/": w.WriteHeader(400) w.Write([]byte(`{ "errors": [ { "code": "NAME_UNKNOWN", "message": "some error" } ] }`)) default: t.Errorf("unexpected URL for mount request %q", r.URL) w.WriteHeader(http.StatusInternalServerError) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true err = repo.Mount(context.Background(), blobDesc, "foo", nil) if err == nil { t.Fatalf("expected error but got success instead") } var errResp *errcode.ErrorResponse if !errors.As(err, &errResp) { t.Fatalf("unexpected error type %#v", err) } if !reflect.DeepEqual(errResp.Errors, errcode.Errors{{ Code: "NAME_UNKNOWN", Message: "some error", }}) { t.Errorf("unexpected errors %#v", errResp.Errors) } } func TestRepository_Mount_Fallback_GetContent(t *testing.T) { // This test checks the case where the server does not know // about the mount query parameters, so the call falls back to // the regular push flow, but using the getContent function // parameter to get the content to push. blob := []byte("hello world") blobDesc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(blob), Size: int64(len(blob)), } var sequence string var gotBlob []byte uuid := "4fd53bc9-565d-4527-ab80-3e051ac4880c" ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPost && r.URL.Path == "/v2/test2/blobs/uploads/": w.Header().Set("Location", "/v2/test2/blobs/uploads/"+uuid) w.WriteHeader(http.StatusAccepted) sequence += "post " return case r.Method == http.MethodPut && r.URL.Path == "/v2/test2/blobs/uploads/"+uuid: if got, want := r.Header.Get("Content-Type"), "application/octet-stream"; got != want { t.Errorf("unexpected content type; got %q want %q", got, want) w.WriteHeader(http.StatusBadRequest) return } if got, want := r.URL.Query().Get("digest"), blobDesc.Digest.String(); got != want { t.Errorf("unexpected content digest; got %q want %q", got, want) w.WriteHeader(http.StatusBadRequest) return } data, err := io.ReadAll(r.Body) if err != nil { t.Errorf("error reading body: %v", err) w.WriteHeader(http.StatusInternalServerError) return } gotBlob = data w.Header().Set("Docker-Content-Digest", blobDesc.Digest.String()) w.WriteHeader(http.StatusCreated) sequence += "put " return default: w.WriteHeader(http.StatusForbidden) } t.Errorf("unexpected access: %s %s", r.Method, r.URL) })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err := NewRepository(uri.Host + "/test2") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true ctx := context.Background() err = repo.Mount(ctx, blobDesc, "test", func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(blob)), nil }) if err != nil { t.Fatalf("Repository.Push() error = %v", err) } if !bytes.Equal(gotBlob, blob) { t.Errorf("Repository.Mount() = %v, want %v", gotBlob, blob) } if got, want := sequence, "post put "; got != want { t.Errorf("unexpected request sequence; got %q want %q", got, want) } } func TestRepository_Mount_Fallback_GetContentError(t *testing.T) { // This test checks the case where the server does not know // about the mount query parameters, so the call falls back to // the regular push flow, but it's possible the caller wants to // avoid the pull/push pattern so returns an error from getContent // and checks it to find out that's happened. blob := []byte("hello world") blobDesc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(blob), Size: int64(len(blob)), } var sequence string uuid := "4fd53bc9-565d-4527-ab80-3e051ac4880c" ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPost && r.URL.Path == "/v2/test2/blobs/uploads/": w.Header().Set("Location", "/v2/test2/blobs/uploads/"+uuid) w.WriteHeader(http.StatusAccepted) sequence += "post " return default: w.WriteHeader(http.StatusForbidden) } t.Errorf("unexpected access: %s %s", r.Method, r.URL) })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err := NewRepository(uri.Host + "/test2") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true ctx := context.Background() testErr := errors.New("test error") err = repo.Mount(ctx, blobDesc, "test", func() (io.ReadCloser, error) { return nil, testErr }) if err == nil { t.Fatalf("expected error but found no error") } if !errors.Is(err, testErr) { t.Fatalf("expected getContent error to be wrapped") } if got, want := sequence, "post "; got != want { t.Errorf("unexpected request sequence; got %q want %q", got, want) } } func TestRepository_Exists(t *testing.T) { blob := []byte("hello world") blobDesc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(blob), Size: int64(len(blob)), } index := []byte(`{"manifests":[]}`) indexDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageIndex, Digest: digest.FromBytes(index), Size: int64(len(index)), } ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodHead { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusMethodNotAllowed) return } switch r.URL.Path { case "/v2/test/blobs/" + blobDesc.Digest.String(): w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Docker-Content-Digest", blobDesc.Digest.String()) w.Header().Set("Content-Length", strconv.Itoa(int(blobDesc.Size))) case "/v2/test/manifests/" + indexDesc.Digest.String(): if accept := r.Header.Get("Accept"); !strings.Contains(accept, indexDesc.MediaType) { t.Errorf("manifest not convertable: %s", accept) w.WriteHeader(http.StatusBadRequest) return } w.Header().Set("Content-Type", indexDesc.MediaType) w.Header().Set("Docker-Content-Digest", indexDesc.Digest.String()) w.Header().Set("Content-Length", strconv.Itoa(int(indexDesc.Size))) default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true ctx := context.Background() exists, err := repo.Exists(ctx, blobDesc) if err != nil { t.Fatalf("Repository.Exists() error = %v", err) } if !exists { t.Errorf("Repository.Exists() = %v, want %v", exists, true) } exists, err = repo.Exists(ctx, indexDesc) if err != nil { t.Fatalf("Repository.Exists() error = %v", err) } if !exists { t.Errorf("Repository.Exists() = %v, want %v", exists, true) } } func TestRepository_Delete(t *testing.T) { blob := []byte("hello world") blobDesc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(blob), Size: int64(len(blob)), } index := []byte(`{"manifests":[]}`) indexDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageIndex, Digest: digest.FromBytes(index), Size: int64(len(index)), } var blobDeleted bool var indexDeleted bool ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodDelete && r.URL.Path == "/v2/test/blobs/"+blobDesc.Digest.String(): blobDeleted = true w.Header().Set("Docker-Content-Digest", blobDesc.Digest.String()) w.WriteHeader(http.StatusAccepted) case r.Method == http.MethodDelete && r.URL.Path == "/v2/test/manifests/"+indexDesc.Digest.String(): indexDeleted = true // no "Docker-Content-Digest" header for manifest deletion w.WriteHeader(http.StatusAccepted) case r.Method == http.MethodGet && r.URL.Path == "/v2/test/manifests/"+indexDesc.Digest.String(): if accept := r.Header.Get("Accept"); !strings.Contains(accept, indexDesc.MediaType) { t.Errorf("manifest not convertable: %s", accept) w.WriteHeader(http.StatusBadRequest) return } w.Header().Set("Content-Type", indexDesc.MediaType) w.Header().Set("Docker-Content-Digest", indexDesc.Digest.String()) if _, err := w.Write(index); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true ctx := context.Background() err = repo.Delete(ctx, blobDesc) if err != nil { t.Fatalf("Repository.Delete() error = %v", err) } if !blobDeleted { t.Errorf("Repository.Delete() = %v, want %v", blobDeleted, true) } err = repo.Delete(ctx, indexDesc) if err != nil { t.Fatalf("Repository.Delete() error = %v", err) } if !indexDeleted { t.Errorf("Repository.Delete() = %v, want %v", indexDeleted, true) } } func TestRepository_Resolve(t *testing.T) { blob := []byte("hello world") blobDesc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(blob), Size: int64(len(blob)), } index := []byte(`{"manifests":[]}`) indexDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageIndex, Digest: digest.FromBytes(index), Size: int64(len(index)), } ref := "foobar" ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodHead { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusMethodNotAllowed) return } switch r.URL.Path { case "/v2/test/manifests/" + blobDesc.Digest.String(): w.WriteHeader(http.StatusNotFound) case "/v2/test/manifests/" + indexDesc.Digest.String(), "/v2/test/manifests/" + ref: if accept := r.Header.Get("Accept"); !strings.Contains(accept, indexDesc.MediaType) { t.Errorf("manifest not convertable: %s", accept) w.WriteHeader(http.StatusBadRequest) return } w.Header().Set("Content-Type", indexDesc.MediaType) w.Header().Set("Docker-Content-Digest", indexDesc.Digest.String()) w.Header().Set("Content-Length", strconv.Itoa(int(indexDesc.Size))) default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repoName := uri.Host + "/test" repo, err := NewRepository(repoName) if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true ctx := context.Background() _, err = repo.Resolve(ctx, blobDesc.Digest.String()) if !errors.Is(err, errdef.ErrNotFound) { t.Errorf("Repository.Resolve() error = %v, wantErr %v", err, errdef.ErrNotFound) } got, err := repo.Resolve(ctx, indexDesc.Digest.String()) if err != nil { t.Fatalf("Repository.Resolve() error = %v", err) } if !reflect.DeepEqual(got, indexDesc) { t.Errorf("Repository.Resolve() = %v, want %v", got, indexDesc) } got, err = repo.Resolve(ctx, ref) if err != nil { t.Fatalf("Repository.Resolve() error = %v", err) } if !reflect.DeepEqual(got, indexDesc) { t.Errorf("Repository.Resolve() = %v, want %v", got, indexDesc) } tagDigestRef := "whatever" + "@" + indexDesc.Digest.String() got, err = repo.Resolve(ctx, tagDigestRef) if err != nil { t.Fatalf("Repository.Resolve() error = %v", err) } if !reflect.DeepEqual(got, indexDesc) { t.Errorf("Repository.Resolve() = %v, want %v", got, indexDesc) } fqdnRef := repoName + ":" + tagDigestRef got, err = repo.Resolve(ctx, fqdnRef) if err != nil { t.Fatalf("Repository.Resolve() error = %v", err) } if !reflect.DeepEqual(got, indexDesc) { t.Errorf("Repository.Resolve() = %v, want %v", got, indexDesc) } } func TestRepository_Tag(t *testing.T) { blob := []byte("hello world") blobDesc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(blob), Size: int64(len(blob)), } index := []byte(`{"manifests":[]}`) indexDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageIndex, Digest: digest.FromBytes(index), Size: int64(len(index)), } var gotIndex []byte ref := "foobar" ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && r.URL.Path == "/v2/test/manifests/"+blobDesc.Digest.String(): w.WriteHeader(http.StatusNotFound) case r.Method == http.MethodGet && r.URL.Path == "/v2/test/manifests/"+indexDesc.Digest.String(): if accept := r.Header.Get("Accept"); !strings.Contains(accept, indexDesc.MediaType) { t.Errorf("manifest not convertable: %s", accept) w.WriteHeader(http.StatusBadRequest) return } w.Header().Set("Content-Type", indexDesc.MediaType) w.Header().Set("Docker-Content-Digest", indexDesc.Digest.String()) if _, err := w.Write(index); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+ref || r.URL.Path == "/v2/test/manifests/"+indexDesc.Digest.String(): if contentType := r.Header.Get("Content-Type"); contentType != indexDesc.MediaType { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotIndex = buf.Bytes() w.Header().Set("Docker-Content-Digest", indexDesc.Digest.String()) w.WriteHeader(http.StatusCreated) return default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusForbidden) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true ctx := context.Background() err = repo.Tag(ctx, blobDesc, ref) if err == nil { t.Errorf("Repository.Tag() error = %v, wantErr %v", err, true) } err = repo.Tag(ctx, indexDesc, ref) if err != nil { t.Fatalf("Repository.Tag() error = %v", err) } if !bytes.Equal(gotIndex, index) { t.Errorf("Repository.Tag() = %v, want %v", gotIndex, index) } gotIndex = nil err = repo.Tag(ctx, indexDesc, indexDesc.Digest.String()) if err != nil { t.Fatalf("Repository.Tag() error = %v", err) } if !bytes.Equal(gotIndex, index) { t.Errorf("Repository.Tag() = %v, want %v", gotIndex, index) } } func TestRepository_PushReference(t *testing.T) { index := []byte(`{"manifests":[]}`) indexDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageIndex, Digest: digest.FromBytes(index), Size: int64(len(index)), } var gotIndex []byte ref := "foobar" ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+ref: if contentType := r.Header.Get("Content-Type"); contentType != indexDesc.MediaType { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotIndex = buf.Bytes() w.Header().Set("Docker-Content-Digest", indexDesc.Digest.String()) w.WriteHeader(http.StatusCreated) return default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusForbidden) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true ctx := context.Background() err = repo.PushReference(ctx, indexDesc, bytes.NewReader(index), ref) if err != nil { t.Fatalf("Repository.PushReference() error = %v", err) } if !bytes.Equal(gotIndex, index) { t.Errorf("Repository.PushReference() = %v, want %v", gotIndex, index) } } func TestRepository_FetchReference(t *testing.T) { blob := []byte("hello world") blobDesc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(blob), Size: int64(len(blob)), } index := []byte(`{"manifests":[]}`) indexDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageIndex, Digest: digest.FromBytes(index), Size: int64(len(index)), } ref := "foobar" ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusMethodNotAllowed) return } switch r.URL.Path { case "/v2/test/manifests/" + blobDesc.Digest.String(): w.WriteHeader(http.StatusNotFound) case "/v2/test/manifests/" + indexDesc.Digest.String(), "/v2/test/manifests/" + ref: if accept := r.Header.Get("Accept"); !strings.Contains(accept, indexDesc.MediaType) { t.Errorf("manifest not convertable: %s", accept) w.WriteHeader(http.StatusBadRequest) return } w.Header().Set("Content-Type", indexDesc.MediaType) w.Header().Set("Docker-Content-Digest", indexDesc.Digest.String()) if _, err := w.Write(index); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repoName := uri.Host + "/test" repo, err := NewRepository(repoName) if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true ctx := context.Background() // test with blob digest _, _, err = repo.FetchReference(ctx, blobDesc.Digest.String()) if !errors.Is(err, errdef.ErrNotFound) { t.Errorf("Repository.FetchReference() error = %v, wantErr %v", err, errdef.ErrNotFound) } // test with manifest digest gotDesc, rc, err := repo.FetchReference(ctx, indexDesc.Digest.String()) if err != nil { t.Fatalf("Repository.FetchReference() error = %v", err) } if !reflect.DeepEqual(gotDesc, indexDesc) { t.Errorf("Repository.FetchReference() = %v, want %v", gotDesc, indexDesc) } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(rc); err != nil { t.Errorf("fail to read: %v", err) } if err := rc.Close(); err != nil { t.Errorf("fail to close: %v", err) } if got := buf.Bytes(); !bytes.Equal(got, index) { t.Errorf("Repository.FetchReference() = %v, want %v", got, index) } // test with manifest tag gotDesc, rc, err = repo.FetchReference(ctx, ref) if err != nil { t.Fatalf("Repository.FetchReference() error = %v", err) } if !reflect.DeepEqual(gotDesc, indexDesc) { t.Errorf("Repository.FetchReference() = %v, want %v", gotDesc, indexDesc) } buf.Reset() if _, err := buf.ReadFrom(rc); err != nil { t.Errorf("fail to read: %v", err) } if err := rc.Close(); err != nil { t.Errorf("fail to close: %v", err) } if got := buf.Bytes(); !bytes.Equal(got, index) { t.Errorf("Repository.FetchReference() = %v, want %v", got, index) } // test with manifest tag@digest tagDigestRef := "whatever" + "@" + indexDesc.Digest.String() gotDesc, rc, err = repo.FetchReference(ctx, tagDigestRef) if err != nil { t.Fatalf("Repository.FetchReference() error = %v", err) } if !reflect.DeepEqual(gotDesc, indexDesc) { t.Errorf("Repository.FetchReference() = %v, want %v", gotDesc, indexDesc) } buf.Reset() if _, err := buf.ReadFrom(rc); err != nil { t.Errorf("fail to read: %v", err) } if err := rc.Close(); err != nil { t.Errorf("fail to close: %v", err) } if got := buf.Bytes(); !bytes.Equal(got, index) { t.Errorf("Repository.FetchReference() = %v, want %v", got, index) } // test with manifest FQDN fqdnRef := repoName + ":" + tagDigestRef gotDesc, rc, err = repo.FetchReference(ctx, fqdnRef) if err != nil { t.Fatalf("Repository.FetchReference() error = %v", err) } if !reflect.DeepEqual(gotDesc, indexDesc) { t.Errorf("Repository.FetchReference() = %v, want %v", gotDesc, indexDesc) } buf.Reset() if _, err := buf.ReadFrom(rc); err != nil { t.Errorf("fail to read: %v", err) } if err := rc.Close(); err != nil { t.Errorf("fail to close: %v", err) } if got := buf.Bytes(); !bytes.Equal(got, index) { t.Errorf("Repository.FetchReference() = %v, want %v", got, index) } } func TestRepository_Tags(t *testing.T) { tagSet := [][]string{ {"the", "quick", "brown", "fox"}, {"jumps", "over", "the", "lazy"}, {"dog"}, } var ts *httptest.Server ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || r.URL.Path != "/v2/test/tags/list" { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } q := r.URL.Query() n, err := strconv.Atoi(q.Get("n")) if err != nil || n != 4 { t.Errorf("bad page size: %s", q.Get("n")) w.WriteHeader(http.StatusBadRequest) return } var tags []string switch q.Get("test") { case "foo": tags = tagSet[1] w.Header().Set("Link", fmt.Sprintf(`<%s/v2/test/tags/list?n=4&test=bar>; rel="next"`, ts.URL)) case "bar": tags = tagSet[2] default: tags = tagSet[0] w.Header().Set("Link", `; rel="next"`) } result := struct { Tags []string `json:"tags"` }{ Tags: tags, } if err := json.NewEncoder(w).Encode(result); err != nil { t.Errorf("failed to write response: %v", err) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true repo.TagListPageSize = 4 ctx := context.Background() index := 0 if err := repo.Tags(ctx, "", func(got []string) error { if index > 2 { t.Fatalf("out of index bound: %d", index) } tags := tagSet[index] index++ if !reflect.DeepEqual(got, tags) { t.Errorf("Repository.Tags() = %v, want %v", got, tags) } return nil }); err != nil { t.Errorf("Repository.Tags() error = %v", err) } } func TestRepository_Predecessors(t *testing.T) { manifest := []byte(`{"layers":[]}`) manifestDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageManifest, Digest: digest.FromBytes(manifest), Size: int64(len(manifest)), } referrerSet := [][]ocispec.Descriptor{ { { MediaType: spec.MediaTypeArtifactManifest, Size: 1, Digest: digest.FromString("1"), ArtifactType: "application/vnd.test", }, { MediaType: spec.MediaTypeArtifactManifest, Size: 2, Digest: digest.FromString("2"), ArtifactType: "application/vnd.test", }, }, { { MediaType: spec.MediaTypeArtifactManifest, Size: 3, Digest: digest.FromString("3"), ArtifactType: "application/vnd.test", }, { MediaType: spec.MediaTypeArtifactManifest, Size: 4, Digest: digest.FromString("4"), ArtifactType: "application/vnd.test", }, }, { { MediaType: spec.MediaTypeArtifactManifest, Size: 5, Digest: digest.FromString("5"), ArtifactType: "application/vnd.test", }, }, } var ts *httptest.Server ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { path := "/v2/test/referrers/" + manifestDesc.Digest.String() if r.Method != http.MethodGet || r.URL.Path != path { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } q := r.URL.Query() n, err := strconv.Atoi(q.Get("n")) if err != nil || n != 2 { t.Errorf("bad page size: %s", q.Get("n")) w.WriteHeader(http.StatusBadRequest) return } var referrers []ocispec.Descriptor switch q.Get("test") { case "foo": referrers = referrerSet[1] w.Header().Set("Link", fmt.Sprintf(`<%s%s?n=2&test=bar>; rel="next"`, ts.URL, path)) case "bar": referrers = referrerSet[2] default: referrers = referrerSet[0] w.Header().Set("Link", fmt.Sprintf(`<%s?n=2&test=foo>; rel="next"`, path)) } result := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageIndex, Manifests: referrers, } w.Header().Set("Content-Type", ocispec.MediaTypeImageIndex) if err := json.NewEncoder(w).Encode(result); err != nil { t.Errorf("failed to write response: %v", err) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true repo.ReferrerListPageSize = 2 ctx := context.Background() got, err := repo.Predecessors(ctx, manifestDesc) if err != nil { t.Fatalf("Repository.Predecessors() error = %v", err) } var want []ocispec.Descriptor for _, referrers := range referrerSet { want = append(want, referrers...) } if !reflect.DeepEqual(got, want) { t.Errorf("Repository.Predecessors() = %v, want %v", got, want) } } func TestRepository_Referrers(t *testing.T) { manifest := []byte(`{"layers":[]}`) manifestDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageManifest, Digest: digest.FromBytes(manifest), Size: int64(len(manifest)), } referrerSet := [][]ocispec.Descriptor{ { { MediaType: spec.MediaTypeArtifactManifest, Size: 1, Digest: digest.FromString("1"), ArtifactType: "application/vnd.test", }, { MediaType: spec.MediaTypeArtifactManifest, Size: 2, Digest: digest.FromString("2"), ArtifactType: "application/vnd.test", }, }, { { MediaType: spec.MediaTypeArtifactManifest, Size: 3, Digest: digest.FromString("3"), ArtifactType: "application/vnd.test", }, { MediaType: spec.MediaTypeArtifactManifest, Size: 4, Digest: digest.FromString("4"), ArtifactType: "application/vnd.test", }, }, { { MediaType: spec.MediaTypeArtifactManifest, Size: 5, Digest: digest.FromString("5"), ArtifactType: "application/vnd.test", }, }, } var ts *httptest.Server ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { path := "/v2/test/referrers/" + manifestDesc.Digest.String() if r.Method != http.MethodGet || r.URL.Path != path { referrersTag := strings.Replace(manifestDesc.Digest.String(), ":", "-", 1) if r.URL.Path != "/v2/test/manifests/"+referrersTag { t.Errorf("unexpected access: %s %q", r.Method, r.URL) } w.WriteHeader(http.StatusNotFound) return } q := r.URL.Query() n, err := strconv.Atoi(q.Get("n")) if err != nil || n != 2 { t.Errorf("bad page size: %s", q.Get("n")) w.WriteHeader(http.StatusBadRequest) return } var referrers []ocispec.Descriptor switch q.Get("test") { case "foo": referrers = referrerSet[1] w.Header().Set("Link", fmt.Sprintf(`<%s%s?n=2&test=bar>; rel="next"`, ts.URL, path)) case "bar": referrers = referrerSet[2] default: referrers = referrerSet[0] w.Header().Set("Link", fmt.Sprintf(`<%s?n=2&test=foo>; rel="next"`, path)) } result := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageIndex, Manifests: referrers, } w.Header().Set("Content-Type", ocispec.MediaTypeImageIndex) if err := json.NewEncoder(w).Encode(result); err != nil { t.Errorf("failed to write response: %v", err) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } ctx := context.Background() // test auto detect // remote server supports Referrers, should be no error repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true repo.ReferrerListPageSize = 2 if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } index := 0 if err := repo.Referrers(ctx, manifestDesc, "", func(got []ocispec.Descriptor) error { if index >= len(referrerSet) { t.Fatalf("out of index bound: %d", index) } referrers := referrerSet[index] index++ if !reflect.DeepEqual(got, referrers) { t.Errorf("Repository.Referrers() = %v, want %v", got, referrers) } return nil }); err != nil { t.Errorf("Repository.Referrers() error = %v", err) } if state := repo.loadReferrersState(); state != referrersStateSupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateSupported) } // test force attempt Referrers // remote server supports Referrers, should be no error repo, err = NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true repo.ReferrerListPageSize = 2 repo.SetReferrersCapability(true) if state := repo.loadReferrersState(); state != referrersStateSupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateSupported) } index = 0 if err := repo.Referrers(ctx, manifestDesc, "", func(got []ocispec.Descriptor) error { if index >= len(referrerSet) { t.Fatalf("out of index bound: %d", index) } referrers := referrerSet[index] index++ if !reflect.DeepEqual(got, referrers) { t.Errorf("Repository.Referrers() = %v, want %v", got, referrers) } return nil }); err != nil { t.Errorf("Repository.Referrers() error = %v", err) } if state := repo.loadReferrersState(); state != referrersStateSupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateSupported) } // test force attempt tag schema // request tag schema but got 404, should be no error repo, err = NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true repo.ReferrerListPageSize = 2 repo.SetReferrersCapability(false) if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } if err := repo.Referrers(ctx, manifestDesc, "", func(got []ocispec.Descriptor) error { return nil }); err != nil { t.Errorf("Repository.Referrers() error = %v", err) } if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } } func TestRepository_Referrers_TagSchemaFallback(t *testing.T) { manifest := []byte(`{"layers":[]}`) manifestDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageManifest, Digest: digest.FromBytes(manifest), Size: int64(len(manifest)), } referrers := []ocispec.Descriptor{ { MediaType: spec.MediaTypeArtifactManifest, Size: 1, Digest: digest.FromString("1"), ArtifactType: "application/vnd.test", }, { MediaType: spec.MediaTypeArtifactManifest, Size: 2, Digest: digest.FromString("2"), ArtifactType: "application/vnd.test", }, { MediaType: spec.MediaTypeArtifactManifest, Size: 3, Digest: digest.FromString("3"), ArtifactType: "application/vnd.test", }, { MediaType: spec.MediaTypeArtifactManifest, Size: 4, Digest: digest.FromString("4"), ArtifactType: "application/vnd.test", }, { MediaType: spec.MediaTypeArtifactManifest, Size: 5, Digest: digest.FromString("5"), ArtifactType: "application/vnd.test", }, } ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { referrersTag := strings.Replace(manifestDesc.Digest.String(), ":", "-", 1) path := "/v2/test/manifests/" + referrersTag if r.Method != http.MethodGet || r.URL.Path != path { if r.URL.Path != "/v2/test/referrers/"+manifestDesc.Digest.String() { t.Errorf("unexpected access: %s %q", r.Method, r.URL) } w.WriteHeader(http.StatusNotFound) return } result := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageIndex, Manifests: referrers, } if err := json.NewEncoder(w).Encode(result); err != nil { t.Errorf("failed to write response: %v", err) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } ctx := context.Background() // test auto detect // remote server does not support Referrers, should fallback to tag schema repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } if err := repo.Referrers(ctx, manifestDesc, "", func(got []ocispec.Descriptor) error { if !reflect.DeepEqual(got, referrers) { t.Errorf("Repository.Referrers() = %v, want %v", got, referrers) } return nil }); err != nil { t.Errorf("Repository.Referrers() error = %v", err) } if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } // test force attempt Referrers // remote server does not support Referrers, should return error repo, err = NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true repo.SetReferrersCapability(true) if state := repo.loadReferrersState(); state != referrersStateSupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateSupported) } if err := repo.Referrers(ctx, manifestDesc, "", func(got []ocispec.Descriptor) error { return nil }); err == nil { t.Errorf("Repository.Referrers() error = %v, wantErr %v", err, true) } if state := repo.loadReferrersState(); state != referrersStateSupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateSupported) } // test force attempt tag schema // should request tag schema repo, err = NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true repo.SetReferrersCapability(false) if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } if err := repo.Referrers(ctx, manifestDesc, "", func(got []ocispec.Descriptor) error { if !reflect.DeepEqual(got, referrers) { t.Errorf("Repository.Referrers() = %v, want %v", got, referrers) } return nil }); err != nil { t.Errorf("Repository.Referrers() error = %v", err) } if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } } func TestRepository_Referrers_TagSchemaFallback_NotFound(t *testing.T) { manifest := []byte(`{"layers":[]}`) manifestDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageManifest, Digest: digest.FromBytes(manifest), Size: int64(len(manifest)), } ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { referrersUrl := "/v2/test/referrers/" + manifestDesc.Digest.String() referrersTag := strings.Replace(manifestDesc.Digest.String(), ":", "-", 1) tagSchemaUrl := "/v2/test/manifests/" + referrersTag if r.Method == http.MethodGet || r.URL.Path == referrersUrl || r.URL.Path == tagSchemaUrl { w.WriteHeader(http.StatusNotFound) return } t.Errorf("unexpected access: %s %q", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } ctx := context.Background() // test auto detect // tag schema referrers not found, should be no error repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } if err := repo.Referrers(ctx, manifestDesc, "", func(got []ocispec.Descriptor) error { return nil }); err != nil { t.Errorf("Repository.Referrers() error = %v", err) } if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } // test force attempt tag schema // tag schema referrers not found, should be no error repo, err = NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true repo.SetReferrersCapability(false) if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } if err := repo.Referrers(ctx, manifestDesc, "", func(got []ocispec.Descriptor) error { return nil }); err != nil { t.Errorf("Repository.Referrers() error = %v", err) } if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } } func TestRepository_Referrers_TagSchemaFallback_ContentType(t *testing.T) { manifest := []byte(`{"layers":[]}`) manifestDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageManifest, Digest: digest.FromBytes(manifest), Size: int64(len(manifest)), } ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { referrersUrl := "/v2/test/referrers/" + manifestDesc.Digest.String() referrersTag := strings.Replace(manifestDesc.Digest.String(), ":", "-", 1) tagSchemaUrl := "/v2/test/manifests/" + referrersTag if r.URL.Path == referrersUrl { w.Header().Set("Content-Type", "application/json") // not an OCI image index w.WriteHeader(http.StatusOK) return } if r.Method == http.MethodGet || r.URL.Path == tagSchemaUrl { w.WriteHeader(http.StatusNotFound) return } t.Errorf("unexpected access: %s %q", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } ctx := context.Background() // test auto detect // tag schema referrers not found, should be no error repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } if err := repo.Referrers(ctx, manifestDesc, "", func(got []ocispec.Descriptor) error { return nil }); err != nil { t.Errorf("Repository.Referrers() error = %v", err) } if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } } func TestRepository_Referrers_BadRequest(t *testing.T) { manifest := []byte(`{"layers":[]}`) manifestDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageManifest, Digest: digest.FromBytes(manifest), Size: int64(len(manifest)), } ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { referrersUrl := "/v2/test/referrers/" + manifestDesc.Digest.String() referrersTag := strings.Replace(manifestDesc.Digest.String(), ":", "-", 1) tagSchemaUrl := "/v2/test/manifests/" + referrersTag if r.Method == http.MethodGet || r.URL.Path == referrersUrl || r.URL.Path == tagSchemaUrl { w.WriteHeader(http.StatusBadRequest) return } t.Errorf("unexpected access: %s %q", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } ctx := context.Background() // test auto detect // Referrers returns error repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } if err := repo.Referrers(ctx, manifestDesc, "", func(got []ocispec.Descriptor) error { return nil }); err == nil { t.Errorf("Repository.Referrers() error = nil, wantErr %v", true) } if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } // test force attempt Referrers // Referrers returns error repo, err = NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true repo.SetReferrersCapability(true) if state := repo.loadReferrersState(); state != referrersStateSupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateSupported) } if err := repo.Referrers(ctx, manifestDesc, "", func(got []ocispec.Descriptor) error { return nil }); err == nil { t.Errorf("Repository.Referrers() error = nil, wantErr %v", true) } if state := repo.loadReferrersState(); state != referrersStateSupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateSupported) } // test force attempt tag schema // Referrers returns error repo, err = NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true repo.SetReferrersCapability(false) if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } if err := repo.Referrers(ctx, manifestDesc, "", func(got []ocispec.Descriptor) error { return nil }); err == nil { t.Errorf("Repository.Referrers() error = nil, wantErr %v", true) } if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } } func TestRepository_Referrers_RepositoryNotFound(t *testing.T) { manifest := []byte(`{"layers":[]}`) manifestDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageManifest, Digest: digest.FromBytes(manifest), Size: int64(len(manifest)), } ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { referrersUrl := "/v2/test/referrers/" + manifestDesc.Digest.String() referrersTag := strings.Replace(manifestDesc.Digest.String(), ":", "-", 1) tagSchemaUrl := "/v2/test/manifests/" + referrersTag if r.Method == http.MethodGet && (r.URL.Path == referrersUrl || r.URL.Path == tagSchemaUrl) { w.WriteHeader(http.StatusNotFound) w.Write([]byte(`{ "errors": [ { "code": "NAME_UNKNOWN", "message": "repository name not known to registry" } ] }`)) return } t.Errorf("unexpected access: %s %q", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } ctx := context.Background() // test auto detect // repository not found, should return error repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } if err := repo.Referrers(ctx, manifestDesc, "", func(got []ocispec.Descriptor) error { return nil }); err == nil { t.Errorf("Repository.Referrers() error = %v, wantErr %v", err, true) } if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } // test force attempt Referrers // repository not found, should return error repo, err = NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true repo.SetReferrersCapability(true) if state := repo.loadReferrersState(); state != referrersStateSupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateSupported) } if err := repo.Referrers(ctx, manifestDesc, "", func(got []ocispec.Descriptor) error { return nil }); err == nil { t.Errorf("Repository.Referrers() error = %v, wantErr %v", err, true) } if state := repo.loadReferrersState(); state != referrersStateSupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateSupported) } // test force attempt tag schema // repository not found, but should not return error repo, err = NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true repo.SetReferrersCapability(false) if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } if err := repo.Referrers(ctx, manifestDesc, "", func(got []ocispec.Descriptor) error { return nil }); err != nil { t.Errorf("Repository.Referrers() error = %v, wantErr %v", err, nil) } if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } } func TestRepository_Referrers_ServerFiltering(t *testing.T) { manifest := []byte(`{"layers":[]}`) manifestDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageManifest, Digest: digest.FromBytes(manifest), Size: int64(len(manifest)), } referrerSet := [][]ocispec.Descriptor{ { { MediaType: spec.MediaTypeArtifactManifest, Size: 1, Digest: digest.FromString("1"), ArtifactType: "application/vnd.test", }, { MediaType: spec.MediaTypeArtifactManifest, Size: 2, Digest: digest.FromString("2"), ArtifactType: "application/vnd.test", }, }, { { MediaType: spec.MediaTypeArtifactManifest, Size: 3, Digest: digest.FromString("3"), ArtifactType: "application/vnd.test", }, { MediaType: spec.MediaTypeArtifactManifest, Size: 4, Digest: digest.FromString("4"), ArtifactType: "application/vnd.test", }, }, { { MediaType: spec.MediaTypeArtifactManifest, Size: 5, Digest: digest.FromString("5"), ArtifactType: "application/vnd.test", }, }, } // Test with filter annotations only var ts *httptest.Server ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { path := "/v2/test/referrers/" + manifestDesc.Digest.String() queryParams, err := url.ParseQuery(r.URL.RawQuery) if err != nil { t.Fatal("failed to parse url query") } if r.Method != http.MethodGet || r.URL.Path != path || reflect.DeepEqual(queryParams["artifactType"], []string{"application%2Fvnd.test"}) { t.Errorf("unexpected access: %s %q", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } q := r.URL.Query() n, err := strconv.Atoi(q.Get("n")) if err != nil || n != 2 { t.Errorf("bad page size: %s", q.Get("n")) w.WriteHeader(http.StatusBadRequest) return } var referrers []ocispec.Descriptor switch q.Get("test") { case "foo": referrers = referrerSet[1] w.Header().Set("Link", fmt.Sprintf(`<%s%s?n=2&test=bar>; rel="next"`, ts.URL, path)) case "bar": referrers = referrerSet[2] default: referrers = referrerSet[0] w.Header().Set("Link", fmt.Sprintf(`<%s?n=2&test=foo>; rel="next"`, path)) } result := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageIndex, Manifests: referrers, // set filter annotations Annotations: map[string]string{ spec.AnnotationReferrersFiltersApplied: "artifactType", }, } w.Header().Set("Content-Type", ocispec.MediaTypeImageIndex) if err := json.NewEncoder(w).Encode(result); err != nil { t.Errorf("failed to write response: %v", err) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true repo.ReferrerListPageSize = 2 ctx := context.Background() index := 0 if err := repo.Referrers(ctx, manifestDesc, "application/vnd.test", func(got []ocispec.Descriptor) error { if index >= len(referrerSet) { t.Fatalf("out of index bound: %d", index) } referrers := referrerSet[index] index++ if !reflect.DeepEqual(got, referrers) { t.Errorf("Repository.Referrers() = %v, want %v", got, referrers) } return nil }); err != nil { t.Errorf("Repository.Referrers() error = %v", err) } if index != len(referrerSet) { t.Errorf("fn invoked %d time(s), want %d", index, len(referrerSet)) } // Test with filter header only ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { path := "/v2/test/referrers/" + manifestDesc.Digest.String() queryParams, err := url.ParseQuery(r.URL.RawQuery) if err != nil { t.Fatal("failed to parse url query") } if r.Method != http.MethodGet || r.URL.Path != path || reflect.DeepEqual(queryParams["artifactType"], []string{"application%2Fvnd.test"}) { t.Errorf("unexpected access: %s %q", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } q := r.URL.Query() n, err := strconv.Atoi(q.Get("n")) if err != nil || n != 2 { t.Errorf("bad page size: %s", q.Get("n")) w.WriteHeader(http.StatusBadRequest) return } var referrers []ocispec.Descriptor switch q.Get("test") { case "foo": referrers = referrerSet[1] w.Header().Set("Link", fmt.Sprintf(`<%s%s?n=2&test=bar>; rel="next"`, ts.URL, path)) case "bar": referrers = referrerSet[2] default: referrers = referrerSet[0] w.Header().Set("Link", fmt.Sprintf(`<%s?n=2&test=foo>; rel="next"`, path)) } result := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageIndex, Manifests: referrers, } w.Header().Set("Content-Type", ocispec.MediaTypeImageIndex) // set filter header w.Header().Set("OCI-Filters-Applied", "artifactType") if err := json.NewEncoder(w).Encode(result); err != nil { t.Errorf("failed to write response: %v", err) } })) defer ts.Close() uri, err = url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err = NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true repo.ReferrerListPageSize = 2 ctx = context.Background() index = 0 if err := repo.Referrers(ctx, manifestDesc, "application/vnd.test", func(got []ocispec.Descriptor) error { if index >= len(referrerSet) { t.Fatalf("out of index bound: %d", index) } referrers := referrerSet[index] index++ if !reflect.DeepEqual(got, referrers) { t.Errorf("Repository.Referrers() = %v, want %v", got, referrers) } return nil }); err != nil { t.Errorf("Repository.Referrers() error = %v", err) } if index != len(referrerSet) { t.Errorf("fn invoked %d time(s), want %d", index, len(referrerSet)) } // Test with both filter annotation and filter header ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { path := "/v2/test/referrers/" + manifestDesc.Digest.String() queryParams, err := url.ParseQuery(r.URL.RawQuery) if err != nil { t.Fatal("failed to parse url query") } if r.Method != http.MethodGet || r.URL.Path != path || reflect.DeepEqual(queryParams["artifactType"], []string{"application%2Fvnd.test"}) { t.Errorf("unexpected access: %s %q", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } q := r.URL.Query() n, err := strconv.Atoi(q.Get("n")) if err != nil || n != 2 { t.Errorf("bad page size: %s", q.Get("n")) w.WriteHeader(http.StatusBadRequest) return } var referrers []ocispec.Descriptor switch q.Get("test") { case "foo": referrers = referrerSet[1] w.Header().Set("Link", fmt.Sprintf(`<%s%s?n=2&test=bar>; rel="next"`, ts.URL, path)) case "bar": referrers = referrerSet[2] default: referrers = referrerSet[0] w.Header().Set("Link", fmt.Sprintf(`<%s?n=2&test=foo>; rel="next"`, path)) } result := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageIndex, Manifests: referrers, // set filter annotation Annotations: map[string]string{ spec.AnnotationReferrersFiltersApplied: "artifactType", }, } w.Header().Set("Content-Type", ocispec.MediaTypeImageIndex) // set filter header w.Header().Set("OCI-Filters-Applied", "artifactType") if err := json.NewEncoder(w).Encode(result); err != nil { t.Errorf("failed to write response: %v", err) } })) defer ts.Close() uri, err = url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err = NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true repo.ReferrerListPageSize = 2 ctx = context.Background() index = 0 if err := repo.Referrers(ctx, manifestDesc, "application/vnd.test", func(got []ocispec.Descriptor) error { if index >= len(referrerSet) { t.Fatalf("out of index bound: %d", index) } referrers := referrerSet[index] index++ if !reflect.DeepEqual(got, referrers) { t.Errorf("Repository.Referrers() = %v, want %v", got, referrers) } return nil }); err != nil { t.Errorf("Repository.Referrers() error = %v", err) } if index != len(referrerSet) { t.Errorf("fn invoked %d time(s), want %d", index, len(referrerSet)) } } func TestRepository_Referrers_ClientFiltering(t *testing.T) { manifest := []byte(`{"layers":[]}`) manifestDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageManifest, Digest: digest.FromBytes(manifest), Size: int64(len(manifest)), } referrerSet := [][]ocispec.Descriptor{ { { MediaType: spec.MediaTypeArtifactManifest, Size: 1, Digest: digest.FromString("1"), ArtifactType: "application/vnd.test", }, { MediaType: spec.MediaTypeArtifactManifest, Size: 2, Digest: digest.FromString("2"), ArtifactType: "application/vnd.foo", }, }, { { MediaType: spec.MediaTypeArtifactManifest, Size: 3, Digest: digest.FromString("3"), ArtifactType: "application/vnd.test", }, { MediaType: spec.MediaTypeArtifactManifest, Size: 4, Digest: digest.FromString("4"), ArtifactType: "application/vnd.bar", }, }, { { MediaType: spec.MediaTypeArtifactManifest, Size: 5, Digest: digest.FromString("5"), ArtifactType: "application/vnd.baz", }, }, } filteredReferrerSet := [][]ocispec.Descriptor{ { { MediaType: spec.MediaTypeArtifactManifest, Size: 1, Digest: digest.FromString("1"), ArtifactType: "application/vnd.test", }, }, { { MediaType: spec.MediaTypeArtifactManifest, Size: 3, Digest: digest.FromString("3"), ArtifactType: "application/vnd.test", }, }, } var ts *httptest.Server ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { path := "/v2/test/referrers/" + manifestDesc.Digest.String() queryParams, err := url.ParseQuery(r.URL.RawQuery) if err != nil { t.Fatal("failed to parse url query") } if r.Method != http.MethodGet || r.URL.Path != path || reflect.DeepEqual(queryParams["artifactType"], []string{"application%2Fvnd.test"}) { t.Errorf("unexpected access: %s %q", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } q := r.URL.Query() n, err := strconv.Atoi(q.Get("n")) if err != nil || n != 2 { t.Errorf("bad page size: %s", q.Get("n")) w.WriteHeader(http.StatusBadRequest) return } var referrers []ocispec.Descriptor switch q.Get("test") { case "foo": referrers = referrerSet[1] w.Header().Set("Link", fmt.Sprintf(`<%s%s?n=2&test=bar>; rel="next"`, ts.URL, path)) case "bar": referrers = referrerSet[2] default: referrers = referrerSet[0] w.Header().Set("Link", fmt.Sprintf(`<%s?n=2&test=foo>; rel="next"`, path)) } result := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageIndex, Manifests: referrers, } w.Header().Set("Content-Type", ocispec.MediaTypeImageIndex) if err := json.NewEncoder(w).Encode(result); err != nil { t.Errorf("failed to write response: %v", err) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true repo.ReferrerListPageSize = 2 ctx := context.Background() index := 0 if err := repo.Referrers(ctx, manifestDesc, "application/vnd.test", func(got []ocispec.Descriptor) error { if index >= len(filteredReferrerSet) { t.Fatalf("out of index bound: %d", index) } referrers := filteredReferrerSet[index] index++ if !reflect.DeepEqual(got, referrers) { t.Errorf("Repository.Referrers() = %v, want %v", got, referrers) } return nil }); err != nil { t.Errorf("Repository.Referrers() error = %v", err) } if index != len(filteredReferrerSet) { t.Errorf("fn invoked %d time(s), want %d", index, len(referrerSet)) } } func TestRepository_Referrers_TagSchemaFallback_ClientFiltering(t *testing.T) { manifest := []byte(`{"layers":[]}`) manifestDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageManifest, Digest: digest.FromBytes(manifest), Size: int64(len(manifest)), } referrers := []ocispec.Descriptor{ { MediaType: spec.MediaTypeArtifactManifest, Size: 1, Digest: digest.FromString("1"), ArtifactType: "application/vnd.test", }, { MediaType: spec.MediaTypeArtifactManifest, Size: 2, Digest: digest.FromString("2"), ArtifactType: "application/vnd.foo", }, { MediaType: spec.MediaTypeArtifactManifest, Size: 3, Digest: digest.FromString("3"), ArtifactType: "application/vnd.test", }, { MediaType: spec.MediaTypeArtifactManifest, Size: 4, Digest: digest.FromString("4"), ArtifactType: "application/vnd.bar", }, { MediaType: spec.MediaTypeArtifactManifest, Size: 5, Digest: digest.FromString("5"), ArtifactType: "application/vnd.baz", }, } filteredReferrers := []ocispec.Descriptor{ { MediaType: spec.MediaTypeArtifactManifest, Size: 1, Digest: digest.FromString("1"), ArtifactType: "application/vnd.test", }, { MediaType: spec.MediaTypeArtifactManifest, Size: 3, Digest: digest.FromString("3"), ArtifactType: "application/vnd.test", }, } ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { referrersTag := strings.Replace(manifestDesc.Digest.String(), ":", "-", 1) path := "/v2/test/manifests/" + referrersTag if r.Method != http.MethodGet || r.URL.Path != path { if r.URL.Path != "/v2/test/referrers/"+manifestDesc.Digest.String() { t.Errorf("unexpected access: %s %q", r.Method, r.URL) } w.WriteHeader(http.StatusNotFound) return } result := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageIndex, Manifests: referrers, } if err := json.NewEncoder(w).Encode(result); err != nil { t.Errorf("failed to write response: %v", err) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true ctx := context.Background() if err := repo.Referrers(ctx, manifestDesc, "application/vnd.test", func(got []ocispec.Descriptor) error { if !reflect.DeepEqual(got, filteredReferrers) { t.Errorf("Repository.Referrers() = %v, want %v", got, filteredReferrers) } return nil }); err != nil { t.Errorf("Repository.Referrers() error = %v", err) } } func Test_BlobStore_Fetch(t *testing.T) { blob := []byte("hello world") blobDesc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(blob), Size: int64(len(blob)), } ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusMethodNotAllowed) return } switch r.URL.Path { case "/v2/test/blobs/" + blobDesc.Digest.String(): w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Docker-Content-Digest", blobDesc.Digest.String()) if _, err := w.Write(blob); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } default: w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true store := repo.Blobs() ctx := context.Background() rc, err := store.Fetch(ctx, blobDesc) if err != nil { t.Fatalf("Blobs.Fetch() error = %v", err) } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(rc); err != nil { t.Errorf("fail to read: %v", err) } if err := rc.Close(); err != nil { t.Errorf("fail to close: %v", err) } if got := buf.Bytes(); !bytes.Equal(got, blob) { t.Errorf("Blobs.Fetch() = %v, want %v", got, blob) } content := []byte("foobar") contentDesc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), } _, err = store.Fetch(ctx, contentDesc) if !errors.Is(err, errdef.ErrNotFound) { t.Errorf("Blobs.Fetch() error = %v, wantErr %v", err, errdef.ErrNotFound) } } func Test_BlobStore_Fetch_Seek(t *testing.T) { blob := []byte("hello world") blobDesc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(blob), Size: int64(len(blob)), } seekable := false ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusMethodNotAllowed) return } switch r.URL.Path { case "/v2/test/blobs/" + blobDesc.Digest.String(): w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Docker-Content-Digest", blobDesc.Digest.String()) if seekable { w.Header().Set("Accept-Ranges", "bytes") } rangeHeader := r.Header.Get("Range") if !seekable || rangeHeader == "" { w.WriteHeader(http.StatusOK) if _, err := w.Write(blob); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } return } var start, end int _, err := fmt.Sscanf(rangeHeader, "bytes=%d-%d", &start, &end) if err != nil { t.Errorf("invalid range header: %s", rangeHeader) w.WriteHeader(http.StatusRequestedRangeNotSatisfiable) return } if start < 0 || start > end || start >= int(blobDesc.Size) { t.Errorf("invalid range: %s", rangeHeader) w.WriteHeader(http.StatusRequestedRangeNotSatisfiable) return } end++ if end > int(blobDesc.Size) { end = int(blobDesc.Size) } w.WriteHeader(http.StatusPartialContent) if _, err := w.Write(blob[start:end]); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } default: w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true store := repo.Blobs() ctx := context.Background() rc, err := store.Fetch(ctx, blobDesc) if err != nil { t.Fatalf("Blobs.Fetch() error = %v", err) } if _, ok := rc.(io.Seeker); ok { t.Errorf("Blobs.Fetch() returns io.Seeker on non-seekable content") } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(rc); err != nil { t.Errorf("fail to read: %v", err) } if err := rc.Close(); err != nil { t.Errorf("fail to close: %v", err) } if got := buf.Bytes(); !bytes.Equal(got, blob) { t.Errorf("Blobs.Fetch() = %v, want %v", got, blob) } seekable = true rc, err = store.Fetch(ctx, blobDesc) if err != nil { t.Fatalf("Blobs.Fetch() error = %v", err) } s, ok := rc.(io.Seeker) if !ok { t.Fatalf("Blobs.Fetch() = %v, want io.Seeker", rc) } buf.Reset() if _, err := buf.ReadFrom(rc); err != nil { t.Errorf("fail to read: %v", err) } if got := buf.Bytes(); !bytes.Equal(got, blob) { t.Errorf("Blobs.Fetch() = %v, want %v", got, blob) } _, err = s.Seek(3, io.SeekStart) if err != nil { t.Errorf("fail to seek: %v", err) } buf.Reset() if _, err := buf.ReadFrom(rc); err != nil { t.Errorf("fail to read: %v", err) } if got := buf.Bytes(); !bytes.Equal(got, blob[3:]) { t.Errorf("Blobs.Fetch() = %v, want %v", got, blob[3:]) } if err := rc.Close(); err != nil { t.Errorf("fail to close: %v", err) } } func Test_BlobStore_Fetch_ZeroSizedBlob(t *testing.T) { blob := []byte("") blobDesc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(blob), Size: int64(len(blob)), } ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusMethodNotAllowed) return } switch r.URL.Path { case "/v2/test/blobs/" + blobDesc.Digest.String(): if rangeHeader := r.Header.Get("Range"); rangeHeader != "" { t.Errorf("unexpected range header") w.WriteHeader(http.StatusBadRequest) return } w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Docker-Content-Digest", blobDesc.Digest.String()) default: w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true store := repo.Blobs() ctx := context.Background() rc, err := store.Fetch(ctx, blobDesc) if err != nil { t.Fatalf("Blobs.Fetch() error = %v", err) } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(rc); err != nil { t.Errorf("fail to read: %v", err) } if err := rc.Close(); err != nil { t.Errorf("fail to close: %v", err) } if got := buf.Bytes(); !bytes.Equal(got, blob) { t.Errorf("Blobs.Fetch() = %v, want %v", got, blob) } } func Test_BlobStore_Push(t *testing.T) { blob := []byte("hello world") blobDesc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(blob), Size: int64(len(blob)), } var gotBlob []byte uuid := "4fd53bc9-565d-4527-ab80-3e051ac4880c" ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPost && r.URL.Path == "/v2/test/blobs/uploads/": w.Header().Set("Location", "/v2/test/blobs/uploads/"+uuid) w.WriteHeader(http.StatusAccepted) return case r.Method == http.MethodPut && r.URL.Path == "/v2/test/blobs/uploads/"+uuid: if contentType := r.Header.Get("Content-Type"); contentType != "application/octet-stream" { w.WriteHeader(http.StatusBadRequest) break } if contentDigest := r.URL.Query().Get("digest"); contentDigest != blobDesc.Digest.String() { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotBlob = buf.Bytes() w.Header().Set("Docker-Content-Digest", blobDesc.Digest.String()) w.WriteHeader(http.StatusCreated) return default: w.WriteHeader(http.StatusForbidden) } t.Errorf("unexpected access: %s %s", r.Method, r.URL) })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true store := repo.Blobs() ctx := context.Background() err = store.Push(ctx, blobDesc, bytes.NewReader(blob)) if err != nil { t.Fatalf("Blobs.Push() error = %v", err) } if !bytes.Equal(gotBlob, blob) { t.Errorf("Blobs.Push() = %v, want %v", gotBlob, blob) } } func Test_BlobStore_Exists(t *testing.T) { blob := []byte("hello world") blobDesc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(blob), Size: int64(len(blob)), } ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodHead { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusMethodNotAllowed) return } switch r.URL.Path { case "/v2/test/blobs/" + blobDesc.Digest.String(): w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Docker-Content-Digest", blobDesc.Digest.String()) w.Header().Set("Content-Length", strconv.Itoa(int(blobDesc.Size))) default: w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true store := repo.Blobs() ctx := context.Background() exists, err := store.Exists(ctx, blobDesc) if err != nil { t.Fatalf("Blobs.Exists() error = %v", err) } if !exists { t.Errorf("Blobs.Exists() = %v, want %v", exists, true) } content := []byte("foobar") contentDesc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), } exists, err = store.Exists(ctx, contentDesc) if err != nil { t.Fatalf("Blobs.Exists() error = %v", err) } if exists { t.Errorf("Blobs.Exists() = %v, want %v", exists, false) } } func Test_BlobStore_Delete(t *testing.T) { blob := []byte("hello world") blobDesc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(blob), Size: int64(len(blob)), } blobDeleted := false ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusMethodNotAllowed) return } switch r.URL.Path { case "/v2/test/blobs/" + blobDesc.Digest.String(): blobDeleted = true w.Header().Set("Docker-Content-Digest", blobDesc.Digest.String()) w.WriteHeader(http.StatusAccepted) default: w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true store := repo.Blobs() ctx := context.Background() err = store.Delete(ctx, blobDesc) if err != nil { t.Fatalf("Blobs.Delete() error = %v", err) } if !blobDeleted { t.Errorf("Blobs.Delete() = %v, want %v", blobDeleted, true) } content := []byte("foobar") contentDesc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), } err = store.Delete(ctx, contentDesc) if !errors.Is(err, errdef.ErrNotFound) { t.Errorf("Blobs.Delete() error = %v, wantErr %v", err, errdef.ErrNotFound) } } func Test_BlobStore_Resolve(t *testing.T) { blob := []byte("hello world") blobDesc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(blob), Size: int64(len(blob)), } ref := "foobar" ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodHead { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusMethodNotAllowed) return } switch r.URL.Path { case "/v2/test/blobs/" + blobDesc.Digest.String(): w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Docker-Content-Digest", blobDesc.Digest.String()) w.Header().Set("Content-Length", strconv.Itoa(int(blobDesc.Size))) default: w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repoName := uri.Host + "/test" repo, err := NewRepository(repoName) if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true store := repo.Blobs() ctx := context.Background() got, err := store.Resolve(ctx, blobDesc.Digest.String()) if err != nil { t.Fatalf("Blobs.Resolve() error = %v", err) } if got.Digest != blobDesc.Digest || got.Size != blobDesc.Size { t.Errorf("Blobs.Resolve() = %v, want %v", got, blobDesc) } _, err = store.Resolve(ctx, ref) if !errors.Is(err, digest.ErrDigestInvalidFormat) { t.Errorf("Blobs.Resolve() error = %v, wantErr %v", err, digest.ErrDigestInvalidFormat) } fqdnRef := repoName + "@" + blobDesc.Digest.String() got, err = store.Resolve(ctx, fqdnRef) if err != nil { t.Fatalf("Blobs.Resolve() error = %v", err) } if got.Digest != blobDesc.Digest || got.Size != blobDesc.Size { t.Errorf("Blobs.Resolve() = %v, want %v", got, blobDesc) } content := []byte("foobar") contentDesc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), } _, err = store.Resolve(ctx, contentDesc.Digest.String()) if !errors.Is(err, errdef.ErrNotFound) { t.Errorf("Blobs.Resolve() error = %v, wantErr %v", err, errdef.ErrNotFound) } } func Test_BlobStore_FetchReference(t *testing.T) { blob := []byte("hello world") blobDesc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(blob), Size: int64(len(blob)), } ref := "foobar" ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusMethodNotAllowed) return } switch r.URL.Path { case "/v2/test/blobs/" + blobDesc.Digest.String(): w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Docker-Content-Digest", blobDesc.Digest.String()) if _, err := w.Write(blob); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } default: w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repoName := uri.Host + "/test" repo, err := NewRepository(repoName) if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true store := repo.Blobs() ctx := context.Background() // test with digest gotDesc, rc, err := store.FetchReference(ctx, blobDesc.Digest.String()) if err != nil { t.Fatalf("Blobs.FetchReference() error = %v", err) } if gotDesc.Digest != blobDesc.Digest || gotDesc.Size != blobDesc.Size { t.Errorf("Blobs.FetchReference() = %v, want %v", gotDesc, blobDesc) } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(rc); err != nil { t.Errorf("fail to read: %v", err) } if err := rc.Close(); err != nil { t.Errorf("fail to close: %v", err) } if got := buf.Bytes(); !bytes.Equal(got, blob) { t.Errorf("Blobs.FetchReference() = %v, want %v", got, blob) } // test with non-digest reference _, _, err = store.FetchReference(ctx, ref) if !errors.Is(err, digest.ErrDigestInvalidFormat) { t.Errorf("Blobs.FetchReference() error = %v, wantErr %v", err, digest.ErrDigestInvalidFormat) } // test with FQDN reference fqdnRef := repoName + "@" + blobDesc.Digest.String() gotDesc, rc, err = store.FetchReference(ctx, fqdnRef) if err != nil { t.Fatalf("Blobs.FetchReference() error = %v", err) } if gotDesc.Digest != blobDesc.Digest || gotDesc.Size != blobDesc.Size { t.Errorf("Blobs.FetchReference() = %v, want %v", gotDesc, blobDesc) } buf.Reset() if _, err := buf.ReadFrom(rc); err != nil { t.Errorf("fail to read: %v", err) } if err := rc.Close(); err != nil { t.Errorf("fail to close: %v", err) } if got := buf.Bytes(); !bytes.Equal(got, blob) { t.Errorf("Blobs.FetchReference() = %v, want %v", got, blob) } content := []byte("foobar") contentDesc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(content), Size: int64(len(content)), } // test with other digest _, _, err = store.FetchReference(ctx, contentDesc.Digest.String()) if !errors.Is(err, errdef.ErrNotFound) { t.Errorf("Blobs.FetchReference() error = %v, wantErr %v", err, errdef.ErrNotFound) } } func Test_BlobStore_FetchReference_Seek(t *testing.T) { blob := []byte("hello world") blobDesc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(blob), Size: int64(len(blob)), } seekable := false ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusMethodNotAllowed) return } switch r.URL.Path { case "/v2/test/blobs/" + blobDesc.Digest.String(): w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Docker-Content-Digest", blobDesc.Digest.String()) if seekable { w.Header().Set("Accept-Ranges", "bytes") } rangeHeader := r.Header.Get("Range") if !seekable || rangeHeader == "" { w.WriteHeader(http.StatusOK) if _, err := w.Write(blob); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } return } var start int _, err := fmt.Sscanf(rangeHeader, "bytes=%d-", &start) if err != nil { t.Errorf("invalid range header: %s", rangeHeader) w.WriteHeader(http.StatusRequestedRangeNotSatisfiable) return } if start < 0 || start >= int(blobDesc.Size) { t.Errorf("invalid range: %s", rangeHeader) w.WriteHeader(http.StatusRequestedRangeNotSatisfiable) return } w.WriteHeader(http.StatusPartialContent) if _, err := w.Write(blob[start:]); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } default: w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true store := repo.Blobs() ctx := context.Background() // test non-seekable content gotDesc, rc, err := store.FetchReference(ctx, blobDesc.Digest.String()) if err != nil { t.Fatalf("Blobs.FetchReference() error = %v", err) } if gotDesc.Digest != blobDesc.Digest || gotDesc.Size != blobDesc.Size { t.Errorf("Blobs.FetchReference() = %v, want %v", gotDesc, blobDesc) } if _, ok := rc.(io.Seeker); ok { t.Errorf("Blobs.FetchReference() returns io.Seeker on non-seekable content") } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(rc); err != nil { t.Errorf("fail to read: %v", err) } if err := rc.Close(); err != nil { t.Errorf("fail to close: %v", err) } if got := buf.Bytes(); !bytes.Equal(got, blob) { t.Errorf("Blobs.FetchReference() = %v, want %v", got, blob) } // test seekable content seekable = true gotDesc, rc, err = store.FetchReference(ctx, blobDesc.Digest.String()) if err != nil { t.Fatalf("Blobs.FetchReference() error = %v", err) } if gotDesc.Digest != blobDesc.Digest || gotDesc.Size != blobDesc.Size { t.Errorf("Blobs.FetchReference() = %v, want %v", gotDesc, blobDesc) } s, ok := rc.(io.Seeker) if !ok { t.Fatalf("Blobs.FetchReference() = %v, want io.Seeker", rc) } buf.Reset() if _, err := buf.ReadFrom(rc); err != nil { t.Errorf("fail to read: %v", err) } if got := buf.Bytes(); !bytes.Equal(got, blob) { t.Errorf("Blobs.FetchReference() = %v, want %v", got, blob) } _, err = s.Seek(3, io.SeekStart) if err != nil { t.Errorf("fail to seek: %v", err) } buf.Reset() if _, err := buf.ReadFrom(rc); err != nil { t.Errorf("fail to read: %v", err) } if got := buf.Bytes(); !bytes.Equal(got, blob[3:]) { t.Errorf("Blobs.FetchReference() = %v, want %v", got, blob[3:]) } if err := rc.Close(); err != nil { t.Errorf("fail to close: %v", err) } } func Test_generateBlobDescriptorWithVariousDockerContentDigestHeaders(t *testing.T) { reference := registry.Reference{ Registry: "eastern.haan.com", Reference: "", Repository: "from25to220ce", } tests := getTestIOStructMapForGetDescriptorClass() for testName, dcdIOStruct := range tests { if dcdIOStruct.isTag { continue } for i, method := range []string{http.MethodGet, http.MethodHead} { reference.Reference = dcdIOStruct.clientSuppliedReference resp := http.Response{ Header: http.Header{ "Content-Type": []string{"application/vnd.docker.distribution.manifest.v2+json"}, headerDockerContentDigest: []string{dcdIOStruct.serverCalculatedDigest.String()}, }, } if method == http.MethodGet { resp.Body = io.NopCloser(bytes.NewBufferString(theAmazingBanClan)) } resp.Request = &http.Request{ Method: method, } var err error var d digest.Digest if d, err = reference.Digest(); err != nil { t.Errorf( "[Blob.%v] %v; got digest from a tag reference unexpectedly", method, testName, ) } errExpected := []bool{dcdIOStruct.errExpectedOnGET, dcdIOStruct.errExpectedOnHEAD}[i] if len(d) == 0 { // To avoid an otherwise impossible scenario in the tested code // path, we set d so that verifyContentDigest does not break. d = dcdIOStruct.serverCalculatedDigest } _, err = generateBlobDescriptor(&resp, d) if !errExpected && err != nil { t.Errorf( "[Blob.%v] %v; expected no error for request, but got err: %v", method, testName, err, ) } else if errExpected && err == nil { t.Errorf( "[Blob.%v] %v; expected an error for request, but got none", method, testName, ) } } } } func TestManifestStoreInterface(t *testing.T) { var ms interface{} = &manifestStore{} if _, ok := ms.(interfaces.ReferenceParser); !ok { t.Error("&manifestStore{} does not conform interfaces.ReferenceParser") } } func TestRepositoryMounterInterface(t *testing.T) { var r interface{} = &Repository{} if _, ok := r.(registry.Mounter); !ok { t.Error("&Repository{} does not conform to registry.Mounter") } } func Test_ManifestStore_Fetch(t *testing.T) { manifest := []byte(`{"layers":[]}`) manifestDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageManifest, Digest: digest.FromBytes(manifest), Size: int64(len(manifest)), } ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusMethodNotAllowed) return } switch r.URL.Path { case "/v2/test/manifests/" + manifestDesc.Digest.String(): if accept := r.Header.Get("Accept"); !strings.Contains(accept, manifestDesc.MediaType) { t.Errorf("manifest not convertable: %s", accept) w.WriteHeader(http.StatusBadRequest) return } w.Header().Set("Content-Type", manifestDesc.MediaType) w.Header().Set("Docker-Content-Digest", manifestDesc.Digest.String()) if _, err := w.Write(manifest); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } default: w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true store := repo.Manifests() ctx := context.Background() rc, err := store.Fetch(ctx, manifestDesc) if err != nil { t.Fatalf("Manifests.Fetch() error = %v", err) } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(rc); err != nil { t.Errorf("fail to read: %v", err) } if err := rc.Close(); err != nil { t.Errorf("fail to close: %v", err) } if got := buf.Bytes(); !bytes.Equal(got, manifest) { t.Errorf("Manifests.Fetch() = %v, want %v", got, manifest) } content := []byte(`{"manifests":[]}`) contentDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageIndex, Digest: digest.FromBytes(content), Size: int64(len(content)), } _, err = store.Fetch(ctx, contentDesc) if !errors.Is(err, errdef.ErrNotFound) { t.Errorf("Manifests.Fetch() error = %v, wantErr %v", err, errdef.ErrNotFound) } } func Test_ManifestStore_Push(t *testing.T) { manifest := []byte(`{"layers":[]}`) manifestDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageManifest, Digest: digest.FromBytes(manifest), Size: int64(len(manifest)), } var gotManifest []byte ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+manifestDesc.Digest.String(): if contentType := r.Header.Get("Content-Type"); contentType != manifestDesc.MediaType { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotManifest = buf.Bytes() w.Header().Set("Docker-Content-Digest", manifestDesc.Digest.String()) w.WriteHeader(http.StatusCreated) return default: w.WriteHeader(http.StatusForbidden) } t.Errorf("unexpected access: %s %s", r.Method, r.URL) })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true store := repo.Manifests() ctx := context.Background() err = store.Push(ctx, manifestDesc, bytes.NewReader(manifest)) if err != nil { t.Fatalf("Manifests.Push() error = %v", err) } if !bytes.Equal(gotManifest, manifest) { t.Errorf("Manifests.Push() = %v, want %v", gotManifest, manifest) } } func Test_ManifestStore_Push_ReferrersAPIAvailable(t *testing.T) { // generate test content subject := []byte(`{"layers":[]}`) subjectDesc := content.NewDescriptorFromBytes(spec.MediaTypeArtifactManifest, subject) artifact := spec.Artifact{ MediaType: spec.MediaTypeArtifactManifest, Subject: &subjectDesc, } artifactJSON, err := json.Marshal(artifact) if err != nil { t.Fatalf("failed to marshal manifest: %v", err) } artifactDesc := content.NewDescriptorFromBytes(artifact.MediaType, artifactJSON) manifest := ocispec.Manifest{ MediaType: ocispec.MediaTypeImageManifest, Subject: &subjectDesc, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatalf("failed to marshal manifest: %v", err) } manifestDesc := content.NewDescriptorFromBytes(manifest.MediaType, manifestJSON) index := ocispec.Index{ MediaType: ocispec.MediaTypeImageIndex, Subject: &subjectDesc, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatalf("failed to marshal manifest: %v", err) } indexDesc := content.NewDescriptorFromBytes(manifest.MediaType, indexJSON) var gotManifest []byte ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+artifactDesc.Digest.String(): if contentType := r.Header.Get("Content-Type"); contentType != artifactDesc.MediaType { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotManifest = buf.Bytes() w.Header().Set("Docker-Content-Digest", artifactDesc.Digest.String()) w.Header().Set("OCI-Subject", subjectDesc.Digest.String()) w.WriteHeader(http.StatusCreated) case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+manifestDesc.Digest.String(): if contentType := r.Header.Get("Content-Type"); contentType != manifestDesc.MediaType { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotManifest = buf.Bytes() w.Header().Set("Docker-Content-Digest", manifestDesc.Digest.String()) w.Header().Set("OCI-Subject", subjectDesc.Digest.String()) w.WriteHeader(http.StatusCreated) case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+indexDesc.Digest.String(): if contentType := r.Header.Get("Content-Type"); contentType != indexDesc.MediaType { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotManifest = buf.Bytes() w.Header().Set("Docker-Content-Digest", indexDesc.Digest.String()) w.Header().Set("OCI-Subject", subjectDesc.Digest.String()) w.WriteHeader(http.StatusCreated) default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } ctx := context.Background() // test pushing artifact with subject repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } err = repo.Push(ctx, artifactDesc, bytes.NewReader(artifactJSON)) if err != nil { t.Fatalf("Manifests.Push() error = %v", err) } if !bytes.Equal(gotManifest, artifactJSON) { t.Errorf("Manifests.Push() = %v, want %v", string(gotManifest), string(artifactJSON)) } if state := repo.loadReferrersState(); state != referrersStateSupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateSupported) } // test pushing image manifest with subject repo, err = NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } err = repo.Push(ctx, manifestDesc, bytes.NewReader(manifestJSON)) if err != nil { t.Fatalf("Manifests.Push() error = %v", err) } if !bytes.Equal(gotManifest, manifestJSON) { t.Errorf("Manifests.Push() = %v, want %v", string(gotManifest), string(manifestJSON)) } if state := repo.loadReferrersState(); state != referrersStateSupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateSupported) } // test pushing image index with subject err = repo.Push(ctx, indexDesc, bytes.NewReader(indexJSON)) if err != nil { t.Fatalf("Manifests.Push() error = %v", err) } if !bytes.Equal(gotManifest, indexJSON) { t.Errorf("Manifests.Push() = %v, want %v", string(gotManifest), string(indexJSON)) } if state := repo.loadReferrersState(); state != referrersStateSupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateSupported) } } func Test_ManifestStore_Push_ReferrersAPIUnavailable(t *testing.T) { // generate test content subject := []byte(`{"layers":[]}`) subjectDesc := content.NewDescriptorFromBytes(spec.MediaTypeArtifactManifest, subject) referrersTag := strings.Replace(subjectDesc.Digest.String(), ":", "-", 1) artifact := spec.Artifact{ MediaType: spec.MediaTypeArtifactManifest, Subject: &subjectDesc, ArtifactType: "application/vnd.test", Annotations: map[string]string{"foo": "bar"}, } artifactJSON, err := json.Marshal(artifact) if err != nil { t.Fatalf("failed to marshal manifest: %v", err) } artifactDesc := content.NewDescriptorFromBytes(artifact.MediaType, artifactJSON) artifactDesc.ArtifactType = artifact.ArtifactType artifactDesc.Annotations = artifact.Annotations // test pushing artifact with subject, a referrer list should be created index_1 := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageIndex, Manifests: []ocispec.Descriptor{ artifactDesc, }, } indexJSON_1, err := json.Marshal(index_1) if err != nil { t.Fatalf("failed to marshal manifest: %v", err) } indexDesc_1 := content.NewDescriptorFromBytes(index_1.MediaType, indexJSON_1) var gotManifest []byte var gotReferrerIndex []byte ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+artifactDesc.Digest.String(): if contentType := r.Header.Get("Content-Type"); contentType != artifactDesc.MediaType { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotManifest = buf.Bytes() w.Header().Set("Docker-Content-Digest", artifactDesc.Digest.String()) w.WriteHeader(http.StatusCreated) case r.Method == http.MethodGet && r.URL.Path == "/v2/test/manifests/"+referrersTag: w.WriteHeader(http.StatusNotFound) case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+referrersTag: if contentType := r.Header.Get("Content-Type"); contentType != ocispec.MediaTypeImageIndex { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotReferrerIndex = buf.Bytes() w.Header().Set("Docker-Content-Digest", indexDesc_1.Digest.String()) w.WriteHeader(http.StatusCreated) default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } ctx := context.Background() repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } err = repo.Push(ctx, artifactDesc, bytes.NewReader(artifactJSON)) if err != nil { t.Fatalf("Manifests.Push() error = %v", err) } if !bytes.Equal(gotManifest, artifactJSON) { t.Errorf("Manifests.Push() = %v, want %v", string(gotManifest), string(artifactJSON)) } if !bytes.Equal(gotReferrerIndex, indexJSON_1) { t.Errorf("got referrers index = %v, want %v", string(gotReferrerIndex), string(indexJSON_1)) } if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } // test pushing artifact with subject when an old empty referrer list exists, // the referrer list should be updated emptyIndex := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageIndex, } emptyIndexJSON, err := json.Marshal(emptyIndex) if err != nil { t.Error("failed to marshal index", err) } emptyIndexDesc := content.NewDescriptorFromBytes(emptyIndex.MediaType, emptyIndexJSON) var indexDeleted bool ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+artifactDesc.Digest.String(): if contentType := r.Header.Get("Content-Type"); contentType != artifactDesc.MediaType { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotManifest = buf.Bytes() w.Header().Set("Docker-Content-Digest", artifactDesc.Digest.String()) w.WriteHeader(http.StatusCreated) case r.Method == http.MethodGet && r.URL.Path == "/v2/test/manifests/"+referrersTag: w.Write(emptyIndexJSON) case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+referrersTag: if contentType := r.Header.Get("Content-Type"); contentType != ocispec.MediaTypeImageIndex { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotReferrerIndex = buf.Bytes() w.Header().Set("Docker-Content-Digest", indexDesc_1.Digest.String()) w.WriteHeader(http.StatusCreated) case r.Method == http.MethodDelete && r.URL.Path == "/v2/test/manifests/"+emptyIndexDesc.Digest.String(): indexDeleted = true // no "Docker-Content-Digest" header for manifest deletion w.WriteHeader(http.StatusAccepted) default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err = url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } ctx = context.Background() repo, err = NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } err = repo.Push(ctx, artifactDesc, bytes.NewReader(artifactJSON)) if err != nil { t.Fatalf("Manifests.Push() error = %v", err) } if !bytes.Equal(gotManifest, artifactJSON) { t.Errorf("Manifests.Push() = %v, want %v", string(gotManifest), string(artifactJSON)) } if !bytes.Equal(gotReferrerIndex, indexJSON_1) { t.Errorf("got referrers index = %v, want %v", string(gotReferrerIndex), string(indexJSON_1)) } if !indexDeleted { t.Errorf("indexDeleted = %v, want %v", indexDeleted, true) } if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } // test pushing image manifest with subject, referrer list should be updated manifest := ocispec.Manifest{ MediaType: ocispec.MediaTypeImageManifest, Config: ocispec.Descriptor{ MediaType: "testconfig", }, Subject: &subjectDesc, Annotations: map[string]string{"foo": "bar"}, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatalf("failed to marshal manifest: %v", err) } manifestDesc := content.NewDescriptorFromBytes(manifest.MediaType, manifestJSON) manifestDesc.ArtifactType = manifest.Config.MediaType manifestDesc.Annotations = manifest.Annotations index_2 := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageIndex, Manifests: []ocispec.Descriptor{ artifactDesc, manifestDesc, }, } indexJSON_2, err := json.Marshal(index_2) if err != nil { t.Fatalf("failed to marshal manifest: %v", err) } indexDesc_2 := content.NewDescriptorFromBytes(index_2.MediaType, indexJSON_2) indexDeleted = false ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+manifestDesc.Digest.String(): if contentType := r.Header.Get("Content-Type"); contentType != manifestDesc.MediaType { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotManifest = buf.Bytes() w.Header().Set("Docker-Content-Digest", manifestDesc.Digest.String()) w.WriteHeader(http.StatusCreated) case r.Method == http.MethodGet && r.URL.Path == "/v2/test/manifests/"+referrersTag: w.Write(indexJSON_1) case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+referrersTag: if contentType := r.Header.Get("Content-Type"); contentType != ocispec.MediaTypeImageIndex { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotReferrerIndex = buf.Bytes() w.Header().Set("Docker-Content-Digest", indexDesc_2.Digest.String()) w.WriteHeader(http.StatusCreated) case r.Method == http.MethodDelete && r.URL.Path == "/v2/test/manifests/"+indexDesc_1.Digest.String(): indexDeleted = true // no "Docker-Content-Digest" header for manifest deletion w.WriteHeader(http.StatusAccepted) default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err = url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } ctx = context.Background() repo, err = NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } err = repo.Push(ctx, manifestDesc, bytes.NewReader(manifestJSON)) if err != nil { t.Fatalf("Manifests.Push() error = %v", err) } if !bytes.Equal(gotManifest, manifestJSON) { t.Errorf("Manifests.Push() = %v, want %v", string(gotManifest), string(manifestJSON)) } if !bytes.Equal(gotReferrerIndex, indexJSON_2) { t.Errorf("got referrers index = %v, want %v", string(gotReferrerIndex), string(indexJSON_2)) } if !indexDeleted { t.Errorf("indexDeleted = %v, want %v", indexDeleted, true) } if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } // test pushing image manifest with subject again, referrers list should not be changed ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+manifestDesc.Digest.String(): if contentType := r.Header.Get("Content-Type"); contentType != manifestDesc.MediaType { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotManifest = buf.Bytes() w.Header().Set("Docker-Content-Digest", manifestDesc.Digest.String()) w.WriteHeader(http.StatusCreated) case r.Method == http.MethodGet && r.URL.Path == "/v2/test/manifests/"+referrersTag: w.Write(indexJSON_2) default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err = url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } ctx = context.Background() repo, err = NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } err = repo.Push(ctx, manifestDesc, bytes.NewReader(manifestJSON)) if err != nil { t.Fatalf("Manifests.Push() error = %v", err) } if !bytes.Equal(gotManifest, manifestJSON) { t.Errorf("Manifests.Push() = %v, want %v", string(gotManifest), string(manifestJSON)) } // referrers list should not be changed if !bytes.Equal(gotReferrerIndex, indexJSON_2) { t.Errorf("got referrers index = %v, want %v", string(gotReferrerIndex), string(indexJSON_2)) } if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } // push image index with subject, referrer list should be updated indexManifest := ocispec.Index{ MediaType: ocispec.MediaTypeImageIndex, Subject: &subjectDesc, ArtifactType: "test/index", Annotations: map[string]string{"foo": "bar"}, } indexManifestJSON, err := json.Marshal(indexManifest) if err != nil { t.Fatalf("failed to marshal manifest: %v", err) } indexManifestDesc := content.NewDescriptorFromBytes(indexManifest.MediaType, indexManifestJSON) indexManifestDesc.ArtifactType = indexManifest.ArtifactType indexManifestDesc.Annotations = indexManifest.Annotations index_3 := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageIndex, Manifests: []ocispec.Descriptor{ artifactDesc, manifestDesc, indexManifestDesc, }, } indexJSON_3, err := json.Marshal(index_3) if err != nil { t.Fatalf("failed to marshal manifest: %v", err) } indexDesc_3 := content.NewDescriptorFromBytes(index_3.MediaType, indexJSON_3) indexDeleted = false ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+indexManifestDesc.Digest.String(): if contentType := r.Header.Get("Content-Type"); contentType != indexManifestDesc.MediaType { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotManifest = buf.Bytes() w.Header().Set("Docker-Content-Digest", indexManifestDesc.Digest.String()) w.WriteHeader(http.StatusCreated) case r.Method == http.MethodGet && r.URL.Path == "/v2/test/manifests/"+referrersTag: w.Write(indexJSON_2) case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+referrersTag: if contentType := r.Header.Get("Content-Type"); contentType != ocispec.MediaTypeImageIndex { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotReferrerIndex = buf.Bytes() w.Header().Set("Docker-Content-Digest", indexDesc_3.Digest.String()) w.WriteHeader(http.StatusCreated) case r.Method == http.MethodDelete && r.URL.Path == "/v2/test/manifests/"+indexDesc_2.Digest.String(): indexDeleted = true // no "Docker-Content-Digest" header for manifest deletion w.WriteHeader(http.StatusAccepted) default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err = url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } ctx = context.Background() repo, err = NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } err = repo.Push(ctx, indexManifestDesc, bytes.NewReader(indexManifestJSON)) if err != nil { t.Fatalf("Manifests.Push() error = %v", err) } if !bytes.Equal(gotManifest, indexManifestJSON) { t.Errorf("Manifests.Push() = %v, want %v", string(gotManifest), string(indexManifestJSON)) } if !bytes.Equal(gotReferrerIndex, indexJSON_3) { t.Errorf("got referrers index = %v, want %v", string(gotReferrerIndex), string(indexJSON_3)) } if !indexDeleted { t.Errorf("indexDeleted = %v, want %v", indexDeleted, true) } if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } } func Test_ManifestStore_Push_ReferrersAPIUnavailable_SkipReferrersGC(t *testing.T) { // generate test content subject := []byte(`{"layers":[]}`) subjectDesc := content.NewDescriptorFromBytes(spec.MediaTypeArtifactManifest, subject) referrersTag := strings.Replace(subjectDesc.Digest.String(), ":", "-", 1) manifest := ocispec.Manifest{ MediaType: ocispec.MediaTypeImageManifest, Config: ocispec.Descriptor{ MediaType: "testconfig", }, Subject: &subjectDesc, Annotations: map[string]string{"foo": "bar"}, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatalf("failed to marshal manifest: %v", err) } manifestDesc := content.NewDescriptorFromBytes(manifest.MediaType, manifestJSON) manifestDesc.ArtifactType = manifest.Config.MediaType manifestDesc.Annotations = manifest.Annotations index_1 := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageIndex, Manifests: []ocispec.Descriptor{ manifestDesc, }, } // test pushing image manifest with subject, a referrers list should be created indexJSON_1, err := json.Marshal(index_1) if err != nil { t.Fatalf("failed to marshal manifest: %v", err) } indexDesc_1 := content.NewDescriptorFromBytes(index_1.MediaType, indexJSON_1) var gotManifest []byte var gotReferrerIndex []byte ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+manifestDesc.Digest.String(): if contentType := r.Header.Get("Content-Type"); contentType != manifestDesc.MediaType { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotManifest = buf.Bytes() w.Header().Set("Docker-Content-Digest", manifestDesc.Digest.String()) w.WriteHeader(http.StatusCreated) case r.Method == http.MethodGet && r.URL.Path == "/v2/test/manifests/"+referrersTag: w.WriteHeader(http.StatusNotFound) case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+referrersTag: if contentType := r.Header.Get("Content-Type"); contentType != ocispec.MediaTypeImageIndex { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotReferrerIndex = buf.Bytes() w.Header().Set("Docker-Content-Digest", indexDesc_1.Digest.String()) w.WriteHeader(http.StatusCreated) default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } ctx := context.Background() repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true repo.SkipReferrersGC = true if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } err = repo.Push(ctx, manifestDesc, bytes.NewReader(manifestJSON)) if err != nil { t.Fatalf("Manifests.Push() error = %v", err) } if !bytes.Equal(gotManifest, manifestJSON) { t.Errorf("Manifests.Push() = %v, want %v", string(gotManifest), string(manifestJSON)) } if !bytes.Equal(gotReferrerIndex, indexJSON_1) { t.Errorf("got referrers index = %v, want %v", string(gotReferrerIndex), string(indexJSON_1)) } if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } // test pushing image manifest with subject when an old empty referrer list exists, // the referrer list should be updated emptyIndex := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageIndex, } emptyIndexJSON, err := json.Marshal(emptyIndex) if err != nil { t.Error("failed to marshal index", err) } ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+manifestDesc.Digest.String(): if contentType := r.Header.Get("Content-Type"); contentType != manifestDesc.MediaType { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotManifest = buf.Bytes() w.Header().Set("Docker-Content-Digest", manifestDesc.Digest.String()) w.WriteHeader(http.StatusCreated) case r.Method == http.MethodGet && r.URL.Path == "/v2/test/manifests/"+referrersTag: w.Write(emptyIndexJSON) case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+referrersTag: if contentType := r.Header.Get("Content-Type"); contentType != ocispec.MediaTypeImageIndex { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotReferrerIndex = buf.Bytes() w.Header().Set("Docker-Content-Digest", indexDesc_1.Digest.String()) w.WriteHeader(http.StatusCreated) default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err = url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } ctx = context.Background() repo, err = NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true repo.SkipReferrersGC = true if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } err = repo.Push(ctx, manifestDesc, bytes.NewReader(manifestJSON)) if err != nil { t.Fatalf("Manifests.Push() error = %v", err) } if !bytes.Equal(gotManifest, manifestJSON) { t.Errorf("Manifests.Push() = %v, want %v", string(gotManifest), string(manifestJSON)) } if !bytes.Equal(gotReferrerIndex, indexJSON_1) { t.Errorf("got referrers index = %v, want %v", string(gotReferrerIndex), string(indexJSON_1)) } if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } // push image index with subject, referrer list should be updated, the old // one should not be deleted indexManifest := ocispec.Index{ MediaType: ocispec.MediaTypeImageIndex, Subject: &subjectDesc, ArtifactType: "test/index", Annotations: map[string]string{"foo": "bar"}, } indexManifestJSON, err := json.Marshal(indexManifest) if err != nil { t.Fatalf("failed to marshal manifest: %v", err) } indexManifestDesc := content.NewDescriptorFromBytes(indexManifest.MediaType, indexManifestJSON) indexManifestDesc.ArtifactType = indexManifest.ArtifactType indexManifestDesc.Annotations = indexManifest.Annotations index_2 := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageIndex, Manifests: []ocispec.Descriptor{ manifestDesc, indexManifestDesc, }, } indexJSON_2, err := json.Marshal(index_2) if err != nil { t.Fatalf("failed to marshal manifest: %v", err) } indexDesc_2 := content.NewDescriptorFromBytes(index_2.MediaType, indexJSON_2) ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+indexManifestDesc.Digest.String(): if contentType := r.Header.Get("Content-Type"); contentType != indexManifestDesc.MediaType { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotManifest = buf.Bytes() w.Header().Set("Docker-Content-Digest", indexManifestDesc.Digest.String()) w.WriteHeader(http.StatusCreated) case r.Method == http.MethodGet && r.URL.Path == "/v2/test/manifests/"+referrersTag: w.Write(indexJSON_1) case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+referrersTag: if contentType := r.Header.Get("Content-Type"); contentType != ocispec.MediaTypeImageIndex { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotReferrerIndex = buf.Bytes() w.Header().Set("Docker-Content-Digest", indexDesc_2.Digest.String()) w.WriteHeader(http.StatusCreated) default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err = url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } ctx = context.Background() repo, err = NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true repo.SkipReferrersGC = true if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } err = repo.Push(ctx, indexManifestDesc, bytes.NewReader(indexManifestJSON)) if err != nil { t.Fatalf("Manifests.Push() error = %v", err) } if !bytes.Equal(gotManifest, indexManifestJSON) { t.Errorf("Manifests.Push() = %v, want %v", string(gotManifest), string(indexManifestJSON)) } if !bytes.Equal(gotReferrerIndex, indexJSON_2) { t.Errorf("got referrers index = %v, want %v", string(gotReferrerIndex), string(indexJSON_2)) } if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } } func Test_ManifestStore_Exists(t *testing.T) { manifest := []byte(`{"layers":[]}`) manifestDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageManifest, Digest: digest.FromBytes(manifest), Size: int64(len(manifest)), } ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodHead { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusMethodNotAllowed) return } switch r.URL.Path { case "/v2/test/manifests/" + manifestDesc.Digest.String(): if accept := r.Header.Get("Accept"); !strings.Contains(accept, manifestDesc.MediaType) { t.Errorf("manifest not convertable: %s", accept) w.WriteHeader(http.StatusBadRequest) return } w.Header().Set("Content-Type", manifestDesc.MediaType) w.Header().Set("Docker-Content-Digest", manifestDesc.Digest.String()) w.Header().Set("Content-Length", strconv.Itoa(int(manifestDesc.Size))) default: w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true store := repo.Manifests() ctx := context.Background() exists, err := store.Exists(ctx, manifestDesc) if err != nil { t.Fatalf("Manifests.Exists() error = %v", err) } if !exists { t.Errorf("Manifests.Exists() = %v, want %v", exists, true) } content := []byte(`{"manifests":[]}`) contentDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageIndex, Digest: digest.FromBytes(content), Size: int64(len(content)), } exists, err = store.Exists(ctx, contentDesc) if err != nil { t.Fatalf("Manifests.Exists() error = %v", err) } if exists { t.Errorf("Manifests.Exists() = %v, want %v", exists, false) } } func Test_ManifestStore_Delete(t *testing.T) { manifest := []byte(`{"layers":[]}`) manifestDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageManifest, Digest: digest.FromBytes(manifest), Size: int64(len(manifest)), } manifestDeleted := false ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete && r.Method != http.MethodGet { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusMethodNotAllowed) } switch { case r.Method == http.MethodDelete && r.URL.Path == "/v2/test/manifests/"+manifestDesc.Digest.String(): manifestDeleted = true // no "Docker-Content-Digest" header for manifest deletion w.WriteHeader(http.StatusAccepted) case r.Method == http.MethodGet && r.URL.Path == "/v2/test/manifests/"+manifestDesc.Digest.String(): if accept := r.Header.Get("Accept"); !strings.Contains(accept, manifestDesc.MediaType) { t.Errorf("manifest not convertable: %s", accept) w.WriteHeader(http.StatusBadRequest) return } w.Header().Set("Content-Type", manifestDesc.MediaType) w.Header().Set("Docker-Content-Digest", manifestDesc.Digest.String()) if _, err := w.Write(manifest); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } default: w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true store := repo.Manifests() ctx := context.Background() // test deleting manifest without subject err = store.Delete(ctx, manifestDesc) if err != nil { t.Fatalf("Manifests.Delete() error = %v", err) } if !manifestDeleted { t.Errorf("Manifests.Delete() = %v, want %v", manifestDeleted, true) } // test deleting content that does not exist content := []byte(`{"manifests":[]}`) contentDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageIndex, Digest: digest.FromBytes(content), Size: int64(len(content)), } err = store.Delete(ctx, contentDesc) if !errors.Is(err, errdef.ErrNotFound) { t.Errorf("Manifests.Delete() error = %v, wantErr %v", err, errdef.ErrNotFound) } } func Test_ManifestStore_Delete_ReferrersAPIAvailable(t *testing.T) { // generate test content subject := []byte(`{"layers":[]}`) subjectDesc := content.NewDescriptorFromBytes(spec.MediaTypeArtifactManifest, subject) artifact := spec.Artifact{ MediaType: spec.MediaTypeArtifactManifest, Subject: &subjectDesc, } artifactJSON, err := json.Marshal(artifact) if err != nil { t.Fatalf("failed to marshal manifest: %v", err) } artifactDesc := content.NewDescriptorFromBytes(artifact.MediaType, artifactJSON) manifest := ocispec.Manifest{ MediaType: ocispec.MediaTypeImageManifest, Subject: &subjectDesc, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatalf("failed to marshal manifest: %v", err) } manifestDesc := content.NewDescriptorFromBytes(manifest.MediaType, manifestJSON) index := ocispec.Index{ MediaType: ocispec.MediaTypeImageIndex, Subject: &subjectDesc, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatalf("failed to marshal manifest: %v", err) } indexDesc := content.NewDescriptorFromBytes(index.MediaType, indexJSON) var manifestDeleted bool var artifactDeleted bool var indexDeleted bool ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete && r.Method != http.MethodGet { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusMethodNotAllowed) } switch { case r.Method == http.MethodDelete && r.URL.Path == "/v2/test/manifests/"+artifactDesc.Digest.String(): artifactDeleted = true // no "Docker-Content-Digest" header for manifest deletion w.WriteHeader(http.StatusAccepted) case r.Method == http.MethodDelete && r.URL.Path == "/v2/test/manifests/"+manifestDesc.Digest.String(): manifestDeleted = true // no "Docker-Content-Digest" header for manifest deletion w.WriteHeader(http.StatusAccepted) case r.Method == http.MethodDelete && r.URL.Path == "/v2/test/manifests/"+indexDesc.Digest.String(): indexDeleted = true // no "Docker-Content-Digest" header for manifest deletion w.WriteHeader(http.StatusAccepted) case r.Method == http.MethodGet && r.URL.Path == "/v2/test/manifests/"+artifactDesc.Digest.String(): if accept := r.Header.Get("Accept"); !strings.Contains(accept, artifactDesc.MediaType) { t.Errorf("manifest not convertable: %s", accept) w.WriteHeader(http.StatusBadRequest) return } w.Header().Set("Content-Type", artifactDesc.MediaType) w.Header().Set("Docker-Content-Digest", artifactDesc.Digest.String()) if _, err := w.Write(artifactJSON); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } case r.Method == http.MethodGet && r.URL.Path == "/v2/test/referrers/"+zeroDigest: result := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageIndex, Manifests: []ocispec.Descriptor{}, } w.Header().Set("Content-Type", ocispec.MediaTypeImageIndex) if err := json.NewEncoder(w).Encode(result); err != nil { t.Errorf("failed to write response: %v", err) } default: w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true store := repo.Manifests() ctx := context.Background() // test deleting artifact with subject if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } err = store.Delete(ctx, artifactDesc) if err != nil { t.Fatalf("Manifests.Delete() error = %v", err) } if !artifactDeleted { t.Errorf("Manifests.Delete() = %v, want %v", artifactDeleted, true) } // test deleting manifest with subject if state := repo.loadReferrersState(); state != referrersStateSupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateSupported) } err = store.Delete(ctx, manifestDesc) if err != nil { t.Fatalf("Manifests.Delete() error = %v", err) } if !manifestDeleted { t.Errorf("Manifests.Delete() = %v, want %v", manifestDeleted, true) } // test deleting index with subject if state := repo.loadReferrersState(); state != referrersStateSupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateSupported) } err = store.Delete(ctx, indexDesc) if err != nil { t.Fatalf("Manifests.Delete() error = %v", err) } if !indexDeleted { t.Errorf("Manifests.Delete() = %v, want %v", indexDeleted, true) } // test deleting content that does not exist content := []byte("whatever") contentDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageManifest, Digest: digest.FromBytes(content), Size: int64(len(content)), } ctx = context.Background() err = store.Delete(ctx, contentDesc) if !errors.Is(err, errdef.ErrNotFound) { t.Errorf("Manifests.Delete() error = %v, wantErr %v", err, errdef.ErrNotFound) } } func Test_ManifestStore_Delete_ReferrersAPIUnavailable(t *testing.T) { // generate test content subject := []byte(`{"layers":[]}`) subjectDesc := content.NewDescriptorFromBytes(spec.MediaTypeArtifactManifest, subject) referrersTag := strings.Replace(subjectDesc.Digest.String(), ":", "-", 1) artifact := spec.Artifact{ MediaType: spec.MediaTypeArtifactManifest, Subject: &subjectDesc, } artifactJSON, err := json.Marshal(artifact) if err != nil { t.Fatalf("failed to marshal manifest: %v", err) } artifactDesc := content.NewDescriptorFromBytes(artifact.MediaType, artifactJSON) manifest := ocispec.Manifest{ MediaType: ocispec.MediaTypeImageManifest, Subject: &subjectDesc, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatalf("failed to marshal manifest: %v", err) } manifestDesc := content.NewDescriptorFromBytes(manifest.MediaType, manifestJSON) indexManifest := ocispec.Index{ MediaType: ocispec.MediaTypeImageIndex, Subject: &subjectDesc, } indexManifestJSON, err := json.Marshal(indexManifest) if err != nil { t.Fatalf("failed to marshal manifest: %v", err) } indexManifestDesc := content.NewDescriptorFromBytes(indexManifest.MediaType, indexManifestJSON) index_1 := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageIndex, Manifests: []ocispec.Descriptor{ artifactDesc, manifestDesc, indexManifestDesc, }, } indexJSON_1, err := json.Marshal(index_1) if err != nil { t.Fatalf("failed to marshal manifest: %v", err) } indexDesc_1 := content.NewDescriptorFromBytes(index_1.MediaType, indexJSON_1) index_2 := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageIndex, Manifests: []ocispec.Descriptor{ manifestDesc, indexManifestDesc, }, } indexJSON_2, err := json.Marshal(index_2) if err != nil { t.Fatalf("failed to marshal manifest: %v", err) } indexDesc_2 := content.NewDescriptorFromBytes(index_2.MediaType, indexJSON_2) index_3 := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageIndex, Manifests: []ocispec.Descriptor{ indexManifestDesc, }, } indexJSON_3, err := json.Marshal(index_3) if err != nil { t.Fatalf("failed to marshal manifest: %v", err) } indexDesc_3 := content.NewDescriptorFromBytes(index_3.MediaType, indexJSON_3) // test deleting artifact with subject, referrers list should be updated manifestDeleted := false indexDeleted := false var gotReferrerIndex []byte ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodDelete && r.URL.Path == "/v2/test/manifests/"+artifactDesc.Digest.String(): manifestDeleted = true // no "Docker-Content-Digest" header for manifest deletion w.WriteHeader(http.StatusAccepted) case r.Method == http.MethodGet && r.URL.Path == "/v2/test/manifests/"+artifactDesc.Digest.String(): if accept := r.Header.Get("Accept"); !strings.Contains(accept, artifactDesc.MediaType) { t.Errorf("manifest not convertable: %s", accept) w.WriteHeader(http.StatusBadRequest) return } w.Header().Set("Content-Type", artifactDesc.MediaType) w.Header().Set("Docker-Content-Digest", artifactDesc.Digest.String()) if _, err := w.Write(artifactJSON); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } case r.Method == http.MethodGet && r.URL.Path == "/v2/test/referrers/"+zeroDigest: w.WriteHeader(http.StatusNotFound) case r.Method == http.MethodGet && r.URL.Path == "/v2/test/manifests/"+referrersTag: w.Write(indexJSON_1) case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+referrersTag: if contentType := r.Header.Get("Content-Type"); contentType != ocispec.MediaTypeImageIndex { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotReferrerIndex = buf.Bytes() w.Header().Set("Docker-Content-Digest", indexDesc_2.Digest.String()) w.WriteHeader(http.StatusCreated) case r.Method == http.MethodDelete && r.URL.Path == "/v2/test/manifests/"+indexDesc_1.Digest.String(): indexDeleted = true // no "Docker-Content-Digest" header for manifest deletion w.WriteHeader(http.StatusAccepted) default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true store := repo.Manifests() ctx := context.Background() if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } err = store.Delete(ctx, artifactDesc) if err != nil { t.Fatalf("Manifests.Delete() error = %v", err) } if !manifestDeleted { t.Errorf("Manifests.Delete() = %v, want %v", manifestDeleted, true) } if !bytes.Equal(gotReferrerIndex, indexJSON_2) { t.Errorf("got referrers index = %v, want %v", string(gotReferrerIndex), string(indexJSON_2)) } if !indexDeleted { t.Errorf("Manifests.Delete() = %v, want %v", manifestDeleted, true) } if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } // test deleting manifest with subject, referrers list should be updated manifestDeleted = false indexDeleted = false ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodDelete && r.URL.Path == "/v2/test/manifests/"+manifestDesc.Digest.String(): manifestDeleted = true // no "Docker-Content-Digest" header for manifest deletion w.WriteHeader(http.StatusAccepted) case r.Method == http.MethodGet && r.URL.Path == "/v2/test/manifests/"+manifestDesc.Digest.String(): if accept := r.Header.Get("Accept"); !strings.Contains(accept, manifestDesc.MediaType) { t.Errorf("manifest not convertable: %s", accept) w.WriteHeader(http.StatusBadRequest) return } w.Header().Set("Content-Type", manifestDesc.MediaType) w.Header().Set("Docker-Content-Digest", manifestDesc.Digest.String()) if _, err := w.Write(manifestJSON); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } case r.Method == http.MethodGet && r.URL.Path == "/v2/test/referrers/"+zeroDigest: w.WriteHeader(http.StatusNotFound) case r.Method == http.MethodGet && r.URL.Path == "/v2/test/manifests/"+referrersTag: w.Write(indexJSON_2) case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+referrersTag: if contentType := r.Header.Get("Content-Type"); contentType != ocispec.MediaTypeImageIndex { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotReferrerIndex = buf.Bytes() w.Header().Set("Docker-Content-Digest", indexDesc_3.Digest.String()) w.WriteHeader(http.StatusCreated) case r.Method == http.MethodDelete && r.URL.Path == "/v2/test/manifests/"+indexDesc_2.Digest.String(): indexDeleted = true // no "Docker-Content-Digest" header for manifest deletion w.WriteHeader(http.StatusAccepted) default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err = url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err = NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true store = repo.Manifests() ctx = context.Background() if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } err = store.Delete(ctx, manifestDesc) if err != nil { t.Fatalf("Manifests.Delete() error = %v", err) } if !manifestDeleted { t.Errorf("Manifests.Delete() = %v, want %v", manifestDeleted, true) } if !indexDeleted { t.Errorf("Manifests.Delete() = %v, want %v", manifestDeleted, true) } if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } // test deleting index with a subject, referrers list should be updated manifestDeleted = false indexDeleted = false ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodDelete && r.URL.Path == "/v2/test/manifests/"+indexManifestDesc.Digest.String(): manifestDeleted = true // no "Docker-Content-Digest" header for manifest deletion w.WriteHeader(http.StatusAccepted) case r.Method == http.MethodGet && r.URL.Path == "/v2/test/manifests/"+indexManifestDesc.Digest.String(): if accept := r.Header.Get("Accept"); !strings.Contains(accept, indexManifestDesc.MediaType) { t.Errorf("manifest not convertable: %s", accept) w.WriteHeader(http.StatusBadRequest) return } w.Header().Set("Content-Type", indexManifestDesc.MediaType) w.Header().Set("Docker-Content-Digest", indexManifestDesc.Digest.String()) if _, err := w.Write(indexManifestJSON); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } case r.Method == http.MethodGet && r.URL.Path == "/v2/test/referrers/"+zeroDigest: w.WriteHeader(http.StatusNotFound) case r.Method == http.MethodGet && r.URL.Path == "/v2/test/manifests/"+referrersTag: w.Write(indexJSON_3) case r.Method == http.MethodDelete && r.URL.Path == "/v2/test/manifests/"+indexDesc_3.Digest.String(): indexDeleted = true // no "Docker-Content-Digest" header for manifest deletion w.WriteHeader(http.StatusAccepted) default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err = url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err = NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true store = repo.Manifests() ctx = context.Background() if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } err = store.Delete(ctx, indexManifestDesc) if err != nil { t.Fatalf("Manifests.Delete() error = %v", err) } if !manifestDeleted { t.Errorf("Manifests.Delete() = %v, want %v", manifestDeleted, true) } if !indexDeleted { t.Errorf("Manifests.Delete() = %v, want %v", manifestDeleted, true) } if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } } func Test_ManifestStore_Delete_ReferrersAPIUnavailable_SkipReferrersGC(t *testing.T) { // generate test content subject := []byte(`{"layers":[]}`) subjectDesc := content.NewDescriptorFromBytes(spec.MediaTypeArtifactManifest, subject) referrersTag := strings.Replace(subjectDesc.Digest.String(), ":", "-", 1) manifest := ocispec.Manifest{ MediaType: ocispec.MediaTypeImageManifest, Subject: &subjectDesc, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatalf("failed to marshal manifest: %v", err) } manifestDesc := content.NewDescriptorFromBytes(manifest.MediaType, manifestJSON) indexManifest := ocispec.Index{ MediaType: ocispec.MediaTypeImageIndex, Subject: &subjectDesc, } indexManifestJSON, err := json.Marshal(indexManifest) if err != nil { t.Fatalf("failed to marshal manifest: %v", err) } indexManifestDesc := content.NewDescriptorFromBytes(indexManifest.MediaType, indexManifestJSON) index_1 := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageIndex, Manifests: []ocispec.Descriptor{ manifestDesc, indexManifestDesc, }, } indexJSON_1, err := json.Marshal(index_1) if err != nil { t.Fatalf("failed to marshal manifest: %v", err) } index_2 := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageIndex, Manifests: []ocispec.Descriptor{ indexManifestDesc, }, } indexJSON_2, err := json.Marshal(index_2) if err != nil { t.Fatalf("failed to marshal manifest: %v", err) } indexDesc_2 := content.NewDescriptorFromBytes(index_2.MediaType, indexJSON_2) index_3 := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageIndex, Manifests: []ocispec.Descriptor{}, } indexJSON_3, err := json.Marshal(index_3) if err != nil { t.Fatalf("failed to marshal manifest: %v", err) } indexDesc_3 := content.NewDescriptorFromBytes(index_3.MediaType, indexJSON_3) // test deleting image manifest with subject, referrers list should be updated, // the old one should not be deleted manifestDeleted := false var gotReferrerIndex []byte ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodDelete && r.URL.Path == "/v2/test/manifests/"+manifestDesc.Digest.String(): manifestDeleted = true // no "Docker-Content-Digest" header for manifest deletion w.WriteHeader(http.StatusAccepted) case r.Method == http.MethodGet && r.URL.Path == "/v2/test/manifests/"+manifestDesc.Digest.String(): if accept := r.Header.Get("Accept"); !strings.Contains(accept, manifestDesc.MediaType) { t.Errorf("manifest not convertable: %s", accept) w.WriteHeader(http.StatusBadRequest) return } w.Header().Set("Content-Type", manifestDesc.MediaType) w.Header().Set("Docker-Content-Digest", manifestDesc.Digest.String()) if _, err := w.Write(manifestJSON); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } case r.Method == http.MethodGet && r.URL.Path == "/v2/test/referrers/"+zeroDigest: w.WriteHeader(http.StatusNotFound) case r.Method == http.MethodGet && r.URL.Path == "/v2/test/manifests/"+referrersTag: w.Write(indexJSON_1) case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+referrersTag: if contentType := r.Header.Get("Content-Type"); contentType != ocispec.MediaTypeImageIndex { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotReferrerIndex = buf.Bytes() w.Header().Set("Docker-Content-Digest", indexDesc_2.Digest.String()) w.WriteHeader(http.StatusCreated) default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true repo.SkipReferrersGC = true store := repo.Manifests() ctx := context.Background() if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } err = store.Delete(ctx, manifestDesc) if err != nil { t.Fatalf("Manifests.Delete() error = %v", err) } if !manifestDeleted { t.Errorf("Manifests.Delete() = %v, want %v", manifestDeleted, true) } if !bytes.Equal(gotReferrerIndex, indexJSON_2) { t.Errorf("got referrers index = %v, want %v", string(gotReferrerIndex), string(indexJSON_2)) } if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } // test deleting index with a subject, referrers list should be updated, // the old one should not be deleted, an empty one should be pushed manifestDeleted = false ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodDelete && r.URL.Path == "/v2/test/manifests/"+indexManifestDesc.Digest.String(): manifestDeleted = true // no "Docker-Content-Digest" header for manifest deletion w.WriteHeader(http.StatusAccepted) case r.Method == http.MethodGet && r.URL.Path == "/v2/test/manifests/"+indexManifestDesc.Digest.String(): if accept := r.Header.Get("Accept"); !strings.Contains(accept, indexManifestDesc.MediaType) { t.Errorf("manifest not convertable: %s", accept) w.WriteHeader(http.StatusBadRequest) return } w.Header().Set("Content-Type", indexManifestDesc.MediaType) w.Header().Set("Docker-Content-Digest", indexManifestDesc.Digest.String()) if _, err := w.Write(indexManifestJSON); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } case r.Method == http.MethodGet && r.URL.Path == "/v2/test/referrers/"+zeroDigest: w.WriteHeader(http.StatusNotFound) case r.Method == http.MethodGet && r.URL.Path == "/v2/test/manifests/"+referrersTag: w.Write(indexJSON_2) case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+referrersTag: if contentType := r.Header.Get("Content-Type"); contentType != ocispec.MediaTypeImageIndex { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotReferrerIndex = buf.Bytes() w.Header().Set("Docker-Content-Digest", indexDesc_3.Digest.String()) w.WriteHeader(http.StatusCreated) default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err = url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err = NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true repo.SkipReferrersGC = true store = repo.Manifests() ctx = context.Background() if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } err = store.Delete(ctx, indexManifestDesc) if err != nil { t.Fatalf("Manifests.Delete() error = %v", err) } if !manifestDeleted { t.Errorf("Manifests.Delete() = %v, want %v", manifestDeleted, true) } if !bytes.Equal(gotReferrerIndex, indexJSON_3) { t.Errorf("got referrers index = %v, want %v", string(gotReferrerIndex), string(indexJSON_3)) } if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } } func Test_ManifestStore_Delete_ReferrersAPIUnavailable_InconsistentIndex(t *testing.T) { // generate test content subject := []byte(`{"layers":[]}`) subjectDesc := content.NewDescriptorFromBytes(spec.MediaTypeArtifactManifest, subject) referrersTag := strings.Replace(subjectDesc.Digest.String(), ":", "-", 1) artifact := spec.Artifact{ MediaType: spec.MediaTypeArtifactManifest, Subject: &subjectDesc, } artifactJSON, err := json.Marshal(artifact) if err != nil { t.Fatalf("failed to marshal manifest: %v", err) } artifactDesc := content.NewDescriptorFromBytes(artifact.MediaType, artifactJSON) // test inconsistent state: index not found manifestDeleted := true ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodDelete && r.URL.Path == "/v2/test/manifests/"+artifactDesc.Digest.String(): manifestDeleted = true // no "Docker-Content-Digest" header for manifest deletion w.WriteHeader(http.StatusAccepted) case r.Method == http.MethodGet && r.URL.Path == "/v2/test/manifests/"+artifactDesc.Digest.String(): if accept := r.Header.Get("Accept"); !strings.Contains(accept, artifactDesc.MediaType) { t.Errorf("manifest not convertable: %s", accept) w.WriteHeader(http.StatusBadRequest) return } w.Header().Set("Content-Type", artifactDesc.MediaType) w.Header().Set("Docker-Content-Digest", artifactDesc.Digest.String()) if _, err := w.Write(artifactJSON); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } case r.Method == http.MethodGet && r.URL.Path == "/v2/test/referrers/"+zeroDigest: w.WriteHeader(http.StatusNotFound) case r.Method == http.MethodGet && r.URL.Path == "/v2/test/manifests/"+referrersTag: w.WriteHeader(http.StatusNotFound) default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true store := repo.Manifests() ctx := context.Background() if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } err = store.Delete(ctx, artifactDesc) if err != nil { t.Fatalf("Manifests.Delete() error = %v", err) } if !manifestDeleted { t.Errorf("Manifests.Delete() = %v, want %v", manifestDeleted, true) } if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } // test inconsistent state: empty referrers list manifestDeleted = true ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodDelete && r.URL.Path == "/v2/test/manifests/"+artifactDesc.Digest.String(): manifestDeleted = true // no "Docker-Content-Digest" header for manifest deletion w.WriteHeader(http.StatusAccepted) case r.Method == http.MethodGet && r.URL.Path == "/v2/test/manifests/"+artifactDesc.Digest.String(): if accept := r.Header.Get("Accept"); !strings.Contains(accept, artifactDesc.MediaType) { t.Errorf("manifest not convertable: %s", accept) w.WriteHeader(http.StatusBadRequest) return } w.Header().Set("Content-Type", artifactDesc.MediaType) w.Header().Set("Docker-Content-Digest", artifactDesc.Digest.String()) if _, err := w.Write(artifactJSON); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } case r.Method == http.MethodGet && r.URL.Path == "/v2/test/referrers/"+zeroDigest: w.WriteHeader(http.StatusNotFound) case r.Method == http.MethodGet && r.URL.Path == "/v2/test/manifests/"+referrersTag: result := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageIndex, Manifests: []ocispec.Descriptor{}, } if err := json.NewEncoder(w).Encode(result); err != nil { t.Errorf("failed to write response: %v", err) } default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err = url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err = NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true store = repo.Manifests() ctx = context.Background() if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } err = store.Delete(ctx, artifactDesc) if err != nil { t.Fatalf("Manifests.Delete() error = %v", err) } if !manifestDeleted { t.Errorf("Manifests.Delete() = %v, want %v", manifestDeleted, true) } if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } // test inconsistent state: current referrer is not in referrers list manifestDeleted = true ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodDelete && r.URL.Path == "/v2/test/manifests/"+artifactDesc.Digest.String(): manifestDeleted = true // no "Docker-Content-Digest" header for manifest deletion w.WriteHeader(http.StatusAccepted) case r.Method == http.MethodGet && r.URL.Path == "/v2/test/manifests/"+artifactDesc.Digest.String(): if accept := r.Header.Get("Accept"); !strings.Contains(accept, artifactDesc.MediaType) { t.Errorf("manifest not convertable: %s", accept) w.WriteHeader(http.StatusBadRequest) return } w.Header().Set("Content-Type", artifactDesc.MediaType) w.Header().Set("Docker-Content-Digest", artifactDesc.Digest.String()) if _, err := w.Write(artifactJSON); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } case r.Method == http.MethodGet && r.URL.Path == "/v2/test/referrers/"+zeroDigest: w.WriteHeader(http.StatusNotFound) case r.Method == http.MethodGet && r.URL.Path == "/v2/test/manifests/"+referrersTag: result := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageIndex, Manifests: []ocispec.Descriptor{ content.NewDescriptorFromBytes(spec.MediaTypeArtifactManifest, []byte("whaterver")), }, } if err := json.NewEncoder(w).Encode(result); err != nil { t.Errorf("failed to write response: %v", err) } default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err = url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err = NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true store = repo.Manifests() ctx = context.Background() if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } err = store.Delete(ctx, artifactDesc) if err != nil { t.Fatalf("Manifests.Delete() error = %v", err) } if !manifestDeleted { t.Errorf("Manifests.Delete() = %v, want %v", manifestDeleted, true) } if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } } func Test_ManifestStore_Resolve(t *testing.T) { manifest := []byte(`{"layers":[]}`) manifestDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageIndex, Digest: digest.FromBytes(manifest), Size: int64(len(manifest)), } ref := "foobar" ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodHead { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusMethodNotAllowed) return } switch r.URL.Path { case "/v2/test/manifests/" + manifestDesc.Digest.String(), "/v2/test/manifests/" + ref: if accept := r.Header.Get("Accept"); !strings.Contains(accept, manifestDesc.MediaType) { t.Errorf("manifest not convertable: %s", accept) w.WriteHeader(http.StatusBadRequest) return } w.Header().Set("Content-Type", manifestDesc.MediaType) w.Header().Set("Docker-Content-Digest", manifestDesc.Digest.String()) w.Header().Set("Content-Length", strconv.Itoa(int(manifestDesc.Size))) default: w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repoName := uri.Host + "/test" repo, err := NewRepository(repoName) if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true store := repo.Manifests() ctx := context.Background() got, err := store.Resolve(ctx, manifestDesc.Digest.String()) if err != nil { t.Fatalf("Manifests.Resolve() error = %v", err) } if !reflect.DeepEqual(got, manifestDesc) { t.Errorf("Manifests.Resolve() = %v, want %v", got, manifestDesc) } got, err = store.Resolve(ctx, ref) if err != nil { t.Fatalf("Manifests.Resolve() error = %v", err) } if !reflect.DeepEqual(got, manifestDesc) { t.Errorf("Manifests.Resolve() = %v, want %v", got, manifestDesc) } tagDigestRef := "whatever" + "@" + manifestDesc.Digest.String() got, err = repo.Resolve(ctx, tagDigestRef) if err != nil { t.Fatalf("Manifests.Resolve() error = %v", err) } if !reflect.DeepEqual(got, manifestDesc) { t.Errorf("Manifests.Resolve() = %v, want %v", got, manifestDesc) } fqdnRef := repoName + ":" + tagDigestRef got, err = repo.Resolve(ctx, fqdnRef) if err != nil { t.Fatalf("Manifests.Resolve() error = %v", err) } if !reflect.DeepEqual(got, manifestDesc) { t.Errorf("Manifests.Resolve() = %v, want %v", got, manifestDesc) } content := []byte(`{"manifests":[]}`) contentDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageIndex, Digest: digest.FromBytes(content), Size: int64(len(content)), } _, err = store.Resolve(ctx, contentDesc.Digest.String()) if !errors.Is(err, errdef.ErrNotFound) { t.Errorf("Manifests.Resolve() error = %v, wantErr %v", err, errdef.ErrNotFound) } } func Test_ManifestStore_FetchReference(t *testing.T) { manifest := []byte(`{"layers":[]}`) manifestDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageIndex, Digest: digest.FromBytes(manifest), Size: int64(len(manifest)), } ref := "foobar" ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusMethodNotAllowed) return } switch r.URL.Path { case "/v2/test/manifests/" + manifestDesc.Digest.String(), "/v2/test/manifests/" + ref: if accept := r.Header.Get("Accept"); !strings.Contains(accept, manifestDesc.MediaType) { t.Errorf("manifest not convertable: %s", accept) w.WriteHeader(http.StatusBadRequest) return } w.Header().Set("Content-Type", manifestDesc.MediaType) w.Header().Set("Docker-Content-Digest", manifestDesc.Digest.String()) if _, err := w.Write(manifest); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } default: w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repoName := uri.Host + "/test" repo, err := NewRepository(repoName) if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true store := repo.Manifests() ctx := context.Background() // test with tag gotDesc, rc, err := store.FetchReference(ctx, ref) if err != nil { t.Fatalf("Manifests.FetchReference() error = %v", err) } if !reflect.DeepEqual(gotDesc, manifestDesc) { t.Errorf("Manifests.FetchReference() = %v, want %v", gotDesc, manifestDesc) } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(rc); err != nil { t.Errorf("fail to read: %v", err) } if err := rc.Close(); err != nil { t.Errorf("fail to close: %v", err) } if got := buf.Bytes(); !bytes.Equal(got, manifest) { t.Errorf("Manifests.FetchReference() = %v, want %v", got, manifest) } // test with other tag randomRef := "whatever" _, _, err = store.FetchReference(ctx, randomRef) if !errors.Is(err, errdef.ErrNotFound) { t.Errorf("Manifests.FetchReference() error = %v, wantErr %v", err, errdef.ErrNotFound) } // test with digest gotDesc, rc, err = store.FetchReference(ctx, manifestDesc.Digest.String()) if err != nil { t.Fatalf("Manifests.FetchReference() error = %v", err) } if !reflect.DeepEqual(gotDesc, manifestDesc) { t.Errorf("Manifests.FetchReference() = %v, want %v", gotDesc, manifestDesc) } buf.Reset() if _, err := buf.ReadFrom(rc); err != nil { t.Errorf("fail to read: %v", err) } if err := rc.Close(); err != nil { t.Errorf("fail to close: %v", err) } if got := buf.Bytes(); !bytes.Equal(got, manifest) { t.Errorf("Manifests.FetchReference() = %v, want %v", got, manifest) } // test with other digest randomContent := []byte("whatever") randomContentDigest := digest.FromBytes(randomContent) _, _, err = store.FetchReference(ctx, randomContentDigest.String()) if !errors.Is(err, errdef.ErrNotFound) { t.Errorf("Manifests.FetchReference() error = %v, wantErr %v", err, errdef.ErrNotFound) } // test with tag@digest tagDigestRef := randomRef + "@" + manifestDesc.Digest.String() gotDesc, rc, err = store.FetchReference(ctx, tagDigestRef) if err != nil { t.Fatalf("Manifests.FetchReference() error = %v", err) } if !reflect.DeepEqual(gotDesc, manifestDesc) { t.Errorf("Manifests.FetchReference() = %v, want %v", gotDesc, manifestDesc) } buf.Reset() if _, err := buf.ReadFrom(rc); err != nil { t.Errorf("fail to read: %v", err) } if err := rc.Close(); err != nil { t.Errorf("fail to close: %v", err) } if got := buf.Bytes(); !bytes.Equal(got, manifest) { t.Errorf("Manifests.FetchReference() = %v, want %v", got, manifest) } // test with FQDN fqdnRef := repoName + ":" + tagDigestRef gotDesc, rc, err = store.FetchReference(ctx, fqdnRef) if err != nil { t.Fatalf("Manifests.FetchReference() error = %v", err) } if !reflect.DeepEqual(gotDesc, manifestDesc) { t.Errorf("Manifests.FetchReference() = %v, want %v", gotDesc, manifestDesc) } buf.Reset() if _, err := buf.ReadFrom(rc); err != nil { t.Errorf("fail to read: %v", err) } if err := rc.Close(); err != nil { t.Errorf("fail to close: %v", err) } if got := buf.Bytes(); !bytes.Equal(got, manifest) { t.Errorf("Manifests.FetchReference() = %v, want %v", got, manifest) } } func Test_ManifestStore_Tag(t *testing.T) { blob := []byte("hello world") blobDesc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(blob), Size: int64(len(blob)), } index := []byte(`{"manifests":[]}`) indexDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageIndex, Digest: digest.FromBytes(index), Size: int64(len(index)), } var gotIndex []byte ref := "foobar" ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && r.URL.Path == "/v2/test/manifests/"+blobDesc.Digest.String(): w.WriteHeader(http.StatusNotFound) case r.Method == http.MethodGet && r.URL.Path == "/v2/test/manifests/"+indexDesc.Digest.String(): if accept := r.Header.Get("Accept"); !strings.Contains(accept, indexDesc.MediaType) { t.Errorf("manifest not convertable: %s", accept) w.WriteHeader(http.StatusBadRequest) return } w.Header().Set("Content-Type", indexDesc.MediaType) w.Header().Set("Docker-Content-Digest", indexDesc.Digest.String()) if _, err := w.Write(index); err != nil { t.Errorf("failed to write %q: %v", r.URL, err) } case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+ref || r.URL.Path == "/v2/test/manifests/"+indexDesc.Digest.String(): if contentType := r.Header.Get("Content-Type"); contentType != indexDesc.MediaType { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotIndex = buf.Bytes() w.Header().Set("Docker-Content-Digest", indexDesc.Digest.String()) w.WriteHeader(http.StatusCreated) return default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusForbidden) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } store := repo.Manifests() repo.PlainHTTP = true ctx := context.Background() err = store.Tag(ctx, blobDesc, ref) if err == nil { t.Errorf("Repository.Tag() error = %v, wantErr %v", err, true) } err = store.Tag(ctx, indexDesc, ref) if err != nil { t.Fatalf("Repository.Tag() error = %v", err) } if !bytes.Equal(gotIndex, index) { t.Errorf("Repository.Tag() = %v, want %v", gotIndex, index) } gotIndex = nil err = store.Tag(ctx, indexDesc, indexDesc.Digest.String()) if err != nil { t.Fatalf("Repository.Tag() error = %v", err) } if !bytes.Equal(gotIndex, index) { t.Errorf("Repository.Tag() = %v, want %v", gotIndex, index) } } func Test_ManifestStore_PushReference(t *testing.T) { index := []byte(`{"manifests":[]}`) indexDesc := ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageIndex, Digest: digest.FromBytes(index), Size: int64(len(index)), } var gotIndex []byte ref := "foobar" ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+ref: if contentType := r.Header.Get("Content-Type"); contentType != indexDesc.MediaType { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotIndex = buf.Bytes() w.Header().Set("Docker-Content-Digest", indexDesc.Digest.String()) w.WriteHeader(http.StatusCreated) return default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusForbidden) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } store := repo.Manifests() repo.PlainHTTP = true ctx := context.Background() err = store.PushReference(ctx, indexDesc, bytes.NewReader(index), ref) if err != nil { t.Fatalf("Repository.PushReference() error = %v", err) } if !bytes.Equal(gotIndex, index) { t.Errorf("Repository.PushReference() = %v, want %v", gotIndex, index) } } func Test_ManifestStore_PushReference_ReferrersAPIAvailable(t *testing.T) { // generate test content subject := []byte(`{"layers":[]}`) subjectDesc := content.NewDescriptorFromBytes(spec.MediaTypeArtifactManifest, subject) artifact := spec.Artifact{ MediaType: spec.MediaTypeArtifactManifest, Subject: &subjectDesc, } artifactJSON, err := json.Marshal(artifact) if err != nil { t.Fatalf("failed to marshal manifest: %v", err) } artifactDesc := content.NewDescriptorFromBytes(artifact.MediaType, artifactJSON) artifactRef := "foo" manifest := ocispec.Manifest{ MediaType: ocispec.MediaTypeImageManifest, Subject: &subjectDesc, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatalf("failed to marshal manifest: %v", err) } manifestDesc := content.NewDescriptorFromBytes(manifest.MediaType, manifestJSON) manifestRef := "bar" index := ocispec.Index{ MediaType: ocispec.MediaTypeImageIndex, Subject: &subjectDesc, } indexJSON, err := json.Marshal(index) if err != nil { t.Fatalf("failed to marshal manifest: %v", err) } indexDesc := content.NewDescriptorFromBytes(index.MediaType, indexJSON) indexRef := "baz" var gotManifest []byte ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+artifactRef: if contentType := r.Header.Get("Content-Type"); contentType != artifactDesc.MediaType { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotManifest = buf.Bytes() w.Header().Set("Docker-Content-Digest", artifactDesc.Digest.String()) w.Header().Set("OCI-Subject", subjectDesc.Digest.String()) w.WriteHeader(http.StatusCreated) case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+manifestRef: if contentType := r.Header.Get("Content-Type"); contentType != manifestDesc.MediaType { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotManifest = buf.Bytes() w.Header().Set("Docker-Content-Digest", manifestDesc.Digest.String()) w.Header().Set("OCI-Subject", subjectDesc.Digest.String()) w.WriteHeader(http.StatusCreated) case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+indexRef: if contentType := r.Header.Get("Content-Type"); contentType != indexDesc.MediaType { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotManifest = buf.Bytes() w.Header().Set("Docker-Content-Digest", indexDesc.Digest.String()) w.Header().Set("OCI-Subject", subjectDesc.Digest.String()) w.WriteHeader(http.StatusCreated) case r.Method == http.MethodGet && r.URL.Path == "/v2/test/referrers/"+zeroDigest: result := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageIndex, Manifests: []ocispec.Descriptor{}, } if err := json.NewEncoder(w).Encode(result); err != nil { t.Errorf("failed to write response: %v", err) } default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } ctx := context.Background() // test pushing artifact with subject repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } err = repo.PushReference(ctx, artifactDesc, bytes.NewReader(artifactJSON), artifactRef) if err != nil { t.Fatalf("Manifests.Push() error = %v", err) } if !bytes.Equal(gotManifest, artifactJSON) { t.Errorf("Manifests.Push() = %v, want %v", string(gotManifest), string(artifactJSON)) } if state := repo.loadReferrersState(); state != referrersStateSupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateSupported) } // test pushing image manifest with subject repo, err = NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } err = repo.PushReference(ctx, manifestDesc, bytes.NewReader(manifestJSON), manifestRef) if err != nil { t.Fatalf("Manifests.Push() error = %v", err) } if !bytes.Equal(gotManifest, manifestJSON) { t.Errorf("Manifests.Push() = %v, want %v", string(gotManifest), string(manifestJSON)) } if state := repo.loadReferrersState(); state != referrersStateSupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateSupported) } // test pushing image index with subject err = repo.PushReference(ctx, indexDesc, bytes.NewReader(indexJSON), indexRef) if err != nil { t.Fatalf("Manifests.Push() error = %v", err) } if !bytes.Equal(gotManifest, indexJSON) { t.Errorf("Manifests.Push() = %v, want %v", string(gotManifest), string(indexJSON)) } if state := repo.loadReferrersState(); state != referrersStateSupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateSupported) } } func Test_ManifestStore_PushReference_ReferrersAPIUnavailable(t *testing.T) { // generate test content subject := []byte(`{"layers":[]}`) subjectDesc := content.NewDescriptorFromBytes(spec.MediaTypeArtifactManifest, subject) referrersTag := strings.Replace(subjectDesc.Digest.String(), ":", "-", 1) artifact := spec.Artifact{ MediaType: spec.MediaTypeArtifactManifest, Subject: &subjectDesc, ArtifactType: "application/vnd.test", Annotations: map[string]string{"foo": "bar"}, } artifactJSON, err := json.Marshal(artifact) if err != nil { t.Fatalf("failed to marshal manifest: %v", err) } artifactDesc := content.NewDescriptorFromBytes(artifact.MediaType, artifactJSON) artifactDesc.ArtifactType = artifact.ArtifactType artifactDesc.Annotations = artifact.Annotations artifactRef := "foo" // test pushing artifact with subject index_1 := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageIndex, Manifests: []ocispec.Descriptor{ artifactDesc, }, } indexJSON_1, err := json.Marshal(index_1) if err != nil { t.Fatalf("failed to marshal manifest: %v", err) } indexDesc_1 := content.NewDescriptorFromBytes(index_1.MediaType, indexJSON_1) var gotManifest []byte var gotReferrerIndex []byte ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+artifactRef: if contentType := r.Header.Get("Content-Type"); contentType != artifactDesc.MediaType { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotManifest = buf.Bytes() w.Header().Set("Docker-Content-Digest", artifactDesc.Digest.String()) w.WriteHeader(http.StatusCreated) case r.Method == http.MethodGet && r.URL.Path == "/v2/test/manifests/"+referrersTag: w.WriteHeader(http.StatusNotFound) case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+referrersTag: if contentType := r.Header.Get("Content-Type"); contentType != ocispec.MediaTypeImageIndex { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotReferrerIndex = buf.Bytes() w.Header().Set("Docker-Content-Digest", indexDesc_1.Digest.String()) w.WriteHeader(http.StatusCreated) default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } ctx := context.Background() repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } err = repo.PushReference(ctx, artifactDesc, bytes.NewReader(artifactJSON), artifactRef) if err != nil { t.Fatalf("Manifests.Push() error = %v", err) } if !bytes.Equal(gotManifest, artifactJSON) { t.Errorf("Manifests.Push() = %v, want %v", string(gotManifest), string(artifactJSON)) } if !bytes.Equal(gotReferrerIndex, indexJSON_1) { t.Errorf("got referrers index = %v, want %v", string(gotReferrerIndex), string(indexJSON_1)) } if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } // test pushing image manifest with subject, referrers list should be updated manifest := ocispec.Manifest{ MediaType: ocispec.MediaTypeImageManifest, Config: ocispec.Descriptor{ MediaType: "testconfig", }, Subject: &subjectDesc, Annotations: map[string]string{"foo": "bar"}, } manifestJSON, err := json.Marshal(manifest) if err != nil { t.Fatalf("failed to marshal manifest: %v", err) } manifestDesc := content.NewDescriptorFromBytes(manifest.MediaType, manifestJSON) manifestDesc.ArtifactType = manifest.Config.MediaType manifestDesc.Annotations = manifest.Annotations manifestRef := "bar" index_2 := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageIndex, Manifests: []ocispec.Descriptor{ artifactDesc, manifestDesc, }, } indexJSON_2, err := json.Marshal(index_2) if err != nil { t.Fatalf("failed to marshal manifest: %v", err) } indexDesc_2 := content.NewDescriptorFromBytes(index_2.MediaType, indexJSON_2) var manifestDeleted bool ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+manifestRef: if contentType := r.Header.Get("Content-Type"); contentType != manifestDesc.MediaType { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotManifest = buf.Bytes() w.Header().Set("Docker-Content-Digest", manifestDesc.Digest.String()) w.WriteHeader(http.StatusCreated) case r.Method == http.MethodGet && r.URL.Path == "/v2/test/manifests/"+referrersTag: w.Write(indexJSON_1) case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+referrersTag: if contentType := r.Header.Get("Content-Type"); contentType != ocispec.MediaTypeImageIndex { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotReferrerIndex = buf.Bytes() w.Header().Set("Docker-Content-Digest", indexDesc_2.Digest.String()) w.WriteHeader(http.StatusCreated) case r.Method == http.MethodDelete && r.URL.Path == "/v2/test/manifests/"+indexDesc_1.Digest.String(): manifestDeleted = true // no "Docker-Content-Digest" header for manifest deletion w.WriteHeader(http.StatusAccepted) default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err = url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } ctx = context.Background() repo, err = NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } err = repo.PushReference(ctx, manifestDesc, bytes.NewReader(manifestJSON), manifestRef) if err != nil { t.Fatalf("Manifests.PushReference() error = %v", err) } if !bytes.Equal(gotManifest, manifestJSON) { t.Errorf("Manifests.PushReference() = %v, want %v", string(gotManifest), string(manifestJSON)) } if !bytes.Equal(gotReferrerIndex, indexJSON_2) { t.Errorf("got referrers index = %v, want %v", string(gotReferrerIndex), string(indexJSON_2)) } if !manifestDeleted { t.Errorf("manifestDeleted = %v, want %v", manifestDeleted, true) } if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } // test pushing image manifest with subject again, referrers list should not be changed ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+manifestRef: if contentType := r.Header.Get("Content-Type"); contentType != manifestDesc.MediaType { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotManifest = buf.Bytes() w.Header().Set("Docker-Content-Digest", manifestDesc.Digest.String()) w.WriteHeader(http.StatusCreated) case r.Method == http.MethodGet && r.URL.Path == "/v2/test/manifests/"+referrersTag: w.Write(indexJSON_2) default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err = url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } ctx = context.Background() repo, err = NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } err = repo.PushReference(ctx, manifestDesc, bytes.NewReader(manifestJSON), manifestRef) if err != nil { t.Fatalf("Manifests.PushReference() error = %v", err) } if !bytes.Equal(gotManifest, manifestJSON) { t.Errorf("Manifests.PushReference() = %v, want %v", string(gotManifest), string(manifestJSON)) } // referrers list should not be changed if !bytes.Equal(gotReferrerIndex, indexJSON_2) { t.Errorf("got referrers index = %v, want %v", string(gotReferrerIndex), string(indexJSON_2)) } if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } // push image index with subject, referrer list should be updated indexManifest := ocispec.Index{ MediaType: ocispec.MediaTypeImageIndex, Subject: &subjectDesc, ArtifactType: "test/index", Annotations: map[string]string{"foo": "bar"}, } indexManifestJSON, err := json.Marshal(indexManifest) if err != nil { t.Fatalf("failed to marshal manifest: %v", err) } indexManifestDesc := content.NewDescriptorFromBytes(indexManifest.MediaType, indexManifestJSON) indexManifestDesc.ArtifactType = indexManifest.ArtifactType indexManifestDesc.Annotations = indexManifest.Annotations indexManifestRef := "baz" index_3 := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageIndex, Manifests: []ocispec.Descriptor{ artifactDesc, manifestDesc, indexManifestDesc, }, } indexJSON_3, err := json.Marshal(index_3) if err != nil { t.Fatalf("failed to marshal manifest: %v", err) } indexDesc_3 := content.NewDescriptorFromBytes(index_3.MediaType, indexJSON_3) manifestDeleted = false ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+indexManifestRef: if contentType := r.Header.Get("Content-Type"); contentType != indexManifestDesc.MediaType { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotManifest = buf.Bytes() w.Header().Set("Docker-Content-Digest", indexManifestDesc.Digest.String()) w.WriteHeader(http.StatusCreated) case r.Method == http.MethodGet && r.URL.Path == "/v2/test/manifests/"+referrersTag: w.Write(indexJSON_2) case r.Method == http.MethodPut && r.URL.Path == "/v2/test/manifests/"+referrersTag: if contentType := r.Header.Get("Content-Type"); contentType != ocispec.MediaTypeImageIndex { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } gotReferrerIndex = buf.Bytes() w.Header().Set("Docker-Content-Digest", indexDesc_3.Digest.String()) w.WriteHeader(http.StatusCreated) case r.Method == http.MethodDelete && r.URL.Path == "/v2/test/manifests/"+indexDesc_2.Digest.String(): manifestDeleted = true // no "Docker-Content-Digest" header for manifest deletion w.WriteHeader(http.StatusAccepted) default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err = url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } ctx = context.Background() repo, err = NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } err = repo.PushReference(ctx, indexManifestDesc, bytes.NewReader(indexManifestJSON), indexManifestRef) if err != nil { t.Fatalf("Manifests.PushReference() error = %v", err) } if !bytes.Equal(gotManifest, indexManifestJSON) { t.Errorf("Manifests.PushReference() = %v, want %v", string(gotManifest), string(indexManifestJSON)) } if !bytes.Equal(gotReferrerIndex, indexJSON_3) { t.Errorf("got referrers index = %v, want %v", string(gotReferrerIndex), string(indexJSON_3)) } if !manifestDeleted { t.Errorf("manifestDeleted = %v, want %v", manifestDeleted, true) } if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } } func Test_ManifestStore_generateDescriptorWithVariousDockerContentDigestHeaders(t *testing.T) { reference := registry.Reference{ Registry: "eastern.haan.com", Reference: "", Repository: "from25to220ce", } tests := getTestIOStructMapForGetDescriptorClass() for testName, dcdIOStruct := range tests { repo, err := NewRepository(fmt.Sprintf("%s/%s", reference.Repository, reference.Repository)) if err != nil { t.Fatalf("failed to initialize repository") } s := manifestStore{repo: repo} for i, method := range []string{http.MethodGet, http.MethodHead} { reference.Reference = dcdIOStruct.clientSuppliedReference resp := http.Response{ Header: http.Header{ "Content-Type": []string{"application/vnd.docker.distribution.manifest.v2+json"}, headerDockerContentDigest: []string{dcdIOStruct.serverCalculatedDigest.String()}, }, } if method == http.MethodGet { resp.Body = io.NopCloser(bytes.NewBufferString(theAmazingBanClan)) } resp.Request = &http.Request{ Method: method, } errExpected := []bool{dcdIOStruct.errExpectedOnGET, dcdIOStruct.errExpectedOnHEAD}[i] _, err = s.generateDescriptor(&resp, reference, method) if !errExpected && err != nil { t.Errorf( "[Manifest.%v] %v; expected no error for request, but got err: %v", method, testName, err, ) } else if errExpected && err == nil { t.Errorf( "[Manifest.%v] %v; expected an error for request, but got none", method, testName, ) } } } } type testTransport struct { proxyHost string underlyingTransport http.RoundTripper mockHost string } func (t *testTransport) RoundTrip(originalReq *http.Request) (*http.Response, error) { req := originalReq.Clone(originalReq.Context()) mockHostName, mockPort, err := net.SplitHostPort(t.mockHost) // when t.mockHost is as form host:port if err == nil && (req.URL.Hostname() != mockHostName || req.URL.Port() != mockPort) { return nil, errors.New("bad request") } // when t.mockHost does not have specified port, in this case, // err is not nil if err != nil && req.URL.Hostname() != t.mockHost { return nil, errors.New("bad request") } req.Host = t.proxyHost req.URL.Host = t.proxyHost resp, err := t.underlyingTransport.RoundTrip(req) if err != nil { return nil, err } resp.Request.Host = t.mockHost resp.Request.URL.Host = t.mockHost return resp, nil } // Helper function to create a registry.BlobStore for // Test_BlobStore_Push_Port443 func blobStore_Push_Port443_create_store(uri *url.URL, testRegistry string) (registry.BlobStore, error) { repo, err := NewRepository(testRegistry + "/test") repo.Client = &auth.Client{ Client: &http.Client{ Transport: &testTransport{ proxyHost: uri.Host, underlyingTransport: http.DefaultTransport, mockHost: testRegistry, }, }, Cache: auth.NewCache(), } repo.PlainHTTP = true store := repo.Blobs() return store, err } func Test_BlobStore_Push_Port443(t *testing.T) { blob := []byte("hello world") blobDesc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(blob), Size: int64(len(blob)), } uuid := "4fd53bc9-565d-4527-ab80-3e051ac4880c" ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPost && r.URL.Path == "/v2/test/blobs/uploads/": w.Header().Set("Location", "http://registry.wabbit-networks.io/v2/test/blobs/uploads/"+uuid) w.WriteHeader(http.StatusAccepted) return case r.Method == http.MethodPut && r.URL.Path == "/v2/test/blobs/uploads/"+uuid: if contentType := r.Header.Get("Content-Type"); contentType != "application/octet-stream" { w.WriteHeader(http.StatusBadRequest) break } if contentDigest := r.URL.Query().Get("digest"); contentDigest != blobDesc.Digest.String() { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } w.Header().Set("Docker-Content-Digest", blobDesc.Digest.String()) w.WriteHeader(http.StatusCreated) return default: w.WriteHeader(http.StatusForbidden) } t.Errorf("unexpected access: %s %s", r.Method, r.URL) })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } // Test case with Host: "registry.wabbit-networks.io:443", // Location: "registry.wabbit-networks.io" testRegistry := "registry.wabbit-networks.io:443" store, err := blobStore_Push_Port443_create_store(uri, testRegistry) if err != nil { t.Fatalf("blobStore_Push_Port443_create_store() error = %v", err) } ctx := context.Background() err = store.Push(ctx, blobDesc, bytes.NewReader(blob)) if err != nil { t.Fatalf("Blobs.Push() error = %v", err) } // Test case with Host: "registry.wabbit-networks.io", // Location: "registry.wabbit-networks.io" testRegistry = "registry.wabbit-networks.io" store, err = blobStore_Push_Port443_create_store(uri, testRegistry) if err != nil { t.Fatalf("blobStore_Push_Port443_create_store() error = %v", err) } err = store.Push(ctx, blobDesc, bytes.NewReader(blob)) if err != nil { t.Fatalf("Blobs.Push() error = %v", err) } } // Helper function to create a registry.BlobStore for // Test_BlobStore_Push_Port443_HTTPS func blobStore_Push_Port443_HTTPS_create_store(uri *url.URL, testRegistry string) (registry.BlobStore, error) { repo, err := NewRepository(testRegistry + "/test") tlsConfig := &tls.Config{ InsecureSkipVerify: true, } transport := &http.Transport{ TLSClientConfig: tlsConfig, } repo.Client = &auth.Client{ Client: &http.Client{ Transport: &testTransport{ proxyHost: uri.Host, underlyingTransport: transport, mockHost: testRegistry, }, }, Cache: auth.NewCache(), } repo.PlainHTTP = false store := repo.Blobs() return store, err } func Test_BlobStore_Push_Port443_HTTPS(t *testing.T) { blob := []byte("hello world") blobDesc := ocispec.Descriptor{ MediaType: "test", Digest: digest.FromBytes(blob), Size: int64(len(blob)), } uuid := "4fd53bc9-565d-4527-ab80-3e051ac4880c" ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPost && r.URL.Path == "/v2/test/blobs/uploads/": w.Header().Set("Location", "https://registry.wabbit-networks.io/v2/test/blobs/uploads/"+uuid) w.WriteHeader(http.StatusAccepted) return case r.Method == http.MethodPut && r.URL.Path == "/v2/test/blobs/uploads/"+uuid: if contentType := r.Header.Get("Content-Type"); contentType != "application/octet-stream" { w.WriteHeader(http.StatusBadRequest) break } if contentDigest := r.URL.Query().Get("digest"); contentDigest != blobDesc.Digest.String() { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } w.Header().Set("Docker-Content-Digest", blobDesc.Digest.String()) w.WriteHeader(http.StatusCreated) return default: w.WriteHeader(http.StatusForbidden) } t.Errorf("unexpected access: %s %s", r.Method, r.URL) })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test https server: %v", err) } ctx := context.Background() // Test case with Host: "registry.wabbit-networks.io:443", // Location: "registry.wabbit-networks.io" testRegistry := "registry.wabbit-networks.io:443" store, err := blobStore_Push_Port443_HTTPS_create_store(uri, testRegistry) if err != nil { t.Fatalf("blobStore_Push_Port443_HTTPS_create_store() error = %v", err) } err = store.Push(ctx, blobDesc, bytes.NewReader(blob)) if err != nil { t.Fatalf("Blobs.Push() error = %v", err) } // Test case with Host: "registry.wabbit-networks.io", // Location: "registry.wabbit-networks.io" testRegistry = "registry.wabbit-networks.io" store, err = blobStore_Push_Port443_HTTPS_create_store(uri, testRegistry) if err != nil { t.Fatalf("blobStore_Push_Port443_HTTPS_create_store() error = %v", err) } err = store.Push(ctx, blobDesc, bytes.NewReader(blob)) if err != nil { t.Fatalf("Blobs.Push() error = %v", err) } ts = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPost && r.URL.Path == "/v2/test/blobs/uploads/": w.Header().Set("Location", "https://registry.wabbit-networks.io:443/v2/test/blobs/uploads/"+uuid) w.WriteHeader(http.StatusAccepted) return case r.Method == http.MethodPut && r.URL.Path == "/v2/test/blobs/uploads/"+uuid: if contentType := r.Header.Get("Content-Type"); contentType != "application/octet-stream" { w.WriteHeader(http.StatusBadRequest) break } if contentDigest := r.URL.Query().Get("digest"); contentDigest != blobDesc.Digest.String() { w.WriteHeader(http.StatusBadRequest) break } buf := bytes.NewBuffer(nil) if _, err := buf.ReadFrom(r.Body); err != nil { t.Errorf("fail to read: %v", err) } w.Header().Set("Docker-Content-Digest", blobDesc.Digest.String()) w.WriteHeader(http.StatusCreated) return default: w.WriteHeader(http.StatusForbidden) } t.Errorf("unexpected access: %s %s", r.Method, r.URL) })) defer ts.Close() uri, err = url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test https server: %v", err) } // Test case with Host: "registry.wabbit-networks.io:443", // Location: "registry.wabbit-networks.io:443" testRegistry = "registry.wabbit-networks.io:443" store, err = blobStore_Push_Port443_HTTPS_create_store(uri, testRegistry) if err != nil { t.Fatalf("blobStore_Push_Port443_HTTPS_create_store() error = %v", err) } err = store.Push(ctx, blobDesc, bytes.NewReader(blob)) if err != nil { t.Fatalf("Blobs.Push() error = %v", err) } // Test case with Host: "registry.wabbit-networks.io", // Location: "registry.wabbit-networks.io:443" testRegistry = "registry.wabbit-networks.io" store, err = blobStore_Push_Port443_HTTPS_create_store(uri, testRegistry) if err != nil { t.Fatalf("blobStore_Push_Port443_HTTPS_create_store() error = %v", err) } err = store.Push(ctx, blobDesc, bytes.NewReader(blob)) if err != nil { t.Fatalf("Blobs.Push() error = %v", err) } } // Testing `last` parameter for Tags list func TestRepository_Tags_WithLastParam(t *testing.T) { tagSet := strings.Split("abcdefghijklmnopqrstuvwxyz", "") var offset int var ts *httptest.Server ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || r.URL.Path != "/v2/test/tags/list" { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } q := r.URL.Query() n, err := strconv.Atoi(q.Get("n")) if err != nil || n != 4 { t.Errorf("bad page size: %s", q.Get("n")) w.WriteHeader(http.StatusBadRequest) return } last := q.Get("last") if last != "" { offset = indexOf(last, tagSet) + 1 } var tags []string switch q.Get("test") { case "foo": tags = tagSet[offset : offset+n] offset += n w.Header().Set("Link", fmt.Sprintf(`<%s/v2/test/tags/list?n=4&last=v&test=bar>; rel="next"`, ts.URL)) case "bar": tags = tagSet[offset : offset+n] default: tags = tagSet[offset : offset+n] offset += n w.Header().Set("Link", fmt.Sprintf(`<%s/v2/test/tags/list?n=4&last=r&test=foo>; rel="next"`, ts.URL)) } result := struct { Tags []string `json:"tags"` }{ Tags: tags, } if err := json.NewEncoder(w).Encode(result); err != nil { t.Errorf("failed to write response: %v", err) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true repo.TagListPageSize = 4 last := "n" startInd := indexOf(last, tagSet) + 1 ctx := context.Background() if err := repo.Tags(ctx, last, func(got []string) error { want := tagSet[startInd : startInd+repo.TagListPageSize] startInd += repo.TagListPageSize if !reflect.DeepEqual(got, want) { t.Errorf("Registry.Repositories() = %v, want %v", got, want) } return nil }); err != nil { t.Errorf("Repository.Tags() error = %v", err) } } func TestRepository_ParseReference(t *testing.T) { type args struct { reference string } tests := []struct { name string repoRef registry.Reference args args want registry.Reference wantErr error }{ { name: "parse tag", repoRef: registry.Reference{ Registry: "registry.example.com", Repository: "hello-world", }, args: args{ reference: "foobar", }, want: registry.Reference{ Registry: "registry.example.com", Repository: "hello-world", Reference: "foobar", }, wantErr: nil, }, { name: "parse digest", repoRef: registry.Reference{ Registry: "registry.example.com", Repository: "hello-world", }, args: args{ reference: "sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", }, want: registry.Reference{ Registry: "registry.example.com", Repository: "hello-world", Reference: "sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", }, wantErr: nil, }, { name: "parse tag@digest", repoRef: registry.Reference{ Registry: "registry.example.com", Repository: "hello-world", }, args: args{ reference: "foobar@sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", }, want: registry.Reference{ Registry: "registry.example.com", Repository: "hello-world", Reference: "sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", }, wantErr: nil, }, { name: "parse FQDN tag", repoRef: registry.Reference{ Registry: "registry.example.com", Repository: "hello-world", }, args: args{ reference: "registry.example.com/hello-world:foobar", }, want: registry.Reference{ Registry: "registry.example.com", Repository: "hello-world", Reference: "foobar", }, wantErr: nil, }, { name: "parse FQDN digest", repoRef: registry.Reference{ Registry: "registry.example.com", Repository: "hello-world", }, args: args{ reference: "registry.example.com/hello-world@sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", }, want: registry.Reference{ Registry: "registry.example.com", Repository: "hello-world", Reference: "sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", }, wantErr: nil, }, { name: "parse FQDN tag@digest", repoRef: registry.Reference{ Registry: "registry.example.com", Repository: "hello-world", }, args: args{ reference: "registry.example.com/hello-world:foobar@sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", }, want: registry.Reference{ Registry: "registry.example.com", Repository: "hello-world", Reference: "sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", }, wantErr: nil, }, { name: "empty reference", repoRef: registry.Reference{ Registry: "registry.example.com", Repository: "hello-world", }, args: args{ reference: "", }, want: registry.Reference{}, wantErr: errdef.ErrInvalidReference, }, { name: "missing repository", repoRef: registry.Reference{ Registry: "registry.example.com", Repository: "hello-world", }, args: args{ reference: "myregistry.example.com:hello-world", }, want: registry.Reference{}, wantErr: errdef.ErrInvalidReference, }, { name: "missing reference", repoRef: registry.Reference{ Registry: "registry.example.com", Repository: "hello-world", }, args: args{ reference: "registry.example.com/hello-world", }, want: registry.Reference{}, wantErr: errdef.ErrInvalidReference, }, { name: "registry mismatch", repoRef: registry.Reference{ Registry: "registry.example.com", Repository: "hello-world", }, args: args{ reference: "myregistry.example.com/hello-world:foobar@sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", }, want: registry.Reference{}, wantErr: errdef.ErrInvalidReference, }, { name: "repository mismatch", repoRef: registry.Reference{ Registry: "registry.example.com", Repository: "hello-world", }, args: args{ reference: "registry.example.com/goodbye-world:foobar@sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", }, want: registry.Reference{}, wantErr: errdef.ErrInvalidReference, }, { name: "digest posing as a tag", repoRef: registry.Reference{ Registry: "registry.example.com", Repository: "hello-world", }, args: args{ reference: "registry.example.com:5000/hello-world:sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", }, want: registry.Reference{}, wantErr: errdef.ErrInvalidReference, }, { name: "missing reference after the at sign", repoRef: registry.Reference{ Registry: "registry.example.com", Repository: "hello-world", }, args: args{ reference: "registry.example.com/hello-world@", }, want: registry.Reference{}, wantErr: errdef.ErrInvalidReference, }, { name: "missing reference after the colon", repoRef: registry.Reference{ Registry: "localhost", }, args: args{ reference: "localhost:5000/hello:", }, want: registry.Reference{}, wantErr: errdef.ErrInvalidReference, }, { name: "zero-size tag, zero-size digest", repoRef: registry.Reference{}, args: args{ reference: "localhost:5000/hello:@", }, want: registry.Reference{}, wantErr: errdef.ErrInvalidReference, }, { name: "zero-size tag with valid digest", repoRef: registry.Reference{}, args: args{ reference: "localhost:5000/hello:@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", }, want: registry.Reference{}, wantErr: errdef.ErrInvalidReference, }, { name: "valid tag with zero-size digest", repoRef: registry.Reference{}, args: args{ reference: "localhost:5000/hello:foobar@", }, want: registry.Reference{}, wantErr: errdef.ErrInvalidReference, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Repository{ Reference: tt.repoRef, } got, err := r.ParseReference(tt.args.reference) if !errors.Is(err, tt.wantErr) { t.Errorf("Repository.ParseReference() error = %v, wantErr %v", err, tt.wantErr) return } if !reflect.DeepEqual(got, tt.want) { t.Errorf("Repository.ParseReference() = %v, want %v", got, tt.want) } }) } } func TestRepository_SetReferrersCapability(t *testing.T) { repo, err := NewRepository("registry.example.com/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } // initial state if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } // valid first time set if err := repo.SetReferrersCapability(true); err != nil { t.Errorf("Repository.SetReferrersCapability() error = %v", err) } if state := repo.loadReferrersState(); state != referrersStateSupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateSupported) } // invalid second time set, state should be no changed if err := repo.SetReferrersCapability(false); !errors.Is(err, ErrReferrersCapabilityAlreadySet) { t.Errorf("Repository.SetReferrersCapability() error = %v, wantErr %v", err, ErrReferrersCapabilityAlreadySet) } if state := repo.loadReferrersState(); state != referrersStateSupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateSupported) } } func Test_generateIndex(t *testing.T) { referrer_1 := spec.Artifact{ MediaType: spec.MediaTypeArtifactManifest, ArtifactType: "foo", } referrerJSON_1, err := json.Marshal(referrer_1) if err != nil { t.Fatal("failed to marshal manifest:", err) } referrer_2 := spec.Artifact{ MediaType: spec.MediaTypeArtifactManifest, ArtifactType: "bar", } referrerJSON_2, err := json.Marshal(referrer_2) if err != nil { t.Fatal("failed to marshal manifest:", err) } referrers := []ocispec.Descriptor{ content.NewDescriptorFromBytes(referrer_1.MediaType, referrerJSON_1), content.NewDescriptorFromBytes(referrer_2.MediaType, referrerJSON_2), } wantIndex := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageIndex, Manifests: referrers, } wantIndexJSON, err := json.Marshal(wantIndex) if err != nil { t.Fatal("failed to marshal index:", err) } wantIndexDesc := content.NewDescriptorFromBytes(wantIndex.MediaType, wantIndexJSON) wantEmptyIndex := ocispec.Index{ Versioned: specs.Versioned{ SchemaVersion: 2, // historical value. does not pertain to OCI or docker version }, MediaType: ocispec.MediaTypeImageIndex, Manifests: []ocispec.Descriptor{}, } wantEmptyIndexJSON, err := json.Marshal(wantEmptyIndex) if err != nil { t.Fatal("failed to marshal index:", err) } wantEmptyIndexDesc := content.NewDescriptorFromBytes(wantEmptyIndex.MediaType, wantEmptyIndexJSON) tests := []struct { name string manifests []ocispec.Descriptor wantDesc ocispec.Descriptor wantBytes []byte wantErr bool }{ { name: "non-empty referrers list", manifests: referrers, wantDesc: wantIndexDesc, wantBytes: wantIndexJSON, wantErr: false, }, { name: "nil referrers list", manifests: nil, wantDesc: wantEmptyIndexDesc, wantBytes: wantEmptyIndexJSON, wantErr: false, }, { name: "empty referrers list", manifests: nil, wantDesc: wantEmptyIndexDesc, wantBytes: wantEmptyIndexJSON, wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, got1, err := generateIndex(tt.manifests) if (err != nil) != tt.wantErr { t.Errorf("generateReferrersIndex() error = %v, wantErr %v", err, tt.wantErr) return } if !reflect.DeepEqual(got, tt.wantDesc) { t.Errorf("generateReferrersIndex() got = %v, want %v", got, tt.wantDesc) } if !reflect.DeepEqual(got1, tt.wantBytes) { t.Errorf("generateReferrersIndex() got1 = %v, want %v", got1, tt.wantBytes) } }) } } func TestRepository_pingReferrers(t *testing.T) { t.Run("referrers available", func(t *testing.T) { count := 0 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && r.URL.Path == "/v2/test/referrers/"+zeroDigest: count++ w.Header().Set("Content-Type", ocispec.MediaTypeImageIndex) w.WriteHeader(http.StatusOK) default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } ctx := context.Background() repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true // 1st call if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } got, err := repo.pingReferrers(ctx) if err != nil { t.Errorf("Repository.pingReferrers() error = %v, wantErr %v", err, nil) } if got != true { t.Errorf("Repository.pingReferrers() = %v, want %v", got, true) } if state := repo.loadReferrersState(); state != referrersStateSupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateSupported) } if count != 1 { t.Errorf("count(Repository.pingReferrers()) = %v, want %v", count, 1) } // 2nd call if state := repo.loadReferrersState(); state != referrersStateSupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateSupported) } got, err = repo.pingReferrers(ctx) if err != nil { t.Errorf("Repository.pingReferrers() error = %v, wantErr %v", err, nil) } if got != true { t.Errorf("Repository.pingReferrers() = %v, want %v", got, true) } if state := repo.loadReferrersState(); state != referrersStateSupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateSupported) } if count != 1 { t.Errorf("count(Repository.pingReferrers()) = %v, want %v", count, 1) } }) t.Run("referrers unavailable", func(t *testing.T) { count := 0 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && r.URL.Path == "/v2/test/referrers/"+zeroDigest: count++ w.WriteHeader(http.StatusNotFound) default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } ctx := context.Background() repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true // 1st call if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } got, err := repo.pingReferrers(ctx) if err != nil { t.Errorf("Repository.pingReferrers() error = %v, wantErr %v", err, nil) } if got != false { t.Errorf("Repository.pingReferrers() = %v, want %v", got, false) } if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } if count != 1 { t.Errorf("count(Repository.pingReferrers()) = %v, want %v", count, 1) } // 2nd call if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } got, err = repo.pingReferrers(ctx) if err != nil { t.Errorf("Repository.pingReferrers() error = %v, wantErr %v", err, nil) } if got != false { t.Errorf("Repository.pingReferrers() = %v, want %v", got, false) } if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } if count != 1 { t.Errorf("count(Repository.pingReferrers()) = %v, want %v", count, 1) } }) t.Run("referrers unavailable incorrect content type", func(t *testing.T) { count := 0 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && r.URL.Path == "/v2/test/referrers/"+zeroDigest: count++ w.Header().Set("Content-Type", "text/html") // can be anything except an OCI image index w.WriteHeader(http.StatusOK) default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } ctx := context.Background() repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true got, err := repo.pingReferrers(ctx) if err != nil { t.Errorf("Repository.pingReferrers() error = %v, wantErr %v", err, nil) } if got != false { t.Errorf("Repository.pingReferrers() = %v, want %v", got, false) } if count != 1 { t.Errorf("count(Repository.pingReferrers()) = %v, want %v", count, 1) } }) } func TestRepository_pingReferrers_RepositoryNotFound(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet && r.URL.Path == "/v2/test/referrers/"+zeroDigest { w.WriteHeader(http.StatusNotFound) w.Write([]byte(`{ "errors": [ { "code": "NAME_UNKNOWN", "message": "repository name not known to registry" } ] }`)) return } t.Errorf("unexpected access: %s %q", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } ctx := context.Background() // test referrers state unknown repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } if _, err = repo.pingReferrers(ctx); err == nil { t.Fatalf("Repository.pingReferrers() error = %v, wantErr %v", err, true) } if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } // test referrers state supported repo, err = NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true repo.SetReferrersCapability(true) if state := repo.loadReferrersState(); state != referrersStateSupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateSupported) } got, err := repo.pingReferrers(ctx) if err != nil { t.Errorf("Repository.pingReferrers() error = %v, wantErr %v", err, nil) } if got != true { t.Errorf("Repository.pingReferrers() = %v, want %v", got, true) } if state := repo.loadReferrersState(); state != referrersStateSupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateSupported) } // test referrers state unsupported repo, err = NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true repo.SetReferrersCapability(false) if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } got, err = repo.pingReferrers(ctx) if err != nil { t.Errorf("Repository.pingReferrers() error = %v, wantErr %v", err, nil) } if got != false { t.Errorf("Repository.pingReferrers() = %v, want %v", got, false) } if state := repo.loadReferrersState(); state != referrersStateUnsupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnsupported) } } func TestRepository_pingReferrers_Concurrent(t *testing.T) { // referrers available var count int32 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && r.URL.Path == "/v2/test/referrers/"+zeroDigest: atomic.AddInt32(&count, 1) w.Header().Set("Content-Type", ocispec.MediaTypeImageIndex) w.WriteHeader(http.StatusOK) default: t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } ctx := context.Background() repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatalf("NewRepository() error = %v", err) } repo.PlainHTTP = true concurrency := 64 eg, egCtx := errgroup.WithContext(ctx) if state := repo.loadReferrersState(); state != referrersStateUnknown { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateUnknown) } for i := 0; i < concurrency; i++ { eg.Go(func() func() error { return func() error { got, err := repo.pingReferrers(egCtx) if err != nil { t.Fatalf("Repository.pingReferrers() error = %v, wantErr %v", err, nil) } if got != true { t.Errorf("Repository.pingReferrers() = %v, want %v", got, true) } return nil } }()) } if err := eg.Wait(); err != nil { t.Fatal(err) } if got := atomic.LoadInt32(&count); got != 1 { t.Errorf("count(Repository.pingReferrers()) = %v, want %v", count, 1) } if state := repo.loadReferrersState(); state != referrersStateSupported { t.Errorf("Repository.loadReferrersState() = %v, want %v", state, referrersStateSupported) } } func TestRepository_do(t *testing.T) { data := []byte(`hello world!`) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || r.URL.Path != "/test" { t.Errorf("unexpected access: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusNotFound) return } w.Header().Add("Warning", `299 - "Test 1: Good warning."`) w.Header().Add("Warning", `199 - "Test 2: Warning with a non-299 code."`) w.Header().Add("Warning", `299 - "Test 3: Good warning."`) w.Header().Add("Warning", `299 myregistry.example.com "Test 4: Warning with a non-unknown agent"`) w.Header().Add("Warning", `299 - "Test 5: Warning with a date." "Sat, 25 Aug 2012 23:34:45 GMT"`) w.Header().Add("wArnIng", `299 - "Test 6: Good warning."`) w.Write(data) })) defer ts.Close() uri, err := url.Parse(ts.URL) if err != nil { t.Fatalf("invalid test http server: %v", err) } testURL := ts.URL + "/test" // test do() without HandleWarning repo, err := NewRepository(uri.Host + "/test") if err != nil { t.Fatal("NewRepository() error =", err) } req, err := http.NewRequest(http.MethodGet, testURL, nil) if err != nil { t.Fatal("failed to create test request:", err) } resp, err := repo.do(req) if err != nil { t.Fatal("Repository.do() error =", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Repository.do() status code = %v, want %v", resp.StatusCode, http.StatusOK) } if got := len(resp.Header["Warning"]); got != 6 { t.Errorf("Repository.do() warning header len = %v, want %v", got, 6) } got, err := io.ReadAll(resp.Body) if err != nil { t.Fatal("io.ReadAll() error =", err) } resp.Body.Close() if !bytes.Equal(got, data) { t.Errorf("Repository.do() = %v, want %v", got, data) } // test do() with HandleWarning repo, err = NewRepository(uri.Host + "/test") if err != nil { t.Fatal("NewRepository() error =", err) } var gotWarnings []Warning repo.HandleWarning = func(warning Warning) { gotWarnings = append(gotWarnings, warning) } req, err = http.NewRequest(http.MethodGet, testURL, nil) if err != nil { t.Fatal("failed to create test request:", err) } resp, err = repo.do(req) if err != nil { t.Fatal("Repository.do() error =", err) } if resp.StatusCode != http.StatusOK { t.Errorf("Repository.do() status code = %v, want %v", resp.StatusCode, http.StatusOK) } if got := len(resp.Header["Warning"]); got != 6 { t.Errorf("Repository.do() warning header len = %v, want %v", got, 6) } got, err = io.ReadAll(resp.Body) if err != nil { t.Errorf("Repository.do() = %v, want %v", got, data) } resp.Body.Close() if !bytes.Equal(got, data) { t.Errorf("Repository.do() = %v, want %v", got, data) } wantWarnings := []Warning{ { WarningValue: WarningValue{ Code: 299, Agent: "-", Text: "Test 1: Good warning.", }, }, { WarningValue: WarningValue{ Code: 299, Agent: "-", Text: "Test 3: Good warning.", }, }, { WarningValue: WarningValue{ Code: 299, Agent: "-", Text: "Test 6: Good warning.", }, }, } if !reflect.DeepEqual(gotWarnings, wantWarnings) { t.Errorf("Repository.do() = %v, want %v", gotWarnings, wantWarnings) } } func TestRepository_clone(t *testing.T) { repo, err := NewRepository("localhost:1234/repo/image") if err != nil { t.Fatalf("invalid repository: %v", err) } crepo := repo.clone() if repo.Reference != crepo.Reference { t.Fatal("references should be the same") } if !reflect.DeepEqual(&repo.referrersPingLock, &crepo.referrersPingLock) { t.Fatal("referrersPingLock should be different") } if !reflect.DeepEqual(&repo.referrersMergePool, &crepo.referrersMergePool) { t.Fatal("referrersMergePool should be different") } } oras-go-2.5.0/registry/remote/retry/000077500000000000000000000000001457674530300174135ustar00rootroot00000000000000oras-go-2.5.0/registry/remote/retry/client.go000066400000000000000000000053661457674530300212320ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package retry import ( "net/http" "time" ) // DefaultClient is a client with the default retry policy. var DefaultClient = NewClient() // NewClient creates an HTTP client with the default retry policy. func NewClient() *http.Client { return &http.Client{ Transport: NewTransport(nil), } } // Transport is an HTTP transport with retry policy. type Transport struct { // Base is the underlying HTTP transport to use. // If nil, http.DefaultTransport is used for round trips. Base http.RoundTripper // Policy returns a retry Policy to use for the request. // If nil, DefaultPolicy is used to determine if the request should be retried. Policy func() Policy } // NewTransport creates an HTTP Transport with the default retry policy. func NewTransport(base http.RoundTripper) *Transport { return &Transport{ Base: base, } } // RoundTrip executes a single HTTP transaction, returning a Response for the // provided Request. // It relies on the configured Policy to determine if the request should be // retried and to backoff. func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { ctx := req.Context() policy := t.policy() attempt := 0 for { resp, respErr := t.roundTrip(req) duration, err := policy.Retry(attempt, resp, respErr) if err != nil { if respErr == nil { resp.Body.Close() } return nil, err } if duration < 0 { return resp, respErr } // rewind the body if possible if req.Body != nil { if req.GetBody == nil { // body can't be rewound, so we can't retry return resp, respErr } body, err := req.GetBody() if err != nil { // failed to rewind the body, so we can't retry return resp, respErr } req.Body = body } // close the response body if needed if respErr == nil { resp.Body.Close() } timer := time.NewTimer(duration) select { case <-ctx.Done(): timer.Stop() return nil, ctx.Err() case <-timer.C: } attempt++ } } func (t *Transport) roundTrip(req *http.Request) (*http.Response, error) { if t.Base == nil { return http.DefaultTransport.RoundTrip(req) } return t.Base.RoundTrip(req) } func (t *Transport) policy() Policy { if t.Policy == nil { return DefaultPolicy } return t.Policy() } oras-go-2.5.0/registry/remote/retry/client_test.go000066400000000000000000000054761457674530300222730ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package retry import ( "bytes" "net/http" "net/http/httptest" "testing" ) func Test_Client(t *testing.T) { testCases := []struct { name string attempts int retryAfter bool StatusCode int expectedErr bool }{ { name: "successful request with 0 retry", attempts: 1, retryAfter: false, StatusCode: http.StatusOK, expectedErr: false, }, { name: "successful request with 1 retry caused by rate limit", // 1 request + 1 retry = 2 attempts attempts: 2, retryAfter: true, StatusCode: http.StatusTooManyRequests, expectedErr: false, }, { name: "successful request with 1 retry caused by 408", // 1 request + 1 retry = 2 attempts attempts: 2, retryAfter: false, StatusCode: http.StatusRequestTimeout, expectedErr: false, }, { name: "successful request with 2 retries caused by 429", // 1 request + 2 retries = 3 attempts attempts: 3, retryAfter: false, StatusCode: http.StatusTooManyRequests, expectedErr: false, }, { name: "unsuccessful request with 6 retries caused by too many retries", // 1 request + 6 retries = 7 attempts attempts: 7, retryAfter: false, StatusCode: http.StatusServiceUnavailable, expectedErr: true, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { count := 0 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { count++ if count < tc.attempts { if tc.retryAfter { w.Header().Set("Retry-After", "1") } http.Error(w, "error", tc.StatusCode) return } w.WriteHeader(http.StatusOK) })) defer ts.Close() req, err := http.NewRequest(http.MethodPost, ts.URL, bytes.NewReader([]byte("test"))) if err != nil { t.Fatalf("failed to create test request: %v", err) } resp, err := DefaultClient.Do(req) if err != nil { t.Fatalf("failed to do test request: %v", err) } if tc.expectedErr { if count != (tc.attempts - 1) { t.Errorf("expected attempts %d, got %d", tc.attempts, count) } if resp.StatusCode != http.StatusServiceUnavailable { t.Errorf("expected status code %d, got %d", http.StatusServiceUnavailable, resp.StatusCode) } return } if tc.attempts != count { t.Errorf("expected attempts %d, got %d", tc.attempts, count) } }) } } oras-go-2.5.0/registry/remote/retry/policy.go000066400000000000000000000114751457674530300212510ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package retry import ( "hash/maphash" "math" "math/rand" "net" "net/http" "strconv" "time" ) // headerRetryAfter is the header key for Retry-After. const headerRetryAfter = "Retry-After" // DefaultPolicy is a policy with fine-tuned retry parameters. // It uses an exponential backoff with jitter. var DefaultPolicy Policy = &GenericPolicy{ Retryable: DefaultPredicate, Backoff: DefaultBackoff, MinWait: 200 * time.Millisecond, MaxWait: 3 * time.Second, MaxRetry: 5, } // DefaultPredicate is a predicate that retries on 5xx errors, 429 Too Many // Requests, 408 Request Timeout and on network dial timeout. var DefaultPredicate Predicate = func(resp *http.Response, err error) (bool, error) { if err != nil { // retry on Dial timeout if err, ok := err.(net.Error); ok && err.Timeout() { return true, nil } return false, err } if resp.StatusCode == http.StatusRequestTimeout || resp.StatusCode == http.StatusTooManyRequests { return true, nil } if resp.StatusCode == 0 || resp.StatusCode >= 500 { return true, nil } return false, nil } // DefaultBackoff is a backoff that uses an exponential backoff with jitter. // It uses a base of 250ms, a factor of 2 and a jitter of 10%. var DefaultBackoff Backoff = ExponentialBackoff(250*time.Millisecond, 2, 0.1) // Policy is a retry policy. type Policy interface { // Retry returns the duration to wait before retrying the request. // It returns a negative value if the request should not be retried. // The attempt is used to: // - calculate the backoff duration, the default backoff is an exponential backoff. // - determine if the request should be retried. // The attempt starts at 0 and should be less than MaxRetry for the request to // be retried. Retry(attempt int, resp *http.Response, err error) (time.Duration, error) } // Predicate is a function that returns true if the request should be retried. type Predicate func(resp *http.Response, err error) (bool, error) // Backoff is a function that returns the duration to wait before retrying the // request. The attempt, is the next attempt number. The response is the // response from the previous request. type Backoff func(attempt int, resp *http.Response) time.Duration // ExponentialBackoff returns a Backoff that uses an exponential backoff with // jitter. The backoff is calculated as: // // temp = backoff * factor ^ attempt // interval = temp * (1 - jitter) + rand.Int63n(2 * jitter * temp) // // The HTTP response is checked for a Retry-After header. If it is present, the // value is used as the backoff duration. func ExponentialBackoff(backoff time.Duration, factor, jitter float64) Backoff { return func(attempt int, resp *http.Response) time.Duration { var h maphash.Hash h.SetSeed(maphash.MakeSeed()) rand := rand.New(rand.NewSource(int64(h.Sum64()))) // check Retry-After if resp != nil && resp.StatusCode == http.StatusTooManyRequests { if v := resp.Header.Get(headerRetryAfter); v != "" { if retryAfter, _ := strconv.ParseInt(v, 10, 64); retryAfter > 0 { return time.Duration(retryAfter) * time.Second } } } // do exponential backoff with jitter temp := float64(backoff) * math.Pow(factor, float64(attempt)) return time.Duration(temp*(1-jitter)) + time.Duration(rand.Int63n(int64(2*jitter*temp))) } } // GenericPolicy is a generic retry policy. type GenericPolicy struct { // Retryable is a predicate that returns true if the request should be // retried. Retryable Predicate // Backoff is a function that returns the duration to wait before retrying. Backoff Backoff // MinWait is the minimum duration to wait before retrying. MinWait time.Duration // MaxWait is the maximum duration to wait before retrying. MaxWait time.Duration // MaxRetry is the maximum number of retries. MaxRetry int } // Retry returns the duration to wait before retrying the request. // It returns -1 if the request should not be retried. func (p *GenericPolicy) Retry(attempt int, resp *http.Response, err error) (time.Duration, error) { if attempt >= p.MaxRetry { return -1, nil } if ok, err := p.Retryable(resp, err); err != nil { return -1, err } else if !ok { return -1, nil } backoff := p.Backoff(attempt, resp) if backoff < p.MinWait { backoff = p.MinWait } if backoff > p.MaxWait { backoff = p.MaxWait } return backoff, nil } oras-go-2.5.0/registry/remote/retry/policy_test.go000066400000000000000000000034211457674530300223000ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package retry import ( "testing" "time" ) func Test_ExponentialBackoff(t *testing.T) { testCases := []struct { name string attempt int expectedBackoff time.Duration }{ { name: "attempt 0 should have a backoff of 0,25s ± 10%", attempt: 0, expectedBackoff: 250 * time.Millisecond, }, { name: "attempt 1 should have a backoff of 0,5s ± 10%", attempt: 1, expectedBackoff: 500 * time.Millisecond, }, { name: "attempt 2 should have a backoff of 1s ± 10%", attempt: 2, expectedBackoff: 1 * time.Second, }, { name: "attempt 3 should have a backoff of 2s ± 10%", attempt: 3, expectedBackoff: 2 * time.Second, }, { name: "attempt 4 should have a backoff of 4s ± 10%", attempt: 4, expectedBackoff: 4 * time.Second, }, { name: "attempt 5 should have a backoff of 8s ± 10%", attempt: 5, expectedBackoff: 8 * time.Second, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { b := DefaultBackoff(tc.attempt, nil) base := float64(tc.expectedBackoff) if !(b >= time.Duration(base*0.9) && b <= time.Duration(base+base*0.1)) { t.Errorf("expected backoff to be %s + jitter, got %s", time.Duration(base), b) } }) } } oras-go-2.5.0/registry/remote/url.go000066400000000000000000000104471457674530300174050ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package remote import ( "fmt" "net/url" "strings" "github.com/opencontainers/go-digest" "oras.land/oras-go/v2/registry" ) // buildScheme returns HTTP scheme used to access the remote registry. func buildScheme(plainHTTP bool) string { if plainHTTP { return "http" } return "https" } // buildRegistryBaseURL builds the URL for accessing the base API. // Format: :///v2/ // Reference: https://docs.docker.com/registry/spec/api/#base func buildRegistryBaseURL(plainHTTP bool, ref registry.Reference) string { return fmt.Sprintf("%s://%s/v2/", buildScheme(plainHTTP), ref.Host()) } // buildRegistryCatalogURL builds the URL for accessing the catalog API. // Format: :///v2/_catalog // Reference: https://docs.docker.com/registry/spec/api/#catalog func buildRegistryCatalogURL(plainHTTP bool, ref registry.Reference) string { return fmt.Sprintf("%s://%s/v2/_catalog", buildScheme(plainHTTP), ref.Host()) } // buildRepositoryBaseURL builds the base endpoint of the remote repository. // Format: :///v2/ func buildRepositoryBaseURL(plainHTTP bool, ref registry.Reference) string { return fmt.Sprintf("%s://%s/v2/%s", buildScheme(plainHTTP), ref.Host(), ref.Repository) } // buildRepositoryTagListURL builds the URL for accessing the tag list API. // Format: :///v2//tags/list // Reference: https://docs.docker.com/registry/spec/api/#tags func buildRepositoryTagListURL(plainHTTP bool, ref registry.Reference) string { return buildRepositoryBaseURL(plainHTTP, ref) + "/tags/list" } // buildRepositoryManifestURL builds the URL for accessing the manifest API. // Format: :///v2//manifests/ // Reference: https://docs.docker.com/registry/spec/api/#manifest func buildRepositoryManifestURL(plainHTTP bool, ref registry.Reference) string { return strings.Join([]string{ buildRepositoryBaseURL(plainHTTP, ref), "manifests", ref.Reference, }, "/") } // buildRepositoryBlobURL builds the URL for accessing the blob API. // Format: :///v2//blobs/ // Reference: https://docs.docker.com/registry/spec/api/#blob func buildRepositoryBlobURL(plainHTTP bool, ref registry.Reference) string { return strings.Join([]string{ buildRepositoryBaseURL(plainHTTP, ref), "blobs", ref.Reference, }, "/") } // buildRepositoryBlobUploadURL builds the URL for blob uploading. // Format: :///v2//blobs/uploads/ // Reference: https://docs.docker.com/registry/spec/api/#initiate-blob-upload func buildRepositoryBlobUploadURL(plainHTTP bool, ref registry.Reference) string { return buildRepositoryBaseURL(plainHTTP, ref) + "/blobs/uploads/" } // buildRepositoryBlobMountURLbuilds the URL for cross-repository mounting. // Format: :///v2//blobs/uploads/?mount=&from= // Reference: https://docs.docker.com/registry/spec/api/#blob func buildRepositoryBlobMountURL(plainHTTP bool, ref registry.Reference, d digest.Digest, fromRepo string) string { return fmt.Sprintf("%s?mount=%s&from=%s", buildRepositoryBlobUploadURL(plainHTTP, ref), d, fromRepo, ) } // buildReferrersURL builds the URL for querying the Referrers API. // Format: :///v2//referrers/?artifactType= // Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#listing-referrers func buildReferrersURL(plainHTTP bool, ref registry.Reference, artifactType string) string { var query string if artifactType != "" { v := url.Values{} v.Set("artifactType", artifactType) query = "?" + v.Encode() } return fmt.Sprintf( "%s/referrers/%s%s", buildRepositoryBaseURL(plainHTTP, ref), ref.Reference, query, ) } oras-go-2.5.0/registry/remote/url_test.go000066400000000000000000000053221457674530300204400ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package remote import ( "net/url" "reflect" "testing" "oras.land/oras-go/v2/registry" ) func Test_buildReferrersURL(t *testing.T) { ref := registry.Reference{ Registry: "localhost", Repository: "hello-world", Reference: "sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", } params := []struct { name string plainHttp bool artifactType string want string }{ { name: "plain http, no filter", plainHttp: true, artifactType: "", want: "http://localhost/v2/hello-world/referrers/sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", }, { name: "https, no filter", plainHttp: false, artifactType: "", want: "https://localhost/v2/hello-world/referrers/sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", }, { name: "plain http, filter", plainHttp: true, artifactType: "signature/example", want: "http://localhost/v2/hello-world/referrers/sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9?artifactType=signature%2Fexample", }, { name: "https, filter", plainHttp: false, artifactType: "signature/example", want: "https://localhost/v2/hello-world/referrers/sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9?artifactType=signature%2Fexample", }, } for _, tt := range params { t.Run(tt.name, func(t *testing.T) { got := buildReferrersURL(tt.plainHttp, ref, tt.artifactType) if !compareUrl(got, tt.want) { t.Errorf("buildReferrersURL() = %s, want %s", got, tt.want) } }) } } // compareUrl compares two urls, regardless of query order and encoding func compareUrl(s1, s2 string) bool { u1, err := url.Parse(s1) if err != nil { return false } u2, err := url.Parse(s2) if err != nil { return false } q1, err := url.ParseQuery(u1.RawQuery) if err != nil { return false } q2, err := url.ParseQuery(u2.RawQuery) if err != nil { return false } return u1.Scheme == u2.Scheme && reflect.DeepEqual(u1.User, u1.User) && u1.Host == u2.Host && u1.Path == u2.Path && reflect.DeepEqual(q1, q2) } oras-go-2.5.0/registry/remote/utils.go000066400000000000000000000051771457674530300177470ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package remote import ( "encoding/json" "errors" "fmt" "io" "net/http" "strings" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/errdef" ) // defaultMaxMetadataBytes specifies the default limit on how many response // bytes are allowed in the server's response to the metadata APIs. // See also: Repository.MaxMetadataBytes var defaultMaxMetadataBytes int64 = 4 * 1024 * 1024 // 4 MiB // errNoLink is returned by parseLink() when no Link header is present. var errNoLink = errors.New("no Link header in response") // parseLink returns the URL of the response's "Link" header, if present. func parseLink(resp *http.Response) (string, error) { link := resp.Header.Get("Link") if link == "" { return "", errNoLink } if link[0] != '<' { return "", fmt.Errorf("invalid next link %q: missing '<'", link) } if i := strings.IndexByte(link, '>'); i == -1 { return "", fmt.Errorf("invalid next link %q: missing '>'", link) } else { link = link[1:i] } linkURL, err := resp.Request.URL.Parse(link) if err != nil { return "", err } return linkURL.String(), nil } // limitReader returns a Reader that reads from r but stops with EOF after n // bytes. If n is less than or equal to zero, defaultMaxMetadataBytes is used. func limitReader(r io.Reader, n int64) io.Reader { if n <= 0 { n = defaultMaxMetadataBytes } return io.LimitReader(r, n) } // limitSize returns ErrSizeExceedsLimit if the size of desc exceeds the limit n. // If n is less than or equal to zero, defaultMaxMetadataBytes is used. func limitSize(desc ocispec.Descriptor, n int64) error { if n <= 0 { n = defaultMaxMetadataBytes } if desc.Size > n { return fmt.Errorf( "content size %v exceeds MaxMetadataBytes %v: %w", desc.Size, n, errdef.ErrSizeExceedsLimit) } return nil } // decodeJSON safely reads the JSON content described by desc, and // decodes it into v. func decodeJSON(r io.Reader, desc ocispec.Descriptor, v any) error { jsonBytes, err := content.ReadAll(r, desc) if err != nil { return err } return json.Unmarshal(jsonBytes, v) } oras-go-2.5.0/registry/remote/utils_test.go000066400000000000000000000066601457674530300210040ustar00rootroot00000000000000/* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package remote import ( "errors" "net/http" "net/url" "testing" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/errdef" ) func Test_parseLink(t *testing.T) { tests := []struct { name string url string header string want string wantErr bool }{ { name: "catalog", url: "https://localhost:5000/v2/_catalog", header: `; rel="next"`, want: "https://localhost:5000/v2/_catalog?last=alpine&n=1", }, { name: "list tag", url: "https://localhost:5000/v2/hello-world/tags/list", header: `; rel="next"`, want: "https://localhost:5000/v2/hello-world/tags/list?last=latest&n=1", }, { name: "other domain", url: "https://localhost:5000/v2/_catalog", header: `; rel="next"`, want: "https://localhost:5001/v2/_catalog?last=alpine&n=1", }, { name: "invalid header", url: "https://localhost:5000/v2/_catalog", header: `