mirror of
https://github.com/goharbor/harbor.git
synced 2026-10-23 10:08:57 +03:00
fix: implement signature inheritance for OCI index children (#23540)
* fix: implement cosign signature inheritance for OCI index children When a multi-arch OCI index is signed with cosign, its per-arch child artifacts have no accessory of their own, so the UI shows them as unsigned. Populate InheritedAccessories from the parent index's cosign signature (display only, never used in copy/delete/walk) and surface it in ListAccessories when the query filters for signature.cosign. Ported from container-registry/harbor-next#27. Signed-off-by: aloui ikram <ikram@container-registry.com> * refactor: expose inherited cosign signatures as a separate API field Rework the OCI index signature inheritance so that the accessory list stays faithful to registry content. The parent's signature is no longer merged into ListAccessories for a child digest; instead the already computed InheritedAccessories is exposed as a new, explicit inherited_accessories field on the artifact get and list endpoints. The inherited entries keep the parent index as their subject digest, so they remain distinguishable from a direct signature: cosign verify against the child digest still fails, and the referrers API still does not list them for that digest. Responses that do not ask for the field are unchanged. Resolving the field costs a reference lookup per artifact, so it is opt-in behind a new with_inherited_accessory query parameter, and an artifact that no index references short-circuits after a single indexed lookup. with_accessory could not serve as the gate because it defaults to true and is also set on the copy path. The Portal reads the new field and renders a distinct icon with a tooltip, and now fetches the child accessories inline with the artifact instead of issuing one extra request per child. Signed-off-by: aloui ikram <ikram@container-registry.com> * feat: extend signature inheritance to notation The inheritance introduced for cosign applies equally to notation. Like cosign, notation signs a single digest and has no flag to descend into the children of an index, so a notation-signed index leaves its children carrying no signature of their own. The Portal already treats both kinds as signed, so handling only cosign meant a notation-signed index still showed every one of its children as unsigned. Inherit both signature types, both when collecting the parent's accessories and in the check that skips artifacts already signed in their own right. That second place matters on its own: a child with its own notation signature under a cosign-signed index was not recognised as directly signed, and so was given an inherited entry, contradicting the documented meaning of the field. Accessory kinds that are not signatures stay excluded, since an SBOM or an accelerator on an index says nothing about the children of that index. Take the inherited icon colour from the Clarity neutral status token rather than a literal, so that it follows the light and dark themes instead of remaining darker than the text beside it in dark mode. Signed-off-by: aloui ikram <ikram@container-registry.com> --------- Signed-off-by: aloui ikram <ikram@container-registry.com> Co-authored-by: aloui ikram <ikram@container-registry.com> Co-authored-by: Wang Yan <wangyan_0219@hotmail.com>
This commit is contained in:
@@ -1072,6 +1072,12 @@ paths:
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
- name: with_inherited_accessory
|
||||
in: query
|
||||
description: Specify whether the accessories of the parent OCI index(es) referencing the artifact are included, in the separate inherited_accessories field. Off by default because it costs an extra reference lookup per artifact.
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
@@ -1185,6 +1191,12 @@ paths:
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
- name: with_inherited_accessory
|
||||
in: query
|
||||
description: Specify whether the accessories of the parent OCI index(es) referencing the artifact are included, in the separate inherited_accessories field. Off by default because it costs an extra reference lookup per artifact.
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
# should be in tag level
|
||||
- name: with_signature
|
||||
in: query
|
||||
@@ -1716,6 +1728,12 @@ paths:
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
- name: with_inherited_accessory
|
||||
in: query
|
||||
description: Specify whether the accessories of the parent OCI index(es) referencing the artifact are included, in the separate inherited_accessories field. Off by default because it costs an extra reference lookup per artifact.
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
- name: latest_in_repository
|
||||
in: query
|
||||
description: Specify whether only the latest pushed artifact of each repository is included inside the returning artifacts. Only works when either artifact_type or media_type is included in the query.
|
||||
@@ -6852,6 +6870,23 @@ definitions:
|
||||
items:
|
||||
$ref: '#/definitions/Accessory'
|
||||
description: The accessory of the artifact.
|
||||
inherited_accessories:
|
||||
type: array
|
||||
# omitted rather than sent as null, so that responses which did not ask for the
|
||||
# inherited accessories stay exactly as they are today
|
||||
x-omitempty: true
|
||||
description: |
|
||||
The signatures of the parent OCI index(es) that reference this artifact, both
|
||||
cosign and notation. A signature on an index covers the whole index, so a child
|
||||
manifest of a signed index is covered by that signature even though it carries
|
||||
none of its own. These entries describe the parent, not this artifact: their
|
||||
subject_artifact_digest is the index digest, so verifying them against this
|
||||
artifact's digest fails and they are not listed by the referrers API for this
|
||||
digest. Only returned when the with_inherited_accessory query parameter is set
|
||||
to true.
|
||||
items:
|
||||
$ref: '#/definitions/Accessory'
|
||||
description: The accessory inherited from the parent OCI index.
|
||||
Tag:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -753,6 +753,9 @@ func (c *controller) assembleArtifact(ctx context.Context, art *artifact.Artifac
|
||||
if option.WithAccessory {
|
||||
c.populateAccessories(ctx, artifact)
|
||||
}
|
||||
if option.WithInheritedAccessory {
|
||||
c.populateInheritedAccessories(ctx, artifact)
|
||||
}
|
||||
return artifact
|
||||
}
|
||||
|
||||
@@ -807,6 +810,75 @@ func (c *controller) populateAccessories(ctx context.Context, art *Artifact) {
|
||||
art.Accessories = accs
|
||||
}
|
||||
|
||||
// populateInheritedAccessories populates the signatures of the parent OCI indexes that
|
||||
// reference the artifact. A signature on an index covers the whole index, so a child
|
||||
// manifest of a signed index is covered by that signature even though it carries none of
|
||||
// its own. Both signing tools Harbor supports behave this way: neither "cosign sign" nor
|
||||
// "notation sign" descends into the children of an index by default. These accessories
|
||||
// describe the parent, not the artifact: their subject digest is the index digest, so
|
||||
// verification against the child digest still fails and Harbor's referrers API still does
|
||||
// not list them. They are exposed separately from Accessories for exactly that reason and
|
||||
// must never be used for copy/delete/walk.
|
||||
//
|
||||
// Artifacts that already carry a signature of their own are skipped, as are artifacts that
|
||||
// no index references.
|
||||
func (c *controller) populateInheritedAccessories(ctx context.Context, art *Artifact) {
|
||||
// resolve the parents first: most artifacts in a registry are referenced by no index at
|
||||
// all, so this single lookup short-circuits the common case before any further query.
|
||||
parents, err := c.artMgr.ListReferences(ctx, &q.Query{
|
||||
Keywords: map[string]any{"ChildID": art.ID},
|
||||
})
|
||||
if err != nil {
|
||||
log.Errorf("failed to list parent references of artifact %d: %v", art.ID, err)
|
||||
return
|
||||
}
|
||||
if len(parents) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// nothing to inherit if the artifact is signed in its own right. Accessories is only
|
||||
// filled in when the caller asked for it, so fall back to a direct lookup when it is not.
|
||||
ownAccs := art.Accessories
|
||||
if ownAccs == nil {
|
||||
ownAccs, err = c.accessoryMgr.List(ctx, q.New(q.KeyWords{"SubjectArtifactID": art.ID}))
|
||||
if err != nil {
|
||||
log.Errorf("failed to list accessories of artifact %d: %v", art.ID, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, acc := range ownAccs {
|
||||
if isSignature(acc) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for _, p := range parents {
|
||||
parentAccs, err := c.accessoryMgr.List(ctx, q.New(q.KeyWords{"SubjectArtifactID": p.ParentID}))
|
||||
if err != nil {
|
||||
log.Errorf("failed to list accessories of parent artifact %d: %v", p.ParentID, err)
|
||||
continue
|
||||
}
|
||||
for _, pAcc := range parentAccs {
|
||||
if isSignature(pAcc) {
|
||||
art.InheritedAccessories = append(art.InheritedAccessories, pAcc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isSignature reports whether the accessory is a signature over its subject, regardless of
|
||||
// the tool that produced it. Only signatures are inherited from a parent index: the other
|
||||
// accessory kinds either describe a single manifest (an SBOM) or are not a claim about it
|
||||
// at all (a nydus accelerator), so they say nothing about the children of an index.
|
||||
func isSignature(acc accessorymodel.Accessory) bool {
|
||||
switch acc.GetData().Type {
|
||||
case accessorymodel.TypeCosignSignature, accessorymodel.TypeNotationSignature:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// HasUnscannableLayer check if it is a in-toto sbom, if it contains any blob with a content_type is application/vnd.in-toto+json, then consider as in-toto sbom
|
||||
func (c *controller) HasUnscannableLayer(ctx context.Context, dgst string) (bool, error) {
|
||||
if len(dgst) == 0 {
|
||||
|
||||
@@ -158,9 +158,199 @@ func (c *controllerTestSuite) TestAssembleArtifact() {
|
||||
c.Contains(artifact.Tags, tg)
|
||||
c.Contains(artifact.Labels, lb)
|
||||
c.Contains(artifact.Accessories, acc)
|
||||
c.Empty(artifact.InheritedAccessories)
|
||||
// the inherited accessories are opt-in, so no parent lookup may happen for this option
|
||||
c.artMgr.AssertNotCalled(c.T(), "ListReferences", mock.Anything, mock.Anything)
|
||||
// TODO check other fields of option
|
||||
}
|
||||
|
||||
// cosignAcc builds a cosign signature accessory whose subject is subDigest
|
||||
func accOfType(id, artID int64, subDigest, accType string) accessorymodel.Accessory {
|
||||
return &basemodel.Default{
|
||||
Data: accessorymodel.AccessoryData{
|
||||
ID: id,
|
||||
ArtifactID: artID,
|
||||
SubArtifactDigest: subDigest,
|
||||
Type: accType,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func cosignAcc(id, artID int64, subDigest string) accessorymodel.Accessory {
|
||||
return accOfType(id, artID, subDigest, accessorymodel.TypeCosignSignature)
|
||||
}
|
||||
|
||||
func notationAcc(id, artID int64, subDigest string) accessorymodel.Accessory {
|
||||
return accOfType(id, artID, subDigest, accessorymodel.TypeNotationSignature)
|
||||
}
|
||||
|
||||
// subjectArtifactID matches an accessory query listing the accessories of the given artifact
|
||||
func subjectArtifactID(id int64) any {
|
||||
return mock.MatchedBy(func(query *q.Query) bool {
|
||||
return query != nil && query.Keywords["SubjectArtifactID"] == id
|
||||
})
|
||||
}
|
||||
|
||||
func (c *controllerTestSuite) TestPopulateInheritedAccessoriesNoParent() {
|
||||
c.artMgr.On("ListReferences", mock.Anything, mock.Anything).Return([]*artifact.Reference{}, nil)
|
||||
|
||||
art := &Artifact{Artifact: artifact.Artifact{ID: 1}}
|
||||
c.ctl.populateInheritedAccessories(nil, art)
|
||||
|
||||
c.Empty(art.InheritedAccessories)
|
||||
// an artifact that no index references must cost nothing beyond the reference lookup
|
||||
c.accMgr.AssertNotCalled(c.T(), "List", mock.Anything, mock.Anything)
|
||||
}
|
||||
|
||||
func (c *controllerTestSuite) TestPopulateInheritedAccessoriesFromParent() {
|
||||
c.artMgr.On("ListReferences", mock.Anything, mock.Anything).Return([]*artifact.Reference{
|
||||
{ParentID: 10, ChildID: 1},
|
||||
}, nil)
|
||||
parentSig := cosignAcc(1, 100, "sha256:parent")
|
||||
c.accMgr.On("List", mock.Anything, subjectArtifactID(int64(10))).
|
||||
Return([]accessorymodel.Accessory{parentSig}, nil)
|
||||
|
||||
// Accessories is set but empty: the artifact carries no signature of its own
|
||||
art := &Artifact{Artifact: artifact.Artifact{ID: 1}, Accessories: []accessorymodel.Accessory{}}
|
||||
c.ctl.populateInheritedAccessories(nil, art)
|
||||
|
||||
c.Require().Len(art.InheritedAccessories, 1)
|
||||
c.Equal(parentSig, art.InheritedAccessories[0])
|
||||
// the inherited entry keeps the parent as its subject, so it stays distinguishable
|
||||
c.Equal("sha256:parent", art.InheritedAccessories[0].GetData().SubArtifactDigest)
|
||||
// and it is never merged into the artifact's own accessories
|
||||
c.Empty(art.Accessories)
|
||||
}
|
||||
|
||||
func (c *controllerTestSuite) TestPopulateInheritedAccessoriesOwnSignature() {
|
||||
c.artMgr.On("ListReferences", mock.Anything, mock.Anything).Return([]*artifact.Reference{
|
||||
{ParentID: 10, ChildID: 1},
|
||||
}, nil)
|
||||
|
||||
// the artifact is signed in its own right, so there is nothing to inherit
|
||||
art := &Artifact{
|
||||
Artifact: artifact.Artifact{ID: 1},
|
||||
Accessories: []accessorymodel.Accessory{cosignAcc(1, 100, "sha256:child")},
|
||||
}
|
||||
c.ctl.populateInheritedAccessories(nil, art)
|
||||
|
||||
c.Empty(art.InheritedAccessories)
|
||||
c.accMgr.AssertNotCalled(c.T(), "List", mock.Anything, subjectArtifactID(int64(10)))
|
||||
}
|
||||
|
||||
func (c *controllerTestSuite) TestPopulateInheritedAccessoriesNotationFromParent() {
|
||||
c.artMgr.On("ListReferences", mock.Anything, mock.Anything).Return([]*artifact.Reference{
|
||||
{ParentID: 10, ChildID: 1},
|
||||
}, nil)
|
||||
parentSig := notationAcc(1, 100, "sha256:parent")
|
||||
c.accMgr.On("List", mock.Anything, subjectArtifactID(int64(10))).
|
||||
Return([]accessorymodel.Accessory{parentSig}, nil)
|
||||
|
||||
// notation signs a single digest just as cosign does, so an index signed with it leaves
|
||||
// its children unsigned in exactly the same way
|
||||
art := &Artifact{Artifact: artifact.Artifact{ID: 1}, Accessories: []accessorymodel.Accessory{}}
|
||||
c.ctl.populateInheritedAccessories(nil, art)
|
||||
|
||||
c.Require().Len(art.InheritedAccessories, 1)
|
||||
c.Equal(parentSig, art.InheritedAccessories[0])
|
||||
}
|
||||
|
||||
func (c *controllerTestSuite) TestPopulateInheritedAccessoriesOwnNotationSignature() {
|
||||
c.artMgr.On("ListReferences", mock.Anything, mock.Anything).Return([]*artifact.Reference{
|
||||
{ParentID: 10, ChildID: 1},
|
||||
}, nil)
|
||||
|
||||
// a signature of either kind makes the artifact signed in its own right: inheriting the
|
||||
// parent's cosign signature on top of it would attach an inherited entry to an artifact
|
||||
// that is directly signed
|
||||
art := &Artifact{
|
||||
Artifact: artifact.Artifact{ID: 1},
|
||||
Accessories: []accessorymodel.Accessory{notationAcc(1, 100, "sha256:child")},
|
||||
}
|
||||
c.ctl.populateInheritedAccessories(nil, art)
|
||||
|
||||
c.Empty(art.InheritedAccessories)
|
||||
c.accMgr.AssertNotCalled(c.T(), "List", mock.Anything, subjectArtifactID(int64(10)))
|
||||
}
|
||||
|
||||
func (c *controllerTestSuite) TestPopulateInheritedAccessoriesNonSignatureParent() {
|
||||
c.artMgr.On("ListReferences", mock.Anything, mock.Anything).Return([]*artifact.Reference{
|
||||
{ParentID: 10, ChildID: 1},
|
||||
}, nil)
|
||||
// an SBOM on the index describes the index alone and says nothing about its children,
|
||||
// so only signatures are inherited
|
||||
c.accMgr.On("List", mock.Anything, subjectArtifactID(int64(10))).
|
||||
Return([]accessorymodel.Accessory{
|
||||
accOfType(1, 100, "sha256:parent", accessorymodel.TypeHarborSBOM),
|
||||
}, nil)
|
||||
|
||||
art := &Artifact{Artifact: artifact.Artifact{ID: 1}, Accessories: []accessorymodel.Accessory{}}
|
||||
c.ctl.populateInheritedAccessories(nil, art)
|
||||
|
||||
c.Empty(art.InheritedAccessories)
|
||||
}
|
||||
|
||||
func (c *controllerTestSuite) TestPopulateInheritedAccessoriesMultipleParents() {
|
||||
c.artMgr.On("ListReferences", mock.Anything, mock.Anything).Return([]*artifact.Reference{
|
||||
{ParentID: 10, ChildID: 1},
|
||||
{ParentID: 20, ChildID: 1},
|
||||
}, nil)
|
||||
c.accMgr.On("List", mock.Anything, subjectArtifactID(int64(10))).
|
||||
Return([]accessorymodel.Accessory{cosignAcc(1, 100, "sha256:parent1")}, nil)
|
||||
c.accMgr.On("List", mock.Anything, subjectArtifactID(int64(20))).
|
||||
Return([]accessorymodel.Accessory{cosignAcc(2, 200, "sha256:parent2")}, nil)
|
||||
|
||||
art := &Artifact{Artifact: artifact.Artifact{ID: 1}, Accessories: []accessorymodel.Accessory{}}
|
||||
c.ctl.populateInheritedAccessories(nil, art)
|
||||
|
||||
// a child can sit in more than one index, and every signing index counts
|
||||
c.Require().Len(art.InheritedAccessories, 2)
|
||||
c.Equal("sha256:parent1", art.InheritedAccessories[0].GetData().SubArtifactDigest)
|
||||
c.Equal("sha256:parent2", art.InheritedAccessories[1].GetData().SubArtifactDigest)
|
||||
}
|
||||
|
||||
func (c *controllerTestSuite) TestPopulateInheritedAccessoriesUnsignedParent() {
|
||||
c.artMgr.On("ListReferences", mock.Anything, mock.Anything).Return([]*artifact.Reference{
|
||||
{ParentID: 10, ChildID: 1},
|
||||
}, nil)
|
||||
c.accMgr.On("List", mock.Anything, subjectArtifactID(int64(10))).
|
||||
Return([]accessorymodel.Accessory{}, nil)
|
||||
|
||||
art := &Artifact{Artifact: artifact.Artifact{ID: 1}, Accessories: []accessorymodel.Accessory{}}
|
||||
c.ctl.populateInheritedAccessories(nil, art)
|
||||
|
||||
c.Empty(art.InheritedAccessories)
|
||||
}
|
||||
|
||||
func (c *controllerTestSuite) TestPopulateInheritedAccessoriesWithoutOwnAccessories() {
|
||||
c.artMgr.On("ListReferences", mock.Anything, mock.Anything).Return([]*artifact.Reference{
|
||||
{ParentID: 10, ChildID: 1},
|
||||
}, nil)
|
||||
// Accessories is nil because the caller did not ask for them, so the artifact's own
|
||||
// accessories have to be resolved before deciding whether anything is inherited
|
||||
c.accMgr.On("List", mock.Anything, subjectArtifactID(int64(1))).
|
||||
Return([]accessorymodel.Accessory{}, nil)
|
||||
c.accMgr.On("List", mock.Anything, subjectArtifactID(int64(10))).
|
||||
Return([]accessorymodel.Accessory{cosignAcc(1, 100, "sha256:parent")}, nil)
|
||||
|
||||
art := &Artifact{Artifact: artifact.Artifact{ID: 1}}
|
||||
c.ctl.populateInheritedAccessories(nil, art)
|
||||
|
||||
c.Require().Len(art.InheritedAccessories, 1)
|
||||
c.accMgr.AssertCalled(c.T(), "List", mock.Anything, subjectArtifactID(int64(1)))
|
||||
}
|
||||
|
||||
func (c *controllerTestSuite) TestPopulateInheritedAccessoriesListReferencesError() {
|
||||
c.artMgr.On("ListReferences", mock.Anything, mock.Anything).
|
||||
Return(nil, errors.New("failed to list references"))
|
||||
|
||||
art := &Artifact{Artifact: artifact.Artifact{ID: 1}}
|
||||
c.ctl.populateInheritedAccessories(nil, art)
|
||||
|
||||
// a failed lookup degrades to "nothing inherited" rather than failing the request
|
||||
c.Empty(art.InheritedAccessories)
|
||||
}
|
||||
|
||||
func (c *controllerTestSuite) TestPopulateIcon() {
|
||||
cases := []struct {
|
||||
art *artifact.Artifact
|
||||
|
||||
@@ -33,6 +33,10 @@ type Artifact struct {
|
||||
AdditionLinks map[string]*AdditionLink `json:"addition_links"` // the resource link for build history(image), values.yaml(chart), dependency(chart), etc
|
||||
Labels []*model.Label `json:"labels"`
|
||||
Accessories []accessoryModel.Accessory `json:"-"`
|
||||
// signatures of the parent OCI index that also cover this artifact, informational only,
|
||||
// never use in copy/delete/walk. Untagged like Accessories: the accessory interface
|
||||
// cannot be unmarshalled directly, the API view is built by the handler instead.
|
||||
InheritedAccessories []accessoryModel.Accessory `json:"-"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON to customize the accessories unmarshal
|
||||
@@ -112,9 +116,14 @@ type AdditionLink struct {
|
||||
|
||||
// Option is used to specify the properties returned when listing/getting artifacts
|
||||
type Option struct {
|
||||
WithTag bool
|
||||
TagOption *tag.Option // only works when WithTag is set to true
|
||||
WithLabel bool
|
||||
WithAccessory bool
|
||||
LatestInRepository bool
|
||||
WithTag bool
|
||||
TagOption *tag.Option // only works when WithTag is set to true
|
||||
WithLabel bool
|
||||
// WithAccessory returns the accessories whose subject is the artifact itself
|
||||
WithAccessory bool
|
||||
// WithInheritedAccessory returns the accessories of the parent OCI index that also
|
||||
// cover the artifact. Opt-in only: it costs an extra reference lookup per artifact,
|
||||
// so it must never be enabled by default or on internal paths such as copy.
|
||||
WithInheritedAccessory bool
|
||||
LatestInRepository bool
|
||||
}
|
||||
|
||||
@@ -31,6 +31,22 @@ func TestUnmarshalJSONWithACC(t *testing.T) {
|
||||
assert.True(t, ok)
|
||||
}
|
||||
|
||||
// InheritedAccessories must stay out of the JSON view of the artifact: the accessory type
|
||||
// is an interface, so a payload carrying that field would fail to unmarshal here. The API
|
||||
// exposes the inherited accessories through the handler's swagger model instead.
|
||||
func TestUnmarshalJSONWithInheritedACC(t *testing.T) {
|
||||
data := []byte(`[{"accessories":[{"artifact_id":9,"type":"signature.cosign"}],
|
||||
"inherited_accessories":[{"artifact_id":10,"subject_artifact_digest":"sha256:parent","type":"signature.cosign"}],
|
||||
"digest":"sha256:e4b315ad03a1d1d9ff0c111e648a1a91066c09ead8352d3d6a48fa971a82922c","type":"IMAGE"}]`)
|
||||
|
||||
var artifact []Artifact
|
||||
err := json.Unmarshal(data, &artifact)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, int64(9), artifact[0].Accessories[0].GetData().ArtifactID)
|
||||
assert.Empty(t, artifact[0].InheritedAccessories)
|
||||
}
|
||||
|
||||
func TestUnmarshalJSONWithACCPartial(t *testing.T) {
|
||||
data := []byte(`[{"accessories":[{"artifact_id":9,"creation_time":"2022-01-20T09:18:50.993Z","digest":"sha256:a7caa2636af890178a0b8c4cdbc47ced4dbdf29a1680e9e50823e85ce35b28d3","icon":"","id":4,"size":501,"subject_artifact_digest":"sha256:a7caa2636af890178a0b8c4cdbc47ced4dbdf29a1680e9e50823e85ce35b28d3","type":"signature.cosign"}, {"artifact_id":2, "type":"signature.cosign"}],
|
||||
"digest":"sha256:e4b315ad03a1d1d9ff0c111e648a1a91066c09ead8352d3d6a48fa971a82922c","tags":[{"artifact_id":8,"id":6,"immutable":false,"name":"latest","pull_time":"2022-01-20T09:18:50.783Z","push_time":"2022-01-20T09:18:50.303Z","repository_id":5,"signed":false}],"type":"IMAGE"}]`)
|
||||
|
||||
@@ -415,6 +415,22 @@
|
||||
shape="check-circle"
|
||||
size="20"
|
||||
class="signed"></clr-icon>
|
||||
} @if (artifact.signed === 'inherited') {
|
||||
<clr-tooltip>
|
||||
<clr-icon
|
||||
clrTooltipTrigger
|
||||
shape="check-circle"
|
||||
size="20"
|
||||
class="signed-inherited"></clr-icon>
|
||||
<clr-tooltip-content
|
||||
clrPosition="top-right"
|
||||
clrSize="lg"
|
||||
*clrIfOpen>
|
||||
<span>{{
|
||||
'ARTIFACT.INHERITED_SIGNATURE' | translate
|
||||
}}</span>
|
||||
</clr-tooltip-content>
|
||||
</clr-tooltip>
|
||||
} @if (artifact.signed === 'false') {
|
||||
<clr-icon
|
||||
shape="times-circle"
|
||||
|
||||
@@ -174,6 +174,14 @@
|
||||
color: #00d40f;
|
||||
}
|
||||
|
||||
// deliberately not the green of a direct signature: the artifact carries no signature of
|
||||
// its own and is only covered by the one on the index that references it. The Clarity
|
||||
// neutral-status token rather than a literal, so the icon tracks the light/dark theme
|
||||
// instead of staying darker than the text beside it in dark mode.
|
||||
.signed-inherited {
|
||||
color: var(--cds-alias-status-neutral);
|
||||
}
|
||||
|
||||
.mb-05 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -92,6 +92,8 @@ export const AVAILABLE_TIME = '0001-01-01T00:00:00.000Z';
|
||||
const CHECKING: string = 'checking';
|
||||
const TRUE: string = 'true';
|
||||
const FALSE: string = 'false';
|
||||
// the artifact carries no signature of its own but the index referencing it is signed
|
||||
const INHERITED: string = 'inherited';
|
||||
|
||||
@Component({
|
||||
selector: 'artifact-list-tab',
|
||||
@@ -413,7 +415,12 @@ export class ArtifactListTabComponent implements OnInit, OnDestroy {
|
||||
withTag: false,
|
||||
XAcceptVulnerabilities:
|
||||
DEFAULT_SUPPORTED_MIME_TYPES,
|
||||
withAccessory: false,
|
||||
// ask for both kinds of accessory here: a child of this
|
||||
// index may be covered by a signature on the index
|
||||
// itself, and having them inline saves a request per
|
||||
// child compared with looking the direct ones up after
|
||||
withAccessory: true,
|
||||
withInheritedAccessory: true,
|
||||
};
|
||||
platFormAttr.push({ platform: child.platform });
|
||||
observableLists.push(
|
||||
@@ -441,7 +448,7 @@ export class ArtifactListTabComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
this.getArtifactTagsAsync(this.artifactList);
|
||||
this.getAccessoriesAsync(this.artifactList);
|
||||
this.checkCosignAndSbomAsync(this.artifactList);
|
||||
this.checkCosignAndSbom(this.artifactList);
|
||||
this.getIconsFromBackEnd();
|
||||
},
|
||||
error => {
|
||||
@@ -1166,6 +1173,42 @@ export class ArtifactListTabComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Children of an index are fetched with their accessories already included, so their
|
||||
// signed state is decided from what is in hand instead of a request per child.
|
||||
checkCosignAndSbom(artifacts: ArtifactFront[]) {
|
||||
artifacts?.forEach(item => {
|
||||
const accessories = item?.accessories ?? [];
|
||||
item.signed = accessories.some(
|
||||
acc =>
|
||||
acc.type === AccessoryType.COSIGN ||
|
||||
acc.type === AccessoryType.NOTATION
|
||||
)
|
||||
? TRUE
|
||||
: this.inheritedSignedState(item);
|
||||
item.sbomDigest = item?.sbom_overview?.sbom_digest;
|
||||
if (!item.sbomDigest) {
|
||||
item.sbomDigest =
|
||||
accessories.filter(
|
||||
acc => acc.type === AccessoryType.SBOM
|
||||
)?.[0]?.digest ?? undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// An artifact with no signature of its own is still covered by a signature on the index
|
||||
// that references it. That is a weaker claim than being signed directly, so it gets its
|
||||
// own state rather than being folded into either "signed" or "not signed".
|
||||
inheritedSignedState(item: ArtifactFront): string {
|
||||
return item?.inherited_accessories?.some(
|
||||
acc =>
|
||||
acc.type === AccessoryType.COSIGN ||
|
||||
acc.type === AccessoryType.NOTATION
|
||||
)
|
||||
? INHERITED
|
||||
: FALSE;
|
||||
}
|
||||
|
||||
// return true if all selected rows are in "running" state
|
||||
canStopScan(): boolean {
|
||||
if (this.onSendingStopScanCommand) {
|
||||
|
||||
@@ -1053,6 +1053,7 @@
|
||||
"COPY_ERROR": "Kopieren fehlgeschlagen, bitte die Befehlsreferenz manuell kopieren."
|
||||
},
|
||||
"ARTIFACT": {
|
||||
"INHERITED_SIGNATURE": "Abgeleitet von der Signatur des übergeordneten Index. Dieses Artefakt ist nicht direkt signiert.",
|
||||
"FILTER_FOR_ARTIFACTS": "Filter Artefakte",
|
||||
"ADDITIONS": "Hinzufügungen",
|
||||
"ANNOTATION": "Annotationen",
|
||||
|
||||
@@ -1053,6 +1053,7 @@
|
||||
"COPY_ERROR": "Copy failed, please try to manually copy the command references."
|
||||
},
|
||||
"ARTIFACT": {
|
||||
"INHERITED_SIGNATURE": "Derived from the signature on the parent index. This artifact is not signed directly.",
|
||||
"FILTER_FOR_ARTIFACTS": "Filter Artifacts",
|
||||
"ADDITIONS": "Additions",
|
||||
"ANNOTATION": "Annotations",
|
||||
|
||||
@@ -1056,6 +1056,7 @@
|
||||
"COPY_ERROR": "Copiar fallido, por favor intenta copiar manualmente las referencias del comando."
|
||||
},
|
||||
"ARTIFACT": {
|
||||
"INHERITED_SIGNATURE": "Derivada de la firma del índice padre. Este artefacto no está firmado directamente.",
|
||||
"FILTER_FOR_ARTIFACTS": "Filtrar Artifacts",
|
||||
"ADDITIONS": "Adiciones",
|
||||
"ANNOTATION": "Anotaciones",
|
||||
|
||||
@@ -1055,6 +1055,7 @@
|
||||
"COPY_ERROR": "Copie échouée, veuillez essayer de copier manuellement les commandes de référence."
|
||||
},
|
||||
"ARTIFACT": {
|
||||
"INHERITED_SIGNATURE": "Dérivée de la signature de l'index parent. Cet artefact n'est pas signé directement.",
|
||||
"FILTER_FOR_ARTIFACTS": "Filtrer les artefacts",
|
||||
"ADDITIONS": "Ajouts",
|
||||
"ANNOTATION": "Annotations",
|
||||
|
||||
@@ -1053,6 +1053,7 @@
|
||||
"COPY_ERROR": "복사에 실패했습니다. 명령 참조를 수동으로 복사해 보세요."
|
||||
},
|
||||
"ARTIFACT": {
|
||||
"INHERITED_SIGNATURE": "상위 인덱스의 서명에서 파생되었습니다. 이 아티팩트는 직접 서명되지 않았습니다.",
|
||||
"FILTER_FOR_ARTIFACTS": "아티팩트 필터",
|
||||
"ADDITIONS": "추가사항",
|
||||
"ANNOTATION": "주석",
|
||||
|
||||
@@ -1056,6 +1056,7 @@
|
||||
"COPY_ERROR": "Cópia falhou, por favor tente copiar o comando de referência manualmente."
|
||||
},
|
||||
"ARTIFACT": {
|
||||
"INHERITED_SIGNATURE": "Derivada da assinatura do índice pai. Este artefato não está assinado diretamente.",
|
||||
"FILTER_FOR_ARTIFACTS": "Filtrar",
|
||||
"ADDITIONS": "Informações Complementares",
|
||||
"ANNOTATION": "Anotações",
|
||||
|
||||
@@ -2291,6 +2291,7 @@
|
||||
"COPY_ERROR": "Копирование не удалось, попробуйте скопировать пример команды вручную."
|
||||
},
|
||||
"ARTIFACT": {
|
||||
"INHERITED_SIGNATURE": "Унаследована от подписи родительского индекса. Этот артефакт не подписан напрямую.",
|
||||
"FILTER_FOR_ARTIFACTS": "Фильтр Артефактов",
|
||||
"ADDITIONS": "Дополнения",
|
||||
"ANNOTATION": "Аннотации",
|
||||
|
||||
@@ -1054,6 +1054,7 @@
|
||||
"COPY_ERROR": "Kopyalama başarısız oldu, lütfen komut referanslarını el ile kopyalamayı deneyin."
|
||||
},
|
||||
"ARTIFACT": {
|
||||
"INHERITED_SIGNATURE": "Üst dizinin imzasından türetilmiştir. Bu yapıt doğrudan imzalanmamış.",
|
||||
"FILTER_FOR_ARTIFACTS": "Filter Artifacts",
|
||||
"ADDITIONS": "Additions",
|
||||
"ANNOTATION": "Annotations",
|
||||
|
||||
@@ -1055,6 +1055,7 @@
|
||||
"COPY_ERROR": "拷贝失败,请尝试手动拷贝参考命令。"
|
||||
},
|
||||
"ARTIFACT": {
|
||||
"INHERITED_SIGNATURE": "派生自父索引的签名。此制品未直接签名。",
|
||||
"FILTER_FOR_ARTIFACTS": "Filter Artifacts",
|
||||
"ADDITIONS": "其他",
|
||||
"ANNOTATION": "注解",
|
||||
|
||||
@@ -1054,6 +1054,7 @@
|
||||
"COPY_ERROR": "複製失敗,請嘗試手動複製指令。"
|
||||
},
|
||||
"ARTIFACT": {
|
||||
"INHERITED_SIGNATURE": "衍生自父索引的簽章。此成品未直接簽署。",
|
||||
"FILTER_FOR_ARTIFACTS": "篩選 Artifact",
|
||||
"ADDITIONS": "附加物",
|
||||
"ANNOTATION": "註解",
|
||||
|
||||
@@ -95,7 +95,7 @@ func (a *artifactAPI) ListArtifacts(ctx context.Context, params operation.ListAr
|
||||
|
||||
// set option
|
||||
option := option(params.WithTag, params.WithImmutableStatus,
|
||||
params.WithLabel, params.WithAccessory, nil)
|
||||
params.WithLabel, params.WithAccessory, nil, params.WithInheritedAccessory)
|
||||
|
||||
// get the total count of artifacts
|
||||
total, err := a.artCtl.Count(ctx, query)
|
||||
@@ -129,7 +129,7 @@ func (a *artifactAPI) GetArtifact(ctx context.Context, params operation.GetArtif
|
||||
}
|
||||
// set option
|
||||
option := option(params.WithTag, params.WithImmutableStatus,
|
||||
params.WithLabel, params.WithAccessory, nil)
|
||||
params.WithLabel, params.WithAccessory, nil, params.WithInheritedAccessory)
|
||||
|
||||
// get the artifact
|
||||
artifact, err := a.artCtl.GetByReference(ctx, fmt.Sprintf("%s/%s", params.ProjectName, params.RepositoryName), params.Reference, option)
|
||||
@@ -505,12 +505,15 @@ func (a *artifactAPI) RequireLabelInProject(ctx context.Context, projectID, labe
|
||||
return nil
|
||||
}
|
||||
|
||||
func option(withTag, withImmutableStatus, withLabel, withAccessory *bool, latestInRepository *bool) *artifact.Option {
|
||||
func option(withTag, withImmutableStatus, withLabel, withAccessory *bool, latestInRepository *bool, withInheritedAccessory *bool) *artifact.Option {
|
||||
option := &artifact.Option{
|
||||
WithTag: true, // return the tag by default
|
||||
WithLabel: lib.BoolValue(withLabel),
|
||||
WithAccessory: true, // return the accessory by default
|
||||
LatestInRepository: lib.BoolValue(latestInRepository),
|
||||
WithTag: true, // return the tag by default
|
||||
WithLabel: lib.BoolValue(withLabel),
|
||||
WithAccessory: true, // return the accessory by default
|
||||
// the inherited accessories cost an extra reference lookup per artifact, so unlike
|
||||
// the accessories they are opt-in rather than returned by default
|
||||
WithInheritedAccessory: lib.BoolValue(withInheritedAccessory),
|
||||
LatestInRepository: lib.BoolValue(latestInRepository),
|
||||
}
|
||||
|
||||
if withTag != nil {
|
||||
|
||||
@@ -18,11 +18,15 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
testifymock "github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"github.com/goharbor/harbor/src/controller/artifact"
|
||||
"github.com/goharbor/harbor/src/controller/project"
|
||||
accessorymodel "github.com/goharbor/harbor/src/pkg/accessory/model"
|
||||
basemodel "github.com/goharbor/harbor/src/pkg/accessory/model/base"
|
||||
pkg_art "github.com/goharbor/harbor/src/pkg/artifact"
|
||||
"github.com/goharbor/harbor/src/pkg/scan/dao/scan"
|
||||
v1 "github.com/goharbor/harbor/src/pkg/scan/rest/v1"
|
||||
"github.com/goharbor/harbor/src/server/v2.0/restapi"
|
||||
@@ -32,6 +36,27 @@ import (
|
||||
htesting "github.com/goharbor/harbor/src/testing/server/v2.0/handler"
|
||||
)
|
||||
|
||||
func TestOption(t *testing.T) {
|
||||
boolPtr := func(b bool) *bool { return &b }
|
||||
|
||||
// the accessories are returned by default, the inherited ones are not: resolving them
|
||||
// costs a reference lookup per artifact, so a caller has to ask for them explicitly
|
||||
opt := option(nil, nil, nil, nil, nil, nil)
|
||||
assert.True(t, opt.WithAccessory)
|
||||
assert.False(t, opt.WithInheritedAccessory)
|
||||
|
||||
opt = option(nil, nil, nil, nil, nil, boolPtr(false))
|
||||
assert.False(t, opt.WithInheritedAccessory)
|
||||
|
||||
opt = option(nil, nil, nil, nil, nil, boolPtr(true))
|
||||
assert.True(t, opt.WithInheritedAccessory)
|
||||
|
||||
// the two flags are independent of each other
|
||||
opt = option(nil, nil, nil, boolPtr(false), nil, boolPtr(true))
|
||||
assert.False(t, opt.WithAccessory)
|
||||
assert.True(t, opt.WithInheritedAccessory)
|
||||
}
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
// with tag
|
||||
input := "library/hello-world:latest"
|
||||
@@ -190,6 +215,80 @@ func (suite *ArtifactTestSuite) TestGetVulnerabilitiesAddition() {
|
||||
}
|
||||
}
|
||||
|
||||
// A child manifest of a signed index is covered by the signature on that index, but carries
|
||||
// no accessory of its own. The inherited accessories are reported in their own field so that
|
||||
// the accessories field keeps describing only what is really attached to this digest.
|
||||
func (suite *ArtifactTestSuite) TestGetArtifactInheritedAccessories() {
|
||||
times := 2
|
||||
suite.Security.On("IsAuthenticated").Return(true).Times(times)
|
||||
suite.Security.On("IsSysAdmin").Return(true).Times(times)
|
||||
mock.OnAnything(suite.Security, "Can").Return(true).Times(times)
|
||||
|
||||
child := "sha256:9572f7cdcee8591948c2963463447a53466950b3fc15a247fcad1917ca215a2f"
|
||||
url := "/projects/library/repositories/photon/artifacts/" + child
|
||||
|
||||
// the artifact as the controller returns it when the inherited accessories were asked for
|
||||
inherited := &artifact.Artifact{
|
||||
Artifact: pkg_art.Artifact{ID: 1, Digest: child},
|
||||
InheritedAccessories: []accessorymodel.Accessory{
|
||||
&basemodel.Default{
|
||||
Data: accessorymodel.AccessoryData{
|
||||
ID: 1,
|
||||
ArtifactID: 2,
|
||||
SubArtifactDigest: "sha256:parent",
|
||||
Type: accessorymodel.TypeCosignSignature,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
{
|
||||
// default request: the option is off and the response is shaped exactly as before
|
||||
var opt *artifact.Option
|
||||
suite.artCtl.On("GetByReference", mock.Anything, mock.Anything, mock.Anything,
|
||||
testifymock.MatchedBy(func(o *artifact.Option) bool { opt = o; return true })).
|
||||
Return(&artifact.Artifact{Artifact: pkg_art.Artifact{ID: 1, Digest: child}}, nil).Once()
|
||||
|
||||
var body map[string]any
|
||||
res, err := suite.GetJSON(url, &body)
|
||||
suite.NoError(err)
|
||||
suite.Equal(200, res.StatusCode)
|
||||
|
||||
suite.Require().NotNil(opt)
|
||||
suite.False(opt.WithInheritedAccessory, "the parent lookup must not run unless asked for")
|
||||
suite.NotContains(body, "inherited_accessories", "the default response must not grow a new field")
|
||||
}
|
||||
|
||||
{
|
||||
// opted in: the inherited signature is reported, and only in its own field
|
||||
var opt *artifact.Option
|
||||
suite.artCtl.On("GetByReference", mock.Anything, mock.Anything, mock.Anything,
|
||||
testifymock.MatchedBy(func(o *artifact.Option) bool { opt = o; return true })).
|
||||
Return(inherited, nil).Once()
|
||||
|
||||
var body map[string]any
|
||||
res, err := suite.GetJSON(url+"?with_inherited_accessory=true", &body)
|
||||
suite.NoError(err)
|
||||
suite.Equal(200, res.StatusCode)
|
||||
|
||||
suite.Require().NotNil(opt)
|
||||
suite.True(opt.WithInheritedAccessory)
|
||||
|
||||
// the artifact's own accessory list stays empty: it really is unsigned
|
||||
suite.Empty(body["accessories"])
|
||||
|
||||
accs, ok := body["inherited_accessories"].([]any)
|
||||
suite.Require().True(ok, "inherited_accessories must be present when opted in")
|
||||
suite.Require().Len(accs, 1)
|
||||
acc := accs[0].(map[string]any)
|
||||
suite.Equal("signature.cosign", acc["type"])
|
||||
// the subject is the parent index, which is what makes the entry self-describing:
|
||||
// a consumer can see this signature does not verify against the requested digest
|
||||
suite.Equal("sha256:parent", acc["subject_artifact_digest"])
|
||||
suite.NotEqual(child, acc["subject_artifact_digest"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactTestSuite(t *testing.T) {
|
||||
suite.Run(t, &ArtifactTestSuite{})
|
||||
}
|
||||
|
||||
@@ -59,6 +59,9 @@ func (a *Artifact) ToSwagger() *models.Artifact {
|
||||
for _, acc := range a.Accessories {
|
||||
art.Accessories = append(art.Accessories, NewAccessory(acc.GetData()).ToSwagger())
|
||||
}
|
||||
for _, acc := range a.InheritedAccessories {
|
||||
art.InheritedAccessories = append(art.InheritedAccessories, NewAccessory(acc.GetData()).ToSwagger())
|
||||
}
|
||||
for _, tag := range a.Tags {
|
||||
art.Tags = append(art.Tags, NewTag(tag).ToSwagger())
|
||||
}
|
||||
|
||||
81
src/server/v2.0/handler/model/artifact_test.go
Normal file
81
src/server/v2.0/handler/model/artifact_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
// Copyright Project Harbor 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 model
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/goharbor/harbor/src/controller/artifact"
|
||||
accessorymodel "github.com/goharbor/harbor/src/pkg/accessory/model"
|
||||
basemodel "github.com/goharbor/harbor/src/pkg/accessory/model/base"
|
||||
pkg_art "github.com/goharbor/harbor/src/pkg/artifact"
|
||||
)
|
||||
|
||||
func cosignAccessory(id int64, subDigest string) accessorymodel.Accessory {
|
||||
return &basemodel.Default{
|
||||
Data: accessorymodel.AccessoryData{
|
||||
ID: id,
|
||||
ArtifactID: id + 100,
|
||||
SubArtifactDigest: subDigest,
|
||||
Type: accessorymodel.TypeCosignSignature,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactToSwaggerAccessories(t *testing.T) {
|
||||
// an artifact with no accessory at all: both lists stay absent from the response
|
||||
art := &Artifact{Artifact: artifact.Artifact{Artifact: pkg_art.Artifact{ID: 1, Digest: "sha256:child"}}}
|
||||
swag := art.ToSwagger()
|
||||
assert.Empty(t, swag.Accessories)
|
||||
assert.Empty(t, swag.InheritedAccessories)
|
||||
|
||||
// the two kinds of accessory are reported side by side and never merged, so a consumer
|
||||
// reading "accessories" keeps getting only what is really attached to this digest
|
||||
art = &Artifact{
|
||||
Artifact: artifact.Artifact{
|
||||
Artifact: pkg_art.Artifact{ID: 1, Digest: "sha256:child"},
|
||||
Accessories: []accessorymodel.Accessory{cosignAccessory(1, "sha256:child")},
|
||||
InheritedAccessories: []accessorymodel.Accessory{cosignAccessory(2, "sha256:parent")},
|
||||
},
|
||||
}
|
||||
swag = art.ToSwagger()
|
||||
|
||||
require.Len(t, swag.Accessories, 1)
|
||||
assert.Equal(t, "sha256:child", swag.Accessories[0].SubjectArtifactDigest)
|
||||
|
||||
require.Len(t, swag.InheritedAccessories, 1)
|
||||
// the inherited entry carries the parent index as its subject, which is what makes it
|
||||
// self-describing: it is not verifiable against this artifact's digest
|
||||
assert.Equal(t, "sha256:parent", swag.InheritedAccessories[0].SubjectArtifactDigest)
|
||||
assert.Equal(t, accessorymodel.TypeCosignSignature, swag.InheritedAccessories[0].Type)
|
||||
}
|
||||
|
||||
func TestArtifactToSwaggerInheritedOnly(t *testing.T) {
|
||||
// a child of a signed index: nothing of its own, one inherited signature
|
||||
art := &Artifact{
|
||||
Artifact: artifact.Artifact{
|
||||
Artifact: pkg_art.Artifact{ID: 1, Digest: "sha256:child"},
|
||||
Accessories: []accessorymodel.Accessory{},
|
||||
InheritedAccessories: []accessorymodel.Accessory{cosignAccessory(2, "sha256:parent")},
|
||||
},
|
||||
}
|
||||
swag := art.ToSwagger()
|
||||
|
||||
assert.Empty(t, swag.Accessories)
|
||||
require.Len(t, swag.InheritedAccessories, 1)
|
||||
}
|
||||
@@ -715,7 +715,7 @@ func (a *projectAPI) ListArtifactsOfProject(ctx context.Context, params operatio
|
||||
|
||||
// set option
|
||||
option := option(params.WithTag, params.WithImmutableStatus,
|
||||
params.WithLabel, params.WithAccessory, params.LatestInRepository)
|
||||
params.WithLabel, params.WithAccessory, params.LatestInRepository, params.WithInheritedAccessory)
|
||||
|
||||
var total int64
|
||||
// list artifacts according to the query and option
|
||||
|
||||
Reference in New Issue
Block a user