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}`), and pass its path as \
392 the binary or ${ENV_OVERRIDE}"
393 ))
394 .remedy(
395 "a self-built daemon works too: cargo build --release -p agentd --target \
396 aarch64-unknown-linux-musl",
397 )
398}
399
400pub fn resolve(
406 request: &Request<'_>,
407 env: &dyn Fn(&str) -> Option<String>,
408 fetch: &dyn Fetch,
409 progress: &mut dyn FnMut(&str),
410) -> Result<Provisioned, ProvisionError> {
411 let version = normalize_version(request.version.unwrap_or(crate::VERSION))?;
412
413 let supplied = match request.binary {
416 Some(path) => Some((path.to_path_buf(), Supplier::Argument)),
417 None => env(ENV_OVERRIDE).map(|path| (PathBuf::from(path), Supplier::Env)),
418 };
419 if let Some((path, supplier)) = supplied {
420 return caller_supplied(path, supplier, version, progress);
421 }
422
423 let state_dir = match request.state_dir {
424 Some(dir) => dir.to_path_buf(),
425 None => microvms_app::names::default_state_root(env),
426 };
427 let cached = cache_path(&state_dir, &version);
428 if let Some(hit) = cache_hit(&state_dir, &cached, &version, progress)? {
429 return Ok(hit);
430 }
431 fetched(&state_dir, &cached, version, fetch, progress)
432}
433
434fn caller_supplied(
435 path: PathBuf,
436 supplier: Supplier,
437 version: String,
438 progress: &mut dyn FnMut(&str),
439) -> Result<Provisioned, ProvisionError> {
440 let named = match supplier {
441 Supplier::Argument => format!("the agentd binary {}", path.display()),
442 Supplier::Env => format!("${ENV_OVERRIDE} names {}, which", path.display()),
443 };
444 let refuse = |failure: Failure, detail: String| {
445 let error = ProvisionError::new(failure, format!("{named} {detail}"));
446 match supplier {
447 Supplier::Argument => error,
448 Supplier::Env => error
449 .remedy(format!(
450 "unset {ENV_OVERRIDE} to provision the release asset instead"
451 ))
452 .remedy("or point it at a real aarch64 agentd binary"),
453 }
454 };
455 if !path.exists() {
456 return Err(refuse(
457 Failure::CallerBinary(supplier),
458 "does not exist. A caller-supplied binary short-circuits provisioning, so a \
459 stale path blocks the fetch that would otherwise have worked."
460 .to_string(),
461 ));
462 }
463 let bytes = std::fs::read(&path).map_err(|error| {
464 refuse(
465 Failure::CallerBinary(supplier),
466 format!("could not be read: {error}"),
467 )
468 })?;
469 if let Some(why) = not_aarch64(&bytes) {
470 return Err(refuse(
471 Failure::NotAarch64,
472 format!(
473 "is {why}. MicroVMs are ARM64-only, and a wrong-architecture daemon fails as \
474 a run-hook timeout 45 minutes into a build."
475 ),
476 )
477 .remedy("build one: cargo build --release -p agentd --target aarch64-unknown-linux-musl"));
478 }
479 match supplier {
480 Supplier::Argument => progress(&format!("using the caller's agentd: {}", path.display())),
481 Supplier::Env => progress(&format!("using ${ENV_OVERRIDE}: {}", path.display())),
482 }
483 Ok(Provisioned {
484 sha256: sha256_hex(&bytes),
485 bytes,
486 path,
487 source: Source::CallerSupplied(supplier),
488 version,
489 })
490}
491
492fn cache_hit(
495 state_dir: &Path,
496 cached: &Path,
497 version: &str,
498 progress: &mut dyn FnMut(&str),
499) -> Result<Option<Provisioned>, ProvisionError> {
500 let bytes = match std::fs::read(cached) {
501 Ok(bytes) => bytes,
502 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
503 Err(error) => return Err(io_error(cached, "read", &error)),
504 };
505 let sha256 = sha256_hex(&bytes);
506 match recorded(state_dir, version, &sha256) {
507 Some(verification) if not_aarch64(&bytes).is_none() => {
508 progress(&format!(
509 "using cached agentd v{version}: {}",
510 cached.display()
511 ));
512 Ok(Some(Provisioned {
513 bytes,
514 path: cached.to_path_buf(),
515 source: Source::Cache(verification),
516 version: version.to_string(),
517 sha256,
518 }))
519 }
520 _ => {
521 progress(&format!(
522 "cached agentd v{version} at {} does not match the digest recorded when it \
523 was verified; discarding it and fetching the release again",
524 cached.display()
525 ));
526 for path in [cached.to_path_buf(), record_path(state_dir, version)] {
527 match std::fs::remove_file(&path) {
528 Ok(()) => {}
529 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
530 Err(error) => return Err(io_error(&path, "discard", &error)),
531 }
532 }
533 Ok(None)
534 }
535 }
536}
537
538fn fetched(
539 state_dir: &Path,
540 cached: &Path,
541 version: String,
542 fetch: &dyn Fetch,
543 progress: &mut dyn FnMut(&str),
544) -> Result<Provisioned, ProvisionError> {
545 let tag = format!("v{version}");
546 progress(&format!(
547 "no agentd given; fetching the release asset for {tag}"
548 ));
549 let dir = cached.parent().expect("the cache path has a parent");
550 std::fs::create_dir_all(dir).map_err(|error| io_error(dir, "create", &error))?;
551 let partial = dir.join(format!(".{ASSET}.partial-{}", std::process::id()));
555 let verification = match fetch.fetch(&tag, &partial, progress) {
556 Ok(verification) => verification,
557 Err(reason) => {
558 let _ = std::fs::remove_file(&partial);
559 return Err(fetch_error(&tag, &reason));
560 }
561 };
562 let bytes = std::fs::read(&partial).map_err(|error| {
563 let _ = std::fs::remove_file(&partial);
564 io_error(&partial, "read", &error)
565 })?;
566 if let Some(why) = not_aarch64(&bytes) {
567 let _ = std::fs::remove_file(&partial);
568 return Err(ProvisionError::new(
569 Failure::NotAarch64,
570 format!(
571 "the fetched {tag} asset is {why}, so it was discarded rather than cached: a \
572 wrong-architecture daemon fails as a run-hook timeout 45 minutes into a build."
573 ),
574 )
575 .remedy("this is a release-asset defect worth reporting, not a local mistake"));
576 }
577
578 #[cfg(unix)]
579 {
580 use std::os::unix::fs::PermissionsExt as _;
581 std::fs::set_permissions(&partial, std::fs::Permissions::from_mode(0o755))
582 .map_err(|error| io_error(&partial, "chmod", &error))?;
583 }
584 std::fs::rename(&partial, cached).map_err(|error| io_error(cached, "install", &error))?;
585 let sha256 = sha256_hex(&bytes);
588 let record = record_path(state_dir, &version);
589 let record_partial = dir.join(format!(".{RECORD}.partial-{}", std::process::id()));
590 std::fs::write(
591 &record_partial,
592 record_json(&version, &sha256, verification),
593 )
594 .and_then(|()| std::fs::rename(&record_partial, &record))
595 .map_err(|error| {
596 let _ = std::fs::remove_file(&record_partial);
597 io_error(&record, "record", &error)
598 })?;
599 progress(&format!(
600 "fetched and verified agentd {tag} ({}); cached at {}",
601 verification.as_str(),
602 cached.display()
603 ));
604 Ok(Provisioned {
605 bytes,
606 path: cached.to_path_buf(),
607 source: Source::Fetched(verification),
608 version,
609 sha256,
610 })
611}
612
613pub fn agentd(version: Option<&str>, state_dir: Option<&Path>) -> Result<Provisioned, Error> {
619 agentd_with(&Request {
620 version,
621 state_dir,
622 binary: None,
623 })
624}
625
626pub fn agentd_with(request: &Request<'_>) -> Result<Provisioned, Error> {
628 let env = &crate::env::process;
629 resolve(request, env, &HttpsFetch::from_env(env), &mut |_| {}).map_err(Error::from)
630}
631
632#[cfg(test)]
633mod tests {
634 use super::*;
635 use std::cell::Cell;
636
637 struct Scripted {
639 bytes: Vec<u8>,
640 verification: Verification,
641 calls: Cell<usize>,
642 }
643
644 impl Scripted {
645 fn elf() -> Self {
646 Self::with(elf_header(REQUIRED_ELF_MACHINE))
647 }
648
649 fn with(bytes: Vec<u8>) -> Self {
650 Self {
651 bytes,
652 verification: Verification::Attestation,
653 calls: Cell::new(0),
654 }
655 }
656 }
657
658 impl Fetch for Scripted {
659 fn fetch(
660 &self,
661 _: &str,
662 dest: &Path,
663 _: &mut dyn FnMut(&str),
664 ) -> Result<Verification, String> {
665 self.calls.set(self.calls.get() + 1);
666 std::fs::write(dest, &self.bytes).expect("writes");
667 Ok(self.verification)
668 }
669 }
670
671 struct Failing;
673
674 impl Fetch for Failing {
675 fn fetch(
676 &self,
677 _: &str,
678 _: &Path,
679 _: &mut dyn FnMut(&str),
680 ) -> Result<Verification, String> {
681 Err("no network in tests".into())
682 }
683 }
684
685 struct Panicking;
687
688 impl Fetch for Panicking {
689 fn fetch(
690 &self,
691 tag: &str,
692 _: &Path,
693 _: &mut dyn FnMut(&str),
694 ) -> Result<Verification, String> {
695 panic!("this request must not fetch (asked for {tag})");
696 }
697 }
698
699 fn elf_header(machine: u16) -> Vec<u8> {
701 let mut header = vec![0u8; 20];
702 header[..4].copy_from_slice(b"\x7fELF");
703 header[5] = 1;
704 header[18..20].copy_from_slice(&machine.to_le_bytes());
705 header
706 }
707
708 fn no_env(_: &str) -> Option<String> {
709 None
710 }
711
712 fn request(dir: &Path) -> Request<'_> {
713 Request {
714 version: Some("9.9.9"),
715 state_dir: Some(dir),
716 binary: None,
717 }
718 }
719
720 #[test]
723 fn a_cache_miss_fetches_once_and_the_next_request_reads_the_cache() {
724 let dir = tempfile::tempdir().expect("a temp dir");
725 let fetch = Scripted::elf();
726 let first = resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("resolves");
727 assert_eq!(first.source, Source::Fetched(Verification::Attestation));
728 assert_eq!(fetch.calls.get(), 1);
729 assert_eq!(first.bytes, std::fs::read(&first.path).expect("reads"));
730 #[cfg(unix)]
731 {
732 use std::os::unix::fs::PermissionsExt as _;
733 let mode = std::fs::metadata(&first.path)
734 .expect("stat")
735 .permissions()
736 .mode();
737 assert_eq!(
738 mode & 0o111,
739 0o111,
740 "the installed binary must be executable"
741 );
742 }
743
744 let second = resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("resolves");
745 assert_eq!(second.source, Source::Cache(Verification::Attestation));
746 assert_eq!(fetch.calls.get(), 1, "a cache hit must not fetch again");
747 assert_eq!(second.path, first.path);
748 assert_eq!(second.sha256, first.sha256);
749 }
750
751 #[test]
753 fn the_version_defaults_to_the_cores_own_and_accepts_a_leading_v() {
754 let dir = tempfile::tempdir().expect("a temp dir");
755 let fetch = Scripted::elf();
756 let default = Request {
757 state_dir: Some(dir.path()),
758 ..Request::default()
759 };
760 let served = resolve(&default, &no_env, &fetch, &mut |_| {}).expect("resolves");
761 assert_eq!(served.version, crate::VERSION);
762 assert_eq!(served.path, cache_path(dir.path(), crate::VERSION));
763 let tagged = format!("v{}", crate::VERSION);
764 let again = Request {
765 version: Some(&tagged),
766 ..default
767 };
768 let hit = resolve(&again, &no_env, &Panicking, &mut |_| {}).expect("resolves");
769 assert_eq!(hit.source.as_str(), "cache");
770 }
771
772 #[test]
775 fn a_caller_binary_outranks_the_environment_and_the_cache() {
776 let dir = tempfile::tempdir().expect("a temp dir");
777 resolve(&request(dir.path()), &no_env, &Scripted::elf(), &mut |_| {}).expect("caches");
778 let own = dir.path().join("my-agentd");
779 let mut bytes = elf_header(REQUIRED_ELF_MACHINE);
780 bytes.extend_from_slice(b"caller-managed");
781 std::fs::write(&own, &bytes).expect("writes");
782 let env_path = dir.path().join("env-agentd");
783 std::fs::write(&env_path, elf_header(REQUIRED_ELF_MACHINE)).expect("writes");
784 let env_str = env_path.display().to_string();
785 let env = move |name: &str| (name == ENV_OVERRIDE).then(|| env_str.clone());
786
787 let from_env = resolve(&request(dir.path()), &env, &Panicking, &mut |_| {}).expect("env");
788 assert_eq!(from_env.source, Source::CallerSupplied(Supplier::Env));
789 assert_eq!(from_env.path, env_path);
790 assert_eq!(from_env.verification(), None);
791
792 let with_binary = Request {
793 binary: Some(&own),
794 ..request(dir.path())
795 };
796 let from_arg = resolve(&with_binary, &env, &Panicking, &mut |_| {}).expect("argument");
797 assert_eq!(from_arg.source, Source::CallerSupplied(Supplier::Argument));
798 assert_eq!(from_arg.bytes, bytes);
799 }
800
801 #[test]
804 fn a_stale_environment_override_is_an_error_rather_than_a_fallthrough() {
805 let dir = tempfile::tempdir().expect("a temp dir");
806 let env = |name: &str| (name == ENV_OVERRIDE).then(|| "/definitely/not/here".to_string());
807 let failure = resolve(&request(dir.path()), &env, &Panicking, &mut |_| {})
808 .expect_err("a stale override must refuse");
809 assert_eq!(failure.failure, Failure::CallerBinary(Supplier::Env));
810 assert_eq!(failure.kind(), ErrorKind::Precondition);
811 assert!(
812 failure.message.contains(ENV_OVERRIDE),
813 "{}",
814 failure.message
815 );
816 assert!(failure.remedies.iter().any(|r| r.contains("unset")));
817 }
818
819 #[test]
822 fn a_caller_binary_that_is_not_aarch64_is_refused() {
823 let dir = tempfile::tempdir().expect("a temp dir");
824 let x86 = dir.path().join("x86");
825 std::fs::write(&x86, elf_header(0x3E)).expect("writes");
826 let script = dir.path().join("script");
827 std::fs::write(&script, b"#!/bin/sh\n").expect("writes");
828 for (path, detail) in [(&x86, "ELF machine 0x3e"), (&script, "not an ELF")] {
829 let with_binary = Request {
830 binary: Some(path),
831 ..request(dir.path())
832 };
833 let failure =
834 resolve(&with_binary, &no_env, &Panicking, &mut |_| {}).expect_err("must refuse");
835 assert_eq!(failure.failure, Failure::NotAarch64);
836 assert!(failure.message.contains(detail), "{}", failure.message);
837 }
838 let x86_str = x86.display().to_string();
839 let env = move |name: &str| (name == ENV_OVERRIDE).then(|| x86_str.clone());
840 let failure =
841 resolve(&request(dir.path()), &env, &Panicking, &mut |_| {}).expect_err("must refuse");
842 assert!(
843 failure.message.contains(ENV_OVERRIDE),
844 "{}",
845 failure.message
846 );
847 }
848
849 #[test]
853 fn a_fetched_non_arm_binary_is_refused_and_not_cached() {
854 let dir = tempfile::tempdir().expect("a temp dir");
855 let failure = resolve(
856 &request(dir.path()),
857 &no_env,
858 &Scripted::with(elf_header(0x3E)),
859 &mut |_| {},
860 )
861 .expect_err("an x86 asset must refuse");
862 assert!(failure.message.contains("0x3e"), "{}", failure.message);
863 assert!(
864 !cache_path(dir.path(), "9.9.9").exists(),
865 "nothing may be cached"
866 );
867
868 let failure = resolve(
869 &request(dir.path()),
870 &no_env,
871 &Scripted::with(b"#!/bin/sh".to_vec()),
872 &mut |_| {},
873 )
874 .expect_err("a non-ELF asset must refuse");
875 assert!(
876 failure.message.contains("not an ELF"),
877 "{}",
878 failure.message
879 );
880 let left: Vec<_> = std::fs::read_dir(cache_path(dir.path(), "9.9.9").parent().unwrap())
881 .expect("the version dir exists")
882 .collect();
883 assert!(left.is_empty(), "no partial file may survive: {left:?}");
884 }
885
886 #[test]
888 fn a_failed_fetch_names_the_tag_and_every_way_out() {
889 let dir = tempfile::tempdir().expect("a temp dir");
890 let failure =
891 resolve(&request(dir.path()), &no_env, &Failing, &mut |_| {}).expect_err("no network");
892 assert_eq!(failure.failure, Failure::Fetch);
893 assert!(failure.message.contains("v9.9.9"), "{}", failure.message);
894 let remedies = failure.remedies.join("\n");
895 assert!(remedies.contains("gh release download"), "{remedies}");
896 assert!(remedies.contains(ENV_OVERRIDE), "{remedies}");
897 assert!(
898 remedies.contains("cargo build --release -p agentd"),
899 "{remedies}"
900 );
901 let error = Error::from(failure);
903 assert_eq!(error.code(), "ERR_PRECONDITION");
904 assert!(error.to_string().contains("gh release download"), "{error}");
905 }
906
907 #[test]
910 fn a_changed_or_unrecorded_cache_entry_is_fetched_again() {
911 let dir = tempfile::tempdir().expect("a temp dir");
912 let fetch = Scripted::elf();
913 resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("caches");
914 let cached = cache_path(dir.path(), "9.9.9");
915 let mut changed = elf_header(REQUIRED_ELF_MACHINE);
916 changed.extend_from_slice(b"changed");
917 std::fs::write(&cached, &changed).expect("overwrites");
918 let mut lines = Vec::new();
919 let again = resolve(&request(dir.path()), &no_env, &fetch, &mut |line| {
920 lines.push(line.to_string())
921 })
922 .expect("refetches");
923 assert_eq!(again.source.as_str(), "fetched");
924 assert_eq!(fetch.calls.get(), 2);
925 assert_eq!(again.bytes, fetch.bytes);
926 assert!(
927 lines.iter().any(|line| line.contains("does not match")),
928 "{lines:?}"
929 );
930
931 std::fs::remove_file(record_path(dir.path(), "9.9.9")).expect("removes the record");
932 let unrecorded =
933 resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("refetches");
934 assert_eq!(unrecorded.source.as_str(), "fetched");
935 assert_eq!(fetch.calls.get(), 3);
936 }
937
938 #[test]
940 fn a_record_for_another_version_does_not_vouch_for_this_one() {
941 let dir = tempfile::tempdir().expect("a temp dir");
942 let fetch = Scripted::elf();
943 resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("caches");
944 let digest = sha256_hex(&fetch.bytes);
945 let record = record_path(dir.path(), "9.9.9");
946 std::fs::write(
947 &record,
948 record_json("1.0.0", &digest, Verification::Attestation),
949 )
950 .expect("writes");
951 resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("refetches");
952 assert_eq!(fetch.calls.get(), 2);
953 std::fs::write(&record, "{not json").expect("writes");
954 resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("refetches");
955 assert_eq!(fetch.calls.get(), 3);
956 }
957
958 #[test]
960 fn a_version_that_is_not_a_tag_is_refused() {
961 for bad in [
962 "", "v", "../x", "1.0/../x", ".hidden", "-rf", "1..2", "a b", "1\n",
963 ] {
964 let failure = normalize_version(bad).expect_err(bad);
965 assert_eq!(failure.kind(), ErrorKind::InvalidArg, "{bad:?}");
966 }
967 assert_eq!(normalize_version("v0.9.0").unwrap(), "0.9.0");
968 assert_eq!(
969 normalize_version("1.2.3-rc.1+build").unwrap(),
970 "1.2.3-rc.1+build"
971 );
972 }
973}