Skip to main content

runner_manager_platform/wsl/
artifact.rs

1// owner: a1-wsl-platform-adapter
2
3//! Choosing the one published Linux archive that matches, proving it is the
4//! one that was published, and putting the binary inside it at
5//! `/usr/local/bin/runner-manager` without a moment in which that path holds
6//! half a file.
7//!
8//! # There is no download here, and that is the point
9//!
10//! `crates/app/src/cli/update.rs` already fetches release assets, and it does
11//! so under two controls worth keeping: the origin is either GitHub or a
12//! loopback/local mirror, and `SHA256SUMS` is checked before anything is
13//! installed. Re-implementing the fetch in this crate would mean a second
14//! origin policy to keep in step with the first — which is the shape of an
15//! unverified download path even when the first version of it is careful.
16//!
17//! So this module takes an archive a caller *already has* plus the checksum
18//! document that describes it, and refuses to do anything with the archive
19//! until its SHA-256 matches. The orchestration layer supplies both from the
20//! existing update path. What is genuinely new here — and could not be
21//! borrowed — is everything after the digest matches, because the destination
22//! is inside another operating system.
23//!
24//! # Exact version, not newest
25//!
26//! [`select_exact_release`] differs from `update`'s selection in exactly one
27//! way, and it is the important one: `update` looks for the *newest* published
28//! archive, and this looks for the archive whose version is *exactly* the one
29//! asked for. `02-target-architecture.md` step 2 requires "the Linux release
30//! artifact whose semantic version exactly matches the controlling Windows
31//! binary", because a WSL host running a different build from the Windows host
32//! that manages it is a support matrix nobody wants and a bug report nobody
33//! can read.
34//!
35//! # Atomicity is a rename inside the distribution
36//!
37//! Windows cannot atomically replace a file that lives in ext4 inside a WSL
38//! virtual disk, so the whole install happens there:
39//!
40//! 1. the archive's SHA-256 is verified **on the Windows side**, before a byte
41//!    of it is piped anywhere;
42//! 2. a `0700` staging directory is created *beside the destination*, so the
43//!    final step is a rename within one filesystem and is therefore atomic;
44//! 3. the archive is streamed into `tar` on the child's stdin and the one
45//!    wanted member is extracted;
46//! 4. the extracted binary is made executable and asked its own `--version`,
47//!    which must be exactly the version selected;
48//! 5. only then is it renamed onto the destination.
49//!
50//! Every failure before step 5 leaves the destination exactly as it was —
51//! `03-security-and-lifecycle.md`'s "old binary remains executable" row — and
52//! the staging directory is removed on the way out either way.
53
54use std::io::Read;
55use std::path::Path;
56use std::time::Duration;
57
58use runner_manager_domain::model::Arch;
59use sha2::{Digest, Sha256};
60
61use super::WslError;
62use super::exec::{ChildInput, PipedInput};
63use super::probe::{LinuxCommand, WslInvoker};
64
65/// Where the Linux binary lives, which is what `install.sh` and the Linux
66/// service registration already assume.
67pub const DEFAULT_LINUX_DESTINATION: &str = "/usr/local/bin/runner-manager";
68
69/// The largest archive this will pipe into a distribution.
70///
71/// The published Linux archive is tens of megabytes; a quarter of a gigabyte
72/// is far above anything the release workflow can produce and far below
73/// anything that would matter to a workstation. The bound exists so that a
74/// wrong path — a caller handing over a disk image by mistake — fails with a
75/// sentence instead of with an allocation.
76pub const MAX_ARCHIVE_BYTES: u64 = 256 * 1024 * 1024;
77
78/// How long the extraction is given. Longer than a probe: it is the one step
79/// that moves real data across the boundary.
80const EXTRACT_TIMEOUT: Duration = Duration::from_secs(300);
81
82// ---------------------------------------------------------------------------
83// Which archive
84// ---------------------------------------------------------------------------
85
86/// The published artifact for one operating system and architecture.
87///
88/// The two rows named here must stay equal to the Linux rows of
89/// `crates/app/src/cli/update.rs`'s `host_target`, to `PUBLISHED_TARGETS` in
90/// `.github/scripts/channels.sh` and to `RELEASE_TARGETS` in `release.yml`.
91/// `linux_release_targets_match_the_published_matrix` below is the test that
92/// says so; without it, a target this module asks for and the release does not
93/// publish would be reported to an operator as "your architecture was dropped"
94/// about a release that is perfectly fine.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub struct ReleaseTarget {
97    triple: &'static str,
98    extension: &'static str,
99    binary: &'static str,
100}
101
102impl ReleaseTarget {
103    /// The Rust target triple the archive is named after.
104    #[must_use]
105    pub fn triple(&self) -> &'static str {
106        self.triple
107    }
108
109    /// The archive's extension, without the dot.
110    #[must_use]
111    pub fn extension(&self) -> &'static str {
112        self.extension
113    }
114
115    /// The executable's name inside the archive.
116    #[must_use]
117    pub fn binary(&self) -> &'static str {
118        self.binary
119    }
120
121    /// The asset name a given version is published under.
122    #[must_use]
123    pub fn asset_for(&self, version: &str) -> String {
124        format!(
125            "runner-manager-{version}-{}.{}",
126            self.triple, self.extension
127        )
128    }
129}
130
131/// The published Linux archive for an architecture.
132///
133/// # Errors
134///
135/// [`WslError::UnsupportedArchitecture`]. 32-bit ARM is refused here rather
136/// than left to fail at download: the release publishes no
137/// `armv7-unknown-linux-gnueabihf` archive, so there is nothing to select.
138pub fn linux_target(distribution: &str, arch: Arch) -> Result<ReleaseTarget, WslError> {
139    match arch {
140        Arch::X64 => Ok(ReleaseTarget {
141            triple: "x86_64-unknown-linux-gnu",
142            extension: "tar.gz",
143            binary: "runner-manager",
144        }),
145        Arch::Arm64 => Ok(ReleaseTarget {
146            triple: "aarch64-unknown-linux-gnu",
147            extension: "tar.gz",
148            binary: "runner-manager",
149        }),
150        Arch::Arm32 => Err(WslError::UnsupportedArchitecture {
151            distribution: distribution.to_string(),
152            reported: "32-bit ARM".to_string(),
153        }),
154    }
155}
156
157/// One release artifact: what it is called and what it must hash to.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct PublishedArtifact {
160    version: String,
161    asset: String,
162    digest: String,
163}
164
165impl PublishedArtifact {
166    /// The exact semantic version.
167    #[must_use]
168    pub fn version(&self) -> &str {
169        &self.version
170    }
171
172    /// The published asset name.
173    #[must_use]
174    pub fn asset(&self) -> &str {
175        &self.asset
176    }
177
178    /// The published SHA-256, lower-case hex.
179    #[must_use]
180    pub fn digest(&self) -> &str {
181        &self.digest
182    }
183}
184
185/// `X.Y.Z`, as three numbers.
186///
187/// Pre-release and build metadata are rejected rather than ignored, for the
188/// reason `update.rs` gives: the release workflow refuses to publish them, so
189/// a tag carrying one is not a release anything here should follow.
190#[must_use]
191pub fn parse_semantic_version(raw: &str) -> Option<(u64, u64, u64)> {
192    let mut parts = raw.split('.');
193    let major = parts.next()?.parse().ok()?;
194    let minor = parts.next()?.parse().ok()?;
195    let patch = parts.next()?.parse().ok()?;
196    if parts.next().is_some() {
197        return None;
198    }
199    Some((major, minor, patch))
200}
201
202/// The version in `runner-manager-<X.Y.Z>-<target>.<extension>`, when the name
203/// is exactly that and nothing else.
204///
205/// Matched whole rather than by prefix: `…-linux-gnu.tar.gz` is a prefix of
206/// `…-linux-gnu.tar.gz.sig`, and a signature file is not an archive.
207#[must_use]
208pub fn version_of_asset(name: &str, target: &ReleaseTarget) -> Option<String> {
209    let rest = name.strip_prefix("runner-manager-")?;
210    let rest = rest.strip_suffix(&format!(".{}", target.extension))?;
211    let version = rest.strip_suffix(&format!("-{}", target.triple))?;
212    parse_semantic_version(version).map(|_| version.to_string())
213}
214
215/// Finds the archive for `target` whose version is exactly `version`.
216///
217/// Both checksum-line forms `sha256sum -c` accepts are accepted here —
218/// `<hash>  <name>` and `<hash> *<name>` — for the reason `update.rs` gives:
219/// a parser stricter than the tool the README tells an operator to verify with
220/// would refuse a release that command is happy with.
221///
222/// # Errors
223///
224/// [`WslError::UnreadableChecksums`] when nothing in the document parses as a
225/// checksum line at all — a truncated download or a proxy error page;
226/// [`WslError::NoSuchArtifact`] when the document is fine and simply does not
227/// publish this version for this architecture;
228/// [`WslError::AmbiguousArtifact`] when two lines claim it, which is a release
229/// to refuse rather than to guess about.
230pub fn select_exact_release(
231    document: &str,
232    target: &ReleaseTarget,
233    version: &str,
234) -> Result<PublishedArtifact, WslError> {
235    if parse_semantic_version(version).is_none() {
236        return Err(WslError::UnreadableChecksums {
237            detail: format!(
238                "`{version}` is not an exact `X.Y.Z` version, and this install selects an \
239                 exact one rather than the newest"
240            ),
241        });
242    }
243    let mut usable = 0_usize;
244    let mut matched: Vec<PublishedArtifact> = Vec::new();
245    for line in document.lines() {
246        let fields: Vec<&str> = line.trim_end_matches('\r').split_whitespace().collect();
247        let [digest, name] = fields[..] else { continue };
248        if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
249            continue;
250        }
251        usable += 1;
252        let name = name.strip_prefix('*').unwrap_or(name);
253        let Some(found) = version_of_asset(name, target) else {
254            continue;
255        };
256        if found != version {
257            continue;
258        }
259        matched.push(PublishedArtifact {
260            version: found,
261            asset: name.to_string(),
262            digest: digest.to_ascii_lowercase(),
263        });
264    }
265
266    if usable == 0 {
267        return Err(WslError::UnreadableChecksums {
268            detail: "the checksum document has no line that reads as \
269                     '<64 hex digits><spaces><asset name>'; it is empty, truncated, or not a \
270                     SHA256SUMS file at all"
271                .to_string(),
272        });
273    }
274    match matched.len() {
275        1 => Ok(matched.remove(0)),
276        0 => Err(WslError::NoSuchArtifact {
277            version: version.to_string(),
278            triple: target.triple.to_string(),
279            published: usable,
280        }),
281        count => Err(WslError::AmbiguousArtifact {
282            version: version.to_string(),
283            triple: target.triple.to_string(),
284            count,
285        }),
286    }
287}
288
289// ---------------------------------------------------------------------------
290// Proving the archive is the published one
291// ---------------------------------------------------------------------------
292
293/// The SHA-256 of a file, lower-case hex.
294///
295/// Read in chunks, which keeps the peak cost of an install to one buffer
296/// rather than to a copy of the archive.
297///
298/// # Errors
299///
300/// [`WslError::UnreadableArchive`].
301pub fn sha256_of_file(path: &Path) -> Result<String, WslError> {
302    let unreadable = |error: std::io::Error| WslError::UnreadableArchive {
303        path: path.to_path_buf(),
304        detail: error.to_string(),
305    };
306    let mut file = std::fs::File::open(path).map_err(unreadable)?;
307    let mut hasher = Sha256::new();
308    let mut buffer = vec![0_u8; 64 * 1024];
309    loop {
310        let read = file.read(&mut buffer).map_err(unreadable)?;
311        if read == 0 {
312            break;
313        }
314        hasher.update(&buffer[..read]);
315    }
316    Ok(hex::encode(hasher.finalize()))
317}
318
319/// Reads an archive into memory and returns it only if it is the published one.
320///
321/// # The bytes that are hashed are the bytes that are piped
322///
323/// The file is read **once**, and the digest is taken over the buffer that the
324/// caller then hands to `tar`. Hashing the file and re-reading it afterwards
325/// would verify one read and install another: anything that replaced the file
326/// between the two — a shared temporary directory, a half-finished download
327/// still being written — would be installed unverified. Reading once removes
328/// the window rather than narrowing it, and costs one pass over the file
329/// instead of two.
330///
331/// The size is checked from the metadata first, so a caller that hands over a
332/// disk image by mistake is refused with a sentence rather than with a
333/// quarter-gigabyte allocation.
334///
335/// # Errors
336///
337/// [`WslError::UnreadableArchive`] when the file cannot be read or is larger
338/// than [`MAX_ARCHIVE_BYTES`]; [`WslError::DigestMismatch`] when it is not the
339/// published archive.
340pub fn read_verified_archive(
341    path: &Path,
342    artifact: &PublishedArtifact,
343) -> Result<Vec<u8>, WslError> {
344    let unreadable = |detail: String| WslError::UnreadableArchive {
345        path: path.to_path_buf(),
346        detail,
347    };
348    let metadata = std::fs::metadata(path).map_err(|error| unreadable(error.to_string()))?;
349    let too_large = |length: u64| {
350        unreadable(format!(
351            "it is {length} bytes, and this refuses to pipe anything larger than \
352             {MAX_ARCHIVE_BYTES}"
353        ))
354    };
355    if metadata.len() > MAX_ARCHIVE_BYTES {
356        return Err(too_large(metadata.len()));
357    }
358    let bytes = std::fs::read(path).map_err(|error| unreadable(error.to_string()))?;
359    // Checked again against what was really read: the file may have grown
360    // between the metadata call and the read.
361    if bytes.len() as u64 > MAX_ARCHIVE_BYTES {
362        return Err(too_large(bytes.len() as u64));
363    }
364    let actual = hex::encode(Sha256::digest(&bytes));
365    if actual != artifact.digest {
366        return Err(WslError::DigestMismatch {
367            path: path.to_path_buf(),
368            expected: artifact.digest.clone(),
369            actual,
370        });
371    }
372    Ok(bytes)
373}
374
375// ---------------------------------------------------------------------------
376// Where it goes
377// ---------------------------------------------------------------------------
378
379/// An absolute Linux path to a file, split into the parts the install needs.
380#[derive(Debug, Clone, PartialEq, Eq)]
381pub struct LinuxBinaryPath {
382    directory: String,
383    file_name: String,
384}
385
386impl LinuxBinaryPath {
387    /// Parses and checks an absolute Linux path.
388    ///
389    /// # Errors
390    ///
391    /// [`WslError::InvalidDestination`] when it is relative, ends in `/`,
392    /// contains a `.` or `..` component, an empty component, or a control
393    /// character. Each of those would make the staging directory this module
394    /// creates land somewhere other than beside the destination, and the
395    /// atomic rename depends on it landing beside it.
396    pub fn parse(path: &str) -> Result<Self, WslError> {
397        let refuse = |reason: &str| {
398            Err(WslError::InvalidDestination {
399                path: path.to_string(),
400                reason: reason.to_string(),
401            })
402        };
403        if !path.starts_with('/') {
404            return refuse("it is not an absolute Linux path");
405        }
406        if path.chars().any(char::is_control) {
407            return refuse("it contains a control character");
408        }
409        let components: Vec<&str> = path.split('/').skip(1).collect();
410        if components.iter().any(|component| component.is_empty()) {
411            return refuse("it has an empty path component, or a trailing slash");
412        }
413        if components
414            .iter()
415            .any(|component| *component == "." || *component == "..")
416        {
417            return refuse("it contains a `.` or `..` component, which is not resolved here");
418        }
419        let Some((file_name, directory_parts)) = components.split_last() else {
420            return refuse("it names the root directory rather than a file");
421        };
422        Ok(Self {
423            directory: format!("/{}", directory_parts.join("/")),
424            file_name: (*file_name).to_string(),
425        })
426    }
427
428    /// The directory the binary lives in, and the staging directory is created
429    /// in.
430    #[must_use]
431    pub fn directory(&self) -> &str {
432        &self.directory
433    }
434
435    /// The file's own name.
436    #[must_use]
437    pub fn file_name(&self) -> &str {
438        &self.file_name
439    }
440
441    /// The whole path.
442    #[must_use]
443    pub fn as_path(&self) -> String {
444        if self.directory == "/" {
445            format!("/{}", self.file_name)
446        } else {
447            format!("{}/{}", self.directory, self.file_name)
448        }
449    }
450}
451
452impl Default for LinuxBinaryPath {
453    fn default() -> Self {
454        Self::parse(DEFAULT_LINUX_DESTINATION)
455            .expect("the product's own default destination is a valid absolute path")
456    }
457}
458
459// ---------------------------------------------------------------------------
460// The install
461// ---------------------------------------------------------------------------
462
463/// What was installed, once the rename succeeded.
464#[derive(Debug, Clone, PartialEq, Eq)]
465pub struct InstalledBinary {
466    destination: String,
467    version: String,
468    asset: String,
469}
470
471impl InstalledBinary {
472    /// Where it now is, inside the distribution.
473    #[must_use]
474    pub fn destination(&self) -> &str {
475        &self.destination
476    }
477
478    /// The version it reported about itself after being installed.
479    #[must_use]
480    pub fn version(&self) -> &str {
481        &self.version
482    }
483
484    /// The published asset it came out of.
485    #[must_use]
486    pub fn asset(&self) -> &str {
487        &self.asset
488    }
489}
490
491/// Stages and installs one release binary inside a distribution.
492#[derive(Debug)]
493pub struct BinaryInstaller<'invoker> {
494    invoker: &'invoker WslInvoker<'invoker>,
495    distribution: String,
496    destination: LinuxBinaryPath,
497    staging_token: String,
498}
499
500impl<'invoker> BinaryInstaller<'invoker> {
501    /// Installs into `destination` in `distribution`.
502    #[must_use]
503    pub fn new(
504        invoker: &'invoker WslInvoker<'invoker>,
505        distribution: impl Into<String>,
506        destination: LinuxBinaryPath,
507    ) -> Self {
508        Self {
509            invoker,
510            distribution: distribution.into(),
511            destination,
512            staging_token: uuid::Uuid::new_v4().simple().to_string(),
513        }
514    }
515
516    /// Fixes the random part of the staging directory's name.
517    ///
518    /// Only a test needs this. It exists so that the argument vectors this
519    /// module builds can be asserted exactly, rather than matched with a
520    /// pattern that would also match a mistake.
521    #[must_use]
522    pub fn with_staging_token(mut self, token: impl Into<String>) -> Self {
523        self.staging_token = token.into();
524        self
525    }
526
527    /// Where the archive is unpacked: beside the destination, so that the
528    /// final rename cannot cross a filesystem.
529    #[must_use]
530    pub fn staging_directory(&self) -> String {
531        let directory = self.destination.directory();
532        let separator = if directory.ends_with('/') { "" } else { "/" };
533        format!(
534            "{directory}{separator}.runner-manager-install-{}",
535            self.staging_token
536        )
537    }
538
539    /// Verifies the archive, unpacks it, checks the version, and renames it
540    /// into place.
541    ///
542    /// # Errors
543    ///
544    /// [`WslError::DigestMismatch`] or [`WslError::UnreadableArchive`] before
545    /// anything is sent; [`WslError::CommandFailed`] from any Linux step; and
546    /// [`WslError::VersionMismatch`] when the archive turned out to hold a
547    /// different build. In every one of those cases the destination is
548    /// untouched and the staging directory has been removed.
549    pub fn install(
550        &self,
551        archive: &Path,
552        artifact: &PublishedArtifact,
553        target: &ReleaseTarget,
554    ) -> Result<InstalledBinary, WslError> {
555        // Nothing crosses the boundary until the digest matches.
556        let bytes = read_verified_archive(archive, artifact)?;
557
558        let staging = self.staging_directory();
559        // `mkdir` without `-p`: it must *create* the directory, so a name that
560        // somehow already exists is a failure rather than a directory whose
561        // contents this then trusts.
562        self.invoker.exec_ok(
563            "create a staging directory inside the distribution",
564            self.command("mkdir").args(["-m", "0700", staging.as_str()]),
565        )?;
566
567        let installed = self.stage_and_rename(&staging, bytes, artifact, target);
568        // Best-effort, and after both outcomes: on success it removes an empty
569        // directory, on failure it removes the partial extraction. A failure
570        // to clean up must not mask the real error, so its result is dropped.
571        drop(
572            self.invoker
573                .exec(self.command("rm").args(["-rf", staging.as_str()])),
574        );
575        installed
576    }
577
578    /// Everything between "the staging directory exists" and "the rename
579    /// happened", so that the caller can clean up on either outcome.
580    fn stage_and_rename(
581        &self,
582        staging: &str,
583        bytes: Vec<u8>,
584        artifact: &PublishedArtifact,
585        target: &ReleaseTarget,
586    ) -> Result<InstalledBinary, WslError> {
587        // The member is named with the directory it really sits in. Asking
588        // `tar` for a bare `runner-manager` would match nothing at all and the
589        // install would fail on every real archive; see [`archive_member`].
590        let member = archive_member(artifact, target);
591        let staged = format!("{staging}/{member}");
592
593        // `--no-same-owner` because the archive's recorded ownership is the
594        // release runner's, not this distribution's, and root would otherwise
595        // honour it. `-` is stdin: the archive is streamed rather than written
596        // to a file inside the distribution, so no temporary copy of it exists
597        // there to be left behind.
598        self.invoker.exec_ok(
599            "unpack the release archive inside the distribution",
600            self.command("tar")
601                .args([
602                    "-xzf",
603                    "-",
604                    "-C",
605                    staging,
606                    "--no-same-owner",
607                    member.as_str(),
608                ])
609                .with_input(ChildInput::Piped(PipedInput::from_bytes(bytes)))
610                .with_timeout(EXTRACT_TIMEOUT),
611        )?;
612
613        self.invoker.exec_ok(
614            "make the unpacked binary executable",
615            self.command("chmod").args(["0755", staged.as_str()]),
616        )?;
617
618        // The archive said which version it was; this asks the binary. They
619        // have to agree before it replaces a binary a service is running.
620        let reported = self.invoker.exec_ok(
621            "read the unpacked binary's version",
622            self.command(staged.as_str()).args(["--version"]),
623        )?;
624        let reported = reported.stdout_text();
625        if !reports_version(&reported, artifact.version()) {
626            return Err(WslError::VersionMismatch {
627                expected: artifact.version().to_string(),
628                reported,
629            });
630        }
631
632        // The atomic step. `-T` so that a destination which is unexpectedly a
633        // directory is a refusal rather than a binary placed *inside* it.
634        let destination = self.destination.as_path();
635        self.invoker.exec_ok(
636            "put the new binary in place",
637            self.command("mv")
638                .args(["-T", staged.as_str(), destination.as_str()]),
639        )?;
640
641        Ok(InstalledBinary {
642            destination,
643            version: artifact.version().to_string(),
644            asset: artifact.asset().to_string(),
645        })
646    }
647
648    fn command(&self, program: &str) -> LinuxCommand {
649        LinuxCommand::new(self.distribution.clone(), program)
650    }
651}
652
653/// The path of the binary **inside** the published archive.
654///
655/// `release.yml` packages every archive as `tar -czf <stem>.tar.gz -C dist
656/// <stem>`, where the stem is `runner-manager-<version>-<triple>` — so the one
657/// top-level entry is a directory of that name and the binary is directly
658/// inside it. `crates/app/src/cli/update.rs` extracts exactly this path, and
659/// `the_archive_member_is_the_path_the_release_really_packages` below is the
660/// test that keeps the two spellings equal.
661///
662/// Naming the member with its directory is also why the staged file is at
663/// `<staging>/<member>` rather than at `<staging>/runner-manager`: `tar`
664/// recreates the intermediate directory, and the rename onto the destination
665/// is still within the destination's own filesystem, so it is still atomic.
666#[must_use]
667fn archive_member(artifact: &PublishedArtifact, target: &ReleaseTarget) -> String {
668    format!(
669        "runner-manager-{}-{}/{}",
670        artifact.version(),
671        target.triple(),
672        target.binary()
673    )
674}
675
676/// Whether `--version` output names exactly this version.
677///
678/// Compared as a whole whitespace-separated token, so that `0.4.0` does not
679/// match a binary that reports `0.4.10`.
680#[must_use]
681fn reports_version(output: &str, version: &str) -> bool {
682    output
683        .split_whitespace()
684        .any(|token| token.trim_start_matches('v') == version)
685}
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690    use std::path::PathBuf;
691
692    use crate::wsl::exec::{CommandOutput, ScriptedRunner};
693    use crate::wsl::probe::WslExecutable;
694
695    const DIGEST_X64: &str = "1111111111111111111111111111111111111111111111111111111111111111";
696    const DIGEST_ARM: &str = "2222222222222222222222222222222222222222222222222222222222222222";
697
698    fn sums() -> String {
699        format!(
700            concat!(
701                "{x64}  runner-manager-0.4.0-x86_64-unknown-linux-gnu.tar.gz\n",
702                "{arm} *runner-manager-0.4.0-aarch64-unknown-linux-gnu.tar.gz\n",
703                "3333333333333333333333333333333333333333333333333333333333333333  \
704                 runner-manager-0.4.0-x86_64-pc-windows-msvc.zip\n",
705                "4444444444444444444444444444444444444444444444444444444444444444  \
706                 runner-manager-0.3.2-x86_64-unknown-linux-gnu.tar.gz\n",
707                "5555555555555555555555555555555555555555555555555555555555555555  \
708                 runner-manager-0.4.0-x86_64-unknown-linux-gnu.tar.gz.sig\n",
709            ),
710            x64 = DIGEST_X64,
711            arm = DIGEST_ARM,
712        )
713    }
714
715    fn x64() -> ReleaseTarget {
716        linux_target("Ubuntu", Arch::X64).expect("x64 is published")
717    }
718
719    // -- Selection -----------------------------------------------------------
720
721    #[test]
722    fn linux_release_targets_match_the_published_matrix() {
723        // The two Linux rows of `crates/app/src/cli/update.rs`'s `host_target`,
724        // written out independently. Asserting the table against itself would
725        // prove nothing; this is the copy that goes red when the release drops
726        // or renames an architecture.
727        assert_eq!(x64().triple(), "x86_64-unknown-linux-gnu");
728        assert_eq!(x64().extension(), "tar.gz");
729        assert_eq!(x64().binary(), "runner-manager");
730        let arm = linux_target("Ubuntu", Arch::Arm64).expect("arm64 is published");
731        assert_eq!(arm.triple(), "aarch64-unknown-linux-gnu");
732        assert_eq!(
733            x64().asset_for("0.4.0"),
734            "runner-manager-0.4.0-x86_64-unknown-linux-gnu.tar.gz"
735        );
736    }
737
738    #[test]
739    fn thirty_two_bit_arm_has_no_artifact_and_is_refused_rather_than_guessed() {
740        let error = linux_target("Ubuntu", Arch::Arm32).expect_err("nothing is published");
741        assert!(
742            matches!(error, WslError::UnsupportedArchitecture { .. }),
743            "{error:?}"
744        );
745    }
746
747    #[test]
748    fn the_exact_version_and_architecture_are_selected_out_of_a_real_document() {
749        let artifact = select_exact_release(&sums(), &x64(), "0.4.0").expect("published");
750        assert_eq!(artifact.version(), "0.4.0");
751        assert_eq!(
752            artifact.asset(),
753            "runner-manager-0.4.0-x86_64-unknown-linux-gnu.tar.gz"
754        );
755        assert_eq!(artifact.digest(), DIGEST_X64);
756    }
757
758    #[test]
759    fn the_star_form_of_a_checksum_line_is_accepted_as_sha256sum_accepts_it() {
760        let arm = linux_target("Ubuntu", Arch::Arm64).expect("published");
761        let artifact = select_exact_release(&sums(), &arm, "0.4.0").expect("published");
762        assert_eq!(artifact.digest(), DIGEST_ARM);
763        assert!(
764            !artifact.asset().starts_with('*'),
765            "the `*` marks a binary read, and is not part of the name"
766        );
767    }
768
769    #[test]
770    fn a_signature_file_is_not_an_archive() {
771        // `…tar.gz.sig` has `…tar.gz` as a prefix, which is why the suffix is
772        // matched whole.
773        assert_eq!(
774            version_of_asset(
775                "runner-manager-0.4.0-x86_64-unknown-linux-gnu.tar.gz.sig",
776                &x64()
777            ),
778            None
779        );
780    }
781
782    #[test]
783    fn a_newer_published_version_is_not_accepted_when_an_exact_one_was_asked_for() {
784        // The difference from `update`, stated as a test: `update` would take
785        // 0.4.0 here; this must take 0.3.2 and nothing else.
786        let artifact = select_exact_release(&sums(), &x64(), "0.3.2").expect("published");
787        assert_eq!(artifact.version(), "0.3.2");
788    }
789
790    #[test]
791    fn a_version_the_release_does_not_publish_says_how_many_assets_it_has() {
792        let error = select_exact_release(&sums(), &x64(), "9.9.9").expect_err("not published");
793        let WslError::NoSuchArtifact {
794            version,
795            triple,
796            published,
797        } = &error
798        else {
799            panic!("unexpected error: {error:?}");
800        };
801        assert_eq!(version, "9.9.9");
802        assert_eq!(triple, "x86_64-unknown-linux-gnu");
803        assert_eq!(*published, 5);
804    }
805
806    #[test]
807    fn two_archives_for_one_target_refuse_rather_than_guess() {
808        let document = format!(
809            "{DIGEST_X64}  runner-manager-0.4.0-x86_64-unknown-linux-gnu.tar.gz\n\
810             {DIGEST_ARM}  runner-manager-0.4.0-x86_64-unknown-linux-gnu.tar.gz\n"
811        );
812        let error = select_exact_release(&document, &x64(), "0.4.0").expect_err("ambiguous");
813        assert!(
814            matches!(error, WslError::AmbiguousArtifact { count: 2, .. }),
815            "{error:?}"
816        );
817    }
818
819    #[test]
820    fn a_document_that_is_not_a_checksum_file_is_told_apart_from_a_missing_row() {
821        let error = select_exact_release("<html>404</html>", &x64(), "0.4.0")
822            .expect_err("not a checksum document");
823        assert!(
824            matches!(error, WslError::UnreadableChecksums { .. }),
825            "{error:?}"
826        );
827    }
828
829    #[test]
830    fn an_inexact_version_is_refused_before_the_document_is_read() {
831        // "newest", "the 0.4 line" and a pre-release are all things an exact
832        // match cannot mean, and each of them would otherwise install a build
833        // that does not match the Windows binary managing it.
834        for version in ["0.4", "0.4.0-rc.1", "latest", "v0.4.0", "0.4.0+build", ""] {
835            let error = select_exact_release(&sums(), &x64(), version)
836                .expect_err("an inexact version must be refused");
837            assert!(
838                matches!(error, WslError::UnreadableChecksums { .. }),
839                "{version:?} produced the wrong refusal: {error:?}"
840            );
841        }
842        // And the one exact spelling is still accepted, so the loop above is
843        // not passing because everything is refused.
844        assert_eq!(
845            select_exact_release(&sums(), &x64(), "0.4.0")
846                .expect("0.4.0 is published")
847                .version(),
848            "0.4.0"
849        );
850    }
851
852    #[test]
853    fn the_archive_member_is_the_path_the_release_really_packages() {
854        // `release.yml` builds `dist/<stem>/runner-manager` and packages it
855        // with `tar -czf <stem>.tar.gz -C dist <stem>`, so the member's
856        // directory is the asset name without its extension. A bare
857        // `runner-manager` matches no member at all, and every install would
858        // then fail at the extract step with "not found in archive".
859        let artifact = select_exact_release(&sums(), &x64(), "0.4.0").expect("published");
860        let member = archive_member(&artifact, &x64());
861        let stem = artifact
862            .asset()
863            .strip_suffix(&format!(".{}", x64().extension()))
864            .expect("the asset name carries the target's extension");
865        assert_eq!(member, format!("{stem}/{}", x64().binary()));
866        assert_eq!(
867            member,
868            "runner-manager-0.4.0-x86_64-unknown-linux-gnu/runner-manager"
869        );
870    }
871
872    // -- Digest --------------------------------------------------------------
873
874    fn write_archive(directory: &Path, bytes: &[u8]) -> (PathBuf, String) {
875        let path = directory.join("archive.tar.gz");
876        std::fs::write(&path, bytes).expect("write the archive");
877        let digest = sha256_of_file(&path).expect("hash it");
878        (path, digest)
879    }
880
881    #[test]
882    fn the_digest_is_the_one_sha256sum_would_print() {
883        let directory = tempfile::tempdir().expect("a temporary directory");
884        let (_, digest) = write_archive(directory.path(), b"");
885        // The SHA-256 of the empty input, which is a constant anybody can check.
886        assert_eq!(
887            digest,
888            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
889        );
890    }
891
892    #[test]
893    fn an_archive_whose_digest_does_not_match_is_never_returned_to_be_piped() {
894        let directory = tempfile::tempdir().expect("a temporary directory");
895        let (path, _) = write_archive(directory.path(), b"not the published bytes");
896        let artifact = PublishedArtifact {
897            version: "0.4.0".to_string(),
898            asset: "runner-manager-0.4.0-x86_64-unknown-linux-gnu.tar.gz".to_string(),
899            digest: DIGEST_X64.to_string(),
900        };
901        let error = read_verified_archive(&path, &artifact).expect_err("mismatch");
902        let WslError::DigestMismatch {
903            expected, actual, ..
904        } = &error
905        else {
906            panic!("unexpected error: {error:?}");
907        };
908        assert_eq!(expected, DIGEST_X64);
909        assert_ne!(actual, DIGEST_X64);
910    }
911
912    // -- Destination ---------------------------------------------------------
913
914    #[test]
915    fn the_default_destination_is_the_one_the_linux_service_already_assumes() {
916        let destination = LinuxBinaryPath::default();
917        assert_eq!(destination.as_path(), DEFAULT_LINUX_DESTINATION);
918        assert_eq!(destination.directory(), "/usr/local/bin");
919        assert_eq!(destination.file_name(), "runner-manager");
920    }
921
922    #[test]
923    fn a_destination_that_would_move_the_staging_directory_elsewhere_is_refused() {
924        for path in [
925            "usr/local/bin/runner-manager",
926            "/usr/local/bin/",
927            "/usr/local//bin/runner-manager",
928            "/usr/local/bin/../../tmp/runner-manager",
929            "/usr/local/bin/./runner-manager",
930            "/",
931            "/usr/local/bin/runner\nmanager",
932        ] {
933            assert!(
934                LinuxBinaryPath::parse(path).is_err(),
935                "{path:?} should be refused"
936            );
937        }
938    }
939
940    #[test]
941    fn a_destination_at_the_root_still_stages_beside_itself() {
942        let destination = LinuxBinaryPath::parse("/runner-manager").expect("valid");
943        assert_eq!(destination.directory(), "/");
944        assert_eq!(destination.as_path(), "/runner-manager");
945        let runner = ScriptedRunner::new();
946        let executable = WslExecutable::at("wsl.exe");
947        let invoker = WslInvoker::new(&runner, &executable);
948        let installer =
949            BinaryInstaller::new(&invoker, "Ubuntu", destination).with_staging_token("token");
950        assert_eq!(
951            installer.staging_directory(),
952            "/.runner-manager-install-token"
953        );
954    }
955
956    // -- The install ---------------------------------------------------------
957
958    struct Fixture {
959        directory: tempfile::TempDir,
960        archive: PathBuf,
961        artifact: PublishedArtifact,
962    }
963
964    fn fixture() -> Fixture {
965        let directory = tempfile::tempdir().expect("a temporary directory");
966        let (archive, digest) = write_archive(directory.path(), b"pretend this is a tar.gz");
967        let artifact = PublishedArtifact {
968            version: "0.4.0".to_string(),
969            asset: "runner-manager-0.4.0-x86_64-unknown-linux-gnu.tar.gz".to_string(),
970            digest,
971        };
972        Fixture {
973            directory,
974            archive,
975            artifact,
976        }
977    }
978
979    fn healthy_runner() -> ScriptedRunner {
980        ScriptedRunner::new().always(
981            "--version",
982            CommandOutput::exited(0, "runner-manager 0.4.0\n", ""),
983        )
984    }
985
986    #[test]
987    fn a_successful_install_runs_exactly_the_expected_argument_vectors_in_order() {
988        let fixture = fixture();
989        let runner = healthy_runner();
990        let executable = WslExecutable::at("wsl.exe");
991        let invoker = WslInvoker::new(&runner, &executable);
992        let installed = BinaryInstaller::new(&invoker, "Ubuntu", LinuxBinaryPath::default())
993            .with_staging_token("token")
994            .install(&fixture.archive, &fixture.artifact, &x64())
995            .expect("the scripted distribution accepts every step");
996
997        assert_eq!(installed.destination(), DEFAULT_LINUX_DESTINATION);
998        assert_eq!(installed.version(), "0.4.0");
999
1000        let staging = "/usr/local/bin/.runner-manager-install-token";
1001        let member = "runner-manager-0.4.0-x86_64-unknown-linux-gnu/runner-manager";
1002        let staged = format!("{staging}/{member}");
1003        let staged = staged.as_str();
1004        let expected: Vec<Vec<String>> = vec![
1005            vec!["mkdir", "-m", "0700", staging],
1006            vec!["tar", "-xzf", "-", "-C", staging, "--no-same-owner", member],
1007            vec!["chmod", "0755", staged],
1008            vec![staged, "--version"],
1009            vec!["mv", "-T", staged, DEFAULT_LINUX_DESTINATION],
1010            vec!["rm", "-rf", staging],
1011        ]
1012        .into_iter()
1013        .map(|step| {
1014            let mut argv = vec![
1015                "--distribution".to_string(),
1016                "Ubuntu".to_string(),
1017                "--user".to_string(),
1018                "root".to_string(),
1019                "--exec".to_string(),
1020            ];
1021            argv.extend(step.into_iter().map(str::to_string));
1022            argv
1023        })
1024        .collect();
1025
1026        let actual: Vec<Vec<String>> = runner
1027            .recorded()
1028            .into_iter()
1029            .map(|request| request.arguments)
1030            .collect();
1031        assert_eq!(actual, expected);
1032        drop(fixture.directory);
1033    }
1034
1035    #[test]
1036    fn the_staging_directory_is_beside_the_destination_so_the_rename_is_atomic() {
1037        // The property the whole failure model rests on: `mv` between two
1038        // paths in one directory is `rename(2)`, and `rename(2)` either
1039        // replaced the file or did not.
1040        let runner = healthy_runner();
1041        let executable = WslExecutable::at("wsl.exe");
1042        let invoker = WslInvoker::new(&runner, &executable);
1043        let installer = BinaryInstaller::new(&invoker, "Ubuntu", LinuxBinaryPath::default())
1044            .with_staging_token("token");
1045        let staging = installer.staging_directory();
1046        assert!(staging.starts_with("/usr/local/bin/"));
1047        assert_eq!(
1048            staging.rfind('/'),
1049            Some("/usr/local/bin".len()),
1050            "the staging directory must be a direct child of the destination's directory: \
1051             {staging}"
1052        );
1053    }
1054
1055    #[test]
1056    fn a_digest_mismatch_never_reaches_the_distribution_at_all() {
1057        let fixture = fixture();
1058        let mut wrong = fixture.artifact.clone();
1059        wrong.digest = DIGEST_X64.to_string();
1060        let runner = healthy_runner();
1061        let executable = WslExecutable::at("wsl.exe");
1062        let invoker = WslInvoker::new(&runner, &executable);
1063        let error = BinaryInstaller::new(&invoker, "Ubuntu", LinuxBinaryPath::default())
1064            .install(&fixture.archive, &wrong, &x64())
1065            .expect_err("the archive is not the published one");
1066        assert!(
1067            matches!(error, WslError::DigestMismatch { .. }),
1068            "{error:?}"
1069        );
1070        assert_eq!(
1071            runner.call_count(),
1072            0,
1073            "nothing may run in the distribution: {:?}",
1074            runner.command_lines()
1075        );
1076    }
1077
1078    #[test]
1079    fn a_failed_extraction_preserves_the_destination_and_removes_the_staging_directory() {
1080        let fixture = fixture();
1081        let runner = healthy_runner().always(
1082            "--exec tar",
1083            CommandOutput::exited(2, "", "gzip: stdin: not in gzip format\n"),
1084        );
1085        let executable = WslExecutable::at("wsl.exe");
1086        let invoker = WslInvoker::new(&runner, &executable);
1087        let error = BinaryInstaller::new(&invoker, "Ubuntu", LinuxBinaryPath::default())
1088            .with_staging_token("token")
1089            .install(&fixture.archive, &fixture.artifact, &x64())
1090            .expect_err("tar refused");
1091        assert!(error.to_string().contains("not in gzip format"), "{error}");
1092
1093        let lines = runner.command_lines();
1094        assert!(
1095            lines.iter().all(|line| !line.contains("--exec mv")),
1096            "the destination must not be touched: {lines:?}"
1097        );
1098        assert!(
1099            lines
1100                .iter()
1101                .any(|line| line
1102                    .contains("--exec rm -rf /usr/local/bin/.runner-manager-install-token")),
1103            "the staging directory must be removed: {lines:?}"
1104        );
1105    }
1106
1107    #[test]
1108    fn a_binary_reporting_a_different_version_is_never_renamed_into_place() {
1109        let fixture = fixture();
1110        let runner = ScriptedRunner::new().always(
1111            "--version",
1112            CommandOutput::exited(0, "runner-manager 0.4.10\n", ""),
1113        );
1114        let executable = WslExecutable::at("wsl.exe");
1115        let invoker = WslInvoker::new(&runner, &executable);
1116        let error = BinaryInstaller::new(&invoker, "Ubuntu", LinuxBinaryPath::default())
1117            .install(&fixture.archive, &fixture.artifact, &x64())
1118            .expect_err("0.4.10 is not 0.4.0");
1119        let WslError::VersionMismatch { expected, reported } = &error else {
1120            panic!("unexpected error: {error:?}");
1121        };
1122        assert_eq!(expected, "0.4.0");
1123        assert!(reported.contains("0.4.10"));
1124        assert!(
1125            runner
1126                .command_lines()
1127                .iter()
1128                .all(|line| !line.contains("--exec mv")),
1129            "the destination must not be touched"
1130        );
1131    }
1132
1133    #[test]
1134    fn a_version_check_matches_whole_tokens_rather_than_prefixes() {
1135        assert!(reports_version("runner-manager 0.4.0", "0.4.0"));
1136        assert!(reports_version("runner-manager v0.4.0", "0.4.0"));
1137        assert!(!reports_version("runner-manager 0.4.10", "0.4.0"));
1138        assert!(!reports_version("runner-manager 10.4.0", "0.4.0"));
1139        assert!(!reports_version("", "0.4.0"));
1140    }
1141
1142    #[test]
1143    fn the_archive_is_piped_rather_than_written_into_the_distribution() {
1144        // `03-security-and-lifecycle.md` wants no temporary copy left inside
1145        // the distribution on a failure. The control is that no step ever
1146        // names a file to write the archive to: it goes to `tar` on stdin.
1147        let fixture = fixture();
1148        let runner = healthy_runner();
1149        let executable = WslExecutable::at("wsl.exe");
1150        let invoker = WslInvoker::new(&runner, &executable);
1151        BinaryInstaller::new(&invoker, "Ubuntu", LinuxBinaryPath::default())
1152            .with_staging_token("token")
1153            .install(&fixture.archive, &fixture.artifact, &x64())
1154            .expect("installed");
1155
1156        let piped = runner.piped_input();
1157        assert_eq!(
1158            piped,
1159            std::fs::read(&fixture.archive).expect("the archive is readable"),
1160            "the whole archive should have gone through the pipe"
1161        );
1162        for request in runner.recorded() {
1163            assert!(
1164                !request
1165                    .arguments
1166                    .iter()
1167                    .any(|argument| argument.contains(".tar.gz")),
1168                "no step may name an archive file inside the distribution: {:?}",
1169                request.arguments
1170            );
1171        }
1172    }
1173}