working commit

This commit is contained in:
2026-03-13 19:02:42 +02:00
parent bebbf79c7a
commit 5c1da77f4c
1329 changed files with 314708 additions and 39 deletions
+41
View File
@@ -0,0 +1,41 @@
# 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
+2
View File
@@ -0,0 +1,2 @@
# Derived from OWNERS.md
* @sajayantony @shizhMSFT @stevelasker @Wwwsylvia
+3
View File
@@ -0,0 +1,3 @@
# 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).
+201
View File
@@ -0,0 +1,201 @@
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.
+61
View File
@@ -0,0 +1,61 @@
# Migration Guide
In version `v2`, ORAS Go library has been completely refreshed with:
- More unified interfaces
- Notably fewer dependencies
- Higher test coverage
- Better documentation
**Additionally, ORAS Go `v2` is now a registry client.**
## Major Changes in `v2`
- Content store
- [`content.File`](https://pkg.go.dev/oras.land/oras-go/pkg/content#File) is now [`file.Store`](https://pkg.go.dev/oras.land/oras-go/v2/content/file#Store)
- [`content.OCI`](https://pkg.go.dev/oras.land/oras-go/pkg/content#OCI) is now [`oci.Store`](https://pkg.go.dev/oras.land/oras-go/v2/content/oci#Store)
- [`content.Memory`](https://pkg.go.dev/oras.land/oras-go/pkg/content#Memory) is now [`memory.Store`](https://pkg.go.dev/oras.land/oras-go/v2/content/memory#Store)
- Registry interaction
- Introduces an [SDK](https://pkg.go.dev/oras.land/oras-go/v2/registry/remote) to interact with OCI-compliant and Docker-compliant registries
- Authentication
- Implements authentication through [`auth.Client`](https://pkg.go.dev/oras.land/oras-go/v2/registry/remote/auth#Client) and supports credential management via [`credentials`](https://pkg.go.dev/oras.land/oras-go/v2/registry/remote/credentials)
- Copy operations
- Enhances artifact [copying](https://pkg.go.dev/oras.land/oras-go/v2#Copy) capabilities between various [`Target`](https://pkg.go.dev/oras.land/oras-go/v2#Target) with flexible options
- Enables [extended-copying](https://pkg.go.dev/oras.land/oras-go/v2#ExtendedCopy) of artifacts along with their predecessors (e.g., referrers)
## 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.
For comprehensive documentation and examples, please refer to [pkg.go.dev](https://pkg.go.dev/oras.land/oras-go/v2).
## FAQs
### Is there a 1:1 mapping of APIs between `v1` and `v2`?
No, `v2` does not have a direct 1:1 mapping of APIs with `v1`, as the structure of the APIs has been significantly redesigned. Instead of looking for a direct replacement, see this as a chance to upgrade your application with `v2`'s new features.
You can explore the [end-to-end examples](https://pkg.go.dev/oras.land/oras-go/v2#pkg-overview) that demonstrate the usage of v2 in practical scenarios.
## Community Support
If you encounter challenges during migration, seek assistance from the community by [submitting GitHub issues](https://github.com/oras-project/oras-go/issues/new) or asking in the [#oras](https://cloud-native.slack.com/archives/CJ1KHJM5Z) Slack channel.
+11
View File
@@ -0,0 +1,11 @@
# Owners
Owners:
- Sajay Antony (@sajayantony)
- Shiwei Zhang (@shizhMSFT)
- Steve Lasker (@stevelasker)
- Sylvia Lei (@Wwwsylvia)
Emeritus:
- Avi Deitcher (@deitch)
- Josh Dolitsky (@jdolitsky)
+66
View File
@@ -0,0 +1,66 @@
# ORAS Go library
[![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)
<p align="left">
<a href="https://oras.land/"><img src="https://oras.land/img/oras.svg" alt="ORAS logo" width="100px"></a>
</p>
`oras-go` is a Go library for managing OCI artifacts, compliant with the [OCI Image Format Specification](https://github.com/opencontainers/image-spec) and the [OCI Distribution Specification](https://github.com/opencontainers/distribution-spec). It provides unified APIs for pushing, pulling, and managing artifacts across OCI-compliant registries, local file systems, and in-memory stores.
> [!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.23` and `1.24`).
## Getting Started
### Concepts
Gain insights into the fundamental concepts:
- [Modeling Artifacts](docs/Modeling-Artifacts.md)
- [Targets and Content Stores](docs/Targets.md)
### Quickstart
Follow the step-by-step tutorial to use `oras-go` v2:
- [Quickstart: Managing OCI Artifacts with `oras-go` v2](docs/tutorial/quickstart.md)
### Examples
Check out sample code for common use cases:
- [Artifact copying](https://pkg.go.dev/oras.land/oras-go/v2#pkg-examples)
- [Registry operations](https://pkg.go.dev/oras.land/oras-go/v2/registry#pkg-examples)
- [Repository operations](https://pkg.go.dev/oras.land/oras-go/v2/registry/remote#pkg-examples)
- [Authentication](https://pkg.go.dev/oras.land/oras-go/v2/registry/remote/auth#pkg-examples)
- [Credentials management](https://pkg.go.dev/oras.land/oras-go/v2/registry/remote/credentials#pkg-examples)
Find more API examples at [pkg.go.dev](https://pkg.go.dev/oras.land/oras-go/v2).
## Versioning
This project follows [Semantic Versioning](https://semver.org/) (`MAJOR`.`MINOR`.`PATCH`), with `MAJOR` for breaking changes, `MINOR` for backward-compatible features, and `PATCH` for backward-compatible fixes.
## Previous Major Versions
### v1 (maintenance)
[![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)
The [`v1`](https://github.com/oras-project/oras-go/tree/v1) branch is maintained for dependency updates and security fixes only. All feature development happens in the [`main`](https://github.com/oras-project/oras-go/tree/main) branch.
To migrate from `v1` to `v2`, see [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md).
## Community
- Code of Conduct: [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)
- Security Policy: [SECURITY.md](SECURITY.md)
- Reviewing Guide: [Reviewing Guide](https://github.com/oras-project/community/blob/main/REVIEWING.md)
- Slack: [`#oras`](https://cloud-native.slack.com/archives/CJ1KHJM5Z) channel on CNCF Slack
+3
View File
@@ -0,0 +1,3 @@
# Security Policy
Please follow the [security policy](https://oras.land/docs/community/reporting_security_concerns) to report a security vulnerability or concern.
+411
View File
@@ -0,0 +1,411 @@
/*
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)
}
+40
View File
@@ -0,0 +1,40 @@
/*
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
}
+122
View File
@@ -0,0 +1,122 @@
/*
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
}
+50
View File
@@ -0,0 +1,50 @@
/*
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}
}
+96
View File
@@ -0,0 +1,96 @@
/*
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)
}
+149
View File
@@ -0,0 +1,149 @@
/*
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 {
if err := desc.Digest.Validate(); err != nil {
return &VerifyReader{
err: fmt.Errorf("failed to validate %s: %w", desc.Digest, err),
}
}
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
}
+47
View File
@@ -0,0 +1,47 @@
/*
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
}
+80
View File
@@ -0,0 +1,80 @@
/*
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)
}
+533
View File
@@ -0,0 +1,533 @@
/*
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), such as an artifact,
// from the source Target to the destination Target.
//
// The root node (e.g. a tagged manifest of the artifact) is identified by the
// source reference.
// 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{}, newCopyError("Copy", CopyErrorOriginSource, errors.New("nil source target"))
}
if dst == nil {
return ocispec.Descriptor{}, newCopyError("Copy", CopyErrorOriginDestination, 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{}, err
}
if opts.MapRoot != nil {
proxy.StopCaching = true
root, err = opts.MapRoot(ctx, proxy, root)
if err != nil {
return ocispec.Descriptor{}, newCopyError("MapRoot", CopyErrorOriginSource, 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), such as an artifact,
// from the source CAS to the destination CAS.
// The root node (e.g. a manifest of the artifact) is identified by a descriptor.
func CopyGraph(ctx context.Context, src content.ReadOnlyStorage, dst content.Storage, root ocispec.Descriptor, opts CopyGraphOptions) error {
if src == nil {
return newCopyError("CopyGraph", CopyErrorOriginSource, errors.New("nil source target"))
}
if dst == nil {
return newCopyError("CopyGraph", CopyErrorOriginDestination, errors.New("nil destination target"))
}
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 newCopyError("Exists", CopyErrorOriginDestination, 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 newCopyError("FindSuccessors", CopyErrorOriginSource, 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 fmt.Errorf("failed to check cache existence: %s: %w", desc.Digest, 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 newCopyError("Mount", CopyErrorOriginDestination, 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 newCopyError("Fetch", CopyErrorOriginSource, err)
}
defer rc.Close()
err = dst.Push(ctx, desc, rc)
if err != nil && !errors.Is(err, errdef.ErrAlreadyExists) {
return newCopyError("Push", CopyErrorOriginDestination, 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 newCopyError("Fetch", CopyErrorOriginSource, err)
}
defer rc.Close()
err = dst.PushReference(ctx, desc, rc, dstRef)
if err != nil && !errors.Is(err, errdef.ErrAlreadyExists) {
return newCopyError("PushReference", CopyErrorOriginDestination, 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 {
desc, err := src.Resolve(ctx, srcRef)
if err != nil {
return ocispec.Descriptor{}, newCopyError("Resolve", CopyErrorOriginSource, err)
}
return desc, nil
}
// optimize performance for ReferenceFetcher targets
refProxy := &registryutil.Proxy{
ReferenceFetcher: refFetcher,
Proxy: proxy,
}
root, rc, err := refProxy.FetchReference(ctx, srcRef)
if err != nil {
return ocispec.Descriptor{}, newCopyError("FetchReference", CopyErrorOriginSource, 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{}, newCopyError("Successors", CopyErrorOriginSource, 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(_ 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 newCopyError("Tag", CopyErrorOriginDestination, 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
}
}
if err := dst.Tag(ctx, root, dstRef); err != nil {
return newCopyError("Tag", CopyErrorOriginDestination, err)
}
return nil
}
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]
}
+78
View File
@@ -0,0 +1,78 @@
/*
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 "fmt"
// CopyErrorOrigin defines the source of a copy error.
type CopyErrorOrigin int
const (
// CopyErrorOriginSource indicates the error occurred at the source side.
CopyErrorOriginSource CopyErrorOrigin = 1
// CopyErrorOriginDestination indicates the error occurred at the destination side.
CopyErrorOriginDestination CopyErrorOrigin = 2
)
// String returns the string representation of the CopyErrorOrigin.
func (o CopyErrorOrigin) String() string {
switch o {
case CopyErrorOriginSource:
return "source"
case CopyErrorOriginDestination:
return "destination"
default:
return "unknown"
}
}
// CopyError represents an error encountered during a copy operation.
type CopyError struct {
// Op is the operation that caused the error.
Op string
// Origin indicates the source of the error.
Origin CopyErrorOrigin
// Err is the underlying error.
Err error
}
// newCopyError creates a new CopyError.
func newCopyError(op string, origin CopyErrorOrigin, err error) error {
if err == nil {
return nil
}
return &CopyError{
Op: op,
Origin: origin,
Err: err,
}
}
// Error implements the error interface for CopyError.
func (e *CopyError) Error() string {
switch e.Origin {
case CopyErrorOriginSource, CopyErrorOriginDestination:
return fmt.Sprintf("failed to perform %q on %s: %v", e.Op, e.Origin, e.Err)
default:
return fmt.Sprintf("failed to perform %q: %v", e.Op, e.Err)
}
}
// Unwrap implements the errors.Unwrap interface for CopyError.
func (e *CopyError) Unwrap() error {
return e.Err
}
+31
View File
@@ -0,0 +1,31 @@
/*
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")
)
+404
View File
@@ -0,0 +1,404 @@
/*
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.
// In other words, it copies a tagged artifact along with its referrers or
// other predecessor manifests referencing it.
//
// The tagged node (e.g. a tagged manifest of the artifact) is identified by the
// source reference.
// 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{}, newCopyError("ExtendedCopy", CopyErrorOriginSource, errors.New("nil source target"))
}
if dst == nil {
return ocispec.Descriptor{}, newCopyError("ExtendedCopy", CopyErrorOriginDestination, errors.New("nil destination target"))
}
if dstRef == "" {
dstRef = srcRef
}
node, err := src.Resolve(ctx, srcRef)
if err != nil {
return ocispec.Descriptor{}, newCopyError("Resolve", CopyErrorOriginSource, 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{}, newCopyError("Tag", CopyErrorOriginDestination, 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.
// In other words, it copies an artifact along with its referrers or other
// predecessor manifests referencing it.
// The node (e.g. a manifest of the artifact) is identified by a descriptor.
func ExtendedCopyGraph(ctx context.Context, src content.ReadOnlyGraphStorage, dst content.Storage, node ocispec.Descriptor, opts ExtendedCopyGraphOptions) error {
if src == nil {
return newCopyError("ExtendedCopyGraph", CopyErrorOriginSource, errors.New("nil source target"))
}
if dst == nil {
return newCopyError("ExtendedCopyGraph", CopyErrorOriginDestination, errors.New("nil destination target"))
}
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, newCopyError("FindPredecessors", CopyErrorOriginSource, 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
}
}
+88
View File
@@ -0,0 +1,88 @@
/*
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
}
+125
View File
@@ -0,0 +1,125 @@
/*
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)
}
@@ -0,0 +1,40 @@
/*
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)
}
+55
View File
@@ -0,0 +1,55 @@
/*
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
}
@@ -0,0 +1,89 @@
/*
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,
}
}
@@ -0,0 +1,24 @@
/*
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"
)
+201
View File
@@ -0,0 +1,201 @@
/*
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
}
+116
View File
@@ -0,0 +1,116 @@
/*
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()
}
@@ -0,0 +1,24 @@
/*
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)
}
+66
View File
@@ -0,0 +1,66 @@
/*
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
}
}
@@ -0,0 +1,84 @@
/*
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
}
}
@@ -0,0 +1,145 @@
/*
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: %w", desc.MediaType, targetConfigMediaType, errdef.ErrUnsupported)
}
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
}
@@ -0,0 +1,102 @@
/*
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
}
+105
View File
@@ -0,0 +1,105 @@
/*
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"
"fmt"
"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{}, fmt.Errorf("%s: %w", reference, 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)
}
+57
View File
@@ -0,0 +1,57 @@
/*
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"`
}
+43
View File
@@ -0,0 +1,43 @@
/*
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
}
+107
View File
@@ -0,0 +1,107 @@
/*
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 {
ctx, cancel := context.WithCancelCause(ctx)
defer cancel(nil)
eg, egCtx := errgroup.WithContext(ctx)
for _, item := range items {
region := LimitRegion(egCtx, limiter)
if err := region.Start(); err != nil {
cancel(err)
// break loop instead of returning to allow previously scheduled
// goroutines to finish their deferred region.End() calls
break
}
eg.Go(func(t T, lr *LimitedRegion) func() error {
return func() error {
defer lr.End()
select {
case <-egCtx.Done():
// skip the task if the context is already cancelled
return nil
default:
}
if err := fn(egCtx, lr, t); err != nil {
cancel(err)
return err
}
return nil
}
}(item, region))
}
if err := eg.Wait(); err != nil {
cancel(err)
}
return context.Cause(ctx)
}
@@ -0,0 +1,67 @@
/*
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"
)
// 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()
}
+140
View File
@@ -0,0 +1,140 @@
/*
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}
}
}
+102
View File
@@ -0,0 +1,102 @@
/*
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
}
+64
View File
@@ -0,0 +1,64 @@
/*
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)
}
}
}
+448
View File
@@ -0,0 +1,448 @@
/*
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
// "org.opencontainers.artifact.created" or "org.opencontainers.image.created"
// 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.1.
// Reference: https://github.com/opencontainers/image-spec/blob/v1.1.1/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. In order to
// make [PackManifest] reproducible, set the key ocispec.AnnotationCreated
// (i.e. "org.opencontainers.image.created") to a fixed value. The value
// must conform to RFC 3339.
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.1/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.
//
// Each time when PackManifest is called, if a time stamp is not specified, a new time
// stamp is generated in the manifest annotations with the key ocispec.AnnotationCreated
// (i.e. "org.opencontainers.image.created"). To make [PackManifest] reproducible,
// set the key ocispec.AnnotationCreated to a fixed value in
// opts.ManifestAnnotations. The value MUST conform to RFC 3339.
//
// 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.1.
// Reference: https://github.com/opencontainers/image-spec/blob/v1.1.1/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
}
+276
View File
@@ -0,0 +1,276 @@
/*
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.1/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.1/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. BackusNaur 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`:
//
// <artifact> ::= <socketaddr> "/" <path>
//
// The former is described as follows:
//
// <socketaddr> ::= <host> | <host> ":" <PORT>
// <host> ::= <ip> | <FQDN>
// <ip> ::= <IPV4-ADDR> | <IPV6-ADDR>
//
// The latter, which is of greater interest here, is described as follows:
//
// <path> ::= <REPOSITORY> | <REPOSITORY> <reference>
// <reference> ::= "@" <digest> | ":" <TAG> "@" <DIGEST> | ":" <TAG>
// <digest> ::= <ALGO> ":" <HASH>
//
// 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 ===> : <!!! TAG !!!> @ <=== 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
}
+52
View File
@@ -0,0 +1,52 @@
/*
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://distribution.github.io/distribution/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
}
@@ -0,0 +1,232 @@
/*
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},
}
}
@@ -0,0 +1,167 @@
/*
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://distribution.github.io/distribution/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
}
@@ -0,0 +1,430 @@
/*
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://distribution.github.io/distribution/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://distribution.github.io/distribution/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://distribution.github.io/distribution/spec/auth/jwt/
// - https://distribution.github.io/distribution/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://distribution.github.io/distribution/spec/auth/jwt/
// - https://distribution.github.io/distribution/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://distribution.github.io/distribution/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://distribution.github.io/distribution/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
}
@@ -0,0 +1,40 @@
/*
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://distribution.github.io/distribution/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://distribution.github.io/distribution/spec/auth/token/
AccessToken string
}
@@ -0,0 +1,325 @@
/*
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://distribution.github.io/distribution/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://distribution.github.io/distribution/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://distribution.github.io/distribution/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://distribution.github.io/distribution/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 := range len(actions) {
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]
}
@@ -0,0 +1,97 @@
/*
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
}
@@ -0,0 +1,332 @@
/*
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
}
@@ -0,0 +1,80 @@
/*
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
}
@@ -0,0 +1,49 @@
/*
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
}
@@ -0,0 +1,81 @@
/*
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"
"fmt"
"sync"
"oras.land/oras-go/v2/registry/remote/auth"
"oras.land/oras-go/v2/registry/remote/credentials/internal/config"
)
// 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{}
}
// NewMemoryStoreFromDockerConfig creates a new in-memory credentials store from the given configuration.
//
// Reference: https://docs.docker.com/engine/reference/commandline/cli/#docker-cli-configuration-file-configjson-properties
func NewMemoryStoreFromDockerConfig(c []byte) (Store, error) {
cfg := struct {
Auths map[string]config.AuthConfig `json:"auths"`
}{}
if err := json.Unmarshal(c, &cfg); err != nil {
return nil, fmt.Errorf("failed to unmarshal auth field: %w: %v", config.ErrInvalidConfigFormat, err)
}
s := &memoryStore{}
for addr, auth := range cfg.Auths {
// Normalize the auth key to hostname.
hostname := config.ToHostname(addr)
cred, err := auth.Credential()
if err != nil {
return nil, err
}
_, _ = s.store.LoadOrStore(hostname, cred)
}
return s, nil
}
// 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
}
@@ -0,0 +1,139 @@
/*
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 = "<token>"
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 "<token>"
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 ""
}
@@ -0,0 +1,23 @@
/*
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"
}
@@ -0,0 +1,25 @@
//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 ""
}
@@ -0,0 +1,29 @@
/*
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"
}
@@ -0,0 +1,23 @@
/*
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"
}
@@ -0,0 +1,102 @@
/*
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
}
@@ -0,0 +1,262 @@
/*
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)
}
@@ -0,0 +1,94 @@
/*
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
}
}
}
@@ -0,0 +1,128 @@
/*
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.1/spec.md#error-codes
// - https://distribution.github.io/distribution/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.1/spec.md#error-codes
// - https://distribution.github.io/distribution/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.1/spec.md#error-codes
// - https://distribution.github.io/distribution/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 "<nil>"
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
}
@@ -0,0 +1,54 @@
/*
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
}
+59
View File
@@ -0,0 +1,59 @@
/*
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, ", ")
}
+225
View File
@@ -0,0 +1,225 @@
/*
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"
"fmt"
"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: <algorithm>-<digest>
// Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#unavailable-referrers-api
func buildReferrersTag(desc ocispec.Descriptor) (string, error) {
if err := desc.Digest.Validate(); err != nil {
return "", fmt.Errorf("failed to build referrers tag for %s: %w", desc.Digest, err)
}
alg := desc.Digest.Algorithm().String()
encoded := desc.Digest.Encoded()
return alg + "-" + encoded, nil
}
// 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]
}
+190
View File
@@ -0,0 +1,190 @@
/*
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://distribution.github.io/distribution/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://distribution.github.io/distribution/spec/api/#base
// - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/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://distribution.github.io/distribution/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)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,114 @@
/*
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()
}
@@ -0,0 +1,154 @@
/*
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/v2"
"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.Int64N(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.NewPCG(0, 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.Int64N(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
}
+119
View File
@@ -0,0 +1,119 @@
/*
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: <scheme>://<registry>/v2/
// Reference: https://distribution.github.io/distribution/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: <scheme>://<registry>/v2/_catalog
// Reference: https://distribution.github.io/distribution/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: <scheme>://<registry>/v2/<repository>
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: <scheme>://<registry>/v2/<repository>/tags/list
// Reference: https://distribution.github.io/distribution/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: <scheme>://<registry>/v2/<repository>/manifests/<digest_or_tag>
// Reference: https://distribution.github.io/distribution/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: <scheme>://<registry>/v2/<repository>/blobs/<digest>
// Reference: https://distribution.github.io/distribution/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: <scheme>://<registry>/v2/<repository>/blobs/uploads/
// Reference: https://distribution.github.io/distribution/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: <scheme>://<registry>/v2/<repository>/blobs/uploads/?mount=<digest>&from=<other_repository>
// Reference: https://distribution.github.io/distribution/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: <scheme>://<registry>/v2/<repository>/referrers/<digest>?artifactType=<artifactType>
// Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.1/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,
)
}
+94
View File
@@ -0,0 +1,94 @@
/*
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)
}
+100
View File
@@ -0,0 +1,100 @@
/*
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"
"fmt"
"strconv"
"strings"
)
const (
// headerWarning is the "Warning" header.
// Reference: https://www.rfc-editor.org/rfc/rfc7234#section-5.5
headerWarning = "Warning"
// warnCode299 is the 299 warn-code.
// Reference: https://www.rfc-editor.org/rfc/rfc7234#section-5.5
warnCode299 = 299
// warnAgentUnknown represents an unknown warn-agent.
// Reference: https://www.rfc-editor.org/rfc/rfc7234#section-5.5
warnAgentUnknown = "-"
)
// errUnexpectedWarningFormat is returned by parseWarningHeader when
// an unexpected warning format is encountered.
var errUnexpectedWarningFormat = errors.New("unexpected warning format")
// WarningValue represents the value of the Warning header.
//
// References:
// - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#warnings
// - https://www.rfc-editor.org/rfc/rfc7234#section-5.5
type WarningValue struct {
// Code is the warn-code.
Code int
// Agent is the warn-agent.
Agent string
// Text is the warn-text.
Text string
}
// Warning contains the value of the warning header and may contain
// other information related to the warning.
//
// References:
// - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#warnings
// - https://www.rfc-editor.org/rfc/rfc7234#section-5.5
type Warning struct {
// WarningValue is the value of the warning header.
WarningValue
}
// parseWarningHeader parses the warning header into WarningValue.
func parseWarningHeader(header string) (WarningValue, error) {
if len(header) < 9 || !strings.HasPrefix(header, `299 - "`) || !strings.HasSuffix(header, `"`) {
// minimum header value: `299 - "x"`
return WarningValue{}, fmt.Errorf("%s: %w", header, errUnexpectedWarningFormat)
}
// validate text only as code and agent are fixed
quotedText := header[6:] // behind `299 - `, quoted by "
text, err := strconv.Unquote(quotedText)
if err != nil {
return WarningValue{}, fmt.Errorf("%s: unexpected text: %w: %v", header, errUnexpectedWarningFormat, err)
}
return WarningValue{
Code: warnCode299,
Agent: warnAgentUnknown,
Text: text,
}, nil
}
// handleWarningHeaders parses the warning headers and handles the parsed
// warnings using handleWarning.
func handleWarningHeaders(headers []string, handleWarning func(Warning)) {
for _, h := range headers {
if value, err := parseWarningHeader(h); err == nil {
// ignore warnings in unexpected formats
handleWarning(Warning{
WarningValue: value,
})
}
}
}
+226
View File
@@ -0,0 +1,226 @@
/*
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 (
"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/descriptor"
"oras.land/oras-go/v2/internal/spec"
)
// Repository is an ORAS target and an union of the blob and the manifest CASs.
//
// As specified by https://distribution.github.io/distribution/spec/api/, it is natural to
// assume that content.Resolver interface only works for manifests. Tagging a
// blob may be resulted in an `ErrUnsupported` error. However, this interface
// does not restrict tagging blobs.
//
// Since a repository is an union of the blob and the manifest CASs, all
// operations defined in the `BlobStore` are executed depending on the media
// type of the given descriptor accordingly.
//
// Furthermore, this interface also provides the ability to enforce the
// separation of the blob and the manifests CASs.
type Repository interface {
content.Storage
content.Deleter
content.TagResolver
ReferenceFetcher
ReferencePusher
ReferrerLister
TagLister
// Blobs provides access to the blob CAS only, which contains config blobs,
// layers, and other generic blobs.
Blobs() BlobStore
// Manifests provides access to the manifest CAS only.
Manifests() ManifestStore
}
// BlobStore is a CAS with the ability to stat and delete its content.
type BlobStore interface {
content.Storage
content.Deleter
content.Resolver
ReferenceFetcher
}
// ManifestStore is a CAS with the ability to stat and delete its content.
// Besides, ManifestStore provides reference tagging.
type ManifestStore interface {
BlobStore
content.Tagger
ReferencePusher
}
// ReferencePusher provides advanced push with the tag service.
type ReferencePusher interface {
// PushReference pushes the manifest with a reference tag.
PushReference(ctx context.Context, expected ocispec.Descriptor, content io.Reader, reference string) error
}
// ReferenceFetcher provides advanced fetch with the tag service.
type ReferenceFetcher interface {
// FetchReference fetches the content identified by the reference.
FetchReference(ctx context.Context, reference string) (ocispec.Descriptor, io.ReadCloser, error)
}
// ReferrerLister provides the Referrers API.
// Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#listing-referrers
type ReferrerLister interface {
Referrers(ctx context.Context, desc ocispec.Descriptor, artifactType string, fn func(referrers []ocispec.Descriptor) error) error
}
// TagLister lists tags by the tag service.
type TagLister interface {
// Tags lists the tags available in the repository.
// Since the returned tag list may be paginated by the underlying
// implementation, a function should be passed in to process the paginated
// tag list.
//
// `last` argument is the `last` parameter when invoking the tags API.
// 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.
//
// Note: When implemented by a remote registry, the tags API is called.
// However, not all registries supports pagination or conforms the
// specification.
//
// References:
// - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#content-discovery
// - https://distribution.github.io/distribution/spec/api/#tags
// See also `Tags()` in this package.
Tags(ctx context.Context, last string, fn func(tags []string) error) error
}
// Mounter allows cross-repository blob mounts.
// For backward compatibility reasons, this is not implemented by
// BlobStore: use a type assertion to check availability.
type Mounter interface {
// Mount makes the blob with the given descriptor in fromRepo
// available in the repository signified by the receiver.
Mount(ctx context.Context,
desc ocispec.Descriptor,
fromRepo string,
getContent func() (io.ReadCloser, error),
) error
}
// Tags lists the tags available in the repository.
func Tags(ctx context.Context, repo TagLister) ([]string, error) {
var res []string
if err := repo.Tags(ctx, "", func(tags []string) error {
res = append(res, tags...)
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.
//
// Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#listing-referrers
func Referrers(ctx context.Context, store content.ReadOnlyGraphStorage, desc ocispec.Descriptor, artifactType string) ([]ocispec.Descriptor, error) {
if !descriptor.IsManifest(desc) {
return nil, fmt.Errorf("the descriptor %v is not a manifest: %w", desc, errdef.ErrUnsupported)
}
var results []ocispec.Descriptor
// use the Referrer API if it is available
if rf, ok := store.(ReferrerLister); ok {
if err := rf.Referrers(ctx, desc, artifactType, func(referrers []ocispec.Descriptor) error {
results = append(results, referrers...)
return nil
}); err != nil {
return nil, err
}
return results, nil
}
predecessors, err := store.Predecessors(ctx, desc)
if err != nil {
return nil, err
}
for _, node := range predecessors {
switch node.MediaType {
case ocispec.MediaTypeImageManifest:
fetched, err := content.FetchAll(ctx, store, node)
if err != nil {
return nil, err
}
var manifest ocispec.Manifest
if err := json.Unmarshal(fetched, &manifest); err != nil {
return nil, err
}
if manifest.Subject == nil || !content.Equal(*manifest.Subject, desc) {
continue
}
node.ArtifactType = manifest.ArtifactType
if node.ArtifactType == "" {
node.ArtifactType = manifest.Config.MediaType
}
node.Annotations = manifest.Annotations
case ocispec.MediaTypeImageIndex:
fetched, err := content.FetchAll(ctx, store, node)
if err != nil {
return nil, err
}
var index ocispec.Index
if err := json.Unmarshal(fetched, &index); err != nil {
return nil, err
}
if index.Subject == nil || !content.Equal(*index.Subject, desc) {
continue
}
node.ArtifactType = index.ArtifactType
node.Annotations = index.Annotations
case spec.MediaTypeArtifactManifest:
fetched, err := content.FetchAll(ctx, store, node)
if err != nil {
return nil, err
}
var artifact spec.Artifact
if err := json.Unmarshal(fetched, &artifact); err != nil {
return nil, err
}
if artifact.Subject == nil || !content.Equal(*artifact.Subject, desc) {
continue
}
node.ArtifactType = artifact.ArtifactType
node.Annotations = artifact.Annotations
default:
continue
}
if artifactType == "" || artifactType == node.ArtifactType {
// the field artifactType in referrers descriptor is allowed to be empty
// https://github.com/opencontainers/distribution-spec/issues/458
results = append(results, node)
}
}
return results, nil
}
+43
View File
@@ -0,0 +1,43 @@
/*
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 "oras.land/oras-go/v2/content"
// Target is a CAS with generic tags.
type Target interface {
content.Storage
content.TagResolver
}
// GraphTarget is a CAS with generic tags that supports direct predecessor node
// finding.
type GraphTarget interface {
content.GraphStorage
content.TagResolver
}
// ReadOnlyTarget represents a read-only Target.
type ReadOnlyTarget interface {
content.ReadOnlyStorage
content.Resolver
}
// ReadOnlyGraphTarget represents a read-only GraphTarget.
type ReadOnlyGraphTarget interface {
content.ReadOnlyGraphStorage
content.Resolver
}