1use std::fmt;
73use std::path::{Path, PathBuf};
74
75use crate::error::{Error, ErrorKind};
76
77pub const RELEASE_REPO: &str = "laithalsaadoon/microvms-agentd";
79
80pub const ASSET: &str = "agentd";
83
84pub const ENV_OVERRIDE: &str = "MICROVM_AGENTD";
87
88pub const REQUIRED_ELF_MACHINE: u16 = 0xB7;
90
91const RECORD: &str = "agentd.verified.json";
93
94const MAX_VERSION_LEN: usize = 64;
97
98#[derive(Clone, Copy, Debug, PartialEq, Eq)]
100pub enum Verification {
101 Attestation,
104 Checksum,
107}
108
109impl Verification {
110 pub fn as_str(self) -> &'static str {
111 match self {
112 Verification::Attestation => "attestation",
113 Verification::Checksum => "checksum",
114 }
115 }
116
117 fn parse(text: &str) -> Option<Self> {
118 match text {
119 "attestation" => Some(Verification::Attestation),
120 "checksum" => Some(Verification::Checksum),
121 _ => None,
122 }
123 }
124}
125
126#[derive(Clone, Copy, Debug, PartialEq, Eq)]
128pub enum Supplier {
129 Argument,
131 Env,
133}
134
135impl Supplier {
136 pub fn as_str(self) -> &'static str {
137 match self {
138 Supplier::Argument => "argument",
139 Supplier::Env => "env",
140 }
141 }
142}
143
144#[derive(Clone, Copy, Debug, PartialEq, Eq)]
146pub enum Source {
147 CallerSupplied(Supplier),
150 Cache(Verification),
153 Fetched(Verification),
155}
156
157impl Source {
158 pub fn as_str(self) -> &'static str {
160 match self {
161 Source::CallerSupplied(_) => "caller-supplied",
162 Source::Cache(_) => "cache",
163 Source::Fetched(_) => "fetched",
164 }
165 }
166
167 pub fn verification(self) -> Option<Verification> {
170 match self {
171 Source::CallerSupplied(_) => None,
172 Source::Cache(verification) | Source::Fetched(verification) => Some(verification),
173 }
174 }
175}
176
177#[derive(Clone, PartialEq, Eq)]
179pub struct Provisioned {
180 pub bytes: Vec<u8>,
182 pub path: PathBuf,
184 pub source: Source,
185 pub version: String,
188 pub sha256: String,
190}
191
192impl Provisioned {
193 pub fn verification(&self) -> Option<Verification> {
195 self.source.verification()
196 }
197}
198
199impl fmt::Debug for Provisioned {
201 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202 f.debug_struct("Provisioned")
203 .field("bytes", &format_args!("<{} bytes>", self.bytes.len()))
204 .field("path", &self.path)
205 .field("source", &self.source)
206 .field("version", &self.version)
207 .field("sha256", &self.sha256)
208 .finish()
209 }
210}
211
212#[derive(Clone, Copy, Debug, Default)]
215pub struct Request<'a> {
216 pub version: Option<&'a str>,
218 pub state_dir: Option<&'a Path>,
221 pub binary: Option<&'a Path>,
223}
224
225#[derive(Clone, Copy, Debug, PartialEq, Eq)]
227pub enum Failure {
228 InvalidVersion,
230 CallerBinary(Supplier),
232 NotAarch64,
234 Fetch,
236 Io,
238}
239
240#[derive(Clone, Debug, PartialEq, Eq)]
242pub struct ProvisionError {
243 pub failure: Failure,
244 pub message: String,
245 pub remedies: Vec<String>,
246}
247
248impl ProvisionError {
249 fn new(failure: Failure, message: impl Into<String>) -> Self {
250 Self {
251 failure,
252 message: message.into(),
253 remedies: Vec::new(),
254 }
255 }
256
257 fn remedy(mut self, remedy: impl Into<String>) -> Self {
258 self.remedies.push(remedy.into());
259 self
260 }
261
262 pub fn kind(&self) -> ErrorKind {
265 match self.failure {
266 Failure::InvalidVersion => ErrorKind::InvalidArg,
267 _ => ErrorKind::Precondition,
268 }
269 }
270}
271
272impl fmt::Display for ProvisionError {
273 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
274 f.write_str(&self.message)
275 }
276}
277
278impl std::error::Error for ProvisionError {}
279
280impl From<ProvisionError> for Error {
283 fn from(error: ProvisionError) -> Self {
284 let mut message = error.message.clone();
285 for remedy in &error.remedies {
286 message.push_str("\n - ");
287 message.push_str(remedy);
288 }
289 Error::new(error.kind(), message)
290 }
291}
292
293pub trait Fetch {
297 fn fetch(
300 &self,
301 tag: &str,
302 dest: &Path,
303 progress: &mut dyn FnMut(&str),
304 ) -> Result<Verification, String>;
305}
306
307pub trait Runner {
310 fn run(&self, argv: &[String]) -> Result<(), String>;
311}
312
313impl<R: Runner + ?Sized> Runner for &R {
315 fn run(&self, argv: &[String]) -> Result<(), String> {
316 (**self).run(argv)
317 }
318}
319
320#[derive(Clone, Copy, Debug, Default)]
322pub struct Subprocess;
323
324impl Runner for Subprocess {
325 fn run(&self, argv: &[String]) -> Result<(), String> {
326 let output = std::process::Command::new(&argv[0])
327 .args(&argv[1..])
328 .stdin(std::process::Stdio::null())
329 .output()
330 .map_err(|error| format!("`{}` did not run: {error}", argv[0]))?;
331 if output.status.success() {
332 return Ok(());
333 }
334 let detail = String::from_utf8_lossy(&output.stderr);
335 let detail = detail.trim();
336 Err(format!(
337 "`{}` exited {}: {}",
338 argv.join(" "),
339 output.status.code().unwrap_or(-1),
340 if detail.is_empty() {
341 "(no stderr)"
342 } else {
343 detail
344 },
345 ))
346 }
347}
348
349#[derive(Clone, Copy, Debug, Default)]
352pub struct ReleaseFetch<R>(pub R);
353
354impl<R: Runner> Fetch for ReleaseFetch<R> {
355 fn fetch(
356 &self,
357 tag: &str,
358 dest: &Path,
359 progress: &mut dyn FnMut(&str),
360 ) -> Result<Verification, String> {
361 let runner = &self.0;
362 match runner.run(&gh_download_args(tag, dest)) {
366 Ok(()) => {
367 progress(&format!(
368 "downloaded {ASSET} {tag} via gh; verifying provenance"
369 ));
370 return match runner.run(&gh_verify_args(dest)) {
374 Ok(()) => Ok(Verification::Attestation),
375 Err(reason) => {
376 let _ = std::fs::remove_file(dest);
377 Err(format!(
378 "`gh attestation verify` refused the downloaded asset: {reason}. \
379 The bytes were discarded; do not retry with verification off."
380 ))
381 }
382 };
383 }
384 Err(gh_reason) => {
385 progress(&format!("gh could not download ({gh_reason}); trying curl"));
386 }
387 }
388
389 runner
390 .run(&curl_args(&asset_url(tag, ASSET), dest))
391 .map_err(|curl_reason| {
392 format!(
393 "neither tool could download {ASSET} {tag} from {RELEASE_REPO}: gh and \
394 curl both failed, most recently: {curl_reason}"
395 )
396 })?;
397 let sums_dest = dest.with_extension("sums");
400 let sums = runner
401 .run(&curl_args(&asset_url(tag, "SHA256SUMS"), &sums_dest))
402 .and_then(|()| std::fs::read_to_string(&sums_dest).map_err(|error| error.to_string()));
403 let _ = std::fs::remove_file(&sums_dest);
404 let sums = sums.map_err(|reason| {
405 let _ = std::fs::remove_file(dest);
406 format!(
407 "downloaded {ASSET} {tag} via curl, but could not fetch the release's \
408 SHA256SUMS to verify it ({reason}). curl alone proves nothing about the \
409 bytes, so this fails closed. Releases before v0.5.0 ship no SHA256SUMS; \
410 for those, authenticate `gh` and retry, or download and verify manually."
411 )
412 })?;
413 let bytes = std::fs::read(dest).map_err(|error| error.to_string())?;
414 if let Err(reason) = verify_sha256(&sums, ASSET, &bytes) {
415 let _ = std::fs::remove_file(dest);
416 return Err(reason);
417 }
418 progress(&format!(
419 "downloaded {ASSET} {tag} via curl; SHA256SUMS entry matched"
420 ));
421 Ok(Verification::Checksum)
422 }
423}
424
425#[derive(Clone, Copy, Debug, Default)]
427pub struct SubprocessFetch;
428
429impl Fetch for SubprocessFetch {
430 fn fetch(
431 &self,
432 tag: &str,
433 dest: &Path,
434 progress: &mut dyn FnMut(&str),
435 ) -> Result<Verification, String> {
436 ReleaseFetch(Subprocess).fetch(tag, dest, progress)
437 }
438}
439
440fn asset_url(tag: &str, asset: &str) -> String {
443 format!("https://github.com/{RELEASE_REPO}/releases/download/{tag}/{asset}")
444}
445
446fn gh_download_args(tag: &str, dest: &Path) -> Vec<String> {
448 vec![
449 "gh".into(),
450 "release".into(),
451 "download".into(),
452 tag.into(),
453 "--repo".into(),
454 RELEASE_REPO.into(),
455 "--pattern".into(),
456 ASSET.into(),
457 "--output".into(),
458 dest.display().to_string(),
459 "--clobber".into(),
460 ]
461}
462
463fn gh_verify_args(dest: &Path) -> Vec<String> {
465 vec![
466 "gh".into(),
467 "attestation".into(),
468 "verify".into(),
469 dest.display().to_string(),
470 "--repo".into(),
471 RELEASE_REPO.into(),
472 ]
473}
474
475fn curl_args(url: &str, dest: &Path) -> Vec<String> {
478 vec![
479 "curl".into(),
480 "-sSfL".into(),
481 "--proto".into(),
482 "=https".into(),
483 "--max-time".into(),
484 "300".into(),
485 "--output".into(),
486 dest.display().to_string(),
487 url.into(),
488 ]
489}
490
491pub fn verify_sha256(sums: &str, asset: &str, bytes: &[u8]) -> Result<(), String> {
495 let expected = sums
496 .lines()
497 .filter_map(|line| {
498 let mut parts = line.split_whitespace();
499 let digest = parts.next()?;
500 let name = parts.next()?;
501 (name.trim_start_matches('*') == asset).then(|| digest.to_ascii_lowercase())
503 })
504 .next()
505 .ok_or_else(|| {
506 format!(
507 "the release's SHA256SUMS has no entry for {asset}, so nothing to verify against"
508 )
509 })?;
510 let actual = sha256_hex(bytes);
511 if actual == expected {
512 return Ok(());
513 }
514 Err(format!(
515 "SHA256 mismatch for {asset}: the release says {expected}, the download hashed to \
516 {actual}. The bytes were not installed; retry, and if it repeats, treat the \
517 mismatch as the finding rather than the obstacle."
518 ))
519}
520
521pub fn sha256_hex(bytes: &[u8]) -> String {
523 use sha2::{Digest as _, Sha256};
524 const_hex::encode(Sha256::digest(bytes))
525}
526
527pub fn elf_machine(bytes: &[u8]) -> Option<u16> {
534 let header = bytes.get(..20)?;
535 if &header[..4] != b"\x7fELF" {
536 return None;
537 }
538 let field = [header[18], header[19]];
539 Some(if header[5] == 1 {
540 u16::from_le_bytes(field)
541 } else {
542 u16::from_be_bytes(field)
543 })
544}
545
546pub fn not_aarch64(bytes: &[u8]) -> Option<String> {
548 match elf_machine(bytes) {
549 Some(REQUIRED_ELF_MACHINE) => None,
550 Some(machine) => Some(format!(
551 "ELF machine 0x{machine:x}, not aarch64 (0x{REQUIRED_ELF_MACHINE:x})"
552 )),
553 None => Some("not an ELF binary at all".to_string()),
554 }
555}
556
557pub fn normalize_version(version: &str) -> Result<String, ProvisionError> {
563 let bare = version.strip_prefix('v').unwrap_or(version);
564 let plain = !bare.is_empty()
565 && bare.len() <= MAX_VERSION_LEN
566 && bare.as_bytes()[0].is_ascii_alphanumeric()
567 && !bare.contains("..")
568 && bare
569 .bytes()
570 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'+' | b'-'));
571 if plain {
572 return Ok(bare.to_string());
573 }
574 Err(ProvisionError::new(
575 Failure::InvalidVersion,
576 format!(
577 "{version:?} is not a release version: expected a tag such as {} or v{}, made of \
578 letters, digits, `.`, `+`, and `-`",
579 crate::VERSION,
580 crate::VERSION
581 ),
582 )
583 .remedy("omit the version to provision the core's own, which is the one it is proven against"))
584}
585
586pub fn cache_path(state_dir: &Path, version: &str) -> PathBuf {
591 state_dir
592 .join("agentd")
593 .join(format!("v{version}"))
594 .join(ASSET)
595}
596
597fn record_path(state_dir: &Path, version: &str) -> PathBuf {
599 state_dir
600 .join("agentd")
601 .join(format!("v{version}"))
602 .join(RECORD)
603}
604
605pub(crate) fn record_json(version: &str, sha256: &str, verification: Verification) -> String {
607 serde_json::json!({
608 "version": version,
609 "sha256": sha256,
610 "verification": verification.as_str(),
611 })
612 .to_string()
613}
614
615fn recorded(state_dir: &Path, version: &str, sha256: &str) -> Option<Verification> {
618 let text = std::fs::read_to_string(record_path(state_dir, version)).ok()?;
619 parse_record(&text, version, sha256)
620}
621
622pub(crate) fn parse_record(text: &str, version: &str, sha256: &str) -> Option<Verification> {
625 let record: serde_json::Value = serde_json::from_str(text).ok()?;
626 let matches =
627 record["version"].as_str() == Some(version) && record["sha256"].as_str() == Some(sha256);
628 matches
629 .then(|| Verification::parse(record["verification"].as_str()?))
630 .flatten()
631}
632
633fn io_error(path: &Path, verb: &str, error: &std::io::Error) -> ProvisionError {
635 ProvisionError::new(
636 Failure::Io,
637 format!("could not {verb} {}: {error}", path.display()),
638 )
639 .remedy("the failure is on this machine's filesystem; the platform was not involved")
640}
641
642fn fetch_error(tag: &str, reason: &str) -> ProvisionError {
646 ProvisionError::new(
647 Failure::Fetch,
648 format!("could not provision the agentd daemon binary for {tag}: {reason}"),
649 )
650 .remedy(format!(
651 "manual download: `gh release download {tag} --repo {RELEASE_REPO} --pattern {ASSET}` \
652 (then `gh attestation verify {ASSET} --repo {RELEASE_REPO}`), and pass its path as \
653 the binary or ${ENV_OVERRIDE}"
654 ))
655 .remedy(
656 "a self-built daemon works too: cargo build --release -p agentd --target \
657 aarch64-unknown-linux-musl",
658 )
659}
660
661pub fn resolve(
667 request: &Request<'_>,
668 env: &dyn Fn(&str) -> Option<String>,
669 fetch: &dyn Fetch,
670 progress: &mut dyn FnMut(&str),
671) -> Result<Provisioned, ProvisionError> {
672 let version = normalize_version(request.version.unwrap_or(crate::VERSION))?;
673
674 let supplied = match request.binary {
677 Some(path) => Some((path.to_path_buf(), Supplier::Argument)),
678 None => env(ENV_OVERRIDE).map(|path| (PathBuf::from(path), Supplier::Env)),
679 };
680 if let Some((path, supplier)) = supplied {
681 return caller_supplied(path, supplier, version, progress);
682 }
683
684 let state_dir = match request.state_dir {
685 Some(dir) => dir.to_path_buf(),
686 None => crate::names::default_state_root(env),
687 };
688 let cached = cache_path(&state_dir, &version);
689 if let Some(hit) = cache_hit(&state_dir, &cached, &version, progress)? {
690 return Ok(hit);
691 }
692 fetched(&state_dir, &cached, version, fetch, progress)
693}
694
695fn caller_supplied(
696 path: PathBuf,
697 supplier: Supplier,
698 version: String,
699 progress: &mut dyn FnMut(&str),
700) -> Result<Provisioned, ProvisionError> {
701 let named = match supplier {
702 Supplier::Argument => format!("the agentd binary {}", path.display()),
703 Supplier::Env => format!("${ENV_OVERRIDE} names {}, which", path.display()),
704 };
705 let refuse = |failure: Failure, detail: String| {
706 let error = ProvisionError::new(failure, format!("{named} {detail}"));
707 match supplier {
708 Supplier::Argument => error,
709 Supplier::Env => error
710 .remedy(format!(
711 "unset {ENV_OVERRIDE} to provision the release asset instead"
712 ))
713 .remedy("or point it at a real aarch64 agentd binary"),
714 }
715 };
716 if !path.exists() {
717 return Err(refuse(
718 Failure::CallerBinary(supplier),
719 "does not exist. A caller-supplied binary short-circuits provisioning, so a \
720 stale path blocks the fetch that would otherwise have worked."
721 .to_string(),
722 ));
723 }
724 let bytes = std::fs::read(&path).map_err(|error| {
725 refuse(
726 Failure::CallerBinary(supplier),
727 format!("could not be read: {error}"),
728 )
729 })?;
730 if let Some(why) = not_aarch64(&bytes) {
731 return Err(refuse(
732 Failure::NotAarch64,
733 format!(
734 "is {why}. MicroVMs are ARM64-only, and a wrong-architecture daemon fails as \
735 a run-hook timeout 45 minutes into a build."
736 ),
737 )
738 .remedy("build one: cargo build --release -p agentd --target aarch64-unknown-linux-musl"));
739 }
740 match supplier {
741 Supplier::Argument => progress(&format!("using the caller's agentd: {}", path.display())),
742 Supplier::Env => progress(&format!("using ${ENV_OVERRIDE}: {}", path.display())),
743 }
744 Ok(Provisioned {
745 sha256: sha256_hex(&bytes),
746 bytes,
747 path,
748 source: Source::CallerSupplied(supplier),
749 version,
750 })
751}
752
753fn cache_hit(
756 state_dir: &Path,
757 cached: &Path,
758 version: &str,
759 progress: &mut dyn FnMut(&str),
760) -> Result<Option<Provisioned>, ProvisionError> {
761 let bytes = match std::fs::read(cached) {
762 Ok(bytes) => bytes,
763 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
764 Err(error) => return Err(io_error(cached, "read", &error)),
765 };
766 let sha256 = sha256_hex(&bytes);
767 match recorded(state_dir, version, &sha256) {
768 Some(verification) if not_aarch64(&bytes).is_none() => {
769 progress(&format!(
770 "using cached agentd v{version}: {}",
771 cached.display()
772 ));
773 Ok(Some(Provisioned {
774 bytes,
775 path: cached.to_path_buf(),
776 source: Source::Cache(verification),
777 version: version.to_string(),
778 sha256,
779 }))
780 }
781 _ => {
782 progress(&format!(
783 "cached agentd v{version} at {} does not match the digest recorded when it \
784 was verified; discarding it and fetching the release again",
785 cached.display()
786 ));
787 for path in [cached.to_path_buf(), record_path(state_dir, version)] {
788 match std::fs::remove_file(&path) {
789 Ok(()) => {}
790 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
791 Err(error) => return Err(io_error(&path, "discard", &error)),
792 }
793 }
794 Ok(None)
795 }
796 }
797}
798
799fn fetched(
800 state_dir: &Path,
801 cached: &Path,
802 version: String,
803 fetch: &dyn Fetch,
804 progress: &mut dyn FnMut(&str),
805) -> Result<Provisioned, ProvisionError> {
806 let tag = format!("v{version}");
807 progress(&format!(
808 "no agentd given; fetching the release asset for {tag}"
809 ));
810 let dir = cached.parent().expect("the cache path has a parent");
811 std::fs::create_dir_all(dir).map_err(|error| io_error(dir, "create", &error))?;
812 let partial = dir.join(format!(".{ASSET}.partial-{}", std::process::id()));
816 let verification = match fetch.fetch(&tag, &partial, progress) {
817 Ok(verification) => verification,
818 Err(reason) => {
819 let _ = std::fs::remove_file(&partial);
820 return Err(fetch_error(&tag, &reason));
821 }
822 };
823 let bytes = std::fs::read(&partial).map_err(|error| {
824 let _ = std::fs::remove_file(&partial);
825 io_error(&partial, "read", &error)
826 })?;
827 if let Some(why) = not_aarch64(&bytes) {
828 let _ = std::fs::remove_file(&partial);
829 return Err(ProvisionError::new(
830 Failure::NotAarch64,
831 format!(
832 "the fetched {tag} asset is {why}, so it was discarded rather than cached: a \
833 wrong-architecture daemon fails as a run-hook timeout 45 minutes into a build."
834 ),
835 )
836 .remedy("this is a release-asset defect worth reporting, not a local mistake"));
837 }
838
839 #[cfg(unix)]
840 {
841 use std::os::unix::fs::PermissionsExt as _;
842 std::fs::set_permissions(&partial, std::fs::Permissions::from_mode(0o755))
843 .map_err(|error| io_error(&partial, "chmod", &error))?;
844 }
845 std::fs::rename(&partial, cached).map_err(|error| io_error(cached, "install", &error))?;
846 let sha256 = sha256_hex(&bytes);
849 let record = record_path(state_dir, &version);
850 let record_partial = dir.join(format!(".{RECORD}.partial-{}", std::process::id()));
851 std::fs::write(
852 &record_partial,
853 record_json(&version, &sha256, verification),
854 )
855 .and_then(|()| std::fs::rename(&record_partial, &record))
856 .map_err(|error| {
857 let _ = std::fs::remove_file(&record_partial);
858 io_error(&record, "record", &error)
859 })?;
860 progress(&format!(
861 "fetched and verified agentd {tag} ({}); cached at {}",
862 verification.as_str(),
863 cached.display()
864 ));
865 Ok(Provisioned {
866 bytes,
867 path: cached.to_path_buf(),
868 source: Source::Fetched(verification),
869 version,
870 sha256,
871 })
872}
873
874fn process_env(name: &str) -> Option<String> {
876 std::env::var(name).ok()
877}
878
879pub fn agentd(version: Option<&str>, state_dir: Option<&Path>) -> Result<Provisioned, Error> {
885 agentd_with(&Request {
886 version,
887 state_dir,
888 binary: None,
889 })
890}
891
892pub fn agentd_with(request: &Request<'_>) -> Result<Provisioned, Error> {
894 resolve(request, &process_env, &SubprocessFetch, &mut |_| {}).map_err(Error::from)
895}
896
897#[cfg(test)]
898mod tests {
899 use super::*;
900 use std::cell::{Cell, RefCell};
901
902 struct Scripted {
904 bytes: Vec<u8>,
905 verification: Verification,
906 calls: Cell<usize>,
907 }
908
909 impl Scripted {
910 fn elf() -> Self {
911 Self::with(elf_header(REQUIRED_ELF_MACHINE))
912 }
913
914 fn with(bytes: Vec<u8>) -> Self {
915 Self {
916 bytes,
917 verification: Verification::Attestation,
918 calls: Cell::new(0),
919 }
920 }
921 }
922
923 impl Fetch for Scripted {
924 fn fetch(
925 &self,
926 _: &str,
927 dest: &Path,
928 _: &mut dyn FnMut(&str),
929 ) -> Result<Verification, String> {
930 self.calls.set(self.calls.get() + 1);
931 std::fs::write(dest, &self.bytes).expect("writes");
932 Ok(self.verification)
933 }
934 }
935
936 struct Failing;
938
939 impl Fetch for Failing {
940 fn fetch(
941 &self,
942 _: &str,
943 _: &Path,
944 _: &mut dyn FnMut(&str),
945 ) -> Result<Verification, String> {
946 Err("no network in tests".into())
947 }
948 }
949
950 struct Panicking;
952
953 impl Fetch for Panicking {
954 fn fetch(
955 &self,
956 tag: &str,
957 _: &Path,
958 _: &mut dyn FnMut(&str),
959 ) -> Result<Verification, String> {
960 panic!("this request must not fetch (asked for {tag})");
961 }
962 }
963
964 fn elf_header(machine: u16) -> Vec<u8> {
966 let mut header = vec![0u8; 20];
967 header[..4].copy_from_slice(b"\x7fELF");
968 header[5] = 1;
969 header[18..20].copy_from_slice(&machine.to_le_bytes());
970 header
971 }
972
973 fn no_env(_: &str) -> Option<String> {
974 None
975 }
976
977 fn request(dir: &Path) -> Request<'_> {
978 Request {
979 version: Some("9.9.9"),
980 state_dir: Some(dir),
981 binary: None,
982 }
983 }
984
985 #[test]
988 fn a_cache_miss_fetches_once_and_the_next_request_reads_the_cache() {
989 let dir = tempfile::tempdir().expect("a temp dir");
990 let fetch = Scripted::elf();
991 let first = resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("resolves");
992 assert_eq!(first.source, Source::Fetched(Verification::Attestation));
993 assert_eq!(fetch.calls.get(), 1);
994 assert_eq!(first.bytes, std::fs::read(&first.path).expect("reads"));
995 #[cfg(unix)]
996 {
997 use std::os::unix::fs::PermissionsExt as _;
998 let mode = std::fs::metadata(&first.path)
999 .expect("stat")
1000 .permissions()
1001 .mode();
1002 assert_eq!(
1003 mode & 0o111,
1004 0o111,
1005 "the installed binary must be executable"
1006 );
1007 }
1008
1009 let second = resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("resolves");
1010 assert_eq!(second.source, Source::Cache(Verification::Attestation));
1011 assert_eq!(fetch.calls.get(), 1, "a cache hit must not fetch again");
1012 assert_eq!(second.path, first.path);
1013 assert_eq!(second.sha256, first.sha256);
1014 }
1015
1016 #[test]
1018 fn the_version_defaults_to_the_cores_own_and_accepts_a_leading_v() {
1019 let dir = tempfile::tempdir().expect("a temp dir");
1020 let fetch = Scripted::elf();
1021 let default = Request {
1022 state_dir: Some(dir.path()),
1023 ..Request::default()
1024 };
1025 let served = resolve(&default, &no_env, &fetch, &mut |_| {}).expect("resolves");
1026 assert_eq!(served.version, crate::VERSION);
1027 assert_eq!(served.path, cache_path(dir.path(), crate::VERSION));
1028 let tagged = format!("v{}", crate::VERSION);
1029 let again = Request {
1030 version: Some(&tagged),
1031 ..default
1032 };
1033 let hit = resolve(&again, &no_env, &Panicking, &mut |_| {}).expect("resolves");
1034 assert_eq!(hit.source.as_str(), "cache");
1035 }
1036
1037 #[test]
1040 fn a_caller_binary_outranks_the_environment_and_the_cache() {
1041 let dir = tempfile::tempdir().expect("a temp dir");
1042 resolve(&request(dir.path()), &no_env, &Scripted::elf(), &mut |_| {}).expect("caches");
1043 let own = dir.path().join("my-agentd");
1044 let mut bytes = elf_header(REQUIRED_ELF_MACHINE);
1045 bytes.extend_from_slice(b"caller-managed");
1046 std::fs::write(&own, &bytes).expect("writes");
1047 let env_path = dir.path().join("env-agentd");
1048 std::fs::write(&env_path, elf_header(REQUIRED_ELF_MACHINE)).expect("writes");
1049 let env_str = env_path.display().to_string();
1050 let env = move |name: &str| (name == ENV_OVERRIDE).then(|| env_str.clone());
1051
1052 let from_env = resolve(&request(dir.path()), &env, &Panicking, &mut |_| {}).expect("env");
1053 assert_eq!(from_env.source, Source::CallerSupplied(Supplier::Env));
1054 assert_eq!(from_env.path, env_path);
1055 assert_eq!(from_env.verification(), None);
1056
1057 let with_binary = Request {
1058 binary: Some(&own),
1059 ..request(dir.path())
1060 };
1061 let from_arg = resolve(&with_binary, &env, &Panicking, &mut |_| {}).expect("argument");
1062 assert_eq!(from_arg.source, Source::CallerSupplied(Supplier::Argument));
1063 assert_eq!(from_arg.bytes, bytes);
1064 }
1065
1066 #[test]
1069 fn a_stale_environment_override_is_an_error_rather_than_a_fallthrough() {
1070 let dir = tempfile::tempdir().expect("a temp dir");
1071 let env = |name: &str| (name == ENV_OVERRIDE).then(|| "/definitely/not/here".to_string());
1072 let failure = resolve(&request(dir.path()), &env, &Panicking, &mut |_| {})
1073 .expect_err("a stale override must refuse");
1074 assert_eq!(failure.failure, Failure::CallerBinary(Supplier::Env));
1075 assert_eq!(failure.kind(), ErrorKind::Precondition);
1076 assert!(
1077 failure.message.contains(ENV_OVERRIDE),
1078 "{}",
1079 failure.message
1080 );
1081 assert!(failure.remedies.iter().any(|r| r.contains("unset")));
1082 }
1083
1084 #[test]
1087 fn a_caller_binary_that_is_not_aarch64_is_refused() {
1088 let dir = tempfile::tempdir().expect("a temp dir");
1089 let x86 = dir.path().join("x86");
1090 std::fs::write(&x86, elf_header(0x3E)).expect("writes");
1091 let script = dir.path().join("script");
1092 std::fs::write(&script, b"#!/bin/sh\n").expect("writes");
1093 for (path, detail) in [(&x86, "ELF machine 0x3e"), (&script, "not an ELF")] {
1094 let with_binary = Request {
1095 binary: Some(path),
1096 ..request(dir.path())
1097 };
1098 let failure =
1099 resolve(&with_binary, &no_env, &Panicking, &mut |_| {}).expect_err("must refuse");
1100 assert_eq!(failure.failure, Failure::NotAarch64);
1101 assert!(failure.message.contains(detail), "{}", failure.message);
1102 }
1103 let x86_str = x86.display().to_string();
1104 let env = move |name: &str| (name == ENV_OVERRIDE).then(|| x86_str.clone());
1105 let failure =
1106 resolve(&request(dir.path()), &env, &Panicking, &mut |_| {}).expect_err("must refuse");
1107 assert!(
1108 failure.message.contains(ENV_OVERRIDE),
1109 "{}",
1110 failure.message
1111 );
1112 }
1113
1114 #[test]
1118 fn a_fetched_non_arm_binary_is_refused_and_not_cached() {
1119 let dir = tempfile::tempdir().expect("a temp dir");
1120 let failure = resolve(
1121 &request(dir.path()),
1122 &no_env,
1123 &Scripted::with(elf_header(0x3E)),
1124 &mut |_| {},
1125 )
1126 .expect_err("an x86 asset must refuse");
1127 assert!(failure.message.contains("0x3e"), "{}", failure.message);
1128 assert!(
1129 !cache_path(dir.path(), "9.9.9").exists(),
1130 "nothing may be cached"
1131 );
1132
1133 let failure = resolve(
1134 &request(dir.path()),
1135 &no_env,
1136 &Scripted::with(b"#!/bin/sh".to_vec()),
1137 &mut |_| {},
1138 )
1139 .expect_err("a non-ELF asset must refuse");
1140 assert!(
1141 failure.message.contains("not an ELF"),
1142 "{}",
1143 failure.message
1144 );
1145 let left: Vec<_> = std::fs::read_dir(cache_path(dir.path(), "9.9.9").parent().unwrap())
1146 .expect("the version dir exists")
1147 .collect();
1148 assert!(left.is_empty(), "no partial file may survive: {left:?}");
1149 }
1150
1151 #[test]
1153 fn a_failed_fetch_names_the_tag_and_every_way_out() {
1154 let dir = tempfile::tempdir().expect("a temp dir");
1155 let failure =
1156 resolve(&request(dir.path()), &no_env, &Failing, &mut |_| {}).expect_err("no network");
1157 assert_eq!(failure.failure, Failure::Fetch);
1158 assert!(failure.message.contains("v9.9.9"), "{}", failure.message);
1159 let remedies = failure.remedies.join("\n");
1160 assert!(remedies.contains("gh release download"), "{remedies}");
1161 assert!(remedies.contains(ENV_OVERRIDE), "{remedies}");
1162 assert!(
1163 remedies.contains("cargo build --release -p agentd"),
1164 "{remedies}"
1165 );
1166 let error = Error::from(failure);
1168 assert_eq!(error.code(), "ERR_PRECONDITION");
1169 assert!(error.to_string().contains("gh release download"), "{error}");
1170 }
1171
1172 #[test]
1175 fn a_changed_or_unrecorded_cache_entry_is_fetched_again() {
1176 let dir = tempfile::tempdir().expect("a temp dir");
1177 let fetch = Scripted::elf();
1178 resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("caches");
1179 let cached = cache_path(dir.path(), "9.9.9");
1180 let mut changed = elf_header(REQUIRED_ELF_MACHINE);
1181 changed.extend_from_slice(b"changed");
1182 std::fs::write(&cached, &changed).expect("overwrites");
1183 let mut lines = Vec::new();
1184 let again = resolve(&request(dir.path()), &no_env, &fetch, &mut |line| {
1185 lines.push(line.to_string())
1186 })
1187 .expect("refetches");
1188 assert_eq!(again.source.as_str(), "fetched");
1189 assert_eq!(fetch.calls.get(), 2);
1190 assert_eq!(again.bytes, fetch.bytes);
1191 assert!(
1192 lines.iter().any(|line| line.contains("does not match")),
1193 "{lines:?}"
1194 );
1195
1196 std::fs::remove_file(record_path(dir.path(), "9.9.9")).expect("removes the record");
1197 let unrecorded =
1198 resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("refetches");
1199 assert_eq!(unrecorded.source.as_str(), "fetched");
1200 assert_eq!(fetch.calls.get(), 3);
1201 }
1202
1203 #[test]
1205 fn a_record_for_another_version_does_not_vouch_for_this_one() {
1206 let dir = tempfile::tempdir().expect("a temp dir");
1207 let fetch = Scripted::elf();
1208 resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("caches");
1209 let digest = sha256_hex(&fetch.bytes);
1210 let record = record_path(dir.path(), "9.9.9");
1211 std::fs::write(
1212 &record,
1213 record_json("1.0.0", &digest, Verification::Attestation),
1214 )
1215 .expect("writes");
1216 resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("refetches");
1217 assert_eq!(fetch.calls.get(), 2);
1218 std::fs::write(&record, "{not json").expect("writes");
1219 resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("refetches");
1220 assert_eq!(fetch.calls.get(), 3);
1221 }
1222
1223 #[test]
1225 fn a_version_that_is_not_a_tag_is_refused() {
1226 for bad in [
1227 "", "v", "../x", "1.0/../x", ".hidden", "-rf", "1..2", "a b", "1\n",
1228 ] {
1229 let failure = normalize_version(bad).expect_err(bad);
1230 assert_eq!(failure.kind(), ErrorKind::InvalidArg, "{bad:?}");
1231 }
1232 assert_eq!(normalize_version("v0.9.0").unwrap(), "0.9.0");
1233 assert_eq!(
1234 normalize_version("1.2.3-rc.1+build").unwrap(),
1235 "1.2.3-rc.1+build"
1236 );
1237 }
1238
1239 #[test]
1242 fn the_checksum_verification_matches_mismatches_and_refuses_a_missing_entry() {
1243 let digest = sha256_hex(b"agentd-bytes");
1244 let sums = format!("{digest} agentd\nother microvm-x86_64.tar.gz\n");
1245 assert!(verify_sha256(&sums, "agentd", b"agentd-bytes").is_ok());
1246 let starred = format!("{digest} *agentd\n");
1247 assert!(verify_sha256(&starred, "agentd", b"agentd-bytes").is_ok());
1248
1249 let mismatch = verify_sha256(&sums, "agentd", b"tampered").expect_err("must refuse");
1250 assert!(mismatch.contains(&digest), "{mismatch}");
1251 assert!(mismatch.contains("not installed"), "{mismatch}");
1252
1253 let missing =
1254 verify_sha256("abc something-else\n", "agentd", b"x").expect_err("must refuse");
1255 assert!(missing.contains("no entry"), "{missing}");
1256 }
1257
1258 #[test]
1260 fn the_elf_header_is_read_in_its_own_byte_order() {
1261 assert_eq!(elf_machine(&elf_header(0xB7)), Some(0xB7));
1262 let mut big = elf_header(0);
1263 big[5] = 2;
1264 big[18..20].copy_from_slice(&0xB7u16.to_be_bytes());
1265 assert_eq!(elf_machine(&big), Some(0xB7));
1266 assert_eq!(elf_machine(b"#!/bin/sh"), None);
1267 assert_eq!(elf_machine(&elf_header(0xB7)[..19]), None);
1268 assert_eq!(not_aarch64(&elf_header(0xB7)), None);
1269 }
1270
1271 #[test]
1273 fn the_subprocess_argv_matches_the_documented_manual_commands() {
1274 let dest = Path::new("/tmp/agentd");
1275 assert_eq!(
1276 gh_download_args("v0.5.0", dest).join(" "),
1277 "gh release download v0.5.0 --repo laithalsaadoon/microvms-agentd \
1278 --pattern agentd --output /tmp/agentd --clobber"
1279 );
1280 assert_eq!(
1281 gh_verify_args(dest).join(" "),
1282 "gh attestation verify /tmp/agentd --repo laithalsaadoon/microvms-agentd"
1283 );
1284 let curl = curl_args(&asset_url("v0.5.0", ASSET), dest).join(" ");
1285 assert!(
1286 curl.starts_with("curl -sSfL --proto =https --max-time 300"),
1287 "{curl}"
1288 );
1289 assert!(
1290 curl.ends_with(
1291 "https://github.com/laithalsaadoon/microvms-agentd/releases/download/v0.5.0/agentd"
1292 ),
1293 "{curl}"
1294 );
1295 }
1296
1297 struct Table {
1300 gh: bool,
1301 attested: bool,
1302 curl: bool,
1303 sums: Option<bool>,
1305 asset: Vec<u8>,
1306 ran: RefCell<Vec<String>>,
1307 }
1308
1309 impl Runner for Table {
1310 fn run(&self, argv: &[String]) -> Result<(), String> {
1311 self.ran.borrow_mut().push(argv[..2].join(" "));
1312 let output = || {
1313 let at = argv.iter().position(|a| a == "--output").expect("--output");
1314 PathBuf::from(&argv[at + 1])
1315 };
1316 match (argv[0].as_str(), argv[1].as_str()) {
1317 ("gh", "release") if self.gh => {
1318 std::fs::write(output(), &self.asset).map_err(|e| e.to_string())
1319 }
1320 ("gh", "attestation") if self.attested => Ok(()),
1321 ("curl", _) if self.curl => {
1322 let url = argv.last().expect("a url");
1323 if url.ends_with("/agentd") {
1324 return std::fs::write(output(), &self.asset).map_err(|e| e.to_string());
1325 }
1326 let digest = match self.sums {
1327 None => return Err("404".into()),
1328 Some(true) => sha256_hex(&self.asset),
1329 Some(false) => sha256_hex(b"other"),
1330 };
1331 std::fs::write(output(), format!("{digest} agentd\n"))
1332 .map_err(|e| e.to_string())
1333 }
1334 _ => Err(format!("{} refused", argv[0])),
1335 }
1336 }
1337 }
1338
1339 #[test]
1344 fn the_verification_table_matches_the_model() {
1345 type Row = (bool, bool, bool, Option<bool>, Option<Verification>);
1347 let rows: [Row; 10] = [
1348 (
1349 true,
1350 true,
1351 true,
1352 Some(true),
1353 Some(Verification::Attestation),
1354 ),
1355 (true, true, false, None, Some(Verification::Attestation)),
1356 (true, false, true, Some(true), None),
1357 (true, false, false, None, None),
1358 (false, false, true, Some(true), Some(Verification::Checksum)),
1359 (false, false, true, Some(false), None),
1360 (false, false, true, None, None),
1361 (false, false, false, Some(true), None),
1362 (false, true, true, Some(true), Some(Verification::Checksum)),
1363 (false, true, true, None, None),
1364 ];
1365 let dir = tempfile::tempdir().expect("a temp dir");
1366 for (gh, attested, curl, sums, expected) in rows {
1367 let table = Table {
1368 gh,
1369 attested,
1370 curl,
1371 sums,
1372 asset: elf_header(REQUIRED_ELF_MACHINE),
1373 ran: RefCell::new(Vec::new()),
1374 };
1375 let dest = dir.path().join("partial");
1376 let verdict = ReleaseFetch(&table)
1377 .fetch("v9.9.9", &dest, &mut |_| {})
1378 .ok();
1379 let row = (gh, attested, curl, sums);
1380 assert_eq!(verdict, expected, "{row:?}");
1381 if verdict.is_none() {
1382 assert!(!dest.exists(), "a refused fetch left bytes behind: {row:?}");
1383 }
1384 if gh && !attested {
1385 let ran = table.ran.borrow();
1386 assert!(
1387 ran.iter().all(|tool| !tool.starts_with("curl")),
1388 "a refused attestation must not fall through to curl: {ran:?}"
1389 );
1390 }
1391 let _ = std::fs::remove_file(&dest);
1392 }
1393 }
1394}