use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use sha2::{Digest, Sha256};
use super::{CliError, Context, Failure, UpdateArgs, write_failed};
use runner_manager_platform::service::InstallRecord;
#[path = "update_force.rs"]
pub(super) mod force;
use force::{force_service_handover, preflight_force, report_force_check};
pub const UPDATE_BASE_URL_VARIABLE: &str = "RUNNER_MANAGER_UPDATE_BASE_URL";
const NPM_PACKAGE: &str = "@ivan-murzak/runner-manager";
const BREW_FORMULA: &str = "IvanMurzak/tap/runner-manager";
const CARGO_CRATE: &str = "runner-manager";
#[derive(Debug, Clone)]
pub(crate) enum AssetSource {
Remote { base: String },
Local { directory: PathBuf },
}
impl std::fmt::Display for AssetSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Remote { base } => f.write_str(base),
Self::Local { directory } => write!(f, "{}", display_path(directory)),
}
}
}
impl AssetSource {
pub(crate) fn resolve(err: &mut dyn Write) -> Result<Self, CliError> {
let Some(raw) = std::env::var_os(UPDATE_BASE_URL_VARIABLE) else {
return Ok(Self::Remote {
base: format!(
"{}/releases/latest/download",
env!("CARGO_PKG_REPOSITORY").trim_end_matches('/')
),
});
};
let raw = raw.to_string_lossy().into_owned();
if !raw.starts_with("http://") && !raw.starts_with("https://") {
let directory = PathBuf::from(&raw);
if !directory.is_dir() {
return Err(CliError::with_remedy(
Failure::InvalidArgument,
format!(
"{UPDATE_BASE_URL_VARIABLE} is not an http(s) URL and not a directory: {raw}"
),
"unset RUNNER_MANAGER_UPDATE_BASE_URL",
));
}
let _ = writeln!(
err,
"warning: reading release assets from {raw} instead of GitHub, because \
{UPDATE_BASE_URL_VARIABLE} is set."
);
return Ok(Self::Local { directory });
}
let parsed = reqwest::Url::parse(&raw).map_err(|source| {
CliError::new(
Failure::InvalidArgument,
format!("{UPDATE_BASE_URL_VARIABLE} is not usable as a URL: {source}"),
)
})?;
let host = parsed.host_str().unwrap_or_default();
if !super::is_loopback_host(host) {
return Err(CliError::with_remedy(
Failure::InvalidArgument,
format!(
"{UPDATE_BASE_URL_VARIABLE} points at {host}, which is not this machine. \
This variable redirects where a replacement executable is downloaded from, \
so it accepts only a loopback origin or a local directory."
),
"unset RUNNER_MANAGER_UPDATE_BASE_URL",
));
}
let _ = writeln!(
err,
"warning: reading release assets from {raw} instead of GitHub, because \
{UPDATE_BASE_URL_VARIABLE} is set."
);
Ok(Self::Remote {
base: raw.trim_end_matches('/').to_string(),
})
}
pub(crate) fn for_version(version: &str, err: &mut dyn Write) -> Result<Self, CliError> {
let resolved = Self::resolve(err)?;
if std::env::var_os(UPDATE_BASE_URL_VARIABLE).is_some() {
return Ok(resolved);
}
Ok(Self::Remote {
base: format!(
"{}/releases/download/v{version}",
env!("CARGO_PKG_REPOSITORY").trim_end_matches('/')
),
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct HostTarget {
triple: &'static str,
extension: &'static str,
binary: &'static str,
}
fn host_target() -> Result<HostTarget, CliError> {
let (os, arch) = (std::env::consts::OS, std::env::consts::ARCH);
let target = match (os, arch) {
("windows", "x86_64") => HostTarget {
triple: "x86_64-pc-windows-msvc",
extension: "zip",
binary: "runner-manager.exe",
},
("macos", "aarch64") => HostTarget {
triple: "aarch64-apple-darwin",
extension: "tar.gz",
binary: "runner-manager",
},
("macos", "x86_64") => HostTarget {
triple: "x86_64-apple-darwin",
extension: "tar.gz",
binary: "runner-manager",
},
("linux", "x86_64") => HostTarget {
triple: "x86_64-unknown-linux-gnu",
extension: "tar.gz",
binary: "runner-manager",
},
("linux", "aarch64") => HostTarget {
triple: "aarch64-unknown-linux-gnu",
extension: "tar.gz",
binary: "runner-manager",
},
_ => {
return Err(CliError::with_remedy(
Failure::UnsupportedHost,
format!(
"no release archive is published for {os}/{arch}, so there is nothing to \
update to. This build was compiled for a platform the release does not cover."
),
"cargo install runner-manager",
));
}
};
Ok(target)
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Channel {
Archive,
Npm,
Homebrew,
Cargo,
SourceBuild,
ServiceCopy,
}
impl Channel {
const fn describe(&self) -> &'static str {
match self {
Self::Archive => "release archive",
Self::Npm => "npm",
Self::Homebrew => "Homebrew",
Self::Cargo => "cargo install",
Self::SourceBuild => "a build in a checkout",
Self::ServiceCopy => "the copy the service runs",
}
}
}
#[derive(Debug, Clone)]
struct Installation {
channel: Channel,
path: PathBuf,
}
impl Installation {
fn detect(context: &Context) -> Result<Self, CliError> {
let raw = std::env::current_exe().map_err(|source| {
CliError::new(
Failure::LocalState,
format!("cannot work out which file this process is running from: {source}"),
)
})?;
let path = std::fs::canonicalize(&raw).unwrap_or(raw);
Ok(Self {
channel: classify(&path, context.paths().state_dir()),
path,
})
}
fn refusal(&self) -> Option<CliError> {
match self.channel {
Channel::SourceBuild => Some(CliError::with_remedy(
Failure::UpdateUnsupported,
format!(
"this is a build in a checkout ({}), not an installation. Updating it means \
updating the checkout, which is a thing only its owner should do.",
display_path(&self.path)
),
"git pull && cargo build --release -p runner-manager",
)),
Channel::ServiceCopy => Some(CliError::with_remedy(
Failure::UpdateUnsupported,
format!(
"this is the private copy `service install` made for the service to run ({}), \
not the installation an operator owns. Update the binary you installed; the \
daemon replaces this copy with it by itself.",
display_path(&self.path)
),
"runner-manager service status",
)),
Channel::Npm => self.npm_prefix().err(),
Channel::Archive | Channel::Homebrew | Channel::Cargo => None,
}
}
fn npm_prefix(&self) -> Result<String, CliError> {
npm_global_prefix(&self.path, cfg!(windows))
.map(
|prefix| match display_path(&prefix).strip_prefix(r"\\?\UNC\") {
Some(share) => format!(r"\\{share}"),
None => display_path(&prefix),
},
)
.ok_or_else(|| {
CliError::with_remedy(
Failure::UpdateUnsupported,
format!(
"this copy is inside a node_modules tree ({}), but not in the layout of a \
global npm install, so there is no npm prefix to update it in. \
`npm install --global` would put a new copy somewhere else and leave \
this one as it is.",
display_path(&self.path)
),
format!("npm install --global {NPM_PACKAGE}"),
)
})
}
}
fn npm_global_prefix(binary: &Path, windows: bool) -> Option<PathBuf> {
let components: Vec<_> = binary.components().collect();
let named = |index: usize, wanted: &str| {
components
.get(index)
.and_then(|component| component.as_os_str().to_str())
.is_some_and(|name| name.eq_ignore_ascii_case(wanted))
};
let (package_scope, package_name) = NPM_PACKAGE.split_once('/')?;
let modules = (0..components.len()).find(|&index| named(index, "node_modules"))?;
if !(named(modules + 1, package_scope) && named(modules + 2, package_name)) {
return None;
}
let root: PathBuf = components[..modules].iter().collect();
if root.join("package.json").exists() {
return None;
}
if windows {
return Some(root);
}
if root.file_name().and_then(|name| name.to_str()) == Some("lib") {
return root.parent().map(Path::to_path_buf);
}
None
}
fn classify(path: &Path, state_dir: &Path) -> Channel {
let component_named = |wanted: &str| {
path.components()
.any(|component| component.as_os_str() == wanted)
};
if let Ok(service_bin) = std::fs::canonicalize(state_dir.join("bin"))
&& path.parent() == Some(service_bin.as_path())
{
return Channel::ServiceCopy;
}
if component_named("node_modules") {
return Channel::Npm;
}
if component_named("Cellar") {
return Channel::Homebrew;
}
if path.parent() == Some(cargo_bin().as_path()) {
return Channel::Cargo;
}
if let Some(parent) = path.parent()
&& matches!(
parent.file_name().and_then(|name| name.to_str()),
Some("debug" | "release")
)
&& parent.parent().and_then(Path::file_name) == Some(std::ffi::OsStr::new("target"))
{
return Channel::SourceBuild;
}
Channel::Archive
}
fn cargo_bin() -> PathBuf {
let home = std::env::var_os("CARGO_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".cargo")))
.or_else(|| std::env::var_os("USERPROFILE").map(|home| PathBuf::from(home).join(".cargo")))
.unwrap_or_default();
let bin = home.join("bin");
std::fs::canonicalize(&bin).unwrap_or(bin)
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PublishedArchive {
version: String,
asset: String,
digest: String,
}
fn read_published_archive(
document: &str,
target: HostTarget,
source: &AssetSource,
) -> Result<PublishedArchive, CliError> {
let mut usable = 0_usize;
let mut matched: Vec<PublishedArchive> = Vec::new();
for line in document.lines() {
let fields: Vec<&str> = line.trim_end_matches('\r').split_whitespace().collect();
let [digest, name] = fields[..] else { continue };
if digest.len() != 64 || !digest.bytes().all(|b| b.is_ascii_hexdigit()) {
continue;
}
usable += 1;
let name = name.strip_prefix('*').unwrap_or(name);
let Some(version) = version_of_asset(name, target) else {
continue;
};
matched.push(PublishedArchive {
version,
asset: name.to_string(),
digest: digest.to_ascii_lowercase(),
});
}
if usable == 0 {
return Err(CliError::with_remedy(
Failure::UnusableResponse,
format!(
"the SHA256SUMS at {source} has no line this command can read. Expected \
'<64 hex digits><spaces><asset name>' on each line; this document is empty, \
truncated, or not a checksum file at all."
),
"runner-manager update --check",
));
}
match matched.len() {
1 => Ok(matched.remove(0)),
0 => Err(CliError::with_remedy(
Failure::UnsupportedHost,
format!(
"the release at {source} publishes no archive for {} (it publishes {usable} \
assets), so there is nothing to update to on this platform.",
target.triple
),
"cargo install runner-manager",
)),
count => Err(CliError::with_remedy(
Failure::UnusableResponse,
format!(
"the release at {source} publishes {count} archives for {}; refusing to guess \
which one is meant.",
target.triple
),
"runner-manager update --check",
)),
}
}
fn version_of_asset(name: &str, target: HostTarget) -> Option<String> {
let rest = name.strip_prefix("runner-manager-")?;
let rest = rest.strip_suffix(&format!(".{}", target.extension))?;
let version = rest.strip_suffix(&format!("-{}", target.triple))?;
parse_version(version).map(|_| version.to_string())
}
fn parse_version(raw: &str) -> Option<(u64, u64, u64)> {
let mut parts = raw.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
if parts.next().is_some() {
return None;
}
Some((major, minor, patch))
}
fn running_version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
pub fn dispatch(context: &Context, args: &UpdateArgs, out: &mut dyn Write) -> Result<(), CliError> {
let failed = write_failed("this update");
let mut err = std::io::stderr();
let installation = Installation::detect(context)?;
let target = host_target()?;
let source = AssetSource::resolve(&mut err)?;
writeln!(out, "runner-manager update").map_err(failed)?;
writeln!(out, " installed {}", running_version()).map_err(failed)?;
writeln!(
out,
" installed by {} ({})",
installation.channel.describe(),
display_path(&installation.path)
)
.map_err(failed)?;
writeln!(out, " release assets {source}").map_err(failed)?;
out.flush().map_err(failed)?;
let runtime = super::runtime()?;
let document = runtime.block_on(fetch_text(&source, "SHA256SUMS"))?;
let published = read_published_archive(&document, target, &source)?;
writeln!(out, " published {}", published.version).map_err(failed)?;
let refusal = installation.refusal();
let ordering = compare_versions(&published.version, running_version());
if ordering != std::cmp::Ordering::Greater {
if ordering == std::cmp::Ordering::Equal && args.force {
if let Some(problem) = refusal {
if args.check {
writeln!(
out,
"`runner-manager update --force` would refuse here: {problem}"
)
.map_err(failed)?;
return Ok(());
}
return Err(problem);
}
if args.check {
match preflight_force(context) {
Ok(()) => report_force_check(context, &installation, &published.version, out)?,
Err(problem) => writeln!(
out,
"`runner-manager update --force` would refuse here: {problem}"
)
.map_err(failed)?,
}
} else {
force_service_handover(context, &installation, &published.version, out)?;
}
return Ok(());
}
writeln!(out).map_err(failed)?;
let installed = running_version();
if ordering == std::cmp::Ordering::Equal
&& let Some(behind) = service_behind(context, &installation, installed)
{
report_service_behind(&behind, installed, args.check, out)?;
return report_managed_wsl_hosts(context.paths(), installed, out);
}
let sentence = if ordering == std::cmp::Ordering::Equal {
format!(
"runner-manager {} is the newest release. Nothing to do.",
running_version()
)
} else {
format!(
"This host runs {}, which is newer than the {} the release publishes. \
Nothing to do; `update` never installs an older build over a newer one.",
running_version(),
published.version
)
};
writeln!(out, "{sentence}").map_err(failed)?;
return report_managed_wsl_hosts(context.paths(), running_version(), out);
}
if args.check {
writeln!(out).map_err(failed)?;
writeln!(
out,
"{} is available. Nothing has been changed, because --check was given.",
published.version
)
.map_err(failed)?;
match &refusal {
None => {
if args.force
&& let Err(problem) = preflight_force(context)
{
writeln!(
out,
"`runner-manager update --force` would refuse here: {problem}"
)
.map_err(failed)?;
return Ok(());
}
let command = if args.force {
"runner-manager update --force"
} else {
"runner-manager update"
};
writeln!(out, "Install it with: {command}").map_err(failed)?;
if args.force {
report_force_check(context, &installation, &published.version, out)?;
}
}
Some(problem) => {
writeln!(out, "`runner-manager update` would refuse here: {problem}")
.map_err(failed)?;
if let Some(remedy) = problem.remedy() {
writeln!(out, " try: {remedy}").map_err(failed)?;
}
}
}
return Ok(());
}
if let Some(problem) = refusal {
return Err(problem);
}
if args.force {
preflight_force(context)?;
}
writeln!(out).map_err(failed)?;
match installation.channel {
Channel::Archive => {
runtime.block_on(replace_from_archive(
&source,
&published,
target,
&installation.path,
out,
))?;
}
Channel::Npm => {
let prefix = installation.npm_prefix()?;
let package = format!("{NPM_PACKAGE}@{}", published.version);
run_package_manager(
"npm",
&["install", "--global", "--prefix", &prefix, &package],
format!("npm install --global --prefix \"{prefix}\" {package}"),
out,
)?;
confirm_npm_copy(&installation.path, &published.version, out)?;
}
Channel::Homebrew => {
if let Err(problem) = run_package_manager(
"brew",
&["update"],
"brew update && brew upgrade IvanMurzak/tap/runner-manager",
out,
) {
writeln!(out, "warning: {}", problem.message()).map_err(failed)?;
}
run_package_manager(
"brew",
&["upgrade", BREW_FORMULA],
"brew upgrade IvanMurzak/tap/runner-manager",
out,
)?;
writeln!(
out,
"\nIf brew reported nothing to upgrade, the tap has not caught up with the \n\
release yet. It is updated by the same release run, usually within minutes."
)
.map_err(failed)?;
confirm_with_version(out)?;
}
Channel::Cargo => {
run_package_manager(
"cargo",
&[
"install",
CARGO_CRATE,
"--version",
&published.version,
"--locked",
"--force",
],
"cargo install runner-manager --locked",
out,
)?;
confirm_with_version(out)?;
}
Channel::SourceBuild | Channel::ServiceCopy => {
return Err(installation.refusal().unwrap_or_else(|| {
CliError::new(Failure::UpdateUnsupported, "nothing to update")
}));
}
}
if args.force {
force_service_handover(context, &installation, &published.version, out)?;
} else {
report_service_consequence(context, &installation, &published.version, out)?;
}
report_managed_wsl_hosts(context.paths(), &published.version, out)
}
fn report_managed_wsl_hosts(
paths: &runner_manager_platform::paths::AppPaths,
version: &str,
out: &mut dyn Write,
) -> Result<(), CliError> {
let failed = write_failed("this update");
let Ok(records) = runner_manager_platform::wsl::record::WslProviderRecord::all(paths) else {
return Ok(());
};
let behind: Vec<_> = records
.iter()
.filter(|record| {
compare_versions(&record.installed_version, version) == std::cmp::Ordering::Less
})
.collect();
if behind.is_empty() {
return Ok(());
}
writeln!(out).map_err(failed)?;
writeln!(
out,
"Managed WSL hosts are updated separately, because updating one drains its own jobs. \
Run, from this prompt (no administrator rights needed, except once for a host first \
set up before 0.4.35 from an elevated prompt, which asks for them itself):"
)
.map_err(failed)?;
for record in behind {
writeln!(
out,
" {} (runs {})",
super::wsl::install_remediation(&record.distribution),
record.installed_version
)
.map_err(failed)?;
}
Ok(())
}
fn compare_versions(left: &str, right: &str) -> std::cmp::Ordering {
match (parse_version(left), parse_version(right)) {
(Some(left), Some(right)) => left.cmp(&right),
_ => std::cmp::Ordering::Less,
}
}
async fn replace_from_archive(
source: &AssetSource,
published: &PublishedArchive,
target: HostTarget,
destination: &Path,
out: &mut dyn Write,
) -> Result<(), CliError> {
let failed = write_failed("this update");
let work = tempfile::tempdir().map_err(|source| {
CliError::new(
Failure::LocalState,
format!("cannot create a temporary directory to download into: {source}"),
)
})?;
writeln!(out, "Downloading {}", published.asset).map_err(failed)?;
out.flush().map_err(failed)?;
let archive = work.path().join(&published.asset);
fetch_file(source, &published.asset, &archive).await?;
let actual = sha256_of(&archive)?;
if actual != published.digest {
return Err(CliError::with_remedy(
Failure::UnusableResponse,
format!(
"CHECKSUM MISMATCH: {} does not match the digest published beside it, so nothing \
has been installed and the binary in place is untouched.\n expected \
(SHA256SUMS): {}\n actually downloaded: {actual}\nThat is either a corrupted \
download or a tampered artifact. Retry, and if it happens again report it at \
{}/issues rather than installing by hand.",
published.asset,
published.digest,
env!("CARGO_PKG_REPOSITORY").trim_end_matches('/'),
),
"runner-manager update",
));
}
writeln!(out, "SHA-256 OK: {}", published.digest).map_err(failed)?;
let inside = format!(
"runner-manager-{}-{}/{}",
published.version, target.triple, target.binary
);
let unpacked = work.path().join(target.binary);
if target.extension == "zip" {
extract_from_zip(&archive, &inside, &unpacked)?;
} else {
extract_from_tar_gz(&archive, &inside, &unpacked)?;
}
if cfg!(windows) {
let supervisor_name = "runner-manager-supervisor.exe";
let supervisor_inside = format!(
"runner-manager-{}-{}/{}",
published.version, target.triple, supervisor_name
);
let unpacked_supervisor = work.path().join(supervisor_name);
extract_from_zip(&archive, &supervisor_inside, &unpacked_supervisor)?;
let supervisor_destination = destination.with_file_name(supervisor_name);
install_over(&unpacked_supervisor, &supervisor_destination)?;
}
install_over(&unpacked, destination)?;
writeln!(
out,
"Installed runner-manager {} to {}",
published.version,
display_path(destination)
)
.map_err(failed)?;
Ok(())
}
pub(crate) async fn fetch_text(source: &AssetSource, asset: &str) -> Result<String, CliError> {
match source {
AssetSource::Local { directory } => {
std::fs::read_to_string(directory.join(asset)).map_err(|error| {
CliError::with_remedy(
Failure::GithubUnavailable,
format!(
"cannot read {asset} from {}: {error}",
display_path(directory)
),
"runner-manager update --check",
)
})
}
AssetSource::Remote { base } => {
let url = format!("{base}/{asset}");
let response = reqwest::get(&url)
.await
.and_then(reqwest::Response::error_for_status)
.map_err(|error| unreachable_release(&url, &error))?;
response
.text()
.await
.map_err(|error| unreachable_release(&url, &error))
}
}
}
pub(crate) async fn fetch_file(
source: &AssetSource,
asset: &str,
into: &Path,
) -> Result<(), CliError> {
let write_failure = |error: std::io::Error| {
CliError::new(
Failure::LocalState,
format!(
"cannot write the downloaded archive to {}: {error}",
display_path(into)
),
)
};
match source {
AssetSource::Local { directory } => std::fs::copy(directory.join(asset), into)
.map(|_| ())
.map_err(|error| {
CliError::new(
Failure::GithubUnavailable,
format!(
"cannot read {asset} from {}: {error}",
display_path(directory)
),
)
}),
AssetSource::Remote { base } => {
use futures::StreamExt as _;
use tokio::io::AsyncWriteExt as _;
let url = format!("{base}/{asset}");
let response = reqwest::get(&url)
.await
.and_then(reqwest::Response::error_for_status)
.map_err(|error| unreachable_release(&url, &error))?;
let mut file = tokio::fs::File::create(into).await.map_err(write_failure)?;
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|error| unreachable_release(&url, &error))?;
file.write_all(&chunk).await.map_err(write_failure)?;
}
file.flush().await.map_err(write_failure)?;
file.sync_all().await.map_err(write_failure)?;
Ok(())
}
}
}
fn unreachable_release(url: &str, error: &reqwest::Error) -> CliError {
CliError::with_remedy(
Failure::GithubUnavailable,
format!("cannot fetch {url}: {error}"),
"runner-manager update --check",
)
}
fn sha256_of(path: &Path) -> Result<String, CliError> {
let failed = |error: std::io::Error| {
CliError::new(
Failure::LocalState,
format!(
"cannot read the downloaded archive at {} back to check it: {error}",
display_path(path)
),
)
};
let mut file = std::fs::File::open(path).map_err(failed)?;
let mut hasher = Sha256::new();
let mut buffer = vec![0_u8; 64 * 1024];
loop {
let read = file.read(&mut buffer).map_err(failed)?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
Ok(hex::encode(hasher.finalize()))
}
fn extract_from_tar_gz(archive: &Path, inside: &str, into: &Path) -> Result<(), CliError> {
let file = std::fs::File::open(archive).map_err(|error| unreadable_archive(archive, &error))?;
let mut tar = tar::Archive::new(flate2::read::GzDecoder::new(file));
let entries = tar
.entries()
.map_err(|error| unreadable_archive(archive, &error))?;
for entry in entries {
let mut entry = entry.map_err(|error| unreadable_archive(archive, &error))?;
let path = entry
.path()
.map_err(|error| unreadable_archive(archive, &error))?
.into_owned();
if path_matches(&path, inside) {
let mut destination =
std::fs::File::create(into).map_err(|error| cannot_stage(into, &error))?;
std::io::copy(&mut entry, &mut destination)
.map_err(|error| cannot_stage(into, &error))?;
return Ok(());
}
}
Err(missing_entry(archive, inside))
}
fn extract_from_zip(archive: &Path, inside: &str, into: &Path) -> Result<(), CliError> {
let file = std::fs::File::open(archive).map_err(|error| unreadable_archive(archive, &error))?;
let mut zip =
zip::ZipArchive::new(file).map_err(|error| unreadable_archive(archive, &error))?;
for index in 0..zip.len() {
let mut entry = zip
.by_index(index)
.map_err(|error| unreadable_archive(archive, &error))?;
let Some(path) = entry.enclosed_name() else {
continue;
};
if path_matches(&path, inside) {
let mut destination =
std::fs::File::create(into).map_err(|error| cannot_stage(into, &error))?;
std::io::copy(&mut entry, &mut destination)
.map_err(|error| cannot_stage(into, &error))?;
return Ok(());
}
}
Err(missing_entry(archive, inside))
}
fn path_matches(entry: &Path, wanted: &str) -> bool {
entry.to_string_lossy().replace('\\', "/") == wanted
}
fn unreadable_archive(archive: &Path, error: &dyn std::fmt::Display) -> CliError {
CliError::with_remedy(
Failure::UnusableResponse,
format!(
"the downloaded archive {} could not be read: {error}. Nothing has been installed.",
display_path(archive)
),
"runner-manager update",
)
}
fn missing_entry(archive: &Path, inside: &str) -> CliError {
CliError::with_remedy(
Failure::UnusableResponse,
format!(
"the downloaded archive {} does not contain {inside}, so there is no binary to \
install. Nothing has been changed.",
display_path(archive)
),
"runner-manager update --check",
)
}
fn cannot_stage(into: &Path, error: &dyn std::fmt::Display) -> CliError {
CliError::new(
Failure::LocalState,
format!(
"cannot write the new binary to {}: {error}",
display_path(into)
),
)
}
fn install_over(new_binary: &Path, destination: &Path) -> Result<(), CliError> {
let failed = |what: &'static str| {
move |error: std::io::Error| {
CliError::with_remedy(
Failure::LocalState,
format!("cannot {what}: {error}"),
"runner-manager update",
)
}
};
if destination.is_dir() {
return Err(CliError::with_remedy(
Failure::LocalState,
format!(
"{} is a directory, not a file, so it cannot be replaced with a binary.",
display_path(destination)
),
"remove it and run runner-manager update again",
));
}
let directory = destination.parent().ok_or_else(|| {
CliError::new(
Failure::LocalState,
format!(
"{} has no parent directory to stage a replacement in",
display_path(destination)
),
)
})?;
let staged = directory.join(format!(
".runner-manager.update-tmp{}",
std::env::consts::EXE_SUFFIX
));
let _ = std::fs::remove_file(&staged);
std::fs::copy(new_binary, &staged).map_err(|error| {
if error.kind() == std::io::ErrorKind::PermissionDenied {
return CliError::with_remedy(
Failure::LocalState,
format!(
"cannot write into {}, so the binary there cannot be replaced: {error}",
display_path(directory)
),
"sudo runner-manager update",
);
}
failed("stage the new binary beside the old one")(error)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755))
.map_err(failed("make the new binary executable"))?;
}
if cfg!(windows) {
let aside = destination.with_extension("old");
let _ = std::fs::remove_file(&aside);
if destination.exists() {
std::fs::rename(destination, &aside).map_err(|error| {
let _ = std::fs::remove_file(&staged);
failed("move the binary being replaced aside")(error)
})?;
}
if let Err(error) = std::fs::rename(&staged, destination) {
let _ = std::fs::rename(&aside, destination);
let _ = std::fs::remove_file(&staged);
return Err(failed("put the new binary in place")(error));
}
let _ = std::fs::remove_file(&aside);
return Ok(());
}
std::fs::rename(&staged, destination).map_err(|error| {
let _ = std::fs::remove_file(&staged);
failed("put the new binary in place")(error)
})
}
fn run_package_manager(
program: &str,
arguments: &[&str],
remedy: impl Into<String>,
out: &mut dyn Write,
) -> Result<(), CliError> {
let failed = write_failed("this update");
let remedy: String = remedy.into();
let not_on_path = || {
CliError::with_remedy(
Failure::UpdateUnsupported,
format!(
"this copy was installed by {program}, and {program} is not on this PATH{}, so it \
cannot be asked to update it.",
if cfg!(windows) {
format!(" (searched for {program} with each PATHEXT extension)")
} else {
String::new()
}
),
remedy.clone(),
)
};
let executable = resolve_program(program).ok_or_else(not_on_path)?;
writeln!(
out,
"Running: {} {}",
display_path(&executable),
arguments.join(" ")
)
.map_err(failed)?;
out.flush().map_err(failed)?;
let status = Command::new(&executable).args(arguments).status();
match status {
Ok(status) if status.success() => Ok(()),
Ok(status) => Err(CliError::with_remedy(
Failure::UpdateFailed,
match status.code() {
Some(code) => format!(
"`{program} {}` exited {code}. The message above is {program}'s own; nothing \
here can add to it.",
arguments.join(" ")
),
None => format!(
"`{program} {}` was killed by a signal.",
arguments.join(" ")
),
},
remedy,
)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Err(not_on_path()),
Err(error) => Err(CliError::with_remedy(
Failure::UpdateFailed,
format!("cannot run {}: {error}", display_path(&executable)),
remedy,
)),
}
}
fn resolve_program(name: &str) -> Option<PathBuf> {
if cfg!(windows) {
runner_manager_platform::host_fitness::find_on_path(name, &std::env::var_os("PATH")?)
} else {
Some(PathBuf::from(name))
}
}
fn confirm_npm_copy(binary: &Path, version: &str, out: &mut dyn Write) -> Result<(), CliError> {
let failed = write_failed("this update");
match super::status::binary_version(binary) {
Some(reported) if reported.split('+').next() == Some(version) => writeln!(
out,
"\nInstalled runner-manager {version} to {}",
display_path(binary)
)
.map_err(failed),
Some(reported) => writeln!(
out,
"\nwarning: npm finished, but {} still reports {reported}, not {version}.",
display_path(binary)
)
.map_err(failed),
None => confirm_with_version(out),
}
}
fn confirm_with_version(out: &mut dyn Write) -> Result<(), CliError> {
writeln!(out, "\nConfirm with: runner-manager --version").map_err(write_failed("this update"))
}
fn report_service_consequence(
context: &Context,
installation: &Installation,
version: &str,
out: &mut dyn Write,
) -> Result<(), CliError> {
let failed = write_failed("this update");
let record = match InstallRecord::read(context.paths()) {
Ok(Some(record)) => record,
Ok(None) | Err(_) => return Ok(()),
};
writeln!(out).map_err(failed)?;
match recorded_source(&record, installation) {
RecordedSource::ThisCopy(_) => writeln!(
out,
"The service runs its own copy of this binary. It will finish every job it is \
holding, replace that copy with {version}, and be restarted by the service \
manager. Nothing else is needed."
)
.map_err(failed),
RecordedSource::Other(source) => write_hand_over_with_force(&source, version, out),
RecordedSource::None => write_reregister(version, out),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum RecordedSource {
None,
ThisCopy(PathBuf),
Other(PathBuf),
}
fn recorded_source(record: &InstallRecord, installation: &Installation) -> RecordedSource {
match &record.source_binary {
Some(source) if same_file(source, &installation.path) => {
RecordedSource::ThisCopy(source.clone())
}
Some(source) => RecordedSource::Other(source.clone()),
None => RecordedSource::None,
}
}
fn write_hand_over_with_force(
source: &Path,
version: &str,
out: &mut dyn Write,
) -> Result<(), CliError> {
let failed = write_failed("this update");
writeln!(
out,
"The service was installed from {}, not from this copy, so it keeps running the version \
there. Hand {version} over to it (the service finishes every running job first):",
display_path(source)
)
.map_err(failed)?;
writeln!(out, " runner-manager update --force").map_err(failed)
}
fn write_reregister(version: &str, out: &mut dyn Write) -> Result<(), CliError> {
let failed = write_failed("this update");
writeln!(
out,
"This host's service registration names a binary directly rather than a copy this \
product owns, so it cannot pick up {version} by itself. Re-register it:"
)
.map_err(failed)?;
writeln!(out, " runner-manager service install").map_err(failed)
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ServiceBehind {
service: String,
source: RecordedSource,
not_running: Option<String>,
}
fn decide_service_behind(
installed: &str,
service: Option<&str>,
source: RecordedSource,
) -> Option<ServiceBehind> {
let service = service?;
let core = service.split('+').next().unwrap_or(service);
(compare_versions(installed, core) == std::cmp::Ordering::Greater).then(|| ServiceBehind {
service: service.to_owned(),
source,
not_running: None,
})
}
fn service_behind(
context: &Context,
installation: &Installation,
installed: &str,
) -> Option<ServiceBehind> {
let record = InstallRecord::read(context.paths()).ok()??;
let service = super::status::binary_version(&record.binary);
let mut behind = decide_service_behind(
installed,
service.as_deref(),
recorded_source(&record, installation),
)?;
if matches!(behind.source, RecordedSource::ThisCopy(_)) {
behind.not_running = restart_unless_running(context, &record);
}
Some(behind)
}
fn restart_unless_running(context: &Context, record: &InstallRecord) -> Option<String> {
let Ok(status) = super::service::operations(context).status() else {
return Some("runner-manager service status".to_owned());
};
let running = status.registration().is_some_and(|registration| {
registration.running
&& registration
.binary()
.is_some_and(|binary| same_file(&binary, &record.binary))
});
(!running).then(|| status.restart_command())
}
fn report_service_behind(
behind: &ServiceBehind,
installed: &str,
check: bool,
out: &mut dyn Write,
) -> Result<(), CliError> {
let failed = write_failed("this update");
let service = &behind.service;
writeln!(
out,
"runner-manager {installed} is the newest release and is installed, but the service still \
runs {service}."
)
.map_err(failed)?;
let source = match &behind.source {
RecordedSource::ThisCopy(source) => source,
RecordedSource::Other(source) => return write_hand_over_with_force(source, installed, out),
RecordedSource::None => return write_reregister(installed, out),
};
let marked = if check { Ok(()) } else { nudge_watcher(source) };
if let Err(error) = &marked {
writeln!(
out,
"warning: could not mark {} as changed ({error}), so the running service may not \
notice it until it restarts.",
display_path(source)
)
.map_err(failed)?;
}
match &behind.not_running {
Some(start) => writeln!(
out,
"The service is not running (or its registration does not match its record), so \
nothing swaps it now. It swaps to {installed} as soon as it starts; to start it now: \
{start}"
)
.map_err(failed)?,
None if marked.is_ok() => writeln!(
out,
"{} {service} -> {installed}: the service finishes every job it is running, then \
restarts on {installed}. No job is interrupted.",
if check { "Would swap" } else { "Swapping" }
)
.map_err(failed)?,
None => {}
}
if check {
writeln!(out, "Nothing has been changed, because --check was given.").map_err(failed)?;
}
writeln!(out, "Check progress with: runner-manager service status").map_err(failed)
}
fn nudge_watcher(path: &Path) -> std::io::Result<()> {
let mut options = std::fs::OpenOptions::new();
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
const FILE_WRITE_ATTRIBUTES: u32 = 0x0100;
options.access_mode(FILE_WRITE_ATTRIBUTES);
}
#[cfg(not(windows))]
options.read(true);
options
.open(path)?
.set_modified(std::time::SystemTime::now())
}
fn same_file(left: &Path, right: &Path) -> bool {
match (std::fs::canonicalize(left), std::fs::canonicalize(right)) {
(Ok(left), Ok(right)) => left == right,
_ => left == right,
}
}
fn display_path(path: &Path) -> String {
let rendered = path.display().to_string();
match rendered.strip_prefix(r"\\?\") {
Some(stripped) if !stripped.starts_with("UNC\\") => stripped.to_string(),
_ => rendered,
}
}
#[cfg(test)]
mod tests {
use super::*;
const LINUX: HostTarget = HostTarget {
triple: "x86_64-unknown-linux-gnu",
extension: "tar.gz",
binary: "runner-manager",
};
fn sums(lines: &[&str]) -> String {
format!("{}\n", lines.join("\n"))
}
fn a_source() -> AssetSource {
AssetSource::Remote {
base: "https://example.invalid/download".to_string(),
}
}
#[test]
fn managed_wsl_hosts_behind_the_new_version_are_named_with_the_command_that_updates_them() {
use runner_manager_platform::wsl::record::WslProviderRecord;
let root = tempfile::tempdir().unwrap();
let paths = runner_manager_platform::paths::AppPaths::rooted_at(root.path());
paths.create_all().unwrap();
let mut out = Vec::new();
report_managed_wsl_hosts(&paths, "0.4.35", &mut out).unwrap();
assert!(out.is_empty(), "no managed host, nothing to say");
for (distribution, version) in [
("Ubuntu", "0.4.34"),
("My Debian", "0.4.30"),
("Current", "0.4.35"),
("Newer", "0.4.100"),
] {
WslProviderRecord::new(distribution, "task", version, chrono::Utc::now())
.write(&paths)
.unwrap();
}
report_managed_wsl_hosts(&paths, "0.4.35", &mut out).unwrap();
let said = String::from_utf8(out).unwrap();
assert!(
said.contains("runner-manager wsl install --distribution \"Ubuntu\" (runs 0.4.34)"),
"{said}"
);
assert!(
said.contains(
"runner-manager wsl install --distribution \"My Debian\" (runs 0.4.30)"
),
"{said}"
);
assert!(
!said.contains("Current") && !said.contains("Newer"),
"a current or newer host is not named: {said}"
);
assert!(said.contains("no administrator rights"), "{said}");
}
#[test]
fn the_archive_for_this_target_is_read_out_of_a_checksum_document() {
let document = sums(&[
&format!(
"{} runner-manager-1.2.3-aarch64-apple-darwin.tar.gz",
"a".repeat(64)
),
&format!(
"{} runner-manager-1.2.3-x86_64-unknown-linux-gnu.tar.gz",
"b".repeat(64)
),
]);
let found = read_published_archive(&document, LINUX, &a_source()).expect("one match");
assert_eq!(found.version, "1.2.3");
assert_eq!(found.digest, "b".repeat(64));
assert_eq!(
found.asset,
"runner-manager-1.2.3-x86_64-unknown-linux-gnu.tar.gz"
);
}
#[test]
fn the_binary_mode_checksum_line_is_accepted_too() {
let document = sums(&[&format!(
"{} *runner-manager-1.2.3-x86_64-unknown-linux-gnu.tar.gz",
"c".repeat(64)
)]);
let found = read_published_archive(&document, LINUX, &a_source()).expect("one match");
assert_eq!(found.digest, "c".repeat(64));
}
#[test]
fn an_unreadable_document_and_a_missing_platform_are_different_classes() {
let unreadable =
read_published_archive("<html>404</html>\n", LINUX, &a_source()).unwrap_err();
assert_eq!(unreadable.class(), Failure::UnusableResponse);
let elsewhere = sums(&[&format!(
"{} runner-manager-1.2.3-aarch64-apple-darwin.tar.gz",
"d".repeat(64)
)]);
let missing = read_published_archive(&elsewhere, LINUX, &a_source()).unwrap_err();
assert_eq!(missing.class(), Failure::UnsupportedHost);
}
#[test]
fn two_archives_for_one_target_are_refused_rather_than_guessed() {
let document = sums(&[
&format!(
"{} runner-manager-1.2.3-x86_64-unknown-linux-gnu.tar.gz",
"e".repeat(64)
),
&format!(
"{} runner-manager-1.2.4-x86_64-unknown-linux-gnu.tar.gz",
"f".repeat(64)
),
]);
let refused = read_published_archive(&document, LINUX, &a_source()).unwrap_err();
assert_eq!(refused.class(), Failure::UnusableResponse);
}
#[test]
fn a_detached_signature_is_not_mistaken_for_the_archive() {
assert_eq!(
version_of_asset(
"runner-manager-1.2.3-x86_64-unknown-linux-gnu.tar.gz.sig",
LINUX
),
None
);
assert_eq!(
version_of_asset(
"runner-manager-1.2.3-x86_64-unknown-linux-gnu.tar.gz",
LINUX
)
.as_deref(),
Some("1.2.3")
);
}
#[test]
fn versions_order_numerically() {
use std::cmp::Ordering;
assert_eq!(compare_versions("0.1.10", "0.1.9"), Ordering::Greater);
assert_eq!(compare_versions("0.1.9", "0.1.10"), Ordering::Less);
assert_eq!(compare_versions("1.0.0", "1.0.0"), Ordering::Equal);
assert_eq!(compare_versions("1.2.3-rc.1", "1.0.0"), Ordering::Less);
}
#[test]
fn each_install_layout_is_recognised() {
let state = PathBuf::from("/var/lib/runner-manager/state");
assert_eq!(
classify(
Path::new(
"/usr/lib/node_modules/@ivan-murzak/runner-manager-linux-x64/bin/runner-manager"
),
&state
),
Channel::Npm
);
assert_eq!(
classify(
Path::new("/opt/homebrew/Cellar/runner-manager/0.1.17/bin/runner-manager"),
&state
),
Channel::Homebrew
);
assert_eq!(
classify(
Path::new("/home/me/checkout/target/release/runner-manager"),
&state
),
Channel::SourceBuild
);
assert_eq!(
classify(Path::new("/home/me/.local/bin/runner-manager"), &state),
Channel::Archive
);
assert_eq!(
classify(Path::new("/opt/release/runner-manager"), &state),
Channel::Archive
);
}
fn parts(components: &[&str]) -> PathBuf {
components.iter().collect()
}
#[test]
fn the_npm_prefix_is_read_from_the_copy_being_updated() {
let copy_of = |account: &str| {
parts(&[
"C:",
"Users",
account,
"AppData",
"Roaming",
"npm",
"node_modules",
"@ivan-murzak",
"runner-manager",
"node_modules",
"@ivan-murzak",
"runner-manager-win32-x64",
"bin",
"runner-manager.exe",
])
};
let prefix_of =
|account: &str| parts(&["C:", "Users", account, "AppData", "Roaming", "npm"]);
assert_eq!(
npm_global_prefix(©_of("ivand"), true),
Some(prefix_of("ivand"))
);
assert_eq!(
npm_global_prefix(©_of("yuriv"), true),
Some(prefix_of("yuriv"))
);
let linux = parts(&[
"/",
"usr",
"lib",
"node_modules",
"@ivan-murzak",
"runner-manager",
"node_modules",
"@ivan-murzak",
"runner-manager-linux-x64",
"bin",
"runner-manager",
]);
assert_eq!(npm_global_prefix(&linux, false), Some(parts(&["/", "usr"])));
}
#[test]
fn a_copy_outside_a_global_npm_layout_has_no_prefix_and_is_refused() {
let hoisted = parts(&[
"/",
"home",
"me",
"project",
"node_modules",
"@ivan-murzak",
"runner-manager-linux-x64",
"bin",
"runner-manager",
]);
assert_eq!(npm_global_prefix(&hoisted, true), None);
assert_eq!(npm_global_prefix(&hoisted, false), None);
let local_wrapper = parts(&[
"/",
"home",
"me",
"project",
"node_modules",
"@ivan-murzak",
"runner-manager",
"node_modules",
"@ivan-murzak",
"runner-manager-linux-x64",
"bin",
"runner-manager",
]);
assert_eq!(npm_global_prefix(&local_wrapper, false), None);
let project = tempfile::tempdir().unwrap();
std::fs::write(project.path().join("package.json"), b"{}").unwrap();
let in_project = [
"node_modules",
"@ivan-murzak",
"runner-manager",
"node_modules",
"@ivan-murzak",
"runner-manager-win32-x64",
"bin",
"runner-manager.exe",
]
.iter()
.fold(project.path().to_path_buf(), |path, part| path.join(part));
assert_eq!(npm_global_prefix(&in_project, true), None);
let prefix = tempfile::tempdir().unwrap();
let in_prefix = in_project
.strip_prefix(project.path())
.map(|rest| prefix.path().join(rest))
.unwrap();
assert_eq!(
npm_global_prefix(&in_prefix, true),
Some(prefix.path().to_path_buf())
);
let dependency = parts(&[
"C:",
"npm",
"node_modules",
"other-tool",
"node_modules",
"@ivan-murzak",
"runner-manager",
"node_modules",
"@ivan-murzak",
"runner-manager-win32-x64",
"bin",
"runner-manager.exe",
]);
assert_eq!(npm_global_prefix(&dependency, true), None);
let installation = Installation {
channel: Channel::Npm,
path: hoisted,
};
let refusal = installation
.refusal()
.expect("refused before --check returns");
assert!(
refusal.message().contains("global npm install"),
"{refusal:?}"
);
}
fn behind(source: RecordedSource) -> Option<ServiceBehind> {
Some(ServiceBehind {
service: "0.4.38".into(),
source,
not_running: None,
})
}
#[test]
fn an_older_service_behind_the_newest_release_is_a_swap_not_nothing_to_do() {
for source in [
RecordedSource::ThisCopy(PathBuf::from("this-copy")),
RecordedSource::Other(PathBuf::from("another-copy")),
RecordedSource::None,
] {
assert_eq!(
decide_service_behind("0.4.39", Some("0.4.38"), source.clone()),
behind(source)
);
}
assert!(
decide_service_behind("0.4.39", Some("0.4.38+git.0123abcd"), RecordedSource::None)
.is_some()
);
}
#[test]
fn a_service_at_the_installed_version_or_unreadable_needs_nothing() {
let this_copy = || RecordedSource::ThisCopy(PathBuf::from("this-copy"));
assert_eq!(
decide_service_behind("0.4.39", Some("0.4.39"), this_copy()),
None
);
assert_eq!(
decide_service_behind("0.4.39", Some("0.4.40"), this_copy()),
None
);
assert_eq!(decide_service_behind("0.4.39", None, this_copy()), None);
assert_eq!(
decide_service_behind("0.4.39", Some("0.4.39+git.0123abcd"), this_copy()),
None
);
}
fn modified(path: &Path) -> std::time::SystemTime {
std::fs::metadata(path).unwrap().modified().unwrap()
}
fn long_ago() -> std::time::SystemTime {
std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000_000)
}
#[test]
fn a_swap_marks_the_watched_copy_changed_and_check_does_not() {
let root = tempfile::tempdir().unwrap();
let source = root.path().join("runner-manager");
std::fs::write(&source, b"the newer build").unwrap();
std::fs::File::options()
.write(true)
.open(&source)
.unwrap()
.set_modified(long_ago())
.unwrap();
let follow_up = ServiceBehind {
service: "0.4.38".into(),
source: RecordedSource::ThisCopy(source.clone()),
not_running: None,
};
let mut out = Vec::new();
report_service_behind(&follow_up, "0.4.39", true, &mut out).unwrap();
let said = String::from_utf8(out).unwrap();
assert!(said.contains("still runs 0.4.38"), "{said}");
assert!(said.contains("Would swap 0.4.38 -> 0.4.39"), "{said}");
assert_eq!(modified(&source), long_ago(), "--check changed the file");
let mut out = Vec::new();
report_service_behind(&follow_up, "0.4.39", false, &mut out).unwrap();
let said = String::from_utf8(out).unwrap();
assert!(said.contains("Swapping 0.4.38 -> 0.4.39"), "{said}");
assert!(said.contains("No job is interrupted"), "{said}");
assert!(!said.contains("warning"), "{said}");
assert!(
modified(&source) > long_ago(),
"the watched file was not marked"
);
assert_eq!(std::fs::read(&source).unwrap(), b"the newer build");
}
#[test]
fn a_service_that_is_not_running_is_told_how_to_start_not_that_it_swaps() {
let root = tempfile::tempdir().unwrap();
let source = root.path().join("runner-manager");
std::fs::write(&source, b"the newer build").unwrap();
std::fs::File::options()
.write(true)
.open(&source)
.unwrap()
.set_modified(long_ago())
.unwrap();
let follow_up = ServiceBehind {
service: "0.4.38".into(),
source: RecordedSource::ThisCopy(source.clone()),
not_running: Some("the-start-command".into()),
};
let mut out = Vec::new();
report_service_behind(&follow_up, "0.4.39", false, &mut out).unwrap();
let said = String::from_utf8(out).unwrap();
assert!(said.contains("is not running"), "{said}");
assert!(said.contains("the-start-command"), "{said}");
assert!(!said.contains("Swapping"), "{said}");
assert!(modified(&source) > long_ago());
}
#[test]
fn a_service_from_another_copy_is_told_to_hand_over_with_force() {
let follow_up = ServiceBehind {
service: "0.4.38".into(),
source: RecordedSource::Other(PathBuf::from("another-copy")),
not_running: None,
};
let mut out = Vec::new();
report_service_behind(&follow_up, "0.4.39", false, &mut out).unwrap();
let said = String::from_utf8(out).unwrap();
assert!(said.contains("another-copy"), "{said}");
assert!(said.contains("runner-manager update --force"), "{said}");
assert!(!said.contains("service install"), "{said}");
}
#[test]
fn a_running_executable_can_be_marked_changed() {
let (system_binary, argument) = if cfg!(windows) {
let root = std::env::var_os("SystemRoot").expect("SystemRoot");
(
PathBuf::from(root).join("System32").join("findstr.exe"),
"x",
)
} else {
(PathBuf::from("/bin/cat"), "-")
};
let root = tempfile::tempdir().unwrap();
let copy = root
.path()
.join(system_binary.file_name().expect("a file name"));
std::fs::copy(&system_binary, ©).unwrap();
std::fs::File::options()
.write(true)
.open(©)
.unwrap()
.set_modified(long_ago())
.unwrap();
const TEXT_FILE_BUSY: i32 = 26;
let mut child = (0..100)
.find_map(|_| {
match std::process::Command::new(©)
.arg(argument)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.spawn()
{
Ok(child) => Some(child),
Err(error) if error.raw_os_error() == Some(TEXT_FILE_BUSY) => {
std::thread::sleep(std::time::Duration::from_millis(20));
None
}
Err(error) => panic!("cannot run {}: {error}", copy.display()),
}
})
.expect("the copy ran");
let marked = nudge_watcher(©);
let still_running = child.try_wait().unwrap().is_none();
drop(child.stdin.take());
child.wait().unwrap();
assert!(still_running, "the copy exited before it was marked");
marked.expect("a running executable can be marked");
assert!(modified(©) > long_ago());
}
}