mirror of
https://github.com/containerd/containerd.git
synced 2026-11-06 13:27:16 +00:00
According to the OCI distribution spec registries may include informational warnings in HTTP Warning headers: - https://github.com/opencontainers/distribution-spec/blob/e612a6e1e1bc717f9fa7e1feb4f05c8b6568754a/spec.md#warnings - https://www.rfc-editor.org/rfc/rfc7234#section-5.5 This change implements support for handling these warnings and propagating them to the resolver. This patch adds a new, optional WarningHandler interface field to ResolverOptions that allows callers to receive and process warnings sent by registries via HTTP Warning headers with warn-code 299. Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
393 lines
9.6 KiB
Go
393 lines
9.6 KiB
Go
/*
|
|
Copyright The containerd 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
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
|
|
|
"github.com/containerd/containerd/v2/pkg/reference"
|
|
)
|
|
|
|
func TestWarningHandler(t *testing.T) {
|
|
name := "testname"
|
|
tag := "latest"
|
|
|
|
m := newManifest(
|
|
newContent(ocispec.MediaTypeImageConfig, []byte("{}")),
|
|
newContent(ocispec.MediaTypeImageLayerGzip, []byte("layer content")),
|
|
)
|
|
mc := newContent(ocispec.MediaTypeImageManifest, m.OCIManifest())
|
|
|
|
mux := http.NewServeMux()
|
|
addWarning := func(h http.Handler, msg string) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Add("Warning", fmt.Sprintf(`299 - "%s"`, msg))
|
|
h.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
for _, c := range []testContent{m.config, m.references[0]} {
|
|
mux.Handle(fmt.Sprintf("/v2/%s/blobs/%s", name, c.Digest()), addWarning(c, "Blob access warning"))
|
|
}
|
|
mux.Handle(fmt.Sprintf("/v2/%s/manifests/%s", name, tag), addWarning(mc, "Manifest access warning"))
|
|
mux.Handle(fmt.Sprintf("/v2/%s/manifests/%s", name, mc.Digest()), addWarning(mc, "Manifest access warning"))
|
|
|
|
s := httptest.NewServer(mux)
|
|
defer s.Close()
|
|
|
|
image := fmt.Sprintf("%s/%s:%s", strings.TrimPrefix(s.URL, "http://"), name, tag)
|
|
|
|
// Create expected descriptors and digests
|
|
mcDesc := mc.Descriptor()
|
|
mcDigest := mc.Digest()
|
|
configDesc := m.config.Descriptor()
|
|
configDigest := m.config.Digest()
|
|
layer0Desc := m.references[0].Descriptor()
|
|
layer0Digest := m.references[0].Digest()
|
|
|
|
type expectedWarning struct {
|
|
warning string
|
|
src WarningSource
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
resolve bool
|
|
descriptors []ocispec.Descriptor
|
|
expectedWarnings []expectedWarning
|
|
}{
|
|
{
|
|
name: "Resolve",
|
|
resolve: true,
|
|
expectedWarnings: []expectedWarning{
|
|
{
|
|
warning: "Manifest access warning",
|
|
src: WarningSource{
|
|
Ref: mustParseRef(t, image),
|
|
Desc: &mcDesc,
|
|
Digest: &mcDigest,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "Fetch manifest",
|
|
descriptors: []ocispec.Descriptor{mc.Descriptor()},
|
|
expectedWarnings: []expectedWarning{
|
|
{
|
|
warning: "Manifest access warning",
|
|
src: WarningSource{
|
|
Ref: mustParseRef(t, image),
|
|
Desc: &mcDesc,
|
|
Digest: &mcDigest,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "Fetch blob",
|
|
descriptors: []ocispec.Descriptor{m.config.Descriptor(), m.references[0].Descriptor()},
|
|
expectedWarnings: []expectedWarning{
|
|
{
|
|
warning: "Blob access warning",
|
|
src: WarningSource{
|
|
Ref: mustParseRef(t, image),
|
|
Desc: &configDesc,
|
|
Digest: &configDigest,
|
|
},
|
|
},
|
|
{
|
|
warning: "Blob access warning",
|
|
src: WarningSource{
|
|
Ref: mustParseRef(t, image),
|
|
Desc: &layer0Desc,
|
|
Digest: &layer0Digest,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
type capturedWarning struct {
|
|
src WarningSource
|
|
warning string
|
|
}
|
|
// No synchronization needed: Resolve and Fetch calls are
|
|
// sequential, so the handler is never called concurrently here.
|
|
var warnings []capturedWarning
|
|
resolver := NewResolver(ResolverOptions{
|
|
WarningHandler: warningHandlerFunc(func(ctx context.Context, src WarningSource, warning string) {
|
|
warnings = append(warnings, capturedWarning{src: src, warning: warning})
|
|
}),
|
|
})
|
|
|
|
ctx := t.Context()
|
|
|
|
if tc.resolve {
|
|
if _, _, err := resolver.Resolve(ctx, image); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
if len(tc.descriptors) > 0 {
|
|
fetcher, err := resolver.Fetcher(ctx, image)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, desc := range tc.descriptors {
|
|
rc, err := fetcher.Fetch(ctx, desc)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := io.Copy(io.Discard, rc); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := rc.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(warnings) != len(tc.expectedWarnings) {
|
|
t.Fatalf("Expected %d warnings, got %d: %v", len(tc.expectedWarnings), len(warnings), warnings)
|
|
}
|
|
|
|
for i, w := range warnings {
|
|
expected := tc.expectedWarnings[i]
|
|
if w.warning != expected.warning {
|
|
t.Fatalf("Expected warning %q, got: %s", expected.warning, w.warning)
|
|
}
|
|
if w.src.Ref.String() != expected.src.Ref.String() {
|
|
t.Fatalf("Expected warning source ref %q, got: %s", expected.src.Ref.String(), w.src.Ref.String())
|
|
}
|
|
// Check descriptor if present in expected
|
|
if expected.src.Desc != nil {
|
|
if w.src.Desc == nil {
|
|
t.Fatalf("Expected warning source descriptor %+v, got nil", expected.src.Desc)
|
|
}
|
|
if !reflect.DeepEqual(*w.src.Desc, *expected.src.Desc) {
|
|
t.Fatalf("Expected warning source descriptor %+v, got: %+v", expected.src.Desc, w.src.Desc)
|
|
}
|
|
}
|
|
// Check digest if present in expected
|
|
if expected.src.Digest != nil {
|
|
if w.src.Digest == nil {
|
|
t.Fatalf("Expected warning source digest %v, got nil", expected.src.Digest)
|
|
}
|
|
if *w.src.Digest != *expected.src.Digest {
|
|
t.Fatalf("Expected warning source digest %v, got: %v", expected.src.Digest, w.src.Digest)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseWarningText(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
header string
|
|
want string
|
|
}{
|
|
{
|
|
name: "simple warning",
|
|
header: `299 - "message"`,
|
|
want: "message",
|
|
},
|
|
{
|
|
name: "prefixed header text invalid",
|
|
header: `foobar 299 - "prefixed message"`,
|
|
want: "",
|
|
},
|
|
{
|
|
name: "escaped quotes preserved",
|
|
header: `299 - "quoted \"text\" inside"`,
|
|
want: `quoted "text" inside`,
|
|
},
|
|
{
|
|
name: "escaped backslash",
|
|
header: `299 - "path\\to\\file"`,
|
|
want: `path\to\file`,
|
|
},
|
|
{
|
|
name: "escaped tab",
|
|
header: "299 - \"escaped\\\ttab\"",
|
|
want: "escaped\ttab",
|
|
},
|
|
{
|
|
name: "escaped space",
|
|
header: `299 - "escaped\ space"`,
|
|
want: "escaped space",
|
|
},
|
|
{
|
|
name: "backslash followed by NUL rejected",
|
|
header: "299 - \"bad\\\x00text\"",
|
|
want: "",
|
|
},
|
|
{
|
|
name: "backslash followed by newline rejected",
|
|
header: "299 - \"bad\\\ntext\"",
|
|
want: "",
|
|
},
|
|
{
|
|
name: "backslash followed by DEL rejected",
|
|
header: "299 - \"bad\\\x7ftext\"",
|
|
want: "",
|
|
},
|
|
{
|
|
name: "bare control char NUL in qdtext rejected",
|
|
header: "299 - \"bad\x00text\"",
|
|
want: "",
|
|
},
|
|
{
|
|
name: "bare control char BEL in qdtext rejected",
|
|
header: "299 - \"bad\x07text\"",
|
|
want: "",
|
|
},
|
|
{
|
|
name: "bare DEL in qdtext rejected",
|
|
header: "299 - \"bad\x7ftext\"",
|
|
want: "",
|
|
},
|
|
{
|
|
name: "obs-text byte in qdtext accepted",
|
|
header: "299 - \"caf\xe9\"",
|
|
want: "caf\xe9",
|
|
},
|
|
{
|
|
name: "multi-byte UTF-8 accepted as obs-text bytes",
|
|
header: "299 - \"\xc3\xa9l\xc3\xa8ve\"",
|
|
want: "\xc3\xa9l\xc3\xa8ve",
|
|
},
|
|
{
|
|
name: "backslash followed by obs-text accepted",
|
|
header: "299 - \"test\\\x80value\"",
|
|
want: "test\x80value",
|
|
},
|
|
{
|
|
name: "percent-encoded space",
|
|
header: `299 - "hello%20world"`,
|
|
want: "hello world",
|
|
},
|
|
{
|
|
name: "percent-encoded special chars",
|
|
header: `299 - "100%25 complete"`,
|
|
want: "100% complete",
|
|
},
|
|
{
|
|
name: "percent-encoded quote rejected",
|
|
header: `299 - "say %22hello%22"`,
|
|
want: "",
|
|
},
|
|
{
|
|
name: "invalid percent-encoding treated as literal",
|
|
header: `299 - "100%ZZ invalid"`,
|
|
want: "100%ZZ invalid",
|
|
},
|
|
{
|
|
name: "incomplete percent-encoding at end",
|
|
header: `299 - "trailing%2"`,
|
|
want: "trailing%2",
|
|
},
|
|
{
|
|
name: "percent-encoded newline rejected",
|
|
header: `299 - "inject%0aline"`,
|
|
want: "",
|
|
},
|
|
{
|
|
name: "percent-encoded CR rejected",
|
|
header: `299 - "inject%0dline"`,
|
|
want: "",
|
|
},
|
|
{
|
|
name: "percent-encoded NUL rejected",
|
|
header: `299 - "inject%00byte"`,
|
|
want: "",
|
|
},
|
|
{
|
|
name: "trailing characters invalid",
|
|
header: `299 - "warn text" extra data`,
|
|
want: "",
|
|
},
|
|
{
|
|
name: "second quoted string invalid",
|
|
header: `299 - "warn text" "extra data"`,
|
|
want: "",
|
|
},
|
|
{
|
|
name: "non oci warning code",
|
|
header: `199 - "ignored"`,
|
|
want: "",
|
|
},
|
|
{
|
|
name: "missing closing quote",
|
|
header: `299 - "incomplete`,
|
|
want: "",
|
|
},
|
|
{
|
|
name: "missing opening quote",
|
|
header: `299 - warn text"`,
|
|
want: "",
|
|
},
|
|
{
|
|
name: "empty input",
|
|
header: "",
|
|
want: "",
|
|
},
|
|
{
|
|
name: "trailing backslash before closing quote",
|
|
header: `299 - "trailing\"`,
|
|
want: "",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := parseWarningText(tt.header)
|
|
if got != tt.want {
|
|
t.Fatalf("parseWarningText(%q) = %q, want %q", tt.header, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestReportWarningsWithNilHandler(t *testing.T) {
|
|
header := http.Header{}
|
|
header.Add("Warning", `299 - "warning message"`)
|
|
|
|
reportWarningsWithSource(t.Context(), header, nil, WarningSource{})
|
|
}
|
|
|
|
func mustParseRef(t *testing.T, ref string) reference.Spec {
|
|
spec, err := reference.Parse(ref)
|
|
if err != nil {
|
|
t.Fatalf("failed to parse reference %q: %v", ref, err)
|
|
}
|
|
return spec
|
|
}
|