From db0e609259e2649e9ba69fe107783582016a76a0 Mon Sep 17 00:00:00 2001 From: martino <32328813+f3rs3n@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:28:29 +0200 Subject: [PATCH] fix(vm): repair Synology loader downloads and extraction --- .github/scripts/tests/test_synology_loader.py | 475 ++++++++++++++++++ scripts/vm/synology.sh | 138 ++--- 2 files changed, 550 insertions(+), 63 deletions(-) create mode 100644 .github/scripts/tests/test_synology_loader.py diff --git a/.github/scripts/tests/test_synology_loader.py b/.github/scripts/tests/test_synology_loader.py new file mode 100644 index 00000000..f288311d --- /dev/null +++ b/.github/scripts/tests/test_synology_loader.py @@ -0,0 +1,475 @@ +"""Offline acquisition fixtures; never source or run the administrative script. + +Requires Bash, jq, curl, wget, gzip and unzip. Only bounded native functions +are loaded; HTTP clients are confined to a loopback fixture server. No host +package manager, Proxmox command, installer or VM is executed. +""" + +from contextlib import contextmanager +import gzip +import http.server +import io +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +import threading +import unittest +import warnings +import zipfile + + +ROOT = Path(__file__).resolve().parents[3] +SOURCE = ROOT / "scripts/vm/synology.sh" +RAW = b"tiny offline loader fixture\x00\x01\x02" * 8 +PLAIN = "alpine-redpill.v1.4.5.6.m-shell.img.gz" +VARIANTS = [ + "alpine-redpill.v1.4.5.6.m-shell-5GB.img.gz", + "alpine-redpill.v1.4.5.6.xtcrp.img.gz", + "alpine-redpill.v1.4.5.6.m-shell.vmdk.gz", + "alpine-redpill.v1.4.5.6.xtcrp-5GB.img.gz", +] + + +def native_function(name): + match = re.search( + rf"^function {re.escape(name)}\(\) \{{\n.*?^\}}", + SOURCE.read_text(), re.MULTILINE | re.DOTALL, + ) + if not match: + raise AssertionError(f"Native function not found: {name}") + return match.group() + + +def asset(name): + return {"name": name, "browser_download_url": f"https://fixtures.invalid/{name}"} + + +def zip_bytes(member, payload=RAW, extras=()): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr(member, payload) + for name, content in extras: + archive.writestr(name, content) + return buffer.getvalue() + + +@contextmanager +def fixture_server(metadata, payload, *, metadata_status=200, asset_status=200, + partial=False): + requests = [] + + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + requests.append(self.path) + is_metadata = self.path.startswith("/metadata/") + body = metadata if is_metadata else payload + status = metadata_status if is_metadata else asset_status + self.send_response(status) + self.send_header("Content-Length", str(len(body) + ( + 37 if partial and not is_metadata else 0))) + self.end_headers() + self.wfile.write(body) + self.close_connection = True + + def log_message(self, format, *args): + pass + + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, + kwargs={"poll_interval": 0.01}, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}", requests + finally: + server.shutdown() + server.server_close() + thread.join() + + +class SynologyLoaderTests(unittest.TestCase): + def run_loader(self, loader="tinycore", *, names=None, metadata=None, + payload=None, pretty=True, before="", invoke="download_loader", + functions=(), **server_options): + if names is None: + names = [PLAIN, *VARIANTS] + if metadata is None: + metadata = json.dumps({"assets": [asset(name) for name in names]}, + indent=2 if pretty else None).encode() + if payload is None: + payload = gzip.compress(RAW) + expected = {"arc": "arc.img", "redpill": "rr.img", "tinycore": "tinycore.img"}[loader] + with tempfile.TemporaryDirectory() as temporary: + base = Path(temporary) + images = base / "images with spaces" + images.mkdir() + previous = images / expected + previous.write_bytes(b"previous verified image") + bin_dir = base / "bin" + bin_dir.mkdir() + for command in ("bash", "jq", "curl", "wget", "gzip", "unzip"): + self.assertIsNotNone(shutil.which(command), f"Install test prerequisite: {command}") + with fixture_server(metadata, payload, **server_options) as (url, requests): + for command in ("curl", "wget"): + real = shutil.which(command) + client_options = ["--no-config", "--tries=1"] if command == "wget" else ["-q"] + wrapper = bin_dir / command + wrapper.write_text( + f"#!{sys.executable}\n" + "import os, sys\n" + "args = sys.argv[1:]\n" + "skip = False\n" + "for arg in args:\n" + " if skip:\n" + " skip = False\n" + " continue\n" + " if arg in ('-O', '--connect-timeout', '--max-time'):\n" + " skip = True\n" + " elif not arg.startswith('-') and not arg.startswith(('https://api.github.com/repos/', 'https://fixtures.invalid/')):\n" + " sys.exit('Forbidden non-fixture argument: ' + arg)\n" + "for i, arg in enumerate(args):\n" + " if arg.startswith('https://api.github.com/repos/'):\n" + f" args[i] = {url!r} + '/metadata/' + arg.split('/repos/', 1)[1]\n" + " elif arg.startswith('https://fixtures.invalid/'):\n" + f" args[i] = {url!r} + '/asset/' + arg.rsplit('/', 1)[1]\n" + " elif '://' in arg:\n" + " sys.exit('Forbidden non-fixture URL: ' + arg)\n" + "os.environ['no_proxy'] = os.environ['NO_PROXY'] = '127.0.0.1'\n" + f"os.execv({real!r}, [{real!r}, *{client_options!r}, *args])\n" + ) + wrapper.chmod(0o755) + # Administrative boundaries are fail-closed even if a test regresses. + for command in ("apt-get", "qm", "pct", "pvesm", "pvesh"): + wrapper = bin_dir / command + wrapper.write_text("#!/bin/bash\nprintf 'FORBIDDEN %s\\n' \"$0\" >&2\nexit 97\n") + wrapper.chmod(0o755) + definitions = "\n".join(native_function(name) for name in + dict.fromkeys(("download_loader", *functions))) + script = r''' +translate() { printf '%s' "$1"; } +msg_info() { printf 'INFO:%s\n' "$*"; } +msg_ok() { printf 'OK:%s\n' "$*"; } +msg_error() { printf 'ERROR:%s\n' "$*"; } +msg_warn() { printf 'WARN:%s\n' "$*"; } +stop_spinner() { :; } +sleep() { :; } +select_loader() { printf 'UNEXPECTED_RECURSION\n'; return 91; } +''' + script += definitions + "\n" + before + "\n" + script += invoke + r''' +result=$? +printf 'RESULT:%s\nFILE:%s\nLOADER_FILE:%s\nLOADER_TYPE:%s\nLOADER_NAME:%s\n' "$result" "$FILE" "$LOADER_FILE" "$LOADER_TYPE" "$LOADER_NAME" +exit "$result" +''' + environment = dict(os.environ, PATH=f"{bin_dir}:{os.environ['PATH']}", + IMAGES_DIR=str(images), LOADER_TYPE=loader, + LOADER_NAME="fixture loader", FILE="stale.img", + LOADER_FILE="/stale.img") + result = subprocess.run(["bash", "-c", script], env=environment, + cwd=base, capture_output=True, text=True, timeout=15) + state = dict(re.findall(r"^(RESULT|FILE|LOADER_FILE|LOADER_TYPE|LOADER_NAME):(.*)$", result.stdout, re.MULTILINE)) + return result, state, previous.read_bytes(), list(requests), sorted( + str(path.relative_to(images)) for path in images.rglob("*")) + + def test_plain_alpine_selection_ignores_competing_variants(self): + for pretty, names in ((True, [*VARIANTS, PLAIN]), + (False, [VARIANTS[2], PLAIN, *VARIANTS[:2], VARIANTS[3]])): + with self.subTest(pretty=pretty): + result, state, contents, requests, files = self.run_loader(names=names, pretty=pretty) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(contents, RAW) + self.assertEqual(state["FILE"], "tinycore.img") + self.assertTrue(state["LOADER_FILE"].endswith("/tinycore.img")) + self.assertEqual([path for path in requests if path.startswith("/asset/")], + [f"/asset/{PLAIN}"]) + self.assertEqual(files, ["tinycore.img"]) + + def assert_failed_acquisition(self, outcome, expected="tinycore.img"): + result, state, contents, requests, files = outcome + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(state["FILE"], "", result.stdout) + self.assertEqual(state["LOADER_FILE"], "", result.stdout) + self.assertEqual(contents, b"previous verified image") + self.assertNotIn("OK:", result.stdout) + self.assertNotIn("UNEXPECTED_RECURSION", result.stdout) + self.assertNotIn("FORBIDDEN", result.stderr) + self.assertEqual(files, [expected]) + return requests + + def test_zip_loaders_parse_minified_metadata(self): + for loader, name, member in (("arc", "arc-26.10.img.zip", "arc.img"), + ("redpill", "rr-26.10.img.zip", "rr.img")): + with self.subTest(loader=loader): + result, state, contents, requests, files = self.run_loader( + loader, names=["README.txt", name], pretty=False, + payload=zip_bytes(member)) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(contents, RAW) + self.assertEqual(state["FILE"], member) + self.assertTrue(state["LOADER_FILE"].endswith("/" + member)) + self.assertEqual(files, [member]) + self.assertEqual(requests[-1], "/asset/" + name) + + def test_metadata_requires_one_typed_unambiguous_asset(self): + for loader, name, member in (("tinycore", PLAIN, "tinycore.img"), + ("arc", "arc-26.10.img.zip", "arc.img"), + ("redpill", "rr-26.10.img.zip", "rr.img")): + cases = { + "none": {"assets": []}, + "duplicate": {"assets": [asset(name), asset(name)]}, + "two_versions": {"assets": [asset(name), asset(name.replace("v1.4.5.6", "v2.0.0") if loader == "tinycore" else "another.img.zip")]}, + "null_name": {"assets": [{"name": None, "browser_download_url": asset(name)["browser_download_url"]}]}, + "numeric_name": {"assets": [{"name": 7, "browser_download_url": asset(name)["browser_download_url"]}]}, + "missing_name": {"assets": [{"browser_download_url": asset(name)["browser_download_url"]}]}, + "null_url": {"assets": [{"name": name, "browser_download_url": None}]}, + "numeric_url": {"assets": [{"name": name, "browser_download_url": 7}]}, + "empty_url": {"assets": [{"name": name, "browser_download_url": ""}]}, + "http_url": {"assets": [{"name": name, "browser_download_url": "http://bad/" + name}]}, + "mismatched_url": {"assets": [{"name": name, "browser_download_url": "https://bad/other.img.gz"}]}, + "whitespace_url": {"assets": [{"name": name, "browser_download_url": "https://bad/ has space/" + name}]}, + "control_url": {"assets": [{"name": name, "browser_download_url": "https://fixtures.invalid/\u0000/" + name}]}, + "wrong_assets_type": {"assets": {}}, + "array_root": [asset(name)], + "error_object": {"message": "API rate limit exceeded"}, + } + for case, document in cases.items(): + with self.subTest(loader=loader, case=case): + requests = self.assert_failed_acquisition(self.run_loader( + loader, metadata=json.dumps(document).encode()), member) + self.assertFalse(any(path.startswith("/asset/") for path in requests)) + + def test_metadata_transport_or_json_failure_is_not_success(self): + for label, metadata, status in ( + ("http_failure_with_valid_body", json.dumps({"assets": [asset(PLAIN)]}).encode(), 503), + ("html_error_body", b"unavailable", 200), + ("truncated_json", b'{"assets":[', 200), + ("multiple_documents", json.dumps({"assets": [asset(PLAIN)]}).encode() * 2, 200), + ): + with self.subTest(case=label): + requests = self.assert_failed_acquisition(self.run_loader( + metadata=metadata, metadata_status=status)) + self.assertFalse(any(path.startswith("/asset/") for path in requests)) + + def test_failed_empty_or_partial_download_preserves_previous_image(self): + for loader, name, member in (("tinycore", PLAIN, "tinycore.img"), + ("arc", "arc-26.10.img.zip", "arc.img"), + ("redpill", "rr-26.10.img.zip", "rr.img")): + payload = gzip.compress(RAW) if loader == "tinycore" else zip_bytes(member) + for label, body, options in ( + ("http_error_with_archive_body", payload, {"asset_status": 503}), + ("empty_download", b"", {}), + ("partial_download", payload, {"partial": True}), + ): + with self.subTest(loader=loader, case=label): + requests = self.assert_failed_acquisition(self.run_loader( + loader, names=[name], payload=body, **options), member) + self.assertEqual(requests[-1], "/asset/" + name) + + def test_invalid_or_empty_compressed_image_cannot_replace_cached_image(self): + valid_gzip = gzip.compress(RAW) + crc_gzip = valid_gzip[:-8] + bytes([valid_gzip[-8] ^ 1]) + valid_gzip[-7:] + for label, payload in ( + ("not_gzip", b"not an image"), + ("truncated_gzip", valid_gzip[:-6]), + ("gzip_crc_failure", crc_gzip), + ("empty_raw_image", gzip.compress(b"")), + ): + with self.subTest(case=label): + self.assert_failed_acquisition(self.run_loader(payload=payload)) + + for loader, name, member in (("arc", "arc-26.10.img.zip", "arc.img"), + ("redpill", "rr-26.10.img.zip", "rr.img")): + stored = io.BytesIO() + with zipfile.ZipFile(stored, "w", zipfile.ZIP_STORED) as archive: + archive.writestr(member, RAW) + crc_zip = stored.getvalue().replace(RAW, b"?" + RAW[1:], 1) + valid_zip = zip_bytes(member) + for label, payload in ( + ("not_zip", b"not an archive"), + ("truncated_zip", valid_zip[:-30]), + ("zip_crc_failure", crc_zip), + ("empty_raw_image", zip_bytes(member, b"")), + ("missing_member", zip_bytes("other.img")), + ("nested_member", zip_bytes("subdirectory/" + member)), + ): + with self.subTest(loader=loader, case=label): + self.assert_failed_acquisition(self.run_loader( + loader, names=[name], payload=payload), member) + + def test_alpine_menu_and_selection_name_preserve_internal_contract(self): + result, state, contents, requests, files = self.run_loader( + functions=("select_loader",), invoke="select_loader", + before=r'''whiptail() { printf 'MENU:%s\n' "$*"; printf '3' >&2; }''') + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("Alpine RedPill Loader (PeterSuh-Q3 M-shell)", result.stderr) + self.assertIn("Selected Loader: Alpine RedPill M-shell", result.stdout) + self.assertNotIn("TinyCore RedPill", result.stderr) + self.assertEqual(contents, RAW) + self.assertEqual(state["FILE"], "tinycore.img") + self.assertIn("/metadata/PeterSuh-Q3/tinycore-redpill/releases/latest", requests) + + def test_zip_expected_member_must_be_unique(self): + for loader, name, member in (("arc", "arc-26.10.img.zip", "arc.img"), + ("redpill", "rr-26.10.img.zip", "rr.img")): + duplicate = io.BytesIO() + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + with zipfile.ZipFile(duplicate, "w") as archive: + archive.writestr(member, RAW) + archive.writestr(member, b"competing image") + with self.subTest(loader=loader): + self.assert_failed_acquisition(self.run_loader( + loader, names=[name], payload=duplicate.getvalue()), member) + + def run_wizard(self, loader, mode, *, main=False, **options): + functions = ( + "select_loader", "select_disk_type", "_build_storage_plan_summary", + "default_settings", "advanced_settings", "start_script", "create_vm", + ) + before = f"WIZARD_MODE={mode}\nLOADER_CHOICE={ {'arc': 1, 'redpill': 2, 'tinycore': 3}[loader] }\n" + # Only UI/storage-entry/capture/host boundaries are replaced. The actual + # loader -> disk plan -> settings -> start_script chain runs in Bash. + before += r''' +NEXTID=123 +NAME='Synology VM' +GEN_MAC='02:00:00:00:00:01' +header_info() { :; } +start_vm_wizard_capture() { :; } +stop_vm_wizard_capture() { :; } +prompt_optional_gpu_passthrough() { printf 'GPU_BOUNDARY\n'; } +select_virtual_disk() { VIRTUAL_DISKS+=('local:32'); } +pct() { return 1; } +qm() { + [[ "$1" == status ]] && return 1 + printf 'QM_BOUNDARY:%s\n' "$*" + return 97 +} +whiptail() { + case "$*" in + *'--title SETTINGS --yesno'*) [[ "$WIZARD_MODE" == default ]]; return ;; + *'VIRTUAL MACHINE ID'*) printf '123' >&2 ;; + *'MACHINE TYPE'*) printf 'q35' >&2 ;; + *'BIOS TYPE'*) printf 'ovmf' >&2 ;; + *'HOSTNAME'*) printf 'Synology-DSM' >&2 ;; + *'CPU MODEL'*) printf '1' >&2 ;; + *'CORE COUNT'*) printf '2' >&2 ;; + *'--title RAM '*) printf '4096' >&2 ;; + *'--title BRIDGE '*) printf 'vmbr0' >&2 ;; + *'MAC ADDRESS'*) printf '02:00:00:00:00:01' >&2 ;; + *'STORAGE PLAN'*) + if [[ ${#VIRTUAL_DISKS[@]} -eq 0 ]]; then printf 'a' >&2; else printf 'd' >&2; fi ;; + *'SELECT LOADER'*) printf '%s' "$LOADER_CHOICE" >&2 ;; + esac + return 0 +} +''' + invoke = "start_script" + if main: + match = re.search(r"^# Main execution\n# =+\n(.*)", SOURCE.read_text(), + re.MULTILINE | re.DOTALL) + if match is None: + self.fail("Native main gate not found") + invoke = match.group(1) + return self.run_loader(loader, functions=functions, before=before, + invoke=invoke, **options) + + def test_native_settings_chain_propagates_loader_failure(self): + for mode in ("default", "advanced"): + for loader, name, member in (("tinycore", PLAIN, "tinycore.img"), + ("arc", "arc-26.10.img.zip", "arc.img"), + ("redpill", "rr-26.10.img.zip", "rr.img")): + for failure in ("metadata", "archive", "download"): + with self.subTest(mode=mode, loader=loader, failure=failure): + result, state, contents, requests, files = self.run_wizard( + loader, mode, names=[name], + metadata=b'{"assets":[]}' if failure == "metadata" else None, + payload=b"invalid compressed image", + asset_status=503 if failure == "download" else 200) + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(state["FILE"], "") + self.assertEqual(state["LOADER_FILE"], "") + self.assertEqual(contents, b"previous verified image") + self.assertNotIn("Downloaded", result.stdout) + self.assertNotIn("QM_BOUNDARY", result.stdout) + self.assertNotIn("FORBIDDEN", result.stderr) + self.assertEqual(files, [member]) + + def test_native_main_gate_never_reaches_qm_create_after_loader_failure(self): + for mode in ("default", "advanced"): + for loader in ("tinycore", "arc", "redpill"): + with self.subTest(mode=mode, loader=loader): + result, state, contents, requests, files = self.run_wizard( + loader, mode, main=True, metadata=b'{"assets":[]}') + # The existing cancellation convention exits zero; failure + # must still stop the native main before GPU or VM creation. + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("VM creation cancelled before disk planning.", result.stdout) + self.assertNotIn("QM_BOUNDARY", result.stdout) + self.assertNotIn("GPU_BOUNDARY", result.stdout) + self.assertNotIn("FORBIDDEN", result.stderr) + self.assertEqual(contents, b"previous verified image") + + def test_native_main_positive_control_reaches_only_stubbed_qm_boundary(self): + for mode in ("default", "advanced"): + with self.subTest(mode=mode): + result, state, contents, requests, files = self.run_wizard("tinycore", mode, main=True) + self.assertEqual(result.stdout.count("QM_BOUNDARY:create "), 1, result.stdout + result.stderr) + self.assertIn("GPU_BOUNDARY", result.stdout) + self.assertEqual(contents, RAW) + + def test_custom_loader_selection_keeps_existing_path_without_network(self): + result, state, contents, requests, files = self.run_loader( + functions=("select_loader", "select_custom_image"), invoke="select_loader", + before=r''' +whiptail() { + if [[ "$*" == *'SELECT CUSTOM IMAGE'* ]]; then + printf '%s/tinycore.img' "$IMAGES_DIR" >&2 + else + printf '4' >&2 + fi +} +''') + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(state["LOADER_TYPE"], "custom") + self.assertEqual(state["FILE"], "tinycore.img") + self.assertTrue(state["LOADER_FILE"].endswith("/tinycore.img")) + self.assertEqual(contents, b"previous verified image") + self.assertEqual(requests, []) + self.assertEqual(files, ["tinycore.img"]) + + def test_zip_extracts_only_expected_image_not_sibling_files(self): + for loader, name, member in (("arc", "arc-26.10.img.zip", "arc.img"), + ("redpill", "rr-26.10.img.zip", "rr.img")): + with self.subTest(loader=loader): + result, state, contents, requests, files = self.run_loader( + loader, names=[name], payload=zip_bytes(member, extras=( + ("sha256sum", b"fixture checksum text"), + ("other.img", b"must not be published")))) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(contents, RAW) + self.assertEqual(files, [member]) + + def test_alpine_selection_is_not_pinned_to_current_release(self): + name = "alpine-redpill.v2.0.1.m-shell.img.gz" + result, state, contents, requests, files = self.run_loader(names=[name, *VARIANTS]) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(contents, RAW) + self.assertEqual(requests[-1], "/asset/" + name) + + def test_variants_or_legacy_family_never_substitute_for_plain_alpine(self): + self.assert_failed_acquisition(self.run_loader( + names=[*VARIANTS, "tinycore-redpill.v1.0.0.m-shell.img.gz"])) + + def test_staging_or_promotion_failure_keeps_previous_image(self): + for boundary in ("mktemp", "mv"): + with self.subTest(boundary=boundary): + self.assert_failed_acquisition(self.run_loader( + before=f"{boundary}() {{ return 1; }}")) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/vm/synology.sh b/scripts/vm/synology.sh index 0de40dab..f8478cb7 100644 --- a/scripts/vm/synology.sh +++ b/scripts/vm/synology.sh @@ -12,12 +12,12 @@ # Description: # Creates and configures a Synology DSM (DiskStation Manager) # virtual machine on Proxmox VE. Downloads one of the supported -# loaders (Arc, RR, TinyCore M-shell, or a custom one), imports +# loaders (Arc, RR, Alpine M-shell, or a custom one), imports # it as the VM boot disk, and prepares CPU/RAM/network/storage # through the ProxMenux default or advanced wizard. # # Features: -# - Supports Arc, RR, TinyCore M-shell and Custom loaders. +# - Supports Arc, RR, Alpine M-shell and Custom loaders. # - Default and Advanced configuration modes. # - Virtual disks (SATA, up to 6) or physical disk passthrough. # - Imports the loader as IDE for maximum compatibility. @@ -755,7 +755,7 @@ function select_loader() { LOADER_OPTION=$(whiptail --backtitle "ProxMenuX" --title "SELECT LOADER" --menu "$(translate "Choose a loader for Synology DSM:")" 15 70 4 \ "1" "AuxXxilium Arc Loader" \ "2" "RedPill Loader (RROrg - RR)" \ - "3" "TinyCore RedPill Loader (PeterSuh-Q3 M-shell)" \ + "3" "Alpine RedPill Loader (PeterSuh-Q3 M-shell)" \ "4" "Custom Loader Image (from $IMAGES_DIR)" \ 3>&1 1>&2 2>&3) @@ -780,7 +780,7 @@ function select_loader() { ;; 3) LOADER_TYPE="tinycore" - LOADER_NAME="TinyCore RedPill M-shell" + LOADER_NAME="Alpine RedPill M-shell" LOADER_URL="https://github.com/PeterSuh-Q3/tinycore-redpill/" echo -e "${DGN}${TAB}Selected Loader: ${BGN}$LOADER_NAME${CL}" download_loader @@ -836,6 +836,9 @@ function select_custom_image() { # Download Loader # ========================================================== function download_loader() { + local repo image_name asset_pattern metadata download_url archive stage + FILE="" + LOADER_FILE="" echo -e "${DGN}${TAB}Retrieving the URL for the ${BGN}$LOADER_NAME loader${CL}" @@ -849,72 +852,81 @@ function download_loader() { fi msg_ok "Installed unzip successfully." fi - - case $LOADER_TYPE in + + asset_pattern='^[^/]+[.]img[.]zip$' + case "$LOADER_TYPE" in arc) - curl -s https://api.github.com/repos/AuxXxilium/arc/releases/latest \ - | grep "browser_download_url.*\.img\.zip" \ - | cut -d '"' -f 4 \ - | xargs wget -q --show-progress -O "$IMAGES_DIR/arc.img.zip" - - if [ -f "$IMAGES_DIR/arc.img.zip" ]; then - cd "$IMAGES_DIR" - unzip -q arc.img.zip - rm arc.img.zip - FILE="arc.img" - LOADER_FILE="$IMAGES_DIR/$FILE" - cd - > /dev/null - else - msg_error "Failed to download $LOADER_NAME loader" - sleep 1 - select_loader - fi + repo="AuxXxilium/arc" + image_name="arc.img" ;; - redpill) - curl -s https://api.github.com/repos/RROrg/rr/releases/latest \ - | grep "browser_download_url.*\.img\.zip" \ - | cut -d '"' -f 4 \ - | xargs wget -q --show-progress -O "$IMAGES_DIR/rr.img.zip" - - if [ -f "$IMAGES_DIR/rr.img.zip" ]; then - cd "$IMAGES_DIR" - msg_info "Unzipping $LOADER_NAME loader. Please wait..." - unzip -qo rr.img.zip - msg_ok "Unzipped $LOADER_NAME loader successfully." - rm -f rr.img.zip - FILE="rr.img" - LOADER_FILE="$IMAGES_DIR/$FILE" - cd - > /dev/null - fi - + repo="RROrg/rr" + image_name="rr.img" ;; - tinycore) - curl -s https://api.github.com/repos/PeterSuh-Q3/tinycore-redpill/releases/latest \ - | grep "browser_download_url.*tinycore-redpill.v.*img.gz" \ - | cut -d '"' -f 4 \ - | xargs wget -q --show-progress -O "$IMAGES_DIR/tinycore.img.gz" - - if [ -f "$IMAGES_DIR/tinycore.img.gz" ]; then - cd "$IMAGES_DIR" - - msg_info "Unzipping $LOADER_NAME loader. Please wait..." - gunzip -f tinycore.img.gz 2> /dev/null - msg_ok "Unzipped $LOADER_NAME loader successfully." - FILE="tinycore.img" - LOADER_FILE="$IMAGES_DIR/$FILE" - cd - > /dev/null - - else - msg_error "Failed to download $LOADER_NAME loader" - sleep 1 - select_loader - - fi + repo="PeterSuh-Q3/tinycore-redpill" + image_name="tinycore.img" + # Plain Alpine M-shell only: never fall back to 5GB, XTCRP or VMDK. + asset_pattern='^alpine-redpill[.]v[0-9]+([.][0-9]+)*[.]m-shell[.]img[.]gz$' ;; + *) return 1 ;; esac - + + if ! metadata=$(curl -fsS --connect-timeout 10 --max-time 30 \ + "https://api.github.com/repos/$repo/releases/latest"); then + msg_error "$(translate "Failed to retrieve loader release metadata.")" + return 1 + fi + # Parse the JSON document, not its formatting. Ambiguity is an error. + if ! download_url=$(jq -ser --arg pattern "$asset_pattern" ' + if length != 1 then error("Expected one release document") else .[0] end + | .assets + | if type != "array" then error("Expected release assets") else . end + | map(select(.name | type == "string") | select(.name | test($pattern))) + | if length != 1 then error("Expected one matching loader asset") else .[0] end + | .name as $name | .browser_download_url + | if type != "string" then error("Expected download URL") else . end + | select(test("^https://[^[:space:][:cntrl:]]+$") and endswith("/" + $name)) + ' <<< "$metadata"); then + msg_error "$(translate "No unambiguous loader download found in release metadata.")" + return 1 + fi + + if ! stage=$(mktemp -d "$IMAGES_DIR/.synology-loader.XXXXXX"); then + msg_error "$(translate "Failed to prepare loader download directory.")" + return 1 + fi + archive="$stage/loader.archive" + if ! wget -q --show-progress -O "$archive" "$download_url" || [[ ! -s "$archive" ]]; then + rm -rf "$stage" + msg_error "$(translate "Failed to download loader image.")" + return 1 + fi + local extracted=false zip_members + if [[ "$LOADER_TYPE" == "tinycore" ]]; then + if gzip -dc "$archive" > "$stage/$image_name" 2>/dev/null; then + extracted=true + fi + elif zip_members=$(unzip -Z1 "$archive" 2>/dev/null) \ + && [[ $(printf '%s\n' "$zip_members" | grep -Fxc "$image_name") -eq 1 ]] \ + && unzip -p "$archive" "$image_name" > "$stage/$image_name" 2>/dev/null; then + extracted=true + fi + if [[ "$extracted" != true || ! -s "$stage/$image_name" ]]; then + rm -rf "$stage" + msg_error "$(translate "Failed to extract a nonempty loader image.")" + return 1 + fi + # The staging directory shares the destination filesystem: publish only a + # complete image, leaving any previous image untouched on failure. + if ! mv -fT "$stage/$image_name" "$IMAGES_DIR/$image_name"; then + rm -rf "$stage" + msg_error "$(translate "Failed to save loader image.")" + return 1 + fi + rm -rf "$stage" + FILE="$image_name" + LOADER_FILE="$IMAGES_DIR/$FILE" msg_ok "Downloaded ${CL}${BL}${FILE}${CL} to ${IMAGES_DIR}" } # =======================================================