1use std::fmt;
82use std::path::{Path, PathBuf};
83
84pub use microvms_domain::provision::{REQUIRED_ELF_MACHINE, elf_machine, not_aarch64};
87
88use microvms_app::error::{Error, ErrorKind};
89
90pub use microvms_app::provision::{
93 ASSET, AttestationVerifier, Bundles, Fetched, RELEASE_REPO, ReleaseSource, Signer,
94 Verification, fetch_release, sha256_hex, verify_sha256,
95};
96
97mod release;
98pub use release::{
99 BUNDLE_ASSET, GitHubRelease, HttpsFetch, PolicyFetch, RELEASE_DIR_VARIABLE, SigstoreVerifier,
100 TOKEN_VARIABLE,
101};
102
103pub const ENV_OVERRIDE: &str = "MICROVM_AGENTD";
106
107const RECORD: &str = "agentd.verified.json";
109
110const MAX_VERSION_LEN: usize = 64;
113
114#[derive(Clone, Copy, Debug, PartialEq, Eq)]
116pub enum Supplier {
117 Argument,
119 Env,
121}
122
123impl Supplier {
124 pub fn as_str(self) -> &'static str {
125 match self {
126 Supplier::Argument => "argument",
127 Supplier::Env => "env",
128 }
129 }
130}
131
132#[derive(Clone, Copy, Debug, PartialEq, Eq)]
134pub enum Source {
135 CallerSupplied(Supplier),
138 Cache(Verification),
141 Fetched(Verification),
143}
144
145impl Source {
146 pub fn as_str(self) -> &'static str {
148 match self {
149 Source::CallerSupplied(_) => "caller-supplied",
150 Source::Cache(_) => "cache",
151 Source::Fetched(_) => "fetched",
152 }
153 }
154
155 pub fn verification(self) -> Option<Verification> {
158 match self {
159 Source::CallerSupplied(_) => None,
160 Source::Cache(verification) | Source::Fetched(verification) => Some(verification),
161 }
162 }
163}
164
165#[derive(Clone, PartialEq, Eq)]
167pub struct Provisioned {
168 pub bytes: Vec<u8>,
170 pub path: PathBuf,
172 pub source: Source,
173 pub version: String,
176 pub sha256: String,
178}
179
180impl Provisioned {
181 pub fn verification(&self) -> Option<Verification> {
183 self.source.verification()
184 }
185}
186
187impl fmt::Debug for Provisioned {
189 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
190 f.debug_struct("Provisioned")
191 .field("bytes", &format_args!("<{} bytes>", self.bytes.len()))
192 .field("path", &self.path)
193 .field("source", &self.source)
194 .field("version", &self.version)
195 .field("sha256", &self.sha256)
196 .finish()
197 }
198}
199
200#[derive(Clone, Copy, Debug, Default)]
203pub struct Request<'a> {
204 pub version: Option<&'a str>,
206 pub state_dir: Option<&'a Path>,
209 pub binary: Option<&'a Path>,
211}
212
213#[derive(Clone, Copy, Debug, PartialEq, Eq)]
215pub enum Failure {
216 InvalidVersion,
218 CallerBinary(Supplier),
220 NotAarch64,
222 Fetch,
224 Io,
226}
227
228#[derive(Clone, Debug, PartialEq, Eq)]
230pub struct ProvisionError {
231 pub failure: Failure,
232 pub message: String,
233 pub remedies: Vec<String>,
234}
235
236impl ProvisionError {
237 fn new(failure: Failure, message: impl Into<String>) -> Self {
238 Self {
239 failure,
240 message: message.into(),
241 remedies: Vec::new(),
242 }
243 }
244
245 fn remedy(mut self, remedy: impl Into<String>) -> Self {
246 self.remedies.push(remedy.into());
247 self
248 }
249
250 pub fn kind(&self) -> ErrorKind {
253 match self.failure {
254 Failure::InvalidVersion => ErrorKind::InvalidArg,
255 _ => ErrorKind::Precondition,
256 }
257 }
258}
259
260impl fmt::Display for ProvisionError {
261 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
262 f.write_str(&self.message)
263 }
264}
265
266impl std::error::Error for ProvisionError {}
267
268impl From<ProvisionError> for Error {
271 fn from(error: ProvisionError) -> Self {
272 let mut message = error.message.clone();
273 for remedy in &error.remedies {
274 message.push_str("\n - ");
275 message.push_str(remedy);
276 }
277 Error::new(error.kind(), message)
278 }
279}
280
281pub trait Fetch {
285 fn fetch(
288 &self,
289 tag: &str,
290 dest: &Path,
291 progress: &mut dyn FnMut(&str),
292 ) -> Result<Verification, String>;
293}
294
295pub fn normalize_version(version: &str) -> Result<String, ProvisionError> {
301 let bare = version.strip_prefix('v').unwrap_or(version);
302 let plain = !bare.is_empty()
303 && bare.len() <= MAX_VERSION_LEN
304 && bare.as_bytes()[0].is_ascii_alphanumeric()
305 && !bare.contains("..")
306 && bare
307 .bytes()
308 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'+' | b'-'));
309 if plain {
310 return Ok(bare.to_string());
311 }
312 Err(ProvisionError::new(
313 Failure::InvalidVersion,
314 format!(
315 "{version:?} is not a release version: expected a tag such as {} or v{}, made of \
316 letters, digits, `.`, `+`, and `-`",
317 crate::VERSION,
318 crate::VERSION
319 ),
320 )
321 .remedy("omit the version to provision the core's own, which is the one it is proven against"))
322}
323
324pub fn cache_path(state_dir: &Path, version: &str) -> PathBuf {
329 state_dir
330 .join("agentd")
331 .join(format!("v{version}"))
332 .join(ASSET)
333}
334
335fn record_path(state_dir: &Path, version: &str) -> PathBuf {
337 state_dir
338 .join("agentd")
339 .join(format!("v{version}"))
340 .join(RECORD)
341}
342
343pub(crate) fn record_json(version: &str, sha256: &str, verification: Verification) -> String {
345 serde_json::json!({
346 "version": version,
347 "sha256": sha256,
348 "verification": verification.as_str(),
349 })
350 .to_string()
351}
352
353fn recorded(state_dir: &Path, version: &str, sha256: &str) -> Option<Verification> {
356 let text = std::fs::read_to_string(record_path(state_dir, version)).ok()?;
357 parse_record(&text, version, sha256)
358}
359
360pub(crate) fn parse_record(text: &str, version: &str, sha256: &str) -> Option<Verification> {
363 let record: serde_json::Value = serde_json::from_str(text).ok()?;
364 let matches =
365 record["version"].as_str() == Some(version) && record["sha256"].as_str() == Some(sha256);
366 let written = record["verification"].as_str()?;
367 [Verification::Attestation, Verification::Checksum]
368 .into_iter()
369 .find(|verification| matches && verification.as_str() == written)
370}
371
372fn io_error(path: &Path, verb: &str, error: &std::io::Error) -> ProvisionError {
374 ProvisionError::new(
375 Failure::Io,
376 format!("could not {verb} {}: {error}", path.display()),
377 )
378 .remedy("the failure is on this machine's filesystem; the platform was not involved")
379}
380
381fn fetch_error(tag: &str, reason: &str) -> ProvisionError {
385 ProvisionError::new(
386 Failure::Fetch,
387 format!("could not provision the agentd daemon binary for {tag}: {reason}"),
388 )
389 .remedy(format!(
390 "manual download: `gh release download {tag} --repo {RELEASE_REPO} --pattern {ASSET}` \
391 (then `gh attestation verify {ASSET} --repo {RELEASE_REPO} \
392 --cert-identity https://github.com/{RELEASE_REPO}/.github/workflows/release.yml@refs/tags/{tag}`), and pass its path as \
393 the binary or ${ENV_OVERRIDE}"
394 ))
395 .remedy(
396 "a self-built daemon works too: cargo build --release -p agentd --target \
397 aarch64-unknown-linux-musl",
398 )
399}
400
401pub fn resolve(
407 request: &Request<'_>,
408 env: &dyn Fn(&str) -> Option<String>,
409 fetch: &dyn Fetch,
410 progress: &mut dyn FnMut(&str),
411) -> Result<Provisioned, ProvisionError> {
412 let version = normalize_version(request.version.unwrap_or(crate::VERSION))?;
413
414 let supplied = match request.binary {
417 Some(path) => Some((path.to_path_buf(), Supplier::Argument)),
418 None => env(ENV_OVERRIDE).map(|path| (PathBuf::from(path), Supplier::Env)),
419 };
420 if let Some((path, supplier)) = supplied {
421 return caller_supplied(path, supplier, version, progress);
422 }
423
424 let state_dir = match request.state_dir {
425 Some(dir) => dir.to_path_buf(),
426 None => crate::names::state_root(env)
427 .map_err(|error| ProvisionError::new(Failure::Io, error.to_string()))?,
428 };
429 let cached = cache_path(&state_dir, &version);
430 if let Some(hit) = cache_hit(&state_dir, &cached, &version, progress)? {
431 return Ok(hit);
432 }
433 fetched(&state_dir, &cached, version, fetch, progress)
434}
435
436fn caller_supplied(
437 path: PathBuf,
438 supplier: Supplier,
439 version: String,
440 progress: &mut dyn FnMut(&str),
441) -> Result<Provisioned, ProvisionError> {
442 let named = match supplier {
443 Supplier::Argument => format!("the agentd binary {}", path.display()),
444 Supplier::Env => format!("${ENV_OVERRIDE} names {}, which", path.display()),
445 };
446 let refuse = |failure: Failure, detail: String| {
447 let error = ProvisionError::new(failure, format!("{named} {detail}"));
448 match supplier {
449 Supplier::Argument => error,
450 Supplier::Env => error
451 .remedy(format!(
452 "unset {ENV_OVERRIDE} to provision the release asset instead"
453 ))
454 .remedy("or point it at a real aarch64 agentd binary"),
455 }
456 };
457 if !path.exists() {
458 return Err(refuse(
459 Failure::CallerBinary(supplier),
460 "does not exist. A caller-supplied binary short-circuits provisioning, so a \
461 stale path blocks the fetch that would otherwise have worked."
462 .to_string(),
463 ));
464 }
465 let bytes = std::fs::read(&path).map_err(|error| {
466 refuse(
467 Failure::CallerBinary(supplier),
468 format!("could not be read: {error}"),
469 )
470 })?;
471 if let Some(why) = not_aarch64(&bytes) {
472 return Err(refuse(
473 Failure::NotAarch64,
474 format!(
475 "is {why}. MicroVMs are ARM64-only, and a wrong-architecture daemon fails as \
476 a run-hook timeout 45 minutes into a build."
477 ),
478 )
479 .remedy("build one: cargo build --release -p agentd --target aarch64-unknown-linux-musl"));
480 }
481 match supplier {
482 Supplier::Argument => progress(&format!("using the caller's agentd: {}", path.display())),
483 Supplier::Env => progress(&format!("using ${ENV_OVERRIDE}: {}", path.display())),
484 }
485 Ok(Provisioned {
486 sha256: sha256_hex(&bytes),
487 bytes,
488 path,
489 source: Source::CallerSupplied(supplier),
490 version,
491 })
492}
493
494fn cache_hit(
497 state_dir: &Path,
498 cached: &Path,
499 version: &str,
500 progress: &mut dyn FnMut(&str),
501) -> Result<Option<Provisioned>, ProvisionError> {
502 let bytes = match std::fs::read(cached) {
503 Ok(bytes) => bytes,
504 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
505 Err(error) => return Err(io_error(cached, "read", &error)),
506 };
507 let sha256 = sha256_hex(&bytes);
508 match recorded(state_dir, version, &sha256) {
509 Some(verification) if not_aarch64(&bytes).is_none() => {
510 progress(&format!(
511 "using cached agentd v{version}: {}",
512 cached.display()
513 ));
514 Ok(Some(Provisioned {
515 bytes,
516 path: cached.to_path_buf(),
517 source: Source::Cache(verification),
518 version: version.to_string(),
519 sha256,
520 }))
521 }
522 _ => {
523 progress(&format!(
524 "cached agentd v{version} at {} does not match the digest recorded when it \
525 was verified; discarding it and fetching the release again",
526 cached.display()
527 ));
528 for path in [cached.to_path_buf(), record_path(state_dir, version)] {
529 match std::fs::remove_file(&path) {
530 Ok(()) => {}
531 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
532 Err(error) => return Err(io_error(&path, "discard", &error)),
533 }
534 }
535 Ok(None)
536 }
537 }
538}
539
540fn fetched(
541 state_dir: &Path,
542 cached: &Path,
543 version: String,
544 fetch: &dyn Fetch,
545 progress: &mut dyn FnMut(&str),
546) -> Result<Provisioned, ProvisionError> {
547 let tag = format!("v{version}");
548 progress(&format!(
549 "no agentd given; fetching the release asset for {tag}"
550 ));
551 let dir = cached.parent().expect("the cache path has a parent");
552 std::fs::create_dir_all(dir).map_err(|error| io_error(dir, "create", &error))?;
553 let partial = dir.join(format!(".{ASSET}.partial-{}", std::process::id()));
557 let verification = match fetch.fetch(&tag, &partial, progress) {
558 Ok(verification) => verification,
559 Err(reason) => {
560 let _ = std::fs::remove_file(&partial);
561 return Err(fetch_error(&tag, &reason));
562 }
563 };
564 let bytes = std::fs::read(&partial).map_err(|error| {
565 let _ = std::fs::remove_file(&partial);
566 io_error(&partial, "read", &error)
567 })?;
568 if let Some(why) = not_aarch64(&bytes) {
569 let _ = std::fs::remove_file(&partial);
570 return Err(ProvisionError::new(
571 Failure::NotAarch64,
572 format!(
573 "the fetched {tag} asset is {why}, so it was discarded rather than cached: a \
574 wrong-architecture daemon fails as a run-hook timeout 45 minutes into a build."
575 ),
576 )
577 .remedy("this is a release-asset defect worth reporting, not a local mistake"));
578 }
579
580 #[cfg(unix)]
581 {
582 use std::os::unix::fs::PermissionsExt as _;
583 std::fs::set_permissions(&partial, std::fs::Permissions::from_mode(0o755))
584 .map_err(|error| io_error(&partial, "chmod", &error))?;
585 }
586 std::fs::rename(&partial, cached).map_err(|error| io_error(cached, "install", &error))?;
587 let sha256 = sha256_hex(&bytes);
590 let record = record_path(state_dir, &version);
591 let record_partial = dir.join(format!(".{RECORD}.partial-{}", std::process::id()));
592 std::fs::write(
593 &record_partial,
594 record_json(&version, &sha256, verification),
595 )
596 .and_then(|()| std::fs::rename(&record_partial, &record))
597 .map_err(|error| {
598 let _ = std::fs::remove_file(&record_partial);
599 io_error(&record, "record", &error)
600 })?;
601 progress(&format!(
602 "fetched and verified agentd {tag} ({}); cached at {}",
603 verification.as_str(),
604 cached.display()
605 ));
606 Ok(Provisioned {
607 bytes,
608 path: cached.to_path_buf(),
609 source: Source::Fetched(verification),
610 version,
611 sha256,
612 })
613}
614
615pub fn agentd(version: Option<&str>, state_dir: Option<&Path>) -> Result<Provisioned, Error> {
621 agentd_with(&Request {
622 version,
623 state_dir,
624 binary: None,
625 })
626}
627
628pub fn agentd_with(request: &Request<'_>) -> Result<Provisioned, Error> {
630 let env = &crate::env::process;
631 resolve(request, env, &HttpsFetch::from_env(env), &mut |_| {}).map_err(Error::from)
632}
633
634#[cfg(test)]
635mod tests {
636 use super::*;
637 use std::cell::Cell;
638
639 struct Scripted {
641 bytes: Vec<u8>,
642 verification: Verification,
643 calls: Cell<usize>,
644 }
645
646 impl Scripted {
647 fn elf() -> Self {
648 Self::with(elf_header(REQUIRED_ELF_MACHINE))
649 }
650
651 fn with(bytes: Vec<u8>) -> Self {
652 Self {
653 bytes,
654 verification: Verification::Attestation,
655 calls: Cell::new(0),
656 }
657 }
658 }
659
660 impl Fetch for Scripted {
661 fn fetch(
662 &self,
663 _: &str,
664 dest: &Path,
665 _: &mut dyn FnMut(&str),
666 ) -> Result<Verification, String> {
667 self.calls.set(self.calls.get() + 1);
668 std::fs::write(dest, &self.bytes).expect("writes");
669 Ok(self.verification)
670 }
671 }
672
673 struct Failing;
675
676 impl Fetch for Failing {
677 fn fetch(
678 &self,
679 _: &str,
680 _: &Path,
681 _: &mut dyn FnMut(&str),
682 ) -> Result<Verification, String> {
683 Err("no network in tests".into())
684 }
685 }
686
687 struct Panicking;
689
690 impl Fetch for Panicking {
691 fn fetch(
692 &self,
693 tag: &str,
694 _: &Path,
695 _: &mut dyn FnMut(&str),
696 ) -> Result<Verification, String> {
697 panic!("this request must not fetch (asked for {tag})");
698 }
699 }
700
701 fn elf_header(machine: u16) -> Vec<u8> {
703 let mut header = vec![0u8; 20];
704 header[..4].copy_from_slice(b"\x7fELF");
705 header[5] = 1;
706 header[18..20].copy_from_slice(&machine.to_le_bytes());
707 header
708 }
709
710 fn no_env(_: &str) -> Option<String> {
711 None
712 }
713
714 fn request(dir: &Path) -> Request<'_> {
715 Request {
716 version: Some("9.9.9"),
717 state_dir: Some(dir),
718 binary: None,
719 }
720 }
721
722 #[test]
723 fn provisioning_requires_an_absolute_default_root() {
724 const CHILD: &str = "MICROVMS_TEST_STATE_ROOT_CHILD";
725 if std::env::var_os(CHILD).is_some() {
726 struct Unused;
727 impl Fetch for Unused {
728 fn fetch(
729 &self,
730 _: &str,
731 _: &Path,
732 _: &mut dyn FnMut(&str),
733 ) -> Result<Verification, String> {
734 panic!("an unresolved default root must not start a fetch");
735 }
736 }
737 let request = Request {
738 binary: None,
739 state_dir: None,
740 version: None,
741 };
742 let error = resolve(
743 &request,
744 &|key| matches!(key, "HOME" | "USERPROFILE").then(|| "relative-home".to_string()),
745 &Unused,
746 &mut |_| {},
747 )
748 .expect_err("relative default root");
749 assert!(error.message.contains("absolute user home"), "{error}");
750 assert!(!Path::new("relative-home").exists());
751 return;
752 }
753 let directory = tempfile::tempdir().expect("scratch directory");
756 let output = std::process::Command::new(std::env::current_exe().expect("test binary"))
757 .args([
758 "--exact",
759 "provision::tests::provisioning_requires_an_absolute_default_root",
760 ])
761 .env(CHILD, "1")
762 .current_dir(directory.path())
763 .output()
764 .expect("child test");
765 assert!(
766 output.status.success(),
767 "{}",
768 String::from_utf8_lossy(&output.stdout)
769 );
770 }
771
772 #[test]
775 fn a_cache_miss_fetches_once_and_the_next_request_reads_the_cache() {
776 let dir = tempfile::tempdir().expect("a temp dir");
777 let fetch = Scripted::elf();
778 let first = resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("resolves");
779 assert_eq!(first.source, Source::Fetched(Verification::Attestation));
780 assert_eq!(fetch.calls.get(), 1);
781 assert_eq!(first.bytes, std::fs::read(&first.path).expect("reads"));
782 #[cfg(unix)]
783 {
784 use std::os::unix::fs::PermissionsExt as _;
785 let mode = std::fs::metadata(&first.path)
786 .expect("stat")
787 .permissions()
788 .mode();
789 assert_eq!(
790 mode & 0o111,
791 0o111,
792 "the installed binary must be executable"
793 );
794 }
795
796 let second = resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("resolves");
797 assert_eq!(second.source, Source::Cache(Verification::Attestation));
798 assert_eq!(fetch.calls.get(), 1, "a cache hit must not fetch again");
799 assert_eq!(second.path, first.path);
800 assert_eq!(second.sha256, first.sha256);
801 }
802
803 #[test]
805 fn the_version_defaults_to_the_cores_own_and_accepts_a_leading_v() {
806 let dir = tempfile::tempdir().expect("a temp dir");
807 let fetch = Scripted::elf();
808 let default = Request {
809 state_dir: Some(dir.path()),
810 ..Request::default()
811 };
812 let served = resolve(&default, &no_env, &fetch, &mut |_| {}).expect("resolves");
813 assert_eq!(served.version, crate::VERSION);
814 assert_eq!(served.path, cache_path(dir.path(), crate::VERSION));
815 let tagged = format!("v{}", crate::VERSION);
816 let again = Request {
817 version: Some(&tagged),
818 ..default
819 };
820 let hit = resolve(&again, &no_env, &Panicking, &mut |_| {}).expect("resolves");
821 assert_eq!(hit.source.as_str(), "cache");
822 }
823
824 #[test]
827 fn a_caller_binary_outranks_the_environment_and_the_cache() {
828 let dir = tempfile::tempdir().expect("a temp dir");
829 resolve(&request(dir.path()), &no_env, &Scripted::elf(), &mut |_| {}).expect("caches");
830 let own = dir.path().join("my-agentd");
831 let mut bytes = elf_header(REQUIRED_ELF_MACHINE);
832 bytes.extend_from_slice(b"caller-managed");
833 std::fs::write(&own, &bytes).expect("writes");
834 let env_path = dir.path().join("env-agentd");
835 std::fs::write(&env_path, elf_header(REQUIRED_ELF_MACHINE)).expect("writes");
836 let env_str = env_path.display().to_string();
837 let env = move |name: &str| (name == ENV_OVERRIDE).then(|| env_str.clone());
838
839 let from_env = resolve(&request(dir.path()), &env, &Panicking, &mut |_| {}).expect("env");
840 assert_eq!(from_env.source, Source::CallerSupplied(Supplier::Env));
841 assert_eq!(from_env.path, env_path);
842 assert_eq!(from_env.verification(), None);
843
844 let with_binary = Request {
845 binary: Some(&own),
846 ..request(dir.path())
847 };
848 let from_arg = resolve(&with_binary, &env, &Panicking, &mut |_| {}).expect("argument");
849 assert_eq!(from_arg.source, Source::CallerSupplied(Supplier::Argument));
850 assert_eq!(from_arg.bytes, bytes);
851 }
852
853 #[test]
856 fn a_stale_environment_override_is_an_error_rather_than_a_fallthrough() {
857 let dir = tempfile::tempdir().expect("a temp dir");
858 let env = |name: &str| (name == ENV_OVERRIDE).then(|| "/definitely/not/here".to_string());
859 let failure = resolve(&request(dir.path()), &env, &Panicking, &mut |_| {})
860 .expect_err("a stale override must refuse");
861 assert_eq!(failure.failure, Failure::CallerBinary(Supplier::Env));
862 assert_eq!(failure.kind(), ErrorKind::Precondition);
863 assert!(
864 failure.message.contains(ENV_OVERRIDE),
865 "{}",
866 failure.message
867 );
868 assert!(failure.remedies.iter().any(|r| r.contains("unset")));
869 }
870
871 #[test]
874 fn a_caller_binary_that_is_not_aarch64_is_refused() {
875 let dir = tempfile::tempdir().expect("a temp dir");
876 let x86 = dir.path().join("x86");
877 std::fs::write(&x86, elf_header(0x3E)).expect("writes");
878 let script = dir.path().join("script");
879 std::fs::write(&script, b"#!/bin/sh\n").expect("writes");
880 for (path, detail) in [(&x86, "ELF machine 0x3e"), (&script, "not an ELF")] {
881 let with_binary = Request {
882 binary: Some(path),
883 ..request(dir.path())
884 };
885 let failure =
886 resolve(&with_binary, &no_env, &Panicking, &mut |_| {}).expect_err("must refuse");
887 assert_eq!(failure.failure, Failure::NotAarch64);
888 assert!(failure.message.contains(detail), "{}", failure.message);
889 }
890 let x86_str = x86.display().to_string();
891 let env = move |name: &str| (name == ENV_OVERRIDE).then(|| x86_str.clone());
892 let failure =
893 resolve(&request(dir.path()), &env, &Panicking, &mut |_| {}).expect_err("must refuse");
894 assert!(
895 failure.message.contains(ENV_OVERRIDE),
896 "{}",
897 failure.message
898 );
899 }
900
901 #[test]
905 fn a_fetched_non_arm_binary_is_refused_and_not_cached() {
906 let dir = tempfile::tempdir().expect("a temp dir");
907 let failure = resolve(
908 &request(dir.path()),
909 &no_env,
910 &Scripted::with(elf_header(0x3E)),
911 &mut |_| {},
912 )
913 .expect_err("an x86 asset must refuse");
914 assert!(failure.message.contains("0x3e"), "{}", failure.message);
915 assert!(
916 !cache_path(dir.path(), "9.9.9").exists(),
917 "nothing may be cached"
918 );
919
920 let failure = resolve(
921 &request(dir.path()),
922 &no_env,
923 &Scripted::with(b"#!/bin/sh".to_vec()),
924 &mut |_| {},
925 )
926 .expect_err("a non-ELF asset must refuse");
927 assert!(
928 failure.message.contains("not an ELF"),
929 "{}",
930 failure.message
931 );
932 let left: Vec<_> = std::fs::read_dir(cache_path(dir.path(), "9.9.9").parent().unwrap())
933 .expect("the version dir exists")
934 .collect();
935 assert!(left.is_empty(), "no partial file may survive: {left:?}");
936 }
937
938 #[test]
940 fn a_failed_fetch_names_the_tag_and_every_way_out() {
941 let dir = tempfile::tempdir().expect("a temp dir");
942 let failure =
943 resolve(&request(dir.path()), &no_env, &Failing, &mut |_| {}).expect_err("no network");
944 assert_eq!(failure.failure, Failure::Fetch);
945 assert!(failure.message.contains("v9.9.9"), "{}", failure.message);
946 let remedies = failure.remedies.join("\n");
947 assert!(
948 remedies.contains("gh release download v9.9.9"),
949 "{remedies}"
950 );
951 assert!(remedies.contains("--cert-identity https://github.com/laithalsaadoon/microvms-agentd/.github/workflows/release.yml@refs/tags/v9.9.9"), "{remedies}");
952 assert!(remedies.contains(ENV_OVERRIDE), "{remedies}");
953 assert!(
954 remedies.contains("cargo build --release -p agentd"),
955 "{remedies}"
956 );
957 let error = Error::from(failure);
959 assert_eq!(error.code(), "ERR_PRECONDITION");
960 assert!(error.to_string().contains("gh release download"), "{error}");
961 }
962
963 #[test]
966 fn a_changed_or_unrecorded_cache_entry_is_fetched_again() {
967 let dir = tempfile::tempdir().expect("a temp dir");
968 let fetch = Scripted::elf();
969 resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("caches");
970 let cached = cache_path(dir.path(), "9.9.9");
971 let mut changed = elf_header(REQUIRED_ELF_MACHINE);
972 changed.extend_from_slice(b"changed");
973 std::fs::write(&cached, &changed).expect("overwrites");
974 let mut lines = Vec::new();
975 let again = resolve(&request(dir.path()), &no_env, &fetch, &mut |line| {
976 lines.push(line.to_string())
977 })
978 .expect("refetches");
979 assert_eq!(again.source.as_str(), "fetched");
980 assert_eq!(fetch.calls.get(), 2);
981 assert_eq!(again.bytes, fetch.bytes);
982 assert!(
983 lines.iter().any(|line| line.contains("does not match")),
984 "{lines:?}"
985 );
986
987 std::fs::remove_file(record_path(dir.path(), "9.9.9")).expect("removes the record");
988 let unrecorded =
989 resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("refetches");
990 assert_eq!(unrecorded.source.as_str(), "fetched");
991 assert_eq!(fetch.calls.get(), 3);
992 }
993
994 #[test]
996 fn a_record_for_another_version_does_not_vouch_for_this_one() {
997 let dir = tempfile::tempdir().expect("a temp dir");
998 let fetch = Scripted::elf();
999 resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("caches");
1000 let digest = sha256_hex(&fetch.bytes);
1001 let record = record_path(dir.path(), "9.9.9");
1002 std::fs::write(
1003 &record,
1004 record_json("1.0.0", &digest, Verification::Attestation),
1005 )
1006 .expect("writes");
1007 resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("refetches");
1008 assert_eq!(fetch.calls.get(), 2);
1009 std::fs::write(&record, "{not json").expect("writes");
1010 resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("refetches");
1011 assert_eq!(fetch.calls.get(), 3);
1012 }
1013
1014 #[test]
1016 fn a_version_that_is_not_a_tag_is_refused() {
1017 for bad in [
1018 "", "v", "../x", "1.0/../x", ".hidden", "-rf", "1..2", "a b", "1\n",
1019 ] {
1020 let failure = normalize_version(bad).expect_err(bad);
1021 assert_eq!(failure.kind(), ErrorKind::InvalidArg, "{bad:?}");
1022 }
1023 assert_eq!(normalize_version("v0.9.0").unwrap(), "0.9.0");
1024 assert_eq!(
1025 normalize_version("1.2.3-rc.1+build").unwrap(),
1026 "1.2.3-rc.1+build"
1027 );
1028 }
1029}