Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 42 additions & 3 deletions bin/yerd/src/cli_shim.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,14 @@
//! generated ini (`{data}/php-cli-<minor>.ini`, which carries the user's global
//! settings **and** that version's registered extensions), and `exec`s PHP.
//! Pointing `PHPRC` per version is what lets a custom extension load in the CLI,
//! and `PHPRC` (rather than `-d`) is inherited by any child PHP the exec'd one
//! spawns. Unix-only: these wrappers are never created on other platforms.
//! and `PHPRC` (rather than `-d`) is inherited by a child PHP the exec'd one
//! spawns through its absolute interpreter path; a child that resolves `php` from
//! `PATH` re-enters this shim instead and gets its own `PHPRC`. To keep coverage
//! alive across that second kind of hop, the shim honours `YERD_COVER=1` from the
//! environment (exported by the cover shims) by pointing `PHPRC` at the cover ini
//! for the version *it* resolves, falling back to the clean ini with a stderr
//! notice when pcov isn't available. Unix-only: these wrappers are never created
//! on other platforms.

use std::os::unix::process::CommandExt as _;
use std::path::Path;
Expand Down Expand Up @@ -59,6 +65,13 @@ fn parse_cli_name(name: &str) -> Option<CliSpec> {
Some(CliSpec::Version(major, minor))
}

/// Whether `YERD_COVER`'s value turns coverage on. Deliberately narrow: only the
/// exact value `1` counts, so an unset, empty, `0`, or stray value left in a
/// long-lived shell never silently instruments every `php` run.
fn cover_env_enabled(value: Option<&std::ffi::OsStr>) -> bool {
value.is_some_and(|v| v == "1")
}

fn run(spec: &CliSpec) -> ExitCode {
let dirs = match ActivePaths::new().resolve() {
Ok(d) => d,
Expand All @@ -70,7 +83,18 @@ fn run(spec: &CliSpec) -> ExitCode {
};

let mut cmd = std::process::Command::new(&php_bin);
if let Some(phprc) = cli_phprc(&dirs, &minor) {
let cover_ini = if cover_env_enabled(std::env::var_os("YERD_COVER").as_deref()) {
match crate::cover_shim::prepare_cover_ini(&dirs, &minor) {
Ok(path) => Some(path),
Err(msg) => {
eprintln!("yerd: YERD_COVER=1 ignored ({msg}); running without coverage");
None
}
}
} else {
None
};
if let Some(phprc) = cover_ini.or_else(|| cli_phprc(&dirs, &minor)) {
cmd.env("PHPRC", phprc);
}
let err = cmd.args(std::env::args_os().skip(1)).exec();
Expand Down Expand Up @@ -129,4 +153,19 @@ mod tests {
assert!(parse_cli_name("php8.").is_none());
assert!(parse_cli_name("php.5").is_none());
}

#[test]
fn cover_env_enabled_only_for_exactly_one() {
for (value, want) in [
(None, false),
(Some(""), false),
(Some("0"), false),
(Some("true"), false),
(Some("1 "), false),
(Some("1"), true),
] {
let got = cover_env_enabled(value.map(std::ffi::OsStr::new));
assert_eq!(got, want, "YERD_COVER={value:?}");
}
}
}
64 changes: 43 additions & 21 deletions bin/yerd/src/cover_shim.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@
//! `PHPRC` at a pcov-augmented copy of Yerd's CLI ini, and `exec`s PHP with
//! coverage enabled - leaving the clean `php`/`php<ver>` shims untouched.
//! `PHPRC` (rather than `-d` flags) is what it is: those flags are process-local,
//! but this env var is inherited by any PHP process the exec'd one spawns in
//! turn (e.g. `artisan test`'s child PHPUnit/Pest/paratest run), so coverage
//! stays enabled across that hop too. Unix-only: cover shims are never created
//! on other platforms.
//! but this env var is inherited by a child PHP the exec'd one spawns through its
//! absolute interpreter path (`PHP_BINARY`, what `artisan test`'s child
//! PHPUnit/Pest/paratest run does), so coverage stays enabled across that hop. A
//! child that instead resolves `php` from `PATH` re-enters Yerd's plain CLI shim,
//! which sets its own `PHPRC`; for that hop these shims also export
//! `YERD_COVER=1`, which the plain shim honours by deriving the cover ini for the
//! version *it* resolves. Unix-only: cover shims are never created on other
//! platforms.

use std::ffi::OsString;
use std::io::Write as _;
Expand Down Expand Up @@ -77,44 +81,62 @@ fn run(spec: &CoverSpec, forward: &[OsString]) -> ExitCode {
Ok(t) => t,
Err(msg) => return fail(msg),
};
let cover_ini_path = match prepare_cover_ini(&dirs, &minor) {
Ok(p) => p,
Err(msg) => return fail(msg),
};

let err = Command::new(&php_bin)
.env("PHPRC", &cover_ini_path)
.env("YERD_COVER", "1")
.args(forward)
.exec();
if err.kind() == std::io::ErrorKind::NotFound {
return fail(format!(
"PHP binary not found at {} ({err}) — reinstall with `yerd install php {minor}`",
php_bin.display()
));
}
fail(format!("failed to exec {}: {err}", php_bin.display()))
}

/// Derive the pcov-enabled ini for `minor` and return its path
/// (`{data}/php-ext/php-<minor>/cover.ini`), writing it atomically.
///
/// Shared by the cover shims, which treat an `Err` as fatal, and by the plain
/// CLI shim under `YERD_COVER=1`, which treats it as a reason to run without
/// coverage. Every failure - no `pcov.so` for that version (which is also what a
/// legacy minor hits, since pcov is never built for < 8.2), an unreadable base
/// ini, a path that can't be rendered as an ini value, or a failed write -
/// returns `Err(message)` rather than exiting.
pub(crate) fn prepare_cover_ini(dirs: &PlatformDirs, minor: &str) -> Result<PathBuf, String> {
let ext_dir = dirs.data.join("php-ext").join(format!("php-{minor}"));
let pcov = ext_dir.join("pcov.so");
if !pcov.is_file() {
return fail(format!(
return Err(format!(
"pcov not installed for PHP {minor} — reinstall PHP or wait for the background fetch"
));
}

let base = match crate::shim::cli_phprc(&dirs, &minor) {
let base = match crate::shim::cli_phprc(dirs, minor) {
Some(ini) => match std::fs::read_to_string(&ini) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return fail(format!("cannot read Yerd's CLI php.ini: {e}")),
Err(e) => return Err(format!("cannot read Yerd's CLI php.ini: {e}")),
},
None => String::new(),
};
let Some(cover_ini) = php_settings::render_cover_ini(&base, &pcov) else {
return fail(format!(
return Err(format!(
"cannot enable pcov: {} isn't safe to use as an ini value (no control characters, `;`, or `#`, and it must be valid UTF-8) - move Yerd's data directory to a path without those",
pcov.display()
));
};
let cover_ini_path = ext_dir.join("cover.ini");
if let Err(e) = atomic_write(&cover_ini_path, cover_ini.as_bytes()) {
return fail(format!("cannot write {}: {e}", cover_ini_path.display()));
return Err(format!("cannot write {}: {e}", cover_ini_path.display()));
}

let err = Command::new(&php_bin)
.env("PHPRC", &cover_ini_path)
.args(forward)
.exec();
if err.kind() == std::io::ErrorKind::NotFound {
return fail(format!(
"PHP binary not found at {} ({err}) — reinstall with `yerd install php {minor}`",
php_bin.display()
));
}
fail(format!("failed to exec {}: {err}", php_bin.display()))
Ok(cover_ini_path)
}

/// Write `bytes` to `path` atomically (tempfile in the same directory +
Expand Down
Loading
Loading