use std::fmt;
use std::future::Future;
use std::io::Read as _;
use std::path::{Path, PathBuf};
use std::time::Duration;
use microvms_app::provision::{
ASSET, AttestationVerifier, Bundles, CHECKSUMS, RELEASE_REPO, ReleaseSource, Signer,
fetch_release,
};
use super::{Fetch, Verification};
pub const BUNDLE_ASSET: &str = "agentd.sigstore.json";
pub const TOKEN_VARIABLE: &str = "GITHUB_TOKEN";
pub const RELEASE_DIR_VARIABLE: &str = "MICROVM_RELEASE_DIR";
const ASSET_LIMIT: usize = 64 << 20;
const SMALL_LIMIT: usize = 1 << 20;
const API_LIMIT: usize = 8 << 20;
#[derive(Clone, Default)]
pub struct GitHubRelease {
token: Option<String>,
}
impl GitHubRelease {
pub fn new(token: Option<String>) -> Self {
Self {
token: token.filter(|token| !token.is_empty()),
}
}
pub fn from_env(env: &dyn Fn(&str) -> Option<String>) -> Self {
Self::new(env(TOKEN_VARIABLE))
}
fn lookup(&self, tag: &str, sha256: &str, get: &Get<'_>) -> Bundles {
let asset_reason = match get(&asset_url(tag, BUNDLE_ASSET), None, SMALL_LIMIT) {
Ok(body) => return Bundles::Published(vec![String::from_utf8_lossy(&body).into()]),
Err(failure) if failure.success() => return Bundles::Absent(failure.message),
Err(failure) => failure,
};
let (api_absent, api_reason) = match self.api_bundles(sha256, get) {
Ok(bundles) if !bundles.is_empty() => return Bundles::Published(bundles),
Ok(_) => (true, "no attestation for the digest".to_string()),
Err(failure) => (failure.not_found() || failure.success(), failure.message),
};
let reason = format!(
"the {BUNDLE_ASSET} asset: {}; the attestations API: {api_reason}",
asset_reason.message
);
if asset_reason.not_found() || api_absent {
Bundles::Absent(reason)
} else {
Bundles::Unreachable(reason)
}
}
fn api_bundles(&self, sha256: &str, get: &Get<'_>) -> Result<Vec<String>, HttpFailure> {
let url = attestations_url(sha256);
let body = get(&url, self.token.as_deref(), API_LIMIT).map_err(|failure| {
if self.token.is_none() && matches!(failure.status, Some(403 | 429)) {
HttpFailure {
message: format!(
"{} (the unauthenticated rate limit; set {TOKEN_VARIABLE} to raise it)",
failure.message
),
..failure
}
} else {
failure
}
})?;
parse_attestations(&body).map_err(|message| HttpFailure {
status: Some(200),
message,
})
}
}
type Get<'a> = dyn Fn(&str, Option<&str>, usize) -> Result<Vec<u8>, HttpFailure> + 'a;
#[derive(Clone, Debug, PartialEq, Eq)]
struct HttpFailure {
status: Option<u16>,
message: String,
}
impl HttpFailure {
fn success(&self) -> bool {
self.status
.is_some_and(|status| (200..300).contains(&status))
}
fn not_found(&self) -> bool {
self.status == Some(404)
}
}
fn https_get(url: &str, token: Option<&str>, limit: usize) -> Result<Vec<u8>, HttpFailure> {
run(get(url, token, limit))
}
impl fmt::Debug for GitHubRelease {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("GitHubRelease")
.field("token", &self.token.as_ref().map(|_| "<redacted>"))
.finish()
}
}
impl ReleaseSource for GitHubRelease {
fn asset(&self, tag: &str, name: &str) -> Result<Vec<u8>, String> {
https_get(&asset_url(tag, name), None, ASSET_LIMIT).map_err(|failure| failure.message)
}
fn checksums(&self, tag: &str) -> Result<String, String> {
let body = https_get(&asset_url(tag, CHECKSUMS), None, SMALL_LIMIT)
.map_err(|failure| failure.message)?;
String::from_utf8(body).map_err(|_| format!("{CHECKSUMS} is not UTF-8"))
}
fn attestations(&self, tag: &str, sha256: &str) -> Bundles {
self.lookup(tag, sha256, &https_get)
}
}
pub fn asset_url(tag: &str, asset: &str) -> String {
format!("https://github.com/{RELEASE_REPO}/releases/download/{tag}/{asset}")
}
pub fn attestations_url(sha256: &str) -> String {
format!("https://api.github.com/repos/{RELEASE_REPO}/attestations/sha256:{sha256}")
}
#[derive(Clone, Debug)]
struct DirectoryRelease {
root: PathBuf,
}
impl DirectoryRelease {
fn read(&self, tag: &str, name: &str, limit: usize) -> Result<Vec<u8>, String> {
read_limited(&self.root.join(tag).join(name), limit)
.map_err(|reason| format!("${RELEASE_DIR_VARIABLE}: {reason}"))
}
}
impl ReleaseSource for DirectoryRelease {
fn asset(&self, tag: &str, name: &str) -> Result<Vec<u8>, String> {
self.read(tag, name, ASSET_LIMIT)
}
fn checksums(&self, tag: &str) -> Result<String, String> {
let body = self.read(tag, CHECKSUMS, SMALL_LIMIT)?;
String::from_utf8(body).map_err(|_| format!("{CHECKSUMS} is not UTF-8"))
}
fn attestations(&self, tag: &str, _: &str) -> Bundles {
match self.read(tag, BUNDLE_ASSET, SMALL_LIMIT) {
Ok(body) => Bundles::Published(vec![String::from_utf8_lossy(&body).into()]),
Err(reason) => Bundles::Absent(reason),
}
}
}
fn read_limited(path: &Path, limit: usize) -> Result<Vec<u8>, String> {
let file = std::fs::File::open(path)
.map_err(|error| format!("could not open {}: {error}", path.display()))?;
let mut bytes = Vec::new();
file.take(limit as u64 + 1)
.read_to_end(&mut bytes)
.map_err(|error| format!("could not read {}: {error}", path.display()))?;
if bytes.len() > limit {
return Err(format!("{} is over {limit} bytes", path.display()));
}
Ok(bytes)
}
fn parse_attestations(body: &[u8]) -> Result<Vec<String>, String> {
let response: serde_json::Value = serde_json::from_slice(body)
.map_err(|error| format!("the response is not JSON: {error}"))?;
let listed = response["attestations"]
.as_array()
.ok_or("the response has no `attestations` list")?;
let bundles: Vec<String> = listed
.iter()
.filter(|attestation| attestation["bundle"].is_object())
.map(|attestation| attestation["bundle"].to_string())
.collect();
if bundles.is_empty() && !listed.is_empty() {
return Err("the response lists attestations with no inline bundle".to_string());
}
Ok(bundles)
}
fn run<T: Send>(
request: impl Future<Output = Result<T, HttpFailure>> + Send,
) -> Result<T, HttpFailure> {
let failure = |message: String| HttpFailure {
status: None,
message,
};
std::thread::scope(|scope| {
scope
.spawn(|| {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|error| {
failure(format!(
"could not start a runtime for the download: {error}"
))
})?
.block_on(request)
})
.join()
.unwrap_or_else(|_| Err(failure("the download thread panicked".to_string())))
})
}
async fn get(url: &str, token: Option<&str>, limit: usize) -> Result<Vec<u8>, HttpFailure> {
let failure = |message: String| HttpFailure {
status: None,
message,
};
let client = reqwest::Client::builder()
.https_only(true)
.user_agent(concat!("crates/microvms-edges/", env!("CARGO_PKG_VERSION")))
.connect_timeout(Duration::from_secs(30))
.timeout(Duration::from_secs(300))
.build()
.map_err(|error| failure(format!("could not build an HTTP client: {}", chain(&error))))?;
let mut request = client.get(url);
if url.starts_with("https://api.github.com/") {
request = request
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28");
if let Some(token) = token {
request = request.bearer_auth(token);
}
}
let mut response = request
.send()
.await
.map_err(|error| failure(format!("GET {url}: {}", chain(&error))))?;
let status = response.status().as_u16();
let mut body = Body::start(url, response.status(), response.content_length(), limit)?;
while let Some(chunk) = response.chunk().await.map_err(|error| HttpFailure {
status: Some(status),
message: format!("GET {url}: {}", chain(&error)),
})? {
body.push(&chunk)?;
}
Ok(body.bytes)
}
struct Body<'a> {
url: &'a str,
limit: usize,
status: u16,
bytes: Vec<u8>,
}
impl<'a> Body<'a> {
fn start(
url: &'a str,
status: reqwest::StatusCode,
length: Option<u64>,
limit: usize,
) -> Result<Self, HttpFailure> {
if !status.is_success() {
return Err(HttpFailure {
status: Some(status.as_u16()),
message: format!("GET {url}: HTTP {}", status.as_u16()),
});
}
let body = Self {
url,
limit,
status: status.as_u16(),
bytes: Vec::new(),
};
if length.is_some_and(|length| length > limit as u64) {
return Err(body.over_limit());
}
Ok(body)
}
fn push(&mut self, chunk: &[u8]) -> Result<(), HttpFailure> {
if chunk.len() > self.limit.saturating_sub(self.bytes.len()) {
return Err(self.over_limit());
}
self.bytes.extend_from_slice(chunk);
Ok(())
}
fn over_limit(&self) -> HttpFailure {
HttpFailure {
status: Some(self.status),
message: format!("GET {}: the body is over {} bytes", self.url, self.limit),
}
}
}
fn chain(error: &dyn std::error::Error) -> String {
let mut text = error.to_string();
let mut source = error.source();
while let Some(cause) = source {
text.push_str(": ");
text.push_str(&cause.to_string());
source = cause.source();
}
text
}
pub struct SigstoreVerifier {
verifier: sigstore_verify::Verifier,
}
impl SigstoreVerifier {
pub fn public_good() -> Result<Self, String> {
let root = sigstore_trust_root::TrustedRoot::from_json(
sigstore_trust_root::SIGSTORE_PRODUCTION_TRUSTED_ROOT,
)
.map_err(|error| format!("the embedded Sigstore trusted root: {error}"))?;
let verifier = sigstore_verify::Verifier::new(&root)
.map_err(|error| format!("the embedded Sigstore trusted root: {error}"))?;
Ok(Self { verifier })
}
}
impl fmt::Debug for SigstoreVerifier {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("SigstoreVerifier(public-good)")
}
}
impl AttestationVerifier for SigstoreVerifier {
fn verify(&self, bundle: &str, artifact: &[u8], signer: &Signer) -> Result<(), String> {
let bundle = sigstore_types::Bundle::from_json(bundle)
.map_err(|error| format!("the bundle doesn't parse: {error}"))?;
let policy = sigstore_verify::VerificationPolicy::new(&signer.identity, &signer.issuer);
self.verifier
.verify(artifact, &bundle, &policy)
.map_err(|error| error.to_string())?;
let sigstore_types::SignatureContent::DsseEnvelope(envelope) = &bundle.content else {
return Err("the bundle signs a message, not an in-toto attestation".to_string());
};
let statement: sigstore_types::Statement =
serde_json::from_slice(envelope.payload.as_bytes())
.map_err(|error| format!("the attested statement doesn't parse: {error}"))?;
if statement.predicate_type != signer.predicate_type {
return Err(format!(
"predicate type mismatch: expected {}, got {}",
signer.predicate_type, statement.predicate_type
));
}
Ok(())
}
}
#[derive(Clone, Debug, Default)]
pub struct PolicyFetch<S, V> {
pub source: S,
pub verifier: V,
}
impl<S: ReleaseSource, V: AttestationVerifier> Fetch for PolicyFetch<S, V> {
fn fetch(
&self,
tag: &str,
dest: &Path,
progress: &mut dyn FnMut(&str),
) -> Result<Verification, String> {
let fetched = fetch_release(&self.source, &self.verifier, tag, progress)?;
std::fs::write(dest, &fetched.bytes)
.map_err(|error| format!("could not write {ASSET} to {}: {error}", dest.display()))?;
Ok(fetched.verification)
}
}
#[derive(Clone, Debug, Default)]
pub struct HttpsFetch {
release: GitHubRelease,
directory: Option<DirectoryRelease>,
}
impl HttpsFetch {
pub fn from_env(env: &dyn Fn(&str) -> Option<String>) -> Self {
Self {
release: GitHubRelease::from_env(env),
directory: env(RELEASE_DIR_VARIABLE)
.filter(|root| !root.is_empty())
.map(|root| DirectoryRelease {
root: PathBuf::from(root),
}),
}
}
}
impl Fetch for HttpsFetch {
fn fetch(
&self,
tag: &str,
dest: &Path,
progress: &mut dyn FnMut(&str),
) -> Result<Verification, String> {
let verifier = SigstoreVerifier::public_good()?;
match &self.directory {
Some(directory) => {
progress(&format!(
"reading {ASSET} {tag} from {} (${RELEASE_DIR_VARIABLE})",
directory.root.join(tag).display()
));
PolicyFetch {
source: directory.clone(),
verifier,
}
.fetch(tag, dest, progress)
}
None => PolicyFetch {
source: self.release.clone(),
verifier,
}
.fetch(tag, dest, progress),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use microvms_app::provision::{ReleaseSource, sha256_hex};
const TAG: &str = "v0.7.0";
const BUNDLE: &str = include_str!("../../tests/fixtures/release-v0.7.0/agentd.sigstore.json");
const SUMS: &str = include_str!("../../tests/fixtures/release-v0.7.0/SHA256SUMS");
fn agentd() -> &'static [u8] {
static AGENTD: std::sync::LazyLock<Vec<u8>> = std::sync::LazyLock::new(|| {
use std::io::Read as _;
let packed = include_bytes!("../../tests/fixtures/release-v0.7.0/agentd.gz");
let mut bytes = Vec::new();
flate2::read::GzDecoder::new(&packed[..])
.read_to_end(&mut bytes)
.expect("the fixture is gzip");
bytes
});
&AGENTD
}
fn verifier() -> SigstoreVerifier {
SigstoreVerifier::public_good().expect("the embedded trusted root parses")
}
fn flipped() -> Vec<u8> {
let mut bytes = agentd().to_vec();
bytes[1_000_000] ^= 0x01;
bytes
}
fn tampered(edit: impl FnOnce(&mut serde_json::Value)) -> String {
let mut bundle: serde_json::Value =
serde_json::from_str(BUNDLE).expect("the fixture parses");
edit(&mut bundle["verificationMaterial"]["tlogEntries"][0]);
bundle.to_string()
}
fn flip_base64(value: &mut serde_json::Value) {
use base64::Engine as _;
let engine = base64::engine::general_purpose::STANDARD;
let mut bytes = engine
.decode(value.as_str().expect("a base64 string"))
.expect("valid base64");
let middle = bytes.len() / 2;
bytes[middle] ^= 0x01;
*value = serde_json::Value::String(engine.encode(bytes));
}
#[test]
fn the_committed_release_verifies_against_its_bundle() {
verifier()
.verify(BUNDLE, agentd(), &Signer::release(TAG))
.expect("the v0.7.0 release asset is attested by its release workflow");
assert_eq!(
sha256_hex(agentd()),
"b281856000f58e6306bcaca73362e4d77260324a51f9b7ba3de563cf102dbe76",
"the fixture is the published asset"
);
}
#[test]
fn one_flipped_byte_in_the_asset_is_refused() {
let refusal = verifier()
.verify(BUNDLE, &flipped(), &Signer::release(TAG))
.expect_err("changed bytes must not verify");
assert!(refusal.contains("does not match any subject"), "{refusal}");
}
#[test]
fn another_signer_identity_is_refused() {
let release = Signer::release(TAG);
let others = [
Signer::release("v0.8.0"),
Signer {
identity: release.identity.replace("release.yml", "ci.yml"),
..release.clone()
},
Signer {
identity: release.identity.replace("laithalsaadoon", "theagenticguy"),
..release.clone()
},
];
for signer in others {
let refusal = verifier()
.verify(BUNDLE, agentd(), &signer)
.expect_err(&signer.identity);
assert!(refusal.contains("identity mismatch"), "{refusal}");
}
}
#[test]
fn another_issuer_is_refused() {
let signer = Signer {
issuer: "https://accounts.google.com".into(),
..Signer::release(TAG)
};
let refusal = verifier()
.verify(BUNDLE, agentd(), &signer)
.expect_err("another issuer must refuse");
assert!(refusal.contains("issuer mismatch"), "{refusal}");
}
#[test]
fn another_predicate_type_is_refused() {
let signer = Signer {
predicate_type: "https://spdx.dev/Document".into(),
..Signer::release(TAG)
};
let refusal = verifier()
.verify(BUNDLE, agentd(), &signer)
.expect_err("another predicate type must refuse");
assert!(refusal.contains("predicate type mismatch"), "{refusal}");
}
#[test]
fn tampered_transparency_evidence_is_refused() {
let cases = [
(
"proof hash",
tampered(|entry| flip_base64(&mut entry["inclusionProof"]["hashes"][0])),
),
(
"checkpoint signature",
tampered(|entry| {
let envelope = entry["inclusionProof"]["checkpoint"]["envelope"]
.as_str()
.expect("a checkpoint")
.to_string();
let (head, signature) = envelope.trim_end().rsplit_once(' ').expect("signed");
let mut signature = serde_json::Value::String(signature.to_string());
flip_base64(&mut signature);
entry["inclusionProof"]["checkpoint"]["envelope"] = serde_json::Value::String(
format!("{head} {}\n", signature.as_str().expect("a string")),
);
}),
),
(
"proof log index",
tampered(|entry| {
let index: u64 = entry["inclusionProof"]["logIndex"]
.as_str()
.expect("a string")
.parse()
.expect("a number");
entry["inclusionProof"]["logIndex"] =
serde_json::Value::String((index + 1).to_string());
}),
),
(
"proof root hash",
tampered(|entry| flip_base64(&mut entry["inclusionProof"]["rootHash"])),
),
(
"SET",
tampered(|entry| {
flip_base64(&mut entry["inclusionPromise"]["signedEntryTimestamp"])
}),
),
(
"integrated time",
tampered(|entry| {
let time: u64 = entry["integratedTime"]
.as_str()
.expect("a string")
.parse()
.expect("a number");
entry["integratedTime"] = serde_json::Value::String((time + 60).to_string());
}),
),
];
for (what, bundle) in cases {
verifier()
.verify(&bundle, agentd(), &Signer::release(TAG))
.expect_err(what);
}
}
#[test]
fn a_bundle_that_does_not_parse_is_refused() {
let refusal = verifier()
.verify("{\"not\": \"a bundle\"}", agentd(), &Signer::release(TAG))
.expect_err("must refuse");
assert!(refusal.contains("doesn't parse"), "{refusal}");
}
struct Fixture {
asset: Vec<u8>,
bundle: Option<Bundle>,
}
#[derive(Clone, Copy, Debug)]
enum Bundle {
Unreachable,
Absent,
}
impl ReleaseSource for Fixture {
fn asset(&self, tag: &str, name: &str) -> Result<Vec<u8>, String> {
assert_eq!((tag, name), (TAG, ASSET));
Ok(self.asset.clone())
}
fn checksums(&self, _: &str) -> Result<String, String> {
Ok(SUMS.to_string())
}
fn attestations(&self, _: &str, _: &str) -> Bundles {
match self.bundle {
None => Bundles::Published(vec![BUNDLE.to_string()]),
Some(Bundle::Unreachable) => Bundles::Unreachable("HTTP 403".to_string()),
Some(Bundle::Absent) => Bundles::Absent("HTTP 404".to_string()),
}
}
}
#[test]
fn the_policy_over_the_committed_release() {
let fetch = |asset: Vec<u8>, bundle: Option<Bundle>| {
fetch_release(&Fixture { asset, bundle }, &verifier(), TAG, &mut |_| {})
};
let attested = fetch(agentd().to_vec(), None).expect("attested");
assert_eq!(attested.verification, Verification::Attestation);
assert_eq!(attested.bytes, agentd());
let checked = fetch(agentd().to_vec(), Some(Bundle::Unreachable)).expect("checked");
assert_eq!(checked.verification, Verification::Checksum);
let refusal = fetch(agentd().to_vec(), Some(Bundle::Absent))
.expect_err("a release that says it has no bundle is refused");
assert!(refusal.contains("publishes no attestation"), "{refusal}");
let refusal = fetch(flipped(), None).expect_err("flipped bytes, bundle there");
assert!(refusal.contains("discarded"), "{refusal}");
let refusal = fetch(flipped(), Some(Bundle::Unreachable)).expect_err("flipped, no bundle");
assert!(refusal.contains("SHA256 mismatch"), "{refusal}");
}
#[derive(Clone, Copy, Debug)]
enum Answer {
Bundle,
EmptyList,
Junk,
Status(u16),
Transport,
}
fn lookup(token: Option<&str>, asset: Answer, api: Answer) -> (Bundles, Vec<Option<String>>) {
let tokens = std::cell::RefCell::new(Vec::new());
let get = |url: &str, token: Option<&str>, _: usize| {
tokens.borrow_mut().push(token.map(str::to_string));
let (answer, from_api) = if url == asset_url(TAG, BUNDLE_ASSET) {
(asset, false)
} else {
assert_eq!(url, attestations_url("abc"), "only the two lookups run");
(api, true)
};
let bundle: serde_json::Value = serde_json::from_str(BUNDLE).expect("parses");
match answer {
Answer::Bundle if from_api => Ok(serde_json::json!({
"attestations": [{ "bundle": bundle }]
})
.to_string()
.into_bytes()),
Answer::Bundle => Ok(BUNDLE.as_bytes().to_vec()),
Answer::EmptyList => Ok(br#"{"attestations": []}"#.to_vec()),
Answer::Junk => Ok(b"\xff<html>".to_vec()),
Answer::Status(status) => Err(HttpFailure {
status: Some(status),
message: format!("GET {url}: HTTP {status}"),
}),
Answer::Transport => Err(HttpFailure {
status: None,
message: format!("GET {url}: error sending request: dns error"),
}),
}
};
let found = GitHubRelease::new(token.map(str::to_string)).lookup(TAG, "abc", &get);
(found, tokens.into_inner())
}
#[test]
fn a_missing_bundle_is_absent_and_a_failed_request_is_unreachable() {
use Answer::*;
let published = |found: &Bundles| matches!(found, Bundles::Published(b) if b.len() == 1);
let absent = |found: &Bundles| matches!(found, Bundles::Absent(_));
let unreachable = |found: &Bundles| matches!(found, Bundles::Unreachable(_));
type Row<'a> = (Answer, Answer, &'a dyn Fn(&Bundles) -> bool);
let rows: [Row<'_>; 14] = [
(Bundle, Status(500), &published),
(Junk, Status(404), &published),
(Junk, Bundle, &published),
(Status(404), Bundle, &published),
(Status(503), Bundle, &published),
(Status(404), Status(404), &absent),
(Status(404), EmptyList, &absent),
(Status(404), Status(403), &absent),
(Status(404), Transport, &absent),
(Transport, Status(404), &absent),
(Status(403), EmptyList, &absent),
(Status(403), Status(429), &unreachable),
(Transport, Status(502), &unreachable),
(Status(500), Junk, &absent),
];
for (asset, api, expected) in rows {
let (found, _) = lookup(None, asset, api);
assert!(expected(&found), "asset {asset:?}, API {api:?}: {found:?}");
}
let (found, _) = lookup(None, Status(404), Bundle);
let Bundles::Published(bundles) = found else {
panic!("{found:?}")
};
verifier()
.verify(&bundles[0], agentd(), &Signer::release(TAG))
.expect("the API's bundle verifies like the asset's");
}
#[test]
fn successful_incomplete_bundle_responses_do_not_use_checksums() {
for status in [200, 206] {
for declared in [true, false] {
let get = |url: &str, _: Option<&str>, _: usize| {
if url == asset_url(TAG, BUNDLE_ASSET) {
let status = reqwest::StatusCode::from_u16(status).expect("status");
if declared {
Err(Body::start(url, status, Some(2), 1).err().expect("limit"))
} else {
let mut body = Body::start(url, status, None, 1).expect("start");
Err(body.push(b"xx").expect_err("limit"))
}
} else {
panic!("a successful bundle response must not try the API")
}
};
assert!(matches!(
GitHubRelease::new(None).lookup(TAG, "abc", &get),
Bundles::Absent(_)
));
}
}
}
#[test]
fn an_incomplete_successful_transfer_does_not_use_checksums() {
for asset in [true, false] {
let get = |url: &str, _: Option<&str>, _: usize| {
let is_asset = url == asset_url(TAG, BUNDLE_ASSET);
Err(HttpFailure {
status: Some(if is_asset == asset { 200 } else { 503 }),
message: "the response body ended early".into(),
})
};
assert!(matches!(
GitHubRelease::new(None).lookup(TAG, "abc", &get),
Bundles::Absent(_)
));
}
}
#[test]
fn a_successful_api_answer_without_a_bundle_does_not_use_checksums() {
for body in [
b"<html>".as_slice(),
br#"{"attestations":[{"bundle_url":"https://example.invalid/bundle"}]}"#,
] {
let get = |url: &str, _: Option<&str>, _: usize| {
if url == asset_url(TAG, BUNDLE_ASSET) {
Err(HttpFailure {
status: Some(503),
message: "unavailable".into(),
})
} else {
Ok(body.to_vec())
}
};
assert!(matches!(
GitHubRelease::new(None).lookup(TAG, "abc", &get),
Bundles::Absent(_)
));
}
}
#[test]
fn the_token_goes_only_to_the_api() {
let (_, tokens) = lookup(Some("ghp_secret"), Answer::Status(404), Answer::Bundle);
assert_eq!(tokens, [None, Some("ghp_secret".to_string())]);
let (found, _) = lookup(None, Answer::Transport, Answer::Status(403));
let Bundles::Unreachable(reason) = found else {
panic!("{found:?}")
};
assert!(reason.contains("set GITHUB_TOKEN"), "{reason}");
let (found, _) = lookup(Some("t"), Answer::Transport, Answer::Status(403));
assert!(
!format!("{found:?}").contains("set GITHUB_TOKEN"),
"{found:?}"
);
}
#[test]
fn the_api_response_yields_each_bundle() {
let bundle: serde_json::Value = serde_json::from_str(BUNDLE).expect("parses");
let body = serde_json::json!({
"attestations": [
{ "bundle": bundle, "repository_id": 1 },
{ "bundle_url": "https://example.invalid/only-a-url" },
]
});
let only_urls = r#"{"attestations": [{"bundle_url": "https://example.invalid/x"}]}"#;
assert!(parse_attestations(only_urls.as_bytes()).is_err());
let bundles = parse_attestations(body.to_string().as_bytes()).expect("parses");
assert_eq!(bundles.len(), 1);
verifier()
.verify(&bundles[0], agentd(), &Signer::release(TAG))
.expect("the API's bundle verifies like the asset's");
assert_eq!(
parse_attestations(br#"{"attestations": []}"#).expect("parses"),
Vec::<String>::new()
);
assert!(parse_attestations(br#"{"message": "Not Found"}"#).is_err());
assert!(parse_attestations(b"<html>").is_err());
}
#[test]
fn the_urls_are_the_public_release_and_the_attestations_api() {
assert_eq!(
asset_url("v0.7.0", BUNDLE_ASSET),
"https://github.com/laithalsaadoon/microvms-agentd/releases/download/v0.7.0/\
agentd.sigstore.json"
);
assert_eq!(
attestations_url("abc"),
"https://api.github.com/repos/laithalsaadoon/microvms-agentd/attestations/sha256:abc"
);
}
#[test]
fn the_token_is_read_from_the_environment_and_never_printed() {
let with =
GitHubRelease::from_env(&|name| (name == TOKEN_VARIABLE).then(|| "ghp_secret".into()));
assert_eq!(with.token.as_deref(), Some("ghp_secret"));
let printed = format!(
"{:?}",
HttpsFetch {
release: with,
directory: None,
}
);
assert!(!printed.contains("ghp_secret"), "{printed}");
assert!(printed.contains("redacted"), "{printed}");
assert_eq!(
GitHubRelease::from_env(&|_| Some(String::new())).token,
None
);
assert_eq!(GitHubRelease::from_env(&|_| None).token, None);
let fetch = HttpsFetch::from_env(&|name| (name == TOKEN_VARIABLE).then(|| "t".into()));
assert_eq!(fetch.release.token.as_deref(), Some("t"));
}
fn release_dir(tag: &str, bundle: bool) -> tempfile::TempDir {
let root = tempfile::tempdir().expect("a temp dir");
let dir = root.path().join(tag);
std::fs::create_dir(&dir).expect("the tag's directory");
std::fs::write(dir.join(ASSET), agentd()).expect("the asset");
std::fs::write(dir.join(CHECKSUMS), SUMS).expect("the checksums");
if bundle {
std::fs::write(dir.join(BUNDLE_ASSET), BUNDLE).expect("the bundle");
}
root
}
fn from_dir(root: &Path) -> HttpsFetch {
let root = root.display().to_string();
HttpsFetch::from_env(&|name| (name == RELEASE_DIR_VARIABLE).then(|| root.clone()))
}
#[test]
fn a_release_directory_is_proven_by_its_attestation() {
let root = release_dir(TAG, true);
let dest = root.path().join("installed");
let mut lines = Vec::new();
let verification = from_dir(root.path())
.fetch(TAG, &dest, &mut |line| lines.push(line.to_string()))
.expect("the directory's release verifies");
assert_eq!(verification, Verification::Attestation);
assert_eq!(std::fs::read(&dest).expect("installed"), agentd());
let dir = root.path().join(TAG).display().to_string();
assert!(
lines[0].contains(&dir) && lines[0].contains(RELEASE_DIR_VARIABLE),
"{lines:?}"
);
}
#[test]
fn a_release_directory_without_its_bundle_is_refused_despite_a_matching_checksum() {
let root = release_dir(TAG, false);
let dest = root.path().join("installed");
let refusal = from_dir(root.path())
.fetch(TAG, &dest, &mut |_| {})
.expect_err("no bundle, no proof");
assert!(refusal.contains("publishes no attestation"), "{refusal}");
assert!(refusal.contains(RELEASE_DIR_VARIABLE), "{refusal}");
assert!(!dest.exists());
}
#[test]
fn another_releases_files_under_the_requested_tag_are_refused() {
let root = release_dir("v0.8.0", true);
let dest = root.path().join("installed");
let refusal = from_dir(root.path())
.fetch("v0.8.0", &dest, &mut |_| {})
.expect_err("v0.7.0's bundle doesn't prove v0.8.0");
assert!(refusal.contains("identity mismatch"), "{refusal}");
assert!(!dest.exists());
}
#[test]
fn a_release_directory_without_the_release_is_an_error_and_an_empty_one_is_unset() {
let root = tempfile::tempdir().expect("a temp dir");
let refusal = from_dir(root.path())
.fetch(TAG, &root.path().join("installed"), &mut |_| {})
.expect_err("nothing to read");
let missing = root.path().join(TAG).join(ASSET).display().to_string();
assert!(refusal.contains(&missing), "{refusal}");
assert!(
HttpsFetch::from_env(&|_| Some(String::new()))
.directory
.is_none()
);
assert!(HttpsFetch::from_env(&|_| None).directory.is_none());
}
#[test]
fn a_release_directory_reads_its_checksums_and_refuses_a_file_over_its_limit() {
let root = release_dir(TAG, true);
let directory = DirectoryRelease {
root: root.path().to_path_buf(),
};
assert_eq!(directory.checksums(TAG).as_deref(), Ok(SUMS));
let file = root.path().join("four");
std::fs::write(&file, b"four").expect("a file");
assert_eq!(read_limited(&file, 4).as_deref(), Ok(&b"four"[..]));
let refusal = read_limited(&file, 3).expect_err("over the limit");
assert!(refusal.contains("over 3 bytes"), "{refusal}");
}
#[tokio::test]
async fn a_request_runs_from_inside_a_runtime() {
let answer = run(async { Ok::<_, HttpFailure>(7) });
assert_eq!(answer, Ok(7));
}
#[test]
fn a_status_other_than_success_is_refused_with_the_status() {
let start = |status: u16| {
let status = reqwest::StatusCode::from_u16(status).expect("a status");
Body::start("https://example.invalid/x", status, None, 8).map(|body| body.bytes)
};
assert_eq!(start(200), Ok(Vec::new()));
assert_eq!(start(204), Ok(Vec::new()));
for status in [301, 403, 404, 429, 500] {
assert_eq!(
start(status),
Err(HttpFailure {
status: Some(status),
message: format!("GET https://example.invalid/x: HTTP {status}"),
}),
);
}
}
#[test]
fn a_body_over_its_limit_is_refused() {
let over = Err(HttpFailure {
status: Some(200),
message: "GET https://example.invalid/x: the body is over 4 bytes".to_string(),
});
let start = |length: Option<u64>| {
Body::start(
"https://example.invalid/x",
reqwest::StatusCode::OK,
length,
4,
)
};
for length in [None, Some(0), Some(3), Some(4)] {
assert!(start(length).is_ok(), "declared {length:?}");
}
assert_eq!(start(Some(5)).map(|body| body.bytes), over);
let read = |chunks: &[&[u8]]| {
let mut body = start(None)?;
for chunk in chunks {
body.push(chunk)?;
}
Ok(body.bytes)
};
assert_eq!(read(&[b"ab", b"cd"]), Ok(b"abcd".to_vec()));
assert_eq!(read(&[b"abcd", b""]), Ok(b"abcd".to_vec()));
assert_eq!(read(&[b"abcde"]), over);
assert_eq!(read(&[b"ab", b"cd", b"e"]), over);
assert_eq!(read(&[b"a", b"a", b"a", b"a", b"a"]), over);
}
#[test]
fn the_limits_admit_a_real_release() {
assert!(agentd().len() <= ASSET_LIMIT, "{} bytes", agentd().len());
assert!(BUNDLE.len() <= SMALL_LIMIT, "{} bytes", BUNDLE.len());
assert!(SUMS.len() <= SMALL_LIMIT, "{} bytes", SUMS.len());
let listed = format!(r#"{{"attestations": [{{"bundle": {BUNDLE}}}]}}"#);
assert!(listed.len() <= API_LIMIT, "{} bytes", listed.len());
}
#[test]
fn an_error_is_printed_with_its_causes() {
#[derive(Debug)]
struct Layer(&'static str, Option<Box<Layer>>);
impl fmt::Display for Layer {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.0)
}
}
impl std::error::Error for Layer {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.1.as_deref().map(|cause| cause as _)
}
}
let error = Layer(
"error sending request",
Some(Box::new(Layer(
"dns error",
Some(Box::new(Layer("no such host", None))),
))),
);
assert_eq!(
chain(&error),
"error sending request: dns error: no such host"
);
assert_eq!(chain(&Layer("timed out", None)), "timed out");
}
#[test]
fn the_verifier_prints_the_root_it_holds() {
assert_eq!(format!("{:?}", verifier()), "SigstoreVerifier(public-good)");
}
}