Skip to content
Open
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
1 change: 1 addition & 0 deletions common/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ require (
go.etcd.io/bbolt v1.4.3
go.podman.io/image/v5 v5.38.0
go.podman.io/storage v1.61.0
go.step.sm/crypto v0.57.0
golang.org/x/crypto v0.44.0
golang.org/x/sync v0.18.0
golang.org/x/sys v0.38.0
Expand Down
4 changes: 4 additions & 0 deletions common/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,8 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262 h1:unQFBIznI+VYD1/1fApl1A+9VcBk+9dcqGfnePY87LY=
github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262/go.mod h1:MyOHs9Po2fbM1LHej6sBUT8ozbxmMOFG+E+rx/GSGuc=
github.com/smallstep/pkcs7 v0.1.1 h1:x+rPdt2W088V9Vkjho4KtoggyktZJlMduZAtRHm68LU=
github.com/smallstep/pkcs7 v0.1.1/go.mod h1:dL6j5AIz9GHjVEBTXtW+QliALcgM19RtXaTeyxI+AfA=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
Expand Down Expand Up @@ -325,6 +327,8 @@ go.podman.io/image/v5 v5.38.0 h1:aUKrCANkPvze1bnhLJsaubcfz0d9v/bSDLnwsXJm6G4=
go.podman.io/image/v5 v5.38.0/go.mod h1:hSIoIUzgBnmc4DjoIdzk63aloqVbD7QXDMkSE/cvG90=
go.podman.io/storage v1.61.0 h1:5hD/oyRYt1f1gxgvect+8syZBQhGhV28dCw2+CZpx0Q=
go.podman.io/storage v1.61.0/go.mod h1:A3UBK0XypjNZ6pghRhuxg62+2NIm5lcUGv/7XyMhMUI=
go.step.sm/crypto v0.57.0 h1:YjoRQDaJYAxHLVwjst0Bl0xcnoKzVwuHCJtEo2VSHYU=
go.step.sm/crypto v0.57.0/go.mod h1:+Lwp5gOVPaTa3H/Ul/TzGbxQPXZZcKIUGMS0lG6n9Go=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package libartifact
package store

import (
"encoding/json"
Expand All @@ -7,6 +7,7 @@ import (

"github.com/opencontainers/go-digest"
"go.podman.io/common/pkg/libartifact/types"
"go.podman.io/image/v5/docker/reference"
"go.podman.io/image/v5/manifest"
)

Expand Down Expand Up @@ -36,7 +37,7 @@ func (a *Artifact) GetName() (string, error) {
return "", types.ErrArtifactUnamed
}

// SetName is a accessor for setting the artifact name
// SetName is an accessor for setting the artifact name
// Note: long term this may not be needed, and we would
// be comfortable with simply using the exported field
// called Name.
Expand All @@ -55,16 +56,16 @@ func (a *Artifact) GetDigest() (*digest.Digest, error) {

type ArtifactList []*Artifact

// GetByNameOrDigest returns an artifact, if present, by a given name
// getByNameOrDigest returns an artifact, if present, by a given name
// Returns an error if not found.
func (al ArtifactList) GetByNameOrDigest(nameOrDigest string) (*Artifact, bool, error) {
func (al ArtifactList) getByNameOrDigest(nameOrDigest string) (*Artifact, bool, error) {
Copy link
Member

Choose a reason for hiding this comment

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

I see there is a lot of iteration over lists. In extreme cases, when the store has a lot of artifacts, this will cost us some time. I think there could be an optimization of this search.

// This is the hot route through
for _, artifact := range al {
if artifact.Name == nameOrDigest {
return artifact, false, nil
}
}
// Before giving up, check by digest
// Before giving up, check by full or partial ID
Copy link
Contributor

Choose a reason for hiding this comment

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

Probably unrelated: The “partial ID” syntax does not work well for multiple digest algorithms. “ID” references are only sort-of-required if it is possible to create an unnamed object, or pull and untag an object. Maybe we can drop the concept?

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 dont really love it either but "people" screamed about consistency with images and other objects. I doubt we can drop the concept.

Copy link
Member

Choose a reason for hiding this comment

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

I hate partial IDs, a digest cannot be partial it is only valid in its full form. Script should be using full ids always and for users they can use the name+tag or just press tab for shell completion.

Copy link
Contributor

Choose a reason for hiding this comment

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

How about supporting ID prefixes (or even all IDs) only for sha256 digests (and sha256-shaped c/storage IDs)?

That would preserve all existing code, but force users who want sha512 (or any future digest) to migrate.

If we could establish that as a policy for both artifacts and images, that would set clear user expectations — and probably fairly simplify the sha512 migration.

for _, artifact := range al {
Copy link
Contributor

Choose a reason for hiding this comment

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

In this loop, the function returns some entry matching the digest; there may be multiple.

[MULTI], see elsewhere.

artifactDigest, err := artifact.GetDigest()
if err != nil {
Expand All @@ -75,5 +76,30 @@ func (al ArtifactList) GetByNameOrDigest(nameOrDigest string) (*Artifact, bool,
return artifact, true, nil
}
}
named, err := reference.ParseNamed(nameOrDigest)
if err != nil {
return nil, false, fmt.Errorf("invalid artifact: %q", nameOrDigest)
}

// And finally, check for things with a name and digest
// i.e. quay.io/podman/machine-os:sha256:7e952f1deece2717022d7cc066dd21d1468236560d23a79c80448d49b2048e99
if d, isDigested := named.(reference.Digested); isDigested {
for _, a := range al {
Copy link
Contributor

Choose a reason for hiding this comment

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

[MULTI], see elsewhere.

storedArtifactNamed, err := reference.ParseNamed(a.Name)
if err != nil {
return nil, false, err
}
if storedArtifactNamed.Name() == named.Name() {
artifactDigest, err := a.GetDigest()
Copy link
Contributor

@mtrmac mtrmac Nov 12, 2025

Choose a reason for hiding this comment

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

This will break for sha256 vs. sha512 references to the same object — but hopefully it can be implemented by re-digesting the original manifest blob using the digest value lookup digests’s algorithm.

if err != nil {
return nil, false, err
}
if d.Digest() == *artifactDigest {
return a, true, nil
}
}
}
}
// Nothing was found in the store that matches
return nil, false, fmt.Errorf("%s: %w", nameOrDigest, types.ErrArtifactNotExist)
}
100 changes: 100 additions & 0 deletions common/pkg/libartifact/store/reference.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
//go:build !remote

package store

import (
"context"

"go.podman.io/image/v5/docker/reference"
)

type ArtifactReference struct {
reference.Named
}

// NewArtifactReference is a theoretical reference to an artifact. It needs to be
// a fully qualified oci reference except for tag, where we add
// "latest" as the tag if tag is empty. Valid references:
//
// quay.io/podman/machine-os:latest
// quay.io/podman/machine-os
// quay.io/podman/machine-os@sha256:916ede4b2b9012f91f63100f8ba82d07ed81bf8a55d23c1503285a22a9759a1e
//
// Note: Partial sha references and digests (IDs) are not allowed.
func NewArtifactReference(input string) (ArtifactReference, error) {
ar := ArtifactReference{}
named, err := stringToNamed(input)
if err != nil {
return ArtifactReference{}, err
}
ar.Named = named
return ar, nil
}

func (ar ArtifactReference) IsDigested() bool {

This comment was marked as resolved.

Copy link
Contributor

Choose a reason for hiding this comment

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

The way Go works, it is generally better to define interfaces at callers than callees, unless the callee is really an abstraction over multiple implementations.

  • API stability: If we export a struct with methods, we can always add more methods. If we export an interface, adding a method to the interface breaks all other implementations — so there should either be no other implementations, and then we don’t need an interface, or we can’t add a method, and we have to keep adding more and more interfaces (and manual type checks) to the API.
  • If there is only one “real” implementation, an interface is typically used for mocks in tests. In that case the tests can define a private interface, perhaps with a very small subset of the full object, exposing just the methods that need mocking.

Copy link
Member

Choose a reason for hiding this comment

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

Thanks, I didn't realize that and learned something new.

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh, and:

  • Interface conformance is implicit in Go: In other languages, a class may need to explicitly declare its conformance to an interface/protocol — so if anyone ever might want to substitute an implementation via an interface, it’s easier for everyone when the original implementation also declares an interface, and it becomes “a best practice”. In Go, any external consumer can declare an interface without cooperation with the original implementation.

_, isDigested := ar.Named.(reference.Digested)
return isDigested
}

type ArtifactStoreReference struct {
ArtifactFromStore *Artifact
IsDigested bool
Ref reference.Named
}

// NewArtifactStorageReference refers to an object already in the artifact store. It
// can be a name or a full or partial digest. Conveniently, it also embeds the artifact
// as part of its return.
func NewArtifactStorageReference(nameOrDigest string, as *ArtifactStore) (ArtifactStoreReference, error) {
Copy link
Member

Choose a reason for hiding this comment

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

I think it would be good to mention that these type of functions require the caller to hold a lock.

lookupInput := nameOrDigest
asf := ArtifactStoreReference{}
al, err := as.getArtifacts(context.Background(), nil)
if err != nil {
return ArtifactStoreReference{}, err
}

// Try to parse as a valid OCI reference
named, parseErr := stringToNamed(nameOrDigest)
if parseErr == nil {
lookupInput = named.String()
}

// Lookup in the store
a, isDigest, err := al.getByNameOrDigest(lookupInput)
Copy link
Contributor

Choose a reason for hiding this comment

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

By this time, the stringToNamed has parsed the string, and ~knows whether it is named/digested/ invalid == ID prefix.

But getByNameOrDigest conflates that all back into a string, and on the digest lookup path parses it again.

I’m not saying it doesn’t work, but it feels wasteful (doesn’t really matter) and hard to follow (what I worry about).

if err != nil {
return ArtifactStoreReference{}, err
}

// If parsing failed, parse the artifact's name instead
if parseErr != nil {
fqName, err := a.GetName()
Copy link
Contributor

Choose a reason for hiding this comment

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

[MULTI], see elsewhere.

… this assigns some of possibly several names matching the digest to the returned value.

And then the name is used as the primary lookup key, e.g. in ArtifactStore.Remove.

That does something, but it’s not clear that it’s what the user wanted.

Again, can we entirely delete the “ID” lookup case? Or at least have it fail if there are multiple matching objects? Or would we want to have it fail only in some situations? delete digestValue is a problem; inspect digestValue might be fine.


… but this also applies to digest lookups: pull repo@theDigest; pull repo:tagPointingToThatDigest; pull repo:tagAlsoPointingToThatDigest creates 3 separate named objects in the store, but getByNameOrDigest will only return one (~arbitrarily-chosen) name.

if err != nil {
return ArtifactStoreReference{}, err
}
named, err = stringToNamed(fqName)
if err != nil {
return ArtifactStoreReference{}, err
}
}

asf.Ref = named
asf.IsDigested = isDigest
asf.ArtifactFromStore = a
return asf, nil
}

// stringToNamed converts a string to a reference.Named.
func stringToNamed(s string) (reference.Named, error) {
named, err := reference.ParseNamed(s)
if err != nil {
return ArtifactReference{}, err
}
// If the supplied input is neither tagged nor has
// a digest, then add "latest"
_, isTagged := named.(reference.Tagged)
_, isDigested := named.(reference.Digested)
if !isTagged && !isDigested {
named = reference.TagNameOnly(named)
}
Comment on lines +92 to +98
Copy link
Contributor

Choose a reason for hiding this comment

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

This can just be TagNameOnly, it does exactly the same check internally.

return named, nil
}
186 changes: 186 additions & 0 deletions common/pkg/libartifact/store/reference_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package store

import (
"fmt"
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.podman.io/image/v5/types"
)

func TestNewArtifactReference(t *testing.T) {
Copy link
Contributor

Choose a reason for hiding this comment

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

(Consider using a table-driven test.)

Copy link
Member Author

Choose a reason for hiding this comment

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

do you have a pointer to something you hold in high regard ?

Copy link
Contributor

Choose a reason for hiding this comment

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

(This is a non-blocking comment either way. Having verbose tests is better than not having tests.)

I don’t care that much about the structure… the valuable part is avoiding repetition in the common parts.

  • As a minimal example, there is image/docker/docker_transport_test.go:testParseReference.
  • It might be easier to have a separate table+loop for successes and for failures, situation-specific
  • There is now testing.T.Run, which causes every item to show up in the test output. Situation-specific again.

// Test valid reference
ar, err := NewArtifactReference("quay.io/podman/machine-os:5.1")
assert.NoError(t, err)
assert.NotNil(t, ar.Named)
assert.Equal(t, "quay.io/podman/machine-os:5.1", ar.Named.String())

// Test another valid reference
ar, err = NewArtifactReference("docker.io/library/nginx:latest")
assert.NoError(t, err)
assert.NotNil(t, ar.Named)

// Test invalid reference - empty string
_, err = NewArtifactReference("")
assert.Error(t, err)

// Test invalid reference - malformed
_, err = NewArtifactReference("invalid::reference")
assert.Error(t, err)

// Test latest is added when no tag is provided
ar, err = NewArtifactReference("quay.io/machine-os/podman")
assert.NoError(t, err)
assert.Equal(t, "quay.io/machine-os/podman:latest", ar.Named.String())

// Input with a digest is good
ar, err = NewArtifactReference("quay.io/machine-os/podman@sha256:8b96f36deaf1d2713858eebd9ef2fee9610df8452fbd083bbfa7dca66d6fcd0b")
assert.NoError(t, err)
assert.True(t, ar.IsDigested())

// Partial digests are a no-go
_, err = NewArtifactReference("quay.io/machine-os/podman@sha256:8b96f36deaf1d2")
assert.Error(t, err)

// "IDs" are also a no-go
_, err = NewArtifactReference("84ddb405470e733d0202d6946e48fc75a7ee231337bdeb31a8579407a7052d9e")
assert.Error(t, err)
}

func TestArtifactReference_IsDigested(t *testing.T) {
// Test reference with tag (not digested)
ar, err := NewArtifactReference("quay.io/podman/machine-os:5.1")
require.NoError(t, err)
assert.False(t, ar.IsDigested())

// Test reference with digest (digested)
ar, err = NewArtifactReference("quay.io/podman/machine-os@sha256:8b96f36deaf1d2713858eebd9ef2fee9610df8452fbd083bbfa7dca66d6fcd0b")
require.NoError(t, err)
assert.True(t, ar.IsDigested())

// Test reference with latest tag (not digested)
ar, err = NewArtifactReference("quay.io/podman/machine-os:latest")
require.NoError(t, err)
assert.False(t, ar.IsDigested())
}

func TestNewArtifactStorageReference_ValidReference(t *testing.T) {
repo := "quay.io/podman/machine-os"
tag := "5.1"
ref := fmt.Sprintf("%s:%s", repo, tag)
as, artifactDigest := setupNewStore(t, ref, nil, nil)

// Test with a valid named reference - should find the artifact in the store
asr, err := NewArtifactStorageReference(ref, as)
assert.NoError(t, err)
assert.NotNil(t, asr.Ref)
assert.Equal(t, "quay.io/podman/machine-os:5.1", asr.Ref.String())
assert.False(t, asr.IsDigested)
assert.NotNil(t, asr.ArtifactFromStore)
assert.Equal(t, "quay.io/podman/machine-os:5.1", asr.ArtifactFromStore.Name)

// Lookup by Digest
asr, err = NewArtifactStorageReference(fmt.Sprintf("%s@%s", repo, artifactDigest.String()), as)
assert.NoError(t, err)
assert.NotNil(t, asr.ArtifactFromStore)
assert.True(t, asr.IsDigested)
assert.NotNil(t, asr.ArtifactFromStore)
}

func TestNewArtifactStorageReference_AutoTagLatest(t *testing.T) {
repoNameOnly := "quay.io/podman/machine-os"
as, _ := setupNewStore(t, repoNameOnly, nil, nil)

// Test with a reference without a tag (should auto-add :latest)
asr, err := NewArtifactStorageReference(repoNameOnly, as)
assert.NoError(t, err)
assert.NotNil(t, asr.Ref)
assert.Equal(t, fmt.Sprintf("%s:latest", repoNameOnly), asr.Ref.String())
assert.False(t, asr.IsDigested)
}

func TestNewArtifactStorageReference_InvalidReference(t *testing.T) {
storePath := filepath.Join(t.TempDir(), "store")
sc := &types.SystemContext{}

// Create an artifact store
as, err := NewArtifactStore(storePath, sc)
require.NoError(t, err)
require.NotNil(t, as)

// Test with an invalid reference that also doesn't exist in the store
// This should fail both as a reference parse and as a store lookup
_, err = NewArtifactStorageReference("nonexistent-digest-12345", as)
assert.Error(t, err)
}

func TestNewArtifactStorageReference_EmptyString(t *testing.T) {
storePath := filepath.Join(t.TempDir(), "store")
sc := &types.SystemContext{}

// Create an artifact store
as, err := NewArtifactStore(storePath, sc)
require.NoError(t, err)
require.NotNil(t, as)

// Test with empty string
_, err = NewArtifactStorageReference("", as)
assert.Error(t, err)
}

func TestStringToNamed(t *testing.T) {
// Test valid named reference
named, err := stringToNamed("quay.io/podman/machine-os:5.1")
assert.NoError(t, err)
assert.NotNil(t, named)
assert.Equal(t, "quay.io/podman/machine-os:5.1", named.String())

// Test reference without tag (should add :latest)
named, err = stringToNamed("quay.io/podman/machine-os")
assert.NoError(t, err)
assert.NotNil(t, named)
assert.Equal(t, "quay.io/podman/machine-os:latest", named.String())

// Test reference with digest
named, err = stringToNamed("quay.io/podman/machine-os@sha256:8b96f36deaf1d2713858eebd9ef2fee9610df8452fbd083bbfa7dca66d6fcd0b")
assert.NoError(t, err)
assert.NotNil(t, named)
assert.Equal(t, "quay.io/podman/machine-os@sha256:8b96f36deaf1d2713858eebd9ef2fee9610df8452fbd083bbfa7dca66d6fcd0b", named.String())

// Test invalid reference
_, err = stringToNamed("invalid::reference")
assert.Error(t, err)

// Test empty string
_, err = stringToNamed("")
assert.Error(t, err)
}

func TestNewArtifactStore(t *testing.T) {
// Test with valid absolute path
storePath := filepath.Join(t.TempDir(), "store")
sc := &types.SystemContext{}

as, err := NewArtifactStore(storePath, sc)
assert.NoError(t, err)
assert.NotNil(t, as)
assert.Equal(t, storePath, as.storePath)

// Verify the index file was created
indexPath := filepath.Join(storePath, "index.json")
_, err = os.Stat(indexPath)
assert.NoError(t, err)

// Test with empty path
_, err = NewArtifactStore("", sc)
assert.Error(t, err)
assert.Contains(t, err.Error(), "store path cannot be empty")

// Test with relative path
_, err = NewArtifactStore("relative/path", sc)
assert.Error(t, err)
assert.Contains(t, err.Error(), "must be absolute")
}
Loading
Loading