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,57 @@ 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+ # Split sync into two parts, first all non-cosign tags, then cosign tags
113+ exclude_tags_and_cosign = (self .remote .exclude_tags or []) + ["sha256-*" ]
114+ tag_list = filter_resources (
115+ self ._full_tag_list , self .remote .include_tags , exclude_tags_and_cosign
116+ )
117+ else :
118+ tag_list = self ._full_tag_list
111119 await pb .aincrement ()
112120
121+ await self ._process_tags (tag_list , signature_source )
122+
123+ if self .remote .include_tags or self .remote .exclude_tags :
124+ # Process cosign companion tags after all non-cosign tags are synced
125+ companion_tags = self ._find_cosign_companion_tags ()
126+ if companion_tags :
127+ log .info (
128+ "Syncing %d cosign companion tag(s) for filtered images" ,
129+ len (companion_tags ),
130+ )
131+ await self ._process_tags (
132+ companion_tags , signature_source , msg = "Processing Cosign Companion Tags"
133+ )
134+
135+ def _find_cosign_companion_tags (self ):
136+ """Find cosign companion tags for synced digests."""
137+ companion_tags = []
138+ for tag in self ._cosign_tags :
139+ # Convert sha256-<digest>[.sig|.att|.sbom] to sha256:<digest>
140+ tag_without_suffix = tag .rsplit ("." , 1 )[0 ]
141+ digest = tag_without_suffix .replace ("-" , ":" , 1 )
142+ if digest in self ._synced_digests :
143+ companion_tags .append (tag )
144+ return companion_tags
145+
146+ async def _process_tags (self , tag_list , signature_source , msg = "Processing Tags" ):
147+ """Download and process a batch of tags, creating declarative content objects."""
148+ BATCH_SIZE = 500
149+ to_download = []
150+
113151 for tag_name in tag_list :
114152 relative_url = "/v2/{name}/manifests/{tag}" .format (
115153 name = self .remote .namespaced_upstream_name , tag = tag_name
@@ -121,7 +159,7 @@ async def run(self):
121159 )
122160
123161 async with ProgressReport (
124- message = "Processing Tags" ,
162+ message = msg ,
125163 code = "sync.processing.tag" ,
126164 total = len (tag_list ),
127165 ) as pb_parsed_tags :
@@ -135,25 +173,21 @@ async def run(self):
135173
136174 digest = calculate_digest (raw_text_data )
137175 tag_name = response .url .split ("/" )[- 1 ]
176+ media_type = determine_media_type (content_data , response )
138177
139- # Look for cosign signatures
140- # cosign signature has a tag convention 'sha256-1234.sig'
141178 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
179+ if not (
180+ self . _is_cosign_companion_tag ( tag_name , media_type , content_data )
181+ or await self . _has_cosign_signature ( digest )
145182 ):
146- # skip this tag, there is no corresponding signature
147183 log .info (
148184 "The unsigned image {digest} can't be synced "
149185 "due to a requirement to sync signed content "
150186 "only." .format (digest = digest )
151187 )
152- # Count the skipped tagks as parsed too.
153188 await pb_parsed_tags .aincrement ()
154189 continue
155190
156- media_type = determine_media_type (content_data , response )
157191 validate_manifest (content_data , media_type , digest )
158192
159193 tag_dc = DeclarativeContent (Tag (name = tag_name ))
@@ -183,23 +217,21 @@ async def run(self):
183217 tag = tag_name ,
184218 )
185219 )
186- # do not pass down the pipeline a manifest list with unsigned
187- # manifests.
188220 break
189221 self .signature_dcs .extend (man_sig_dcs )
190222 list_dc .extra_data ["listed_manifests" ].append (listed_manifest )
191223
192224 else :
193225 # Manifest indices can be signed too. It is not mandatory.
194226 # If signature is available mirror it.
227+ self ._synced_digests .add (digest )
195228 if signature_source is not None :
196229 list_sig_dcs = await self .create_signatures (list_dc , signature_source )
197230 if list_sig_dcs :
198231 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)
201232 tag_dc .extra_data ["tagged_manifest_dc" ] = list_dc
202233 for listed_manifest in list_dc .extra_data ["listed_manifests" ]:
234+ self ._synced_digests .add (listed_manifest ["manifest_dc" ].content .digest )
203235 await self .handle_blobs (
204236 listed_manifest ["manifest_dc" ], listed_manifest ["content_data" ]
205237 )
@@ -215,9 +247,9 @@ async def run(self):
215247 if signature_source is not None :
216248 man_sig_dcs = await self .create_signatures (man_dc , signature_source )
217249 if self .signed_only and not man_sig_dcs :
218- # do not pass down the pipeline unsigned manifests
219250 continue
220251 self .signature_dcs .extend (man_sig_dcs )
252+ self ._synced_digests .add (digest )
221253 tag_dc .extra_data ["tagged_manifest_dc" ] = man_dc
222254 await self .handle_blobs (man_dc , content_data )
223255 self .tag_dcs .append (tag_dc )
@@ -239,6 +271,35 @@ async def run(self):
239271
240272 await self .resolve_flush ()
241273
274+ async def _has_cosign_signature (self , digest ):
275+ """Check if a digest has a cosign signature."""
276+ cosign_digest = digest .replace ("sha256:" , "sha256-" )
277+ if f"{ cosign_digest } .sig" in self ._cosign_tags :
278+ return True
279+ if cosign_digest in self ._cosign_tags :
280+ # Potential V3 cosign tag needs to be checked if it is a cosign companion tag
281+ relative_url = f"/v2/{ self .remote .namespaced_upstream_name } /manifests/{ cosign_digest } "
282+ tag_url = urljoin (self .remote .url , relative_url )
283+ content_data , raw_text_data , response = await self ._download_manifest_data (tag_url )
284+ media_type = determine_media_type (content_data , response )
285+ if self ._is_cosign_companion_tag (cosign_digest , media_type , content_data ):
286+ return True
287+ return False
288+
289+ def _is_cosign_companion_tag (self , tag_name , media_type , content_data ):
290+ """Check if a fetched tag is a cosign companion tag."""
291+ if tag_name .startswith ("sha256-" ):
292+ if len (tag_name ) == 71 :
293+ # V3 cosign companion tags are index lists with each entry having an artifactType
294+ if media_type == MEDIA_TYPE .INDEX_OCI :
295+ if manifests := content_data .get ("manifests" , []):
296+ if all ("artifactType" in entry for entry in manifests ):
297+ return True
298+ elif any (tag_name .endswith (s ) for s in COSIGN_TAG_SUFFIXES ):
299+ # V2 cosign companion tags are in the format sha256-<digest>.<suffix>
300+ return True
301+ return False
302+
242303 async def get_signature_source (self ):
243304 """
244305 Find out where signatures come from: sigstore, extension API or not available at all.
0 commit comments