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