forked from TrueCloudLab/distribution
f536633ca8
Manifests are now fetched by a field called "reference", which may be a tag or a digest. When using digests to reference a manifest, the data is immutable. The routes and specification have been updated to allow this. There are a few caveats to this approach: 1. It may be problematic to rely on data format to differentiate between a tag and a digest. Currently, they are disjoint but there may modifications on either side that break this guarantee. 2. The caching characteristics of returned content are very different for digest versus tag-based references. Digest urls can be cached forever while tag urls cannot. Both of these are minimal caveats that we can live with in the future. Signed-off-by: Stephen J Day <stephen.day@docker.com>
315 lines
8.5 KiB
Go
315 lines
8.5 KiB
Go
package v2
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"math/rand"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gorilla/mux"
|
|
)
|
|
|
|
type routeTestCase struct {
|
|
RequestURI string
|
|
ExpectedURI string
|
|
Vars map[string]string
|
|
RouteName string
|
|
StatusCode int
|
|
}
|
|
|
|
// TestRouter registers a test handler with all the routes and ensures that
|
|
// each route returns the expected path variables. Not method verification is
|
|
// present. This not meant to be exhaustive but as check to ensure that the
|
|
// expected variables are extracted.
|
|
//
|
|
// This may go away as the application structure comes together.
|
|
func TestRouter(t *testing.T) {
|
|
testCases := []routeTestCase{
|
|
{
|
|
RouteName: RouteNameBase,
|
|
RequestURI: "/v2/",
|
|
Vars: map[string]string{},
|
|
},
|
|
{
|
|
RouteName: RouteNameManifest,
|
|
RequestURI: "/v2/foo/manifests/bar",
|
|
Vars: map[string]string{
|
|
"name": "foo",
|
|
"reference": "bar",
|
|
},
|
|
},
|
|
{
|
|
RouteName: RouteNameManifest,
|
|
RequestURI: "/v2/foo/bar/manifests/tag",
|
|
Vars: map[string]string{
|
|
"name": "foo/bar",
|
|
"reference": "tag",
|
|
},
|
|
},
|
|
{
|
|
RouteName: RouteNameManifest,
|
|
RequestURI: "/v2/foo/bar/manifests/sha256:abcdef01234567890",
|
|
Vars: map[string]string{
|
|
"name": "foo/bar",
|
|
"reference": "sha256:abcdef01234567890",
|
|
},
|
|
},
|
|
{
|
|
RouteName: RouteNameTags,
|
|
RequestURI: "/v2/foo/bar/tags/list",
|
|
Vars: map[string]string{
|
|
"name": "foo/bar",
|
|
},
|
|
},
|
|
{
|
|
RouteName: RouteNameBlob,
|
|
RequestURI: "/v2/foo/bar/blobs/tarsum.dev+foo:abcdef0919234",
|
|
Vars: map[string]string{
|
|
"name": "foo/bar",
|
|
"digest": "tarsum.dev+foo:abcdef0919234",
|
|
},
|
|
},
|
|
{
|
|
RouteName: RouteNameBlob,
|
|
RequestURI: "/v2/foo/bar/blobs/sha256:abcdef0919234",
|
|
Vars: map[string]string{
|
|
"name": "foo/bar",
|
|
"digest": "sha256:abcdef0919234",
|
|
},
|
|
},
|
|
{
|
|
RouteName: RouteNameBlobUpload,
|
|
RequestURI: "/v2/foo/bar/blobs/uploads/",
|
|
Vars: map[string]string{
|
|
"name": "foo/bar",
|
|
},
|
|
},
|
|
{
|
|
RouteName: RouteNameBlobUploadChunk,
|
|
RequestURI: "/v2/foo/bar/blobs/uploads/uuid",
|
|
Vars: map[string]string{
|
|
"name": "foo/bar",
|
|
"uuid": "uuid",
|
|
},
|
|
},
|
|
{
|
|
RouteName: RouteNameBlobUploadChunk,
|
|
RequestURI: "/v2/foo/bar/blobs/uploads/D95306FA-FAD3-4E36-8D41-CF1C93EF8286",
|
|
Vars: map[string]string{
|
|
"name": "foo/bar",
|
|
"uuid": "D95306FA-FAD3-4E36-8D41-CF1C93EF8286",
|
|
},
|
|
},
|
|
{
|
|
RouteName: RouteNameBlobUploadChunk,
|
|
RequestURI: "/v2/foo/bar/blobs/uploads/RDk1MzA2RkEtRkFEMy00RTM2LThENDEtQ0YxQzkzRUY4Mjg2IA==",
|
|
Vars: map[string]string{
|
|
"name": "foo/bar",
|
|
"uuid": "RDk1MzA2RkEtRkFEMy00RTM2LThENDEtQ0YxQzkzRUY4Mjg2IA==",
|
|
},
|
|
},
|
|
{
|
|
// Check ambiguity: ensure we can distinguish between tags for
|
|
// "foo/bar/image/image" and image for "foo/bar/image" with tag
|
|
// "tags"
|
|
RouteName: RouteNameManifest,
|
|
RequestURI: "/v2/foo/bar/manifests/manifests/tags",
|
|
Vars: map[string]string{
|
|
"name": "foo/bar/manifests",
|
|
"reference": "tags",
|
|
},
|
|
},
|
|
{
|
|
// This case presents an ambiguity between foo/bar with tag="tags"
|
|
// and list tags for "foo/bar/manifest"
|
|
RouteName: RouteNameTags,
|
|
RequestURI: "/v2/foo/bar/manifests/tags/list",
|
|
Vars: map[string]string{
|
|
"name": "foo/bar/manifests",
|
|
},
|
|
},
|
|
}
|
|
|
|
checkTestRouter(t, testCases, "", true)
|
|
checkTestRouter(t, testCases, "/prefix/", true)
|
|
}
|
|
|
|
func TestRouterWithPathTraversals(t *testing.T) {
|
|
testCases := []routeTestCase{
|
|
{
|
|
RouteName: RouteNameBlobUploadChunk,
|
|
RequestURI: "/v2/foo/../../blob/uploads/D95306FA-FAD3-4E36-8D41-CF1C93EF8286",
|
|
ExpectedURI: "/blob/uploads/D95306FA-FAD3-4E36-8D41-CF1C93EF8286",
|
|
StatusCode: http.StatusNotFound,
|
|
},
|
|
{
|
|
// Testing for path traversal attack handling
|
|
RouteName: RouteNameTags,
|
|
RequestURI: "/v2/foo/../bar/baz/tags/list",
|
|
ExpectedURI: "/v2/bar/baz/tags/list",
|
|
Vars: map[string]string{
|
|
"name": "bar/baz",
|
|
},
|
|
},
|
|
}
|
|
checkTestRouter(t, testCases, "", false)
|
|
}
|
|
|
|
func TestRouterWithBadCharacters(t *testing.T) {
|
|
if testing.Short() {
|
|
testCases := []routeTestCase{
|
|
{
|
|
RouteName: RouteNameBlobUploadChunk,
|
|
RequestURI: "/v2/foo/blob/uploads/不95306FA-FAD3-4E36-8D41-CF1C93EF8286",
|
|
StatusCode: http.StatusNotFound,
|
|
},
|
|
{
|
|
// Testing for path traversal attack handling
|
|
RouteName: RouteNameTags,
|
|
RequestURI: "/v2/foo/不bar/tags/list",
|
|
StatusCode: http.StatusNotFound,
|
|
},
|
|
}
|
|
checkTestRouter(t, testCases, "", true)
|
|
} else {
|
|
// in the long version we're going to fuzz the router
|
|
// with random UTF8 characters not in the 128 bit ASCII range.
|
|
// These are not valid characters for the router and we expect
|
|
// 404s on every test.
|
|
rand.Seed(time.Now().UTC().UnixNano())
|
|
testCases := make([]routeTestCase, 1000)
|
|
for idx := range testCases {
|
|
testCases[idx] = routeTestCase{
|
|
RouteName: RouteNameTags,
|
|
RequestURI: fmt.Sprintf("/v2/%v/%v/tags/list", randomString(10), randomString(10)),
|
|
StatusCode: http.StatusNotFound,
|
|
}
|
|
}
|
|
checkTestRouter(t, testCases, "", true)
|
|
}
|
|
}
|
|
|
|
func checkTestRouter(t *testing.T, testCases []routeTestCase, prefix string, deeplyEqual bool) {
|
|
router := RouterWithPrefix(prefix)
|
|
|
|
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
testCase := routeTestCase{
|
|
RequestURI: r.RequestURI,
|
|
Vars: mux.Vars(r),
|
|
RouteName: mux.CurrentRoute(r).GetName(),
|
|
}
|
|
|
|
enc := json.NewEncoder(w)
|
|
|
|
if err := enc.Encode(testCase); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
})
|
|
|
|
// Startup test server
|
|
server := httptest.NewServer(router)
|
|
|
|
for _, testcase := range testCases {
|
|
testcase.RequestURI = strings.TrimSuffix(prefix, "/") + testcase.RequestURI
|
|
// Register the endpoint
|
|
route := router.GetRoute(testcase.RouteName)
|
|
if route == nil {
|
|
t.Fatalf("route for name %q not found", testcase.RouteName)
|
|
}
|
|
|
|
route.Handler(testHandler)
|
|
|
|
u := server.URL + testcase.RequestURI
|
|
|
|
resp, err := http.Get(u)
|
|
|
|
if err != nil {
|
|
t.Fatalf("error issuing get request: %v", err)
|
|
}
|
|
|
|
if testcase.StatusCode == 0 {
|
|
// Override default, zero-value
|
|
testcase.StatusCode = http.StatusOK
|
|
}
|
|
if testcase.ExpectedURI == "" {
|
|
// Override default, zero-value
|
|
testcase.ExpectedURI = testcase.RequestURI
|
|
}
|
|
|
|
if resp.StatusCode != testcase.StatusCode {
|
|
t.Fatalf("unexpected status for %s: %v %v", u, resp.Status, resp.StatusCode)
|
|
}
|
|
|
|
if testcase.StatusCode != http.StatusOK {
|
|
// We don't care about json response.
|
|
continue
|
|
}
|
|
|
|
dec := json.NewDecoder(resp.Body)
|
|
|
|
var actualRouteInfo routeTestCase
|
|
if err := dec.Decode(&actualRouteInfo); err != nil {
|
|
t.Fatalf("error reading json response: %v", err)
|
|
}
|
|
// Needs to be set out of band
|
|
actualRouteInfo.StatusCode = resp.StatusCode
|
|
|
|
if actualRouteInfo.RequestURI != testcase.ExpectedURI {
|
|
t.Fatalf("URI %v incorrectly parsed, expected %v", actualRouteInfo.RequestURI, testcase.ExpectedURI)
|
|
}
|
|
|
|
if actualRouteInfo.RouteName != testcase.RouteName {
|
|
t.Fatalf("incorrect route %q matched, expected %q", actualRouteInfo.RouteName, testcase.RouteName)
|
|
}
|
|
|
|
// when testing deep equality, the actualRouteInfo has an empty ExpectedURI, we don't want
|
|
// that to make the comparison fail. We're otherwise done with the testcase so empty the
|
|
// testcase.ExpectedURI
|
|
testcase.ExpectedURI = ""
|
|
if deeplyEqual && !reflect.DeepEqual(actualRouteInfo, testcase) {
|
|
t.Fatalf("actual does not equal expected: %#v != %#v", actualRouteInfo, testcase)
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// -------------- START LICENSED CODE --------------
|
|
// The following code is derivative of https://github.com/google/gofuzz
|
|
// gofuzz is licensed under the Apache License, Version 2.0, January 2004,
|
|
// a copy of which can be found in the LICENSE file at the root of this
|
|
// repository.
|
|
|
|
// These functions allow us to generate strings containing only multibyte
|
|
// characters that are invalid in our URLs. They are used above for fuzzing
|
|
// to ensure we always get 404s on these invalid strings
|
|
type charRange struct {
|
|
first, last rune
|
|
}
|
|
|
|
// choose returns a random unicode character from the given range, using the
|
|
// given randomness source.
|
|
func (r *charRange) choose() rune {
|
|
count := int64(r.last - r.first)
|
|
return r.first + rune(rand.Int63n(count))
|
|
}
|
|
|
|
var unicodeRanges = []charRange{
|
|
{'\u00a0', '\u02af'}, // Multi-byte encoded characters
|
|
{'\u4e00', '\u9fff'}, // Common CJK (even longer encodings)
|
|
}
|
|
|
|
func randomString(length int) string {
|
|
runes := make([]rune, length)
|
|
for i := range runes {
|
|
runes[i] = unicodeRanges[rand.Intn(len(unicodeRanges))].choose()
|
|
}
|
|
return string(runes)
|
|
}
|
|
|
|
// -------------- END LICENSED CODE --------------
|