use serde::{Deserialize, Serialize};
use super::UpgradeError;
pub const RELEASE_TOPIC: &str = "x0x/release";
pub const SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReleaseManifest {
pub schema_version: u32,
pub version: String,
pub timestamp: u64,
pub assets: Vec<PlatformAsset>,
pub skill_url: String,
#[serde(with = "hex_bytes_32")]
pub skill_sha256: [u8; 32],
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlatformAsset {
pub target: String,
pub archive_url: String,
#[serde(with = "hex_bytes_32")]
pub archive_sha256: [u8; 32],
pub signature_url: String,
}
impl ReleaseManifest {
pub fn asset_for_current_platform(&self) -> Option<&PlatformAsset> {
let target = current_platform_target()?;
self.assets.iter().find(|a| a.target == target)
}
pub fn matches_platform(&self, target: &str) -> Option<&PlatformAsset> {
self.assets.iter().find(|a| a.target == target)
}
}
pub fn encode_signed_manifest(manifest_json: &[u8], signature: &[u8]) -> Vec<u8> {
let len = (manifest_json.len() as u32).to_be_bytes();
let mut payload = Vec::with_capacity(4 + manifest_json.len() + signature.len());
payload.extend_from_slice(&len);
payload.extend_from_slice(manifest_json);
payload.extend_from_slice(signature);
payload
}
pub fn decode_signed_manifest(payload: &[u8]) -> Result<(&[u8], &[u8]), UpgradeError> {
if payload.len() < 4 {
return Err(UpgradeError::InvalidManifest("payload too short".into()));
}
let len_bytes: [u8; 4] = payload[..4]
.try_into()
.map_err(|_| UpgradeError::InvalidManifest("length prefix not 4 bytes".into()))?;
let len = u32::from_be_bytes(len_bytes) as usize;
if payload.len() < 4 + len {
return Err(UpgradeError::InvalidManifest(
"payload truncated: manifest length exceeds payload size".into(),
));
}
let manifest_json = &payload[4..4 + len];
let signature = &payload[4 + len..];
if signature.is_empty() {
return Err(UpgradeError::InvalidManifest(
"missing signature after manifest".into(),
));
}
Ok((manifest_json, signature))
}
pub fn current_platform_target() -> Option<&'static str> {
#[cfg(all(target_os = "linux", target_arch = "x86_64", target_env = "musl"))]
{
return Some("x86_64-unknown-linux-musl");
}
#[cfg(all(target_os = "linux", target_arch = "x86_64", not(target_env = "musl")))]
{
return Some("x86_64-unknown-linux-gnu");
}
#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
{
return Some("aarch64-unknown-linux-gnu");
}
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
{
return Some("x86_64-apple-darwin");
}
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
{
return Some("aarch64-apple-darwin");
}
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
{
return Some("x86_64-pc-windows-msvc");
}
#[allow(unreachable_code)]
None
}
pub fn is_newer(new_version: &str, current_version: &str) -> bool {
match (
semver::Version::parse(new_version),
semver::Version::parse(current_version),
) {
(Ok(new), Ok(current)) => new > current,
_ => false,
}
}
mod hex_bytes_32 {
use serde::{self, Deserialize, Deserializer, Serializer};
pub fn serialize<S>(bytes: &[u8; 32], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&hex::encode(bytes))
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<[u8; 32], D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
let bytes = hex::decode(&s).map_err(serde::de::Error::custom)?;
bytes
.try_into()
.map_err(|_| serde::de::Error::custom("expected exactly 32 bytes"))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_manifest() -> ReleaseManifest {
ReleaseManifest {
schema_version: SCHEMA_VERSION,
version: "0.5.0".to_string(),
timestamp: 1710000000,
assets: vec![
PlatformAsset {
target: "x86_64-unknown-linux-gnu".to_string(),
archive_url: "https://example.com/x0x-linux-x64-gnu.tar.gz".to_string(),
archive_sha256: [0xAA; 32],
signature_url: "https://example.com/x0x-linux-x64-gnu.tar.gz.sig".to_string(),
},
PlatformAsset {
target: "x86_64-unknown-linux-musl".to_string(),
archive_url: "https://example.com/x0x-linux-x64-musl.tar.gz".to_string(),
archive_sha256: [0xBB; 32],
signature_url: "https://example.com/x0x-linux-x64-musl.tar.gz.sig".to_string(),
},
PlatformAsset {
target: "aarch64-unknown-linux-gnu".to_string(),
archive_url: "https://example.com/x0x-linux-arm64-gnu.tar.gz".to_string(),
archive_sha256: [0xCC; 32],
signature_url: "https://example.com/x0x-linux-arm64-gnu.tar.gz.sig".to_string(),
},
PlatformAsset {
target: "x86_64-apple-darwin".to_string(),
archive_url: "https://example.com/x0x-macos-x64.tar.gz".to_string(),
archive_sha256: [0xDD; 32],
signature_url: "https://example.com/x0x-macos-x64.tar.gz.sig".to_string(),
},
PlatformAsset {
target: "aarch64-apple-darwin".to_string(),
archive_url: "https://example.com/x0x-macos-arm64.tar.gz".to_string(),
archive_sha256: [0xEE; 32],
signature_url: "https://example.com/x0x-macos-arm64.tar.gz.sig".to_string(),
},
PlatformAsset {
target: "x86_64-pc-windows-msvc".to_string(),
archive_url: "https://example.com/x0x-windows-x64.zip".to_string(),
archive_sha256: [0xFF; 32],
signature_url: "https://example.com/x0x-windows-x64.zip.sig".to_string(),
},
],
skill_sha256: [0xAB; 32],
skill_url: "https://example.com/SKILL.md".to_string(),
}
}
#[test]
fn test_json_round_trip() {
let manifest = make_manifest();
let json = serde_json::to_string_pretty(&manifest).unwrap();
let decoded: ReleaseManifest = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.schema_version, SCHEMA_VERSION);
assert_eq!(decoded.version, "0.5.0");
assert_eq!(decoded.assets.len(), 6);
assert_eq!(decoded.skill_sha256, [0xAB; 32]);
assert_eq!(decoded.timestamp, 1710000000);
assert_eq!(decoded.assets[0].archive_sha256, [0xAA; 32]);
}
#[test]
fn test_encode_decode_round_trip() {
let manifest_json = b"test manifest json";
let signature = b"test signature bytes";
let payload = encode_signed_manifest(manifest_json, signature);
let (decoded_json, decoded_sig) = decode_signed_manifest(&payload).unwrap();
assert_eq!(decoded_json, manifest_json);
assert_eq!(decoded_sig, signature);
}
#[test]
fn test_decode_too_short() {
let result = decode_signed_manifest(&[0, 0]);
assert!(result.is_err());
}
#[test]
fn test_decode_truncated() {
let mut payload = vec![0, 0, 0, 100];
payload.extend_from_slice(&[0u8; 6]);
let result = decode_signed_manifest(&payload);
assert!(result.is_err());
}
#[test]
fn test_decode_missing_signature() {
let manifest = b"hello";
let len = (manifest.len() as u32).to_be_bytes();
let mut payload = Vec::new();
payload.extend_from_slice(&len);
payload.extend_from_slice(manifest);
let result = decode_signed_manifest(&payload);
assert!(result.is_err());
}
#[test]
fn test_platform_matching_correct_target() {
let manifest = make_manifest();
let asset = manifest
.matches_platform("x86_64-unknown-linux-gnu")
.unwrap();
assert!(asset.archive_url.contains("linux-x64-gnu"));
}
#[test]
fn test_platform_matching_musl() {
let manifest = make_manifest();
let asset = manifest
.matches_platform("x86_64-unknown-linux-musl")
.unwrap();
assert!(asset.archive_url.contains("linux-x64-musl"));
}
#[test]
fn test_platform_matching_no_match() {
let manifest = make_manifest();
assert!(manifest
.matches_platform("mips-unknown-linux-gnu")
.is_none());
}
#[test]
fn test_is_newer_with_newer_version() {
assert!(is_newer("1.1.0", "1.0.0"));
assert!(is_newer("2.0.0", "1.9.9"));
assert!(is_newer("0.4.0", "0.3.1"));
}
#[test]
fn test_is_newer_with_same_or_older() {
assert!(!is_newer("1.0.0", "1.0.0"));
assert!(!is_newer("0.9.0", "1.0.0"));
assert!(!is_newer("1.0.0", "2.0.0"));
}
#[test]
fn test_is_newer_with_invalid_versions() {
assert!(!is_newer("not-a-version", "1.0.0"));
assert!(!is_newer("1.0.0", "not-a-version"));
}
#[test]
fn test_current_platform_target_returns_some() {
#[cfg(any(
all(target_os = "linux", target_arch = "x86_64"),
all(target_os = "linux", target_arch = "aarch64"),
all(target_os = "macos", target_arch = "x86_64"),
all(target_os = "macos", target_arch = "aarch64"),
all(target_os = "windows", target_arch = "x86_64"),
))]
assert!(current_platform_target().is_some());
}
#[test]
fn test_all_platform_assets_matchable() {
let manifest = make_manifest();
let targets = [
"x86_64-unknown-linux-gnu",
"x86_64-unknown-linux-musl",
"aarch64-unknown-linux-gnu",
"x86_64-apple-darwin",
"aarch64-apple-darwin",
"x86_64-pc-windows-msvc",
];
for target in &targets {
assert!(
manifest.matches_platform(target).is_some(),
"No match for target: {target}"
);
}
}
#[test]
fn test_hex_sha256_in_json() {
let manifest = make_manifest();
let json = serde_json::to_string(&manifest).unwrap();
assert!(json.contains(&"aa".repeat(32)));
}
}