4040
4141log = logging .getLogger (__name__ )
4242
43+ COSIGN_TAG_SUFFIXES = (".sig" , ".att" , ".sbom" )
44+
4345
4446class ContainerFirstStage (Stage ):
4547 """
@@ -60,6 +62,9 @@ def __init__(self, remote, signed_only):
6062 self .manifest_list_dcs = []
6163 self .manifest_dcs = []
6264 self .signature_dcs = []
65+ self ._synced_digests = set ()
66+ self ._full_tag_list = []
67+ self ._cosign_tags = []
6368
6469 async def _download_manifest_data (self , manifest_url ):
6570 downloader = self .remote .get_downloader (url = manifest_url )
@@ -92,24 +97,54 @@ async def run(self):
9297 """
9398 ContainerFirstStage.
9499 """
95-
96- to_download = []
97- BATCH_SIZE = 500
98-
99- # it can be whether a separate sigstore location or registry with extended signatures API
100100 signature_source = await self .get_signature_source ()
101101
102102 async with ProgressReport (
103103 message = "Downloading tag list" , code = "sync.downloading.tag_list" , total = 1
104104 ) as pb :
105105 repo_name = self .remote .namespaced_upstream_name
106106 tag_list_url = "/v2/{name}/tags/list" .format (name = repo_name )
107- tag_list = await self .get_paginated_tag_list (tag_list_url , repo_name )
108- tag_list = filter_resources (
109- tag_list , self .remote . include_tags , self .remote .exclude_tags
107+ self . _full_tag_list = await self .get_paginated_tag_list (tag_list_url , repo_name )
108+ self . _cosign_tags = filter_resources (
109+ self ._full_tag_list , [ "sha256-*" ] , self .remote .exclude_tags
110110 )
111+ if self .remote .include_tags or self .remote .exclude_tags :
112+ exclude_tags_and_cosign = (self .remote .exclude_tags or []) + ["sha256-*" ]
113+ tag_list = filter_resources (
114+ self ._full_tag_list , self .remote .include_tags , exclude_tags_and_cosign
115+ )
116+ else :
117+ tag_list = self ._full_tag_list
111118 await pb .aincrement ()
112119
120+ await self ._process_tags (tag_list , signature_source )
121+
122+ if self .remote .include_tags or self .remote .exclude_tags :
123+ companion_tags = self ._find_cosign_companion_tags ()
124+ if companion_tags :
125+ log .info (
126+ "Syncing %d cosign companion tag(s) for filtered images" ,
127+ len (companion_tags ),
128+ )
129+ await self ._process_tags (
130+ companion_tags , signature_source , msg = "Processing Cosign Companion Tags"
131+ )
132+
133+ def _find_cosign_companion_tags (self ):
134+ """Find cosign companion tags for synced digests."""
135+ companion_tags = []
136+ for tag in self ._cosign_tags :
137+ tag_without_suffix = tag .rsplit ("." , 1 )[0 ]
138+ digest = tag_without_suffix .replace ("-" , ":" , 1 )
139+ if digest in self ._synced_digests :
140+ companion_tags .append (tag )
141+ return companion_tags
142+
143+ async def _process_tags (self , tag_list , signature_source , msg = "Processing Tags" ):
144+ """Download and process a batch of tags, creating declarative content objects."""
145+ BATCH_SIZE = 500
146+ to_download = []
147+
113148 for tag_name in tag_list :
114149 relative_url = "/v2/{name}/manifests/{tag}" .format (
115150 name = self .remote .namespaced_upstream_name , tag = tag_name
@@ -121,7 +156,7 @@ async def run(self):
121156 )
122157
123158 async with ProgressReport (
124- message = "Processing Tags" ,
159+ message = msg ,
125160 code = "sync.processing.tag" ,
126161 total = len (tag_list ),
127162 ) as pb_parsed_tags :
@@ -135,25 +170,21 @@ async def run(self):
135170
136171 digest = calculate_digest (raw_text_data )
137172 tag_name = response .url .split ("/" )[- 1 ]
173+ media_type = determine_media_type (content_data , response )
138174
139- # Look for cosign signatures
140- # cosign signature has a tag convention 'sha256-1234.sig'
141175 if self .signed_only and not signature_source :
142- if (
143- not ( tag_name . endswith ( ".sig" ) and tag_name . startswith ( "sha256-" ) )
144- and f"sha256- { digest . removeprefix ( 'sha256:' ) } .sig" not in tag_list
176+ if not (
177+ self . _is_cosign_companion_tag ( tag_name , media_type , content_data )
178+ or await self . _has_cosign_signature ( digest )
145179 ):
146- # skip this tag, there is no corresponding signature
147180 log .info (
148181 "The unsigned image {digest} can't be synced "
149182 "due to a requirement to sync signed content "
150183 "only." .format (digest = digest )
151184 )
152- # Count the skipped tagks as parsed too.
153185 await pb_parsed_tags .aincrement ()
154186 continue
155187
156- media_type = determine_media_type (content_data , response )
157188 validate_manifest (content_data , media_type , digest )
158189
159190 tag_dc = DeclarativeContent (Tag (name = tag_name ))
@@ -183,23 +214,21 @@ async def run(self):
183214 tag = tag_name ,
184215 )
185216 )
186- # do not pass down the pipeline a manifest list with unsigned
187- # manifests.
188217 break
189218 self .signature_dcs .extend (man_sig_dcs )
190219 list_dc .extra_data ["listed_manifests" ].append (listed_manifest )
191220
192221 else :
193222 # Manifest indices can be signed too. It is not mandatory.
194223 # If signature is available mirror it.
224+ self ._synced_digests .add (digest )
195225 if signature_source is not None :
196226 list_sig_dcs = await self .create_signatures (list_dc , signature_source )
197227 if list_sig_dcs :
198228 self .signature_dcs .extend (list_sig_dcs )
199- # only pass the manifest list and tag down the pipeline if there were no
200- # issues with signatures (no `break` in the `for` loop)
201229 tag_dc .extra_data ["tagged_manifest_dc" ] = list_dc
202230 for listed_manifest in list_dc .extra_data ["listed_manifests" ]:
231+ self ._synced_digests .add (listed_manifest ["manifest_dc" ].content .digest )
203232 await self .handle_blobs (
204233 listed_manifest ["manifest_dc" ], listed_manifest ["content_data" ]
205234 )
@@ -215,9 +244,9 @@ async def run(self):
215244 if signature_source is not None :
216245 man_sig_dcs = await self .create_signatures (man_dc , signature_source )
217246 if self .signed_only and not man_sig_dcs :
218- # do not pass down the pipeline unsigned manifests
219247 continue
220248 self .signature_dcs .extend (man_sig_dcs )
249+ self ._synced_digests .add (digest )
221250 tag_dc .extra_data ["tagged_manifest_dc" ] = man_dc
222251 await self .handle_blobs (man_dc , content_data )
223252 self .tag_dcs .append (tag_dc )
@@ -239,6 +268,35 @@ async def run(self):
239268
240269 await self .resolve_flush ()
241270
271+ async def _has_cosign_signature (self , digest ):
272+ """Check if a digest has a cosign signature."""
273+ cosign_digest = digest .replace ("sha256:" , "sha256-" )
274+ if f"{ cosign_digest } .sig" in self .cosign_tags :
275+ return True
276+ if cosign_digest in self .cosign_tags :
277+ # Potential V3 cosign tag needs to be checked if it is a cosign companion tag
278+ relative_url = f"/v2/{ self .remote .namespaced_upstream_name } /manifests/{ cosign_digest } "
279+ tag_url = urljoin (self .remote .url , relative_url )
280+ content_data , raw_text_data , response = await self ._download_manifest_data (tag_url )
281+ media_type = determine_media_type (content_data , response )
282+ if self ._is_cosign_companion_tag (cosign_digest , media_type , content_data ):
283+ return True
284+ return False
285+
286+ def _is_cosign_companion_tag (self , tag_name , media_type , content_data ):
287+ """Check if a fetched tag is a cosign companion tag."""
288+ if tag_name .startswith ("sha256-" ):
289+ if len (tag_name ) == 71 :
290+ # V3 cosign companion tags are index lists with each entry having an artifactType
291+ if media_type == MEDIA_TYPE .INDEX_OCI :
292+ if manifests := content_data .get ("manifests" , []):
293+ if all (entry .has ("artifactType" ) for entry in manifests ):
294+ return True
295+ elif any (tag_name .endswith (s ) for s in COSIGN_TAG_SUFFIXES ):
296+ # V2 cosign companion tags are in the format sha256-<digest>.<suffix>
297+ return True
298+ return False
299+
242300 async def get_signature_source (self ):
243301 """
244302 Find out where signatures come from: sigstore, extension API or not available at all.
0 commit comments