//! Shared release-manifest type for the signed release-channel pull pipeline. //! //! The publisher tool (`crates/release-tool`) builds a [`ReleaseManifest`], //! serializes it to canonical bytes, signs those bytes with the channel's //! Ed25519 private key, and publishes the resulting [`SignedManifest`] as //! `manifest.json` on the release channel. The gateway (`huskies-server`) //! fetches that file, re-serializes the embedded manifest with //! [`ReleaseManifest::canonical_bytes`], and verifies the signature against //! its pinned public key before trusting anything in it. //! //! Keeping the type in its own dependency-light crate lets both sides agree //! on the exact byte representation to sign/verify without the server crate //! ever linking signing code, and without the publisher tool depending on //! the full `huskies` server crate. use serde::{Deserialize, Serialize}; /// The signed payload describing one published release artifact. /// /// Field order is significant: [`ReleaseManifest::canonical_bytes`] relies on /// `serde_json`'s struct serialization preserving declaration order, so the /// signer and verifier always agree on the exact bytes being signed. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ReleaseManifest { /// Filename of the artifact within the channel (e.g. `huskies-linux-arm64`). pub artifact: String, /// Lowercase hex sha256 digest of the artifact's bytes. pub sha256: String, /// Version identifier — the short git commit hash the artifact was built from. pub version: String, /// Release channel name this manifest was signed for (e.g. `stable`). pub channel: String, /// Unix timestamp (seconds) the manifest was signed at. /// /// Used for rollback/replay detection: a pull refuses any manifest whose /// timestamp is not strictly newer than the currently installed one. pub timestamp: i64, } impl ReleaseManifest { /// Serialize this manifest deterministically for signing and verification. /// /// Both the publisher and the gateway construct this independently from /// their own in-memory `ReleaseManifest` value — the manifest.json file's /// exact on-disk byte layout is never itself the signed payload. pub fn canonical_bytes(&self) -> Vec { serde_json::to_vec(self).expect("ReleaseManifest serialization cannot fail") } } /// A [`ReleaseManifest`] plus its Ed25519 signature (lowercase hex), as /// published to a release channel's `manifest.json`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SignedManifest { /// The manifest describing the published artifact. pub manifest: ReleaseManifest, /// Hex-encoded Ed25519 signature over `manifest.canonical_bytes()`. pub signature: String, } #[cfg(test)] mod tests { use super::*; fn sample() -> ReleaseManifest { ReleaseManifest { artifact: "huskies-linux-arm64".to_string(), sha256: "a".repeat(64), version: "abc1234".to_string(), channel: "stable".to_string(), timestamp: 1_700_000_000, } } #[test] fn canonical_bytes_is_deterministic() { let m = sample(); assert_eq!(m.canonical_bytes(), m.canonical_bytes()); } #[test] fn canonical_bytes_changes_with_any_field() { let m1 = sample(); let mut m2 = sample(); m2.timestamp += 1; assert_ne!(m1.canonical_bytes(), m2.canonical_bytes()); } #[test] fn signed_manifest_roundtrips_through_json() { let signed = SignedManifest { manifest: sample(), signature: "deadbeef".to_string(), }; let json = serde_json::to_string(&signed).unwrap(); let parsed: SignedManifest = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.manifest, signed.manifest); assert_eq!(parsed.signature, signed.signature); } #[test] fn manifest_missing_field_fails_to_parse() { let bad = serde_json::json!({ "artifact": "huskies-linux-arm64", "sha256": "a".repeat(64), "version": "abc1234", "channel": "stable" // timestamp missing }); let result: Result = serde_json::from_value(bad); assert!( result.is_err(), "manifest missing a field must fail to parse" ); } #[test] fn signed_manifest_missing_signature_fails_to_parse() { let bad = serde_json::json!({ "manifest": sample() }); let result: Result = serde_json::from_value(bad); assert!( result.is_err(), "signed manifest missing signature must fail to parse" ); } }