4646import tarfile
4747import tempfile
4848import time
49+ import urllib .parse
50+ import urllib .request
4951from collections import OrderedDict
5052from collections import namedtuple
5153from json import JSONEncoder
@@ -99,7 +101,7 @@ class WindowsError(OSError): # type: ignore
99101VERSION_REGEX_REPLACE_DEFAULT = r'\1'
100102IDF_MAINTAINER = os .environ .get ('IDF_MAINTAINER' ) or False
101103TODO_MESSAGE = 'TODO'
102- DOWNLOAD_RETRY_COUNT = 3
104+ DOWNLOAD_RETRY_COUNT = 5
103105URL_PREFIX_MAP_SEPARATOR = ','
104106IDF_TOOLS_INSTALL_CMD = os .environ .get ('IDF_TOOLS_INSTALL_CMD' )
105107IDF_TOOLS_EXPORT_CMD = os .environ .get ('IDF_TOOLS_INSTALL_CMD' )
@@ -460,6 +462,126 @@ def parse_platform_arg(platform_str: str) -> str:
460462DL_CERT_DICT = {'dl.espressif.com' : DIGICERT_ROOT_G2_CERT , 'github.com' : DIGICERT_ROOT_CA_CERT }
461463
462464
465+ def create_esp_idf_ssl_context (url : str ) -> ssl .SSLContext :
466+ """
467+ Creates ESP-IDF optimized SSL context with OpenSSL version detection.
468+
469+ This function detects the SSL backend (LibreSSL vs OpenSSL) and creates
470+ an appropriate SSL context with version-specific optimizations. It also
471+ handles custom DigiCert certificates for known domains.
472+
473+ Args:
474+ url: The URL to create SSL context for
475+
476+ Returns:
477+ Configured SSL context optimized for the detected backend
478+ """
479+ ssl_version = ssl .OPENSSL_VERSION
480+ ssl_version_info = ssl .OPENSSL_VERSION_INFO
481+
482+ info (f"SSL Backend: { ssl_version } ({ ssl_version_info } )" )
483+
484+ # Create context based on detected SSL backend version
485+ if "LibreSSL" in ssl_version :
486+ # macOS LibreSSL - more conservative settings
487+ ctx = ssl .create_default_context ()
488+ ctx .set_ciphers ('ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:!aNULL:!MD5:!DSS' )
489+ ctx .check_hostname = True
490+ ctx .verify_mode = ssl .CERT_REQUIRED
491+ info ("LibreSSL-compatible configuration activated" )
492+
493+ elif ssl_version_info >= (3 , 0 , 0 ):
494+ # OpenSSL 3.x - use modern features
495+ ctx = ssl .create_default_context ()
496+ ctx .minimum_version = ssl .TLSVersion .TLSv1_2
497+ if hasattr (ssl , 'TLSVersion' ) and hasattr (ssl .TLSVersion , 'TLSv1_3' ):
498+ ctx .maximum_version = ssl .TLSVersion .TLSv1_3
499+ info ("OpenSSL 3.x modern configuration activated" )
500+
501+ elif ssl_version_info >= (1 , 1 , 1 ):
502+ # OpenSSL 1.1.1+ - proven configuration
503+ ctx = ssl .create_default_context ()
504+ ctx .minimum_version = ssl .TLSVersion .TLSv1_2
505+ info ("OpenSSL 1.1.1+ standard configuration activated" )
506+
507+ else :
508+ # Legacy OpenSSL - basic functionality
509+ warn ("Outdated OpenSSL version detected, using legacy mode" )
510+ ctx = ssl .create_default_context ()
511+ ctx .options |= ssl .OP_NO_SSLv2 | ssl .OP_NO_SSLv3
512+
513+ # ESP-IDF DigiCert Certificate Handling
514+ parsed_url = urllib .parse .urlparse (url )
515+ domain = parsed_url .netloc .lower ()
516+
517+ if domain in DL_CERT_DICT :
518+ cert_data = DL_CERT_DICT [domain ]
519+ ctx .load_verify_locations (cadata = cert_data )
520+ # Disable hostname checking for custom certificates
521+ ctx .check_hostname = False
522+ ctx .verify_mode = ssl .CERT_REQUIRED
523+ info (f"✓ Custom DigiCert certificate loaded for { domain } " )
524+
525+ return ctx
526+
527+
528+ def get_ssl_fallback_contexts (url : str ) -> List [Tuple [str , ssl .SSLContext ]]:
529+ """
530+ Creates fallback SSL contexts for different scenarios.
531+
532+ This function provides multiple SSL context configurations that are tried
533+ in order when downloading fails. This approach maximizes compatibility
534+ across different systems and SSL configurations.
535+
536+ Args:
537+ url: The URL to create contexts for
538+
539+ Returns:
540+ List of tuples containing (config_name, ssl_context) pairs
541+ """
542+ contexts = []
543+
544+ # 1. Primary context with backend detection
545+ try :
546+ primary_ctx = create_esp_idf_ssl_context (url )
547+ contexts .append (('esp_idf_optimized' , primary_ctx ))
548+ except Exception as e :
549+ warn (f"Primary SSL context failed: { e } " )
550+
551+ # 2. Standard context with custom certificates
552+ try :
553+ standard_ctx = ssl .create_default_context ()
554+ parsed_url = urllib .parse .urlparse (url )
555+ domain = parsed_url .netloc .lower ()
556+
557+ if domain in DL_CERT_DICT :
558+ cert_data = DL_CERT_DICT [domain ]
559+ standard_ctx .load_verify_locations (cadata = cert_data )
560+ standard_ctx .check_hostname = False
561+
562+ contexts .append (('standard_with_custom_cert' , standard_ctx ))
563+ except Exception as e :
564+ warn (f"Standard SSL context with custom cert failed: { e } " )
565+
566+ # 3. System default without modifications
567+ try :
568+ system_ctx = ssl .create_default_context ()
569+ contexts .append (('system_default' , system_ctx ))
570+ except Exception as e :
571+ warn (f"System default SSL context failed: { e } " )
572+
573+ # 4. Unverified as last resort
574+ try :
575+ unverified_ctx = ssl .create_default_context ()
576+ unverified_ctx .check_hostname = False
577+ unverified_ctx .verify_mode = ssl .CERT_NONE
578+ contexts .append (('unverified' , unverified_ctx ))
579+ except Exception as e :
580+ warn (f"Unverified SSL context failed: { e } " )
581+
582+ return contexts
583+
584+
463585def run_cmd_check_output (
464586 cmd : List [str ], input_text : Optional [str ] = None , extra_paths : Optional [List [str ]] = None
465587) -> bytes :
@@ -540,11 +662,53 @@ def get_file_size_sha256(filename: str, block_size: int = 65536) -> Tuple[int, s
540662def report_progress (count : int , block_size : int , total_size : int ) -> None :
541663 """
542664 Prints progress (count * block_size * 100 / total_size) to stdout.
665+
666+ Args:
667+ count: Number of blocks downloaded
668+ block_size: Size of each block in bytes
669+ total_size: Total file size in bytes
543670 """
544- percent = int (count * block_size * 100 / total_size )
545- percent = min (100 , percent )
546- sys .stdout .write ('\r %d%%' % percent )
547- sys .stdout .flush ()
671+ if total_size > 0 :
672+ percent = int (count * block_size * 100 / total_size )
673+ percent = min (100 , percent )
674+ sys .stdout .write ('\r %d%%' % percent )
675+ sys .stdout .flush ()
676+
677+ def download_file_with_progress (response , destination : str ) -> None :
678+ """
679+ Downloads file from urllib response object with progress display.
680+
681+ This function replaces the manual implementation that was in the original
682+ urlretrieve_ctx function, providing progress feedback during download.
683+
684+ Args:
685+ response: urllib response object to read from
686+ destination: Local file path to save the downloaded content
687+ """
688+ with open (destination , 'wb' ) as f :
689+ total_size = int (response .getheader ('Content-Length' , 0 ))
690+ downloaded = 0
691+ block_size = 8192
692+ blocknum = 0
693+
694+ if total_size > 0 :
695+ info (f'File size: { total_size } bytes' )
696+
697+ while True :
698+ chunk = response .read (block_size )
699+ if not chunk :
700+ break
701+ f .write (chunk )
702+ downloaded += len (chunk )
703+ blocknum += 1
704+
705+ # Show progress using report_progress() (compatible with original version)
706+ if total_size > 0 :
707+ report_progress (blocknum , block_size , total_size )
708+ else :
709+ # Fallback for unknown file size
710+ sys .stdout .write (f'\r { downloaded } bytes downloaded' )
711+ sys .stdout .flush ()
548712
549713
550714def mkdir_p (path : str ) -> None :
@@ -595,6 +759,12 @@ def unpack(filename: str, destination: str) -> None:
595759def splittype (url : str ) -> Tuple [Optional [str ], str ]:
596760 """
597761 Splits given url into its type (e.g. https, file) and the rest.
762+
763+ Args:
764+ url: URL to split
765+
766+ Returns:
767+ Tuple of (scheme, data) where scheme is lowercase protocol name or None
598768 """
599769 match = re .match ('([^/:]+):(.*)' , url , re .DOTALL )
600770 if match :
@@ -603,6 +773,64 @@ def splittype(url: str) -> Tuple[Optional[str], str]:
603773 return None , url
604774
605775
776+ def classify_ssl_error (exception : Exception ) -> str :
777+ """
778+ Classifies SSL errors for better debugging output.
779+
780+ Args:
781+ exception: The exception to classify
782+
783+ Returns:
784+ String description of the error type
785+ """
786+ error_msg = str (exception ).upper ()
787+
788+ if 'CERTIFICATE_VERIFY_FAILED' in error_msg :
789+ return 'Certificate verification failed'
790+ elif 'SSL_HANDSHAKE_FAILURE' in error_msg or 'HANDSHAKE_FAILURE' in error_msg :
791+ return 'SSL handshake failure'
792+ elif 'TIMEOUT' in error_msg :
793+ return 'Connection timeout'
794+ elif 'CONNECTION_RESET' in error_msg or 'CONNECTION RESET' in error_msg :
795+ return 'Connection reset by peer'
796+ elif 'HOSTNAME_MISMATCH' in error_msg :
797+ return 'Hostname mismatch'
798+ else :
799+ return f'Unknown SSL error: { str (exception )[:50 ]} '
800+
801+
802+ def setup_mac_certificate_paths (ctx : ssl .SSLContext ) -> ssl .SSLContext :
803+ """
804+ Add macOS specific certificate paths for better compatibility.
805+
806+ This function attempts to load certificates from various macOS-specific
807+ paths to improve SSL compatibility, especially with Homebrew installations.
808+
809+ Args:
810+ ctx: SSL context to enhance with additional certificate paths
811+
812+ Returns:
813+ Enhanced SSL context with additional certificate paths loaded
814+ """
815+ mac_cert_paths = [
816+ '/System/Library/OpenSSL/certs/cert.pem' , # System OpenSSL
817+ '/usr/local/etc/openssl/cert.pem' , # Homebrew OpenSSL
818+ '/opt/homebrew/etc/openssl@3/cert.pem' , # Homebrew M1/M2
819+ '/etc/ssl/cert.pem' # Generic Unix
820+ ]
821+
822+ for cert_path in mac_cert_paths :
823+ if os .path .exists (cert_path ):
824+ try :
825+ ctx .load_verify_locations (cert_path )
826+ info (f"Loaded certificates from: { cert_path } " )
827+ break
828+ except Exception as e :
829+ warn (f"Failed to load { cert_path } : { e } " )
830+
831+ return ctx
832+
833+
606834def urlretrieve_ctx (
607835 url : str ,
608836 filename : str ,
@@ -659,49 +887,69 @@ def urlretrieve_ctx(
659887def download (url : str , destination : str ) -> Union [None , Exception ]:
660888 """
661889 Download from given url and save into given destination.
890+
891+ This is the new urllib-based implementation with SSL backend detection.
892+ It automatically detects the SSL backend (LibreSSL vs OpenSSL) and adapts the
893+ SSL configuration accordingly. Multiple fallback contexts are tried to maximize
894+ compatibility across different systems, especially macOS.
895+
896+ Args:
897+ url: URL to download from
898+ destination: Local file path to save to
899+
900+ Returns:
901+ None on success, Exception object on failure
662902 """
663903 info (f'Downloading { url } ' )
664904 info (f'Destination: { destination } ' )
665- # Try multiple SSL configurations for better Mac compatibility
666- ssl_configs = []
667- # First try: Custom certificates for known sites
668- for site , cert in DL_CERT_DICT .items ():
669- if site in url :
670- ctx = ssl .create_default_context ()
671- ctx .check_hostname = False
672- ctx .verify_mode = ssl .CERT_NONE
673- ctx .load_verify_locations (cadata = cert )
674- ssl_configs .append (('custom_cert' , ctx ))
675- break
676- # Second try: Default SSL context
677- ssl_configs .append (('default' , ssl .create_default_context ()))
678- # Third try: Unverified SSL (less secure but might work on problematic systems)
679- unverified_ctx = ssl .create_default_context ()
680- unverified_ctx .check_hostname = False
681- unverified_ctx .verify_mode = ssl .CERT_NONE
682- ssl_configs .append (('unverified' , unverified_ctx ))
683- # Fourth try: No SSL context (for HTTP or as last resort)
684- ssl_configs .append (('none' , None ))
685905
906+ # Get SSL fallback contexts for robust SSL handling
907+ ssl_contexts = get_ssl_fallback_contexts (url )
686908 last_exception = None
687- for config_name , ctx in ssl_configs :
909+
910+ for config_name , ctx in ssl_contexts :
688911 try :
689- if config_name != 'none' :
690- info (f'Trying SSL configuration: { config_name } ' )
691- urlretrieve_ctx (url , destination , None , context = ctx )
692- if config_name != 'none' :
693- info (f'Successfully downloaded using SSL configuration: { config_name } ' )
912+ info (f'Trying SSL configuration: { config_name } ' )
913+
914+ if url .startswith ('https' ):
915+ # HTTPS with specific SSL context
916+ req = urllib .request .Request (url , headers = {
917+ 'User-Agent' : 'pioarduino'
918+ })
919+
920+ with urllib .request .urlopen (req , context = ctx , timeout = 60 ) as response :
921+ download_file_with_progress (response , destination )
922+
923+ elif url .startswith ('http' ):
924+ # HTTP without SSL context
925+ urllib .request .urlretrieve (url , destination , report_progress )
926+
927+ else :
928+ # Other protocols (file://, etc.)
929+ urllib .request .urlretrieve (url , destination , report_progress )
930+
931+ info (f'Successfully downloaded using SSL configuration: { config_name } ' )
694932 sys .stdout .write ('\r Done\n ' )
933+ sys .stdout .flush ()
695934 return None
935+
696936 except Exception as e :
697937 last_exception = e
698- # Only show SSL-related errors for debugging
699- if 'SSL' in str (e ) or 'CERTIFICATE' in str (e ):
938+ error_msg = str (e ).upper ()
939+
940+ # Detect and log SSL-specific errors
941+ if any (keyword in error_msg for keyword in ['SSL' , 'CERTIFICATE' , 'TLS' , 'HANDSHAKE' ]):
700942 warn (f'SSL configuration "{ config_name } " failed: { str (e )[:100 ]} ...' )
943+ elif 'TIMEOUT' in error_msg :
944+ warn (f'Timeout with configuration "{ config_name } "' )
945+ else :
946+ warn (f'Configuration "{ config_name } " failed: { str (e )[:100 ]} ...' )
701947 continue
702948
703- # If all configurations failed, return the last exception
949+ # If all configurations failed
704950 sys .stdout .flush ()
951+ error_msg = f"All SSL configurations failed. Last error: { last_exception } "
952+ warn (error_msg )
705953 return last_exception
706954
707955
0 commit comments