1use std::fmt;
59use std::future::Future;
60use std::io::Read as _;
61use std::path::{Path, PathBuf};
62use std::time::Duration;
63
64use microvms_app::provision::{
65 ASSET, AttestationVerifier, Bundles, CHECKSUMS, RELEASE_REPO, ReleaseSource, Signer,
66 fetch_release,
67};
68
69use super::{Fetch, Verification};
70
71pub const BUNDLE_ASSET: &str = "agentd.sigstore.json";
73
74pub const TOKEN_VARIABLE: &str = "GITHUB_TOKEN";
76
77pub const RELEASE_DIR_VARIABLE: &str = "MICROVM_RELEASE_DIR";
80
81const ASSET_LIMIT: usize = 64 << 20;
84
85const SMALL_LIMIT: usize = 1 << 20;
87
88const API_LIMIT: usize = 8 << 20;
90
91#[derive(Clone, Default)]
94pub struct GitHubRelease {
95 token: Option<String>,
96}
97
98impl GitHubRelease {
99 pub fn new(token: Option<String>) -> Self {
102 Self {
103 token: token.filter(|token| !token.is_empty()),
104 }
105 }
106
107 pub fn from_env(env: &dyn Fn(&str) -> Option<String>) -> Self {
109 Self::new(env(TOKEN_VARIABLE))
110 }
111
112 fn lookup(&self, tag: &str, sha256: &str, get: &Get<'_>) -> Bundles {
115 let asset_reason = match get(&asset_url(tag, BUNDLE_ASSET), None, SMALL_LIMIT) {
116 Ok(body) => return Bundles::Published(vec![String::from_utf8_lossy(&body).into()]),
117 Err(failure) => failure,
118 };
119 let (api_absent, api_reason) = match self.api_bundles(sha256, get) {
120 Ok(bundles) if !bundles.is_empty() => return Bundles::Published(bundles),
121 Ok(_) => (true, "no attestation for the digest".to_string()),
122 Err(failure) => (failure.not_found(), failure.message),
123 };
124 let reason = format!(
125 "the {BUNDLE_ASSET} asset: {}; the attestations API: {api_reason}",
126 asset_reason.message
127 );
128 if asset_reason.not_found() || api_absent {
129 Bundles::Absent(reason)
130 } else {
131 Bundles::Unreachable(reason)
132 }
133 }
134
135 fn api_bundles(&self, sha256: &str, get: &Get<'_>) -> Result<Vec<String>, HttpFailure> {
139 let url = attestations_url(sha256);
140 let body = get(&url, self.token.as_deref(), API_LIMIT).map_err(|failure| {
141 if self.token.is_none() && matches!(failure.status, Some(403 | 429)) {
142 HttpFailure {
143 message: format!(
144 "{} (the unauthenticated rate limit; set {TOKEN_VARIABLE} to raise it)",
145 failure.message
146 ),
147 ..failure
148 }
149 } else {
150 failure
151 }
152 })?;
153 parse_attestations(&body).map_err(|message| HttpFailure {
154 status: None,
155 message,
156 })
157 }
158}
159
160type Get<'a> = dyn Fn(&str, Option<&str>, usize) -> Result<Vec<u8>, HttpFailure> + 'a;
162
163#[derive(Clone, Debug, PartialEq, Eq)]
165struct HttpFailure {
166 status: Option<u16>,
168 message: String,
169}
170
171impl HttpFailure {
172 fn not_found(&self) -> bool {
174 self.status == Some(404)
175 }
176}
177
178fn https_get(url: &str, token: Option<&str>, limit: usize) -> Result<Vec<u8>, HttpFailure> {
180 run(get(url, token, limit))
181}
182
183impl fmt::Debug for GitHubRelease {
185 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
186 f.debug_struct("GitHubRelease")
187 .field("token", &self.token.as_ref().map(|_| "<redacted>"))
188 .finish()
189 }
190}
191
192impl ReleaseSource for GitHubRelease {
193 fn asset(&self, tag: &str, name: &str) -> Result<Vec<u8>, String> {
194 https_get(&asset_url(tag, name), None, ASSET_LIMIT).map_err(|failure| failure.message)
195 }
196
197 fn checksums(&self, tag: &str) -> Result<String, String> {
198 let body = https_get(&asset_url(tag, CHECKSUMS), None, SMALL_LIMIT)
199 .map_err(|failure| failure.message)?;
200 String::from_utf8(body).map_err(|_| format!("{CHECKSUMS} is not UTF-8"))
201 }
202
203 fn attestations(&self, tag: &str, sha256: &str) -> Bundles {
204 self.lookup(tag, sha256, &https_get)
205 }
206}
207
208pub fn asset_url(tag: &str, asset: &str) -> String {
211 format!("https://github.com/{RELEASE_REPO}/releases/download/{tag}/{asset}")
212}
213
214pub fn attestations_url(sha256: &str) -> String {
216 format!("https://api.github.com/repos/{RELEASE_REPO}/attestations/sha256:{sha256}")
217}
218
219#[derive(Clone, Debug)]
223struct DirectoryRelease {
224 root: PathBuf,
225}
226
227impl DirectoryRelease {
228 fn read(&self, tag: &str, name: &str, limit: usize) -> Result<Vec<u8>, String> {
230 read_limited(&self.root.join(tag).join(name), limit)
231 .map_err(|reason| format!("${RELEASE_DIR_VARIABLE}: {reason}"))
232 }
233}
234
235impl ReleaseSource for DirectoryRelease {
236 fn asset(&self, tag: &str, name: &str) -> Result<Vec<u8>, String> {
237 self.read(tag, name, ASSET_LIMIT)
238 }
239
240 fn checksums(&self, tag: &str) -> Result<String, String> {
241 let body = self.read(tag, CHECKSUMS, SMALL_LIMIT)?;
242 String::from_utf8(body).map_err(|_| format!("{CHECKSUMS} is not UTF-8"))
243 }
244
245 fn attestations(&self, tag: &str, _: &str) -> Bundles {
246 match self.read(tag, BUNDLE_ASSET, SMALL_LIMIT) {
250 Ok(body) => Bundles::Published(vec![String::from_utf8_lossy(&body).into()]),
251 Err(reason) => Bundles::Absent(reason),
252 }
253 }
254}
255
256fn read_limited(path: &Path, limit: usize) -> Result<Vec<u8>, String> {
259 let file = std::fs::File::open(path)
260 .map_err(|error| format!("could not open {}: {error}", path.display()))?;
261 let mut bytes = Vec::new();
262 file.take(limit as u64 + 1)
264 .read_to_end(&mut bytes)
265 .map_err(|error| format!("could not read {}: {error}", path.display()))?;
266 if bytes.len() > limit {
267 return Err(format!("{} is over {limit} bytes", path.display()));
268 }
269 Ok(bytes)
270}
271
272fn parse_attestations(body: &[u8]) -> Result<Vec<String>, String> {
274 let response: serde_json::Value = serde_json::from_slice(body)
275 .map_err(|error| format!("the response is not JSON: {error}"))?;
276 let listed = response["attestations"]
277 .as_array()
278 .ok_or("the response has no `attestations` list")?;
279 let bundles: Vec<String> = listed
280 .iter()
281 .filter(|attestation| attestation["bundle"].is_object())
282 .map(|attestation| attestation["bundle"].to_string())
283 .collect();
284 if bundles.is_empty() && !listed.is_empty() {
285 return Err("the response lists attestations with no inline bundle".to_string());
286 }
287 Ok(bundles)
288}
289
290fn run<T: Send>(
293 request: impl Future<Output = Result<T, HttpFailure>> + Send,
294) -> Result<T, HttpFailure> {
295 let failure = |message: String| HttpFailure {
296 status: None,
297 message,
298 };
299 std::thread::scope(|scope| {
300 scope
301 .spawn(|| {
302 tokio::runtime::Builder::new_current_thread()
303 .enable_all()
304 .build()
305 .map_err(|error| {
306 failure(format!(
307 "could not start a runtime for the download: {error}"
308 ))
309 })?
310 .block_on(request)
311 })
312 .join()
313 .unwrap_or_else(|_| Err(failure("the download thread panicked".to_string())))
314 })
315}
316
317async fn get(url: &str, token: Option<&str>, limit: usize) -> Result<Vec<u8>, HttpFailure> {
323 let failure = |message: String| HttpFailure {
324 status: None,
325 message,
326 };
327 let client = reqwest::Client::builder()
328 .https_only(true)
329 .user_agent(concat!("crates/microvms-edges/", env!("CARGO_PKG_VERSION")))
331 .connect_timeout(Duration::from_secs(30))
332 .timeout(Duration::from_secs(300))
334 .build()
335 .map_err(|error| failure(format!("could not build an HTTP client: {}", chain(&error))))?;
336 let mut request = client.get(url);
337 if url.starts_with("https://api.github.com/") {
338 request = request
339 .header("Accept", "application/vnd.github+json")
340 .header("X-GitHub-Api-Version", "2022-11-28");
341 if let Some(token) = token {
342 request = request.bearer_auth(token);
343 }
344 }
345 let mut response = request
346 .send()
347 .await
348 .map_err(|error| failure(format!("GET {url}: {}", chain(&error))))?;
349 let mut body = Body::start(url, response.status(), response.content_length(), limit)?;
350 while let Some(chunk) = response
351 .chunk()
352 .await
353 .map_err(|error| failure(format!("GET {url}: {}", chain(&error))))?
354 {
355 body.push(&chunk)?;
356 }
357 Ok(body.bytes)
358}
359
360struct Body<'a> {
363 url: &'a str,
364 limit: usize,
365 bytes: Vec<u8>,
366}
367
368impl<'a> Body<'a> {
369 fn start(
373 url: &'a str,
374 status: reqwest::StatusCode,
375 length: Option<u64>,
376 limit: usize,
377 ) -> Result<Self, HttpFailure> {
378 if !status.is_success() {
379 return Err(HttpFailure {
380 status: Some(status.as_u16()),
381 message: format!("GET {url}: HTTP {}", status.as_u16()),
382 });
383 }
384 let body = Self {
385 url,
386 limit,
387 bytes: Vec::new(),
388 };
389 if length.is_some_and(|length| length > limit as u64) {
390 return Err(body.over_limit());
391 }
392 Ok(body)
393 }
394
395 fn push(&mut self, chunk: &[u8]) -> Result<(), HttpFailure> {
398 if self.bytes.len() + chunk.len() > self.limit {
399 return Err(self.over_limit());
400 }
401 self.bytes.extend_from_slice(chunk);
402 Ok(())
403 }
404
405 fn over_limit(&self) -> HttpFailure {
406 HttpFailure {
407 status: None,
408 message: format!("GET {}: the body is over {} bytes", self.url, self.limit),
409 }
410 }
411}
412
413fn chain(error: &dyn std::error::Error) -> String {
416 let mut text = error.to_string();
417 let mut source = error.source();
418 while let Some(cause) = source {
419 text.push_str(": ");
420 text.push_str(&cause.to_string());
421 source = cause.source();
422 }
423 text
424}
425
426pub struct SigstoreVerifier {
445 verifier: sigstore_verify::Verifier,
446}
447
448impl SigstoreVerifier {
449 pub fn public_good() -> Result<Self, String> {
452 let root = sigstore_trust_root::TrustedRoot::from_json(
453 sigstore_trust_root::SIGSTORE_PRODUCTION_TRUSTED_ROOT,
454 )
455 .map_err(|error| format!("the embedded Sigstore trusted root: {error}"))?;
456 let verifier = sigstore_verify::Verifier::new(&root)
457 .map_err(|error| format!("the embedded Sigstore trusted root: {error}"))?;
458 Ok(Self { verifier })
459 }
460}
461
462impl fmt::Debug for SigstoreVerifier {
463 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
464 f.write_str("SigstoreVerifier(public-good)")
465 }
466}
467
468impl AttestationVerifier for SigstoreVerifier {
469 fn verify(&self, bundle: &str, artifact: &[u8], signer: &Signer) -> Result<(), String> {
470 let bundle = sigstore_types::Bundle::from_json(bundle)
471 .map_err(|error| format!("the bundle doesn't parse: {error}"))?;
472 let policy = sigstore_verify::VerificationPolicy::new(&signer.identity, &signer.issuer);
475 self.verifier
476 .verify(artifact, &bundle, &policy)
477 .map_err(|error| error.to_string())?;
478 let sigstore_types::SignatureContent::DsseEnvelope(envelope) = &bundle.content else {
481 return Err("the bundle signs a message, not an in-toto attestation".to_string());
482 };
483 let statement: sigstore_types::Statement =
484 serde_json::from_slice(envelope.payload.as_bytes())
485 .map_err(|error| format!("the attested statement doesn't parse: {error}"))?;
486 if statement.predicate_type != signer.predicate_type {
487 return Err(format!(
488 "predicate type mismatch: expected {}, got {}",
489 signer.predicate_type, statement.predicate_type
490 ));
491 }
492 Ok(())
493 }
494}
495
496#[derive(Clone, Debug, Default)]
501pub struct PolicyFetch<S, V> {
502 pub source: S,
503 pub verifier: V,
504}
505
506impl<S: ReleaseSource, V: AttestationVerifier> Fetch for PolicyFetch<S, V> {
507 fn fetch(
508 &self,
509 tag: &str,
510 dest: &Path,
511 progress: &mut dyn FnMut(&str),
512 ) -> Result<Verification, String> {
513 let fetched = fetch_release(&self.source, &self.verifier, tag, progress)?;
514 std::fs::write(dest, &fetched.bytes)
515 .map_err(|error| format!("could not write {ASSET} to {}: {error}", dest.display()))?;
516 Ok(fetched.verification)
517 }
518}
519
520#[derive(Clone, Debug, Default)]
523pub struct HttpsFetch {
524 release: GitHubRelease,
525 directory: Option<DirectoryRelease>,
526}
527
528impl HttpsFetch {
529 pub fn from_env(env: &dyn Fn(&str) -> Option<String>) -> Self {
533 Self {
534 release: GitHubRelease::from_env(env),
535 directory: env(RELEASE_DIR_VARIABLE)
536 .filter(|root| !root.is_empty())
537 .map(|root| DirectoryRelease {
538 root: PathBuf::from(root),
539 }),
540 }
541 }
542}
543
544impl Fetch for HttpsFetch {
545 fn fetch(
546 &self,
547 tag: &str,
548 dest: &Path,
549 progress: &mut dyn FnMut(&str),
550 ) -> Result<Verification, String> {
551 let verifier = SigstoreVerifier::public_good()?;
552 match &self.directory {
553 Some(directory) => {
554 progress(&format!(
555 "reading {ASSET} {tag} from {} (${RELEASE_DIR_VARIABLE})",
556 directory.root.join(tag).display()
557 ));
558 PolicyFetch {
559 source: directory.clone(),
560 verifier,
561 }
562 .fetch(tag, dest, progress)
563 }
564 None => PolicyFetch {
565 source: self.release.clone(),
566 verifier,
567 }
568 .fetch(tag, dest, progress),
569 }
570 }
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576 use microvms_app::provision::{ReleaseSource, sha256_hex};
577
578 const TAG: &str = "v0.7.0";
582 const BUNDLE: &str = include_str!("../../tests/fixtures/release-v0.7.0/agentd.sigstore.json");
583 const SUMS: &str = include_str!("../../tests/fixtures/release-v0.7.0/SHA256SUMS");
584
585 fn agentd() -> &'static [u8] {
589 static AGENTD: std::sync::LazyLock<Vec<u8>> = std::sync::LazyLock::new(|| {
590 use std::io::Read as _;
591 let packed = include_bytes!("../../tests/fixtures/release-v0.7.0/agentd.gz");
592 let mut bytes = Vec::new();
593 flate2::read::GzDecoder::new(&packed[..])
594 .read_to_end(&mut bytes)
595 .expect("the fixture is gzip");
596 bytes
597 });
598 &AGENTD
599 }
600
601 fn verifier() -> SigstoreVerifier {
602 SigstoreVerifier::public_good().expect("the embedded trusted root parses")
603 }
604
605 fn flipped() -> Vec<u8> {
607 let mut bytes = agentd().to_vec();
608 bytes[1_000_000] ^= 0x01;
609 bytes
610 }
611
612 fn tampered(edit: impl FnOnce(&mut serde_json::Value)) -> String {
614 let mut bundle: serde_json::Value =
615 serde_json::from_str(BUNDLE).expect("the fixture parses");
616 edit(&mut bundle["verificationMaterial"]["tlogEntries"][0]);
617 bundle.to_string()
618 }
619
620 fn flip_base64(value: &mut serde_json::Value) {
622 use base64::Engine as _;
623 let engine = base64::engine::general_purpose::STANDARD;
624 let mut bytes = engine
625 .decode(value.as_str().expect("a base64 string"))
626 .expect("valid base64");
627 let middle = bytes.len() / 2;
628 bytes[middle] ^= 0x01;
629 *value = serde_json::Value::String(engine.encode(bytes));
630 }
631
632 #[test]
635 fn the_committed_release_verifies_against_its_bundle() {
636 verifier()
637 .verify(BUNDLE, agentd(), &Signer::release(TAG))
638 .expect("the v0.7.0 release asset is attested by its release workflow");
639 assert_eq!(
640 sha256_hex(agentd()),
641 "b281856000f58e6306bcaca73362e4d77260324a51f9b7ba3de563cf102dbe76",
642 "the fixture is the published asset"
643 );
644 }
645
646 #[test]
648 fn one_flipped_byte_in_the_asset_is_refused() {
649 let refusal = verifier()
650 .verify(BUNDLE, &flipped(), &Signer::release(TAG))
651 .expect_err("changed bytes must not verify");
652 assert!(refusal.contains("does not match any subject"), "{refusal}");
653 }
654
655 #[test]
659 fn another_signer_identity_is_refused() {
660 let release = Signer::release(TAG);
661 let others = [
662 Signer::release("v0.8.0"),
663 Signer {
664 identity: release.identity.replace("release.yml", "ci.yml"),
665 ..release.clone()
666 },
667 Signer {
668 identity: release.identity.replace("laithalsaadoon", "theagenticguy"),
669 ..release.clone()
670 },
671 ];
672 for signer in others {
673 let refusal = verifier()
674 .verify(BUNDLE, agentd(), &signer)
675 .expect_err(&signer.identity);
676 assert!(refusal.contains("identity mismatch"), "{refusal}");
677 }
678 }
679
680 #[test]
682 fn another_issuer_is_refused() {
683 let signer = Signer {
684 issuer: "https://accounts.google.com".into(),
685 ..Signer::release(TAG)
686 };
687 let refusal = verifier()
688 .verify(BUNDLE, agentd(), &signer)
689 .expect_err("another issuer must refuse");
690 assert!(refusal.contains("issuer mismatch"), "{refusal}");
691 }
692
693 #[test]
695 fn another_predicate_type_is_refused() {
696 let signer = Signer {
697 predicate_type: "https://spdx.dev/Document".into(),
698 ..Signer::release(TAG)
699 };
700 let refusal = verifier()
701 .verify(BUNDLE, agentd(), &signer)
702 .expect_err("another predicate type must refuse");
703 assert!(refusal.contains("predicate type mismatch"), "{refusal}");
704 }
705
706 #[test]
712 fn tampered_transparency_evidence_is_refused() {
713 let cases = [
714 (
715 "proof hash",
716 tampered(|entry| flip_base64(&mut entry["inclusionProof"]["hashes"][0])),
717 ),
718 (
719 "checkpoint signature",
720 tampered(|entry| {
721 let envelope = entry["inclusionProof"]["checkpoint"]["envelope"]
722 .as_str()
723 .expect("a checkpoint")
724 .to_string();
725 let (head, signature) = envelope.trim_end().rsplit_once(' ').expect("signed");
726 let mut signature = serde_json::Value::String(signature.to_string());
727 flip_base64(&mut signature);
728 entry["inclusionProof"]["checkpoint"]["envelope"] = serde_json::Value::String(
729 format!("{head} {}\n", signature.as_str().expect("a string")),
730 );
731 }),
732 ),
733 (
734 "proof log index",
735 tampered(|entry| {
736 let index: u64 = entry["inclusionProof"]["logIndex"]
737 .as_str()
738 .expect("a string")
739 .parse()
740 .expect("a number");
741 entry["inclusionProof"]["logIndex"] =
742 serde_json::Value::String((index + 1).to_string());
743 }),
744 ),
745 (
746 "proof root hash",
747 tampered(|entry| flip_base64(&mut entry["inclusionProof"]["rootHash"])),
748 ),
749 (
750 "SET",
751 tampered(|entry| {
752 flip_base64(&mut entry["inclusionPromise"]["signedEntryTimestamp"])
753 }),
754 ),
755 (
756 "integrated time",
757 tampered(|entry| {
758 let time: u64 = entry["integratedTime"]
759 .as_str()
760 .expect("a string")
761 .parse()
762 .expect("a number");
763 entry["integratedTime"] = serde_json::Value::String((time + 60).to_string());
764 }),
765 ),
766 ];
767 for (what, bundle) in cases {
768 verifier()
769 .verify(&bundle, agentd(), &Signer::release(TAG))
770 .expect_err(what);
771 }
772 }
773
774 #[test]
776 fn a_bundle_that_does_not_parse_is_refused() {
777 let refusal = verifier()
778 .verify("{\"not\": \"a bundle\"}", agentd(), &Signer::release(TAG))
779 .expect_err("must refuse");
780 assert!(refusal.contains("doesn't parse"), "{refusal}");
781 }
782
783 struct Fixture {
785 asset: Vec<u8>,
786 bundle: Option<Bundle>,
787 }
788
789 #[derive(Clone, Copy, Debug)]
791 enum Bundle {
792 Unreachable,
793 Absent,
794 }
795
796 impl ReleaseSource for Fixture {
797 fn asset(&self, tag: &str, name: &str) -> Result<Vec<u8>, String> {
798 assert_eq!((tag, name), (TAG, ASSET));
799 Ok(self.asset.clone())
800 }
801
802 fn checksums(&self, _: &str) -> Result<String, String> {
803 Ok(SUMS.to_string())
804 }
805
806 fn attestations(&self, _: &str, _: &str) -> Bundles {
807 match self.bundle {
808 None => Bundles::Published(vec![BUNDLE.to_string()]),
809 Some(Bundle::Unreachable) => Bundles::Unreachable("HTTP 403".to_string()),
810 Some(Bundle::Absent) => Bundles::Absent("HTTP 404".to_string()),
811 }
812 }
813 }
814
815 #[test]
819 fn the_policy_over_the_committed_release() {
820 let fetch = |asset: Vec<u8>, bundle: Option<Bundle>| {
821 fetch_release(&Fixture { asset, bundle }, &verifier(), TAG, &mut |_| {})
822 };
823 let attested = fetch(agentd().to_vec(), None).expect("attested");
824 assert_eq!(attested.verification, Verification::Attestation);
825 assert_eq!(attested.bytes, agentd());
826 let checked = fetch(agentd().to_vec(), Some(Bundle::Unreachable)).expect("checked");
827 assert_eq!(checked.verification, Verification::Checksum);
828 let refusal = fetch(agentd().to_vec(), Some(Bundle::Absent))
829 .expect_err("a release that says it has no bundle is refused");
830 assert!(refusal.contains("publishes no attestation"), "{refusal}");
831
832 let refusal = fetch(flipped(), None).expect_err("flipped bytes, bundle there");
833 assert!(refusal.contains("discarded"), "{refusal}");
834 let refusal = fetch(flipped(), Some(Bundle::Unreachable)).expect_err("flipped, no bundle");
835 assert!(refusal.contains("SHA256 mismatch"), "{refusal}");
836 }
837
838 #[derive(Clone, Copy, Debug)]
840 enum Answer {
841 Bundle,
843 EmptyList,
845 Junk,
847 Status(u16),
848 Transport,
850 }
851
852 fn lookup(token: Option<&str>, asset: Answer, api: Answer) -> (Bundles, Vec<Option<String>>) {
855 let tokens = std::cell::RefCell::new(Vec::new());
856 let get = |url: &str, token: Option<&str>, _: usize| {
857 tokens.borrow_mut().push(token.map(str::to_string));
858 let (answer, from_api) = if url == asset_url(TAG, BUNDLE_ASSET) {
859 (asset, false)
860 } else {
861 assert_eq!(url, attestations_url("abc"), "only the two lookups run");
862 (api, true)
863 };
864 let bundle: serde_json::Value = serde_json::from_str(BUNDLE).expect("parses");
865 match answer {
866 Answer::Bundle if from_api => Ok(serde_json::json!({
867 "attestations": [{ "bundle": bundle }]
868 })
869 .to_string()
870 .into_bytes()),
871 Answer::Bundle => Ok(BUNDLE.as_bytes().to_vec()),
872 Answer::EmptyList => Ok(br#"{"attestations": []}"#.to_vec()),
873 Answer::Junk => Ok(b"\xff<html>".to_vec()),
874 Answer::Status(status) => Err(HttpFailure {
875 status: Some(status),
876 message: format!("GET {url}: HTTP {status}"),
877 }),
878 Answer::Transport => Err(HttpFailure {
879 status: None,
880 message: format!("GET {url}: error sending request: dns error"),
881 }),
882 }
883 };
884 let found = GitHubRelease::new(token.map(str::to_string)).lookup(TAG, "abc", &get);
885 (found, tokens.into_inner())
886 }
887
888 #[test]
893 fn a_missing_bundle_is_absent_and_a_failed_request_is_unreachable() {
894 use Answer::*;
895 let published = |found: &Bundles| matches!(found, Bundles::Published(b) if b.len() == 1);
896 let absent = |found: &Bundles| matches!(found, Bundles::Absent(_));
897 let unreachable = |found: &Bundles| matches!(found, Bundles::Unreachable(_));
898 type Row<'a> = (Answer, Answer, &'a dyn Fn(&Bundles) -> bool);
899 let rows: [Row<'_>; 14] = [
900 (Bundle, Status(500), &published),
901 (Junk, Status(404), &published),
904 (Junk, Bundle, &published),
905 (Status(404), Bundle, &published),
906 (Status(503), Bundle, &published),
907 (Status(404), Status(404), &absent),
908 (Status(404), EmptyList, &absent),
909 (Status(404), Status(403), &absent),
910 (Status(404), Transport, &absent),
911 (Transport, Status(404), &absent),
912 (Status(403), EmptyList, &absent),
913 (Status(403), Status(429), &unreachable),
914 (Transport, Status(502), &unreachable),
915 (Status(500), Junk, &unreachable),
916 ];
917 for (asset, api, expected) in rows {
918 let (found, _) = lookup(None, asset, api);
919 assert!(expected(&found), "asset {asset:?}, API {api:?}: {found:?}");
920 }
921 let (found, _) = lookup(None, Status(404), Bundle);
922 let Bundles::Published(bundles) = found else {
923 panic!("{found:?}")
924 };
925 verifier()
926 .verify(&bundles[0], agentd(), &Signer::release(TAG))
927 .expect("the API's bundle verifies like the asset's");
928 }
929
930 #[test]
933 fn the_token_goes_only_to_the_api() {
934 let (_, tokens) = lookup(Some("ghp_secret"), Answer::Status(404), Answer::Bundle);
935 assert_eq!(tokens, [None, Some("ghp_secret".to_string())]);
936 let (found, _) = lookup(None, Answer::Transport, Answer::Status(403));
937 let Bundles::Unreachable(reason) = found else {
938 panic!("{found:?}")
939 };
940 assert!(reason.contains("set GITHUB_TOKEN"), "{reason}");
941 let (found, _) = lookup(Some("t"), Answer::Transport, Answer::Status(403));
942 assert!(
943 !format!("{found:?}").contains("set GITHUB_TOKEN"),
944 "{found:?}"
945 );
946 }
947
948 #[test]
950 fn the_api_response_yields_each_bundle() {
951 let bundle: serde_json::Value = serde_json::from_str(BUNDLE).expect("parses");
952 let body = serde_json::json!({
953 "attestations": [
954 { "bundle": bundle, "repository_id": 1 },
955 { "bundle_url": "https://example.invalid/only-a-url" },
956 ]
957 });
958 let only_urls = r#"{"attestations": [{"bundle_url": "https://example.invalid/x"}]}"#;
959 assert!(parse_attestations(only_urls.as_bytes()).is_err());
960 let bundles = parse_attestations(body.to_string().as_bytes()).expect("parses");
961 assert_eq!(bundles.len(), 1);
962 verifier()
963 .verify(&bundles[0], agentd(), &Signer::release(TAG))
964 .expect("the API's bundle verifies like the asset's");
965
966 assert_eq!(
967 parse_attestations(br#"{"attestations": []}"#).expect("parses"),
968 Vec::<String>::new()
969 );
970 assert!(parse_attestations(br#"{"message": "Not Found"}"#).is_err());
971 assert!(parse_attestations(b"<html>").is_err());
972 }
973
974 #[test]
976 fn the_urls_are_the_public_release_and_the_attestations_api() {
977 assert_eq!(
978 asset_url("v0.7.0", BUNDLE_ASSET),
979 "https://github.com/laithalsaadoon/microvms-agentd/releases/download/v0.7.0/\
980 agentd.sigstore.json"
981 );
982 assert_eq!(
983 attestations_url("abc"),
984 "https://api.github.com/repos/laithalsaadoon/microvms-agentd/attestations/sha256:abc"
985 );
986 }
987
988 #[test]
991 fn the_token_is_read_from_the_environment_and_never_printed() {
992 let with =
993 GitHubRelease::from_env(&|name| (name == TOKEN_VARIABLE).then(|| "ghp_secret".into()));
994 assert_eq!(with.token.as_deref(), Some("ghp_secret"));
995 let printed = format!(
996 "{:?}",
997 HttpsFetch {
998 release: with,
999 directory: None,
1000 }
1001 );
1002 assert!(!printed.contains("ghp_secret"), "{printed}");
1003 assert!(printed.contains("redacted"), "{printed}");
1004 assert_eq!(
1005 GitHubRelease::from_env(&|_| Some(String::new())).token,
1006 None
1007 );
1008 assert_eq!(GitHubRelease::from_env(&|_| None).token, None);
1009 let fetch = HttpsFetch::from_env(&|name| (name == TOKEN_VARIABLE).then(|| "t".into()));
1010 assert_eq!(fetch.release.token.as_deref(), Some("t"));
1011 }
1012
1013 fn release_dir(tag: &str, bundle: bool) -> tempfile::TempDir {
1016 let root = tempfile::tempdir().expect("a temp dir");
1017 let dir = root.path().join(tag);
1018 std::fs::create_dir(&dir).expect("the tag's directory");
1019 std::fs::write(dir.join(ASSET), agentd()).expect("the asset");
1020 std::fs::write(dir.join(CHECKSUMS), SUMS).expect("the checksums");
1021 if bundle {
1022 std::fs::write(dir.join(BUNDLE_ASSET), BUNDLE).expect("the bundle");
1023 }
1024 root
1025 }
1026
1027 fn from_dir(root: &Path) -> HttpsFetch {
1029 let root = root.display().to_string();
1030 HttpsFetch::from_env(&|name| (name == RELEASE_DIR_VARIABLE).then(|| root.clone()))
1031 }
1032
1033 #[test]
1037 fn a_release_directory_is_proven_by_its_attestation() {
1038 let root = release_dir(TAG, true);
1039 let dest = root.path().join("installed");
1040 let mut lines = Vec::new();
1041 let verification = from_dir(root.path())
1042 .fetch(TAG, &dest, &mut |line| lines.push(line.to_string()))
1043 .expect("the directory's release verifies");
1044 assert_eq!(verification, Verification::Attestation);
1045 assert_eq!(std::fs::read(&dest).expect("installed"), agentd());
1046 let dir = root.path().join(TAG).display().to_string();
1047 assert!(
1048 lines[0].contains(&dir) && lines[0].contains(RELEASE_DIR_VARIABLE),
1049 "{lines:?}"
1050 );
1051 }
1052
1053 #[test]
1057 fn a_release_directory_without_its_bundle_is_refused_despite_a_matching_checksum() {
1058 let root = release_dir(TAG, false);
1059 let dest = root.path().join("installed");
1060 let refusal = from_dir(root.path())
1061 .fetch(TAG, &dest, &mut |_| {})
1062 .expect_err("no bundle, no proof");
1063 assert!(refusal.contains("publishes no attestation"), "{refusal}");
1064 assert!(refusal.contains(RELEASE_DIR_VARIABLE), "{refusal}");
1065 assert!(!dest.exists());
1066 }
1067
1068 #[test]
1072 fn another_releases_files_under_the_requested_tag_are_refused() {
1073 let root = release_dir("v0.8.0", true);
1074 let dest = root.path().join("installed");
1075 let refusal = from_dir(root.path())
1076 .fetch("v0.8.0", &dest, &mut |_| {})
1077 .expect_err("v0.7.0's bundle doesn't prove v0.8.0");
1078 assert!(refusal.contains("identity mismatch"), "{refusal}");
1079 assert!(!dest.exists());
1080 }
1081
1082 #[test]
1085 fn a_release_directory_without_the_release_is_an_error_and_an_empty_one_is_unset() {
1086 let root = tempfile::tempdir().expect("a temp dir");
1087 let refusal = from_dir(root.path())
1088 .fetch(TAG, &root.path().join("installed"), &mut |_| {})
1089 .expect_err("nothing to read");
1090 let missing = root.path().join(TAG).join(ASSET).display().to_string();
1091 assert!(refusal.contains(&missing), "{refusal}");
1092 assert!(
1093 HttpsFetch::from_env(&|_| Some(String::new()))
1094 .directory
1095 .is_none()
1096 );
1097 assert!(HttpsFetch::from_env(&|_| None).directory.is_none());
1098 }
1099
1100 #[test]
1103 fn a_release_directory_reads_its_checksums_and_refuses_a_file_over_its_limit() {
1104 let root = release_dir(TAG, true);
1105 let directory = DirectoryRelease {
1106 root: root.path().to_path_buf(),
1107 };
1108 assert_eq!(directory.checksums(TAG).as_deref(), Ok(SUMS));
1109 let file = root.path().join("four");
1110 std::fs::write(&file, b"four").expect("a file");
1111 assert_eq!(read_limited(&file, 4).as_deref(), Ok(&b"four"[..]));
1112 let refusal = read_limited(&file, 3).expect_err("over the limit");
1113 assert!(refusal.contains("over 3 bytes"), "{refusal}");
1114 }
1115
1116 #[tokio::test]
1119 async fn a_request_runs_from_inside_a_runtime() {
1120 let answer = run(async { Ok::<_, HttpFailure>(7) });
1121 assert_eq!(answer, Ok(7));
1122 }
1123
1124 #[test]
1127 fn a_status_other_than_success_is_refused_with_the_status() {
1128 let start = |status: u16| {
1129 let status = reqwest::StatusCode::from_u16(status).expect("a status");
1130 Body::start("https://example.invalid/x", status, None, 8).map(|body| body.bytes)
1131 };
1132 assert_eq!(start(200), Ok(Vec::new()));
1133 assert_eq!(start(204), Ok(Vec::new()));
1134 for status in [301, 403, 404, 429, 500] {
1135 assert_eq!(
1136 start(status),
1137 Err(HttpFailure {
1138 status: Some(status),
1139 message: format!("GET https://example.invalid/x: HTTP {status}"),
1140 }),
1141 );
1142 }
1143 }
1144
1145 #[test]
1148 fn a_body_over_its_limit_is_refused() {
1149 let over = Err(HttpFailure {
1150 status: None,
1151 message: "GET https://example.invalid/x: the body is over 4 bytes".to_string(),
1152 });
1153 let start = |length: Option<u64>| {
1154 Body::start(
1155 "https://example.invalid/x",
1156 reqwest::StatusCode::OK,
1157 length,
1158 4,
1159 )
1160 };
1161 for length in [None, Some(0), Some(3), Some(4)] {
1162 assert!(start(length).is_ok(), "declared {length:?}");
1163 }
1164 assert_eq!(start(Some(5)).map(|body| body.bytes), over);
1165
1166 let read = |chunks: &[&[u8]]| {
1167 let mut body = start(None)?;
1168 for chunk in chunks {
1169 body.push(chunk)?;
1170 }
1171 Ok(body.bytes)
1172 };
1173 assert_eq!(read(&[b"ab", b"cd"]), Ok(b"abcd".to_vec()));
1174 assert_eq!(read(&[b"abcd", b""]), Ok(b"abcd".to_vec()));
1175 assert_eq!(read(&[b"abcde"]), over);
1176 assert_eq!(read(&[b"ab", b"cd", b"e"]), over);
1177 assert_eq!(read(&[b"a", b"a", b"a", b"a", b"a"]), over);
1178 }
1179
1180 #[test]
1183 fn the_limits_admit_a_real_release() {
1184 assert!(agentd().len() <= ASSET_LIMIT, "{} bytes", agentd().len());
1185 assert!(BUNDLE.len() <= SMALL_LIMIT, "{} bytes", BUNDLE.len());
1186 assert!(SUMS.len() <= SMALL_LIMIT, "{} bytes", SUMS.len());
1187 let listed = format!(r#"{{"attestations": [{{"bundle": {BUNDLE}}}]}}"#);
1188 assert!(listed.len() <= API_LIMIT, "{} bytes", listed.len());
1189 }
1190
1191 #[test]
1194 fn an_error_is_printed_with_its_causes() {
1195 #[derive(Debug)]
1196 struct Layer(&'static str, Option<Box<Layer>>);
1197 impl fmt::Display for Layer {
1198 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1199 f.write_str(self.0)
1200 }
1201 }
1202 impl std::error::Error for Layer {
1203 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1204 self.1.as_deref().map(|cause| cause as _)
1205 }
1206 }
1207 let error = Layer(
1208 "error sending request",
1209 Some(Box::new(Layer(
1210 "dns error",
1211 Some(Box::new(Layer("no such host", None))),
1212 ))),
1213 );
1214 assert_eq!(
1215 chain(&error),
1216 "error sending request: dns error: no such host"
1217 );
1218 assert_eq!(chain(&Layer("timed out", None)), "timed out");
1219 }
1220
1221 #[test]
1224 fn the_verifier_prints_the_root_it_holds() {
1225 assert_eq!(format!("{:?}", verifier()), "SigstoreVerifier(public-good)");
1226 }
1227}