mod common;
use common::{
FixtureRelease, TARGETS, bash_program, build_release, install_script, posix, repository_root,
run_bash, substitute_payload,
};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Mutex, MutexGuard};
use tempfile::TempDir;
struct Host {
uname_s: &'static str,
uname_m: &'static str,
}
const LINUX_X64: Host = Host {
uname_s: "Linux",
uname_m: "x86_64",
};
fn run_install_sh(
host: &Host,
base: &Path,
install_dir: &Path,
arguments: &[&str],
) -> (bool, String) {
run_bash(
&install_script("install.sh"),
arguments,
&[
("RUNNER_MANAGER_INSTALL_UNAME_S", host.uname_s),
("RUNNER_MANAGER_INSTALL_UNAME_M", host.uname_m),
("RUNNER_MANAGER_INSTALL_BASE_URL", &posix(base)),
("RUNNER_MANAGER_INSTALL_DIR", &posix(install_dir)),
],
)
}
fn plan_value(output: &str, key: &str) -> String {
let prefix = format!("{key}=");
output
.lines()
.find_map(|line| line.trim().strip_prefix(&prefix))
.unwrap_or_else(|| panic!("--print-plan printed no `{key}=` line:\n{output}"))
.to_string()
}
fn run_installed(binary: &Path) -> String {
let output = Command::new(bash_program())
.arg(posix(binary))
.output()
.unwrap_or_else(|err| panic!("cannot run the installed binary: {err}"));
assert!(
output.status.success(),
"the installed binary did not run: {}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
fn installed_entries(directory: &Path) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(directory)
.unwrap_or_else(|err| panic!("cannot list {}: {err}", directory.display()))
.map(|entry| {
entry
.expect("a directory entry")
.file_name()
.to_string_lossy()
.into_owned()
})
.collect();
names.sort();
names
}
#[test]
fn install_sh_selects_the_right_artifact_for_every_supported_platform() {
let temporary = TempDir::new().expect("a temporary directory");
let base = temporary.path();
for (uname_s, uname_m, expected) in [
("Linux", "x86_64", "x86_64-unknown-linux-gnu"),
("Linux", "amd64", "x86_64-unknown-linux-gnu"),
("Linux", "aarch64", "aarch64-unknown-linux-gnu"),
("Linux", "arm64", "aarch64-unknown-linux-gnu"),
("Darwin", "arm64", "aarch64-apple-darwin"),
("Darwin", "aarch64", "aarch64-apple-darwin"),
("Darwin", "x86_64", "x86_64-apple-darwin"),
] {
let host = Host { uname_s, uname_m };
let (ok, output) = run_install_sh(&host, base, base, &["--print-plan"]);
assert!(ok, "install.sh refused {uname_s}/{uname_m}:\n{output}");
assert_eq!(
plan_value(&output, "target"),
expected,
"install.sh maps {uname_s}/{uname_m} to the wrong artifact:\n{output}"
);
}
}
#[test]
fn install_sh_refuses_a_platform_it_does_not_recognise() {
let temporary = TempDir::new().expect("a temporary directory");
let base = temporary.path();
for (uname_s, uname_m, must_mention, why) in [
(
"SunOS",
"x86_64",
"SunOS",
"the rejection has to name the value it saw, or an operator cannot \
tell a wrong override from an unsupported host",
),
(
"Linux",
"riscv64",
"cargo install",
"an unsupported architecture still has a way in -- building from \
source -- and the refusal is where to say so",
),
(
"MINGW64_NT-10.0-26100",
"x86_64",
"install.ps1",
"a Windows user piping this into Git Bash is the likeliest way to \
reach install.sh by mistake, and it must point at the right script \
rather than install a Linux binary that cannot run",
),
] {
let host = Host { uname_s, uname_m };
let (ok, output) = run_install_sh(&host, base, base, &["--print-plan"]);
assert!(
!ok,
"install.sh accepted {uname_s}/{uname_m}, which it publishes no \
artifact for:\n{output}"
);
assert!(
output.contains(must_mention),
"the refusal for {uname_s}/{uname_m} does not mention \
`{must_mention}`: {why}\n{output}"
);
}
}
#[test]
fn install_sh_defaults_to_the_documented_directory() {
let temporary = TempDir::new().expect("a temporary directory");
let home = temporary.path().join("fixture-home");
std::fs::create_dir_all(&home).expect("a fake home");
let (ok, output) = run_bash(
&install_script("install.sh"),
&["--print-plan"],
&[
("RUNNER_MANAGER_INSTALL_UNAME_S", "Linux"),
("RUNNER_MANAGER_INSTALL_UNAME_M", "x86_64"),
("RUNNER_MANAGER_INSTALL_BASE_URL", &posix(temporary.path())),
("HOME", &posix(&home)),
("RUNNER_MANAGER_INSTALL_DIR", ""),
],
);
assert!(ok, "install.sh --print-plan failed:\n{output}");
let directory = plan_value(&output, "install_dir");
assert!(
directory.ends_with("/fixture-home/.local/bin"),
"install.sh must default to `$HOME/.local/bin`, and it must be the HOME \
it was given rather than a hardcoded path. `~/.local/bin` is chosen \
because it belongs to the user rather than to a toolchain, so it does \
not move when the operator switches Node or Rust versions -- which an \
installed service's recorded absolute path cannot survive. \
Got `{directory}`.\n{output}"
);
assert_eq!(
plan_value(&output, "binary"),
format!("{directory}/runner-manager"),
"the plan's binary path must be the install directory plus the binary \
name; that path is what `service install` records\n{output}"
);
}
#[test]
fn install_sh_rejects_a_version_that_is_not_x_y_z() {
let temporary = TempDir::new().expect("a temporary directory");
let base = temporary.path();
for (argument, why) in [
("1.2", "a Cargo version has exactly three components"),
("v1.2.3", "the `v` belongs to the tag, not to the version"),
("abc", "not a version at all"),
("1.2.3-rc1", "pre-releases are not a v1 channel (D12)"),
] {
let (ok, output) = run_install_sh(&LINUX_X64, base, base, &["--version", argument]);
assert!(
!ok,
"install.sh accepted --version {argument}: {why}\n{output}"
);
assert!(
output.contains("is not X.Y.Z") || output.contains("belongs to the tag"),
"the refusal of --version {argument} must say what is wrong with \
it, not fail later as a download error:\n{output}"
);
}
}
struct Installed {
_temporary: TempDir,
release: FixtureRelease,
directory: PathBuf,
}
impl Installed {
fn binary(&self) -> PathBuf {
self.directory.join("runner-manager")
}
}
fn prepare(version: &str) -> Installed {
let temporary = TempDir::new().expect("a temporary directory");
let release = build_release(temporary.path(), version);
let directory = temporary.path().join("bin");
Installed {
_temporary: temporary,
release,
directory,
}
}
#[test]
fn install_sh_verifies_the_published_digest_and_installs_a_working_binary() {
let fixture = prepare("1.2.3");
let (ok, output) = run_install_sh(&LINUX_X64, &fixture.release.assets, &fixture.directory, &[]);
assert!(ok, "install.sh failed on a good release:\n{output}");
assert!(
output.contains("SHA-256 OK"),
"install.sh installed without reporting that it verified the archive:\n{output}"
);
let binary = fixture.binary();
assert!(
binary.is_file(),
"install.sh reported success and installed nothing to {}",
fixture.directory.display()
);
assert_eq!(
run_installed(&binary),
fixture.release.expected_output("x86_64-unknown-linux-gnu"),
"the installed file is not the binary from the archive"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&binary)
.expect("metadata")
.permissions()
.mode();
assert!(
mode & 0o111 != 0,
"the installed binary is not executable (mode {mode:o}). A file \
that has to be chmod'ed by hand is not an install."
);
}
assert!(
output.contains("Release 1.2.3"),
"install.sh must report the version it resolved from SHA256SUMS:\n{output}"
);
}
#[test]
fn install_sh_aborts_on_a_corrupted_archive_and_leaves_the_previous_install_alone() {
let fixture = prepare("1.2.3");
let (ok, output) = run_install_sh(&LINUX_X64, &fixture.release.assets, &fixture.directory, &[]);
assert!(ok, "the first install must succeed:\n{output}");
let before = run_installed(&fixture.binary());
substitute_payload(&fixture.release, "x86_64-unknown-linux-gnu");
let (ok, output) = run_install_sh(&LINUX_X64, &fixture.release.assets, &fixture.directory, &[]);
assert!(
!ok,
"install.sh installed an archive whose digest does not match the \
published one. `07-security.md` lists artifact tampering in transit as \
a threat whose only control is this check.\n{output}"
);
assert!(
output.contains("CHECKSUM MISMATCH"),
"the abort must say plainly what went wrong; a user seeing only a \
non-zero exit will assume a network problem and retry forever:\n{output}"
);
assert!(
output.contains("nothing has been\ninstalled") || output.contains("nothing has been"),
"the abort must tell the user that nothing was installed:\n{output}"
);
assert_eq!(
run_installed(&fixture.binary()),
before,
"a failed install replaced or damaged the binary that was already \
working. A failed upgrade must be a no-op."
);
assert_eq!(
installed_entries(&fixture.directory),
vec!["runner-manager".to_string()],
"the aborted install left a staging file behind in the install \
directory"
);
}
#[test]
fn install_sh_is_idempotent() {
let fixture = prepare("1.2.3");
for attempt in 1..=2 {
let (ok, output) =
run_install_sh(&LINUX_X64, &fixture.release.assets, &fixture.directory, &[]);
assert!(ok, "install.sh failed on attempt {attempt}:\n{output}");
}
assert_eq!(
installed_entries(&fixture.directory),
vec!["runner-manager".to_string()],
"running install.sh twice must leave exactly one working binary and no \
staging or backup files"
);
assert_eq!(
run_installed(&fixture.binary()),
fixture.release.expected_output("x86_64-unknown-linux-gnu"),
"the binary left by the second run does not work"
);
}
#[test]
fn install_sh_installs_the_exact_version_asked_for_or_nothing() {
let fixture = prepare("1.2.3");
let (ok, output) = run_install_sh(
&LINUX_X64,
&fixture.release.assets,
&fixture.directory,
&["--version", "1.2.3"],
);
assert!(ok, "--version 1.2.3 must install release 1.2.3:\n{output}");
assert!(
fixture.binary().is_file(),
"nothing was installed:\n{output}"
);
let (ok, output) = run_install_sh(
&LINUX_X64,
&fixture.release.assets,
&fixture.directory,
&["--version", "9.9.9"],
);
assert!(
!ok,
"install.sh installed 1.2.3 when it was asked for 9.9.9:\n{output}"
);
assert!(
output.contains("9.9.9") && output.contains("1.2.3"),
"the refusal must name what was asked for and what is available:\n{output}"
);
}
#[test]
fn install_sh_refuses_a_release_that_publishes_nothing_for_this_platform() {
let fixture = prepare("1.2.3");
let sums = std::fs::read_to_string(fixture.release.sums()).expect("the fixture SHA256SUMS");
let thinned: String = sums
.lines()
.filter(|line| !line.contains("x86_64-unknown-linux-gnu"))
.map(|line| format!("{line}\n"))
.collect();
assert!(
thinned.lines().count() == TARGETS.len() - 1,
"the fixture SHA256SUMS did not lose exactly one line; the assertion \
below would not be testing what it says"
);
std::fs::write(fixture.release.sums(), thinned).expect("rewriting SHA256SUMS");
let (ok, output) = run_install_sh(&LINUX_X64, &fixture.release.assets, &fixture.directory, &[]);
assert!(!ok, "install.sh installed something anyway:\n{output}");
assert!(
output.contains("lists no archive for x86_64-unknown-linux-gnu"),
"the refusal must name the platform the release is missing:\n{output}"
);
}
fn executable_text(name: &str) -> String {
let source = std::fs::read_to_string(install_script(name))
.unwrap_or_else(|err| panic!("cannot read {name}: {err}"));
let mut without_blocks = String::new();
let mut rest = source.as_str();
while let Some(open) = rest.find("<#") {
without_blocks.push_str(&rest[..open]);
match rest[open..].find("#>") {
Some(close) => rest = &rest[open + close + 2..],
None => {
rest = "";
break;
}
}
}
without_blocks.push_str(rest);
without_blocks
.lines()
.filter(|line| !line.trim_start().starts_with('#'))
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn neither_installer_offers_a_way_to_skip_the_checksum() {
for name in ["install.sh", "install.ps1"] {
let source = executable_text(name).to_lowercase();
assert!(
source.contains("sha256sums"),
"{name}'s executable text never reads SHA256SUMS, so the absences \
below would be vacuous -- a script that verifies nothing trivially \
has no flag to switch verification off"
);
for forbidden in [
"--skip-verify",
"--no-verify",
"--insecure",
"-skipverify",
"-noverify",
"-insecure",
] {
assert!(
!source.contains(forbidden),
"{name} accepts `{forbidden}`. The SHA-256 check is the only \
control `07-security.md` lists against a tampered release \
artifact; it does not get an off switch."
);
}
}
}
#[test]
fn install_sh_stays_runnable_by_a_posix_shell() {
let raw = std::fs::read_to_string(install_script("install.sh")).expect("install.sh");
assert!(
raw.starts_with("#!/bin/sh\n"),
"install.sh must declare `#!/bin/sh`: the README documents piping it \
into `sh`, and the shebang is what a reader checks it against"
);
let source = executable_text("install.sh");
assert!(
source.contains("case \"$uname_s\" in"),
"install.sh no longer looks like the script this scan was written \
against; the bashism assertions below would be checking nothing"
);
for (bashism, why) in [
("<<<", "here-strings are bash-only"),
(
"declare ",
"`declare` is bash-only; POSIX has no equivalent",
),
("${!", "indirect expansion is bash-only"),
(
",,}",
"case conversion in a parameter expansion is bash-only",
),
(
"^^}",
"case conversion in a parameter expansion is bash-only",
),
(
"function ",
"`function name()` is bash syntax; POSIX is `name()`",
),
("+=(", "arrays are bash-only"),
(
"local -",
"`local -a`/`local -n` are bash-only; plain `local` is fine",
),
(
"set -o pipefail",
"`pipefail` is a bash/ksh option that only recent dash grew. \
BusyBox ash -- Alpine's `/bin/sh` -- and dash before 0.5.12 reject \
the whole `set` on line 1. Both sibling scripts here use it, which \
is exactly why it is the likeliest thing to be pasted in",
),
(
"&>",
"`&>file` is bash's combined redirect; POSIX is `>file 2>&1`",
),
(
"echo -e",
"`echo -e` is bash; dash's `echo` interprets escapes with no flag \
and prints a literal `-e`. `printf` is the portable spelling, and \
it is what this script already uses",
),
] {
assert!(
!source.contains(bashism),
"install.sh uses `{bashism}`: {why}. It runs under `sh`, which on \
Debian and Ubuntu is dash and on Alpine is BusyBox ash."
);
}
for (number, line) in source.lines().enumerate() {
let number = number + 1;
assert!(
!line.replace("[[:", "").contains("[["),
"install.sh line {number} uses bash's `[[`; POSIX test is `[`:\n {line}"
);
assert!(
!(line.contains(" == ") && line.contains("[ ")),
"install.sh line {number} compares with `==` inside `[ ]`; POSIX \
test compares with `=`:\n {line}"
);
assert!(
!line.replace("$((", "").contains("(("),
"install.sh line {number} uses bash's `((...))` arithmetic command; \
POSIX has `$((...))` expansion, or `expr`:\n {line}"
);
assert!(
!has_substring_expansion(line),
"install.sh line {number} uses bash's `${{var:offset:length}}` \
substring expansion; POSIX parameter expansion has no offsets. \
`${{var:-default}}` and `${{var:+alt}}` are POSIX and are not what \
this flags:\n {line}"
);
assert!(
!has_ansi_c_quoting(line),
"install.sh line {number} uses bash's `$'...'` ANSI-C quoting; dash \
reads that as a `$` followed by a quoted string:\n {line}"
);
assert!(
!sources_a_file(line),
"install.sh line {number} runs `source`; POSIX spells it `.`:\n {line}"
);
}
}
fn has_substring_expansion(line: &str) -> bool {
let bytes = line.as_bytes();
let mut index = 0;
while let Some(offset) = line[index..].find("${") {
let mut cursor = index + offset + 2;
while cursor < bytes.len()
&& (bytes[cursor].is_ascii_alphanumeric() || bytes[cursor] == b'_')
{
cursor += 1;
}
if cursor + 1 < bytes.len() && bytes[cursor] == b':' && bytes[cursor + 1].is_ascii_digit() {
return true;
}
index = index + offset + 2;
}
false
}
fn has_ansi_c_quoting(line: &str) -> bool {
let bytes = line.as_bytes();
let mut index = 0;
while let Some(offset) = line[index..].find("$'") {
let at = index + offset;
let opens_a_word = match at.checked_sub(1).map(|previous| bytes[previous]) {
None => true,
Some(byte) => byte.is_ascii_whitespace() || matches!(byte, b'=' | b'(' | b'{' | b','),
};
if opens_a_word {
return true;
}
index = at + 2;
}
false
}
fn sources_a_file(line: &str) -> bool {
let trimmed = line.trim_start();
trimmed.starts_with("source ")
|| ["; source ", "&& source ", "|| source ", "( source "]
.iter()
.any(|opener| line.contains(opener))
}
static POWERSHELL_PROCESS: Mutex<()> = Mutex::new(());
fn powershell_process() -> MutexGuard<'static, ()> {
POWERSHELL_PROCESS
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn powershell_hosts() -> Vec<PathBuf> {
let mut hosts: Vec<PathBuf> = Vec::new();
for name in ["pwsh", "pwsh.exe"] {
if let Some(found) = find_program(name)
&& !hosts.contains(&found)
{
hosts.push(found);
}
}
if cfg!(windows) {
let mut candidates: Vec<PathBuf> = Vec::new();
if let Some(found) = find_program("powershell.exe") {
candidates.push(found);
}
let system_root = std::env::var_os("SystemRoot")
.unwrap_or_else(|| std::ffi::OsString::from(r"C:\Windows"));
candidates.push(
PathBuf::from(system_root)
.join("System32")
.join("WindowsPowerShell")
.join("v1.0")
.join("powershell.exe"),
);
for candidate in candidates {
if candidate.is_file() {
hosts.push(candidate);
break;
}
}
}
hosts
}
fn find_program(program: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path)
.map(|directory| directory.join(program))
.find(|candidate| candidate.is_file())
}
fn powershell_hosts_or_skip() -> Vec<PathBuf> {
let hosts = powershell_hosts();
if cfg!(windows) {
assert!(
!hosts.is_empty(),
"no PowerShell found on a Windows host. Windows PowerShell 5.1 is \
part of the operating system, so this is a broken PATH rather than \
a missing dependency."
);
assert!(
hosts.iter().any(|host| host
.file_name()
.is_some_and(|name| name.eq_ignore_ascii_case("powershell.exe"))),
"Windows PowerShell 5.1 is not among the hosts these tests will \
run ({hosts:?}). It is part of the operating system, and it is the \
host install.ps1 is deliberately written for -- a clean Windows \
machine has 5.1 and no PowerShell 7. Exercising only pwsh 7 is \
what previously let a 5.1-only breakage ship green."
);
return hosts;
}
if !hosts.is_empty() {
return hosts;
}
assert!(
std::env::var_os("CI").is_none(),
"no `pwsh` on PATH in CI. PowerShell 7 is preinstalled on GitHub's \
ubuntu and macOS runner images; if that stops being true, install it \
in the workflow rather than letting install.ps1 go untested."
);
eprintln!(
"SKIPPED: no PowerShell on PATH. install.ps1 is exercised on Windows \
and in CI; install `pwsh` to run it here."
);
hosts
}
fn is_windows_powershell(shell: &Path) -> bool {
shell
.file_name()
.is_some_and(|name| name.eq_ignore_ascii_case("powershell.exe"))
}
fn clean_windows_powershell_module_path() -> String {
let system_root = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".to_string());
format!(r"{system_root}\System32\WindowsPowerShell\v1.0\Modules")
}
fn supply_windows_host_environment(command: &mut Command) {
if !cfg!(windows) {
command.env("PROCESSOR_ARCHITECTURE", "AMD64");
}
}
fn run_install_ps1(
shell: &Path,
base: &Path,
install_dir: &Path,
arguments: &[&str],
) -> (bool, String) {
let script = install_script("install.ps1");
let mut command = Command::new(shell);
supply_windows_host_environment(&mut command);
command.arg("-NoProfile").arg("-NonInteractive");
if is_windows_powershell(shell) {
command.env("PSModulePath", clean_windows_powershell_module_path());
}
if cfg!(windows) {
command.arg("-ExecutionPolicy").arg("Bypass");
}
command.arg("-File").arg(&script);
command.arg("-BaseUrl").arg(base);
command.arg("-Dir").arg(install_dir);
command.args(arguments);
command.current_dir(repository_root());
let _runtime = powershell_process();
let output = command
.output()
.unwrap_or_else(|err| panic!("cannot run install.ps1: {err}"));
(
output.status.success(),
format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
),
)
}
#[test]
fn install_ps1_selects_the_windows_artifact_for_both_architectures() {
for shell in powershell_hosts_or_skip() {
let host = shell.display();
let temporary = TempDir::new().expect("a temporary directory");
let base = temporary.path();
for architecture in ["AMD64", "ARM64"] {
let (ok, output) =
run_install_ps1(&shell, base, base, &["-PrintPlan", "-Arch", architecture]);
assert!(
ok,
"install.ps1 refused {architecture} under {host}:\n{output}"
);
assert_eq!(
plan_value(&output, "target"),
"x86_64-pc-windows-msvc",
"install.ps1 maps {architecture} to the wrong artifact under \
{host}:\n{output}"
);
}
let (ok, output) = run_install_ps1(&shell, base, base, &["-PrintPlan", "-Arch", "x86"]);
assert!(
!ok,
"install.ps1 accepted a 32-bit x86 host, which publishes no \
artifact, under {host}:\n{output}"
);
assert!(
output.contains("cargo install"),
"the refusal must point at the way in that still exists ({host}):\n{output}"
);
}
}
#[test]
fn install_ps1_defaults_to_the_documented_directory() {
for shell in powershell_hosts_or_skip() {
let host = shell.display();
let temporary = TempDir::new().expect("a temporary directory");
let local_app_data = temporary.path().join("LocalAppData");
std::fs::create_dir_all(&local_app_data).expect("a fake LOCALAPPDATA");
let script = install_script("install.ps1");
let mut command = Command::new(&shell);
supply_windows_host_environment(&mut command);
command.arg("-NoProfile").arg("-NonInteractive");
if cfg!(windows) {
command.arg("-ExecutionPolicy").arg("Bypass");
}
command.arg("-File").arg(&script);
command.arg("-BaseUrl").arg(temporary.path());
command.arg("-PrintPlan");
command.env("LOCALAPPDATA", &local_app_data);
command.env("RUNNER_MANAGER_INSTALL_DIR", "");
command.current_dir(repository_root());
let _runtime = powershell_process();
let output = command.output().expect("cannot run install.ps1");
let text = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
output.status.success(),
"install.ps1 --print-plan failed under {host}:\n{text}"
);
let directory = plan_value(&text, "install_dir");
assert!(
directory.starts_with(&local_app_data.to_string_lossy().to_string()),
"install.ps1 must default under %LOCALAPPDATA%, got {directory} \
under {host}"
);
assert!(
directory.contains("Programs") && directory.ends_with("runner-manager"),
"install.ps1 must default to %LOCALAPPDATA%\\Programs\\runner-manager. \
It is per-user, needs no elevation, and does not move when a \
toolchain moves -- which an installed service's recorded absolute \
path cannot survive. Got {directory} under {host}"
);
}
}
#[test]
fn install_ps1_verifies_the_published_digest_and_installs() {
for shell in powershell_hosts_or_skip() {
let host = shell.display();
let fixture = prepare("1.2.3");
let (ok, output) =
run_install_ps1(&shell, &fixture.release.assets, &fixture.directory, &[]);
assert!(
ok,
"install.ps1 failed on a good release under {host}:\n{output}"
);
assert!(
output.contains("SHA-256 OK"),
"install.ps1 installed without reporting that it verified the \
archive ({host}):\n{output}"
);
let binary = fixture.directory.join("runner-manager.exe");
assert!(
binary.is_file(),
"install.ps1 reported success and installed nothing to {} under {host}",
fixture.directory.display()
);
assert_eq!(
std::fs::read(&binary).expect("the installed binary"),
std::fs::read(
fixture
.release
.staged("x86_64-pc-windows-msvc")
.join("runner-manager.exe")
)
.expect("the staged binary"),
"the installed file is not byte-identical to the one in the archive \
({host})"
);
assert!(
output.contains("Release 1.2.3"),
"install.ps1 must report the version it resolved from SHA256SUMS \
({host}):\n{output}"
);
}
}
#[test]
fn install_ps1_aborts_on_a_corrupted_archive_and_leaves_the_previous_install_alone() {
for shell in powershell_hosts_or_skip() {
let host = shell.display();
let fixture = prepare("1.2.3");
let (ok, output) =
run_install_ps1(&shell, &fixture.release.assets, &fixture.directory, &[]);
assert!(ok, "the first install must succeed under {host}:\n{output}");
let binary = fixture.directory.join("runner-manager.exe");
let before = std::fs::read(&binary).expect("the installed binary");
substitute_payload(&fixture.release, "x86_64-pc-windows-msvc");
let (ok, output) =
run_install_ps1(&shell, &fixture.release.assets, &fixture.directory, &[]);
assert!(
!ok,
"install.ps1 installed an archive whose digest does not match the \
published one, under {host}:\n{output}"
);
assert!(
output.contains("CHECKSUM MISMATCH"),
"the abort must say plainly what went wrong ({host}):\n{output}"
);
assert_eq!(
std::fs::read(&binary).expect("the installed binary"),
before,
"a failed install replaced or damaged a binary that was already \
working, under {host}. A failed upgrade must be a no-op."
);
assert_eq!(
installed_entries(&fixture.directory),
vec!["runner-manager.exe".to_string()],
"the aborted install left a staging file behind ({host})"
);
}
}
#[test]
fn install_ps1_is_idempotent_and_pins_the_version_asked_for() {
for shell in powershell_hosts_or_skip() {
let host = shell.display();
let fixture = prepare("1.2.3");
for attempt in 1..=2 {
let (ok, output) =
run_install_ps1(&shell, &fixture.release.assets, &fixture.directory, &[]);
assert!(
ok,
"install.ps1 failed on attempt {attempt} under {host}:\n{output}"
);
}
assert_eq!(
installed_entries(&fixture.directory),
vec!["runner-manager.exe".to_string()],
"running install.ps1 twice must leave exactly one binary and no \
staging files ({host})"
);
let (ok, output) = run_install_ps1(
&shell,
&fixture.release.assets,
&fixture.directory,
&["-Version", "9.9.9"],
);
assert!(
!ok,
"install.ps1 installed 1.2.3 when it was asked for 9.9.9, under \
{host}:\n{output}"
);
assert!(
output.contains("9.9.9") && output.contains("1.2.3"),
"the refusal must name what was asked for and what is available \
({host}):\n{output}"
);
let (ok, output) = run_install_ps1(
&shell,
&fixture.release.assets,
&fixture.directory,
&["-Version", "v1.2.3"],
);
assert!(
!ok,
"install.ps1 accepted -Version v1.2.3 under {host}:\n{output}"
);
assert!(
output.contains("belongs to the tag"),
"the refusal must explain that the `v` is the tag's, not the \
version's ({host}):\n{output}"
);
}
}
fn documented_windows_two_step_run_command() -> String {
let source = std::fs::read_to_string(repository_root().join("README.md"))
.expect("README.md must be readable")
.replace("\r\n", "\n");
let mut blocks: Vec<Vec<String>> = Vec::new();
let mut current: Option<Vec<String>> = None;
for line in source.lines() {
if let Some(rest) = line.trim_end().strip_prefix("```") {
match current.take() {
Some(body) => blocks.push(body),
None => {
if rest.trim() == "powershell" {
current = Some(Vec::new());
}
}
}
continue;
}
if let Some(body) = current.as_mut() {
body.push(line.trim_end().to_string());
}
}
let block = blocks
.iter()
.find(|body| {
body.iter()
.any(|line| line.contains("-OutFile install.ps1"))
})
.unwrap_or_else(|| {
panic!(
"README.md has no ```powershell block that downloads install.ps1 \
with `-OutFile`. That block is the two-step download-read-run \
form `09-release-distribution.md` requires for operators who \
will not pipe a remote script into a shell, and this test runs \
its last line verbatim."
)
});
block
.iter()
.rev()
.find(|line| !line.trim().is_empty())
.expect("the two-step block must end with the step that runs the script")
.trim()
.to_string()
}
fn collapse_whitespace(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn run_under_restricted_policy(
shell: &Path,
working_directory: &Path,
command: &str,
envs: &[(&str, &str)],
) -> (bool, String) {
let script = format!(
"Set-ExecutionPolicy -Scope Process -ExecutionPolicy Restricted -Force; \
if ((Get-ExecutionPolicy) -ne 'Restricted') {{ \
Write-Output \"POLICY-NOT-RESTRICTED \
MachinePolicy=$(Get-ExecutionPolicy -Scope MachinePolicy) \
UserPolicy=$(Get-ExecutionPolicy -Scope UserPolicy) \
Effective=$(Get-ExecutionPolicy)\"; exit 0 }}; {command}"
);
let mut process = Command::new(shell);
process
.arg("-NoProfile")
.arg("-NonInteractive")
.arg("-Command")
.arg(&script);
if is_windows_powershell(shell) {
process.env("PSModulePath", clean_windows_powershell_module_path());
}
process.current_dir(working_directory);
for (key, value) in envs {
process.env(key, value);
}
let _runtime = powershell_process();
let output = process
.output()
.unwrap_or_else(|err| panic!("cannot run {}: {err}", shell.display()));
(
output.status.success(),
format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
),
)
}
#[test]
fn the_documented_windows_two_step_form_installs_under_a_restricted_policy() {
if !cfg!(windows) {
eprintln!(
"SKIPPED: execution policy is a Windows concept. The documented \
two-step form is exercised on the windows leg."
);
return;
}
let run_step = documented_windows_two_step_run_command();
for shell in powershell_hosts_or_skip() {
let host = shell.display().to_string();
let fixture = prepare("1.2.3");
let download = fixture.release.root.join("download");
std::fs::create_dir_all(&download).expect("a download directory");
std::fs::copy(install_script("install.ps1"), download.join("install.ps1"))
.expect("copying install.ps1 the way the documented download would");
let (ran, refusal) =
run_under_restricted_policy(&shell, &download, "& '.\\install.ps1' -PrintPlan", &[]);
if refusal.contains("POLICY-NOT-RESTRICTED") {
let pinned_by_group_policy = !refusal.contains("MachinePolicy=Undefined")
|| !refusal.contains("UserPolicy=Undefined");
assert!(
pinned_by_group_policy,
"the execution policy could not be lowered to Restricted \
({host}), and no Group Policy explains it. Process scope \
outranks every other scope, so something else is overriding \
it and this test's negative control is no longer measuring \
what it claims:\n{refusal}"
);
eprintln!(
"SKIPPED: a Group Policy pins the execution policy on this \
machine ({host}), so `Restricted` cannot be constructed. \
Reported scopes: {}",
refusal.trim()
);
continue;
}
assert!(
!ran,
"running `.\\install.ps1` as a FILE succeeded under a Restricted \
execution policy on {host}. That is not what Windows does, so \
either the policy was not applied or this test is measuring \
nothing:\n{refusal}"
);
assert!(
collapse_whitespace(&refusal).contains("running scripts is disabled"),
"the file form was refused under {host}, but not by the execution \
policy -- so the policy is not what this test is holding \
constant:\n{refusal}"
);
let assets = posix(&fixture.release.assets);
let directory = posix(&fixture.directory);
let environment = [
("RUNNER_MANAGER_INSTALL_BASE_URL", assets.as_str()),
("RUNNER_MANAGER_INSTALL_DIR", directory.as_str()),
];
let (ok, output) = run_under_restricted_policy(&shell, &download, &run_step, &environment);
assert!(
ok,
"the README documents `{run_step}` as the last step of the two-step \
install, and it does not work on a Windows host with the default \
`Restricted` execution policy ({host}). An operator who declines to \
pipe a remote script into a shell is refused at the last step, \
after reading the script:\n{output}"
);
assert!(
output.contains("SHA-256 OK"),
"the documented form installed without reporting that it verified \
the archive ({host}):\n{output}"
);
let binary = fixture.directory.join("runner-manager.exe");
assert!(
binary.is_file(),
"the documented form reported success and installed nothing to {} \
under {host}:\n{output}",
fixture.directory.display()
);
let before = std::fs::read(&binary).expect("the installed binary");
assert_eq!(
before,
std::fs::read(
fixture
.release
.staged("x86_64-pc-windows-msvc")
.join("runner-manager.exe")
)
.expect("the staged binary"),
"the .NET zip reader installed something that is not byte-for-byte \
the archive's payload under {host}. The fixture archive carries a \
directory entry, the binary and a LICENSE, so an extractor that \
takes the first entry and stops, or that writes a truncated file, \
reaches here rather than passing on a name:\n{output}"
);
substitute_payload(&fixture.release, "x86_64-pc-windows-msvc");
let (ok, output) = run_under_restricted_policy(&shell, &download, &run_step, &environment);
assert!(
!ok,
"the documented form installed an archive whose digest does not \
match the published one, under {host}:\n{output}"
);
assert!(
output.contains("CHECKSUM MISMATCH"),
"the abort message did not survive the documented invocation on \
{host}. A user who sees only a non-zero exit -- or a closed \
terminal -- assumes a network problem and retries forever:\n{output}"
);
assert_eq!(
std::fs::read(&binary).expect("the installed binary"),
before,
"a failed install through the documented form replaced or damaged a \
binary that was already working ({host}). A failed upgrade must be \
a no-op."
);
}
}
#[test]
fn install_sh_removes_its_staging_file_when_the_install_step_fails() {
let fixture = prepare("1.2.3");
let (ok, output) = run_install_sh(&LINUX_X64, &fixture.release.assets, &fixture.directory, &[]);
assert!(ok, "the first install must succeed:\n{output}");
let shim = fixture.release.root.join("shim");
std::fs::create_dir_all(&shim).expect("a shim directory");
let failing_mv = shim.join("mv");
std::fs::write(&failing_mv, "#!/bin/sh\nexit 1\n").expect("a failing mv");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&failing_mv, std::fs::Permissions::from_mode(0o755))
.expect("the shim must be executable");
}
let wrapper = format!(
"cd '{}' && PATH=\"$PWD:$PATH\" && export PATH && exec '{}'",
posix(&shim),
posix(&install_script("install.sh"))
);
let mut command = Command::new(bash_program());
command.arg("-c").arg(&wrapper);
command.env("RUNNER_MANAGER_INSTALL_UNAME_S", LINUX_X64.uname_s);
command.env("RUNNER_MANAGER_INSTALL_UNAME_M", LINUX_X64.uname_m);
command.env(
"RUNNER_MANAGER_INSTALL_BASE_URL",
posix(&fixture.release.assets),
);
command.env("RUNNER_MANAGER_INSTALL_DIR", posix(&fixture.directory));
let finished = command.output().expect("cannot run install.sh");
let ok = finished.status.success();
let output = format!(
"{}{}",
String::from_utf8_lossy(&finished.stdout),
String::from_utf8_lossy(&finished.stderr)
);
assert!(
!ok,
"`mv` was shadowed with a command that always fails and install.sh \
still reported success, so the branch this test exists for was never \
entered:\n{output}"
);
assert!(
output.contains("could not install into"),
"the failure must name the step that failed:\n{output}"
);
assert_eq!(
installed_entries(&fixture.directory),
vec!["runner-manager".to_string()],
"install.sh left its staging file in the install directory after a \
failed install. The EXIT trap has to remove the staged path as well as \
`$work`: the staged path is the one temporary file that is NOT under \
`$work`, and nothing else will ever clean it up."
);
assert_eq!(
run_installed(&fixture.binary()),
fixture.release.expected_output("x86_64-unknown-linux-gnu"),
"the binary that was already working must survive a failed upgrade"
);
}
#[test]
fn install_sh_refuses_a_destination_that_is_a_directory() {
let fixture = prepare("1.2.3");
let destination = fixture.directory.join("runner-manager");
std::fs::create_dir_all(&destination).expect("a directory where the binary belongs");
let (ok, output) = run_install_sh(&LINUX_X64, &fixture.release.assets, &fixture.directory, &[]);
assert!(
!ok,
"install.sh reported SUCCESS with a directory where the binary belongs. \
`mv -f` moved the staged file inside it and exited 0, so the `|| fail` \
never ran:\n{output}"
);
assert!(
output.contains("is a directory, not a file"),
"the refusal must name the cause. `could not install into ...` sends \
the reader to permissions, which is not what is wrong:\n{output}"
);
assert!(
!output.contains("Installed runner-manager"),
"install.sh announced an install it did not perform:\n{output}"
);
assert_eq!(
installed_entries(&destination),
Vec::<String>::new(),
"install.sh moved the staged binary INSIDE the directory that was \
standing where the binary belongs. That is the false success this \
guard exists to prevent, now with a non-zero exit in front of it."
);
assert_eq!(
installed_entries(&fixture.directory),
vec!["runner-manager".to_string()],
"install.sh left its staging file in the install directory after \
refusing. The EXIT trap removes the staged path, and this is the case \
where it must still be there to remove."
);
}
#[test]
fn install_ps1_refuses_a_destination_that_is_a_directory() {
for shell in powershell_hosts_or_skip() {
let host = shell.display().to_string();
let fixture = prepare("1.2.3");
let destination = fixture.directory.join("runner-manager.exe");
std::fs::create_dir_all(&destination).expect("a directory where the binary belongs");
let (ok, output) =
run_install_ps1(&shell, &fixture.release.assets, &fixture.directory, &[]);
assert!(
!ok,
"install.ps1 reported SUCCESS with a directory where the binary \
belongs ({host}). `Move-Item -Force` moved the staged file inside \
it and did not throw, so the `catch` never ran:\n{output}"
);
assert!(
output.contains("is a directory, not a file"),
"the refusal must name the cause ({host}). `could not replace ...` \
sends the reader to the running-agent advice, which is not what is \
wrong here:\n{output}"
);
assert!(
!output.contains("Installed runner-manager"),
"install.ps1 announced an install it did not perform \
({host}):\n{output}"
);
assert_eq!(
installed_entries(&destination),
Vec::<String>::new(),
"install.ps1 moved the staged binary INSIDE the directory standing \
where the binary belongs ({host})"
);
assert_eq!(
installed_entries(&fixture.directory),
vec!["runner-manager.exe".to_string()],
"install.ps1 left its staging file in the install directory after \
refusing ({host}). The guard removes the staged copy before it \
fails, the way the `catch` beside it already did."
);
}
}
fn powershell_seven_modules() -> Option<PathBuf> {
for shell in powershell_hosts() {
if is_windows_powershell(&shell) {
continue;
}
if let Some(modules) = shell.parent().map(|directory| directory.join("Modules"))
&& modules.is_dir()
{
return Some(modules);
}
}
None
}
#[test]
fn install_ps1_verifies_the_digest_where_get_filehash_is_shadowed() {
if !cfg!(windows) {
eprintln!("SKIPPED: Windows PowerShell 5.1 exists only on Windows.");
return;
}
let Some(shell) = powershell_hosts_or_skip()
.into_iter()
.find(|host| is_windows_powershell(host))
else {
return;
};
let Some(seven) = powershell_seven_modules() else {
assert!(
std::env::var_os("CI").is_none(),
"PowerShell 7 is preinstalled on GitHub's windows runner image, so \
its module directory not being found in CI means this test cannot \
construct the condition it exists for -- and the .NET digest \
fallback in install.ps1 would then be executed by nothing."
);
eprintln!(
"SKIPPED: no PowerShell 7 on this machine, so 5.1's module path \
cannot be shadowed the way a dual-install shadows it."
);
return;
};
let shadowed = format!(
"{};{}",
seven.display(),
clean_windows_powershell_module_path()
);
let mut probe = Command::new(&shell);
probe
.arg("-NoProfile")
.arg("-NonInteractive")
.arg("-Command")
.arg("if (Get-Command Get-FileHash -ErrorAction SilentlyContinue) { 'PRESENT' } else { 'ABSENT' }")
.env("PSModulePath", &shadowed);
let _runtime = powershell_process();
let probed = probe.output().expect("cannot run Windows PowerShell");
drop(_runtime);
let probed = String::from_utf8_lossy(&probed.stdout).trim().to_string();
if probed != "ABSENT" {
eprintln!(
"SKIPPED: `Get-FileHash` still resolves under a PowerShell 7 module \
path on this machine (probe said {probed:?}), so the shadowing this \
test reproduces no longer happens here."
);
return;
}
let fixture = prepare("1.2.3");
let script = install_script("install.ps1");
let run = |arguments: &[&str]| -> (bool, String) {
let mut command = Command::new(&shell);
command
.arg("-NoProfile")
.arg("-NonInteractive")
.arg("-ExecutionPolicy")
.arg("Bypass")
.arg("-File")
.arg(&script)
.arg("-BaseUrl")
.arg(&fixture.release.assets)
.arg("-Dir")
.arg(&fixture.directory)
.args(arguments)
.env("PSModulePath", &shadowed)
.current_dir(repository_root());
let _runtime = powershell_process();
let output = command.output().expect("cannot run install.ps1");
(
output.status.success(),
format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
),
)
};
let (ok, output) = run(&[]);
assert!(
ok,
"install.ps1 could not install under a Windows PowerShell 5.1 whose \
`Get-FileHash` is shadowed by a PowerShell 7 install. That is a real \
machine, and the failure lands after the download at the verification \
step:\n{output}"
);
assert!(
output.contains("SHA-256 OK"),
"the fallback digest path installed without reporting that it verified \
the archive:\n{output}"
);
let binary = fixture.directory.join("runner-manager.exe");
let before = std::fs::read(&binary).expect("the installed binary");
substitute_payload(&fixture.release, "x86_64-pc-windows-msvc");
let (ok, output) = run(&[]);
assert!(
!ok,
"the fallback digest path accepted an archive whose digest does not \
match the published one. A fallback that cannot refuse is worse than \
no fallback: it turns a loud missing-cmdlet error into a silent \
unverified install:\n{output}"
);
assert!(
output.contains("CHECKSUM MISMATCH"),
"the fallback path aborted without saying why:\n{output}"
);
assert_eq!(
std::fs::read(&binary).expect("the installed binary"),
before,
"a failed install through the fallback path damaged a working binary"
);
}
fn rewrite_sums_as_binary_mode(release: &FixtureRelease) {
let text = std::fs::read_to_string(release.sums()).expect("the fixture SHA256SUMS");
let rewritten: String = text
.lines()
.map(|line| match line.split_once(" ") {
Some((hash, name)) => format!("{hash} *{name}\n"),
None => format!("{line}\n"),
})
.collect();
assert!(
rewritten.contains(" *runner-manager-"),
"the fixture rewrite produced no binary-mode line:\n{rewritten}"
);
std::fs::write(release.sums(), rewritten).expect("rewriting SHA256SUMS");
}
#[test]
fn both_installers_read_the_binary_mode_form_of_sha256sums() {
let fixture = prepare("1.2.3");
rewrite_sums_as_binary_mode(&fixture.release);
let (ok, output) = run_install_sh(&LINUX_X64, &fixture.release.assets, &fixture.directory, &[]);
assert!(
ok,
"install.sh refused a SHA256SUMS in the binary-mode form that \
`sha256sum -b` writes and `sha256sum -c` verifies:\n{output}"
);
assert_eq!(
run_installed(&fixture.binary()),
fixture.release.expected_output("x86_64-unknown-linux-gnu"),
"install.sh parsed the binary-mode form but installed the wrong thing"
);
for shell in powershell_hosts_or_skip() {
let host = shell.display();
let fixture = prepare("1.2.3");
rewrite_sums_as_binary_mode(&fixture.release);
let (ok, output) =
run_install_ps1(&shell, &fixture.release.assets, &fixture.directory, &[]);
assert!(
ok,
"install.ps1 refused a SHA256SUMS in the binary-mode form under \
{host}:\n{output}"
);
assert!(
fixture.directory.join("runner-manager.exe").is_file(),
"install.ps1 reported success and installed nothing ({host}):\n{output}"
);
}
}
#[test]
fn install_sh_tells_an_unreadable_checksum_file_from_a_missing_platform() {
let fixture = prepare("1.2.3");
std::fs::write(
fixture.release.sums(),
"<html><head><title>404 Not Found</title></head></html>\n",
)
.expect("overwriting SHA256SUMS");
let (ok, output) = run_install_sh(&LINUX_X64, &fixture.release.assets, &fixture.directory, &[]);
assert!(
!ok,
"install.sh installed something from a SHA256SUMS it could not \
parse:\n{output}"
);
assert!(
output.contains("no line this script can read"),
"the refusal must say the file could not be read at all:\n{output}"
);
assert!(
!output.contains("does not publish that platform"),
"install.sh reported an unparseable SHA256SUMS as a release that \
skipped this platform. The user then goes looking for a missing build \
instead of a corrupted download:\n{output}"
);
}
fn add_decoy_lines(release: &FixtureRelease) {
let mut text = std::fs::read_to_string(release.sums()).expect("the fixture SHA256SUMS");
for (target, extension, _) in TARGETS {
text.push_str(&format!(
"{} vendored-runner-manager-{}-{target}.{extension}\n",
"b".repeat(64),
release.version
));
text.push_str(&format!(
"{} runner-manager-{}-{target}.{extension}.sig\n",
"c".repeat(64),
release.version
));
text.push_str(&format!(
"junk {} runner-manager-{}-{target}.{extension}\n",
"d".repeat(64),
release.version
));
text.push_str(&format!(
"{} runner-manager-{}-{target}.{extension}\n",
"e".repeat(70),
release.version
));
}
std::fs::write(release.sums(), &text).expect("rewriting SHA256SUMS");
let written = std::fs::read_to_string(release.sums()).expect("the rewritten SHA256SUMS");
for (target, extension, _) in TARGETS {
for shape in [
format!(
"{} vendored-runner-manager-{}-{target}.{extension}",
"b".repeat(64),
release.version
),
format!(
"{} runner-manager-{}-{target}.{extension}.sig",
"c".repeat(64),
release.version
),
format!(
"junk {} runner-manager-{}-{target}.{extension}",
"d".repeat(64),
release.version
),
format!(
"{} runner-manager-{}-{target}.{extension}",
"e".repeat(70),
release.version
),
] {
assert!(
written.lines().any(|line| line == shape),
"the decoy `{shape}` is not in the SHA256SUMS the scripts will \
read. Without it the anchor it constrains is untested and \
every assertion below passes for the wrong reason."
);
}
}
}
#[test]
fn both_installers_match_the_whole_asset_name_when_neighbours_are_published() {
let fixture = prepare("1.2.3");
add_decoy_lines(&fixture.release);
let (ok, output) = run_install_sh(&LINUX_X64, &fixture.release.assets, &fixture.directory, &[]);
assert!(
ok,
"install.sh could not resolve an archive in a release that also \
publishes a `.sig`, a vendored rebuild, and two malformed digest \
fields beside it. Anchored at both ends there is exactly one match; \
unanchored there are more:\n{output}"
);
assert!(
output.lines().any(|line| line.trim()
== "Release 1.2.3, asset runner-manager-1.2.3-x86_64-unknown-linux-gnu.tar.gz"),
"install.sh resolved the wrong asset out of a release with decoys \
beside the archive:\n{output}"
);
assert_eq!(
run_installed(&fixture.binary()),
fixture.release.expected_output("x86_64-unknown-linux-gnu"),
"install.sh installed something other than the archive SHA256SUMS names"
);
for shell in powershell_hosts_or_skip() {
let host = shell.display();
let fixture = prepare("1.2.3");
add_decoy_lines(&fixture.release);
let (ok, output) =
run_install_ps1(&shell, &fixture.release.assets, &fixture.directory, &[]);
assert!(
ok,
"install.ps1 could not resolve an archive in a release that also \
publishes a `.sig`, a vendored rebuild, and two malformed digest \
fields beside it ({host}):\n{output}"
);
assert!(
output.lines().any(|line| line.trim()
== "Release 1.2.3, asset runner-manager-1.2.3-x86_64-pc-windows-msvc.zip"),
"install.ps1 resolved the wrong asset out of a release with decoys \
beside the archive ({host}):\n{output}"
);
assert_eq!(
std::fs::read(fixture.directory.join("runner-manager.exe"))
.expect("the installed binary"),
std::fs::read(
fixture
.release
.staged("x86_64-pc-windows-msvc")
.join("runner-manager.exe")
)
.expect("the staged binary"),
"install.ps1 installed something other than the archive SHA256SUMS \
names ({host})"
);
}
}
fn dash_program() -> Option<PathBuf> {
if let Some(explicit) = std::env::var_os("RUNNER_MANAGER_DASH") {
let path = PathBuf::from(explicit);
if path.is_file() {
return Some(path);
}
}
for name in ["dash", "dash.exe"] {
if let Some(found) = find_program(name) {
return Some(found);
}
}
if !cfg!(windows) {
let standard = PathBuf::from("/bin/dash");
if standard.is_file() {
return Some(standard);
}
}
if let Some(git) = find_program("git.exe")
&& let Some(root) = git.parent().and_then(Path::parent)
{
let candidate = root.join("usr").join("bin").join("dash.exe");
if candidate.is_file() {
return Some(candidate);
}
}
let standard = PathBuf::from(r"C:\Program Files\Git\usr\bin\dash.exe");
if standard.is_file() {
return Some(standard);
}
None
}
fn dash_or_skip() -> Option<PathBuf> {
if let Some(found) = dash_program() {
return Some(found);
}
assert!(
std::env::var_os("CI").is_none() || cfg!(target_os = "macos"),
"no `dash` found in CI. Ubuntu's /bin/sh IS dash and Git for Windows \
ships usr/bin/dash.exe, so on those two legs this is a broken \
environment rather than a missing dependency -- and this is the only \
test that runs install.sh under a shell that is not bash. macOS ships \
no dash and is the leg allowed to skip."
);
eprintln!(
"SKIPPED: no dash on this machine. install.sh's POSIX compliance is \
then covered only by the static scan, which catches only what somebody \
thought to list."
);
None
}
fn supply_shell_tool_path(command: &mut Command, shell: &Path) {
let Some(directory) = shell.parent() else {
return;
};
let inherited = std::env::var_os("PATH").unwrap_or_default();
let paths = std::iter::once(directory.to_path_buf()).chain(std::env::split_paths(&inherited));
let joined = std::env::join_paths(paths).unwrap_or_else(|err| {
panic!(
"cannot put {} on PATH for {}: {err}",
directory.display(),
shell.display()
)
});
command.env("PATH", joined);
}
fn run_with_shell(
shell: &Path,
script: &Path,
arguments: &[&str],
envs: &[(&str, &str)],
) -> (bool, String) {
let mut command = Command::new(shell);
supply_shell_tool_path(&mut command, shell);
command.arg(posix(script));
command.args(arguments);
command.current_dir(repository_root());
for (key, value) in envs {
command.env(key, value);
}
let output = command.output().unwrap_or_else(|err| {
panic!(
"cannot run {} under {}: {err}",
posix(script),
shell.display()
)
});
(
output.status.success(),
format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
),
)
}
#[test]
fn install_sh_installs_end_to_end_under_a_real_posix_shell() {
let Some(dash) = dash_or_skip() else {
return;
};
let mut probe = Command::new(&dash);
supply_shell_tool_path(&mut probe, &dash);
let probe = probe
.arg("-c")
.arg("for tool in awk sed grep cut tr mktemp tar; do command -v \"$tool\" || exit 1; done")
.output()
.unwrap_or_else(|err| panic!("cannot probe dash at {}: {err}", dash.display()));
assert!(
probe.status.success(),
"dash at {} cannot see every POSIX tool install.sh requires. The test \
would then measure a broken direct-shell PATH instead of the \
installer:\n{}{}",
dash.display(),
String::from_utf8_lossy(&probe.stdout),
String::from_utf8_lossy(&probe.stderr)
);
let fixture = prepare("1.2.3");
let assets = posix(&fixture.release.assets);
let directory = posix(&fixture.directory);
let environment = [
("RUNNER_MANAGER_INSTALL_UNAME_S", "Linux"),
("RUNNER_MANAGER_INSTALL_UNAME_M", "x86_64"),
("RUNNER_MANAGER_INSTALL_BASE_URL", assets.as_str()),
("RUNNER_MANAGER_INSTALL_DIR", directory.as_str()),
];
let script = install_script("install.sh");
let (ok, output) = run_with_shell(&dash, &script, &[], &environment);
assert!(
ok,
"install.sh failed under dash ({}). The documented command is \
`curl ... | sh`, and on Debian and Ubuntu that shell IS this \
one:\n{output}",
dash.display()
);
assert!(
output.contains("SHA-256 OK"),
"install.sh installed under dash without reporting that it verified \
the archive:\n{output}"
);
assert_eq!(
run_installed(&fixture.binary()),
fixture.release.expected_output("x86_64-unknown-linux-gnu"),
"the file install.sh installed under dash is not the binary from the \
archive"
);
substitute_payload(&fixture.release, "x86_64-unknown-linux-gnu");
let (ok, output) = run_with_shell(&dash, &script, &[], &environment);
assert!(
!ok,
"install.sh under dash installed an archive whose digest does not \
match the published one:\n{output}"
);
assert!(
output.contains("CHECKSUM MISMATCH"),
"the abort must say plainly what went wrong under dash too:\n{output}"
);
assert_eq!(
installed_entries(&fixture.directory),
vec!["runner-manager".to_string()],
"the aborted install under dash left a staging file behind"
);
}