diff options
| -rw-r--r-- | docs/next/CHANGELOG.md | 1 | ||||
| -rw-r--r-- | justfile | 4 | ||||
| -rw-r--r-- | scripts/changelog.py | 46 | ||||
| -rw-r--r-- | scripts/test_changelog.py | 77 | ||||
| -rw-r--r-- | scripts/test_unix_installer.py | 171 | ||||
| -rw-r--r-- | src/remote/attach.rs | 44 | ||||
| -rw-r--r-- | src/update.rs | 68 | ||||
| -rw-r--r-- | website/install.ps1 | 17 | ||||
| -rwxr-xr-x | website/install.sh | 36 |
9 files changed, 421 insertions, 43 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 5ec122d5..31008c6f 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -8,6 +8,7 @@ - The plugin marketplace now discovers valid manifests at repository roots and subdirectories, groups multiple plugins under each repository, and publishes their versions and exact default-branch commits. ### Fixed +- Stable direct installs, self-updates, and remote helper downloads now require and verify the SHA-256 digest published for each GitHub release asset. - Configs containing the retired Herdr-written `ui.agent_panel_scope` setting no longer report it as an unknown key after upgrades. (#2292) - Claude Code confirmation prompts using `Enter to confirm ยท Esc to cancel` now report `blocked` instead of `idle`. (#2268) - Sidebar agent lists keep scrolling when differently sized clients are attached to the same session. (#2255, thanks @aiworkflowpro) @@ -3,7 +3,7 @@ # Run tests test: cargo nextest run --locked --status-level fail --final-status-level fail --failure-output final --success-output never - python3 -m unittest scripts.test_agent_detection_manifest_check scripts.test_changelog scripts.test_config_reference_check scripts.test_docs_translation_parity scripts.test_hermes_integration_asset scripts.test_package_windows_conpty scripts.test_preview scripts.test_vendor_libghostty_vt scripts.test_vendor_portable_pty + python3 -m unittest scripts.test_agent_detection_manifest_check scripts.test_changelog scripts.test_config_reference_check scripts.test_docs_translation_parity scripts.test_hermes_integration_asset scripts.test_package_windows_conpty scripts.test_preview scripts.test_unix_installer scripts.test_vendor_libghostty_vt scripts.test_vendor_portable_pty just integration-assets-test just plugin-marketplace-test @@ -38,7 +38,7 @@ windows-lint: # Check formatting + run unit tests + Windows target lint + maintenance script tests [unix] check: ci windows-lint - python3 -m unittest scripts.test_agent_detection_manifest_check scripts.test_changelog scripts.test_config_reference_check scripts.test_docs_translation_parity scripts.test_hermes_integration_asset scripts.test_package_windows_conpty scripts.test_preview scripts.test_vendor_libghostty_vt scripts.test_vendor_portable_pty + python3 -m unittest scripts.test_agent_detection_manifest_check scripts.test_changelog scripts.test_config_reference_check scripts.test_docs_translation_parity scripts.test_hermes_integration_asset scripts.test_package_windows_conpty scripts.test_preview scripts.test_unix_installer scripts.test_vendor_libghostty_vt scripts.test_vendor_portable_pty @echo "docs reminder: if this changes user-facing behavior, make sure the relevant release docs are updated or called out before release." [script("powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File")] diff --git a/scripts/changelog.py b/scripts/changelog.py index a5d2daf2..4d023ccc 100644 --- a/scripts/changelog.py +++ b/scripts/changelog.py @@ -186,11 +186,26 @@ def normalize_assets(value: Any, label: str) -> dict[str, str]: return normalized_assets +def normalize_sha256(value: Any, label: str) -> dict[str, str]: + if not isinstance(value, dict): + raise ChangelogError(f"{label} must be an object") + + checksums: dict[str, str] = {} + for target in ASSET_TARGETS: + checksum = value.get(target) + if not isinstance(checksum, str) or re.fullmatch( + r"[0-9a-fA-F]{64}", checksum.strip() + ) is None: + raise ChangelogError(f"{label} has invalid SHA-256 for {target}") + checksums[target] = checksum.strip().lower() + return checksums + + def normalize_release_metadata(value: Any, label: str, version: str) -> dict[str, Any]: if not isinstance(value, dict): raise ChangelogError(f"{label} must be an object") - allowed_keys = {"notes", "announcement", "assets", "protocol"} + allowed_keys = {"notes", "announcement", "assets", "sha256", "protocol"} extra_keys = sorted(set(value) - allowed_keys) if extra_keys: raise ChangelogError(f"{label} has unsupported field(s): {', '.join(extra_keys)}") @@ -213,6 +228,8 @@ def normalize_release_metadata(value: Any, label: str, version: str) -> dict[str metadata["assets"] = normalize_assets(value.get("assets"), f"{label}.assets") else: metadata["assets"] = default_release_assets(version) + if "sha256" in value: + metadata["sha256"] = normalize_sha256(value.get("sha256"), f"{label}.sha256") announcement = normalize_announcement(value.get("announcement"), label) if announcement is not None: metadata["announcement"] = announcement @@ -243,6 +260,7 @@ def build_latest_json( version: str, notes: str, assets: dict[str, str], + sha256: dict[str, str], protocol: int | None = None, announcement: dict[str, str] | None = None, releases: dict[str, Any] | None = None, @@ -256,6 +274,7 @@ def build_latest_json( protocol = read_protocol_version() ordered_assets = normalize_assets(assets, "assets") + ordered_sha256 = normalize_sha256(sha256, "sha256") normalized_announcement = normalize_announcement(announcement, "root") archived_releases = normalize_releases(releases) current_metadata: dict[str, Any] = { @@ -263,6 +282,7 @@ def build_latest_json( "protocol": protocol, "assets": ordered_assets, } + current_metadata["sha256"] = ordered_sha256 if normalized_announcement is not None: current_metadata["announcement"] = normalized_announcement archived_releases[normalized_version] = current_metadata @@ -277,6 +297,7 @@ def build_latest_json( "notes": normalized_notes, "assets": ordered_assets, } + manifest["sha256"] = ordered_sha256 if normalized_announcement is not None: manifest["announcement"] = normalized_announcement manifest["releases"] = archived_releases @@ -322,7 +343,14 @@ def manifest_from_release_payload( if isinstance(name, str) and name not in release_assets: release_assets[name] = asset + missing_assets = [name for name in EXPECTED_ASSET_NAMES.values() if name not in release_assets] + if missing_assets: + raise ChangelogError( + f"GitHub release v{normalized_version} is missing asset {missing_assets[0]}" + ) + manifest_assets: dict[str, str] = {} + manifest_sha256: dict[str, str] = {} for target, asset_name in EXPECTED_ASSET_NAMES.items(): asset = release_assets.get(asset_name) if not isinstance(asset, dict): @@ -330,13 +358,19 @@ def manifest_from_release_payload( url = str(asset.get("url") or "").strip() if not url: raise ChangelogError(f"GitHub release asset {asset_name} is missing a download URL") + digest = str(asset.get("digest") or "").strip() + digest_match = re.fullmatch(r"sha256:([0-9a-fA-F]{64})", digest) + if digest_match is None: + raise ChangelogError(f"GitHub release asset {asset_name} is missing a SHA-256 digest") manifest_assets[target] = url + manifest_sha256[target] = digest_match.group(1).lower() return { "version": normalized_version, "protocol": protocol if protocol is not None else read_protocol_version(), "notes": notes, "assets": manifest_assets, + "sha256": manifest_sha256, } @@ -358,12 +392,14 @@ def canonicalize_manifest(manifest: dict[str, Any], label: str) -> dict[str, Any raise ChangelogError(f"{label} is missing an assets object") normalized_assets = normalize_assets(assets, f"{label} assets") + normalized_sha256 = normalize_sha256(manifest.get("sha256"), f"{label} sha256") return { "version": normalize_version(version), "protocol": protocol, "notes": notes.strip(), "assets": normalized_assets, + "sha256": normalized_sha256, } @@ -391,6 +427,10 @@ def ensure_current_release_assets_are_mirrored(manifest: dict[str, Any], label: raise ChangelogError( f"{label} releases.{canonical['version']}.assets must match top-level assets" ) + if metadata.get("sha256") != canonical["sha256"]: + raise ChangelogError( + f"{label} releases.{canonical['version']}.sha256 must match top-level sha256" + ) def load_text(path: Path) -> str: @@ -431,6 +471,9 @@ def archived_releases_from_current_manifest(manifest: dict[str, Any]) -> dict[st metadata["assets"] = normalize_assets(assets, "current root assets") else: metadata["assets"] = default_release_assets(normalized_version) + sha256 = manifest.get("sha256") + if isinstance(sha256, dict): + metadata["sha256"] = normalize_sha256(sha256, "current root sha256") announcement = normalize_announcement(manifest.get("announcement"), "current root") if announcement is not None: metadata["announcement"] = announcement @@ -592,6 +635,7 @@ def cmd_sync_latest_json(args: argparse.Namespace) -> int: version, str(new_manifest["notes"]), dict(new_manifest["assets"]), + sha256=dict(new_manifest["sha256"]), protocol=int(new_manifest["protocol"]), announcement=announcement, releases=archived_releases_from_current_manifest(current_manifest), diff --git a/scripts/test_changelog.py b/scripts/test_changelog.py index dff19caf..ad384734 100644 --- a/scripts/test_changelog.py +++ b/scripts/test_changelog.py @@ -24,6 +24,31 @@ from scripts.changelog import ( ) +def release_sha256() -> dict[str, str]: + return { + "linux-x86_64": "a" * 64, + "linux-aarch64": "b" * 64, + "macos-x86_64": "c" * 64, + "macos-aarch64": "d" * 64, + } + + +def release_assets_with_digests() -> list[dict[str, str]]: + return [ + { + "name": f"herdr-{target}", + "url": f"https://example.com/{target}", + "digest": f"sha256:{digest * 64}", + } + for target, digest in ( + ("linux-x86_64", "a"), + ("linux-aarch64", "b"), + ("macos-x86_64", "c"), + ("macos-aarch64", "d"), + ) + ] + + class ChangelogScriptTests(unittest.TestCase): def test_prepare_release_moves_unreleased_into_versioned_section(self) -> None: original = """# Changelog\n\n## Unreleased\n\n### Fixed\n- Smoothed Claude flapping.\n\n## [0.1.0] - 2026-03-27\n\n### Added\n- Initial release.\n""" @@ -55,6 +80,7 @@ class ChangelogScriptTests(unittest.TestCase): "0.1.1", "\n### Fixed\n- One\n\n", default_release_assets("0.1.1"), + release_sha256(), ) ) @@ -67,6 +93,7 @@ class ChangelogScriptTests(unittest.TestCase): "v0.1.1", "### Fixed\n- Smoothed Claude flapping.\n", default_release_assets("0.1.1"), + release_sha256(), ) ) @@ -83,6 +110,8 @@ class ChangelogScriptTests(unittest.TestCase): }, ) self.assertEqual(manifest["releases"]["0.1.1"]["assets"], manifest["assets"]) + self.assertEqual(manifest["sha256"], release_sha256()) + self.assertEqual(manifest["releases"]["0.1.1"]["sha256"], manifest["sha256"]) def test_build_latest_json_embeds_product_announcement(self) -> None: manifest = json.loads( @@ -90,6 +119,7 @@ class ChangelogScriptTests(unittest.TestCase): "0.1.1", "### Fixed\n- One", default_release_assets("0.1.1"), + release_sha256(), announcement={"id": "keybinding-v2", "title": "Keybind Refactor", "body": "body"}, ) ) @@ -109,6 +139,7 @@ class ChangelogScriptTests(unittest.TestCase): "0.1.2", "### Fixed\n- Two", default_release_assets("0.1.2"), + release_sha256(), releases={"0.1.1": {"notes": "### Fixed\n- One"}}, ) ) @@ -127,6 +158,7 @@ class ChangelogScriptTests(unittest.TestCase): "0.1.2", "### Fixed\n- Two", default_release_assets("0.1.2"), + release_sha256(), releases={"0.1.1": {"notes": "### Fixed\n- One", "assets": assets}}, ) ) @@ -139,6 +171,7 @@ class ChangelogScriptTests(unittest.TestCase): "0.1.2", "### Fixed\n- Two", default_release_assets("0.1.2"), + release_sha256(), releases={"0.1.1": {"notes": "### Fixed\n- One", "protocol": 7}}, ) ) @@ -151,6 +184,7 @@ class ChangelogScriptTests(unittest.TestCase): "0.1.2", "### Fixed\n- Two", default_release_assets("0.1.2"), + release_sha256(), releases={ "0.1.1": { "notes": "### Breaking Changes\n- The client/server protocol is now version 7." @@ -280,19 +314,14 @@ class ChangelogScriptTests(unittest.TestCase): finally: path.unlink(missing_ok=True) - def test_manifest_from_release_payload_uses_release_body_and_asset_urls(self) -> None: + def test_manifest_from_release_payload_uses_release_body_and_asset_digests(self) -> None: manifest = manifest_from_release_payload( { "tagName": "v0.1.1", "isDraft": False, "isPrerelease": False, "body": "### Fixed\n- One\n", - "assets": [ - {"name": "herdr-linux-x86_64", "url": "https://example.com/linux-x86_64"}, - {"name": "herdr-linux-aarch64", "url": "https://example.com/linux-aarch64"}, - {"name": "herdr-macos-x86_64", "url": "https://example.com/macos-x86_64"}, - {"name": "herdr-macos-aarch64", "url": "https://example.com/macos-aarch64"}, - ], + "assets": release_assets_with_digests(), }, "0.1.1", ) @@ -309,6 +338,7 @@ class ChangelogScriptTests(unittest.TestCase): "macos-x86_64": "https://example.com/macos-x86_64", "macos-aarch64": "https://example.com/macos-aarch64", }, + "sha256": release_sha256(), }, ) @@ -319,12 +349,7 @@ class ChangelogScriptTests(unittest.TestCase): "isDraft": False, "isPrerelease": False, "body": "### Fixed\n- One\n", - "assets": [ - {"name": "herdr-linux-x86_64", "url": "https://example.com/linux-x86_64"}, - {"name": "herdr-linux-aarch64", "url": "https://example.com/linux-aarch64"}, - {"name": "herdr-macos-x86_64", "url": "https://example.com/macos-x86_64"}, - {"name": "herdr-macos-aarch64", "url": "https://example.com/macos-aarch64"}, - ], + "assets": release_assets_with_digests(), }, "0.1.1", protocol=42, @@ -332,6 +357,22 @@ class ChangelogScriptTests(unittest.TestCase): self.assertEqual(manifest["protocol"], 42) + def test_manifest_from_release_payload_rejects_missing_digest(self) -> None: + assets = release_assets_with_digests() + assets[0].pop("digest") + + with self.assertRaisesRegex(ChangelogError, "missing a SHA-256 digest"): + manifest_from_release_payload( + { + "tagName": "v0.1.1", + "isDraft": False, + "isPrerelease": False, + "body": "### Fixed\n- One\n", + "assets": assets, + }, + "0.1.1", + ) + def test_manifest_from_release_payload_rejects_missing_asset(self) -> None: with self.assertRaisesRegex(ChangelogError, "missing asset herdr-macos-aarch64"): manifest_from_release_payload( @@ -386,6 +427,7 @@ class ChangelogScriptTests(unittest.TestCase): "macos-x86_64": "https://example.com/macos-x86_64", "macos-aarch64": "https://example.com/macos-aarch64", }, + "sha256": release_sha256(), } expected = { "version": "0.1.1", @@ -397,6 +439,7 @@ class ChangelogScriptTests(unittest.TestCase): "macos-x86_64": "https://example.com/macos-x86_64", "macos-aarch64": "https://example.com/macos-aarch64", }, + "sha256": release_sha256(), } canonical = ensure_manifest_matches_expected(actual, expected, "test manifest") @@ -410,10 +453,12 @@ class ChangelogScriptTests(unittest.TestCase): "protocol": read_protocol_version(), "notes": "### Fixed\n- One", "assets": assets, + "sha256": release_sha256(), "releases": { "0.1.1": { "notes": "### Fixed\n- One", "assets": assets, + "sha256": release_sha256(), } }, }, @@ -428,10 +473,12 @@ class ChangelogScriptTests(unittest.TestCase): "protocol": read_protocol_version(), "notes": "### Fixed\n- One", "assets": default_release_assets("0.1.1"), + "sha256": release_sha256(), "releases": { "0.1.1": { "notes": "### Fixed\n- One", "assets": default_release_assets("0.1.0"), + "sha256": release_sha256(), } }, }, @@ -451,6 +498,7 @@ class ChangelogScriptTests(unittest.TestCase): "macos-x86_64": "https://example.com/macos-x86_64", "macos-aarch64": "https://example.com/macos-aarch64", }, + "sha256": release_sha256(), }, { "version": "0.1.1", @@ -462,6 +510,7 @@ class ChangelogScriptTests(unittest.TestCase): "macos-x86_64": "https://example.com/macos-x86_64", "macos-aarch64": "https://example.com/macos-aarch64", }, + "sha256": release_sha256(), }, "test manifest", ) @@ -483,12 +532,14 @@ class ChangelogScriptTests(unittest.TestCase): "protocol": read_protocol_version() + 1, "notes": "### Fixed\n- One", "assets": default_release_assets("0.1.1"), + "sha256": release_sha256(), } expected = { "version": "0.1.1", "protocol": read_protocol_version(), "notes": "### Fixed\n- One", "assets": default_release_assets("0.1.1"), + "sha256": release_sha256(), } with self.assertRaisesRegex(ChangelogError, "does not match"): diff --git a/scripts/test_unix_installer.py b/scripts/test_unix_installer.py new file mode 100644 index 00000000..82de55c8 --- /dev/null +++ b/scripts/test_unix_installer.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +INSTALLER = REPO_ROOT / "website" / "install.sh" +REQUIRED_COMMANDS = ("awk", "cat", "chmod", "cp", "mkdir", "mktemp", "mv", "rm") + + +class UnixInstallerTests(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory(prefix="herdr-installer-test-") + self.root = Path(self.temp_dir.name) + self.bin_dir = self.root / "bin" + self.bin_dir.mkdir() + self.install_dir = self.root / "install" + self.payload = self.root / "payload" + self.payload.write_bytes(b"fake-herdr-binary\n") + self.expected_sha256 = hashlib.sha256(self.payload.read_bytes()).hexdigest() + + for command in REQUIRED_COMMANDS: + path = shutil.which(command) + if path is None: + self.fail(f"test host is missing required command: {command}") + (self.bin_dir / command).symlink_to(path) + + self._write_executable( + "uname", + """#!/bin/sh +case "$1" in + -s) echo Linux ;; + -m) echo x86_64 ;; + *) exit 1 ;; +esac +""", + ) + self._write_executable( + "curl", + """#!/bin/sh +out="" +previous="" +for argument in "$@"; do + if [ "$previous" = "-o" ]; then + out="$argument" + break + fi + previous="$argument" +done +if [ -n "$out" ]; then + cp "$FAKE_PAYLOAD" "$out" +else + cat "$FAKE_MANIFEST" +fi +""", + ) + + def tearDown(self) -> None: + self.temp_dir.cleanup() + + def _write_executable(self, name: str, content: str) -> None: + path = self.bin_dir / name + path.write_text(content, encoding="utf-8") + path.chmod(0o755) + + def _select_checksum_tool(self, tool: str) -> None: + if tool == "sha256sum": + path = shutil.which("sha256sum") + if path is None: + self.fail("test host is missing sha256sum") + (self.bin_dir / "sha256sum").symlink_to(path) + return + + if tool == "shasum": + sha256sum = shutil.which("sha256sum") + if sha256sum is None: + self.fail("test host is missing sha256sum for the shasum fixture") + self._write_executable( + "shasum", + f"""#!/bin/sh +[ "$1" = "-a" ] && [ "$2" = "256" ] || exit 2 +shift 2 +exec {sha256sum} "$@" +""", + ) + return + + if tool == "openssl": + path = shutil.which("openssl") + if path is None: + self.fail("test host is missing openssl") + (self.bin_dir / "openssl").symlink_to(path) + return + + self.fail(f"unknown checksum tool fixture: {tool}") + + def _write_manifest(self, checksum: str | None) -> Path: + manifest: dict[str, object] = { + "version": "9.9.9", + "assets": { + "linux-x86_64": "https://example.invalid/herdr-linux-x86_64" + }, + } + if checksum is not None: + manifest["sha256"] = {"linux-x86_64": checksum} + path = self.root / "latest.json" + path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + return path + + def _run_installer(self, checksum: str | None, tool: str = "sha256sum") -> subprocess.CompletedProcess[str]: + self._select_checksum_tool(tool) + manifest = self._write_manifest(checksum) + env = { + **os.environ, + "PATH": str(self.bin_dir), + "FAKE_MANIFEST": str(manifest), + "FAKE_PAYLOAD": str(self.payload), + "HERDR_INSTALL_DIR": str(self.install_dir), + } + return subprocess.run( + ["/bin/sh", str(INSTALLER)], + env=env, + capture_output=True, + text=True, + check=False, + ) + + def test_valid_download_uses_each_supported_checksum_tool(self) -> None: + for tool in ("sha256sum", "shasum", "openssl"): + with self.subTest(tool=tool): + shutil.rmtree(self.install_dir, ignore_errors=True) + for name in ("sha256sum", "shasum", "openssl"): + (self.bin_dir / name).unlink(missing_ok=True) + + result = self._run_installer(self.expected_sha256.upper(), tool) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual((self.install_dir / "herdr").read_bytes(), self.payload.read_bytes()) + + def test_checksum_mismatch_does_not_replace_existing_binary(self) -> None: + self.install_dir.mkdir() + installed = self.install_dir / "herdr" + installed.write_bytes(b"existing-herdr\n") + + result = self._run_installer("0" * 64) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("checksum did not match", result.stderr) + self.assertEqual(installed.read_bytes(), b"existing-herdr\n") + + def test_missing_checksum_fails_without_replacing_existing_binary(self) -> None: + self.install_dir.mkdir() + installed = self.install_dir / "herdr" + installed.write_bytes(b"existing-herdr\n") + + result = self._run_installer(None) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("valid SHA-256 checksum", result.stderr) + self.assertEqual(installed.read_bytes(), b"existing-herdr\n") + + +if __name__ == "__main__": + unittest.main() diff --git a/src/remote/attach.rs b/src/remote/attach.rs index 4121d468..55124217 100644 --- a/src/remote/attach.rs +++ b/src/remote/attach.rs @@ -298,6 +298,8 @@ struct RemoteUpdateManifest { version: String, protocol: Option<u32>, assets: BTreeMap<String, RemoteAssetRef>, + #[serde(default)] + sha256: BTreeMap<String, String>, #[serde(default, deserialize_with = "deserialize_remote_manifest_releases")] releases: BTreeMap<String, RemoteReleaseMetadata>, } @@ -307,6 +309,8 @@ struct RemoteReleaseMetadata { protocol: Option<u32>, #[serde(default)] assets: BTreeMap<String, RemoteAssetRef>, + #[serde(default)] + sha256: BTreeMap<String, String>, } #[derive(Deserialize)] @@ -350,6 +354,7 @@ impl RemoteUpdateManifest { return Some(RemoteManifestReleaseRef { protocol: self.protocol, assets: &self.assets, + sha256: &self.sha256, }); } @@ -357,6 +362,7 @@ impl RemoteUpdateManifest { (!release.assets.is_empty()).then_some(RemoteManifestReleaseRef { protocol: release.protocol, assets: &release.assets, + sha256: &release.sha256, }) }) } @@ -366,6 +372,7 @@ impl RemoteUpdateManifest { struct RemoteManifestReleaseRef<'a> { protocol: Option<u32>, assets: &'a BTreeMap<String, RemoteAssetRef>, + sha256: &'a BTreeMap<String, String>, } fn current_version() -> String { @@ -1531,15 +1538,21 @@ fn remote_release_asset(asset_key: &str) -> io::Result<RemoteReleaseAsset> { ))); } } - release - .assets - .get(asset_key) - .map(remote_asset_info) - .ok_or_else(|| { - io::Error::other(format!( - "no {asset_key} binary in the release manifest for herdr {current_version}" - )) - }) + let asset = release.assets.get(asset_key).ok_or_else(|| { + io::Error::other(format!( + "no {asset_key} binary in the release manifest for herdr {current_version}" + )) + })?; + let mut asset = remote_asset_info(asset); + asset.sha256 = asset + .sha256 + .or_else(|| release.sha256.get(asset_key).cloned()); + if asset.sha256.is_none() { + return Err(io::Error::other(format!( + "release manifest asset {asset_key} is missing a SHA-256 checksum" + ))); + } + Ok(asset) } fn private_download_dir(asset_key: &str) -> io::Result<PathBuf> { @@ -2891,6 +2904,9 @@ mod tests { "assets": { "linux-x86_64": "https://example.com/latest" }, + "sha256": { + "linux-x86_64": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, "releases": { "1.2.3": { "assets": { @@ -2902,13 +2918,15 @@ mod tests { ) .unwrap(); + let release = manifest.release_for_version("1.2.3").unwrap(); assert_eq!( - manifest - .release_for_version("1.2.3") - .and_then(|release| release.assets.get("linux-x86_64")) - .map(RemoteAssetRef::url), + release.assets.get("linux-x86_64").map(RemoteAssetRef::url), Some("https://example.com/latest") ); + assert_eq!( + release.sha256.get("linux-x86_64").map(String::as_str), + Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + ); } #[test] diff --git a/src/update.rs b/src/update.rs index 2120fb3c..abc9663f 100644 --- a/src/update.rs +++ b/src/update.rs @@ -169,6 +169,8 @@ struct UpdateManifest { protocol: Option<u32>, notes: String, assets: BTreeMap<String, AssetRef>, + #[serde(default)] + sha256: BTreeMap<String, String>, announcement: Option<serde_json::Value>, #[serde(default, deserialize_with = "deserialize_manifest_releases")] releases: BTreeMap<String, serde_json::Value>, @@ -362,6 +364,13 @@ fn release_info_from_manifest(manifest: &UpdateManifest) -> Result<Option<Releas .get(&asset_key) .ok_or_else(|| format!("no binary for {asset_key} in update manifest"))?; let download_url = asset.url.clone(); + let sha256 = asset + .sha256 + .clone() + .or_else(|| manifest.sha256.get(&asset_key).cloned()) + .ok_or_else(|| { + format!("update manifest asset {asset_key} is missing a SHA-256 checksum") + })?; Ok(Some(ReleaseInfo { identity: latest.to_string(), @@ -372,7 +381,7 @@ fn release_info_from_manifest(manifest: &UpdateManifest) -> Result<Option<Releas #[cfg(not(windows))] target_protocol: manifest.protocol, download_url, - sha256: asset.sha256.clone(), + sha256: Some(sha256), notes_body, })) } @@ -3334,6 +3343,26 @@ mod tests { } #[test] + fn stable_update_requires_asset_checksum() { + let (os, arch) = platform_target(); + let asset_key = format!("{os}-{arch}"); + let json = format!( + r####"{{ + "version": "99.99.99", + "notes": "### Changed\n- One", + "assets": {{ + "{asset_key}": "https://example.com/herdr" + }} + }}"#### + ); + let manifest: UpdateManifest = serde_json::from_str(&json).unwrap(); + + assert!(release_info_from_manifest(&manifest) + .unwrap_err() + .contains("missing a SHA-256 checksum")); + } + + #[test] fn invalid_manifest_announcement_does_not_block_release_info() { let (os, arch) = platform_target(); let asset_key = format!("{os}-{arch}"); @@ -3348,7 +3377,10 @@ mod tests { "body": "### Heads up\n- Defaults changed" }}, "assets": {{ - "{asset_key}": "https://example.com/herdr" + "{asset_key}": {{ + "url": "https://example.com/herdr", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }} }} }}"#### ); @@ -3428,8 +3460,18 @@ mod tests { #[test] fn checked_in_website_manifest_matches_update_schema() { - let manifest: UpdateManifest = serde_json::from_str(include_str!("../website/latest.json")) - .expect("website/latest.json should match updater schema"); + #[derive(Deserialize)] + struct LegacyUpdateManifest { + assets: BTreeMap<String, String>, + } + + let json = include_str!("../website/latest.json"); + let legacy: LegacyUpdateManifest = serde_json::from_str(json) + .expect("website/latest.json should keep legacy string asset URLs"); + assert_eq!(legacy.assets.len(), 4); + + let manifest: UpdateManifest = + serde_json::from_str(json).expect("website/latest.json should match updater schema"); assert!(!manifest .metadata_for_version(&Version::parse(&manifest.version).unwrap()) @@ -3449,11 +3491,16 @@ mod tests { "macos-x86_64", "macos-aarch64", ] { - let url = &manifest + let asset = manifest .assets .get(target) - .unwrap_or_else(|| panic!("missing asset URL for {target}")) - .url; + .unwrap_or_else(|| panic!("missing asset URL for {target}")); + let url = &asset.url; + assert_eq!( + manifest.sha256.get(target).map(String::len), + Some(64), + "missing SHA-256 checksum for {target}" + ); assert!( url.contains(&format!("/releases/download/v{}/", manifest.version)), "unexpected release URL for {target}: {url}" @@ -3475,10 +3522,13 @@ mod tests { "macos-x86_64", "macos-aarch64", ] { - let url = assets + let asset = assets .get(target) - .and_then(serde_json::Value::as_str) + .cloned() .unwrap_or_else(|| panic!("missing asset URL for {version} {target}")); + let asset: AssetRef = serde_json::from_value(asset) + .unwrap_or_else(|_| panic!("invalid asset for {version} {target}")); + let url = &asset.url; assert!( url.contains(&format!("/releases/download/v{version}/")), "unexpected release URL for {version} {target}: {url}" diff --git a/website/install.ps1 b/website/install.ps1 index e5842dc8..b7ea80aa 100644 --- a/website/install.ps1 +++ b/website/install.ps1 @@ -154,12 +154,21 @@ function Get-ManifestAsset { throw "Release manifest does not include a binary for $Target." } + $sha256 = $null + $shaMapProperty = $Manifest.PSObject.Properties["sha256"] + if ($null -ne $shaMapProperty -and $null -ne $shaMapProperty.Value) { + $targetShaProperty = $shaMapProperty.Value.PSObject.Properties[$Target] + if ($null -ne $targetShaProperty -and -not [string]::IsNullOrWhiteSpace([string]$targetShaProperty.Value)) { + $sha256 = [string]$targetShaProperty.Value + } + } + $asset = $property.Value if ($asset -is [string]) { $url = [string]$asset return [PSCustomObject]@{ Url = $url - Sha256 = $null + Sha256 = $sha256 Format = if ($url.EndsWith(".zip", [System.StringComparison]::OrdinalIgnoreCase)) { "zip" } else { "exe" } } } @@ -180,10 +189,8 @@ function Get-ManifestAsset { throw "Release manifest asset $Target has unsupported format '$format'." } $shaProperty = $asset.PSObject.Properties["sha256"] - $sha256 = if ($null -eq $shaProperty -or [string]::IsNullOrWhiteSpace([string]$shaProperty.Value)) { - $null - } else { - [string]$shaProperty.Value + if ($null -ne $shaProperty -and -not [string]::IsNullOrWhiteSpace([string]$shaProperty.Value)) { + $sha256 = [string]$shaProperty.Value } return [PSCustomObject]@{ diff --git a/website/install.sh b/website/install.sh index 81c583ac..4e3ce40d 100755 --- a/website/install.sh +++ b/website/install.sh @@ -50,11 +50,38 @@ main() { exit } ')" + SHA256="$(printf '%s\n' "$MANIFEST" | awk -v target="\"${TARGET}\"" ' + /^[[:space:]]*"sha256"[[:space:]]*:/ { in_sha256 = 1; next } + in_sha256 && /^[[:space:]]*}/ { exit } + in_sha256 && index($0, target) { + sub(/^.*:[[:space:]]*"/, "") + sub(/".*$/, "") + print + exit + } + ')" VERSION="$(printf '%s\n' "$MANIFEST" | awk -F '"' '/^[[:space:]]*"version"[[:space:]]*:/ { print $4; exit }')" if [ -z "$URL" ]; then err "release manifest does not include a binary for ${TARGET}" fi + if [ "${#SHA256}" -ne 64 ]; then + err "release manifest does not include a valid SHA-256 checksum for ${TARGET}" + fi + if ! printf '%s\n' "$SHA256" | awk '/[^0-9A-Fa-f]/ { exit 1 }'; then + err "release manifest does not include a valid SHA-256 checksum for ${TARGET}" + fi + SHA256="$(printf '%s\n' "$SHA256" | awk '{ print tolower($0) }')" + + if command -v sha256sum >/dev/null 2>&1; then + SHA256_TOOL="sha256sum" + elif command -v shasum >/dev/null 2>&1; then + SHA256_TOOL="shasum" + elif command -v openssl >/dev/null 2>&1; then + SHA256_TOOL="openssl" + else + err "SHA-256 verification requires sha256sum, shasum, or openssl" + fi if [ -n "$VERSION" ]; then log "downloading v${VERSION}..." @@ -68,6 +95,15 @@ main() { err "download failed from ${URL}" fi + case "$SHA256_TOOL" in + sha256sum) ACTUAL_SHA256="$(sha256sum < "${TMP}/${BIN}" | awk '{ print $1 }')" ;; + shasum) ACTUAL_SHA256="$(shasum -a 256 < "${TMP}/${BIN}" | awk '{ print $1 }')" ;; + openssl) ACTUAL_SHA256="$(openssl dgst -sha256 < "${TMP}/${BIN}" | awk '{ print $NF }')" ;; + esac + if [ "$ACTUAL_SHA256" != "$SHA256" ]; then + err "downloaded Herdr checksum did not match" + fi + # install mkdir -p "$INSTALL_DIR" mv "${TMP}/${BIN}" "${INSTALL_DIR}/${BIN}" |
