use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::chain::PcrsHex;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StagedUpgradeStatus {
Building,
Staged,
Confirmed,
Promoted,
Revoked,
Failed,
Expired,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StagedUpgradeJson {
pub id: Uuid,
pub enclave_id: Uuid,
pub status: StagedUpgradeStatus,
pub docker_image: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_digest: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pcrs: Option<PcrsHex>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub valid_from: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upgrade_link_id: Option<Uuid>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revocation_link_id: Option<Uuid>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub builder_rev: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub crates_rev: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub synchronizer_pcrs: Option<Vec<PcrsHex>>,
#[serde(default)]
pub synchronizer_enabled: bool,
pub created_at: DateTime<Utc>,
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn sample() -> StagedUpgradeJson {
StagedUpgradeJson {
id: Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
enclave_id: Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap(),
status: StagedUpgradeStatus::Confirmed,
docker_image: "registry.example.com/owner/app:v2".into(),
image_digest: Some("sha256:abcdef".into()),
pcrs: Some(PcrsHex {
pcr0: "aa".repeat(24),
pcr1: "bb".repeat(24),
pcr2: "cc".repeat(24),
}),
valid_from: Some(Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap()),
upgrade_link_id: Some(Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap()),
revocation_link_id: None,
error_message: None,
builder_rev: Some("08cc66bf46b79981253011725b9e792d6353a586".into()),
crates_rev: Some("842b3394f1699b1fc7ae376ec7741fa9e4029621".into()),
synchronizer_pcrs: Some(vec![PcrsHex {
pcr0: "dd".repeat(48),
pcr1: "ee".repeat(48),
pcr2: "ff".repeat(48),
}]),
synchronizer_enabled: true,
created_at: Utc.with_ymd_and_hms(2024, 12, 31, 0, 0, 0).unwrap(),
}
}
#[test]
fn staged_upgrade_json_round_trip() {
let orig = sample();
let json = serde_json::to_string(&orig).unwrap();
let back: StagedUpgradeJson = serde_json::from_str(&json).unwrap();
assert_eq!(back.id, orig.id);
assert_eq!(back.enclave_id, orig.enclave_id);
assert_eq!(back.status, orig.status);
assert_eq!(back.docker_image, orig.docker_image);
assert_eq!(back.image_digest, orig.image_digest);
assert_eq!(back.upgrade_link_id, orig.upgrade_link_id);
assert!(back.revocation_link_id.is_none());
assert!(back.error_message.is_none());
}
#[test]
fn staged_upgrade_json_field_names() {
let v = serde_json::to_value(sample()).unwrap();
assert!(v.get("id").is_some(), "missing id");
assert!(v.get("enclave_id").is_some(), "missing enclave_id");
assert!(v.get("status").is_some(), "missing status");
assert!(v.get("docker_image").is_some(), "missing docker_image");
assert!(v.get("created_at").is_some(), "missing created_at");
assert!(v.get("image_digest").is_some(), "missing image_digest");
assert!(v.get("pcrs").is_some(), "missing pcrs");
assert!(v.get("valid_from").is_some(), "missing valid_from");
assert!(
v.get("upgrade_link_id").is_some(),
"missing upgrade_link_id"
);
assert!(
v.get("revocation_link_id").is_none(),
"revocation_link_id should be absent"
);
assert!(
v.get("error_message").is_none(),
"error_message should be absent"
);
}
#[test]
fn staged_upgrade_status_serde_lowercase() {
let cases = [
(StagedUpgradeStatus::Building, "building"),
(StagedUpgradeStatus::Staged, "staged"),
(StagedUpgradeStatus::Confirmed, "confirmed"),
(StagedUpgradeStatus::Promoted, "promoted"),
(StagedUpgradeStatus::Revoked, "revoked"),
(StagedUpgradeStatus::Failed, "failed"),
(StagedUpgradeStatus::Expired, "expired"),
];
for (status, expected_json) in &cases {
let got = serde_json::to_string(status).unwrap();
assert_eq!(got, format!("\"{expected_json}\""), "status {status:?}");
let back: StagedUpgradeStatus = serde_json::from_str(&got).unwrap();
assert_eq!(back, *status);
}
}
#[test]
fn building_status_has_no_optional_fields() {
let building = StagedUpgradeJson {
id: Uuid::parse_str("00000000-0000-0000-0000-000000000010").unwrap(),
enclave_id: Uuid::parse_str("00000000-0000-0000-0000-000000000011").unwrap(),
status: StagedUpgradeStatus::Building,
docker_image: "registry.example.com/owner/app:v3".into(),
image_digest: None,
pcrs: None,
valid_from: None,
upgrade_link_id: None,
revocation_link_id: None,
error_message: None,
builder_rev: None,
crates_rev: None,
synchronizer_pcrs: None,
synchronizer_enabled: false,
created_at: Utc.with_ymd_and_hms(2024, 12, 31, 0, 0, 0).unwrap(),
};
let v = serde_json::to_value(&building).unwrap();
assert!(v.get("image_digest").is_none());
assert!(v.get("pcrs").is_none());
assert!(v.get("valid_from").is_none());
assert!(v.get("upgrade_link_id").is_none());
assert!(v.get("builder_rev").is_none());
assert!(v.get("crates_rev").is_none());
assert!(v.get("synchronizer_pcrs").is_none());
assert_eq!(
v.get("synchronizer_enabled"),
Some(&serde_json::json!(false))
);
}
#[test]
fn revs_round_trip_when_present() {
let v = serde_json::to_value(sample()).unwrap();
assert_eq!(
v.get("builder_rev").and_then(|x| x.as_str()),
Some("08cc66bf46b79981253011725b9e792d6353a586")
);
assert_eq!(
v.get("crates_rev").and_then(|x| x.as_str()),
Some("842b3394f1699b1fc7ae376ec7741fa9e4029621")
);
let back: StagedUpgradeJson = serde_json::from_value(v).unwrap();
assert_eq!(
back.builder_rev.as_deref(),
Some("08cc66bf46b79981253011725b9e792d6353a586")
);
assert_eq!(
back.crates_rev.as_deref(),
Some("842b3394f1699b1fc7ae376ec7741fa9e4029621")
);
}
#[test]
fn deserialises_payload_without_revs() {
let json = r#"{
"id": "00000000-0000-0000-0000-000000000001",
"enclave_id": "00000000-0000-0000-0000-000000000002",
"status": "staged",
"docker_image": "registry.example.com/owner/app:v2",
"image_digest": "sha256:abcdef",
"created_at": "2024-12-31T00:00:00Z"
}"#;
let back: StagedUpgradeJson = serde_json::from_str(json).unwrap();
assert!(back.builder_rev.is_none());
assert!(back.crates_rev.is_none());
assert_eq!(back.image_digest.as_deref(), Some("sha256:abcdef"));
assert!(back.synchronizer_pcrs.is_none());
assert!(!back.synchronizer_enabled);
}
#[test]
fn synchronizer_provenance_round_trips_when_present() {
let v = serde_json::to_value(sample()).unwrap();
let anchors = v
.get("synchronizer_pcrs")
.and_then(|x| x.as_array())
.expect("synchronizer_pcrs present as a list");
assert_eq!(anchors.len(), 1);
assert_eq!(
anchors[0].get("PCR0").and_then(|x| x.as_str()),
Some("dd".repeat(48).as_str())
);
assert_eq!(
v.get("synchronizer_enabled"),
Some(&serde_json::json!(true))
);
let back: StagedUpgradeJson = serde_json::from_value(v).unwrap();
let pcrs = back.synchronizer_pcrs.expect("anchors survive round-trip");
assert_eq!(pcrs[0].pcr1, "ee".repeat(48));
assert!(back.synchronizer_enabled);
}
}