11import os
22import time
3+ import zipfile
34from platform import platform
45from typing import TYPE_CHECKING , Callable , Optional
56
2324from cycode .cli .files_collector .sca .sca_file_collector import add_sca_dependencies_tree_documents_if_needed
2425from cycode .cli .files_collector .zip_documents import zip_documents
2526from cycode .cli .models import CliError , Document , LocalScanResult
27+ from cycode .cli .utils .host_info import is_64bit
2628from cycode .cli .utils .path_utils import get_absolute_path , get_path_by_os
2729from cycode .cli .utils .progress_bar import ScanProgressBarSection
2830from cycode .cli .utils .scan_batch import run_parallel_batched_scan
@@ -145,6 +147,7 @@ def _get_scan_documents_thread_func(
145147 is_git_diff : bool ,
146148 is_commit_range : bool ,
147149 scan_parameters : dict ,
150+ prezipped : Optional ['InMemoryZip' ] = None ,
148151) -> Callable [[list [Document ]], tuple [str , CliError , LocalScanResult ]]:
149152 cycode_client = ctx .obj ['client' ]
150153 scan_type = ctx .obj ['scan_type' ]
@@ -164,9 +167,14 @@ def _scan_batch_thread_func(batch: list[Document]) -> tuple[str, CliError, Local
164167
165168 should_use_sync_flow = _should_use_sync_flow (command_scan_type , scan_type , sync_option )
166169
170+ # the single ZIP flow already built the archive to check that it fits; don't build it twice
171+ zipped_documents = prezipped
172+
167173 try :
168- logger .debug ('Preparing local files, %s' , {'batch_files_count' : len (batch )})
169- zipped_documents = zip_documents (scan_type , batch )
174+ if zipped_documents is None :
175+ logger .debug ('Preparing local files, %s' , {'batch_files_count' : len (batch )})
176+ zipped_documents = zip_documents (scan_type , batch )
177+
170178 zip_file_size = zipped_documents .size
171179 scan_result = _perform_scan (
172180 cycode_client ,
@@ -189,6 +197,9 @@ def _scan_batch_thread_func(batch: list[Document]) -> tuple[str, CliError, Local
189197 except Exception as e :
190198 error = handle_scan_exception (ctx , e , return_exception = True )
191199 error_message = str (e )
200+ finally :
201+ if zipped_documents is not None :
202+ zipped_documents .cleanup ()
192203
193204 if local_scan_result :
194205 detections_count = local_scan_result .detections_count
@@ -225,34 +236,77 @@ def _scan_batch_thread_func(batch: list[Document]) -> tuple[str, CliError, Local
225236 return _scan_batch_thread_func
226237
227238
239+ def _log_selected_upload_mode (mode : str , reason : str , documents_count : int ) -> None :
240+ logger .debug (
241+ 'Selected upload mode, %s' ,
242+ {
243+ 'mode' : mode ,
244+ 'reason' : reason ,
245+ 'documents_count' : documents_count ,
246+ 'max_files_count' : consts .ZIP_MAX_FILES_COUNT ,
247+ 'zip64_enabled' : is_64bit (),
248+ },
249+ )
250+
251+
252+ def _exceeds_non_zip64_files_count (documents_to_scan : list [Document ]) -> bool :
253+ """Whether a single ZIP can't hold all the documents because ZIP64 is unavailable.
254+
255+ Without ZIP64 (32-bit interpreter) the archive is capped at 65,535 entries.
256+ """
257+ return not is_64bit () and len (documents_to_scan ) > consts .ZIP_MAX_FILES_COUNT
258+
259+
228260def _run_presigned_upload_scan (
229- scan_batch_thread_func : Callable ,
230- scan_type : str ,
261+ ctx : typer .Context ,
262+ is_git_diff : bool ,
263+ is_commit_range : bool ,
264+ scan_parameters : dict ,
231265 documents_to_scan : list [Document ],
232266 progress_bar : 'BaseProgressBar' ,
233267 printer : 'ConsolePrinter' ,
234268) -> tuple :
235- try :
236- # Try to zip all documents as a single batch; ZipTooLargeError raised if it exceeds the scan type's limit
237- zip_documents ( scan_type , documents_to_scan )
238- # It fits: skip batching and upload everything as one ZIP
269+ scan_type = ctx . obj [ 'scan_type' ]
270+ documents_count = len ( documents_to_scan )
271+
272+ def run_batched () -> tuple :
239273 return run_parallel_batched_scan (
240- scan_batch_thread_func ,
274+ _get_scan_documents_thread_func ( ctx , is_git_diff , is_commit_range , scan_parameters ) ,
241275 scan_type ,
242276 documents_to_scan ,
243277 progress_bar = progress_bar ,
244- skip_batching = True ,
245278 )
246- except custom_exceptions .ZipTooLargeError :
279+
280+ if _exceeds_non_zip64_files_count (documents_to_scan ):
281+ # Don't waste time zipping documents we already know won't fit into a single ZIP
282+ _log_selected_upload_mode ('batched' , 'files_count_exceeds_non_zip64_limit' , documents_count )
283+ return run_batched ()
284+
285+ zipped_documents = None
286+ try :
287+ # Try to zip all documents as a single batch; ZipTooLargeError raised if it exceeds the scan type's limit
288+ zipped_documents = zip_documents (scan_type , documents_to_scan )
289+ except (custom_exceptions .ZipTooLargeError , zipfile .LargeZipFile ):
290+ # LargeZipFile is a safety net: the files count pre-check above should have caught it already
291+ _log_selected_upload_mode ('batched' , 'zip_too_large' , documents_count )
292+ if zipped_documents is not None :
293+ zipped_documents .cleanup ()
294+
247295 printer .print_warning (
248296 'The scan is too large to upload as a single file. This may result in corrupted scan results.'
249297 )
250- return run_parallel_batched_scan (
251- scan_batch_thread_func ,
252- scan_type ,
253- documents_to_scan ,
254- progress_bar = progress_bar ,
255- )
298+ return run_batched ()
299+
300+ # It fits: skip batching and upload everything as one ZIP. The archive we just built is the one
301+ # that gets uploaded, so the scan doesn't pay for compressing every document twice
302+ _log_selected_upload_mode ('single_zip' , 'fits_single_zip' , documents_count )
303+ return run_parallel_batched_scan (
304+ _get_scan_documents_thread_func (ctx , is_git_diff , is_commit_range , scan_parameters , zipped_documents ),
305+ scan_type ,
306+ documents_to_scan ,
307+ progress_bar = progress_bar ,
308+ skip_batching = True ,
309+ )
256310
257311
258312def scan_documents (
@@ -277,18 +331,19 @@ def scan_documents(
277331 )
278332 return
279333
280- scan_batch_thread_func = _get_scan_documents_thread_func (ctx , is_git_diff , is_commit_range , scan_parameters )
281-
282334 # Presigned single-file upload is async-only; a --sync scan must stay on the batched inline path
283335 # so it never builds one oversized zip to POST synchronously.
284336 should_use_sync_flow = _should_use_sync_flow (ctx .info_name , scan_type , ctx .obj ['sync' ])
285337 if should_use_presigned_upload (scan_type ) and not should_use_sync_flow :
286338 errors , local_scan_results = _run_presigned_upload_scan (
287- scan_batch_thread_func , scan_type , documents_to_scan , progress_bar , printer
339+ ctx , is_git_diff , is_commit_range , scan_parameters , documents_to_scan , progress_bar , printer
288340 )
289341 else :
290342 errors , local_scan_results = run_parallel_batched_scan (
291- scan_batch_thread_func , scan_type , documents_to_scan , progress_bar = progress_bar
343+ _get_scan_documents_thread_func (ctx , is_git_diff , is_commit_range , scan_parameters ),
344+ scan_type ,
345+ documents_to_scan ,
346+ progress_bar = progress_bar ,
292347 )
293348
294349 try_set_aggregation_report_url_if_needed (ctx , scan_parameters , ctx .obj ['client' ], scan_type )
0 commit comments