//! `pull ` 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 ` //! //! Pipeline: //! 1. Look up `` in `projects.toml`'s `[release_channels.]` //! 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 ` //! 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 ` command from a raw message body. /// /// Returns the channel name when the stripped message is `pull `. /// 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 { 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>>, ) -> 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::>() .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>> { 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 `/` so `run_pull` can be exercised /// end-to-end without a real network. async fn spawn_mock_channel(manifest_body: Vec, artifact_body: Vec) -> 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" ); } }