46
46
import tarfile
47
47
import tempfile
48
48
import time
49
+ import urllib .parse
50
+ import urllib .request
49
51
from collections import OrderedDict
50
52
from collections import namedtuple
51
53
from json import JSONEncoder
@@ -99,7 +101,7 @@ class WindowsError(OSError): # type: ignore
99
101
VERSION_REGEX_REPLACE_DEFAULT = r'\1'
100
102
IDF_MAINTAINER = os .environ .get ('IDF_MAINTAINER' ) or False
101
103
TODO_MESSAGE = 'TODO'
102
- DOWNLOAD_RETRY_COUNT = 3
104
+ DOWNLOAD_RETRY_COUNT = 5
103
105
URL_PREFIX_MAP_SEPARATOR = ','
104
106
IDF_TOOLS_INSTALL_CMD = os .environ .get ('IDF_TOOLS_INSTALL_CMD' )
105
107
IDF_TOOLS_EXPORT_CMD = os .environ .get ('IDF_TOOLS_INSTALL_CMD' )
@@ -460,6 +462,126 @@ def parse_platform_arg(platform_str: str) -> str:
460
462
DL_CERT_DICT = {'dl.espressif.com' : DIGICERT_ROOT_G2_CERT , 'github.com' : DIGICERT_ROOT_CA_CERT }
461
463
462
464
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
+
463
585
def run_cmd_check_output (
464
586
cmd : List [str ], input_text : Optional [str ] = None , extra_paths : Optional [List [str ]] = None
465
587
) -> bytes :
@@ -540,11 +662,53 @@ def get_file_size_sha256(filename: str, block_size: int = 65536) -> Tuple[int, s
540
662
def report_progress (count : int , block_size : int , total_size : int ) -> None :
541
663
"""
542
664
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
543
670
"""
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 ()
548
712
549
713
550
714
def mkdir_p (path : str ) -> None :
@@ -595,6 +759,12 @@ def unpack(filename: str, destination: str) -> None:
595
759
def splittype (url : str ) -> Tuple [Optional [str ], str ]:
596
760
"""
597
761
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
598
768
"""
599
769
match = re .match ('([^/:]+):(.*)' , url , re .DOTALL )
600
770
if match :
@@ -603,6 +773,64 @@ def splittype(url: str) -> Tuple[Optional[str], str]:
603
773
return None , url
604
774
605
775
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
+
606
834
def urlretrieve_ctx (
607
835
url : str ,
608
836
filename : str ,
@@ -659,49 +887,69 @@ def urlretrieve_ctx(
659
887
def download (url : str , destination : str ) -> Union [None , Exception ]:
660
888
"""
661
889
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
662
902
"""
663
903
info (f'Downloading { url } ' )
664
904
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 ))
685
905
906
+ # Get SSL fallback contexts for robust SSL handling
907
+ ssl_contexts = get_ssl_fallback_contexts (url )
686
908
last_exception = None
687
- for config_name , ctx in ssl_configs :
909
+
910
+ for config_name , ctx in ssl_contexts :
688
911
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 } ' )
694
932
sys .stdout .write ('\r Done\n ' )
933
+ sys .stdout .flush ()
695
934
return None
935
+
696
936
except Exception as e :
697
937
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' ]):
700
942
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 ]} ...' )
701
947
continue
702
948
703
- # If all configurations failed, return the last exception
949
+ # If all configurations failed
704
950
sys .stdout .flush ()
951
+ error_msg = f"All SSL configurations failed. Last error: { last_exception } "
952
+ warn (error_msg )
705
953
return last_exception
706
954
707
955
0 commit comments