aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/next/CHANGELOG.md1
-rw-r--r--justfile4
-rw-r--r--scripts/changelog.py46
-rw-r--r--scripts/test_changelog.py77
-rw-r--r--scripts/test_unix_installer.py171
-rw-r--r--src/remote/attach.rs44
-rw-r--r--src/update.rs68
-rw-r--r--website/install.ps117
-rwxr-xr-xwebsite/install.sh36
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)
diff --git a/justfile b/justfile
index a387f223..4294d06f 100644
--- a/justfile
+++ b/justfile
@@ -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}"