huskies: merge 1169 story Gateway pulls signed artifacts from a release channel into its local store
This commit is contained in:
@@ -94,6 +94,14 @@ pub struct BotContext {
|
||||
/// The `new project` command writes here so HTTP handlers see the new entry
|
||||
/// immediately without requiring a gateway restart. `None` in standalone mode.
|
||||
pub gateway_projects_store: Option<Arc<RwLock<BTreeMap<String, ProjectEntry>>>>,
|
||||
/// In gateway mode: shared configured release channels (story 1169).
|
||||
///
|
||||
/// Read by the `pull <channel>` command to resolve a channel's
|
||||
/// `base_url`, pinned `pubkey`, and optional `bearer_token`. `None` in
|
||||
/// standalone mode.
|
||||
pub gateway_channels_store: Option<
|
||||
Arc<RwLock<BTreeMap<String, crate::service::gateway::config::ReleaseChannelConfig>>>,
|
||||
>,
|
||||
/// Bounded FIFO set of already-handled incoming event IDs.
|
||||
///
|
||||
/// The Matrix sync loop can replay events on reconnect. This set ensures
|
||||
@@ -315,6 +323,7 @@ mod tests {
|
||||
)),
|
||||
gateway_active_project,
|
||||
gateway_projects_store,
|
||||
gateway_channels_store: None,
|
||||
handled_incoming_event_ids: Arc::new(TokioMutex::new(SeenEventIds::new(
|
||||
SEEN_EVENT_IDS_CAP,
|
||||
))),
|
||||
|
||||
@@ -548,6 +548,33 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
||||
return;
|
||||
}
|
||||
|
||||
// In gateway mode, handle the `pull <channel>` command: fetch, verify, and
|
||||
// install a signed release-channel artifact into the artifact store.
|
||||
if ctx.is_gateway()
|
||||
&& let Some(channel) = super::super::super::pull::extract_pull_command(
|
||||
&user_message,
|
||||
&ctx.services.bot_name,
|
||||
ctx.matrix_user_id.as_str(),
|
||||
)
|
||||
{
|
||||
slog!("[matrix-bot] Handling 'pull {channel}' from {sender}");
|
||||
let response = if let Some(ref store) = ctx.gateway_channels_store {
|
||||
super::super::super::pull::handle_pull(&channel, store).await
|
||||
} else {
|
||||
"Gateway release channels unavailable — cannot pull.".to_string()
|
||||
};
|
||||
let html = markdown_to_html(&response);
|
||||
if let Ok(msg_id) = ctx
|
||||
.transport
|
||||
.send_message(&room_id_str, &response, &html)
|
||||
.await
|
||||
&& let Ok(event_id) = msg_id.parse()
|
||||
{
|
||||
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// In gateway mode, handle the `upgrade [<project>]` command to upgrade a
|
||||
// sled's binary in-container, streaming phase markers to the room.
|
||||
if ctx.is_gateway()
|
||||
|
||||
@@ -40,6 +40,16 @@ pub async fn run_bot(
|
||||
tokio::sync::broadcast::Receiver<crate::service::gateway::GatewayStatusEvent>,
|
||||
>,
|
||||
gateway_port: Option<u16>,
|
||||
gateway_channels_store: Option<
|
||||
Arc<
|
||||
RwLock<
|
||||
std::collections::BTreeMap<
|
||||
String,
|
||||
crate::service::gateway::config::ReleaseChannelConfig,
|
||||
>,
|
||||
>,
|
||||
>,
|
||||
>,
|
||||
) -> Result<(), String> {
|
||||
let project_root = &services.project_root;
|
||||
let store_path = project_root.join(".huskies").join("matrix_store");
|
||||
@@ -332,6 +342,7 @@ pub async fn run_bot(
|
||||
timer_store,
|
||||
gateway_active_project,
|
||||
gateway_projects_store,
|
||||
gateway_channels_store,
|
||||
handled_incoming_event_ids: Arc::new(TokioMutex::new(super::context::SeenEventIds::new(
|
||||
super::context::SEEN_EVENT_IDS_CAP,
|
||||
))),
|
||||
|
||||
@@ -916,6 +916,7 @@ mod tests {
|
||||
)),
|
||||
gateway_active_project: None,
|
||||
gateway_projects_store: None,
|
||||
gateway_channels_store: None,
|
||||
handled_incoming_event_ids: Arc::new(TokioMutex::new(
|
||||
crate::chat::transport::matrix::bot::context::SeenEventIds::new(
|
||||
crate::chat::transport::matrix::bot::context::SEEN_EVENT_IDS_CAP,
|
||||
|
||||
@@ -35,6 +35,9 @@ pub mod new_project;
|
||||
pub mod project_rebuild;
|
||||
/// `projects` chat command — list all registered gateway projects.
|
||||
pub mod projects;
|
||||
/// `pull <channel>` gateway chat command — fetch, verify, and install a
|
||||
/// signed release-channel artifact into the gateway's artifact store.
|
||||
pub mod pull;
|
||||
/// `rebuild gateway` command parsing (gateway self-rebuild).
|
||||
pub mod rebuild;
|
||||
/// `release` gateway chat command — build the sled binary and publish it.
|
||||
@@ -103,6 +106,16 @@ pub fn spawn_bot(
|
||||
tokio::sync::broadcast::Receiver<crate::service::gateway::GatewayStatusEvent>,
|
||||
>,
|
||||
gateway_port: Option<u16>,
|
||||
gateway_channels_store: Option<
|
||||
Arc<
|
||||
RwLock<
|
||||
std::collections::BTreeMap<
|
||||
String,
|
||||
crate::service::gateway::config::ReleaseChannelConfig,
|
||||
>,
|
||||
>,
|
||||
>,
|
||||
>,
|
||||
) -> Option<tokio::task::AbortHandle> {
|
||||
let config = match BotConfig::load(project_root) {
|
||||
Some(c) => c,
|
||||
@@ -142,6 +155,7 @@ pub fn spawn_bot(
|
||||
timer_store,
|
||||
gateway_event_rx,
|
||||
gateway_port,
|
||||
gateway_channels_store,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -90,6 +90,7 @@ mod tests {
|
||||
)),
|
||||
gateway_active_project: Some(active_project),
|
||||
gateway_projects_store: Some(store),
|
||||
gateway_channels_store: None,
|
||||
handled_incoming_event_ids: Arc::new(TokioMutex::new(
|
||||
crate::chat::transport::matrix::bot::context::SeenEventIds::new(
|
||||
crate::chat::transport::matrix::bot::context::SEEN_EVENT_IDS_CAP,
|
||||
@@ -208,6 +209,7 @@ mod tests {
|
||||
)),
|
||||
gateway_active_project: None,
|
||||
gateway_projects_store: None,
|
||||
gateway_channels_store: None,
|
||||
handled_incoming_event_ids: Arc::new(TokioMutex::new(
|
||||
crate::chat::transport::matrix::bot::context::SeenEventIds::new(
|
||||
crate::chat::transport::matrix::bot::context::SEEN_EVENT_IDS_CAP,
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
//! `pull <channel>` gateway chat command — fetch a signed release manifest
|
||||
//! from a configured channel, verify it, download and verify the artifact,
|
||||
//! and install it atomically into the gateway's artifact store.
|
||||
//!
|
||||
//! Usage (gateway mode only): `{bot} pull <channel>`
|
||||
//!
|
||||
//! Pipeline:
|
||||
//! 1. Look up `<channel>` in `projects.toml`'s `[release_channels.<name>]`
|
||||
//! table. Missing channel, missing `base_url`, or missing `pubkey` is a
|
||||
//! loud chat error — there is no unsigned-pull mode.
|
||||
//! 2. `GET {base_url}/manifest.json` (with `Authorization: Bearer <token>`
|
||||
//! when a `bearer_token` is configured) and verify its Ed25519 signature
|
||||
//! against the channel's pinned `pubkey`.
|
||||
//! 3. Refuse the manifest if its signed timestamp is not newer than the
|
||||
//! currently installed artifact's (rollback/replay protection).
|
||||
//! 4. `GET {base_url}/{manifest.artifact}` and verify its sha256 against the
|
||||
//! manifest.
|
||||
//! 5. Only once every check passes: atomically install the artifact plus its
|
||||
//! `.hash` and `.manifest.json` sidecars into `~/.huskies/artifacts/` —
|
||||
//! the same location `release` publishes to, so `upgrade all` picks it up
|
||||
//! unchanged.
|
||||
//!
|
||||
//! Any failure at any step installs nothing and reports a specific chat error.
|
||||
|
||||
use crate::service::gateway::config::ReleaseChannelConfig;
|
||||
use crate::service::gateway::release_manifest as verify;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Parse a `pull <channel>` command from a raw message body.
|
||||
///
|
||||
/// Returns the channel name when the stripped message is `pull <name>`.
|
||||
/// Returns `None` for `pull` with no argument or any other command.
|
||||
pub fn extract_pull_command(message: &str, bot_name: &str, bot_user_id: &str) -> Option<String> {
|
||||
let stripped = crate::chat::util::strip_bot_mention(message, bot_name, bot_user_id);
|
||||
let trimmed = stripped
|
||||
.trim()
|
||||
.trim_start_matches(|c: char| !c.is_alphanumeric());
|
||||
|
||||
let (cmd, rest) = match trimmed.split_once(char::is_whitespace) {
|
||||
Some((c, r)) => (c, r.trim()),
|
||||
None => (trimmed, ""),
|
||||
};
|
||||
|
||||
if !cmd.eq_ignore_ascii_case("pull") || rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(rest.split_whitespace().next().unwrap_or(rest).to_string())
|
||||
}
|
||||
|
||||
/// Fetch, verify, and install the named release channel's latest artifact.
|
||||
///
|
||||
/// Returns a Markdown status message suitable for posting straight to chat.
|
||||
pub async fn handle_pull(
|
||||
channel: &str,
|
||||
channels_store: &Arc<RwLock<BTreeMap<String, ReleaseChannelConfig>>>,
|
||||
) -> String {
|
||||
let cfg = {
|
||||
let channels = channels_store.read().await;
|
||||
match channels.get(channel).cloned() {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
let available: Vec<&String> = channels.keys().collect();
|
||||
return if available.is_empty() {
|
||||
format!(
|
||||
"No release channels are configured. Add one under \
|
||||
`[release_channels.{channel}]` in projects.toml with `base_url` and `pubkey`."
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Unknown channel `{channel}`. Configured channels: {}",
|
||||
available
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let Some(base_url) = cfg.base_url.filter(|u| !u.is_empty()) else {
|
||||
return format!(
|
||||
"Channel `{channel}` has no `base_url` configured — cannot pull. \
|
||||
There is no unsigned-pull mode."
|
||||
);
|
||||
};
|
||||
let Some(pubkey) = cfg.pubkey.filter(|p| !p.is_empty()) else {
|
||||
return format!(
|
||||
"Channel `{channel}` has no pinned `pubkey` configured — cannot pull. \
|
||||
There is no unsigned-pull mode."
|
||||
);
|
||||
};
|
||||
|
||||
run_pull(
|
||||
channel,
|
||||
&base_url,
|
||||
&pubkey,
|
||||
cfg.bearer_token.as_deref(),
|
||||
&crate::http::artifacts_dir(),
|
||||
crate::http::SLED_ARTIFACT_NAME,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Run the pull pipeline against an already-resolved channel config.
|
||||
///
|
||||
/// Split from [`handle_pull`] so tests can point `artifacts_dir` at a temp
|
||||
/// directory instead of the real `~/.huskies/artifacts/`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn run_pull(
|
||||
channel: &str,
|
||||
base_url: &str,
|
||||
pubkey: &str,
|
||||
bearer_token: Option<&str>,
|
||||
artifacts_dir: &Path,
|
||||
artifact_name: &str,
|
||||
) -> String {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(60))
|
||||
.build()
|
||||
.unwrap_or_default();
|
||||
|
||||
let signed =
|
||||
match crate::service::gateway::io::fetch_release_manifest(&client, base_url, bearer_token)
|
||||
.await
|
||||
{
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
return format!("Pull from `{channel}` failed: could not fetch manifest — {e}");
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = verify::verify_manifest_signature(&signed, pubkey) {
|
||||
return format!("Pull from `{channel}` failed: {e}. Nothing was installed.");
|
||||
}
|
||||
|
||||
if signed.manifest.channel != channel {
|
||||
return format!(
|
||||
"Pull from `{channel}` failed: manifest is signed for channel `{}`, not `{channel}`. \
|
||||
Nothing was installed.",
|
||||
signed.manifest.channel
|
||||
);
|
||||
}
|
||||
|
||||
let installed =
|
||||
crate::service::gateway::io::read_installed_manifest(artifacts_dir, artifact_name);
|
||||
if let Err(e) = verify::check_rollback(&signed.manifest, installed.as_ref()) {
|
||||
return format!("Pull from `{channel}` refused: {e}");
|
||||
}
|
||||
|
||||
let bytes = match crate::service::gateway::io::download_channel_artifact(
|
||||
&client,
|
||||
base_url,
|
||||
&signed.manifest.artifact,
|
||||
bearer_token,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
return format!(
|
||||
"Pull from `{channel}` failed: could not download artifact — {e}. Nothing was installed."
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = verify::verify_artifact_sha256(&bytes, &signed.manifest.sha256) {
|
||||
return format!("Pull from `{channel}` failed: {e}. Nothing was installed.");
|
||||
}
|
||||
|
||||
if let Err(e) = crate::service::gateway::io::install_pulled_artifact(
|
||||
artifacts_dir,
|
||||
artifact_name,
|
||||
&bytes,
|
||||
&signed.manifest,
|
||||
) {
|
||||
return format!("Pull from `{channel}` failed while installing: {e}");
|
||||
}
|
||||
|
||||
let size_mb = bytes.len() / (1024 * 1024);
|
||||
format!(
|
||||
"Pulled **{}** from `{channel}` ({size_mb} MB, verified signature + sha256).\n\
|
||||
Say `upgrade all` to roll it out to the fleet.",
|
||||
signed.manifest.version
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bft_json_crdt::keypair::{make_keypair, sign};
|
||||
use release_manifest::{ReleaseManifest, SignedManifest};
|
||||
|
||||
fn make_store(
|
||||
entries: Vec<(&str, ReleaseChannelConfig)>,
|
||||
) -> Arc<RwLock<BTreeMap<String, ReleaseChannelConfig>>> {
|
||||
let mut map = BTreeMap::new();
|
||||
for (name, cfg) in entries {
|
||||
map.insert(name.to_string(), cfg);
|
||||
}
|
||||
Arc::new(RwLock::new(map))
|
||||
}
|
||||
|
||||
// ── extract_pull_command ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extract_pull_basic() {
|
||||
let cmd = extract_pull_command("Timmy pull stable", "Timmy", "@timmy:home");
|
||||
assert_eq!(cmd, Some("stable".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_pull_case_insensitive() {
|
||||
let cmd = extract_pull_command("Timmy PULL stable", "Timmy", "@timmy:home");
|
||||
assert_eq!(cmd, Some("stable".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_pull_no_arg_is_none() {
|
||||
assert_eq!(
|
||||
extract_pull_command("Timmy pull", "Timmy", "@timmy:home"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_non_pull_returns_none() {
|
||||
assert_eq!(
|
||||
extract_pull_command("Timmy status", "Timmy", "@timmy:home"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_pull_full_user_id() {
|
||||
let cmd = extract_pull_command("@timmy:home pull nightly", "Timmy", "@timmy:home");
|
||||
assert_eq!(cmd, Some("nightly".to_string()));
|
||||
}
|
||||
|
||||
// ── handle_pull config validation ────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn pull_unknown_channel_reports_error() {
|
||||
let store = make_store(vec![]);
|
||||
let msg = handle_pull("stable", &store).await;
|
||||
assert!(
|
||||
msg.contains("No release channels"),
|
||||
"empty store should say no channels: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pull_unknown_channel_among_configured_lists_them() {
|
||||
let store = make_store(vec![(
|
||||
"beta",
|
||||
ReleaseChannelConfig {
|
||||
base_url: Some("http://example.com".into()),
|
||||
pubkey: Some("ab".repeat(32)),
|
||||
bearer_token: None,
|
||||
},
|
||||
)]);
|
||||
let msg = handle_pull("stable", &store).await;
|
||||
assert!(msg.contains("Unknown channel"));
|
||||
assert!(msg.contains("beta"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pull_missing_base_url_is_actionable_error() {
|
||||
let store = make_store(vec![(
|
||||
"stable",
|
||||
ReleaseChannelConfig {
|
||||
base_url: None,
|
||||
pubkey: Some("ab".repeat(32)),
|
||||
bearer_token: None,
|
||||
},
|
||||
)]);
|
||||
let msg = handle_pull("stable", &store).await;
|
||||
assert!(msg.contains("no `base_url`"));
|
||||
assert!(msg.contains("no unsigned-pull mode"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pull_missing_pubkey_is_actionable_error() {
|
||||
let store = make_store(vec![(
|
||||
"stable",
|
||||
ReleaseChannelConfig {
|
||||
base_url: Some("http://example.com".into()),
|
||||
pubkey: None,
|
||||
bearer_token: None,
|
||||
},
|
||||
)]);
|
||||
let msg = handle_pull("stable", &store).await;
|
||||
assert!(msg.contains("no pinned `pubkey`"));
|
||||
assert!(msg.contains("no unsigned-pull mode"));
|
||||
}
|
||||
|
||||
// ── run_pull end-to-end against a local mock channel ─────────────────────
|
||||
|
||||
/// Spawn a minimal HTTP server serving fixed responses for
|
||||
/// `/manifest.json` and `/<artifact>` so `run_pull` can be exercised
|
||||
/// end-to-end without a real network.
|
||||
async fn spawn_mock_channel(manifest_body: Vec<u8>, artifact_body: Vec<u8>) -> u16 {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut stream, _)) = listener.accept().await else {
|
||||
break;
|
||||
};
|
||||
let manifest_body = manifest_body.clone();
|
||||
let artifact_body = artifact_body.clone();
|
||||
tokio::spawn(async move {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let mut buf = [0u8; 4096];
|
||||
let n = stream.read(&mut buf).await.unwrap_or(0);
|
||||
let req = String::from_utf8_lossy(&buf[..n]).to_string();
|
||||
let path = req.split_whitespace().nth(1).unwrap_or("");
|
||||
let body = if path.ends_with("manifest.json") {
|
||||
manifest_body
|
||||
} else {
|
||||
artifact_body
|
||||
};
|
||||
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;
|
||||
});
|
||||
}
|
||||
});
|
||||
port
|
||||
}
|
||||
|
||||
fn signed(
|
||||
kp: &bft_json_crdt::keypair::Ed25519KeyPair,
|
||||
manifest: ReleaseManifest,
|
||||
) -> SignedManifest {
|
||||
let sig = sign(kp, &manifest.canonical_bytes());
|
||||
SignedManifest {
|
||||
manifest,
|
||||
signature: sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pull_accepts_valid_signature_and_matching_sha256() {
|
||||
let kp = make_keypair();
|
||||
let pubkey = crate::node_identity::public_key_hex(&kp);
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
let artifact_body = b"a genuinely valid binary".to_vec();
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&artifact_body);
|
||||
let sha256: String = hasher
|
||||
.finalize()
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect();
|
||||
|
||||
let manifest = ReleaseManifest {
|
||||
artifact: "huskies-linux-arm64".to_string(),
|
||||
sha256,
|
||||
version: "abc1234".to_string(),
|
||||
channel: "stable".to_string(),
|
||||
timestamp: 1,
|
||||
};
|
||||
let signed_manifest = signed(&kp, manifest);
|
||||
let manifest_body = serde_json::to_vec(&signed_manifest).unwrap();
|
||||
|
||||
let port = spawn_mock_channel(manifest_body, artifact_body).await;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
let msg = run_pull(
|
||||
"stable",
|
||||
&format!("http://127.0.0.1:{port}"),
|
||||
&pubkey,
|
||||
None,
|
||||
dir.path(),
|
||||
"huskies-linux-arm64",
|
||||
)
|
||||
.await;
|
||||
assert!(msg.contains("Pulled"), "expected success message: {msg}");
|
||||
assert!(msg.contains("abc1234"));
|
||||
assert!(dir.path().join("huskies-linux-arm64").exists());
|
||||
assert!(dir.path().join("huskies-linux-arm64.hash").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pull_rejects_manifest_from_wrong_key() {
|
||||
let kp = make_keypair();
|
||||
let wrong_kp = make_keypair();
|
||||
let wrong_pubkey = crate::node_identity::public_key_hex(&wrong_kp);
|
||||
|
||||
let manifest = ReleaseManifest {
|
||||
artifact: "huskies-linux-arm64".to_string(),
|
||||
sha256: "a".repeat(64),
|
||||
version: "abc1234".to_string(),
|
||||
channel: "stable".to_string(),
|
||||
timestamp: 1,
|
||||
};
|
||||
let signed_manifest = signed(&kp, manifest);
|
||||
let manifest_body = serde_json::to_vec(&signed_manifest).unwrap();
|
||||
|
||||
let port = spawn_mock_channel(manifest_body, b"binary".to_vec()).await;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
let msg = run_pull(
|
||||
"stable",
|
||||
&format!("http://127.0.0.1:{port}"),
|
||||
&wrong_pubkey,
|
||||
None,
|
||||
dir.path(),
|
||||
"huskies-linux-arm64",
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
msg.contains("signature"),
|
||||
"expected signature failure: {msg}"
|
||||
);
|
||||
assert!(msg.contains("Nothing was installed"));
|
||||
assert!(!dir.path().join("huskies-linux-arm64").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pull_rejects_tampered_artifact_sha256_mismatch() {
|
||||
let kp = make_keypair();
|
||||
let pubkey = crate::node_identity::public_key_hex(&kp);
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b"the real bytes");
|
||||
let real_sha256: String = hasher
|
||||
.finalize()
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect();
|
||||
|
||||
let manifest = ReleaseManifest {
|
||||
artifact: "huskies-linux-arm64".to_string(),
|
||||
sha256: real_sha256,
|
||||
version: "abc1234".to_string(),
|
||||
channel: "stable".to_string(),
|
||||
timestamp: 1,
|
||||
};
|
||||
let signed_manifest = signed(&kp, manifest);
|
||||
let manifest_body = serde_json::to_vec(&signed_manifest).unwrap();
|
||||
|
||||
// Serve different bytes than what was signed — sha256 mismatch.
|
||||
let port = spawn_mock_channel(manifest_body, b"tampered bytes".to_vec()).await;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
let msg = run_pull(
|
||||
"stable",
|
||||
&format!("http://127.0.0.1:{port}"),
|
||||
&pubkey,
|
||||
None,
|
||||
dir.path(),
|
||||
"huskies-linux-arm64",
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
msg.contains("sha256 mismatch"),
|
||||
"expected sha256 failure: {msg}"
|
||||
);
|
||||
assert!(msg.contains("Nothing was installed"));
|
||||
assert!(!dir.path().join("huskies-linux-arm64").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pull_missing_manifest_endpoint_is_loud_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let msg = run_pull(
|
||||
"stable",
|
||||
"http://127.0.0.1:1", // nothing listening
|
||||
&"ab".repeat(32),
|
||||
None,
|
||||
dir.path(),
|
||||
"huskies-linux-arm64",
|
||||
)
|
||||
.await;
|
||||
assert!(msg.contains("could not fetch manifest"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pull_refuses_manifest_older_than_installed() {
|
||||
let kp = make_keypair();
|
||||
let pubkey = crate::node_identity::public_key_hex(&kp);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
// Pre-install a manifest with a later timestamp than the one about to be pulled.
|
||||
let installed = ReleaseManifest {
|
||||
artifact: "huskies-linux-arm64".to_string(),
|
||||
sha256: "a".repeat(64),
|
||||
version: "newer0000".to_string(),
|
||||
channel: "stable".to_string(),
|
||||
timestamp: 100,
|
||||
};
|
||||
crate::service::gateway::io::install_pulled_artifact(
|
||||
dir.path(),
|
||||
"huskies-linux-arm64",
|
||||
b"already installed",
|
||||
&installed,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let older_manifest = ReleaseManifest {
|
||||
artifact: "huskies-linux-arm64".to_string(),
|
||||
sha256: "b".repeat(64),
|
||||
version: "older0000".to_string(),
|
||||
channel: "stable".to_string(),
|
||||
timestamp: 50,
|
||||
};
|
||||
let signed_manifest = signed(&kp, older_manifest);
|
||||
let manifest_body = serde_json::to_vec(&signed_manifest).unwrap();
|
||||
let port = spawn_mock_channel(manifest_body, b"stale binary".to_vec()).await;
|
||||
|
||||
let msg = run_pull(
|
||||
"stable",
|
||||
&format!("http://127.0.0.1:{port}"),
|
||||
&pubkey,
|
||||
None,
|
||||
dir.path(),
|
||||
"huskies-linux-arm64",
|
||||
)
|
||||
.await;
|
||||
assert!(msg.contains("refused"), "expected rollback refusal: {msg}");
|
||||
// The previously installed artifact must remain untouched.
|
||||
assert_eq!(
|
||||
std::fs::read(dir.path().join("huskies-linux-arm64")).unwrap(),
|
||||
b"already installed"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user