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 installed_or_identical(
397 renamed: std::io::Result<()>,
398 target: &Path,
399 expected: &[u8],
400) -> std::io::Result<()> {
401 let Err(error) = renamed else {
402 return Ok(());
403 };
404 match std::fs::read(target) {
405 Ok(present) if present == expected => Ok(()),
406 _ => Err(error),
407 }
408}
409
410fn io_error(path: &Path, verb: &str, error: &std::io::Error) -> ProvisionError {
412 ProvisionError::new(
413 Failure::Io,
414 format!("could not {verb} {}: {error}", path.display()),
415 )
416 .remedy("the failure is on this machine's filesystem; the platform was not involved")
417}
418
419fn fetch_error(tag: &str, reason: &str) -> ProvisionError {
423 ProvisionError::new(
424 Failure::Fetch,
425 format!("could not provision the agentd daemon binary for {tag}: {reason}"),
426 )
427 .remedy(format!(
428 "manual download: `gh release download {tag} --repo {RELEASE_REPO} --pattern {ASSET}` \
429 (then `gh attestation verify {ASSET} --repo {RELEASE_REPO} \
430 --cert-identity https://github.com/{RELEASE_REPO}/.github/workflows/release.yml@refs/tags/{tag}`), and pass its path as \
431 the binary or ${ENV_OVERRIDE}"
432 ))
433 .remedy(
434 "a self-built daemon works too: cargo build --release -p agentd --target \
435 aarch64-unknown-linux-musl",
436 )
437}
438
439pub fn resolve(
445 request: &Request<'_>,
446 env: &dyn Fn(&str) -> Option<String>,
447 fetch: &dyn Fetch,
448 progress: &mut dyn FnMut(&str),
449) -> Result<Provisioned, ProvisionError> {
450 let version = normalize_version(request.version.unwrap_or(crate::VERSION))?;
451
452 let supplied = match request.binary {
455 Some(path) => Some((path.to_path_buf(), Supplier::Argument)),
456 None => env(ENV_OVERRIDE).map(|path| (PathBuf::from(path), Supplier::Env)),
457 };
458 if let Some((path, supplier)) = supplied {
459 return caller_supplied(path, supplier, version, progress);
460 }
461
462 let state_dir = match request.state_dir {
463 Some(dir) => dir.to_path_buf(),
464 None => crate::names::state_root(env)
465 .map_err(|error| ProvisionError::new(Failure::Io, error.to_string()))?,
466 };
467 let cached = cache_path(&state_dir, &version);
468 if let Some(hit) = cache_hit(&state_dir, &cached, &version, progress)? {
469 return Ok(hit);
470 }
471 fetched(&state_dir, &cached, version, fetch, progress)
472}
473
474fn caller_supplied(
475 path: PathBuf,
476 supplier: Supplier,
477 version: String,
478 progress: &mut dyn FnMut(&str),
479) -> Result<Provisioned, ProvisionError> {
480 let named = match supplier {
481 Supplier::Argument => format!("the agentd binary {}", path.display()),
482 Supplier::Env => format!("${ENV_OVERRIDE} names {}, which", path.display()),
483 };
484 let refuse = |failure: Failure, detail: String| {
485 let error = ProvisionError::new(failure, format!("{named} {detail}"));
486 match supplier {
487 Supplier::Argument => error,
488 Supplier::Env => error
489 .remedy(format!(
490 "unset {ENV_OVERRIDE} to provision the release asset instead"
491 ))
492 .remedy("or point it at a real aarch64 agentd binary"),
493 }
494 };
495 if !path.exists() {
496 return Err(refuse(
497 Failure::CallerBinary(supplier),
498 "does not exist. A caller-supplied binary short-circuits provisioning, so a \
499 stale path blocks the fetch that would otherwise have worked."
500 .to_string(),
501 ));
502 }
503 let bytes = std::fs::read(&path).map_err(|error| {
504 refuse(
505 Failure::CallerBinary(supplier),
506 format!("could not be read: {error}"),
507 )
508 })?;
509 if let Some(why) = not_aarch64(&bytes) {
510 return Err(refuse(
511 Failure::NotAarch64,
512 format!(
513 "is {why}. MicroVMs are ARM64-only, and a wrong-architecture daemon fails as \
514 a run-hook timeout 45 minutes into a build."
515 ),
516 )
517 .remedy("build one: cargo build --release -p agentd --target aarch64-unknown-linux-musl"));
518 }
519 match supplier {
520 Supplier::Argument => progress(&format!("using the caller's agentd: {}", path.display())),
521 Supplier::Env => progress(&format!("using ${ENV_OVERRIDE}: {}", path.display())),
522 }
523 Ok(Provisioned {
524 sha256: sha256_hex(&bytes),
525 bytes,
526 path,
527 source: Source::CallerSupplied(supplier),
528 version,
529 })
530}
531
532fn cache_hit(
535 state_dir: &Path,
536 cached: &Path,
537 version: &str,
538 progress: &mut dyn FnMut(&str),
539) -> Result<Option<Provisioned>, ProvisionError> {
540 let bytes = match std::fs::read(cached) {
541 Ok(bytes) => bytes,
542 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
543 Err(error) => return Err(io_error(cached, "read", &error)),
544 };
545 let sha256 = sha256_hex(&bytes);
546 match recorded(state_dir, version, &sha256) {
547 Some(verification) if not_aarch64(&bytes).is_none() => {
548 progress(&format!(
549 "using cached agentd v{version}: {}",
550 cached.display()
551 ));
552 Ok(Some(Provisioned {
553 bytes,
554 path: cached.to_path_buf(),
555 source: Source::Cache(verification),
556 version: version.to_string(),
557 sha256,
558 }))
559 }
560 _ => {
561 progress(&format!(
562 "cached agentd v{version} at {} does not match the digest recorded when it \
563 was verified; ignoring it and fetching the release again",
564 cached.display()
565 ));
566 Ok(None)
570 }
571 }
572}
573
574fn fetched(
575 state_dir: &Path,
576 cached: &Path,
577 version: String,
578 fetch: &dyn Fetch,
579 progress: &mut dyn FnMut(&str),
580) -> Result<Provisioned, ProvisionError> {
581 let tag = format!("v{version}");
582 progress(&format!(
583 "no agentd given; fetching the release asset for {tag}"
584 ));
585 let dir = cached.parent().expect("the cache path has a parent");
586 std::fs::create_dir_all(dir).map_err(|error| io_error(dir, "create", &error))?;
587 let download = tempfile::NamedTempFile::new_in(dir)
592 .map_err(|error| io_error(dir, "create temporary download in", &error))?
593 .into_temp_path();
594 let Fetched {
595 bytes,
596 verification,
597 } = fetch
598 .fetch_bytes(&tag, &download, progress)
599 .map_err(|reason| fetch_error(&tag, &reason))?;
600 if let Some(why) = not_aarch64(&bytes) {
601 return Err(ProvisionError::new(
602 Failure::NotAarch64,
603 format!(
604 "the fetched {tag} asset is {why}, so it was discarded rather than cached: a \
605 wrong-architecture daemon fails as a run-hook timeout 45 minutes into a build."
606 ),
607 )
608 .remedy("this is a release-asset defect worth reporting, not a local mistake"));
609 }
610
611 drop(download);
614 let mut partial = tempfile::NamedTempFile::new_in(dir)
615 .map_err(|error| io_error(dir, "create temporary binary in", &error))?;
616 partial
617 .as_file_mut()
618 .set_len(0)
619 .and_then(|()| partial.write_all(&bytes))
620 .map_err(|error| io_error(partial.path(), "write", &error))?;
621 #[cfg(unix)]
622 {
623 use std::os::unix::fs::PermissionsExt as _;
624 partial
625 .as_file()
626 .set_permissions(std::fs::Permissions::from_mode(0o755))
627 .map_err(|error| io_error(partial.path(), "chmod", &error))?;
628 }
629 let installed = partial
630 .into_temp_path()
631 .persist(cached)
632 .map_err(|error| error.error);
633 installed_or_identical(installed, cached, &bytes)
634 .map_err(|error| io_error(cached, "install", &error))?;
635 let sha256 = sha256_hex(&bytes);
638 let record = record_path(state_dir, &version);
639 let record_body = record_json(&version, &sha256, verification);
640 let mut record_partial = tempfile::NamedTempFile::new_in(dir)
641 .map_err(|error| io_error(dir, "create temporary record in", &error))?;
642 record_partial
643 .write_all(record_body.as_bytes())
644 .map_err(|error| io_error(&record, "write record", &error))?;
645 let recorded = record_partial
646 .into_temp_path()
647 .persist(&record)
648 .map_err(|error| error.error);
649 installed_or_identical(recorded, &record, record_body.as_bytes())
650 .map_err(|error| io_error(&record, "record", &error))?;
651 progress(&format!(
652 "fetched and verified agentd {tag} ({}); cached at {}",
653 verification.as_str(),
654 cached.display()
655 ));
656 Ok(Provisioned {
657 bytes,
658 path: cached.to_path_buf(),
659 source: Source::Fetched(verification),
660 version,
661 sha256,
662 })
663}
664
665pub fn agentd(version: Option<&str>, state_dir: Option<&Path>) -> Result<Provisioned, Error> {
671 agentd_with(&Request {
672 version,
673 state_dir,
674 binary: None,
675 })
676}
677
678pub fn agentd_with(request: &Request<'_>) -> Result<Provisioned, Error> {
680 let env = &crate::env::process;
681 resolve(request, env, &HttpsFetch::from_env(env), &mut |_| {}).map_err(Error::from)
682}
683
684#[cfg(test)]
685mod tests {
686 use super::*;
687 use std::cell::Cell;
688
689 #[test]
696 fn a_rename_refused_after_an_identical_install_still_installs() {
697 let dir = tempfile::tempdir().expect("a temporary directory");
698 let target = dir.path().join("agentd");
699 let refused = || Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied));
700 std::fs::write(&target, b"verified").expect("writes");
701 installed_or_identical(refused(), &target, b"verified")
702 .expect("identical bytes are installed");
703 let other = installed_or_identical(refused(), &target, b"different")
704 .expect_err("other bytes keep the refusal");
705 assert_eq!(other.kind(), std::io::ErrorKind::PermissionDenied);
706 std::fs::remove_file(&target).expect("removes");
707 let missing = installed_or_identical(refused(), &target, b"verified")
708 .expect_err("a missing target keeps the refusal");
709 assert_eq!(missing.kind(), std::io::ErrorKind::PermissionDenied);
710 installed_or_identical(Ok(()), &target, b"verified")
711 .expect("a rename that succeeded is installed");
712 }
713
714 struct Scripted {
716 bytes: Vec<u8>,
717 verification: Verification,
718 calls: Cell<usize>,
719 }
720
721 impl Scripted {
722 fn elf() -> Self {
723 Self::with(elf_header(REQUIRED_ELF_MACHINE))
724 }
725
726 fn with(bytes: Vec<u8>) -> Self {
727 Self {
728 bytes,
729 verification: Verification::Attestation,
730 calls: Cell::new(0),
731 }
732 }
733 }
734
735 impl Fetch for Scripted {
736 fn fetch(
737 &self,
738 _: &str,
739 dest: &Path,
740 _: &mut dyn FnMut(&str),
741 ) -> Result<Verification, String> {
742 self.calls.set(self.calls.get() + 1);
743 std::fs::write(dest, &self.bytes).expect("writes");
744 Ok(self.verification)
745 }
746 }
747
748 struct Failing;
750
751 impl Fetch for Failing {
752 fn fetch(
753 &self,
754 _: &str,
755 _: &Path,
756 _: &mut dyn FnMut(&str),
757 ) -> Result<Verification, String> {
758 Err("no network in tests".into())
759 }
760 }
761
762 struct Panicking;
764
765 impl Fetch for Panicking {
766 fn fetch(
767 &self,
768 tag: &str,
769 _: &Path,
770 _: &mut dyn FnMut(&str),
771 ) -> Result<Verification, String> {
772 panic!("this request must not fetch (asked for {tag})");
773 }
774 }
775
776 fn elf_header(machine: u16) -> Vec<u8> {
778 let mut header = vec![0u8; 20];
779 header[..4].copy_from_slice(b"\x7fELF");
780 header[5] = 1;
781 header[18..20].copy_from_slice(&machine.to_le_bytes());
782 header
783 }
784
785 fn no_env(_: &str) -> Option<String> {
786 None
787 }
788
789 fn request(dir: &Path) -> Request<'_> {
790 Request {
791 version: Some("9.9.9"),
792 state_dir: Some(dir),
793 binary: None,
794 }
795 }
796
797 #[test]
798 fn provisioning_requires_an_absolute_default_root() {
799 const CHILD: &str = "MICROVMS_TEST_STATE_ROOT_CHILD";
800 if std::env::var_os(CHILD).is_some() {
801 struct Unused;
802 impl Fetch for Unused {
803 fn fetch(
804 &self,
805 _: &str,
806 _: &Path,
807 _: &mut dyn FnMut(&str),
808 ) -> Result<Verification, String> {
809 panic!("an unresolved default root must not start a fetch");
810 }
811 }
812 let request = Request {
813 binary: None,
814 state_dir: None,
815 version: None,
816 };
817 let error = resolve(
818 &request,
819 &|key| matches!(key, "HOME" | "USERPROFILE").then(|| "relative-home".to_string()),
820 &Unused,
821 &mut |_| {},
822 )
823 .expect_err("relative default root");
824 assert!(error.message.contains("absolute user home"), "{error}");
825 assert!(!Path::new("relative-home").exists());
826 return;
827 }
828 let directory = tempfile::tempdir().expect("scratch directory");
831 let output = std::process::Command::new(std::env::current_exe().expect("test binary"))
832 .args([
833 "--exact",
834 "provision::tests::provisioning_requires_an_absolute_default_root",
835 ])
836 .env(CHILD, "1")
837 .current_dir(directory.path())
838 .output()
839 .expect("child test");
840 assert!(
841 output.status.success(),
842 "{}",
843 String::from_utf8_lossy(&output.stdout)
844 );
845 }
846
847 #[test]
850 fn a_cache_miss_fetches_once_and_the_next_request_reads_the_cache() {
851 let dir = tempfile::tempdir().expect("a temp dir");
852 let fetch = Scripted::elf();
853 let first = resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("resolves");
854 assert_eq!(first.source, Source::Fetched(Verification::Attestation));
855 assert_eq!(fetch.calls.get(), 1);
856 assert_eq!(first.bytes, std::fs::read(&first.path).expect("reads"));
857 #[cfg(unix)]
858 {
859 use std::os::unix::fs::PermissionsExt as _;
860 let mode = std::fs::metadata(&first.path)
861 .expect("stat")
862 .permissions()
863 .mode();
864 assert_eq!(
865 mode & 0o111,
866 0o111,
867 "the installed binary must be executable"
868 );
869 }
870
871 let second = resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("resolves");
872 assert_eq!(second.source, Source::Cache(Verification::Attestation));
873 assert_eq!(fetch.calls.get(), 1, "a cache hit must not fetch again");
874 assert_eq!(second.path, first.path);
875 assert_eq!(second.sha256, first.sha256);
876 }
877
878 #[test]
880 fn the_version_defaults_to_the_cores_own_and_accepts_a_leading_v() {
881 let dir = tempfile::tempdir().expect("a temp dir");
882 let fetch = Scripted::elf();
883 let default = Request {
884 state_dir: Some(dir.path()),
885 ..Request::default()
886 };
887 let served = resolve(&default, &no_env, &fetch, &mut |_| {}).expect("resolves");
888 assert_eq!(served.version, crate::VERSION);
889 assert_eq!(served.path, cache_path(dir.path(), crate::VERSION));
890 let tagged = format!("v{}", crate::VERSION);
891 let again = Request {
892 version: Some(&tagged),
893 ..default
894 };
895 let hit = resolve(&again, &no_env, &Panicking, &mut |_| {}).expect("resolves");
896 assert_eq!(hit.source.as_str(), "cache");
897 }
898
899 #[test]
902 fn a_caller_binary_outranks_the_environment_and_the_cache() {
903 let dir = tempfile::tempdir().expect("a temp dir");
904 resolve(&request(dir.path()), &no_env, &Scripted::elf(), &mut |_| {}).expect("caches");
905 let own = dir.path().join("my-agentd");
906 let mut bytes = elf_header(REQUIRED_ELF_MACHINE);
907 bytes.extend_from_slice(b"caller-managed");
908 std::fs::write(&own, &bytes).expect("writes");
909 let env_path = dir.path().join("env-agentd");
910 std::fs::write(&env_path, elf_header(REQUIRED_ELF_MACHINE)).expect("writes");
911 let env_str = env_path.display().to_string();
912 let env = move |name: &str| (name == ENV_OVERRIDE).then(|| env_str.clone());
913
914 let from_env = resolve(&request(dir.path()), &env, &Panicking, &mut |_| {}).expect("env");
915 assert_eq!(from_env.source, Source::CallerSupplied(Supplier::Env));
916 assert_eq!(from_env.path, env_path);
917 assert_eq!(from_env.verification(), None);
918
919 let with_binary = Request {
920 binary: Some(&own),
921 ..request(dir.path())
922 };
923 let from_arg = resolve(&with_binary, &env, &Panicking, &mut |_| {}).expect("argument");
924 assert_eq!(from_arg.source, Source::CallerSupplied(Supplier::Argument));
925 assert_eq!(from_arg.bytes, bytes);
926 }
927
928 #[test]
931 fn a_stale_environment_override_is_an_error_rather_than_a_fallthrough() {
932 let dir = tempfile::tempdir().expect("a temp dir");
933 let env = |name: &str| (name == ENV_OVERRIDE).then(|| "/definitely/not/here".to_string());
934 let failure = resolve(&request(dir.path()), &env, &Panicking, &mut |_| {})
935 .expect_err("a stale override must refuse");
936 assert_eq!(failure.failure, Failure::CallerBinary(Supplier::Env));
937 assert_eq!(failure.kind(), ErrorKind::Precondition);
938 assert!(
939 failure.message.contains(ENV_OVERRIDE),
940 "{}",
941 failure.message
942 );
943 assert!(failure.remedies.iter().any(|r| r.contains("unset")));
944 }
945
946 #[test]
949 fn a_caller_binary_that_is_not_aarch64_is_refused() {
950 let dir = tempfile::tempdir().expect("a temp dir");
951 let x86 = dir.path().join("x86");
952 std::fs::write(&x86, elf_header(0x3E)).expect("writes");
953 let script = dir.path().join("script");
954 std::fs::write(&script, b"#!/bin/sh\n").expect("writes");
955 for (path, detail) in [(&x86, "ELF machine 0x3e"), (&script, "not an ELF")] {
956 let with_binary = Request {
957 binary: Some(path),
958 ..request(dir.path())
959 };
960 let failure =
961 resolve(&with_binary, &no_env, &Panicking, &mut |_| {}).expect_err("must refuse");
962 assert_eq!(failure.failure, Failure::NotAarch64);
963 assert!(failure.message.contains(detail), "{}", failure.message);
964 }
965 let x86_str = x86.display().to_string();
966 let env = move |name: &str| (name == ENV_OVERRIDE).then(|| x86_str.clone());
967 let failure =
968 resolve(&request(dir.path()), &env, &Panicking, &mut |_| {}).expect_err("must refuse");
969 assert!(
970 failure.message.contains(ENV_OVERRIDE),
971 "{}",
972 failure.message
973 );
974 }
975
976 #[test]
980 fn a_fetched_non_arm_binary_is_refused_and_not_cached() {
981 let dir = tempfile::tempdir().expect("a temp dir");
982 let failure = resolve(
983 &request(dir.path()),
984 &no_env,
985 &Scripted::with(elf_header(0x3E)),
986 &mut |_| {},
987 )
988 .expect_err("an x86 asset must refuse");
989 assert!(failure.message.contains("0x3e"), "{}", failure.message);
990 assert!(
991 !cache_path(dir.path(), "9.9.9").exists(),
992 "nothing may be cached"
993 );
994
995 let failure = resolve(
996 &request(dir.path()),
997 &no_env,
998 &Scripted::with(b"#!/bin/sh".to_vec()),
999 &mut |_| {},
1000 )
1001 .expect_err("a non-ELF asset must refuse");
1002 assert!(
1003 failure.message.contains("not an ELF"),
1004 "{}",
1005 failure.message
1006 );
1007 let left: Vec<_> = std::fs::read_dir(cache_path(dir.path(), "9.9.9").parent().unwrap())
1008 .expect("the version dir exists")
1009 .collect();
1010 assert!(left.is_empty(), "no partial file may survive: {left:?}");
1011 }
1012
1013 #[test]
1015 fn a_failed_fetch_names_the_tag_and_every_way_out() {
1016 let dir = tempfile::tempdir().expect("a temp dir");
1017 let failure =
1018 resolve(&request(dir.path()), &no_env, &Failing, &mut |_| {}).expect_err("no network");
1019 assert_eq!(failure.failure, Failure::Fetch);
1020 assert!(failure.message.contains("v9.9.9"), "{}", failure.message);
1021 let remedies = failure.remedies.join("\n");
1022 assert!(
1023 remedies.contains("gh release download v9.9.9"),
1024 "{remedies}"
1025 );
1026 assert!(remedies.contains("--cert-identity https://github.com/laithalsaadoon/microvms-agentd/.github/workflows/release.yml@refs/tags/v9.9.9"), "{remedies}");
1027 assert!(remedies.contains(ENV_OVERRIDE), "{remedies}");
1028 assert!(
1029 remedies.contains("cargo build --release -p agentd"),
1030 "{remedies}"
1031 );
1032 let error = Error::from(failure);
1034 assert_eq!(error.code(), "ERR_PRECONDITION");
1035 assert!(error.to_string().contains("gh release download"), "{error}");
1036 }
1037
1038 #[test]
1041 fn a_changed_or_unrecorded_cache_entry_is_fetched_again() {
1042 let dir = tempfile::tempdir().expect("a temp dir");
1043 let fetch = Scripted::elf();
1044 resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("caches");
1045 let cached = cache_path(dir.path(), "9.9.9");
1046 let mut changed = elf_header(REQUIRED_ELF_MACHINE);
1047 changed.extend_from_slice(b"changed");
1048 std::fs::write(&cached, &changed).expect("overwrites");
1049 let mut lines = Vec::new();
1050 let again = resolve(&request(dir.path()), &no_env, &fetch, &mut |line| {
1051 lines.push(line.to_string())
1052 })
1053 .expect("refetches");
1054 assert_eq!(again.source.as_str(), "fetched");
1055 assert_eq!(fetch.calls.get(), 2);
1056 assert_eq!(again.bytes, fetch.bytes);
1057 assert!(
1058 lines.iter().any(|line| line.contains("does not match")),
1059 "{lines:?}"
1060 );
1061
1062 std::fs::remove_file(record_path(dir.path(), "9.9.9")).expect("removes the record");
1063 let unrecorded =
1064 resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("refetches");
1065 assert_eq!(unrecorded.source.as_str(), "fetched");
1066 assert_eq!(fetch.calls.get(), 3);
1067 }
1068
1069 #[test]
1071 fn a_record_for_another_version_does_not_vouch_for_this_one() {
1072 let dir = tempfile::tempdir().expect("a temp dir");
1073 let fetch = Scripted::elf();
1074 resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("caches");
1075 let digest = sha256_hex(&fetch.bytes);
1076 let record = record_path(dir.path(), "9.9.9");
1077 std::fs::write(
1078 &record,
1079 record_json("1.0.0", &digest, Verification::Attestation),
1080 )
1081 .expect("writes");
1082 resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("refetches");
1083 assert_eq!(fetch.calls.get(), 2);
1084 std::fs::write(&record, "{not json").expect("writes");
1085 resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("refetches");
1086 assert_eq!(fetch.calls.get(), 3);
1087 }
1088
1089 #[test]
1091 fn a_version_that_is_not_a_tag_is_refused() {
1092 for bad in [
1093 "", "v", "../x", "1.0/../x", ".hidden", "-rf", "1..2", "a b", "1\n",
1094 ] {
1095 let failure = normalize_version(bad).expect_err(bad);
1096 assert_eq!(failure.kind(), ErrorKind::InvalidArg, "{bad:?}");
1097 }
1098 assert_eq!(normalize_version("v0.9.0").unwrap(), "0.9.0");
1099 assert_eq!(
1100 normalize_version("1.2.3-rc.1+build").unwrap(),
1101 "1.2.3-rc.1+build"
1102 );
1103 }
1104}