Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 21 additions & 10 deletions image/directory/directory_dest.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@ import (
"go.podman.io/storage/pkg/fileutils"
)

const version = "Directory Transport Version: 1.1\n"

// ErrNotContainerImageDir indicates that the directory doesn't match the expected contents of a directory created
// using the 'dir' transport
var ErrNotContainerImageDir = errors.New("not a containers image directory, don't want to overwrite important data")
Expand All @@ -33,7 +31,8 @@ type dirImageDestination struct {
stubs.NoPutBlobPartialInitialize
stubs.AlwaysSupportsSignatures

ref dirReference
ref dirReference
usesNonSHA256Digest bool
}

// newImageDestination returns an ImageDestination for writing to a directory.
Expand Down Expand Up @@ -76,9 +75,14 @@ func newImageDestination(sys *types.SystemContext, ref dirReference) (private.Im
return nil, err
}
// check if contents of version file is what we expect it to be
if string(contents) != version {
versionStr := string(contents)
parsedVersion, err := parseVersion(versionStr)
if err != nil {
return nil, ErrNotContainerImageDir
}
if parsedVersion.isGreaterThan(maxSupportedVersion) {
return nil, UnsupportedVersionError{Version: versionStr, Path: ref.resolvedPath}
}
} else {
return nil, ErrNotContainerImageDir
}
Expand All @@ -94,11 +98,6 @@ func newImageDestination(sys *types.SystemContext, ref dirReference) (private.Im
return nil, fmt.Errorf("unable to create directory %q: %w", ref.resolvedPath, err)
}
}
// create version file
err = os.WriteFile(ref.versionPath(), []byte(version), 0o644)
if err != nil {
return nil, fmt.Errorf("creating version file %q: %w", ref.versionPath(), err)
}

d := &dirImageDestination{
PropertyMethodsInitialize: impl.PropertyMethods(impl.Properties{
Expand Down Expand Up @@ -151,13 +150,17 @@ func (d *dirImageDestination) PutBlobWithOptions(ctx context.Context, stream io.
}
}()

digester, stream := putblobdigest.DigestIfCanonicalUnknown(stream, inputInfo)
digester, stream := putblobdigest.DigestIfUnknown(stream, inputInfo)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the destination-transport-side digest computation must be a more complex logic, see in the earlier PR about the interaction with cannotModifyManifestReason.

… and dirReference.layerPath discards the algorithm name; that does not generalize for other algorithms, we need to move towards agility where adding an extra algorithm is a ~parameter change and does not require any more changes to the “code proper”; i.e. discarding algorithm names is no longer much of an option.

(We need to keep the existing file names for sha256, to retain compatibility. And… do we define a new value for versionPath?!)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated PutBlob to store blob under provided digest algorithm with the algorithm name prepended (except for Canonical) along with a canonical digest hardlink.

$ /usr/bin/ls
dc518581817f4e75a7dcfd35383e67c3ef85438250c17e10090b5a31ab8f68d4  manifest.json  sha512-2ee373e378345b35e7966a106c5c0a40a005a13bfc87695d89c5bb217f969c351e73810cf78d3d841237098731cf76878f05af8f8d28176c316681f9422ff688  version

$ diff dc518581817f4e75a7dcfd35383e67c3ef85438250c17e10090b5a31ab8f68d4 sha512-2ee373e378345b35e7966a106c5c0a40a005a13bfc87695d89c5bb217f969c351e73810cf78d3d841237098731cf76878f05af8f8d28176c316681f9422ff688
$

do we define a new value for versionPath?!)

Doesn't break existing behaviour but there's new stuff, so maybe we should? I'll defer to you.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Hard links are not supported by all file systems (FAT)
  • Symbolic links are not supported by all file systems either, and generally restricted to admin users on Windows

And, anyway, readers of dir: can only start with the manifest, and the values provided in the manifest. So if PutBlob returns sha512, and the manifest is written to include sha512, readers will not know the sha256 value and have no way to use it.

So I don’t think we need to compute both digests at all; just the layerPath changes to the path computation, + some (as-yet-undefined) logic for PutBlob to use “the algorithm the user wanted”, should be sufficient.

do we define a new value for versionPath?!)

I think with the changes to layerPath, we need to. Previously it was, hypothetically, possible to read a complete sha512 image from dir:, and those images will now break. And we will need to update both dir…Dest… and dir…Src…: destinations should refuse to work on future versions, and still assign the existing 1.1 version for sha256 images for maximum compatibility. sources should detect+refuse future versions.

Consider making the dir changes a separate PR.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll keep the dir changes here because of the review comments. Separate PR for image/internal at #486


// TODO: This can take quite some time, and should ideally be cancellable using ctx.Done().
size, err := io.Copy(blobFile, stream)
if err != nil {
return private.UploadedBlob{}, err
}
blobDigest := digester.Digest()
if blobDigest.Algorithm() != digest.Canonical { // compare the special case in layerPath
d.usesNonSHA256Digest = true
}
if inputInfo.Size != -1 && size != inputInfo.Size {
return private.UploadedBlob{}, fmt.Errorf("Size mismatch when copying %s, expected %d, got %d", blobDigest, inputInfo.Size, size)
}
Expand Down Expand Up @@ -257,6 +260,14 @@ func (d *dirImageDestination) PutSignaturesWithFormat(ctx context.Context, signa
// - Uploaded data MAY be visible to others before CommitWithOptions() is called
// - Uploaded data MAY be removed or MAY remain around if Close() is called without CommitWithOptions() (i.e. rollback is allowed but not guaranteed)
func (d *dirImageDestination) CommitWithOptions(ctx context.Context, options private.CommitOptions) error {
versionToWrite := version1_1
if d.usesNonSHA256Digest {
versionToWrite = version1_2
}
err := os.WriteFile(d.ref.versionPath(), []byte(versionToWrite.String()), 0o644)
if err != nil {
return fmt.Errorf("writing version file %q: %w", d.ref.versionPath(), err)
}
return nil
}

Expand Down
67 changes: 67 additions & 0 deletions image/directory/directory_dest_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package directory

import (
"bytes"
"context"
"os"
"path/filepath"
"testing"

"github.com/opencontainers/go-digest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.podman.io/image/v5/pkg/blobinfocache/memory"
"go.podman.io/image/v5/types"
)

func TestVersionAssignment(t *testing.T) {
for _, c := range []struct {
name string
algorithms []digest.Algorithm
expectedVersion version
}{
{
name: "SHA256 only gets version 1.1",
algorithms: []digest.Algorithm{digest.SHA256},
expectedVersion: version1_1,
},
{
name: "SHA512 only gets version 1.2",
algorithms: []digest.Algorithm{digest.SHA512},
expectedVersion: version1_2,
},
{
name: "Mixed SHA256 and SHA512 gets version 1.2",
algorithms: []digest.Algorithm{digest.SHA256, digest.SHA512},
expectedVersion: version1_2,
},
} {
t.Run(c.name, func(t *testing.T) {
ref, tmpDir := refToTempDir(t)
cache := memory.New()

dest, err := ref.NewImageDestination(context.Background(), nil)
require.NoError(t, err)
defer dest.Close()

for i, algo := range c.algorithms {
blobData := []byte("test-blob-" + algo.String() + "-" + string(rune(i)))
var blobDigest digest.Digest
if algo == digest.SHA256 {
blobDigest = ""
} else {
blobDigest = algo.FromBytes(blobData)
}
_, err = dest.PutBlob(context.Background(), bytes.NewReader(blobData), types.BlobInfo{Digest: blobDigest, Size: int64(len(blobData))}, cache, false)
require.NoError(t, err)
}

err = dest.Commit(context.Background(), nil)
require.NoError(t, err)

versionBytes, err := os.ReadFile(filepath.Join(tmpDir, "version"))
require.NoError(t, err)
assert.Equal(t, c.expectedVersion.String(), string(versionBytes))
})
}
}
21 changes: 19 additions & 2 deletions image/directory/directory_src.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,24 @@ type dirImageSource struct {

// newImageSource returns an ImageSource reading from an existing directory.
// The caller must call .Close() on the returned ImageSource.
func newImageSource(ref dirReference) private.ImageSource {
func newImageSource(ref dirReference) (private.ImageSource, error) {
versionPath := ref.versionPath()
contents, err := os.ReadFile(versionPath)
if err != nil {
if !os.IsNotExist(err) {
return nil, fmt.Errorf("reading version file %q: %w", versionPath, err)
}
} else {
versionStr := string(contents)
parsedVersion, err := parseVersion(versionStr)
if err != nil {
return nil, fmt.Errorf("invalid version file content: %q", versionStr)
}
if parsedVersion.isGreaterThan(maxSupportedVersion) {
return nil, UnsupportedVersionError{Version: versionStr, Path: ref.resolvedPath}
}
}

s := &dirImageSource{
PropertyMethodsInitialize: impl.PropertyMethods(impl.Properties{
HasThreadSafeGetBlob: false,
Expand All @@ -36,7 +53,7 @@ func newImageSource(ref dirReference) private.ImageSource {
ref: ref,
}
s.Compat = impl.AddCompat(s)
return s
return s, nil
}

// Reference returns the reference used to set up this source, _as specified by the user_
Expand Down
3 changes: 3 additions & 0 deletions image/directory/directory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"io"
"os"
"path/filepath"
"testing"

"github.com/opencontainers/go-digest"
Expand Down Expand Up @@ -203,6 +204,8 @@ func TestGetPutSignatures(t *testing.T) {

func TestSourceReference(t *testing.T) {
ref, tmpDir := refToTempDir(t)
err := os.WriteFile(filepath.Join(tmpDir, "version"), []byte("Directory Transport Version: 1.1\n"), 0o644)
require.NoError(t, err)

src, err := ref.NewImageSource(context.Background(), nil)
require.NoError(t, err)
Expand Down
17 changes: 12 additions & 5 deletions image/directory/directory_transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ func (ref dirReference) NewImage(ctx context.Context, sys *types.SystemContext)
// NewImageSource returns a types.ImageSource for this reference.
// The caller must call .Close() on the returned ImageSource.
func (ref dirReference) NewImageSource(ctx context.Context, sys *types.SystemContext) (types.ImageSource, error) {
return newImageSource(ref), nil
return newImageSource(ref)
}

// NewImageDestination returns a types.ImageDestination for this reference.
Expand All @@ -172,12 +172,19 @@ func (ref dirReference) manifestPath(instanceDigest *digest.Digest) (string, err
}

// layerPath returns a path for a layer tarball within a directory using our conventions.
func (ref dirReference) layerPath(digest digest.Digest) (string, error) {
if err := digest.Validate(); err != nil { // digest.Digest.Encoded() panics on failure, and could possibly result in a path with ../, so validate explicitly.
func (ref dirReference) layerPath(d digest.Digest) (string, error) {
if err := d.Validate(); err != nil { // digest.Digest.Encoded() panics on failure, and could possibly result in a path with ../, so validate explicitly.
return "", err
}
// FIXME: Should we keep the digest identification?
return filepath.Join(ref.path, digest.Encoded()), nil

var filename string
if d.Algorithm() == digest.Canonical {
filename = d.Encoded()
} else {
filename = d.Algorithm().String() + "-" + d.Encoded()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder whether we need to do a version check and conditionally use the old logic here. I guess that doesn’t matter in practice.

}

return filepath.Join(ref.path, filename), nil
}

// signaturePath returns a path for a signature within a directory using our conventions.
Expand Down
17 changes: 13 additions & 4 deletions image/directory/directory_transport_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,9 @@ func TestReferenceNewImageNoValidManifest(t *testing.T) {
}

func TestReferenceNewImageSource(t *testing.T) {
ref, _ := refToTempDir(t)
ref, tmpDir := refToTempDir(t)
err := os.WriteFile(filepath.Join(tmpDir, "version"), []byte("Directory Transport Version: 1.1\n"), 0o644)
require.NoError(t, err)
src, err := ref.NewImageSource(context.Background(), nil)
assert.NoError(t, err)
defer src.Close()
Expand Down Expand Up @@ -209,14 +211,21 @@ func TestReferenceManifestPath(t *testing.T) {
}

func TestReferenceLayerPath(t *testing.T) {
const hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
const hex256 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
const hex512 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"

ref, tmpDir := refToTempDir(t)
dirRef, ok := ref.(dirReference)
require.True(t, ok)
res, err := dirRef.layerPath("sha256:" + hex)

res, err := dirRef.layerPath("sha256:" + hex256)
require.NoError(t, err)
assert.Equal(t, tmpDir+"/"+hex, res)
assert.Equal(t, tmpDir+"/"+hex256, res)

res, err = dirRef.layerPath("sha512:" + hex512)
require.NoError(t, err)
assert.Equal(t, tmpDir+"/sha512-"+hex512, res)

_, err = dirRef.layerPath(digest.Digest("sha256:../hello"))
assert.Error(t, err)
}
Expand Down
62 changes: 62 additions & 0 deletions image/directory/version.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package directory

import (
"fmt"
)

const (
versionPrefix = "Directory Transport Version: "
)

// version represents a parsed directory transport version
type version struct {
major int
minor int
}

// Supported versions
// Write version file based on digest algorithm used.
// 1.1 for sha256-only images, 1.2 otherwise.
var (
version1_1 = version{major: 1, minor: 1}
version1_2 = version{major: 1, minor: 2}
maxSupportedVersion = version1_2
)

// String formats a version as a string suitable for writing to the version file
func (v version) String() string {
return fmt.Sprintf("%s%d.%d\n", versionPrefix, v.major, v.minor)
}

// parseVersion parses a version string into major and minor components.
// Returns an error if the format is invalid.
func parseVersion(versionStr string) (version, error) {
var v version
expectedFormat := versionPrefix + "%d.%d\n"
// Sscanf parsing is a bit loose (treats spaces specially), but a strict check immediately follows
n, err := fmt.Sscanf(versionStr, expectedFormat, &v.major, &v.minor)
if err != nil || n != 2 || versionStr != v.String() {
return version{}, fmt.Errorf("invalid version format")
}
return v, nil
}

// TODO: Potential refactor for better interoperability with `cmp`
// https://github.com/containers/container-libs/pull/475#discussion_r2571131267
// isGreaterThan returns true if v is greater than other
func (v version) isGreaterThan(other version) bool {
if v.major != other.major {
return v.major > other.major
}
return v.minor > other.minor
}

// UnsupportedVersionError indicates that the directory uses a version newer than we support
type UnsupportedVersionError struct {
Version string // The unsupported version string found
Path string // The path to the directory
}

func (e UnsupportedVersionError) Error() string {
return fmt.Sprintf("unsupported directory transport version %q at %s", e.Version, e.Path)
}
55 changes: 55 additions & 0 deletions image/directory/version_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package directory

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestParseVersion(t *testing.T) {
for _, c := range []struct {
input string
expected version
shouldError bool
}{
{
input: "Directory Transport Version: 1.1\n",
expected: version{major: 1, minor: 1},
},
{
input: "Directory Transport Version: 1.2\n",
expected: version{major: 1, minor: 2},
},
{
input: "Invalid prefix 1.1\n",
shouldError: true,
},
{
input: "Directory Transport Version: 1.1",
shouldError: true,
},
{
input: "Directory Transport Version: abc\n",
shouldError: true,
},
} {
t.Run(c.input, func(t *testing.T) {
v, err := parseVersion(c.input)
if c.shouldError {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, c.expected, v)
}
})
}
}

func TestVersionComparison(t *testing.T) {
assert.False(t, version1_1.isGreaterThan(version1_1))
assert.False(t, version1_1.isGreaterThan(version1_2))
assert.True(t, version1_2.isGreaterThan(version1_1))
assert.True(t, version{major: 2, minor: 0}.isGreaterThan(version1_2))
assert.True(t, version{major: 1, minor: 3}.isGreaterThan(version1_2))
}
Loading