2525from logging import shutdown as logging_shutdown
2626from logging import warning as logging_warning
2727from typing import Any , Callable , Optional , Union
28- from urllib .parse import urljoin
28+ from urllib .parse import urljoin , urlparse
2929from webbrowser import open as webbrowser_open
3030
3131import certifi
@@ -333,13 +333,22 @@ def get_expected_sha256_from_release(release_info: dict[str, Any], filename: str
333333 return None
334334
335335
336- def _validate_windows_installer_url (url : str ) -> bool :
337- """Validate that URL is from a trusted GitHub source."""
338- # This whitelist is intentionally restrictive for security reasons
339- if url .startswith ("https://github.com/" ):
340- return True
341- logging_error (_ ("Windows installer URL must be from github.com: %s" ), url )
342- return False
336+ def _validate_github_url (url : str ) -> bool :
337+ """Validate that URL is from the trusted GitHub releases download path."""
338+ # Parse and normalise before checking — guards against leading/trailing whitespace,
339+ # scheme case differences, and github.com appearing only in the path.
340+ parsed = urlparse (url .strip ())
341+ if parsed .scheme .lower () != "https" :
342+ logging_error (_ ("Installer URL must use HTTPS: %s" ), url )
343+ return False
344+ if parsed .hostname != "github.com" :
345+ logging_error (_ ("Installer URL must be from github.com: %s" ), url )
346+ return False
347+ expected_prefix = "/ArduPilot/MethodicConfigurator/releases/download/"
348+ if not parsed .path .startswith (expected_prefix ):
349+ logging_error (_ ("Installer URL must point to an ArduPilot/MethodicConfigurator release asset: %s" ), url )
350+ return False
351+ return True
343352
344353
345354def _verify_installer_integrity (path : str , expected_sha256 : Optional [str ]) -> bool :
@@ -357,32 +366,33 @@ def _verify_installer_integrity(path: str, expected_sha256: Optional[str]) -> bo
357366 return True
358367
359368
360- def _validate_windows_installer_file (path : str ) -> bool :
361- """Validate installer file size, PE signature, and set secure permissions."""
369+ def _validate_download_file (path : str , magic_bytes : Optional [ bytes ] = None , magic_error_msg : str = "" ) -> bool :
370+ """Validate downloaded file size, optional magic bytes signature, and set secure permissions."""
362371 try :
363372 st = os .stat (path )
364373 if st .st_size < 1024 :
365- logging_error (_ ("Downloaded installer too small: %d bytes" ), st .st_size )
374+ logging_error (_ ("Downloaded file too small: %d bytes" ), st .st_size )
366375 with contextlib .suppress (OSError , FileNotFoundError ):
367376 os .remove (path )
368377 return False
369378
370- with open (path , "rb" ) as _fh :
371- sig = _fh .read (2 )
372- # see ARCHITECTURE_1_software_update.md for rationale of this simplified test
373- if sig != PE_MAGIC_BYTES :
374- logging_error (_ ("Downloaded installer does not appear to be a Windows executable" ))
375- with contextlib .suppress (OSError , FileNotFoundError ):
376- os .remove (path )
377- return False
379+ if magic_bytes is not None :
380+ with open (path , "rb" ) as _fh :
381+ sig = _fh .read (len (magic_bytes ))
382+ # see ARCHITECTURE_1_software_update.md for rationale of this simplified test
383+ if sig != magic_bytes :
384+ logging_error ("%s" , magic_error_msg or _ ("Downloaded file has unexpected format" ))
385+ with contextlib .suppress (OSError , FileNotFoundError ):
386+ os .remove (path )
387+ return False
378388
379389 # Try to restrict permissions where supported
380390 with contextlib .suppress (PermissionError , OSError , NotImplementedError ):
381391 os .chmod (path , 0o600 )
382392
383393 return True
384394 except OSError as e :
385- logging_error (_ ("Failed to validate downloaded installer : %s" ), e )
395+ logging_error (_ ("Failed to validate downloaded file : %s" ), e )
386396 with contextlib .suppress (OSError , FileNotFoundError ):
387397 os .remove (path )
388398 return False
@@ -454,7 +464,7 @@ def download_and_install_on_windows(
454464
455465 """
456466 # Validate URL is from trusted GitHub source
457- if not _validate_windows_installer_url (download_url ):
467+ if not _validate_github_url (download_url ):
458468 return False
459469
460470 logging_info (_ ("Downloading and installing new version for Windows..." ))
@@ -478,7 +488,11 @@ def download_and_install_on_windows(
478488 return False
479489
480490 # Validate file size, PE signature, and set secure permissions
481- if not _validate_windows_installer_file (temp_path ):
491+ if not _validate_download_file (
492+ temp_path ,
493+ magic_bytes = PE_MAGIC_BYTES ,
494+ magic_error_msg = str (_ ("Downloaded installer does not appear to be a Windows executable" )),
495+ ):
482496 return False
483497
484498 if progress_callback :
@@ -497,6 +511,155 @@ def download_and_install_on_windows(
497511 return False
498512
499513
514+ def _mount_dmg (dmg_path : str ) -> Optional [str ]:
515+ """Mount a DMG file using hdiutil and return the mount point path."""
516+ try :
517+ result = subprocess .run ( # noqa: S603
518+ ["hdiutil" , "attach" , dmg_path , "-nobrowse" , "-noautoopen" ], # noqa: S607
519+ capture_output = True ,
520+ text = True ,
521+ check = True ,
522+ )
523+ # hdiutil output lines are tab-separated: <device>\t[fstype]\t<mountpoint>
524+ # The number of tab-separated fields varies; scan all parts for a /Volumes/ path.
525+ for line in result .stdout .strip ().splitlines ():
526+ parts = [p .strip () for p in line .split ("\t " )]
527+ for part in parts :
528+ if part .startswith ("/Volumes/" ):
529+ return part
530+ logging_error (_ ("Could not parse mount point from hdiutil output: %s" ), result .stdout )
531+ except subprocess .CalledProcessError as e :
532+ logging_error (_ ("Failed to mount DMG: %s" ), e )
533+ except OSError as e :
534+ logging_error (_ ("Failed to run hdiutil: %s" ), e )
535+ return None
536+
537+
538+ def _install_app_from_mount (mount_point : str , progress_callback : Optional [Callable [[float , str ], None ]] = None ) -> bool :
539+ """Copy .app bundle from DMG mount point to /Applications using ditto."""
540+ try :
541+ apps = [os .path .join (mount_point , f ) for f in os .listdir (mount_point ) if f .endswith (".app" )]
542+ if not apps :
543+ logging_error (_ ("No .app bundle found in DMG at %s" ), mount_point )
544+ return False
545+
546+ app_path = apps [0 ]
547+ app_name = os .path .basename (app_path )
548+ dest = os .path .join ("/Applications" , app_name )
549+
550+ if progress_callback :
551+ progress_callback (60.0 , _ ("Installing {}..." ).format (app_name ))
552+
553+ # Use ditto to preserve metadata, resource forks, and extended attributes
554+ subprocess .check_call (["ditto" , app_path , dest ]) # noqa: S603, S607
555+
556+ if progress_callback :
557+ progress_callback (80.0 , _ ("App installed" ))
558+
559+ return True
560+ except subprocess .CalledProcessError as e :
561+ logging_error (_ ("Failed to install app from DMG: %s" ), e )
562+ except OSError as e :
563+ logging_error (_ ("Error during app installation: %s" ), e )
564+ return False
565+
566+
567+ def _unmount_dmg (mount_point : str ) -> None :
568+ """Unmount a DMG volume using hdiutil detach."""
569+ with contextlib .suppress (subprocess .CalledProcessError , OSError ):
570+ subprocess .run ( # noqa: S603
571+ ["hdiutil" , "detach" , mount_point , "-force" ], # noqa: S607
572+ capture_output = True ,
573+ check = False ,
574+ )
575+
576+
577+ def _mount_and_install_dmg (
578+ temp_path : str ,
579+ progress_callback : Optional [Callable [[float , str ], None ]] = None ,
580+ ) -> bool :
581+ """Mount the DMG at temp_path, install the .app to /Applications, then unmount."""
582+ if progress_callback :
583+ progress_callback (40.0 , _ ("Mounting DMG..." ))
584+
585+ mount_point = _mount_dmg (temp_path )
586+ if not mount_point :
587+ return False
588+
589+ try :
590+ result = _install_app_from_mount (mount_point , progress_callback )
591+ finally :
592+ _unmount_dmg (mount_point )
593+
594+ if result :
595+ if progress_callback :
596+ progress_callback (100.0 , _ ("Installation complete. Please restart the application." ))
597+ logging_info (_ ("macOS DMG installation complete. Please restart the application." ))
598+ return result
599+
600+
601+ def download_and_install_on_macos (
602+ download_url : str ,
603+ file_name : str ,
604+ progress_callback : Optional [Callable [[float , str ], None ]] = None ,
605+ expected_sha256 : Optional [str ] = None ,
606+ ) -> bool :
607+ """
608+ Download and install a new version of the application on macOS via DMG.
609+
610+ This function orchestrates the complete update process: URL validation, download,
611+ integrity check, DMG mount, app copy, and unmount.
612+
613+ Args:
614+ download_url: The URL from which to download the DMG installer
615+ file_name: The name to save the downloaded file as
616+ progress_callback: Optional callback function to report progress
617+ Takes two arguments: progress (0.0-100.0) and status message
618+ expected_sha256: Optional SHA256 hex digest expected for the downloaded DMG. If provided,
619+ the downloaded file will be verified and the install will be aborted on mismatch.
620+
621+ Returns:
622+ bool: True if installation completed successfully, False otherwise
623+
624+ """
625+ if not _validate_github_url (download_url ):
626+ return False
627+
628+ logging_info (_ ("Downloading and installing new version for macOS..." ))
629+
630+ try :
631+ with tempfile .TemporaryDirectory () as temp_dir :
632+ temp_path = os .path .join (temp_dir , file_name )
633+
634+ # Download with progress updates
635+ if not download_file_from_url (
636+ download_url ,
637+ temp_path ,
638+ timeout = 60 , # Increased timeout for large files
639+ progress_callback = progress_callback ,
640+ ):
641+ logging_error (_ ("Failed to download DMG from %s" ), download_url )
642+ return False
643+
644+ # Verify SHA256 checksum if provided
645+ if progress_callback :
646+ progress_callback (0.0 , _ ("Verifying checksum..." ))
647+ if not _verify_installer_integrity (temp_path , expected_sha256 ):
648+ return False
649+
650+ # Validate file size and set secure permissions (no magic bytes check for DMG)
651+ if progress_callback :
652+ progress_callback (20.0 , _ ("Validating file..." ))
653+ if not _validate_download_file (temp_path ):
654+ return False
655+
656+ return _mount_and_install_dmg (temp_path , progress_callback )
657+
658+ except OSError as e :
659+ logging_error (_ ("File operation failed: {}" ).format (e ))
660+ return False
661+
662+
500663def download_and_install_pip_release (progress_callback : Optional [Callable [[float , str ], None ]] = None ) -> int :
501664 """Download and install the latest release via pip/uv from PyPI."""
502665 if progress_callback :
0 commit comments