From bcd8431778ba35c9d920d485258b5bf449d1e0d2 Mon Sep 17 00:00:00 2001 From: kloptops Date: Sun, 25 Oct 2026 00:26:23 +0800 Subject: [PATCH] Install URLs: /X.zip?md5= checks the download against that md5 One URL both fetches and verifies a zip, without a .md5 file next to it: `harbourmaster install https://host/X.zip?md5=...`. The md5 parameter is taken off the URL that's downloaded, other parameters are kept. A malformed md5 is refused before anything is downloaded; with a .md5 URL as well, the two have to agree. Also: an upper case or tab separated .md5 file is accepted, and a URL with no file name in its path no longer raises IndexError. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY --- CLAUDE.md | 2 +- .../pylibs/harbourmaster/source/__init__.py | 39 +++++- PortMaster/pylibs/locales/de_DE.json | 1 + PortMaster/pylibs/locales/es_ES.json | 1 + PortMaster/pylibs/locales/fr_FR.json | 1 + PortMaster/pylibs/locales/it_IT.json | 1 + PortMaster/pylibs/locales/ja_JP.json | 1 + PortMaster/pylibs/locales/ko_KR.json | 1 + PortMaster/pylibs/locales/nl_NL.json | 1 + PortMaster/pylibs/locales/pl_PL.json | 1 + PortMaster/pylibs/locales/pt_BR.json | 1 + PortMaster/pylibs/locales/ro_RO.json | 1 + PortMaster/pylibs/locales/ru_RU.json | 1 + PortMaster/pylibs/locales/strings.json | 18 ++- PortMaster/pylibs/locales/sv_SE.json | 1 + PortMaster/pylibs/locales/uk_UA.json | 1 + PortMaster/pylibs/locales/zh_CN.json | 1 + tests/test_source.py | 126 ++++++++++++++++++ 18 files changed, 188 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a4480f9f..7d689ec2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ python3 -I tools/i18n.py check # language files: unused strings, los pugwash flags: `--quiet --no-check --debug --no-colour --force-colour --no-log --offline --no-harbour`. -To test on a device: `./do_release.sh test`, copy `dist/PortMaster.zip` over (stream it over ssh: `scp` has truncated files on some devices), then on the device run `./PortMaster/harbourmaster --no-check install ./PortMaster.zip` (the `./` prefix makes it install a local file). +To test on a device: `./do_release.sh test`, copy `dist/PortMaster.zip` over (stream it over ssh: `scp` has truncated files on some devices), then on the device run `./PortMaster/harbourmaster --no-check install ./PortMaster.zip` (the `./` prefix makes it install a local file). `harbourmaster install` also takes a URL: `/X.zip.md5` fetches the md5 file and then `X.zip`, and `/X.zip?md5=` checks the download against that md5 (the `md5` parameter isn't sent to the server); both must agree when given together (`raw_download` in `source/__init__.py`). ## Local-dev mode (HM_TESTING) diff --git a/PortMaster/pylibs/harbourmaster/source/__init__.py b/PortMaster/pylibs/harbourmaster/source/__init__.py index 54789f05..02b9c0d9 100644 --- a/PortMaster/pylibs/harbourmaster/source/__init__.py +++ b/PortMaster/pylibs/harbourmaster/source/__init__.py @@ -4,7 +4,9 @@ # PortMasterV3 is the only one left; raw_download handles installs from a plain url. from harbourmaster.i18n import _ -from urllib.parse import urlparse, urlunparse +import re + +from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse from loguru import logger from ..info import port_info_load from ..util import name_cleaner @@ -15,6 +17,8 @@ ################################################################################ ## Raw Downloader +MD5_RE = re.compile(r'^[0-9a-f]{32}$') + def raw_download(save_path, file_url, callback=None, file_name=None, md5_source=None): """ @@ -24,8 +28,29 @@ def raw_download(save_path, file_url, callback=None, file_name=None, md5_source= original_url = file_url url_info = urlparse(file_url) + ## "?md5=" on the url: the md5 the download must have, and not sent to the server. + query = parse_qsl(url_info.query, keep_blank_values=True) + md5_query = [value for key, value in query if key.lower() == 'md5'] + if md5_query: + md5_query = md5_query[-1].strip().lower() + if not MD5_RE.match(md5_query): + if callback is not None: + callback.message_box(_("Unable to download file, its md5 is not valid.")) + + logger.error(f"Unable to download file: {file_url!r} [bad md5 {md5_query!r}]") + return None + + url_info = url_info._replace(query=urlencode([(key, value) for key, value in query if key.lower() != 'md5'])) + file_url = urlunparse(url_info) + + if md5_source is None: + md5_source = md5_query + + else: + md5_query = None + if file_name is None: - file_name = url_info.path.rsplit('/', 1)[1] + file_name = url_info.path.rsplit('/', 1)[-1] if file_name.endswith('.md5') or file_name.endswith('.md5sum'): ## If it is an md5 file, we assume the actual zip is sans the md5/md5sum @@ -36,7 +61,15 @@ def raw_download(save_path, file_url, callback=None, file_name=None, md5_source= logger.error(f"Unable to download file: {file_url!r}") return None - md5_source = md5_source.strip().split(' ', 1)[0] + md5_source = md5_source.strip().split(None, 1)[0].lower() if md5_source.strip() else "" + + if md5_query is not None and md5_source != md5_query: + ## Two answers to "what should it be": neither can be trusted. + if callback is not None: + callback.message_box(_("Download validation failed.")) + + logger.error(f"Unable to download file: {file_url!r} [md5 file {md5_source!r} != url md5 {md5_query!r}]") + return None file_name = file_name.rsplit('.', 1)[0] file_url = urlunparse(url_info._replace(path=url_info.path.rsplit('.', 1)[0])) diff --git a/PortMaster/pylibs/locales/de_DE.json b/PortMaster/pylibs/locales/de_DE.json index d2f0a557..4a11a397 100644 --- a/PortMaster/pylibs/locales/de_DE.json +++ b/PortMaster/pylibs/locales/de_DE.json @@ -60,6 +60,7 @@ "Unable to download {runtime}, game may not run correctly.": "{runtime} konnte nicht heruntergeladen werden, möglicherweise funktioniert das Spiel nicht korrekt.", "Successfully downloaded {runtime}.": "Erfolgreich {runtime} heruntergeladen", "\n\nIn order to do so:\nMENU -> UTILITIES -> Rescan Rom Directory": "\n\nUm dies zu tun:\nMENÜ→-> UTILITIES -> Rom-Verzeichnis erneut scannen", + "Unable to download file, its md5 is not valid.": "Datei kann nicht heruntergeladen werden, ihre md5 ist ungültig.", "Unable to download verification file.": "Überprüfungsdatei konnte nicht heruntergeladen werden", "Unable to download non zip files.": "Nur Zip Dateien können heruntergeladen werden.", "Cache out of date.": "Cache veraltet", diff --git a/PortMaster/pylibs/locales/es_ES.json b/PortMaster/pylibs/locales/es_ES.json index e43d2480..b8ce7c5c 100644 --- a/PortMaster/pylibs/locales/es_ES.json +++ b/PortMaster/pylibs/locales/es_ES.json @@ -60,6 +60,7 @@ "Unable to download {runtime}, game may not run correctly.": "No se puede descargar {runtime}, es posible que el juego no funcione correctamente.", "Successfully downloaded {runtime}.": "{runtime} Descargado correctamente.", "\n\nIn order to do so:\nMENU -> UTILITIES -> Rescan Rom Directory": "\nPara hacerlo:\n\nMENÚ > UTILIDADES > REESCANEAR DIRECTORIO DE ROMS", + "Unable to download file, its md5 is not valid.": "No se puede descargar el archivo, su md5 no es válido.", "Unable to download verification file.": "No se puede descargar el archivo de verificación.", "Unable to download non zip files.": "No se pueden descargar archivos de tipo no zip.", "Cache out of date.": "Caché Obsoleta.", diff --git a/PortMaster/pylibs/locales/fr_FR.json b/PortMaster/pylibs/locales/fr_FR.json index f4c9252f..80a240ec 100644 --- a/PortMaster/pylibs/locales/fr_FR.json +++ b/PortMaster/pylibs/locales/fr_FR.json @@ -60,6 +60,7 @@ "Unable to download {runtime}, game may not run correctly.": "Impossible de télécharger {runtime}, le jeu pourrait ne pas s'exécuter correctement.", "Successfully downloaded {runtime}.": "Téléchargement de {runtime} réussi.", "\n\nIn order to do so:\nMENU -> UTILITIES -> Rescan Rom Directory": "\n\nPour ce faire :\nMENU -> UTILITIES -> Rescan Rom Directory", + "Unable to download file, its md5 is not valid.": "Impossible de télécharger le fichier, son md5 n'est pas valide.", "Unable to download verification file.": "Impossible de télécharger le fichier de vérification.", "Unable to download non zip files.": "Impossible de télécharger des fichiers non compressés.", "Cache out of date.": "Cache obsolète.", diff --git a/PortMaster/pylibs/locales/it_IT.json b/PortMaster/pylibs/locales/it_IT.json index 4ae4100e..196715b2 100644 --- a/PortMaster/pylibs/locales/it_IT.json +++ b/PortMaster/pylibs/locales/it_IT.json @@ -60,6 +60,7 @@ "Unable to download {runtime}, game may not run correctly.": "Impossibile scaricare la runtime {runtime}, il gioco potrebbe presentare dei problemi.", "Successfully downloaded {runtime}.": "Scaricato con successo {runtime}", "\n\nIn order to do so:\nMENU -> UTILITIES -> Rescan Rom Directory": "\n\nPer farlo:\nMENU -> UTILITIES -> Rescan Rom Directory", + "Unable to download file, its md5 is not valid.": "Impossibile scaricare il file, il suo md5 non è valido.", "Unable to download verification file.": "Non posso scaricare i file di verifica", "Unable to download non zip files.": "Non posso scaricare i file non zip", "Cache out of date.": "Cache Obsoleta.", diff --git a/PortMaster/pylibs/locales/ja_JP.json b/PortMaster/pylibs/locales/ja_JP.json index e00fef6f..7789e74c 100644 --- a/PortMaster/pylibs/locales/ja_JP.json +++ b/PortMaster/pylibs/locales/ja_JP.json @@ -60,6 +60,7 @@ "Unable to download {runtime}, game may not run correctly.": "{runtime} をダウンロードできません。ゲームが正しく実行されない可能性があります。", "Successfully downloaded {runtime}.": "{runtime} のダウンロードに成功しました。", "\n\nIn order to do so:\nMENU -> UTILITIES -> Rescan Rom Directory": "\n\n手順:\nMENU -> UTILITIES -> Rescan Rom Directory", + "Unable to download file, its md5 is not valid.": "ファイルをダウンロードできません。md5 が正しくありません。", "Unable to download verification file.": "確認情報が見つかりません。", "Unable to download non zip files.": "非 zip ファイルをダウンロードできません。", "Cache out of date.": "キャッシュが古いです。", diff --git a/PortMaster/pylibs/locales/ko_KR.json b/PortMaster/pylibs/locales/ko_KR.json index 9cfc12db..2ababb84 100644 --- a/PortMaster/pylibs/locales/ko_KR.json +++ b/PortMaster/pylibs/locales/ko_KR.json @@ -60,6 +60,7 @@ "Unable to download {runtime}, game may not run correctly.": "{runtime} 다운로드할 수 없습니다. 게임이 제대로 실행되지 않을 수 있습니다.", "Successfully downloaded {runtime}.": "{runtime} 다운로드 성공", "\n\nIn order to do so:\nMENU -> UTILITIES -> Rescan Rom Directory": "\n\n그렇게 하기 위해:\n메뉴 -> 유틸리티 -> 롬 디렉토리 다시 스캔", + "Unable to download file, its md5 is not valid.": "파일을 다운로드할 수 없습니다. md5가 올바르지 않습니다.", "Unable to download verification file.": "검증 파일을 다운로드할 수 없습니다.", "Unable to download non zip files.": "Zip 파일이 아니면 다운로드할 수 없습니다.", "Cache out of date.": "캐시가 오래되었습니다.", diff --git a/PortMaster/pylibs/locales/nl_NL.json b/PortMaster/pylibs/locales/nl_NL.json index e0696366..ad6ab51d 100644 --- a/PortMaster/pylibs/locales/nl_NL.json +++ b/PortMaster/pylibs/locales/nl_NL.json @@ -60,6 +60,7 @@ "Unable to download {runtime}, game may not run correctly.": "Kan {runtime} niet downloaden, het spel kan mogelijk niet correct worden uitgevoerd.", "Successfully downloaded {runtime}.": "{runtime} succesvol gedownload.", "\n\nIn order to do so:\nMENU -> UTILITIES -> Rescan Rom Directory": "\n\nOm dit te doen:\nMENU → HULPPROGRAMMA'S → Rom-map opnieuw scannen", + "Unable to download file, its md5 is not valid.": "Kan bestand niet downloaden, de md5 is ongeldig.", "Unable to download verification file.": "Kan verificatiebestand niet downloaden.", "Unable to download non zip files.": "Kan geen non-zip bestanden downloaden.", "Cache out of date.": "Cache verouderd.", diff --git a/PortMaster/pylibs/locales/pl_PL.json b/PortMaster/pylibs/locales/pl_PL.json index 490d1275..ed1de54c 100644 --- a/PortMaster/pylibs/locales/pl_PL.json +++ b/PortMaster/pylibs/locales/pl_PL.json @@ -60,6 +60,7 @@ "Unable to download {runtime}, game may not run correctly.": "Nie można pobrać {runtime}, gra może nie działać poprawnie.", "Successfully downloaded {runtime}.": "Pomyślnie pobrano {runtime}.", "\n\nIn order to do so:\nMENU -> UTILITIES -> Rescan Rom Directory": "\n\nAby to zrobić:\nMENU -> UTILITY -> Przeskanuj katalog Rom", + "Unable to download file, its md5 is not valid.": "Nie można pobrać pliku, jego md5 jest nieprawidłowe.", "Unable to download verification file.": "Nie można pobrać pliku weryfikacyjnego.", "Unable to download non zip files.": "Nie można pobrać plików bez zip.", "Cache out of date.": "Pamięć podręczna nieaktualna.", diff --git a/PortMaster/pylibs/locales/pt_BR.json b/PortMaster/pylibs/locales/pt_BR.json index ceb90901..7ad2e30e 100644 --- a/PortMaster/pylibs/locales/pt_BR.json +++ b/PortMaster/pylibs/locales/pt_BR.json @@ -60,6 +60,7 @@ "Unable to download {runtime}, game may not run correctly.": "Impossível baixar {runtime}, jogo pode não funcionar corretamente.", "Successfully downloaded {runtime}.": "{runtime} baixado com sucesso.", "\n\nIn order to do so:\nMENU -> UTILITIES -> Rescan Rom Directory": "\n\nPara fazer isso:\nMENU -> UTILIDADES -> Reescanear Diretório de Roms", + "Unable to download file, its md5 is not valid.": "Não foi possível baixar o arquivo, o md5 não é válido.", "Unable to download verification file.": "Impossível baixar arquivo de verificação.", "Unable to download non zip files.": "Incapaz de baixar arquivos que não sejam zip.", "Cache out of date.": "Cache desatualizado.", diff --git a/PortMaster/pylibs/locales/ro_RO.json b/PortMaster/pylibs/locales/ro_RO.json index f6c31463..7ae282ea 100644 --- a/PortMaster/pylibs/locales/ro_RO.json +++ b/PortMaster/pylibs/locales/ro_RO.json @@ -60,6 +60,7 @@ "Unable to download {runtime}, game may not run correctly.": "Nu s-a putut descărca {runtime}, jocul ar putea să nu ruleze corect.", "Successfully downloaded {runtime}.": "{runtime} a fost descărcat cu succes.", "\n\nIn order to do so:\nMENU -> UTILITIES -> Rescan Rom Directory": "\n\nPentru a face acest lucru:\nMENU -> UTILITARE -> Rescanare Director ROM", + "Unable to download file, its md5 is not valid.": "Nu se poate descărca fișierul, md5-ul său nu este valid.", "Unable to download verification file.": "Nu s-a putut descărca fișierul de verificare.", "Unable to download non zip files.": "Nu s-au putut descărca fișierele non zip.", "Cache out of date.": "Cache-ul este expirat.", diff --git a/PortMaster/pylibs/locales/ru_RU.json b/PortMaster/pylibs/locales/ru_RU.json index 1b9a8af4..8daf2733 100644 --- a/PortMaster/pylibs/locales/ru_RU.json +++ b/PortMaster/pylibs/locales/ru_RU.json @@ -60,6 +60,7 @@ "Unable to download {runtime}, game may not run correctly.": "Не удается загрузить {runtime}, игра может работать некорректно.", "Successfully downloaded {runtime}.": "{runtime} Успешно загружено.", "\n\nIn order to do so:\nMENU -> UTILITIES -> Rescan Rom Directory": "\n\nЧтобы сделать это:\nМЕНЮ -> ПРОГРАММЫ -> Повторить поиск каталога", + "Unable to download file, its md5 is not valid.": "Не удалось загрузить файл: неверный md5.", "Unable to download verification file.": "Не удалось загрузить проверочный файл.", "Unable to download non zip files.": "Невозможно загрузить не zip-файлы.", "Cache out of date.": "Кэш устарел.", diff --git a/PortMaster/pylibs/locales/strings.json b/PortMaster/pylibs/locales/strings.json index c07a93a9..157067b5 100644 --- a/PortMaster/pylibs/locales/strings.json +++ b/PortMaster/pylibs/locales/strings.json @@ -27,6 +27,7 @@ "Download validation failed.": [ "pylibs/harbourmaster/hm_info.py:146", "pylibs/harbourmaster/hm_info.py:168", + "pylibs/harbourmaster/source/__init__.py:69", "pylibs/harbourmaster/util/net.py:148" ], "Unable to download file.": [ @@ -212,11 +213,14 @@ "\n\nIn order to do so:\nMENU -> UTILITIES -> Rescan Rom Directory": [ "pylibs/harbourmaster/platform/retrodeck.py:18" ], + "Unable to download file, its md5 is not valid.": [ + "pylibs/harbourmaster/source/__init__.py:38" + ], "Unable to download verification file.": [ - "pylibs/harbourmaster/source/__init__.py:35" + "pylibs/harbourmaster/source/__init__.py:60" ], "Unable to download non zip files.": [ - "pylibs/harbourmaster/source/__init__.py:46" + "pylibs/harbourmaster/source/__init__.py:79" ], "Cache out of date.": [ "pylibs/harbourmaster/source/base.py:34" @@ -229,19 +233,19 @@ ], "Updating": [ "pylibs/harbourmaster/source/portmaster.py:42", - "pylibs/pugwash/theme.py:581" + "pylibs/pugwash/theme.py:588" ], "Fetching latest info": [ "pylibs/harbourmaster/source/portmaster.py:46", - "pylibs/pugwash/theme.py:591" + "pylibs/pugwash/theme.py:598" ], "Done.": [ "pylibs/harbourmaster/source/portmaster.py:165", - "pylibs/pugwash/theme.py:614" + "pylibs/pugwash/theme.py:621" ], "Fetching info": [ "pylibs/harbourmaster/source/portmaster.py:197", - "pylibs/pugwash/theme.py:620" + "pylibs/pugwash/theme.py:627" ], "Unable to find {port_name}.": [ "pylibs/harbourmaster/source/portmaster.py:418" @@ -1064,7 +1068,7 @@ "pylibs/pugwash/theme.py:358" ], "Up to date already": [ - "pylibs/pugwash/theme.py:588" + "pylibs/pugwash/theme.py:595" ], "You are switching from the {old_release_channel} to the {new_release_channel} version.\n\nDo you want to downgrade?": [ "pylibs/pugwash/update.py:79" diff --git a/PortMaster/pylibs/locales/sv_SE.json b/PortMaster/pylibs/locales/sv_SE.json index 0989db44..fcf51594 100644 --- a/PortMaster/pylibs/locales/sv_SE.json +++ b/PortMaster/pylibs/locales/sv_SE.json @@ -60,6 +60,7 @@ "Unable to download {runtime}, game may not run correctly.": "Kunde inte ladda ner {runtime}, spelet kanske inte körs korrekt.", "Successfully downloaded {runtime}.": "Hämtat {runtime}.", "\n\nIn order to do so:\nMENU -> UTILITIES -> Rescan Rom Directory": "\n\nSå här gör du:\nMENU -> UTILITIES -> Rescan Rom Directory", + "Unable to download file, its md5 is not valid.": "Kan inte ladda ner filen, dess md5 är ogiltig.", "Unable to download verification file.": "Kunde inte hämta verifieringsfilen.", "Unable to download non zip files.": "Kan inte hämta icke-zip-filer.", "Cache out of date.": "Cache är föråldrad.", diff --git a/PortMaster/pylibs/locales/uk_UA.json b/PortMaster/pylibs/locales/uk_UA.json index 9f9b3a7e..afd66e06 100644 --- a/PortMaster/pylibs/locales/uk_UA.json +++ b/PortMaster/pylibs/locales/uk_UA.json @@ -60,6 +60,7 @@ "Unable to download {runtime}, game may not run correctly.": "Не вдалося завантажити {runtime}, гра може працювати некоректно.", "Successfully downloaded {runtime}.": "Успішно завантажено {runtime}.", "\n\nIn order to do so:\nMENU -> UTILITIES -> Rescan Rom Directory": "\n\nЩоб це зробити: \nМЕНЮ -> УТИЛІТИ -> Повторно сканувати папку з ROM'ами", + "Unable to download file, its md5 is not valid.": "Не вдалося завантажити файл: неправильний md5.", "Unable to download verification file.": "Не вдалося завантажити інформацію для перевірки.", "Unable to download non zip files.": "Не вдалося завантажити не zip-файли.", "Cache out of date.": "Кеш застарілий.", diff --git a/PortMaster/pylibs/locales/zh_CN.json b/PortMaster/pylibs/locales/zh_CN.json index ac8e566f..da2964de 100644 --- a/PortMaster/pylibs/locales/zh_CN.json +++ b/PortMaster/pylibs/locales/zh_CN.json @@ -60,6 +60,7 @@ "Unable to download {runtime}, game may not run correctly.": "无法下载 {runtime},游戏可能无法正常运行。", "Successfully downloaded {runtime}.": "成功下载 {runtime}。", "\n\nIn order to do so:\nMENU -> UTILITIES -> Rescan Rom Directory": "\n\n操作方法:\nMENU -> UTILITIES -> Rescan Rom Directory", + "Unable to download file, its md5 is not valid.": "无法下载文件,其 md5 无效。", "Unable to download verification file.": "无法下载验证信息。", "Unable to download non zip files.": "无法下载非zip文件。", "Cache out of date.": "缓存过期。", diff --git a/tests/test_source.py b/tests/test_source.py index 9d4c4836..461cf6f9 100644 --- a/tests/test_source.py +++ b/tests/test_source.py @@ -148,6 +148,132 @@ def test_raw_download_md5_unavailable(tmp_path, callback, monkeypatch): assert callback.message_boxes == ["Unable to download verification file."] +################################################################################ +## raw_download: "?md5=" on the url is the md5 the download must have. +ZIP_DATA = b"PK\x05\x06" + b"\x00" * 18 + + +class FakeDownload: + status_code = 200 + headers = {} + + def __init__(self, data): + self.data = data + + def iter_content(self, chunk_size=1, decode_unicode=False): + yield self.data + + +@pytest.fixture +def served(monkeypatch): + """The urls downloaded and md5 files fetched; every download is ZIP_DATA, every md5 file `served.md5_file`.""" + import hashlib + served = type("Served", (), {})() + served.got = [] + served.fetched = [] + served.md5 = hashlib.md5(ZIP_DATA).hexdigest() + served.md5_file = served.md5 + " port.zip\n" + + def get(url, **kwargs): + served.got.append(url) + return FakeDownload(ZIP_DATA) + + def fetch_text(url): + served.fetched.append(url) + return served.md5_file + + monkeypatch.setattr(hm_net.requests, "get", get) + monkeypatch.setattr(hm_net, "fetch_text", fetch_text) + return served + + +def test_raw_download_md5_query(tmp_path, callback, served): + url = f"https://example.com/files/port.zip?md5={served.md5}" + result = hm_source.raw_download(tmp_path, url, callback=callback) + + assert result['zip_file'] == tmp_path / "port.zip" and result['zip_file'].read_bytes() == ZIP_DATA + assert result['status']['md5'] == served.md5 and result['status']['url'] == url + ## The md5 isn't sent to the server. + assert served.got == ["https://example.com/files/port.zip"] + assert "Passed file validation." in callback.messages + + +@pytest.mark.parametrize("query, sent", [ + ("token=abc&md5={md5}", "?token=abc"), + ("md5={md5}&token=abc&x=", "?token=abc&x="), + ("MD5={MD5}", ""), + ]) +def test_raw_download_md5_query_keeps_the_rest(tmp_path, callback, served, query, sent): + url = "https://example.com/port.zip?" + query.format(md5=served.md5, MD5=served.md5.upper()) + assert hm_source.raw_download(tmp_path, url, callback=callback) is not None + assert served.got == ["https://example.com/port.zip" + sent] + + +def test_raw_download_md5_query_wrong(tmp_path, callback, served): + result = hm_source.raw_download(tmp_path, f"https://example.com/port.zip?md5={'0' * 32}", callback=callback) + + assert result is None + assert callback.message_boxes == ["Download validation failed."] + assert not (tmp_path / "port.zip").exists() + + +@pytest.mark.parametrize("md5", ["", "abc", "z" * 32, "0" * 31, "0" * 33, "0" * 32 + "%00"]) +def test_raw_download_md5_query_not_an_md5(tmp_path, callback, served, md5): + result = hm_source.raw_download(tmp_path, f"https://example.com/port.zip?md5={md5}", callback=callback) + + assert result is None and served.got == [] + assert callback.message_boxes == ["Unable to download file, its md5 is not valid."] + + +def test_raw_download_md5_file(tmp_path, callback, served): + ## The .md5 file form: as before, and an upper case md5sum is fine too. + served.md5_file = served.md5.upper() + "\tport.zip\n" + result = hm_source.raw_download(tmp_path, "https://example.com/port.zip.md5", callback=callback) + + assert result is not None and result['name'] == "port.zip" + assert served.fetched == ["https://example.com/port.zip.md5"] and served.got == ["https://example.com/port.zip"] + + +def test_raw_download_md5_file_and_query(tmp_path, callback, served): + url = f"https://example.com/port.zip.md5?md5={served.md5}" + assert hm_source.raw_download(tmp_path, url, callback=callback) is not None + assert served.got == ["https://example.com/port.zip"] + + +def test_raw_download_md5_file_and_query_disagree(tmp_path, callback, served): + ## Which one is right? Neither is trusted: nothing is downloaded. + result = hm_source.raw_download(tmp_path, f"https://example.com/port.zip.md5?md5={'0' * 32}", callback=callback) + + assert result is None and served.got == [] + assert callback.message_boxes == ["Download validation failed."] + + +def test_raw_download_no_md5(tmp_path, callback, served): + assert hm_source.raw_download(tmp_path, "https://example.com/port.zip?x=1", callback=callback) is not None + assert served.got == ["https://example.com/port.zip?x=1"] + assert "Unable to validate download." in callback.messages + + +@pytest.mark.parametrize("url", ["https://example.com", "https://example.com/", "https://example.com/?md5=" + "0" * 32]) +def test_raw_download_no_file_name(tmp_path, callback, served, url): + assert hm_source.raw_download(tmp_path, url, callback=callback) is None + assert callback.message_boxes == ["Unable to download non zip files."] and served.got == [] + + +def test_install_md5_query(hm, callback, served, monkeypatch): + ## harbourmaster install ?md5=...: the CLI and the GUI both end up here. + monkeypatch.setitem(hm.config, 'offline', False) + monkeypatch.setattr(hm, "callback", callback) + installed = [] + monkeypatch.setattr(hm, "_install_portmaster", lambda zip_file, do_delete=False: installed.append(zip_file.name) or 0) + + assert hm.install_port(f"https://example.com/PortMaster.zip?md5={served.md5}") == 0 + assert installed == ["PortMaster.zip"] and served.got == ["https://example.com/PortMaster.zip"] + + assert hm.install_port(f"https://example.com/PortMaster.zip?md5={'0' * 32}") == 255 + assert len(installed) == 1 + + ################################################################################ ## Old source files are upgraded to PortMasterV3 when loaded. LEGACY_SOURCES = {