1use std::fmt;
60use std::future::Future;
61use std::io::Read as _;
62use std::path::{Path, PathBuf};
63use std::time::Duration;
64
65use microvms_app::provision::{
66 ASSET, AttestationVerifier, Bundles, CHECKSUMS, RELEASE_REPO, ReleaseSource, Signer,
67 fetch_release,
68};
69
70use super::{Fetch, Verification};
71
72pub const BUNDLE_ASSET: &str = "agentd.sigstore.json";
74
75pub const TOKEN_VARIABLE: &str = "GITHUB_TOKEN";
77
78pub const RELEASE_DIR_VARIABLE: &str = "MICROVM_RELEASE_DIR";
81
82const ASSET_LIMIT: usize = 64 << 20;
85
86const SMALL_LIMIT: usize = 1 << 20;
88
89const API_LIMIT: usize = 8 << 20;
91
92#[derive(Clone, Default)]
95pub struct GitHubRelease {
96 token: Option<String>,
97}
98
99impl GitHubRelease {
100 pub fn new(token: Option<String>) -> Self {
103 Self {
104 token: token.filter(|token| !token.is_empty()),
105 }
106 }
107
108 pub fn from_env(env: &dyn Fn(&str) -> Option<String>) -> Self {
110 Self::new(env(TOKEN_VARIABLE))
111 }
112
113 fn lookup(&self, tag: &str, sha256: &str, get: &Get<'_>) -> Bundles {
116 let asset_reason = match get(&asset_url(tag, BUNDLE_ASSET), None, SMALL_LIMIT) {
117 Ok(body) => return Bundles::Published(vec![String::from_utf8_lossy(&body).into()]),
118 Err(failure) if failure.success() => return Bundles::Absent(failure.message),
119 Err(failure) => failure,
120 };
121 let (api_absent, api_reason) = match self.api_bundles(sha256, get) {
122 Ok(bundles) if !bundles.is_empty() => return Bundles::Published(bundles),
123 Ok(_) => (true, "no attestation for the digest".to_string()),
124 Err(failure) => (failure.not_found() || failure.success(), failure.message),
125 };
126 let reason = format!(
127 "the {BUNDLE_ASSET} asset: {}; the attestations API: {api_reason}",
128 asset_reason.message
129 );
130 if asset_reason.not_found() || api_absent {
131 Bundles::Absent(reason)
132 } else {
133 Bundles::Unreachable(reason)
134 }
135 }
136
137 fn api_bundles(&self, sha256: &str, get: &Get<'_>) -> Result<Vec<String>, HttpFailure> {
141 let url = attestations_url(sha256);
142 let body = get(&url, self.token.as_deref(), API_LIMIT).map_err(|failure| {
143 if self.token.is_none() && matches!(failure.status, Some(403 | 429)) {
144 HttpFailure {
145 message: format!(
146 "{} (the unauthenticated rate limit; set {TOKEN_VARIABLE} to raise it)",
147 failure.message
148 ),
149 ..failure
150 }
151 } else {
152 failure
153 }
154 })?;
155 parse_attestations(&body).map_err(|message| HttpFailure {
156 status: Some(200),
157 message,
158 })
159 }
160}
161
162type Get<'a> = dyn Fn(&str, Option<&str>, usize) -> Result<Vec<u8>, HttpFailure> + 'a;
164
165#[derive(Clone, Debug, PartialEq, Eq)]
167struct HttpFailure {
168 status: Option<u16>,
170 message: String,
171}
172
173impl HttpFailure {
174 fn success(&self) -> bool {
176 self.status
177 .is_some_and(|status| (200..300).contains(&status))
178 }
179
180 fn not_found(&self) -> bool {
181 self.status == Some(404)
182 }
183}
184
185fn https_get(url: &str, token: Option<&str>, limit: usize) -> Result<Vec<u8>, HttpFailure> {
187 run(get(url, token, limit))
188}
189
190impl fmt::Debug for GitHubRelease {
192 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
193 f.debug_struct("GitHubRelease")
194 .field("token", &self.token.as_ref().map(|_| "<redacted>"))
195 .finish()
196 }
197}
198
199impl ReleaseSource for GitHubRelease {
200 fn asset(&self, tag: &str, name: &str) -> Result<Vec<u8>, String> {
201 https_get(&asset_url(tag, name), None, ASSET_LIMIT).map_err(|failure| failure.message)
202 }
203
204 fn checksums(&self, tag: &str) -> Result<String, String> {
205 let body = https_get(&asset_url(tag, CHECKSUMS), None, SMALL_LIMIT)
206 .map_err(|failure| failure.message)?;
207 String::from_utf8(body).map_err(|_| format!("{CHECKSUMS} is not UTF-8"))
208 }
209
210 fn attestations(&self, tag: &str, sha256: &str) -> Bundles {
211 self.lookup(tag, sha256, &https_get)
212 }
213}
214
215pub fn asset_url(tag: &str, asset: &str) -> String {
218 format!("https://github.com/{RELEASE_REPO}/releases/download/{tag}/{asset}")
219}
220
221pub fn attestations_url(sha256: &str) -> String {
223 format!("https://api.github.com/repos/{RELEASE_REPO}/attestations/sha256:{sha256}")
224}
225
226#[derive(Clone, Debug)]
230struct DirectoryRelease {
231 root: PathBuf,
232}
233
234impl DirectoryRelease {
235 fn read(&self, tag: &str, name: &str, limit: usize) -> Result<Vec<u8>, String> {
237 read_limited(&self.root.join(tag).join(name), limit)
238 .map_err(|reason| format!("${RELEASE_DIR_VARIABLE}: {reason}"))
239 }
240}
241
242impl ReleaseSource for DirectoryRelease {
243 fn asset(&self, tag: &str, name: &str) -> Result<Vec<u8>, String> {
244 self.read(tag, name, ASSET_LIMIT)
245 }
246
247 fn checksums(&self, tag: &str) -> Result<String, String> {
248 let body = self.read(tag, CHECKSUMS, SMALL_LIMIT)?;
249 String::from_utf8(body).map_err(|_| format!("{CHECKSUMS} is not UTF-8"))
250 }
251
252 fn attestations(&self, tag: &str, _: &str) -> Bundles {
253 match self.read(tag, BUNDLE_ASSET, SMALL_LIMIT) {
257 Ok(body) => Bundles::Published(vec![String::from_utf8_lossy(&body).into()]),
258 Err(reason) => Bundles::Absent(reason),
259 }
260 }
261}
262
263fn read_limited(path: &Path, limit: usize) -> Result<Vec<u8>, String> {
266 let file = std::fs::File::open(path)
267 .map_err(|error| format!("could not open {}: {error}", path.display()))?;
268 let mut bytes = Vec::new();
269 file.take(limit as u64 + 1)
271 .read_to_end(&mut bytes)
272 .map_err(|error| format!("could not read {}: {error}", path.display()))?;
273 if bytes.len() > limit {
274 return Err(format!("{} is over {limit} bytes", path.display()));
275 }
276 Ok(bytes)
277}
278
279fn parse_attestations(body: &[u8]) -> Result<Vec<String>, String> {
281 let response: serde_json::Value = serde_json::from_slice(body)
282 .map_err(|error| format!("the response is not JSON: {error}"))?;
283 let listed = response["attestations"]
284 .as_array()
285 .ok_or("the response has no `attestations` list")?;
286 let bundles: Vec<String> = listed
287 .iter()
288 .filter(|attestation| attestation["bundle"].is_object())
289 .map(|attestation| attestation["bundle"].to_string())
290 .collect();
291 if bundles.is_empty() && !listed.is_empty() {
292 return Err("the response lists attestations with no inline bundle".to_string());
293 }
294 Ok(bundles)
295}
296
297fn run<T: Send>(
300 request: impl Future<Output = Result<T, HttpFailure>> + Send,
301) -> Result<T, HttpFailure> {
302 let failure = |message: String| HttpFailure {
303 status: None,
304 message,
305 };
306 std::thread::scope(|scope| {
307 scope
308 .spawn(|| {
309 tokio::runtime::Builder::new_current_thread()
310 .enable_all()
311 .build()
312 .map_err(|error| {
313 failure(format!(
314 "could not start a runtime for the download: {error}"
315 ))
316 })?
317 .block_on(request)
318 })
319 .join()
320 .unwrap_or_else(|_| Err(failure("the download thread panicked".to_string())))
321 })
322}
323
324async fn get(url: &str, token: Option<&str>, limit: usize) -> Result<Vec<u8>, HttpFailure> {
330 let failure = |message: String| HttpFailure {
331 status: None,
332 message,
333 };
334 let client = reqwest::Client::builder()
335 .https_only(true)
336 .user_agent(concat!("crates/microvms-edges/", env!("CARGO_PKG_VERSION")))
338 .connect_timeout(Duration::from_secs(30))
339 .timeout(Duration::from_secs(300))
341 .build()
342 .map_err(|error| failure(format!("could not build an HTTP client: {}", chain(&error))))?;
343 let mut request = client.get(url);
344 if url.starts_with("https://api.github.com/") {
345 request = request
346 .header("Accept", "application/vnd.github+json")
347 .header("X-GitHub-Api-Version", "2022-11-28");
348 if let Some(token) = token {
349 request = request.bearer_auth(token);
350 }
351 }
352 let mut response = request
353 .send()
354 .await
355 .map_err(|error| failure(format!("GET {url}: {}", chain(&error))))?;
356 let status = response.status().as_u16();
357 let mut body = Body::start(url, response.status(), response.content_length(), limit)?;
358 while let Some(chunk) = response.chunk().await.map_err(|error| HttpFailure {
359 status: Some(status),
360 message: format!("GET {url}: {}", chain(&error)),
361 })? {
362 body.push(&chunk)?;
363 }
364 Ok(body.bytes)
365}
366
367struct Body<'a> {
370 url: &'a str,
371 limit: usize,
372 status: u16,
373 bytes: Vec<u8>,
374}
375
376impl<'a> Body<'a> {
377 fn start(
381 url: &'a str,
382 status: reqwest::StatusCode,
383 length: Option<u64>,
384 limit: usize,
385 ) -> Result<Self, HttpFailure> {
386 if !status.is_success() {
387 return Err(HttpFailure {
388 status: Some(status.as_u16()),
389 message: format!("GET {url}: HTTP {}", status.as_u16()),
390 });
391 }
392 let body = Self {
393 url,
394 limit,
395 status: status.as_u16(),
396 bytes: Vec::new(),
397 };
398 if length.is_some_and(|length| length > limit as u64) {
399 return Err(body.over_limit());
400 }
401 Ok(body)
402 }
403
404 fn push(&mut self, chunk: &[u8]) -> Result<(), HttpFailure> {
407 if chunk.len() > self.limit.saturating_sub(self.bytes.len()) {
408 return Err(self.over_limit());
409 }
410 self.bytes.extend_from_slice(chunk);
411 Ok(())
412 }
413
414 fn over_limit(&self) -> HttpFailure {
415 HttpFailure {
416 status: Some(self.status),
417 message: format!("GET {}: the body is over {} bytes", self.url, self.limit),
418 }
419 }
420}
421
422fn chain(error: &dyn std::error::Error) -> String {
425 let mut text = error.to_string();
426 let mut source = error.source();
427 while let Some(cause) = source {
428 text.push_str(": ");
429 text.push_str(&cause.to_string());
430 source = cause.source();
431 }
432 text
433}
434
435pub struct SigstoreVerifier {
454 verifier: sigstore_verify::Verifier,
455}
456
457impl SigstoreVerifier {
458 pub fn public_good() -> Result<Self, String> {
461 let root = sigstore_trust_root::TrustedRoot::from_json(
462 sigstore_trust_root::SIGSTORE_PRODUCTION_TRUSTED_ROOT,
463 )
464 .map_err(|error| format!("the embedded Sigstore trusted root: {error}"))?;
465 let verifier = sigstore_verify::Verifier::new(&root)
466 .map_err(|error| format!("the embedded Sigstore trusted root: {error}"))?;
467 Ok(Self { verifier })
468 }
469}
470
471impl fmt::Debug for SigstoreVerifier {
472 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
473 f.write_str("SigstoreVerifier(public-good)")
474 }
475}
476
477impl AttestationVerifier for SigstoreVerifier {
478 fn verify(&self, bundle: &str, artifact: &[u8], signer: &Signer) -> Result<(), String> {
479 let bundle = sigstore_types::Bundle::from_json(bundle)
480 .map_err(|error| format!("the bundle doesn't parse: {error}"))?;
481 let policy = sigstore_verify::VerificationPolicy::new(&signer.identity, &signer.issuer);
484 self.verifier
485 .verify(artifact, &bundle, &policy)
486 .map_err(|error| error.to_string())?;
487 let sigstore_types::SignatureContent::DsseEnvelope(envelope) = &bundle.content else {
490 return Err("the bundle signs a message, not an in-toto attestation".to_string());
491 };
492 let statement: sigstore_types::Statement =
493 serde_json::from_slice(envelope.payload.as_bytes())
494 .map_err(|error| format!("the attested statement doesn't parse: {error}"))?;
495 if statement.predicate_type != signer.predicate_type {
496 return Err(format!(
497 "predicate type mismatch: expected {}, got {}",
498 signer.predicate_type, statement.predicate_type
499 ));
500 }
501 Ok(())
502 }
503}
504
505#[derive(Clone, Debug, Default)]
510pub struct PolicyFetch<S, V> {
511 pub source: S,
512 pub verifier: V,
513}
514
515impl<S: ReleaseSource, V: AttestationVerifier> Fetch for PolicyFetch<S, V> {
516 fn fetch(
517 &self,
518 tag: &str,
519 dest: &Path,
520 progress: &mut dyn FnMut(&str),
521 ) -> Result<Verification, String> {
522 let fetched = self.fetch_bytes(tag, dest, progress)?;
523 std::fs::write(dest, &fetched.bytes)
524 .map_err(|error| format!("could not write {ASSET} to {}: {error}", dest.display()))?;
525 Ok(fetched.verification)
526 }
527
528 fn fetch_bytes(
529 &self,
530 tag: &str,
531 _: &Path,
532 progress: &mut dyn FnMut(&str),
533 ) -> Result<super::Fetched, String> {
534 fetch_release(&self.source, &self.verifier, tag, progress)
535 }
536}
537
538#[derive(Clone, Debug, Default)]
541pub struct HttpsFetch {
542 release: GitHubRelease,
543 directory: Option<DirectoryRelease>,
544}
545
546impl HttpsFetch {
547 pub fn from_env(env: &dyn Fn(&str) -> Option<String>) -> Self {
551 Self {
552 release: GitHubRelease::from_env(env),
553 directory: env(RELEASE_DIR_VARIABLE)
554 .filter(|root| !root.is_empty())
555 .map(|root| DirectoryRelease {
556 root: PathBuf::from(root),
557 }),
558 }
559 }
560}
561
562impl Fetch for HttpsFetch {
563 fn fetch(
564 &self,
565 tag: &str,
566 dest: &Path,
567 progress: &mut dyn FnMut(&str),
568 ) -> Result<Verification, String> {
569 let fetched = self.fetch_bytes(tag, dest, progress)?;
570 std::fs::write(dest, &fetched.bytes)
571 .map_err(|error| format!("could not write {ASSET} to {}: {error}", dest.display()))?;
572 Ok(fetched.verification)
573 }
574
575 fn fetch_bytes(
576 &self,
577 tag: &str,
578 dest: &Path,
579 progress: &mut dyn FnMut(&str),
580 ) -> Result<super::Fetched, String> {
581 let verifier = SigstoreVerifier::public_good()?;
582 match &self.directory {
583 Some(directory) => {
584 progress(&format!(
585 "reading {ASSET} {tag} from {} (${RELEASE_DIR_VARIABLE})",
586 directory.root.join(tag).display()
587 ));
588 PolicyFetch {
589 source: directory.clone(),
590 verifier,
591 }
592 .fetch_bytes(tag, dest, progress)
593 }
594 None => PolicyFetch {
595 source: self.release.clone(),
596 verifier,
597 }
598 .fetch_bytes(tag, dest, progress),
599 }
600 }
601}
602
603#[cfg(test)]
604mod tests {
605 use super::*;
606 use microvms_app::provision::{ReleaseSource, sha256_hex};
607
608 const TAG: &str = "v0.7.0";
612 const BUNDLE: &str = include_str!("../../tests/fixtures/release-v0.7.0/agentd.sigstore.json");
613 const SUMS: &str = include_str!("../../tests/fixtures/release-v0.7.0/SHA256SUMS");
614
615 fn agentd() -> &'static [u8] {
619 static AGENTD: std::sync::LazyLock<Vec<u8>> = std::sync::LazyLock::new(|| {
620 use std::io::Read as _;
621 let packed = include_bytes!("../../tests/fixtures/release-v0.7.0/agentd.gz");
622 let mut bytes = Vec::new();
623 flate2::read::GzDecoder::new(&packed[..])
624 .read_to_end(&mut bytes)
625 .expect("the fixture is gzip");
626 bytes
627 });
628 &AGENTD
629 }
630
631 fn verifier() -> SigstoreVerifier {
632 SigstoreVerifier::public_good().expect("the embedded trusted root parses")
633 }
634
635 fn flipped() -> Vec<u8> {
637 let mut bytes = agentd().to_vec();
638 bytes[1_000_000] ^= 0x01;
639 bytes
640 }
641
642 fn tampered(edit: impl FnOnce(&mut serde_json::Value)) -> String {
644 let mut bundle: serde_json::Value =
645 serde_json::from_str(BUNDLE).expect("the fixture parses");
646 edit(&mut bundle["verificationMaterial"]["tlogEntries"][0]);
647 bundle.to_string()
648 }
649
650 fn flip_base64(value: &mut serde_json::Value) {
652 use base64::Engine as _;
653 let engine = base64::engine::general_purpose::STANDARD;
654 let mut bytes = engine
655 .decode(value.as_str().expect("a base64 string"))
656 .expect("valid base64");
657 let middle = bytes.len() / 2;
658 bytes[middle] ^= 0x01;
659 *value = serde_json::Value::String(engine.encode(bytes));
660 }
661
662 #[test]
665 fn the_committed_release_verifies_against_its_bundle() {
666 verifier()
667 .verify(BUNDLE, agentd(), &Signer::release(TAG))
668 .expect("the v0.7.0 release asset is attested by its release workflow");
669 assert_eq!(
670 sha256_hex(agentd()),
671 "b281856000f58e6306bcaca73362e4d77260324a51f9b7ba3de563cf102dbe76",
672 "the fixture is the published asset"
673 );
674 }
675
676 #[test]
678 fn one_flipped_byte_in_the_asset_is_refused() {
679 let refusal = verifier()
680 .verify(BUNDLE, &flipped(), &Signer::release(TAG))
681 .expect_err("changed bytes must not verify");
682 assert!(refusal.contains("does not match any subject"), "{refusal}");
683 }
684
685 #[test]
689 fn another_signer_identity_is_refused() {
690 let release = Signer::release(TAG);
691 let others = [
692 Signer::release("v0.8.0"),
693 Signer {
694 identity: release.identity.replace("release.yml", "ci.yml"),
695 ..release.clone()
696 },
697 Signer {
698 identity: release.identity.replace("laithalsaadoon", "theagenticguy"),
699 ..release.clone()
700 },
701 ];
702 for signer in others {
703 let refusal = verifier()
704 .verify(BUNDLE, agentd(), &signer)
705 .expect_err(&signer.identity);
706 assert!(refusal.contains("identity mismatch"), "{refusal}");
707 }
708 }
709
710 #[test]
712 fn another_issuer_is_refused() {
713 let signer = Signer {
714 issuer: "https://accounts.google.com".into(),
715 ..Signer::release(TAG)
716 };
717 let refusal = verifier()
718 .verify(BUNDLE, agentd(), &signer)
719 .expect_err("another issuer must refuse");
720 assert!(refusal.contains("issuer mismatch"), "{refusal}");
721 }
722
723 #[test]
725 fn another_predicate_type_is_refused() {
726 let signer = Signer {
727 predicate_type: "https://spdx.dev/Document".into(),
728 ..Signer::release(TAG)
729 };
730 let refusal = verifier()
731 .verify(BUNDLE, agentd(), &signer)
732 .expect_err("another predicate type must refuse");
733 assert!(refusal.contains("predicate type mismatch"), "{refusal}");
734 }
735
736 #[test]
742 fn tampered_transparency_evidence_is_refused() {
743 let cases = [
744 (
745 "proof hash",
746 tampered(|entry| flip_base64(&mut entry["inclusionProof"]["hashes"][0])),
747 ),
748 (
749 "checkpoint signature",
750 tampered(|entry| {
751 let envelope = entry["inclusionProof"]["checkpoint"]["envelope"]
752 .as_str()
753 .expect("a checkpoint")
754 .to_string();
755 let (head, signature) = envelope.trim_end().rsplit_once(' ').expect("signed");
756 let mut signature = serde_json::Value::String(signature.to_string());
757 flip_base64(&mut signature);
758 entry["inclusionProof"]["checkpoint"]["envelope"] = serde_json::Value::String(
759 format!("{head} {}\n", signature.as_str().expect("a string")),
760 );
761 }),
762 ),
763 (
764 "proof log index",
765 tampered(|entry| {
766 let index: u64 = entry["inclusionProof"]["logIndex"]
767 .as_str()
768 .expect("a string")
769 .parse()
770 .expect("a number");
771 entry["inclusionProof"]["logIndex"] =
772 serde_json::Value::String((index + 1).to_string());
773 }),
774 ),
775 (
776 "proof root hash",
777 tampered(|entry| flip_base64(&mut entry["inclusionProof"]["rootHash"])),
778 ),
779 (
780 "SET",
781 tampered(|entry| {
782 flip_base64(&mut entry["inclusionPromise"]["signedEntryTimestamp"])
783 }),
784 ),
785 (
786 "integrated time",
787 tampered(|entry| {
788 let time: u64 = entry["integratedTime"]
789 .as_str()
790 .expect("a string")
791 .parse()
792 .expect("a number");
793 entry["integratedTime"] = serde_json::Value::String((time + 60).to_string());
794 }),
795 ),
796 ];
797 for (what, bundle) in cases {
798 verifier()
799 .verify(&bundle, agentd(), &Signer::release(TAG))
800 .expect_err(what);
801 }
802 }
803
804 #[test]
806 fn a_bundle_that_does_not_parse_is_refused() {
807 let refusal = verifier()
808 .verify("{\"not\": \"a bundle\"}", agentd(), &Signer::release(TAG))
809 .expect_err("must refuse");
810 assert!(refusal.contains("doesn't parse"), "{refusal}");
811 }
812
813 struct Fixture {
815 asset: Vec<u8>,
816 bundle: Option<Bundle>,
817 }
818
819 #[derive(Clone, Copy, Debug)]
821 enum Bundle {
822 Unreachable,
823 Absent,
824 }
825
826 impl ReleaseSource for Fixture {
827 fn asset(&self, tag: &str, name: &str) -> Result<Vec<u8>, String> {
828 assert_eq!((tag, name), (TAG, ASSET));
829 Ok(self.asset.clone())
830 }
831
832 fn checksums(&self, _: &str) -> Result<String, String> {
833 Ok(SUMS.to_string())
834 }
835
836 fn attestations(&self, _: &str, _: &str) -> Bundles {
837 match self.bundle {
838 None => Bundles::Published(vec![BUNDLE.to_string()]),
839 Some(Bundle::Unreachable) => Bundles::Unreachable("HTTP 403".to_string()),
840 Some(Bundle::Absent) => Bundles::Absent("HTTP 404".to_string()),
841 }
842 }
843 }
844
845 #[test]
849 fn the_policy_over_the_committed_release() {
850 let fetch = |asset: Vec<u8>, bundle: Option<Bundle>| {
851 fetch_release(&Fixture { asset, bundle }, &verifier(), TAG, &mut |_| {})
852 };
853 let attested = fetch(agentd().to_vec(), None).expect("attested");
854 assert_eq!(attested.verification, Verification::Attestation);
855 assert_eq!(attested.bytes, agentd());
856 let checked = fetch(agentd().to_vec(), Some(Bundle::Unreachable)).expect("checked");
857 assert_eq!(checked.verification, Verification::Checksum);
858 let refusal = fetch(agentd().to_vec(), Some(Bundle::Absent))
859 .expect_err("a release that says it has no bundle is refused");
860 assert!(refusal.contains("publishes no attestation"), "{refusal}");
861
862 let refusal = fetch(flipped(), None).expect_err("flipped bytes, bundle there");
863 assert!(refusal.contains("discarded"), "{refusal}");
864 let refusal = fetch(flipped(), Some(Bundle::Unreachable)).expect_err("flipped, no bundle");
865 assert!(refusal.contains("SHA256 mismatch"), "{refusal}");
866 }
867
868 #[derive(Clone, Copy, Debug)]
870 enum Answer {
871 Bundle,
873 EmptyList,
875 Junk,
877 Status(u16),
878 Transport,
880 }
881
882 fn lookup(token: Option<&str>, asset: Answer, api: Answer) -> (Bundles, Vec<Option<String>>) {
885 let tokens = std::cell::RefCell::new(Vec::new());
886 let get = |url: &str, token: Option<&str>, _: usize| {
887 tokens.borrow_mut().push(token.map(str::to_string));
888 let (answer, from_api) = if url == asset_url(TAG, BUNDLE_ASSET) {
889 (asset, false)
890 } else {
891 assert_eq!(url, attestations_url("abc"), "only the two lookups run");
892 (api, true)
893 };
894 let bundle: serde_json::Value = serde_json::from_str(BUNDLE).expect("parses");
895 match answer {
896 Answer::Bundle if from_api => Ok(serde_json::json!({
897 "attestations": [{ "bundle": bundle }]
898 })
899 .to_string()
900 .into_bytes()),
901 Answer::Bundle => Ok(BUNDLE.as_bytes().to_vec()),
902 Answer::EmptyList => Ok(br#"{"attestations": []}"#.to_vec()),
903 Answer::Junk => Ok(b"\xff<html>".to_vec()),
904 Answer::Status(status) => Err(HttpFailure {
905 status: Some(status),
906 message: format!("GET {url}: HTTP {status}"),
907 }),
908 Answer::Transport => Err(HttpFailure {
909 status: None,
910 message: format!("GET {url}: error sending request: dns error"),
911 }),
912 }
913 };
914 let found = GitHubRelease::new(token.map(str::to_string)).lookup(TAG, "abc", &get);
915 (found, tokens.into_inner())
916 }
917
918 #[test]
923 fn a_missing_bundle_is_absent_and_a_failed_request_is_unreachable() {
924 use Answer::*;
925 let published = |found: &Bundles| matches!(found, Bundles::Published(b) if b.len() == 1);
926 let absent = |found: &Bundles| matches!(found, Bundles::Absent(_));
927 let unreachable = |found: &Bundles| matches!(found, Bundles::Unreachable(_));
928 type Row<'a> = (Answer, Answer, &'a dyn Fn(&Bundles) -> bool);
929 let rows: [Row<'_>; 14] = [
930 (Bundle, Status(500), &published),
931 (Junk, Status(404), &published),
934 (Junk, Bundle, &published),
935 (Status(404), Bundle, &published),
936 (Status(503), Bundle, &published),
937 (Status(404), Status(404), &absent),
938 (Status(404), EmptyList, &absent),
939 (Status(404), Status(403), &absent),
940 (Status(404), Transport, &absent),
941 (Transport, Status(404), &absent),
942 (Status(403), EmptyList, &absent),
943 (Status(403), Status(429), &unreachable),
944 (Transport, Status(502), &unreachable),
945 (Status(500), Junk, &absent),
946 ];
947 for (asset, api, expected) in rows {
948 let (found, _) = lookup(None, asset, api);
949 assert!(expected(&found), "asset {asset:?}, API {api:?}: {found:?}");
950 }
951 let (found, _) = lookup(None, Status(404), Bundle);
952 let Bundles::Published(bundles) = found else {
953 panic!("{found:?}")
954 };
955 verifier()
956 .verify(&bundles[0], agentd(), &Signer::release(TAG))
957 .expect("the API's bundle verifies like the asset's");
958 }
959
960 #[test]
962 fn successful_incomplete_bundle_responses_do_not_use_checksums() {
963 for status in [200, 206] {
964 for declared in [true, false] {
965 let get = |url: &str, _: Option<&str>, _: usize| {
966 if url == asset_url(TAG, BUNDLE_ASSET) {
967 let status = reqwest::StatusCode::from_u16(status).expect("status");
968 if declared {
969 Err(Body::start(url, status, Some(2), 1).err().expect("limit"))
970 } else {
971 let mut body = Body::start(url, status, None, 1).expect("start");
972 Err(body.push(b"xx").expect_err("limit"))
973 }
974 } else {
975 panic!("a successful bundle response must not try the API")
976 }
977 };
978 assert!(matches!(
979 GitHubRelease::new(None).lookup(TAG, "abc", &get),
980 Bundles::Absent(_)
981 ));
982 }
983 }
984 }
985
986 #[test]
988 fn an_incomplete_successful_transfer_does_not_use_checksums() {
989 for asset in [true, false] {
990 let get = |url: &str, _: Option<&str>, _: usize| {
991 let is_asset = url == asset_url(TAG, BUNDLE_ASSET);
992 Err(HttpFailure {
993 status: Some(if is_asset == asset { 200 } else { 503 }),
994 message: "the response body ended early".into(),
995 })
996 };
997 assert!(matches!(
998 GitHubRelease::new(None).lookup(TAG, "abc", &get),
999 Bundles::Absent(_)
1000 ));
1001 }
1002 }
1003
1004 #[test]
1006 fn a_successful_api_answer_without_a_bundle_does_not_use_checksums() {
1007 for body in [
1008 b"<html>".as_slice(),
1009 br#"{"attestations":[{"bundle_url":"https://example.invalid/bundle"}]}"#,
1010 ] {
1011 let get = |url: &str, _: Option<&str>, _: usize| {
1012 if url == asset_url(TAG, BUNDLE_ASSET) {
1013 Err(HttpFailure {
1014 status: Some(503),
1015 message: "unavailable".into(),
1016 })
1017 } else {
1018 Ok(body.to_vec())
1019 }
1020 };
1021 assert!(matches!(
1022 GitHubRelease::new(None).lookup(TAG, "abc", &get),
1023 Bundles::Absent(_)
1024 ));
1025 }
1026 }
1027
1028 #[test]
1031 fn the_token_goes_only_to_the_api() {
1032 let (_, tokens) = lookup(Some("ghp_secret"), Answer::Status(404), Answer::Bundle);
1033 assert_eq!(tokens, [None, Some("ghp_secret".to_string())]);
1034 let (found, _) = lookup(None, Answer::Transport, Answer::Status(403));
1035 let Bundles::Unreachable(reason) = found else {
1036 panic!("{found:?}")
1037 };
1038 assert!(reason.contains("set GITHUB_TOKEN"), "{reason}");
1039 let (found, _) = lookup(Some("t"), Answer::Transport, Answer::Status(403));
1040 assert!(
1041 !format!("{found:?}").contains("set GITHUB_TOKEN"),
1042 "{found:?}"
1043 );
1044 }
1045
1046 #[test]
1048 fn the_api_response_yields_each_bundle() {
1049 let bundle: serde_json::Value = serde_json::from_str(BUNDLE).expect("parses");
1050 let body = serde_json::json!({
1051 "attestations": [
1052 { "bundle": bundle, "repository_id": 1 },
1053 { "bundle_url": "https://example.invalid/only-a-url" },
1054 ]
1055 });
1056 let only_urls = r#"{"attestations": [{"bundle_url": "https://example.invalid/x"}]}"#;
1057 assert!(parse_attestations(only_urls.as_bytes()).is_err());
1058 let bundles = parse_attestations(body.to_string().as_bytes()).expect("parses");
1059 assert_eq!(bundles.len(), 1);
1060 verifier()
1061 .verify(&bundles[0], agentd(), &Signer::release(TAG))
1062 .expect("the API's bundle verifies like the asset's");
1063
1064 assert_eq!(
1065 parse_attestations(br#"{"attestations": []}"#).expect("parses"),
1066 Vec::<String>::new()
1067 );
1068 assert!(parse_attestations(br#"{"message": "Not Found"}"#).is_err());
1069 assert!(parse_attestations(b"<html>").is_err());
1070 }
1071
1072 #[test]
1074 fn the_urls_are_the_public_release_and_the_attestations_api() {
1075 assert_eq!(
1076 asset_url("v0.7.0", BUNDLE_ASSET),
1077 "https://github.com/laithalsaadoon/microvms-agentd/releases/download/v0.7.0/\
1078 agentd.sigstore.json"
1079 );
1080 assert_eq!(
1081 attestations_url("abc"),
1082 "https://api.github.com/repos/laithalsaadoon/microvms-agentd/attestations/sha256:abc"
1083 );
1084 }
1085
1086 #[test]
1089 fn the_token_is_read_from_the_environment_and_never_printed() {
1090 let with =
1091 GitHubRelease::from_env(&|name| (name == TOKEN_VARIABLE).then(|| "ghp_secret".into()));
1092 assert_eq!(with.token.as_deref(), Some("ghp_secret"));
1093 let printed = format!(
1094 "{:?}",
1095 HttpsFetch {
1096 release: with,
1097 directory: None,
1098 }
1099 );
1100 assert!(!printed.contains("ghp_secret"), "{printed}");
1101 assert!(printed.contains("redacted"), "{printed}");
1102 assert_eq!(
1103 GitHubRelease::from_env(&|_| Some(String::new())).token,
1104 None
1105 );
1106 assert_eq!(GitHubRelease::from_env(&|_| None).token, None);
1107 let fetch = HttpsFetch::from_env(&|name| (name == TOKEN_VARIABLE).then(|| "t".into()));
1108 assert_eq!(fetch.release.token.as_deref(), Some("t"));
1109 }
1110
1111 fn release_dir(tag: &str, bundle: bool) -> tempfile::TempDir {
1114 let root = tempfile::tempdir().expect("a temp dir");
1115 let dir = root.path().join(tag);
1116 std::fs::create_dir(&dir).expect("the tag's directory");
1117 std::fs::write(dir.join(ASSET), agentd()).expect("the asset");
1118 std::fs::write(dir.join(CHECKSUMS), SUMS).expect("the checksums");
1119 if bundle {
1120 std::fs::write(dir.join(BUNDLE_ASSET), BUNDLE).expect("the bundle");
1121 }
1122 root
1123 }
1124
1125 fn from_dir(root: &Path) -> HttpsFetch {
1127 let root = root.display().to_string();
1128 HttpsFetch::from_env(&|name| (name == RELEASE_DIR_VARIABLE).then(|| root.clone()))
1129 }
1130
1131 #[test]
1135 fn a_release_directory_is_proven_by_its_attestation() {
1136 let root = release_dir(TAG, true);
1137 let dest = root.path().join("installed");
1138 let mut lines = Vec::new();
1139 let verification = from_dir(root.path())
1140 .fetch(TAG, &dest, &mut |line| lines.push(line.to_string()))
1141 .expect("the directory's release verifies");
1142 assert_eq!(verification, Verification::Attestation);
1143 assert_eq!(std::fs::read(&dest).expect("installed"), agentd());
1144 let dir = root.path().join(TAG).display().to_string();
1145 assert!(
1146 lines[0].contains(&dir) && lines[0].contains(RELEASE_DIR_VARIABLE),
1147 "{lines:?}"
1148 );
1149 }
1150
1151 #[test]
1155 fn a_release_directory_without_its_bundle_is_refused_despite_a_matching_checksum() {
1156 let root = release_dir(TAG, false);
1157 let dest = root.path().join("installed");
1158 let refusal = from_dir(root.path())
1159 .fetch(TAG, &dest, &mut |_| {})
1160 .expect_err("no bundle, no proof");
1161 assert!(refusal.contains("publishes no attestation"), "{refusal}");
1162 assert!(refusal.contains(RELEASE_DIR_VARIABLE), "{refusal}");
1163 assert!(!dest.exists());
1164 }
1165
1166 #[test]
1170 fn another_releases_files_under_the_requested_tag_are_refused() {
1171 let root = release_dir("v0.8.0", true);
1172 let dest = root.path().join("installed");
1173 let refusal = from_dir(root.path())
1174 .fetch("v0.8.0", &dest, &mut |_| {})
1175 .expect_err("v0.7.0's bundle doesn't prove v0.8.0");
1176 assert!(refusal.contains("identity mismatch"), "{refusal}");
1177 assert!(!dest.exists());
1178 }
1179
1180 #[test]
1183 fn a_release_directory_without_the_release_is_an_error_and_an_empty_one_is_unset() {
1184 let root = tempfile::tempdir().expect("a temp dir");
1185 let refusal = from_dir(root.path())
1186 .fetch(TAG, &root.path().join("installed"), &mut |_| {})
1187 .expect_err("nothing to read");
1188 let missing = root.path().join(TAG).join(ASSET).display().to_string();
1189 assert!(refusal.contains(&missing), "{refusal}");
1190 assert!(
1191 HttpsFetch::from_env(&|_| Some(String::new()))
1192 .directory
1193 .is_none()
1194 );
1195 assert!(HttpsFetch::from_env(&|_| None).directory.is_none());
1196 }
1197
1198 #[test]
1201 fn a_release_directory_reads_its_checksums_and_refuses_a_file_over_its_limit() {
1202 let root = release_dir(TAG, true);
1203 let directory = DirectoryRelease {
1204 root: root.path().to_path_buf(),
1205 };
1206 assert_eq!(directory.checksums(TAG).as_deref(), Ok(SUMS));
1207 let file = root.path().join("four");
1208 std::fs::write(&file, b"four").expect("a file");
1209 assert_eq!(read_limited(&file, 4).as_deref(), Ok(&b"four"[..]));
1210 let refusal = read_limited(&file, 3).expect_err("over the limit");
1211 assert!(refusal.contains("over 3 bytes"), "{refusal}");
1212 }
1213
1214 #[tokio::test]
1217 async fn a_request_runs_from_inside_a_runtime() {
1218 let answer = run(async { Ok::<_, HttpFailure>(7) });
1219 assert_eq!(answer, Ok(7));
1220 }
1221
1222 #[test]
1225 fn a_status_other_than_success_is_refused_with_the_status() {
1226 let start = |status: u16| {
1227 let status = reqwest::StatusCode::from_u16(status).expect("a status");
1228 Body::start("https://example.invalid/x", status, None, 8).map(|body| body.bytes)
1229 };
1230 assert_eq!(start(200), Ok(Vec::new()));
1231 assert_eq!(start(204), Ok(Vec::new()));
1232 for status in [301, 403, 404, 429, 500] {
1233 assert_eq!(
1234 start(status),
1235 Err(HttpFailure {
1236 status: Some(status),
1237 message: format!("GET https://example.invalid/x: HTTP {status}"),
1238 }),
1239 );
1240 }
1241 }
1242
1243 #[test]
1246 fn a_body_over_its_limit_is_refused() {
1247 let over = Err(HttpFailure {
1248 status: Some(200),
1249 message: "GET https://example.invalid/x: the body is over 4 bytes".to_string(),
1250 });
1251 let start = |length: Option<u64>| {
1252 Body::start(
1253 "https://example.invalid/x",
1254 reqwest::StatusCode::OK,
1255 length,
1256 4,
1257 )
1258 };
1259 for length in [None, Some(0), Some(3), Some(4)] {
1260 assert!(start(length).is_ok(), "declared {length:?}");
1261 }
1262 assert_eq!(start(Some(5)).map(|body| body.bytes), over);
1263
1264 let read = |chunks: &[&[u8]]| {
1265 let mut body = start(None)?;
1266 for chunk in chunks {
1267 body.push(chunk)?;
1268 }
1269 Ok(body.bytes)
1270 };
1271 assert_eq!(read(&[b"ab", b"cd"]), Ok(b"abcd".to_vec()));
1272 assert_eq!(read(&[b"abcd", b""]), Ok(b"abcd".to_vec()));
1273 assert_eq!(read(&[b"abcde"]), over);
1274 assert_eq!(read(&[b"ab", b"cd", b"e"]), over);
1275 assert_eq!(read(&[b"a", b"a", b"a", b"a", b"a"]), over);
1276 }
1277
1278 #[test]
1281 fn the_limits_admit_a_real_release() {
1282 assert!(agentd().len() <= ASSET_LIMIT, "{} bytes", agentd().len());
1283 assert!(BUNDLE.len() <= SMALL_LIMIT, "{} bytes", BUNDLE.len());
1284 assert!(SUMS.len() <= SMALL_LIMIT, "{} bytes", SUMS.len());
1285 let listed = format!(r#"{{"attestations": [{{"bundle": {BUNDLE}}}]}}"#);
1286 assert!(listed.len() <= API_LIMIT, "{} bytes", listed.len());
1287 }
1288
1289 #[test]
1292 fn an_error_is_printed_with_its_causes() {
1293 #[derive(Debug)]
1294 struct Layer(&'static str, Option<Box<Layer>>);
1295 impl fmt::Display for Layer {
1296 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1297 f.write_str(self.0)
1298 }
1299 }
1300 impl std::error::Error for Layer {
1301 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1302 self.1.as_deref().map(|cause| cause as _)
1303 }
1304 }
1305 let error = Layer(
1306 "error sending request",
1307 Some(Box::new(Layer(
1308 "dns error",
1309 Some(Box::new(Layer("no such host", None))),
1310 ))),
1311 );
1312 assert_eq!(
1313 chain(&error),
1314 "error sending request: dns error: no such host"
1315 );
1316 assert_eq!(chain(&Layer("timed out", None)), "timed out");
1317 }
1318
1319 #[test]
1322 fn the_verifier_prints_the_root_it_holds() {
1323 assert_eq!(format!("{:?}", verifier()), "SigstoreVerifier(public-good)");
1324 }
1325}