From bc08e84d09a869c8d5e380b719db7de5cb8bf74a Mon Sep 17 00:00:00 2001 From: kloptops Date: Mon, 2 Nov 2026 11:57:25 +0800 Subject: [PATCH] Install URLs: X.zip.md5?md5= downloads X.zip directly With the md5 in the link, the .md5 file isn't fetched: X.zip is downloaded and checked against the link's md5. Older PortMasters (no ?md5= support) fetch the .md5 file and then X.zip from the same link, so DeviceHub can send this one form to every PortMaster version and have the download checked either way. Before, both were fetched and had to agree. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY --- CLAUDE.md | 2 +- .../pylibs/harbourmaster/source/__init__.py | 16 +++++++--------- tests/test_source.py | 14 +++++++++----- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 23cf40b4..cd0526de 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). `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`). +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). Given together, `/X.zip.md5?md5=` downloads `X.zip` straight away and checks it against the link's md5, without fetching the md5 file; older PortMasters fetch the md5 file and then `X.zip`, so DeviceHub sends this form to every version (`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 02b9c0d9..945274b3 100644 --- a/PortMaster/pylibs/harbourmaster/source/__init__.py +++ b/PortMaster/pylibs/harbourmaster/source/__init__.py @@ -52,7 +52,13 @@ def raw_download(save_path, file_url, callback=None, file_name=None, md5_source= if file_name is None: file_name = url_info.path.rsplit('/', 1)[-1] - if file_name.endswith('.md5') or file_name.endswith('.md5sum'): + if (file_name.endswith('.md5') or file_name.endswith('.md5sum')) and md5_query is not None: + ## "/X.zip.md5?md5=": the md5 is right here, so the .md5 file isn't fetched; X.zip is. + ## (Older PortMasters fetch the .md5 file and then X.zip, so the one link works for both.) + file_name = file_name.rsplit('.', 1)[0] + file_url = urlunparse(url_info._replace(path=url_info.path.rsplit('.', 1)[0])) + + elif 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 md5_source = net.fetch_text(file_url) if md5_source is None: @@ -63,14 +69,6 @@ def raw_download(save_path, file_url, callback=None, file_name=None, md5_source= 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/tests/test_source.py b/tests/test_source.py index 461cf6f9..0c5dea19 100644 --- a/tests/test_source.py +++ b/tests/test_source.py @@ -235,17 +235,21 @@ def test_raw_download_md5_file(tmp_path, callback, served): def test_raw_download_md5_file_and_query(tmp_path, callback, served): + ## The md5 is in the link, so the .md5 file isn't fetched: the zip is downloaded and checked. + ## (DeviceHub sends these: older PortMasters fetch the .md5 file, then the zip, from the same link.) 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"] + result = hm_source.raw_download(tmp_path, url, callback=callback) + assert result is not None and result['name'] == "port.zip" + assert served.fetched == [] and 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. +def test_raw_download_md5_file_and_wrong_query(tmp_path, callback, served): + ## The link's md5 is what the download is checked against. 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 result is None and served.fetched == [] assert callback.message_boxes == ["Download validation failed."] + assert not (tmp_path / "port.zip").exists() def test_raw_download_no_md5(tmp_path, callback, served):