updated vendor

This commit is contained in:
2026-06-16 08:02:19 +02:00
parent 2f7f99d3f0
commit 77299d0c64
1283 changed files with 67302 additions and 208958 deletions
+26
View File
@@ -0,0 +1,26 @@
# Copyright The ORAS Authors.
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
version: 2
# oras-go is a library — no binary builds or archives needed.
builds:
- skip: true
checksum:
disable: true
release:
# Tags containing -alpha, -beta, or -rc are automatically marked pre-release.
prerelease: auto
draft: false
+1 -1
View File
@@ -1,2 +1,2 @@
# Derived from OWNERS.md
* @sajayantony @shizhMSFT @stevelasker @Wwwsylvia
* @sabre1041 @shizhMSFT @TerryHowe @Wwwsylvia
+4 -2
View File
@@ -1,11 +1,13 @@
# Owners
Owners:
- Sajay Antony (@sajayantony)
- Andrew Block (@sabre1041)
- Shiwei Zhang (@shizhMSFT)
- Steve Lasker (@stevelasker)
- Sylvia Lei (@Wwwsylvia)
- Terry Howe (@TerryHowe)
Emeritus:
- Avi Deitcher (@deitch)
- Josh Dolitsky (@jdolitsky)
- Sajay Antony (@sajayantony)
- Steve Lasker (@stevelasker)
+1 -1
View File
@@ -12,7 +12,7 @@
`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`).
> 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.24` and `1.25`).
## Getting Started
+108
View File
@@ -0,0 +1,108 @@
# Releasing oras-go
Releases are created via a GitOps workflow. Merging a `release/vX.Y.Z` branch
into `v2` automatically tags the commit and publishes the GitHub Release.
## Steps
### 1. Create a release branch
The release branch needs at least one commit so GitHub will allow a PR to be
opened. Use an empty commit as a lightweight marker:
```bash
git fetch upstream
git checkout -b release/v2.7.0 upstream/v2
git commit --allow-empty -s -m "chore: prepare release v2.7.0"
git push origin release/v2.7.0
```
The release does not need to contain the changes being released — those are
already on `v2`. The PR is a trigger: when it merges, the workflow tags the
PR's `merge_commit_sha` (the exact commit that landed on `v2`), which includes
all prior work on the branch.
### 2. Open a pull request
Open a PR from `release/v2.7.0` targeting the `v2` branch. Write the release
notes directly in the PR description using the format from prior releases:
```markdown
## New Features
...
## Bug Fixes
...
## Documentation
...
## Other Changes
...
```
The PR description becomes the GitHub Release body verbatim, so write it in
its final form.
### 3. Get approvals
Branch protection on `v2` requires approval from at least 3 of the 4 owners
listed in [OWNERS.md](OWNERS.md). Reviewers should verify:
- The target commit is correct
- The release notes are accurate and complete
- All CI checks pass
### 4. Merge
Merge the PR. The [release workflow](.github/workflows/release.yml)
automatically:
1. Extracts the version from the branch name (`release/v2.7.0``v2.7.0`)
2. Creates and pushes the git tag
3. Publishes the GitHub Release with the PR body as release notes
## Pre-releases
Tags containing `-alpha`, `-beta`, or `-rc` (e.g., `v2.7.0-rc.1`) are
automatically marked as pre-release on GitHub. Use the same branch naming
convention: `release/v2.7.0-rc.1`.
## Testing the workflow locally
Three levels of local validation are available without triggering a real release:
**1. Validate the goreleaser config:**
```bash
goreleaser check
```
**2. Validate workflow structure and job matching (dry run):**
```bash
act pull_request \
-e .github/act/release-event.json \
-W .github/workflows/release.yml \
-n
```
**3. Run the workflow end-to-end with a fake token (Colima + cached actions required):**
```bash
act pull_request \
-e .github/act/release-event.json \
-W .github/workflows/release.yml \
-s GITHUB_TOKEN=fake \
--pull=false \
--action-offline-mode \
--container-daemon-socket -
```
This runs all steps up to and including version extraction (`version=vX.Y.Z` will
appear in the output). The `git push` step then fails with a permission error —
that is expected and confirms no tag was pushed. The mock event payload is at
`.github/act/release-event.json`.
## Updating the documentation site
After a release, update [oras-www](https://github.com/oras-project/oras-www)
to reflect the new version. See the `CLAUDE.md` in that repository for the
exact steps.
+7 -1
View File
@@ -24,6 +24,12 @@ import (
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
)
// maxDescriptorSize is the upper-bound for descriptor sizes accepted by
// ReadAll. Descriptors sourced from attacker-supplied OCI layouts can carry
// arbitrarily large Size values; without this cap, make([]byte, desc.Size)
// triggers a runtime panic before any allocation occurs.
const maxDescriptorSize = 32 * 1024 * 1024 // 32 MiB
var (
// ErrInvalidDescriptorSize is returned by ReadAll() when
// the descriptor has an invalid size.
@@ -119,7 +125,7 @@ func NewVerifyReader(r io.Reader, desc ocispec.Descriptor) *VerifyReader {
// 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 {
if desc.Size < 0 || desc.Size > maxDescriptorSize {
return nil, ErrInvalidDescriptorSize
}
buf := make([]byte, desc.Size)
+1 -1
View File
@@ -80,7 +80,7 @@ func (m *Memory) Exists(_ context.Context, target ocispec.Descriptor) (bool, err
// 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 {
m.content.Range(func(key, value any) bool {
res[key.(descriptor.Descriptor)] = value.([]byte)
return true
})
+29 -35
View File
@@ -25,7 +25,6 @@ import (
"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"
)
@@ -34,9 +33,9 @@ import (
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
// 2. Memory.nodes saves the ocispec.Descriptor indexed by digest, which are used by
// the other fields.
nodes map[descriptor.Descriptor]ocispec.Descriptor
nodes map[digest.Digest]ocispec.Descriptor
// predecessors has the following properties and behaviors:
// 1. a node exists in Memory.predecessors if it has at least one predecessor
@@ -44,14 +43,14 @@ type Memory struct {
// 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]
predecessors map[digest.Digest]set.Set[digest.Digest]
// 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]
successors map[digest.Digest]set.Set[digest.Digest]
lock sync.RWMutex
}
@@ -59,9 +58,9 @@ type Memory struct {
// 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]),
nodes: make(map[digest.Digest]ocispec.Descriptor),
predecessors: make(map[digest.Digest]set.Set[digest.Digest]),
successors: make(map[digest.Digest]set.Set[digest.Digest]),
}
}
@@ -108,14 +107,13 @@ func (m *Memory) Predecessors(_ context.Context, node ocispec.Descriptor) ([]oci
m.lock.RLock()
defer m.lock.RUnlock()
key := descriptor.FromOCI(node)
set, exists := m.predecessors[key]
set, exists := m.predecessors[node.Digest]
if !exists {
return nil, nil
}
var res []ocispec.Descriptor
for k := range set {
res = append(res, m.nodes[k])
for digest := range set {
res = append(res, m.nodes[digest])
}
return res, nil
}
@@ -126,25 +124,24 @@ 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)
for successorDigest := range m.successors[node.Digest] {
predecessorEntry := m.predecessors[successorDigest]
predecessorEntry.Delete(node.Digest)
// 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.predecessors, successorDigest)
if _, exists := m.nodes[successorDigest]; exists {
danglings = append(danglings, m.nodes[successorDigest])
}
}
}
delete(m.successors, nodeKey)
delete(m.nodes, nodeKey)
delete(m.successors, node.Digest)
delete(m.nodes, node.Digest)
return danglings
}
@@ -154,8 +151,8 @@ func (m *Memory) DigestSet() set.Set[digest.Digest] {
defer m.lock.RUnlock()
s := set.New[digest.Digest]()
for desc := range m.nodes {
s.Add(desc.Digest)
for digest := range m.nodes {
s.Add(digest)
}
return s
}
@@ -170,22 +167,20 @@ func (m *Memory) index(ctx context.Context, fetcher content.Fetcher, node ocispe
defer m.lock.Unlock()
// index the node
nodeKey := descriptor.FromOCI(node)
m.nodes[nodeKey] = node
m.nodes[node.Digest] = 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
successorSet := set.New[digest.Digest]()
m.successors[node.Digest] = successorSet
for _, successor := range successors {
successorKey := descriptor.FromOCI(successor)
successorSet.Add(successorKey)
predecessorSet, exists := m.predecessors[successorKey]
successorSet.Add(successor.Digest)
predecessorSet, exists := m.predecessors[successor.Digest]
if !exists {
predecessorSet = set.New[descriptor.Descriptor]()
m.predecessors[successorKey] = predecessorSet
predecessorSet = set.New[digest.Digest]()
m.predecessors[successor.Digest] = predecessorSet
}
predecessorSet.Add(nodeKey)
predecessorSet.Add(node.Digest)
}
return successors, nil
}
@@ -195,7 +190,6 @@ func (m *Memory) Exists(node ocispec.Descriptor) bool {
m.lock.RLock()
defer m.lock.RUnlock()
nodeKey := descriptor.FromOCI(node)
_, exists := m.nodes[nodeKey]
_, exists := m.nodes[node.Digest]
return exists
}
+2 -2
View File
@@ -24,7 +24,7 @@ import (
// 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{}
result any
err error
status chan bool
}
@@ -46,7 +46,7 @@ func NewOnce() *Once {
// 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) {
func (o *Once) Do(ctx context.Context, f func() (any, error)) (bool, any, error) {
defer func() {
if r := recover(); r != nil {
o.status <- true
+1 -1
View File
@@ -109,7 +109,7 @@ func (cc *concurrentCache) Set(ctx context.Context, registry string, scheme Sche
}, " ")
statusValue, _ := cc.status.LoadOrStore(statusKey, syncutil.NewOnce())
fetchOnce := statusValue.(*syncutil.Once)
fetchedFirst, result, err := fetchOnce.Do(ctx, func() (interface{}, error) {
fetchedFirst, result, err := fetchOnce.Do(ctx, func() (any, error) {
return fetch(ctx)
})
if fetchedFirst {
+101 -1
View File
@@ -23,6 +23,7 @@ import (
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
@@ -136,7 +137,50 @@ 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)
// Drop the Authorization header when a redirect crosses an HTTP origin
// (scheme, host, or port). The standard library only strips sensitive
// headers when the hostname changes, so a redirect to a different port on
// the same host would otherwise forward credentials to an unintended
// endpoint. Any caller-provided CheckRedirect is preserved.
// Reference: https://github.com/oras-project/oras-go/security/advisories/GHSA-vh4v-2xq2-g5cg
client := c.client()
clientCopy := *client
checkRedirect := client.CheckRedirect
clientCopy.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) > 0 && !sameHTTPOrigin(via[len(via)-1].URL, req.URL) {
req.Header.Del("Authorization")
}
if checkRedirect != nil {
return checkRedirect(req, via)
}
return nil
}
return clientCopy.Do(req)
}
// sameHTTPOrigin reports whether a and b share the same HTTP origin, i.e. the
// same scheme and host. Default ports are normalized so that, for example,
// "example.com" and "example.com:443" compare equal over https.
func sameHTTPOrigin(a, b *url.URL) bool {
if !strings.EqualFold(a.Scheme, b.Scheme) {
return false
}
return canonicalHost(a) == canonicalHost(b)
}
// canonicalHost returns the lower-cased host of u with the default port for its
// scheme applied when no explicit port is present.
func canonicalHost(u *url.URL) string {
port := u.Port()
if port == "" {
switch strings.ToLower(u.Scheme) {
case "https":
port = "443"
case "http":
port = "80"
}
}
return strings.ToLower(u.Hostname()) + ":" + port
}
// credential resolves the credential for the given registry.
@@ -156,6 +200,49 @@ func (c *Client) cache() Cache {
return c.Cache
}
// validateRealm rejects bearer token realm URLs that would have the client
// forward credentials to obviously unsafe destinations:
//
// - schemes other than http or https,
// - http realms when the registry was contacted over https (TLS downgrade),
// - hosts that are IP literals in loopback, link-local, private, or
// unspecified ranges (e.g. cloud instance metadata services such as
// 169.254.169.254).
//
// Cross-host realms with a public hostname are permitted, because the
// distribution spec allows a separate token endpoint (e.g. Docker Hub's
// auth.docker.io). When the registry itself is reached at the same hostname
// as the realm, the IP-literal check is skipped so loopback and in-cluster
// deployments continue to work.
func validateRealm(realm string, registryURL *url.URL) error {
if realm == "" {
return nil
}
realmURL, err := url.Parse(realm)
if err != nil {
return fmt.Errorf("failed to parse bearer realm %q: %w", realm, err)
}
switch realmURL.Scheme {
case "https":
// always allowed
case "http":
if registryURL != nil && registryURL.Scheme == "https" {
return fmt.Errorf("bearer realm %q uses http but registry was contacted over https", realm)
}
default:
return fmt.Errorf("bearer realm %q uses unsupported scheme %q", realm, realmURL.Scheme)
}
if ip := net.ParseIP(realmURL.Hostname()); ip != nil {
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
ip.IsPrivate() || ip.IsUnspecified() {
if registryURL == nil || realmURL.Hostname() != registryURL.Hostname() {
return fmt.Errorf("bearer realm host %q is a loopback, link-local, private, or unspecified address", realmURL.Hostname())
}
}
}
return nil
}
// SetUserAgent sets the user agent for all out-going requests.
func (c *Client) SetUserAgent(userAgent string) {
if c.Header == nil {
@@ -182,6 +269,9 @@ func (c *Client) Do(originalReq *http.Request) (*http.Response, error) {
var attemptedKey string
cache := c.cache()
host := originalReq.Host
if host == "" {
host = originalReq.URL.Host
}
scheme, err := cache.GetScheme(ctx, host)
if err == nil {
switch scheme {
@@ -207,6 +297,13 @@ func (c *Client) Do(originalReq *http.Request) (*http.Response, error) {
if resp.StatusCode != http.StatusUnauthorized {
return resp, nil
}
// If the challenge came from a different origin than originally requested
// (e.g. the request was redirected to another host or port), do not resolve
// or send the registry credentials to that origin.
// Reference: https://github.com/oras-project/oras-go/security/advisories/GHSA-vh4v-2xq2-g5cg
if resp.Request != nil && !sameHTTPOrigin(originalReq.URL, resp.Request.URL) {
return resp, nil
}
// attempt again with credentials for recognized schemes
challenge := resp.Header.Get("Www-Authenticate")
@@ -257,6 +354,9 @@ func (c *Client) Do(originalReq *http.Request) (*http.Response, error) {
// attempt with credentials
realm := params["realm"]
if err := validateRealm(realm, originalReq.URL); err != nil {
return nil, fmt.Errorf("%s %q: %w", resp.Request.Method, resp.Request.URL, err)
}
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)
+1 -1
View File
@@ -254,7 +254,7 @@ func CleanScopes(scopes []string) []string {
actionSet = make(map[string]struct{})
namedActions[resourceName] = actionSet
}
for _, action := range strings.Split(actions, ",") {
for action := range strings.SplitSeq(actions, ",") {
if action != "" {
actionSet[action] = struct{}{}
}
@@ -21,6 +21,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
@@ -128,7 +129,13 @@ func Load(configPath string) (*Config, error) {
// 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 errors.Is(err, io.EOF) {
// empty or whitespace only file
cfg.content = make(map[string]json.RawMessage)
cfg.authsCache = make(map[string]json.RawMessage)
return cfg, nil
}
return nil, fmt.Errorf("failed to decode config file %s: %w: %v", configPath, ErrInvalidConfigFormat, err)
}
if credsStoreBytes, ok := cfg.content[configFieldCredentialsStore]; ok {
@@ -81,10 +81,12 @@ func Credential(store Store) auth.CredentialFunc {
// 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/".
// the registry 'registry-1.docker.io' or the alias '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" {
if registry == "docker.io" ||
registry == "registry-1.docker.io" {
return "https://index.docker.io/v1/"
}
return registry
+2 -6
View File
@@ -16,6 +16,7 @@ limitations under the License.
package remote
import (
"slices"
"strings"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
@@ -41,12 +42,7 @@ 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
return slices.Contains(manifestMediaTypes, desc.MediaType)
}
// manifestAcceptHeader generates the set in the `Accept` header for resolving
+2 -2
View File
@@ -118,8 +118,8 @@ func isReferrersFilterApplied(applied, requested string) bool {
if applied == "" || requested == "" {
return false
}
filters := strings.Split(applied, ",")
for _, f := range filters {
filters := strings.SplitSeq(applied, ",")
for f := range filters {
if f == requested {
return true
}
@@ -24,6 +24,7 @@ import (
"io"
"mime"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
@@ -872,6 +873,25 @@ func (s *blobStore) Push(ctx context.Context, expected ocispec.Descriptor, conte
return s.completePushAfterInitialPost(ctx, req, resp, expected, content)
}
// sameUploadHost reports whether location and reqURL refer to the same host,
// normalizing implicit default ports (80 for http, 443 for https) so that
// e.g. "example.com" and "example.com:443" compare equal over HTTPS.
func sameUploadHost(location, reqURL *url.URL) bool {
if location.Hostname() != reqURL.Hostname() {
return false
}
canonicalPort := func(u *url.URL) string {
if p := u.Port(); p != "" {
return p
}
if u.Scheme == "https" {
return "443"
}
return "80"
}
return canonicalPort(location) == canonicalPort(reqURL)
}
// completePushAfterInitialPost implements step 2 of the push protocol. This can be invoked either by
// Push or by Mount when the receiving repository does not implement the
// mount endpoint.
@@ -894,6 +914,15 @@ func (s *blobStore) completePushAfterInitialPost(ctx context.Context, req *http.
if reqPort == "443" && locationHostname == reqHostname && locationPort == "" {
location.Host = locationHostname + ":" + reqPort
}
// Validate the Location stays on the same host to prevent credentials from
// being forwarded to an attacker-controlled endpoint.
// Reference: https://github.com/oras-project/oras-go/security/advisories/GHSA-jxpm-75mh-9fp7
if !sameUploadHost(location, req.URL) {
return fmt.Errorf("blob upload Location %q is on a different host than the registry %q", location.Host, req.URL.Host)
}
if req.URL.Scheme == "https" && location.Scheme != "https" {
return fmt.Errorf("blob upload Location %q downgrades scheme from https", location.Host)
}
url := location.String()
req, err = http.NewRequestWithContext(ctx, http.MethodPut, url, content)
if err != nil {