use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum UpdateChannel {
Stable,
Beta,
Nightly,
Custom(String),
}
impl UpdateChannel {
pub fn as_str(&self) -> &str {
match self {
Self::Stable => "stable",
Self::Beta => "beta",
Self::Nightly => "nightly",
Self::Custom(name) => name,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateManifest {
pub version: String,
pub channel: UpdateChannel,
pub url: String,
pub sha256: String,
pub size_bytes: u64,
pub release_notes: Option<String>,
pub min_version: Option<String>,
}
impl UpdateManifest {
pub fn is_compatible_with(&self, current_version: &str) -> bool {
let Some(min) = &self.min_version else {
return true;
};
match (parse_semver(current_version), parse_semver(min)) {
(Some(current), Some(minimum)) => current >= minimum,
_ => false,
}
}
pub fn validate(&self) -> anyhow::Result<()> {
if self.version.is_empty() {
anyhow::bail!("update version must not be empty");
}
if self.url.is_empty() {
anyhow::bail!("update URL must not be empty");
}
if !self.url.starts_with("https://") {
anyhow::bail!("update URL must use https");
}
if self.sha256.len() != 64 {
anyhow::bail!("sha256 must be exactly 64 hex characters");
}
if !self.sha256.chars().all(|c| c.is_ascii_hexdigit()) {
anyhow::bail!("sha256 must contain only hex characters");
}
if self.size_bytes == 0 {
anyhow::bail!("size_bytes must be greater than zero");
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdatePolicy {
pub channel: UpdateChannel,
pub auto_check: bool,
pub auto_download: bool,
pub auto_install: bool,
pub check_interval_secs: u64,
}
impl UpdatePolicy {
pub fn default_stable() -> Self {
Self {
channel: UpdateChannel::Stable,
auto_check: true,
auto_download: false,
auto_install: false,
check_interval_secs: 86400,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RollbackInfo {
pub previous_version: String,
pub rollback_path: String,
pub created_at: u64,
}
fn manifest_signing_payload(manifest: &UpdateManifest) -> Vec<u8> {
format!(
"kael-update-v1\n{}\n{}\n{}\n{}\n{}",
manifest.version,
manifest.channel.as_str(),
manifest.url,
manifest.sha256,
manifest.size_bytes,
)
.into_bytes()
}
pub fn sign_manifest(manifest: &UpdateManifest, key: &SigningKey) -> Signature {
let payload = manifest_signing_payload(manifest);
key.sign(&payload)
}
pub fn verify_manifest(
manifest: &UpdateManifest,
signature: &Signature,
key: &VerifyingKey,
) -> bool {
let payload = manifest_signing_payload(manifest);
key.verify(&payload, signature).is_ok()
}
fn parse_semver(version: &str) -> Option<(u64, u64, u64)> {
let parts: Vec<&str> = version.split('.').collect();
if parts.len() != 3 {
return None;
}
Some((
parts[0].parse().ok()?,
parts[1].parse().ok()?,
parts[2].parse().ok()?,
))
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::SigningKey;
fn valid_sha256() -> String {
"a".repeat(64)
}
fn valid_manifest() -> UpdateManifest {
UpdateManifest {
version: "2.0.0".to_string(),
channel: UpdateChannel::Stable,
url: "https://example.com/update.tar.gz".to_string(),
sha256: valid_sha256(),
size_bytes: 1024,
release_notes: Some("Bug fixes".to_string()),
min_version: Some("1.0.0".to_string()),
}
}
#[test]
fn compatible_when_no_min_version() {
let mut m = valid_manifest();
m.min_version = None;
assert!(m.is_compatible_with("0.1.0"));
}
#[test]
fn compatible_when_current_equals_min() {
let m = valid_manifest();
assert!(m.is_compatible_with("1.0.0"));
}
#[test]
fn compatible_when_current_above_min() {
let m = valid_manifest();
assert!(m.is_compatible_with("1.5.0"));
}
#[test]
fn incompatible_when_current_below_min() {
let m = valid_manifest();
assert!(!m.is_compatible_with("0.9.0"));
}
#[test]
fn validate_valid_manifest() {
valid_manifest().validate().unwrap();
}
#[test]
fn validate_empty_version() {
let mut m = valid_manifest();
m.version = String::new();
assert!(m.validate().is_err());
}
#[test]
fn validate_empty_url() {
let mut m = valid_manifest();
m.url = String::new();
assert!(m.validate().is_err());
}
#[test]
fn manifest_rejects_insecure_update_url() {
let mut m = valid_manifest();
m.url = "http://example.com/update.zip".to_string();
assert!(m.validate().is_err());
m.url = "https://example.com/update.zip".to_string();
assert!(m.validate().is_ok());
}
#[test]
fn validate_bad_sha256_length() {
let mut m = valid_manifest();
m.sha256 = "abc".to_string();
assert!(m.validate().is_err());
}
#[test]
fn validate_bad_sha256_chars() {
let mut m = valid_manifest();
m.sha256 = "g".repeat(64);
assert!(m.validate().is_err());
}
#[test]
fn validate_zero_size() {
let mut m = valid_manifest();
m.size_bytes = 0;
assert!(m.validate().is_err());
}
#[test]
fn default_policy_is_conservative() {
let policy = UpdatePolicy::default_stable();
assert!(policy.auto_check);
assert!(!policy.auto_download);
assert!(!policy.auto_install);
assert_eq!(policy.channel, UpdateChannel::Stable);
}
#[test]
fn rollback_info_serialization() {
let info = RollbackInfo {
previous_version: "1.0.0".to_string(),
rollback_path: "/backups/v1.0.0".to_string(),
created_at: 1700000000,
};
let json = serde_json::to_string(&info).unwrap();
let restored: RollbackInfo = serde_json::from_str(&json).unwrap();
assert_eq!(restored.previous_version, "1.0.0");
}
#[test]
fn custom_channel_equality() {
let a = UpdateChannel::Custom("internal".to_string());
let b = UpdateChannel::Custom("internal".to_string());
assert_eq!(a, b);
assert_ne!(a, UpdateChannel::Stable);
}
#[test]
fn manifest_serialization_roundtrip() {
let m = valid_manifest();
let json = serde_json::to_string(&m).unwrap();
let restored: UpdateManifest = serde_json::from_str(&json).unwrap();
assert_eq!(restored.version, "2.0.0");
assert_eq!(restored.channel, UpdateChannel::Stable);
}
#[test]
fn sign_and_verify_manifest() {
let signing_key = SigningKey::generate(&mut rand::thread_rng());
let verifying_key = signing_key.verifying_key();
let manifest = UpdateManifest {
version: "1.2.0".into(),
channel: UpdateChannel::Stable,
url: "https://example.com/update.tar.gz".into(),
sha256: "abc123".into(),
size_bytes: 1024,
release_notes: None,
min_version: None,
};
let signature = sign_manifest(&manifest, &signing_key);
assert!(verify_manifest(&manifest, &signature, &verifying_key));
}
#[test]
fn tampered_manifest_fails_verification() {
let signing_key = SigningKey::generate(&mut rand::thread_rng());
let verifying_key = signing_key.verifying_key();
let mut manifest = UpdateManifest {
version: "1.2.0".into(),
channel: UpdateChannel::Stable,
url: "https://example.com/update.tar.gz".into(),
sha256: "abc123".into(),
size_bytes: 1024,
release_notes: None,
min_version: None,
};
let signature = sign_manifest(&manifest, &signing_key);
manifest.url = "https://evil.com/malware.tar.gz".into();
assert!(!verify_manifest(&manifest, &signature, &verifying_key));
}
#[test]
fn downgrade_rejected() {
let manifest = UpdateManifest {
version: "1.0.0".into(),
channel: UpdateChannel::Stable,
url: "https://example.com/update.tar.gz".into(),
sha256: "abc".into(),
size_bytes: 1024,
release_notes: None,
min_version: Some("1.5.0".into()),
};
assert!(!manifest.is_compatible_with("1.2.0"));
}
#[test]
fn upgrade_accepted() {
let manifest = UpdateManifest {
version: "2.0.0".into(),
channel: UpdateChannel::Stable,
url: "https://example.com/update.tar.gz".into(),
sha256: "abc".into(),
size_bytes: 1024,
release_notes: None,
min_version: Some("1.0.0".into()),
};
assert!(manifest.is_compatible_with("1.5.0"));
}
}