diff --git a/docker/docker_image.go b/docker/docker_image.go index 42bbfd95ee..93160480ea 100644 --- a/docker/docker_image.go +++ b/docker/docker_image.go @@ -123,6 +123,9 @@ func GetDigest(ctx context.Context, sys *types.SystemContext, ref types.ImageRef if !ok { return "", errors.New("ref must be a dockerReference") } + if dr.isUnknownDigest { + return "", fmt.Errorf("docker: reference %q is for unknown digest case; cannot get digest", dr.StringWithinTransport()) + } tagOrDigest, err := dr.tagOrDigest() if err != nil { diff --git a/docker/docker_image_dest.go b/docker/docker_image_dest.go index 774068c276..a9a36f0a34 100644 --- a/docker/docker_image_dest.go +++ b/docker/docker_image_dest.go @@ -452,7 +452,15 @@ func (d *dockerImageDestination) TryReusingBlobWithOptions(ctx context.Context, // but may accept a different manifest type, the returned error must be an ManifestTypeRejectedError. func (d *dockerImageDestination) PutManifest(ctx context.Context, m []byte, instanceDigest *digest.Digest) error { var refTail string - if instanceDigest != nil { + // If d.ref.isUnknownDigest=true, then we push without a tag, so get the + // digest that will be used + if d.ref.isUnknownDigest { + digest, err := manifest.Digest(m) + if err != nil { + return err + } + refTail = digest.String() + } else if instanceDigest != nil { // If the instanceDigest is provided, then use it as the refTail, because the reference, // whether it includes a tag or a digest, refers to the list as a whole, and not this // particular instance. diff --git a/docker/docker_image_src.go b/docker/docker_image_src.go index 231d5d2124..f9d4d6030f 100644 --- a/docker/docker_image_src.go +++ b/docker/docker_image_src.go @@ -38,8 +38,8 @@ type dockerImageSource struct { impl.DoesNotAffectLayerInfosForCopy stubs.ImplementsGetBlobAt - logicalRef dockerReference // The reference the user requested. - physicalRef dockerReference // The actual reference we are accessing (possibly a mirror) + logicalRef dockerReference // The reference the user requested. This must satisfy !isUnknownDigest + physicalRef dockerReference // The actual reference we are accessing (possibly a mirror). This must satisfy !isUnknownDigest c *dockerClient // State cachedManifest []byte // nil if not loaded yet @@ -48,7 +48,12 @@ type dockerImageSource struct { // newImageSource creates a new ImageSource for the specified image reference. // The caller must call .Close() on the returned ImageSource. +// The caller must ensure !ref.isUnknownDigest. func newImageSource(ctx context.Context, sys *types.SystemContext, ref dockerReference) (*dockerImageSource, error) { + if ref.isUnknownDigest { + return nil, fmt.Errorf("reading images from docker: reference %q without a tag or digest is not supported", ref.StringWithinTransport()) + } + registryConfig, err := loadRegistryConfiguration(sys) if err != nil { return nil, err @@ -121,7 +126,7 @@ func newImageSource(ctx context.Context, sys *types.SystemContext, ref dockerRef // The caller must call .Close() on the returned ImageSource. func newImageSourceAttempt(ctx context.Context, sys *types.SystemContext, logicalRef dockerReference, pullSource sysregistriesv2.PullSource, registryConfig *registryConfiguration) (*dockerImageSource, error) { - physicalRef, err := newReference(pullSource.Reference) + physicalRef, err := newReference(pullSource.Reference, false) if err != nil { return nil, err } @@ -591,6 +596,10 @@ func (s *dockerImageSource) getSignaturesFromSigstoreAttachments(ctx context.Con // deleteImage deletes the named image from the registry, if supported. func deleteImage(ctx context.Context, sys *types.SystemContext, ref dockerReference) error { + if ref.isUnknownDigest { + return fmt.Errorf("Docker reference without a tag or digest cannot be deleted") + } + registryConfig, err := loadRegistryConfiguration(sys) if err != nil { return err diff --git a/docker/docker_transport.go b/docker/docker_transport.go index 6ae8491594..1c89302f46 100644 --- a/docker/docker_transport.go +++ b/docker/docker_transport.go @@ -12,6 +12,11 @@ import ( "github.com/containers/image/v5/types" ) +// UnknownDigestSuffix can be appended to a reference when the caller +// wants to push an image without a tag or digest. +// NewReferenceUnknownDigest() is called when this const is detected. +const UnknownDigestSuffix = "@@unknown-digest@@" + func init() { transports.Register(Transport) } @@ -43,7 +48,8 @@ func (t dockerTransport) ValidatePolicyConfigurationScope(scope string) error { // dockerReference is an ImageReference for Docker images. type dockerReference struct { - ref reference.Named // By construction we know that !reference.IsNameOnly(ref) + ref reference.Named // By construction we know that !reference.IsNameOnly(ref) unless isUnknownDigest=true + isUnknownDigest bool } // ParseReference converts a string, which should not start with the ImageTransport.Name prefix, into an Docker ImageReference. @@ -51,23 +57,46 @@ func ParseReference(refString string) (types.ImageReference, error) { if !strings.HasPrefix(refString, "//") { return nil, fmt.Errorf("docker: image reference %s does not start with //", refString) } + // Check if ref has UnknownDigestSuffix suffixed to it + unknownDigest := false + if strings.HasSuffix(refString, UnknownDigestSuffix) { + unknownDigest = true + refString = strings.TrimSuffix(refString, UnknownDigestSuffix) + } ref, err := reference.ParseNormalizedNamed(strings.TrimPrefix(refString, "//")) if err != nil { return nil, err } + + if unknownDigest { + if !reference.IsNameOnly(ref) { + return nil, fmt.Errorf("docker: image reference %q has unknown digest set but it contains either a tag or digest", ref.String()+UnknownDigestSuffix) + } + return NewReferenceUnknownDigest(ref) + } + ref = reference.TagNameOnly(ref) return NewReference(ref) } // NewReference returns a Docker reference for a named reference. The reference must satisfy !reference.IsNameOnly(). func NewReference(ref reference.Named) (types.ImageReference, error) { - return newReference(ref) + return newReference(ref, false) +} + +// NewReferenceUnknownDigest returns a Docker reference for a named reference, which can be used to write images without setting +// a tag on the registry. The reference must satisfy reference.IsNameOnly() +func NewReferenceUnknownDigest(ref reference.Named) (types.ImageReference, error) { + return newReference(ref, true) } // newReference returns a dockerReference for a named reference. -func newReference(ref reference.Named) (dockerReference, error) { - if reference.IsNameOnly(ref) { - return dockerReference{}, fmt.Errorf("Docker reference %s has neither a tag nor a digest", reference.FamiliarString(ref)) +func newReference(ref reference.Named, unknownDigest bool) (dockerReference, error) { + if reference.IsNameOnly(ref) && !unknownDigest { + return dockerReference{}, fmt.Errorf("Docker reference %s is not for an unknown digest case; tag or digest is needed", reference.FamiliarString(ref)) + } + if !reference.IsNameOnly(ref) && unknownDigest { + return dockerReference{}, fmt.Errorf("Docker reference %s is for an unknown digest case but reference has a tag or digest", reference.FamiliarString(ref)) } // A github.com/distribution/reference value can have a tag and a digest at the same time! // The docker/distribution API does not really support that (we can’t ask for an image with a specific @@ -81,7 +110,8 @@ func newReference(ref reference.Named) (dockerReference, error) { } return dockerReference{ - ref: ref, + ref: ref, + isUnknownDigest: unknownDigest, }, nil } @@ -95,7 +125,11 @@ func (ref dockerReference) Transport() types.ImageTransport { // e.g. default attribute values omitted by the user may be filled in the return value, or vice versa. // WARNING: Do not use the return value in the UI to describe an image, it does not contain the Transport().Name() prefix. func (ref dockerReference) StringWithinTransport() string { - return "//" + reference.FamiliarString(ref.ref) + famString := "//" + reference.FamiliarString(ref.ref) + if ref.isUnknownDigest { + return famString + UnknownDigestSuffix + } + return famString } // DockerReference returns a Docker reference associated with this reference @@ -113,6 +147,9 @@ func (ref dockerReference) DockerReference() reference.Named { // not required/guaranteed that it will be a valid input to Transport().ParseReference(). // Returns "" if configuration identities for these references are not supported. func (ref dockerReference) PolicyConfigurationIdentity() string { + if ref.isUnknownDigest { + return ref.ref.Name() + } res, err := policyconfiguration.DockerReferenceIdentity(ref.ref) if res == "" || err != nil { // Coverage: Should never happen, NewReference above should refuse values which could cause a failure. panic(fmt.Sprintf("Internal inconsistency: policyconfiguration.DockerReferenceIdentity returned %#v, %v", res, err)) @@ -126,7 +163,13 @@ func (ref dockerReference) PolicyConfigurationIdentity() string { // It is STRONGLY recommended for the first element, if any, to be a prefix of PolicyConfigurationIdentity(), // and each following element to be a prefix of the element preceding it. func (ref dockerReference) PolicyConfigurationNamespaces() []string { - return policyconfiguration.DockerReferenceNamespaces(ref.ref) + namespaces := policyconfiguration.DockerReferenceNamespaces(ref.ref) + if ref.isUnknownDigest { + if len(namespaces) != 0 && namespaces[0] == ref.ref.Name() { + namespaces = namespaces[1:] + } + } + return namespaces } // NewImage returns a types.ImageCloser for this reference, possibly specialized for this ImageTransport. @@ -163,6 +206,10 @@ func (ref dockerReference) tagOrDigest() (string, error) { if ref, ok := ref.ref.(reference.NamedTagged); ok { return ref.Tag(), nil } + + if ref.isUnknownDigest { + return "", fmt.Errorf("Docker reference %q is for an unknown digest case, has neither a digest nor a tag", reference.FamiliarString(ref.ref)) + } // This should not happen, NewReference above refuses reference.IsNameOnly values. return "", fmt.Errorf("Internal inconsistency: Reference %s unexpectedly has neither a digest nor a tag", reference.FamiliarString(ref.ref)) } diff --git a/docker/docker_transport_test.go b/docker/docker_transport_test.go index 83c72e3abf..c7bc1917c9 100644 --- a/docker/docker_transport_test.go +++ b/docker/docker_transport_test.go @@ -2,6 +2,7 @@ package docker import ( "context" + "strings" "testing" "github.com/containers/image/v5/docker/reference" @@ -11,8 +12,9 @@ import ( ) const ( - sha256digestHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - sha256digest = "@sha256:" + sha256digestHex + sha256digestHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + sha256digest = "@sha256:" + sha256digestHex + unknownDigestSuffixTest = "@@unknown-digest@@" ) func TestTransportName(t *testing.T) { @@ -43,17 +45,24 @@ func TestParseReference(t *testing.T) { // testParseReference is a test shared for Transport.ParseReference and ParseReference. func testParseReference(t *testing.T, fn func(string) (types.ImageReference, error)) { - for _, c := range []struct{ input, expected string }{ - {"busybox", ""}, // Missing // prefix - {"//busybox:notlatest", "docker.io/library/busybox:notlatest"}, // Explicit tag - {"//busybox" + sha256digest, "docker.io/library/busybox" + sha256digest}, // Explicit digest - {"//busybox", "docker.io/library/busybox:latest"}, // Default tag + for _, c := range []struct { + input, expected string + expectedUnknownDigest bool + }{ + {"busybox", "", false}, // Missing // prefix + {"//busybox:notlatest", "docker.io/library/busybox:notlatest", false}, // Explicit tag + {"//busybox" + sha256digest, "docker.io/library/busybox" + sha256digest, false}, // Explicit digest + {"//busybox", "docker.io/library/busybox:latest", false}, // Default tag // A github.com/distribution/reference value can have a tag and a digest at the same time! // The docker/distribution API does not really support that (we can’t ask for an image with a specific // tag and digest), so fail. This MAY be accepted in the future. - {"//busybox:latest" + sha256digest, ""}, // Both tag and digest - {"//docker.io/library/busybox:latest", "docker.io/library/busybox:latest"}, // All implied values explicitly specified - {"//UPPERCASEISINVALID", ""}, // Invalid input + {"//busybox:latest" + sha256digest, "", false}, // Both tag and digest + {"//docker.io/library/busybox:latest", "docker.io/library/busybox:latest", false}, // All implied values explicitly specified + {"//UPPERCASEISINVALID", "", false}, // Invalid input + {"//busybox" + unknownDigestSuffixTest, "docker.io/library/busybox", true}, // UnknownDigest suffix + {"//example.com/ns/busybox" + unknownDigestSuffixTest, "example.com/ns/busybox", true}, // UnknownDigest with registry/repo + {"//example.com/ns/busybox:tag1" + unknownDigestSuffixTest, "", false}, // UnknownDigest with tag should fail + {"//example.com/ns/busybox" + sha256digest + unknownDigestSuffixTest, "", false}, // UnknownDigest with digest should fail } { ref, err := fn(c.input) if c.expected == "" { @@ -63,20 +72,29 @@ func testParseReference(t *testing.T, fn func(string) (types.ImageReference, err dockerRef, ok := ref.(dockerReference) require.True(t, ok, c.input) assert.Equal(t, c.expected, dockerRef.ref.String(), c.input) + assert.Equal(t, c.expectedUnknownDigest, dockerRef.isUnknownDigest) } } } // A common list of reference formats to test for the various ImageReference methods. -var validReferenceTestCases = []struct{ input, dockerRef, stringWithinTransport string }{ - {"busybox:notlatest", "docker.io/library/busybox:notlatest", "//busybox:notlatest"}, // Explicit tag - {"busybox" + sha256digest, "docker.io/library/busybox" + sha256digest, "//busybox" + sha256digest}, // Explicit digest - {"docker.io/library/busybox:latest", "docker.io/library/busybox:latest", "//busybox:latest"}, // All implied values explicitly specified - {"example.com/ns/foo:bar", "example.com/ns/foo:bar", "//example.com/ns/foo:bar"}, // All values explicitly specified +var validReferenceTestCases = []struct { + input, dockerRef, stringWithinTransport string + expectedUnknownDigest bool +}{ + {"busybox:notlatest", "docker.io/library/busybox:notlatest", "//busybox:notlatest", false}, // Explicit tag + {"busybox" + sha256digest, "docker.io/library/busybox" + sha256digest, "//busybox" + sha256digest, false}, // Explicit digest + {"docker.io/library/busybox:latest", "docker.io/library/busybox:latest", "//busybox:latest", false}, // All implied values explicitly specified + {"example.com/ns/foo:bar", "example.com/ns/foo:bar", "//example.com/ns/foo:bar", false}, // All values explicitly specified + {"example.com/ns/busybox" + unknownDigestSuffixTest, "example.com/ns/busybox", "//example.com/ns/busybox" + unknownDigestSuffixTest, true}, // UnknownDigest Suffix full name + {"busybox" + unknownDigestSuffixTest, "docker.io/library/busybox", "//busybox" + unknownDigestSuffixTest, true}, // UnknownDigest short name } func TestNewReference(t *testing.T) { for _, c := range validReferenceTestCases { + if strings.HasSuffix(c.input, unknownDigestSuffixTest) { + continue + } parsed, err := reference.ParseNormalizedNamed(c.input) require.NoError(t, err) ref, err := NewReference(parsed) @@ -84,6 +102,7 @@ func TestNewReference(t *testing.T) { dockerRef, ok := ref.(dockerReference) require.True(t, ok, c.input) assert.Equal(t, c.dockerRef, dockerRef.ref.String(), c.input) + assert.Equal(t, false, dockerRef.isUnknownDigest) } // Neither a tag nor digest @@ -103,6 +122,28 @@ func TestNewReference(t *testing.T) { assert.Error(t, err) } +func TestNewReferenceUnknownDigest(t *testing.T) { + // References with tags and digests should be rejected + for _, c := range validReferenceTestCases { + if !strings.Contains(c.input, unknownDigestSuffixTest) { + parsed, err := reference.ParseNormalizedNamed(c.input) + require.NoError(t, err) + _, err = NewReferenceUnknownDigest(parsed) + assert.Error(t, err) + continue + } + in := strings.TrimSuffix(c.input, unknownDigestSuffixTest) + parsed, err := reference.ParseNormalizedNamed(in) + require.NoError(t, err) + ref, err := NewReferenceUnknownDigest(parsed) + require.NoError(t, err, c.input) + dockerRef, ok := ref.(dockerReference) + require.True(t, ok, c.input) + assert.Equal(t, c.dockerRef, dockerRef.ref.String(), c.input) + assert.Equal(t, true, dockerRef.isUnknownDigest) + } +} + func TestReferenceTransport(t *testing.T) { ref, err := ParseReference("//busybox") require.NoError(t, err) @@ -138,6 +179,10 @@ func TestReferencePolicyConfigurationIdentity(t *testing.T) { ref, err := ParseReference("//busybox") require.NoError(t, err) assert.Equal(t, "docker.io/library/busybox:latest", ref.PolicyConfigurationIdentity()) + + ref, err = ParseReference("//busybox" + unknownDigestSuffixTest) + require.NoError(t, err) + assert.Equal(t, "docker.io/library/busybox", ref.PolicyConfigurationIdentity()) } func TestReferencePolicyConfigurationNamespaces(t *testing.T) { @@ -150,28 +195,52 @@ func TestReferencePolicyConfigurationNamespaces(t *testing.T) { "docker.io", "*.io", }, ref.PolicyConfigurationNamespaces()) + + ref, err = ParseReference("//busybox" + unknownDigestSuffixTest) + require.NoError(t, err) + assert.Equal(t, []string{ + "docker.io/library", + "docker.io", + "*.io", + }, ref.PolicyConfigurationNamespaces()) } func TestReferenceNewImage(t *testing.T) { - ref, err := ParseReference("//quay.io/libpod/busybox") - require.NoError(t, err) - img, err := ref.NewImage(context.Background(), &types.SystemContext{ + sysCtx := &types.SystemContext{ RegistriesDirPath: "/this/does/not/exist", DockerPerHostCertDirPath: "/this/does/not/exist", ArchitectureChoice: "amd64", OSChoice: "linux", - }) + } + ref, err := ParseReference("//quay.io/libpod/busybox") + require.NoError(t, err) + img, err := ref.NewImage(context.Background(), sysCtx) require.NoError(t, err) defer img.Close() + + // unknownDigest case should return error + ref, err = ParseReference("//quay.io/libpod/busybox" + unknownDigestSuffixTest) + require.NoError(t, err) + _, err = ref.NewImage(context.Background(), sysCtx) + assert.Error(t, err) } func TestReferenceNewImageSource(t *testing.T) { + sysCtx := &types.SystemContext{ + RegistriesDirPath: "/this/does/not/exist", + DockerPerHostCertDirPath: "/this/does/not/exist", + } ref, err := ParseReference("//quay.io/libpod/busybox") require.NoError(t, err) - src, err := ref.NewImageSource(context.Background(), - &types.SystemContext{RegistriesDirPath: "/this/does/not/exist", DockerPerHostCertDirPath: "/this/does/not/exist"}) + src, err := ref.NewImageSource(context.Background(), sysCtx) require.NoError(t, err) defer src.Close() + + // unknownDigest case should return error + ref, err = ParseReference("//quay.io/libpod/busybox" + unknownDigestSuffixTest) + require.NoError(t, err) + _, err = ref.NewImageSource(context.Background(), sysCtx) + assert.Error(t, err) } func TestReferenceNewImageDestination(t *testing.T) { @@ -181,6 +250,13 @@ func TestReferenceNewImageDestination(t *testing.T) { &types.SystemContext{RegistriesDirPath: "/this/does/not/exist", DockerPerHostCertDirPath: "/this/does/not/exist"}) require.NoError(t, err) defer dest.Close() + + ref, err = ParseReference("//quay.io/libpod/busybox" + unknownDigestSuffixTest) + require.NoError(t, err) + dest2, err := ref.NewImageDestination(context.Background(), + &types.SystemContext{RegistriesDirPath: "/this/does/not/exist", DockerPerHostCertDirPath: "/this/does/not/exist"}) + require.NoError(t, err) + defer dest2.Close() } func TestReferenceTagOrDigest(t *testing.T) { @@ -203,4 +279,11 @@ func TestReferenceTagOrDigest(t *testing.T) { dockerRef := dockerReference{ref: ref} _, err = dockerRef.tagOrDigest() assert.Error(t, err) + + // Invalid input, unknownDigest case + ref, err = reference.ParseNormalizedNamed("busybox") + require.NoError(t, err) + dockerRef = dockerReference{ref: ref, isUnknownDigest: true} + _, err = dockerRef.tagOrDigest() + assert.Error(t, err) } diff --git a/docs/containers-transports.5.md b/docs/containers-transports.5.md index 8ec42fe87a..481bdb73c7 100644 --- a/docs/containers-transports.5.md +++ b/docs/containers-transports.5.md @@ -40,10 +40,13 @@ By default, uses the authorization state in `$XDG_RUNTIME_DIR/containers/auth.js If the authorization state is not found there, `$HOME/.docker/config.json` is checked, which is set using docker-login(1). The containers-registries.conf(5) further allows for configuring various settings of a registry. -Note that a _docker-reference_ has the following format: `name[:tag|@digest]`. +Note that a _docker-reference_ has the following format: _name_[**:**_tag_ | **@**_digest_]. While the docker transport does not support both a tag and a digest at the same time some formats like containers-storage do. Digests can also be used in an image destination as long as the manifest matches the provided digest. + +The docker transport supports pushing images without a tag or digest to a registry when the image name is suffixed with **@@unknown-digest@@**. The _name_**@@unknown-digest@@** reference format cannot be used with a reference that has a tag or digest. The digest of images can be explored with skopeo-inspect(1). + If `name` does not contain a slash, it is treated as `docker.io/library/name`. Otherwise, the component before the first slash is checked if it is recognized as a `hostname[:port]` (i.e., it contains either a . or a :, or the component is exactly localhost). If the first component of name is not recognized as a `hostname[:port]`, `name` is treated as `docker.io/name`.