huskies: merge 1169 story Gateway pulls signed artifacts from a release channel into its local store

This commit is contained in:
Huskies Agent
2026-07-16 14:03:31 +00:00
parent 1e0e581bd7
commit 77e0394195
23 changed files with 1643 additions and 7 deletions
+41 -1
View File
@@ -70,8 +70,34 @@ impl ProjectEntry {
}
}
/// Configuration for one signed release channel the gateway can `pull` from.
///
/// All three fields are optional in the TOML shape — a channel can be
/// partially configured while it's being set up — but `pull <channel>`
/// (story 1169) requires both `base_url` and `pubkey` to be present. There is
/// no unsigned-pull mode: a missing pubkey is always a hard error, never a
/// fallback to trusting whatever the channel serves.
#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
pub struct ReleaseChannelConfig {
/// Base URL the channel is served from (e.g. `https://releases.example.com/stable`).
///
/// `pull <channel>` fetches `{base_url}/manifest.json` and, once verified,
/// `{base_url}/{manifest.artifact}`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
/// Pinned Ed25519 public key (hex) the channel's `manifest.json` signature
/// must verify against. Generated by `release-tool keygen` and never
/// derived automatically — an operator must paste it in explicitly.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pubkey: Option<String>,
/// Optional bearer token sent as `Authorization: Bearer <token>` on both
/// the manifest and artifact requests (e.g. for a private release channel).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bearer_token: Option<String>,
}
/// Top-level `projects.toml` config.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct GatewayConfig {
/// Map of project name → container configuration.
#[serde(default)]
@@ -86,6 +112,12 @@ pub struct GatewayConfig {
/// `/api/sled-uplink` using the given secret token as a bearer credential.
#[serde(default)]
pub sled_tokens: BTreeMap<String, String>,
/// Map of channel name → signed release channel configuration (story 1169).
///
/// Populated by an operator adding `[release_channels.<name>]` sections to
/// `projects.toml`. Read by the `pull <channel>` gateway chat command.
#[serde(default)]
pub release_channels: BTreeMap<String, ReleaseChannelConfig>,
}
/// Validate that a gateway config has at least one project.
@@ -206,6 +238,7 @@ auth_token = "secret"
let config = GatewayConfig {
projects: BTreeMap::new(),
sled_tokens: BTreeMap::new(),
release_channels: BTreeMap::new(),
};
assert!(validate_config(&config).is_err());
}
@@ -218,6 +251,7 @@ auth_token = "secret"
let config = GatewayConfig {
projects,
sled_tokens: BTreeMap::new(),
release_channels: BTreeMap::new(),
};
assert_eq!(validate_config(&config).unwrap(), "alpha");
}
@@ -238,6 +272,7 @@ auth_token = "secret"
let config = GatewayConfig {
projects,
sled_tokens: BTreeMap::new(),
release_channels: BTreeMap::new(),
};
assert!(validate_config(&config).is_ok());
}
@@ -340,6 +375,7 @@ auth_token = "secret"
let config = GatewayConfig {
projects,
sled_tokens: BTreeMap::new(),
release_channels: BTreeMap::new(),
};
let toml_str = toml::to_string_pretty(&config).unwrap();
let parsed: GatewayConfig = toml::from_str(&toml_str).unwrap();
@@ -367,6 +403,7 @@ auth_token = "secret"
let config = GatewayConfig {
projects,
sled_tokens: BTreeMap::new(),
release_channels: BTreeMap::new(),
};
let toml_str = toml::to_string_pretty(&config).unwrap();
let parsed: GatewayConfig = toml::from_str(&toml_str).unwrap();
@@ -392,6 +429,7 @@ auth_token = "secret"
let config = GatewayConfig {
projects,
sled_tokens: BTreeMap::new(),
release_channels: BTreeMap::new(),
};
let toml_str = toml::to_string_pretty(&config).unwrap();
assert!(toml_str.contains("expected_node_id"));
@@ -407,6 +445,7 @@ auth_token = "secret"
let config2 = GatewayConfig {
projects: projects2,
sled_tokens: BTreeMap::new(),
release_channels: BTreeMap::new(),
};
let toml_str2 = toml::to_string_pretty(&config2).unwrap();
assert!(
@@ -423,6 +462,7 @@ auth_token = "secret"
let config = GatewayConfig {
projects,
sled_tokens: BTreeMap::new(),
release_channels: BTreeMap::new(),
};
let toml_str = toml::to_string_pretty(&config).unwrap();
assert!(
+285 -5
View File
@@ -22,22 +22,30 @@ pub fn load_config(path: &Path) -> Result<GatewayConfig, String> {
/// Persist the current projects map to `<config_dir>/projects.toml`.
/// Silently ignores write errors or skips when `config_dir` is empty.
///
/// Existing `[sled_tokens]` entries are preserved so that adding or removing
/// projects via the UI does not wipe the sled authentication tokens.
/// Existing `[sled_tokens]` and `[release_channels]` entries are preserved so
/// that adding or removing projects via the UI does not wipe the sled
/// authentication tokens or configured release channels.
pub async fn save_config(projects: &BTreeMap<String, ProjectEntry>, config_dir: &Path) {
if config_dir.as_os_str().is_empty() {
return;
}
let path = config_dir.join("projects.toml");
let sled_tokens = tokio::fs::read_to_string(&path)
let existing = tokio::fs::read_to_string(&path)
.await
.ok()
.and_then(|data| toml::from_str::<GatewayConfig>(&data).ok())
.map(|c| c.sled_tokens)
.and_then(|data| toml::from_str::<GatewayConfig>(&data).ok());
let sled_tokens = existing
.as_ref()
.map(|c| c.sled_tokens.clone())
.unwrap_or_default();
let release_channels = existing
.as_ref()
.map(|c| c.release_channels.clone())
.unwrap_or_default();
let config = GatewayConfig {
projects: projects.clone(),
sled_tokens,
release_channels,
};
if let Ok(data) = toml::to_string_pretty(&config) {
let _ = tokio::fs::write(&path, data).await;
@@ -120,6 +128,115 @@ pub async fn probe_identity(
.ok()
}
// ── Release channel I/O (story 1169) ────────────────────────────────────────
/// Fetch `{base_url}/manifest.json` and parse it into a
/// [`release_manifest::SignedManifest`].
///
/// Sends `Authorization: Bearer <token>` when `bearer_token` is set. Returns
/// `Err` on any network, HTTP-status, or parse failure — `pull` treats all of
/// these the same as any other loud, install-nothing failure.
pub async fn fetch_release_manifest(
client: &Client,
base_url: &str,
bearer_token: Option<&str>,
) -> Result<release_manifest::SignedManifest, String> {
let url = format!("{}/manifest.json", base_url.trim_end_matches('/'));
let mut req = client.get(&url);
if let Some(token) = bearer_token {
req = req.bearer_auth(token);
}
let resp = req
.send()
.await
.map_err(|e| format!("cannot reach {url}: {e}"))?;
if !resp.status().is_success() {
return Err(format!("{url} returned HTTP {}", resp.status()));
}
resp.json()
.await
.map_err(|e| format!("invalid manifest.json at {url}: {e}"))
}
/// Download a named artifact from a release channel.
///
/// Sends `Authorization: Bearer <token>` when `bearer_token` is set.
pub async fn download_channel_artifact(
client: &Client,
base_url: &str,
artifact_name: &str,
bearer_token: Option<&str>,
) -> Result<Vec<u8>, String> {
let url = format!("{}/{artifact_name}", base_url.trim_end_matches('/'));
let mut req = client.get(&url);
if let Some(token) = bearer_token {
req = req.bearer_auth(token);
}
let resp = req
.send()
.await
.map_err(|e| format!("cannot reach {url}: {e}"))?;
if !resp.status().is_success() {
return Err(format!("{url} returned HTTP {}", resp.status()));
}
resp.bytes()
.await
.map(|b| b.to_vec())
.map_err(|e| format!("failed reading artifact body from {url}: {e}"))
}
/// Atomically install a verified artifact plus its `.hash` and
/// `.manifest.json` sidecars into the gateway's artifact store.
///
/// Mirrors the write-tmp-then-rename pattern `release` uses for
/// `SLED_ARTIFACT_NAME` so a concurrent `/api/artifacts` download never
/// observes a half-written file. The `.hash` sidecar carries `manifest.version`
/// so `upgrade all`'s existing convergence check (`sled_upgrade.rs`) keeps
/// working unmodified. The `.manifest.json` sidecar records the full manifest
/// so the next pull can run [`super::release_manifest::check_rollback`]
/// against it.
pub fn install_pulled_artifact(
artifacts_dir: &Path,
artifact_name: &str,
bytes: &[u8],
manifest: &release_manifest::ReleaseManifest,
) -> Result<(), String> {
std::fs::create_dir_all(artifacts_dir)
.map_err(|e| format!("cannot create {}: {e}", artifacts_dir.display()))?;
let artifact_path = artifacts_dir.join(artifact_name);
let tmp_path = artifacts_dir.join(".pull.tmp");
std::fs::write(&tmp_path, bytes).map_err(|e| format!("write tmp artifact failed: {e}"))?;
std::fs::rename(&tmp_path, &artifact_path)
.map_err(|e| format!("rename artifact failed: {e}"))?;
let hash_path = artifacts_dir.join(format!("{artifact_name}.hash"));
std::fs::write(&hash_path, &manifest.version)
.map_err(|e| format!("write hash sidecar failed: {e}"))?;
let manifest_path = artifacts_dir.join(format!("{artifact_name}.manifest.json"));
let manifest_json = serde_json::to_string_pretty(manifest)
.map_err(|e| format!("serialise manifest sidecar: {e}"))?;
std::fs::write(&manifest_path, manifest_json)
.map_err(|e| format!("write manifest sidecar failed: {e}"))?;
Ok(())
}
/// Read the `.manifest.json` sidecar for the currently installed artifact, if any.
///
/// Returns `None` when no artifact has ever been pulled from a channel (e.g.
/// only `release` has published so far, or this is a fresh gateway) — callers
/// treat that as "no rollback information available", not an error.
pub fn read_installed_manifest(
artifacts_dir: &Path,
artifact_name: &str,
) -> Option<release_manifest::ReleaseManifest> {
let path = artifacts_dir.join(format!("{artifact_name}.manifest.json"));
let content = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&content).ok()
}
// ── MCP proxy I/O ───────────────────────────────────────────────────────────
/// Proxy a raw MCP request body to the given project URL.
@@ -534,6 +651,9 @@ pub fn spawn_gateway_bot(
port: u16,
gateway_event_tx: Option<tokio::sync::broadcast::Sender<super::GatewayStatusEvent>>,
permission_registry: std::sync::Arc<crate::service::permission_router::ResponderRegistry>,
gateway_channels_store: std::sync::Arc<
tokio::sync::RwLock<BTreeMap<String, super::config::ReleaseChannelConfig>>,
>,
) -> (
Option<tokio::task::AbortHandle>,
tokio::sync::watch::Sender<Option<crate::rebuild::ShutdownReason>>,
@@ -600,6 +720,7 @@ pub fn spawn_gateway_bot(
timer_store,
gateway_event_rx,
Some(port),
Some(gateway_channels_store),
);
(handle, shutdown_tx)
}
@@ -622,6 +743,8 @@ mod tests {
let permission_registry = crate::service::permission_router::ResponderRegistry::new();
let projects_store =
std::sync::Arc::new(tokio::sync::RwLock::new(std::collections::BTreeMap::new()));
let channels_store =
std::sync::Arc::new(tokio::sync::RwLock::new(std::collections::BTreeMap::new()));
let (handle, shutdown_tx) = spawn_gateway_bot(
tmp.path(),
active,
@@ -629,6 +752,7 @@ mod tests {
3001,
Some(event_tx),
permission_registry,
channels_store,
);
// No bot.toml in tmp → no abort handle spawned.
@@ -647,4 +771,160 @@ mod tests {
"shutdown receiver must see the Manual reason"
);
}
// ── fetch_release_manifest / download_channel_artifact ──────────────────
/// Spawn a one-shot TCP listener that captures the raw request text of a
/// single connection and responds with `body`.
fn spawn_capturing_responder(
listener: tokio::net::TcpListener,
body: Vec<u8>,
) -> tokio::sync::oneshot::Receiver<String> {
let (tx, rx) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
if let Ok((mut stream, _)) = listener.accept().await {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut buf = [0u8; 8192];
let n = stream.read(&mut buf).await.unwrap_or(0);
let request = String::from_utf8_lossy(&buf[..n]).to_string();
let response = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", body.len());
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.write_all(&body).await;
let _ = tx.send(request);
}
});
rx
}
fn sample_signed_manifest() -> release_manifest::SignedManifest {
release_manifest::SignedManifest {
manifest: release_manifest::ReleaseManifest {
artifact: "huskies-linux-arm64".to_string(),
sha256: "a".repeat(64),
version: "abc1234".to_string(),
channel: "stable".to_string(),
timestamp: 1,
},
signature: "deadbeef".to_string(),
}
}
#[tokio::test]
async fn fetch_release_manifest_sends_bearer_auth_header() {
let body = serde_json::to_vec(&sample_signed_manifest()).unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let rx = spawn_capturing_responder(listener, body);
let client = Client::new();
let base_url = format!("http://127.0.0.1:{port}");
let fetched = fetch_release_manifest(&client, &base_url, Some("secret-token"))
.await
.unwrap();
assert_eq!(fetched.manifest.version, "abc1234");
let request = rx.await.unwrap();
assert!(
request
.to_lowercase()
.contains("authorization: bearer secret-token"),
"request must carry the bearer token: {request}"
);
}
#[tokio::test]
async fn fetch_release_manifest_without_token_sends_no_auth_header() {
let body = serde_json::to_vec(&sample_signed_manifest()).unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let rx = spawn_capturing_responder(listener, body);
let client = Client::new();
let base_url = format!("http://127.0.0.1:{port}");
fetch_release_manifest(&client, &base_url, None)
.await
.unwrap();
let request = rx.await.unwrap();
assert!(
!request.to_lowercase().contains("authorization:"),
"request must not carry an Authorization header when no token is set: {request}"
);
}
#[tokio::test]
async fn fetch_release_manifest_unreachable_is_error() {
let client = Client::new();
let err = fetch_release_manifest(&client, "http://127.0.0.1:1", None)
.await
.unwrap_err();
assert!(!err.is_empty());
}
#[tokio::test]
async fn download_channel_artifact_returns_bytes() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let rx = spawn_capturing_responder(listener, b"binary bytes".to_vec());
let client = Client::new();
let base_url = format!("http://127.0.0.1:{port}");
let bytes = download_channel_artifact(&client, &base_url, "huskies-linux-arm64", None)
.await
.unwrap();
assert_eq!(bytes, b"binary bytes");
rx.await.unwrap();
}
#[tokio::test]
async fn download_channel_artifact_sends_bearer_auth_header() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let rx = spawn_capturing_responder(listener, b"bytes".to_vec());
let client = Client::new();
let base_url = format!("http://127.0.0.1:{port}");
download_channel_artifact(&client, &base_url, "art", Some("tok"))
.await
.unwrap();
let request = rx.await.unwrap();
assert!(request.to_lowercase().contains("authorization: bearer tok"));
}
// ── install_pulled_artifact / read_installed_manifest ────────────────────
#[test]
fn install_pulled_artifact_writes_atomically_with_sidecars() {
let dir = tempfile::tempdir().unwrap();
let manifest = sample_signed_manifest().manifest;
install_pulled_artifact(
dir.path(),
"huskies-linux-arm64",
b"binary content",
&manifest,
)
.unwrap();
let artifact_path = dir.path().join("huskies-linux-arm64");
assert_eq!(std::fs::read(&artifact_path).unwrap(), b"binary content");
assert!(
!dir.path().join(".pull.tmp").exists(),
"tmp file must be renamed away"
);
let hash = std::fs::read_to_string(dir.path().join("huskies-linux-arm64.hash")).unwrap();
assert_eq!(hash, "abc1234");
let read_back = read_installed_manifest(dir.path(), "huskies-linux-arm64").unwrap();
assert_eq!(read_back, manifest);
}
#[test]
fn read_installed_manifest_missing_sidecar_returns_none() {
let dir = tempfile::tempdir().unwrap();
assert!(read_installed_manifest(dir.path(), "huskies-linux-arm64").is_none());
}
}
+13
View File
@@ -16,6 +16,8 @@ pub mod identity;
pub(crate) mod io;
/// Notification event polling for gateway-level broadcasts.
pub mod polling;
/// Pure signed release-manifest verification (signature, sha256, rollback) — no I/O.
pub mod release_manifest;
pub use aggregation::format_aggregate_status_compact;
pub use config::{GatewayConfig, ProjectEntry};
@@ -247,6 +249,11 @@ pub struct GatewayState {
/// and depopulated when it disconnects. MCP proxy functions check here
/// first (WS route), falling back to HTTP when no live connection exists.
pub sled_connections: Arc<RwLock<HashMap<String, SledConnection>>>,
/// Configured signed release channels (story 1169), keyed by channel name.
///
/// Read by the `pull <channel>` gateway chat command to resolve a
/// channel's `base_url`, pinned `pubkey`, and optional `bearer_token`.
pub release_channels: Arc<RwLock<BTreeMap<String, config::ReleaseChannelConfig>>>,
}
impl GatewayState {
@@ -306,6 +313,7 @@ impl GatewayState {
permission_registry,
sled_tokens,
sled_connections: Arc::new(RwLock::new(HashMap::new())),
release_channels: Arc::new(RwLock::new(gateway_config.release_channels)),
})
}
@@ -700,6 +708,7 @@ pub async fn save_bot_config_and_restart(state: &GatewayState, content: &str) ->
state.port,
Some(state.event_tx.clone()),
Arc::clone(&state.permission_registry),
Arc::clone(&state.release_channels),
);
*handle = new_handle;
*state.bot_shutdown_tx.lock().await = Some(new_shutdown_tx);
@@ -723,6 +732,7 @@ mod tests {
GatewayConfig {
projects,
sled_tokens: BTreeMap::new(),
release_channels: BTreeMap::new(),
}
}
@@ -731,6 +741,7 @@ mod tests {
let config = GatewayConfig {
projects: BTreeMap::new(),
sled_tokens: BTreeMap::new(),
release_channels: BTreeMap::new(),
};
assert!(GatewayState::new(config, PathBuf::from("."), 3000).is_err());
}
@@ -790,6 +801,7 @@ mod tests {
let config = GatewayConfig {
projects,
sled_tokens: BTreeMap::new(),
release_channels: BTreeMap::new(),
};
let state = GatewayState::new(config, PathBuf::from("."), 3000).unwrap();
assert!(state.active_url().await.is_err());
@@ -931,6 +943,7 @@ mod tests {
let config = GatewayConfig {
projects,
sled_tokens: BTreeMap::new(),
release_channels: BTreeMap::new(),
};
let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap();
assert_eq!(
@@ -0,0 +1,195 @@
//! Pure verification for signed release-channel manifests — no I/O.
//!
//! Fetching `manifest.json` over HTTP, downloading the artifact, and
//! installing it atomically all live in [`super::io`]; this module only
//! decides whether a fetched [`release_manifest::SignedManifest`] should be
//! trusted and installed.
use release_manifest::ReleaseManifest;
/// Verify `signed`'s Ed25519 signature against `pinned_pubkey_hex`.
///
/// Re-serializes the embedded manifest with
/// [`ReleaseManifest::canonical_bytes`] rather than trusting whatever bytes
/// the channel actually served, so verification depends only on the
/// semantic manifest fields — not on `manifest.json`'s exact on-disk layout.
pub fn verify_manifest_signature(
signed: &release_manifest::SignedManifest,
pinned_pubkey_hex: &str,
) -> Result<(), String> {
let bytes = signed.manifest.canonical_bytes();
if crate::node_identity::verify_message_strict(pinned_pubkey_hex, &bytes, &signed.signature) {
Ok(())
} else {
Err("manifest signature verification failed against the pinned channel pubkey".to_string())
}
}
/// Verify a downloaded artifact's sha256 against the manifest's pinned hash.
pub fn verify_artifact_sha256(bytes: &[u8], expected_sha256_hex: &str) -> Result<(), String> {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(bytes);
let actual = hex_encode(&hasher.finalize());
if actual.eq_ignore_ascii_case(expected_sha256_hex) {
Ok(())
} else {
Err(format!(
"artifact sha256 mismatch: manifest says {expected_sha256_hex}, downloaded bytes hash to {actual}"
))
}
}
/// Refuse a manifest whose signed timestamp is not strictly newer than the
/// currently installed artifact's — basic rollback / replay protection.
///
/// `installed` is `None` when no manifest sidecar exists yet (first pull, or
/// an artifact previously published by the local `release` command rather
/// than a channel pull); in that case any manifest is accepted.
pub fn check_rollback(
new: &ReleaseManifest,
installed: Option<&ReleaseManifest>,
) -> Result<(), String> {
let Some(installed) = installed else {
return Ok(());
};
if new.timestamp <= installed.timestamp {
return Err(format!(
"manifest timestamp {} (version {}, channel {}) is not newer than the installed \
artifact's timestamp {} (version {}, channel {}) refusing to install a possible \
rollback or replay",
new.timestamp,
new.version,
new.channel,
installed.timestamp,
installed.version,
installed.channel,
));
}
Ok(())
}
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
// ── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use bft_json_crdt::keypair::{make_keypair, sign};
fn sample_manifest() -> 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,
}
}
fn signed_by(
kp: &bft_json_crdt::keypair::Ed25519KeyPair,
manifest: ReleaseManifest,
) -> release_manifest::SignedManifest {
let sig = sign(kp, &manifest.canonical_bytes());
let signature = hex_encode(&sig.to_bytes());
release_manifest::SignedManifest {
manifest,
signature,
}
}
// ── verify_manifest_signature ────────────────────────────────────────────
#[test]
fn valid_signature_is_accepted() {
let kp = make_keypair();
let pubkey = crate::node_identity::public_key_hex(&kp);
let signed = signed_by(&kp, sample_manifest());
assert!(verify_manifest_signature(&signed, &pubkey).is_ok());
}
#[test]
fn signature_from_wrong_key_is_rejected() {
let kp = make_keypair();
let other_kp = make_keypair();
let other_pubkey = crate::node_identity::public_key_hex(&other_kp);
let signed = signed_by(&kp, sample_manifest());
let err = verify_manifest_signature(&signed, &other_pubkey).unwrap_err();
assert!(err.contains("signature verification failed"));
}
#[test]
fn tampered_manifest_field_is_rejected() {
let kp = make_keypair();
let pubkey = crate::node_identity::public_key_hex(&kp);
let mut signed = signed_by(&kp, sample_manifest());
// Tamper with the sha256 after signing — signature no longer matches.
signed.manifest.sha256 = "b".repeat(64);
assert!(verify_manifest_signature(&signed, &pubkey).is_err());
}
#[test]
fn missing_signature_is_rejected() {
let kp = make_keypair();
let pubkey = crate::node_identity::public_key_hex(&kp);
let signed = release_manifest::SignedManifest {
manifest: sample_manifest(),
signature: String::new(),
};
assert!(verify_manifest_signature(&signed, &pubkey).is_err());
}
// ── verify_artifact_sha256 ───────────────────────────────────────────────
#[test]
fn matching_sha256_is_accepted() {
use sha2::{Digest, Sha256};
let bytes = b"artifact contents";
let mut hasher = Sha256::new();
hasher.update(bytes);
let expected = hex_encode(&hasher.finalize());
assert!(verify_artifact_sha256(bytes, &expected).is_ok());
}
#[test]
fn tampered_artifact_sha256_mismatch_is_rejected() {
let err = verify_artifact_sha256(b"tampered bytes", &"a".repeat(64)).unwrap_err();
assert!(err.contains("sha256 mismatch"));
}
// ── check_rollback ───────────────────────────────────────────────────────
#[test]
fn no_installed_manifest_always_allows() {
assert!(check_rollback(&sample_manifest(), None).is_ok());
}
#[test]
fn newer_timestamp_is_allowed() {
let installed = sample_manifest();
let mut newer = sample_manifest();
newer.timestamp = installed.timestamp + 1;
assert!(check_rollback(&newer, Some(&installed)).is_ok());
}
#[test]
fn older_than_installed_manifest_triggers_rollback_warning() {
let installed = sample_manifest();
let mut older = sample_manifest();
older.timestamp = installed.timestamp - 1;
let err = check_rollback(&older, Some(&installed)).unwrap_err();
assert!(err.contains("rollback or replay"));
}
#[test]
fn equal_timestamp_is_refused_as_replay() {
let installed = sample_manifest();
let replay = sample_manifest();
let err = check_rollback(&replay, Some(&installed)).unwrap_err();
assert!(err.contains("rollback or replay"));
}
}