diff --git a/.github/workflows/github-pages.yml b/.github/workflows/github-pages.yml index e9556c07d..7511497ca 100644 --- a/.github/workflows/github-pages.yml +++ b/.github/workflows/github-pages.yml @@ -23,12 +23,6 @@ jobs: with: python-version: '3.13' - - name: Preserve published firmware short-link identities - run: | - python scripts/check_published_firmware_shortlinks.py \ - --manifest docs/_data/firmware_shortlinks.json \ - --previous "$RUNNER_TEMP/previous-firmware-shortlinks.json" - - name: Build env: GH_TOKEN: ${{ github.token }} @@ -38,8 +32,6 @@ jobs: MKDOCS_EDIT_URI: edit/${{ github.ref_name }}/docs/ run: | pip install mkdocs-material - python -B test/test_firmware_shortlinks.py -v - python -B test/test_docs_firmware_shortlinks.py -v node test/test_firmware_picker.js node test/test_partition_catalog_viewer.js python scripts/sync_bootloader_manifest.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index c99456618..c06b9c16d 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -191,11 +191,6 @@ jobs: - name: Verify build output cleanup safety run: python3 -B test/test_build_output_safety.py -v - - name: Verify immutable short firmware downloads and Pages publishing - run: | - python3 -B test/test_firmware_shortlinks.py -v - python3 -B test/test_docs_firmware_shortlinks.py -v - - name: Verify serial mOTA response deadlines and RX fairness run: | python3 -B test/test_mota_serial_deadline.py -v diff --git a/.gitignore b/.gitignore index 88c834c2e..ea6f96bbf 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,6 @@ .vscode/ipch out/ site/ -.cache/firmware-shortlinks/ # Local firmware archives and build logs .releases/ out.*/ diff --git a/docs/_data/firmware_shortlinks.json b/docs/_data/firmware_shortlinks.json deleted file mode 100644 index aabcf96be..000000000 --- a/docs/_data/firmware_shortlinks.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "schemaVersion": 1, - "repository": "mikecarper/MeshCore", - "links": [ - { - "filename": "rak4631-1.17.1.10-p2.uf2", - "version": "1.17.1.10", - "releaseTag": "rak4631-full-companion-v1.17.1.10-preview2", - "asset": "RAK_4631_companion_radio_full-v1.17.1.10-halo-keymind-cascade-preview2-015a3e8e.uf2", - "sha256": "b74243f9741133b0f0ae858fab02de9aaa999f84c474e0a659efce85fe35ad8c", - "size": 1408000, - "label": "RAK4631 Full Companion 1.17.1.10 preview2: UF2" - }, - { - "filename": "rak4631-1.17.1.10-p2.zip", - "version": "1.17.1.10", - "releaseTag": "rak4631-full-companion-v1.17.1.10-preview2", - "asset": "RAK_4631_companion_radio_full-v1.17.1.10-halo-keymind-cascade-preview2-015a3e8e.zip", - "sha256": "11783d291f80c8084bb16f46ad2e35f0b1631da10606723488e2061320a5f6db", - "size": 704759, - "label": "RAK4631 Full Companion 1.17.1.10 preview2: Serial/Bluetooth DFU ZIP" - } - ] -} diff --git a/docs/_javascript/firmware_picker.js b/docs/_javascript/firmware_picker.js index 13d240c91..a82e1b698 100644 --- a/docs/_javascript/firmware_picker.js +++ b/docs/_javascript/firmware_picker.js @@ -23,6 +23,11 @@ ]); const FACET_FIELDS = Object.freeze(FILTER_FIELDS.concat(["install"])); + // Versioned picker selections stay self-contained as release catalogs change. + const SHORT_SELECTION_FIELDS = Object.freeze({ + c: "chipFamily", g: "hardwareFamily", h: "hardware", r: "role", + l: "logging", o: "ota", m: "mode", f: "feature", v: "variant", i: "install", + }); const PROFILE_FIELDS = Object.freeze(["ota", "feature", "variant"]); const CHOICE_FIELDS = Object.freeze([ "chipFamily", "hardwareFamily", "hardware", "role", "logging", @@ -1207,6 +1212,7 @@ function selectionUrl(url, filters, automaticChipFamily) { const result = new URL(url); + result.searchParams.delete("s"); FACET_FIELDS.forEach(function (field) { result.searchParams.delete(field); if (filters[field]) result.searchParams.set(field, filters[field]); @@ -1218,8 +1224,61 @@ return result.href; } + function shortSelectionUrl(url, filters, automaticChipFamily) { + const result = new URL(url); + FACET_FIELDS.concat(["chipAuto", "s"]).forEach(function (field) { + result.searchParams.delete(field); + }); + const parts = ["1"]; + Object.keys(SHORT_SELECTION_FIELDS).forEach(function (key) { + const field = SHORT_SELECTION_FIELDS[key]; + const value = filters[field]; + if (!value || (field === "chipFamily" && automaticChipFamily) || + (field === "hardwareFamily" && value === filters.hardware)) return; + // Dots delimit fields; escape them even though encodeURIComponent does not. + parts.push(key + encodeURIComponent(value).replace(/\./g, "%2E")); + }); + if ((filters.hardwareFamily || filters.hardware) && !automaticChipFamily) { + parts.push("a0"); + } + if (parts.length > 1) result.searchParams.set("s", parts.join(".")); + return result.href; + } + + function shortSelectionParams(params) { + const parts = params.get("s").split("."); + const result = new URLSearchParams(); + if (params.getAll("s").length !== 1 || parts.shift() !== "1" || !parts.length) { + throw new Error("unsupported or incomplete short picker link"); + } + const seen = new Set(); + parts.forEach(function (part) { + const key = part.charAt(0); + if (seen.has(key) || (key !== "a" && + !Object.prototype.hasOwnProperty.call(SHORT_SELECTION_FIELDS, key))) { + throw new Error("duplicate or unknown short picker field"); + } + seen.add(key); + const value = decodeURIComponent(part.slice(1)); + if (!value || (key === "a" && value !== "0" && value !== "1")) { + throw new Error("invalid short picker field"); + } + result.set(key === "a" ? "chipAuto" : SHORT_SELECTION_FIELDS[key], value); + }); + return result; + } + function selectionFromUrl(url, profiles) { - const params = new URL(url).searchParams; + let params = new URL(url).searchParams; + if (params.has("s")) { + try { + // The versioned selection is authoritative over any legacy fields. + params = shortSelectionParams(params); + } catch (error) { + return { filters: {}, automaticChipFamily: false, + unavailable: ["short link: " + error.message] }; + } + } const requested = {}; const filters = {}; const unavailable = []; @@ -1952,7 +2011,7 @@ } function updateSelectionUrl() { - const url = selectionUrl(global.location.href, filters, automaticChipFamily); + const url = shortSelectionUrl(global.location.href, filters, automaticChipFamily); if (url !== global.location.href) { try { global.history.replaceState(global.history.state, "", url); @@ -1962,9 +2021,11 @@ } } if (shareLink) { - const base = global.location.protocol === "file:" - ? root.getAttribute("data-share-url") : url; - shareLink.href = selectionUrl(base || url, filters, automaticChipFamily); + const base = new URL(global.location.protocol === "file:" + ? root.getAttribute("data-share-url") || url : url); + base.pathname = base.pathname.replace(/\/firmware_picker\/$/, "/p/"); + if (global.location.protocol === "file:") base.hash = global.location.hash; + shareLink.href = shortSelectionUrl(base.href, filters, automaticChipFamily); shareLink.hidden = false; } if (copyLinkButton) copyLinkButton.disabled = false; @@ -1987,13 +2048,13 @@ if (copyLinkButton && shareLink) copyLinkButton.addEventListener("click", function () { const copy = global.navigator.clipboard && global.navigator.clipboard.writeText; if (!copy) { - linkStatus.textContent = "Copy the address bar, or copy the 'Link to these settings' link."; + linkStatus.textContent = "Copy the address bar, or copy the 'Short link to these settings' link."; return; } global.navigator.clipboard.writeText(shareLink.href).then(function () { linkStatus.textContent = "Link copied."; }).catch(function () { - linkStatus.textContent = "Copy the address bar, or copy the 'Link to these settings' link."; + linkStatus.textContent = "Copy the address bar, or copy the 'Short link to these settings' link."; }); }); global.addEventListener("popstate", restoreSelectionUrl); @@ -2513,6 +2574,7 @@ choicesUseSameFirmware: choicesUseSameFirmware, selectionRequirements: selectionRequirements, selectionUrl: selectionUrl, + shortSelectionUrl: shortSelectionUrl, selectionFromUrl: selectionFromUrl, canonicalAsset: canonicalAsset, resolveProfileAssets: resolveProfileAssets, diff --git a/docs/d.md b/docs/d.md deleted file mode 100644 index bf60439cd..000000000 --- a/docs/d.md +++ /dev/null @@ -1,35 +0,0 @@ - - - - -# Short firmware links - -These links download the same firmware bytes as the original GitHub release, -with shorter filenames. Each link is pinned to one version and verified against -its SHA-256 checksum when the site is built. Existing links keep their original -firmware; a new release gets a new link. - - - -For **RAK4631 Full Companion**, use the **UF2** for a bootloader-drive -drag-and-drop update. Use the **DFU ZIP** when pasting a custom download URL -into the Windows or Linux MeshCore firmware tool. The ZIP is the native -application update package, not the release's download bundle. These files do -not install a bootloader or SoftDevice. - -The 1.17.1.10 preview2 files are for MeshTalk testing with Keymind Cascade and -USA Cascade defaults. The original release includes instructions, qualification -reports and checksums. See the [firmware picker](firmware_picker.md) for other -board and release choices. - -## Adding links for another release - -Add an entry to `docs/_data/firmware_shortlinks.json` with a unique, -version-specific filename, exact release tag and asset name, byte size, label -and SHA-256 checksum from the validated release. Keep the original firmware -extension. Never change or remove a published filename's version, source or -checksum. Labels can be corrected without changing the download. - -The documentation build downloads and verifies the pinned files before -publishing them under `d/`. A missing asset, incorrect checksum or changed -published link stops deployment instead of publishing a different image. diff --git a/docs/firmware_picker.md b/docs/firmware_picker.md index 06cb0cf89..9ede3b423 100644 --- a/docs/firmware_picker.md +++ b/docs/firmware_picker.md @@ -4,10 +4,6 @@ # Firmware picker -For shorter, version-pinned firmware download URLs, use -[Short firmware links](d.md). Those links serve the original verified firmware -files and include the RAK4631 Full Companion 1.17.1.10 preview2 for MeshTalk testing. - Each result now includes **Restore your settings after flashing**. Its commands follow the selected logging mode and the exact image's verified role/hardware controls. Click On, Off, or Check to view and copy the commands; Companion @@ -45,7 +41,7 @@ The picker reads public release metadata from GitHub. It does not upload device information. Hardware names, target names, and download links come directly from the published firmware assets. -
+
Current release set

Loading release information...

@@ -74,7 +70,7 @@ from the published firmware assets.

Pick in any order. Use Any to clear one choice, or clear everything with the button below. The address bar updates with your choices; - copy its URL to reopen or share the same selection. + use Copy link to settings for a shorter link to the same selection.

@@ -150,7 +146,7 @@ from the published firmware assets.
- +

@@ -253,31 +249,38 @@ changing roles or partition layouts. ## Share a selection -Select any combination of choices, then copy the address bar or use **Copy link -to settings**. Opening the link restores those choices after the release +Select any combination of choices, then use **Copy link to settings** for a +short URL under `https://mikecarper.github.io/MeshCore/p/`. Opening the link +restores those choices after the release catalog loads. Partial selections work too, so you can share a board and role while leaving other choices open. **Clear all choices** removes the picker parameters from the URL. Changes update the current browser-history entry without reloading the page or adding a Back-button entry for every click. -For example, [RAK3401 unified Full repeater](?hardware=RAK_3401&role=repeater&ota=lora-receiver&feature=full&variant=default) +For example, [RAK3401 unified Full repeater](https://mikecarper.github.io/MeshCore/p/?s=1.hRAK_3401.rrepeater.olora-receiver.ffull.vdefault) preselects that board, role, OTA capability, and Full sensor policy. Supported internal or external storage is detected by the unified image. -The query parameters are `chipFamily`, `hardwareFamily`, `hardware`, `role`, +Short links encode only the selected choices in a versioned `s` parameter. +Hardware identifiers stay readable, and the link omits redundant hardware-family +and automatically selected chip-family values. It keeps profile and install +choices explicitly rather than relying on the current catalog's defaults. +The address bar also uses the compact selection format; copying it works too. + +Existing long links still work. Their query parameters are `chipFamily`, `hardwareFamily`, `hardware`, `role`, `logging`, `ota`, `mode`, `feature`, `variant`, and `install`. Values use the picker's internal identifiers rather than the displayed labels. One Firmware profile button sets `ota`, `feature`, and `variant` together. Existing links with only some of those choices still work and show their partial selection. -The generated -link also records `chipAuto` so automatic chip-family selection or an explicit +The selection also records when chip-family filtering was chosen manually so +automatic chip-family selection or an explicit **Any** choice behaves the same after reopening. Existing section anchors and unrelated query parameters are preserved. Links use the current release catalog. If a linked choice is no longer available or conflicts with another choice, the picker identifies it and asks you to review the remaining selections. The downloadable HTML's **Copy link to -settings** button creates a public website link that other people can open. +settings** button creates a short public website link that other people can open. ## FULL versus standard diff --git a/docs/p/index.html b/docs/p/index.html new file mode 100644 index 000000000..c2865607d --- /dev/null +++ b/docs/p/index.html @@ -0,0 +1,22 @@ + + + + + + MeshCore firmware picker + + + +

Opening the firmware picker. Continue to the picker.

+ + + diff --git a/mkdocs.yml b/mkdocs.yml index b9cf0776b..37cff25f7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -8,7 +8,6 @@ edit_uri: !ENV [MKDOCS_EDIT_URI, 'edit/main/docs/'] hooks: - scripts/docs_partition_catalog.py - - scripts/docs_firmware_shortlinks.py # Keep historical URLs for bookmarks while showing only the organized copies. not_in_nav: | @@ -46,7 +45,6 @@ nav: - Home: index.md - Firmware and tools: - firmware_picker.md - - Short firmware links: d.md - notifications.md - preset_test.md - filter_tool.md @@ -133,7 +131,7 @@ extra_javascript: - _javascript/hosted_doc_links.js?v=20261007-1 - _javascript/notification_builder.js?v=20261006-channel9 - https://unpkg.com/leaflet@1.9.4/dist/leaflet.js - - _javascript/firmware_picker.js?v=20261008-rak-uart2-1 + - _javascript/firmware_picker.js?v=20261012-short-picker-1 - _javascript/telemetry_decoder.js?v=20261007-hosted-form - _javascript/management_decoder.js - _javascript/filter_tool.js?v=20261002-hashbytes-2plus diff --git a/scripts/check_published_firmware_shortlinks.py b/scripts/check_published_firmware_shortlinks.py deleted file mode 100644 index f9b003a82..000000000 --- a/scripts/check_published_firmware_shortlinks.py +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env python3 -"""Keep short download identities unchanged from the deployed Pages registry.""" - -import argparse -import os -from pathlib import Path -import subprocess - -from firmware_shortlinks import load_manifest, validate_immutable - - -PUBLISHED_PATH = "_data/firmware_shortlinks.json" - - -def check_published(manifest, previous): - current = load_manifest(manifest) - previous = Path(previous) - previous.unlink(missing_ok=True) - found = subprocess.run( - ["git", "ls-remote", "--exit-code", "--heads", "origin", "gh-pages"], - capture_output=True, text=True, check=False) - if found.returncode == 2: - return False # First Pages deployment: no published branch exists. - if found.returncode: - raise RuntimeError("Cannot check published Pages branch: " + found.stderr.strip()) - subprocess.run(["git", "fetch", "--depth=1", "--no-tags", "origin", "gh-pages"], check=True) - names = subprocess.check_output( - ["git", "ls-tree", "--name-only", "FETCH_HEAD", "--", PUBLISHED_PATH], text=True) - if not names.strip(): - return False # Existing site predates short firmware links. - data = subprocess.check_output(["git", "show", "FETCH_HEAD:" + PUBLISHED_PATH]) - previous.parent.mkdir(parents=True, exist_ok=True) - previous.write_bytes(data) - validate_immutable(load_manifest(previous), current) - return True - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--manifest", type=Path, required=True) - parser.add_argument("--previous", type=Path, required=True) - args = parser.parse_args() - try: - existing = check_published(args.manifest, args.previous) - except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError) as error: - parser.exit(1, "Firmware short-link deployment rejected: " + str(error) + "\n") - if existing and os.environ.get("GITHUB_ENV"): - with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as environment: - environment.write("MESHCORE_SHORTLINKS_PREVIOUS=" + str(args.previous.resolve()) + "\n") - print("Published firmware links preserved." if existing else "First firmware short-link deployment.") - - -if __name__ == "__main__": - main() diff --git a/scripts/docs_firmware_shortlinks.py b/scripts/docs_firmware_shortlinks.py deleted file mode 100644 index 34ee06443..000000000 --- a/scripts/docs_firmware_shortlinks.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Publish version-pinned firmware downloads at short, real binary URLs.""" - -import html -import logging -import os -from pathlib import Path -import sys - -sys.path.insert(0, str(Path(__file__).resolve().parent)) -from firmware_shortlinks import download_url, load_manifest, mirror, validate_immutable - - -MARKER = "" - - -def registry(config): - return load_manifest(Path(config["docs_dir"]) / "_data/firmware_shortlinks.json") - - -def on_page_markdown(markdown, page, config, **kwargs): - if MARKER not in markdown: - return markdown - data = registry(config) - base = config["site_url"].rstrip("/") + "/d/" - rows = ["" - ""] - for link in data["links"]: - label = html.escape(link["label"], quote=True) - alias = base + link["filename"] - original = download_url(data, link).split("/releases/download/", 1)[0] - original += "/releases/tag/" + link["releaseTag"] - rows.append( - "") - rows.append("
FirmwareShort download linkOriginal release
" + label + "" + html.escape(link["filename"]) + "Release
") - return markdown.replace(MARKER, "\n".join(rows)) - - -def on_post_build(config, **kwargs): - data = registry(config) - previous = os.environ.get("MESHCORE_SHORTLINKS_PREVIOUS") - if previous: - validate_immutable(load_manifest(Path(previous)), data) - cache = Path(os.environ.get( - "MESHCORE_SHORTLINKS_CACHE", - str(Path(config.config_file_path).resolve().parent / ".cache/firmware-shortlinks"))) - outputs = mirror(data, Path(config["site_dir"]) / "d", cache) - logging.getLogger("mkdocs.plugins.firmware_shortlinks").info( - "Published %d checksum-verified short firmware downloads", len(outputs)) diff --git a/scripts/firmware_shortlinks.py b/scripts/firmware_shortlinks.py deleted file mode 100644 index ba5123694..000000000 --- a/scripts/firmware_shortlinks.py +++ /dev/null @@ -1,223 +0,0 @@ -#!/usr/bin/env python3 -"""Mirror pinned release firmware as immutable, version-specific short URLs.""" - -import argparse -import hashlib -import json -import os -from pathlib import Path -import re -import shutil -import struct -import tempfile -from urllib.parse import quote, urlsplit -from urllib.request import build_opener, HTTPRedirectHandler - - -REPOSITORY = "mikecarper/MeshCore" -EXTENSIONS = {".bin", ".uf2", ".zip", ".hex", ".mota"} -MAX_ASSET_SIZE = 32 * 1024 * 1024 -IMMUTABLE_FIELDS = ("version", "releaseTag", "asset", "sha256", "size") -DELIVERY_HOSTS = {"github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com"} - - -def _version_in(value, version): - return re.search(r"(? 128: - raise ValueError("links must contain 1..128 firmware aliases") - names = set() - for link in links: - if not isinstance(link, dict): - raise ValueError("firmware link must be an object") - filename, version = link.get("filename"), link.get("version") - if (not isinstance(filename, str) - or re.fullmatch(r"[a-z0-9][a-z0-9._-]{0,159}", filename) is None - or "latest" in filename): - raise ValueError("alias must be a safe flat filename without latest") - if (not isinstance(version, str) - or re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:\.[0-9]+)?", version) is None - or not _version_in(filename, version)): - raise ValueError(filename + ": alias must contain its exact numeric version") - asset, tag = link.get("asset"), link.get("releaseTag") - for field, value in (("asset", asset), ("releaseTag", tag)): - if (not isinstance(value, str) - or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,239}", value) is None - or not _version_in(value, version) or "latest" in value.lower()): - raise ValueError(filename + ": invalid version-specific " + field) - extension = Path(filename).suffix - if extension not in EXTENSIONS or Path(asset).suffix != extension: - raise ValueError(filename + ": alias and source need matching firmware extensions") - if (not isinstance(link.get("sha256"), str) - or re.fullmatch(r"[0-9a-f]{64}", link["sha256"]) is None): - raise ValueError(filename + ": invalid SHA-256") - if type(link.get("size")) is not int or not 1 <= link["size"] <= MAX_ASSET_SIZE: - raise ValueError(filename + ": size must be 1..32 MiB") - label = link.get("label") - if (not isinstance(label, str) or not label.strip() or len(label) > 160 - or any(ord(char) < 32 or ord(char) > 126 for char in label)): - raise ValueError(filename + ": label must be printable ASCII") - if filename in names: - raise ValueError("duplicate firmware alias: " + filename) - names.add(filename) - return manifest - - -def load_manifest(path): - path = Path(path) - if path.stat().st_size > 1024 * 1024: - raise ValueError("firmware registry exceeds 1 MiB") - return _validate_manifest(json.loads(path.read_text(encoding="utf-8"))) - - -def validate_immutable(previous, current): - """Keep published aliases present and pinned; display labels may change.""" - _validate_manifest(current) - if previous is None: - return - _validate_manifest(previous) - by_name = {link["filename"]: link for link in current["links"]} - for old in previous["links"]: - new = by_name.get(old["filename"]) - if new is None or any(new[field] != old[field] for field in IMMUTABLE_FIELDS): - raise ValueError("published firmware alias removed or retargeted: " + old["filename"]) - - -def download_url(manifest, link): - _validate_manifest(manifest) - if link not in manifest["links"]: - raise ValueError("link is not in the firmware registry") - return ("https://github.com/" + REPOSITORY + "/releases/download/" - + quote(link["releaseTag"], safe="") + "/" + quote(link["asset"], safe="")) - - -def _delivery_url_allowed(url): - parsed = urlsplit(url) - return (parsed.scheme == "https" and parsed.hostname in DELIVERY_HOSTS - and parsed.port in (None, 443) and not parsed.username and not parsed.password) - - -class _ReleaseRedirect(HTTPRedirectHandler): - def redirect_request(self, request, response, code, message, headers, url): - if not _delivery_url_allowed(url): - raise ValueError("release download redirected outside GitHub HTTPS delivery") - return super().redirect_request(request, response, code, message, headers, url) - - -def _open_release(url): - return build_opener(_ReleaseRedirect()).open(url, timeout=30) - - -def _check_content(head, extension): - text = head.lstrip(b"\xef\xbb\xbf \t\r\n").lower() - if text.startswith((b" link["size"]: - raise ValueError("release download exceeds its pinned size") - output.write(chunk) - output.flush() - os.fsync(output.fileno()) - _verify_file(destination, link) - - -def mirror(manifest, output_dir, cache_dir, opener=None): - """Validate cache and existing aliases before publishing real firmware files.""" - _validate_manifest(manifest) - output_dir, cache_dir = Path(output_dir), Path(cache_dir) - output_dir.mkdir(parents=True, exist_ok=True) - cache_dir.mkdir(parents=True, exist_ok=True) - for link in manifest["links"]: - output = output_dir / link["filename"] - if output.exists() or output.is_symlink(): - _verify_file(output, link) # An alias is immutable even across builds. - outputs = [] - for link in manifest["links"]: - output = output_dir / link["filename"] - cached = cache_dir / (link["sha256"] + Path(link["filename"]).suffix) - try: - _verify_file(cached, link) - except ValueError: - with tempfile.TemporaryDirectory(prefix=".firmware-", dir=cache_dir) as temporary: - candidate = Path(temporary) / "download" - _download(download_url(manifest, link), link, candidate, opener or _open_release) - os.replace(candidate, cached) - if not output.exists(): - with tempfile.TemporaryDirectory(prefix=".firmware-", dir=output_dir) as temporary: - candidate = Path(temporary) / "publish" - shutil.copyfile(cached, candidate) - _verify_file(candidate, link) - try: - os.link(candidate, output) # Atomic publication without overwriting a racing alias. - except FileExistsError: - _verify_file(output, link) - _verify_file(output, link) - outputs.append(output) - return outputs - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--manifest", required=True, type=Path) - parser.add_argument("--previous", type=Path) - parser.add_argument("--output", type=Path) - parser.add_argument("--cache", type=Path) - parser.add_argument("--check-only", action="store_true") - args = parser.parse_args() - try: - manifest = load_manifest(args.manifest) - previous = load_manifest(args.previous) if args.previous is not None else None - validate_immutable(previous, manifest) - if not args.check_only: - if args.output is None or args.cache is None: - parser.error("--output and --cache are required unless --check-only") - mirror(manifest, args.output, args.cache) - print("Firmware shortlinks validated: " + str(len(manifest["links"]))) - except (OSError, ValueError, KeyError) as error: - parser.exit(1, "Firmware shortlink validation failed: " + str(error) + "\n") - - -if __name__ == "__main__": - main() diff --git a/test/test_docs_firmware_shortlinks.py b/test/test_docs_firmware_shortlinks.py deleted file mode 100644 index b80380981..000000000 --- a/test/test_docs_firmware_shortlinks.py +++ /dev/null @@ -1,121 +0,0 @@ -#!/usr/bin/env python3 -"""Verify rendered download URLs and the deployed-link identity guard.""" - -import copy -import importlib.util -import json -from pathlib import Path -import subprocess -import sys -import tempfile -import unittest -from unittest import mock - -ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(ROOT / "scripts")) -from firmware_shortlinks import load_manifest - - -def module(name, filename): - spec = importlib.util.spec_from_file_location(name, ROOT / "scripts" / filename) - loaded = importlib.util.module_from_spec(spec) - spec.loader.exec_module(loaded) - return loaded - - -HOOK = module("docs_shortlinks_hook", "docs_firmware_shortlinks.py") -GUARD = module("published_shortlinks_guard", "check_published_firmware_shortlinks.py") -MANIFEST = ROOT / "docs/_data/firmware_shortlinks.json" - - -class DocsFirmwareShortlinksTests(unittest.TestCase): - def test_table_uses_site_base_and_versioned_real_file_urls(self): - config = {"docs_dir": str(ROOT / "docs"), "site_url": "https://example.test/fork/"} - data = load_manifest(MANIFEST) - rendered = HOOK.on_page_markdown("Before\n\n" + HOOK.MARKER + "\n\nAfter", None, config) - self.assertNotIn(HOOK.MARKER, rendered) - for entry in data["links"]: - self.assertIn('href="https://example.test/fork/d/' + entry["filename"] + '"', rendered) - self.assertIn("/releases/tag/" + entry["releaseTag"], rendered) - self.assertTrue(rendered.startswith("Before")) - self.assertTrue(rendered.endswith("After")) - self.assertEqual(HOOK.on_page_markdown("Unrelated page", None, config), "Unrelated page") - - def test_registry_labels_are_plain_text_not_html_or_markdown(self): - data = load_manifest(MANIFEST) - data["links"][0]["label"] = '|[bad](javascript:alert(1))' - with mock.patch.object(HOOK, "registry", return_value=data): - result = HOOK.on_page_markdown(HOOK.MARKER, None, {"site_url": "https://example.test/"}) - self.assertNotIn("