540 lines
19 KiB
Rust
540 lines
19 KiB
Rust
//! `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"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|