use std::ffi::{OsStr, OsString};
use std::fmt;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, ExitStatus, Stdio};
use std::time::{Duration, Instant};
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
const RUNNER_JIT_CONFIG_ENV: &str = "ACTIONS_RUNNER_INPUT_JITCONFIG";
#[derive(Debug, thiserror::Error)]
pub enum ProcessError {
#[error("cannot start {}: {source}", program.display())]
Spawn {
program: PathBuf,
#[source]
source: std::io::Error,
},
#[error("cannot read the start time of process {pid}: {source}")]
Identity {
pid: u32,
#[source]
source: std::io::Error,
},
#[error("no live process holds PID {pid}")]
NoSuchProcess {
pid: u32,
},
#[error("cannot control process {pid}: {source}")]
Control {
pid: u32,
#[source]
source: std::io::Error,
},
#[error(
"refusing to start {}: the handoff payload appears in {location}, which would put \
it in this machine's process listing. Pass the handoff file's path instead \
(`07-security.md`, threat table).",
program.display()
)]
SecretInCommandLine {
program: PathBuf,
location: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ProcessIdentity {
pid: u32,
start_token: String,
}
const EXITED_BEFORE_IDENTIFIED: &str = "exited-before-identified";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Adoption {
Live,
Gone,
PidRecycled {
current: ProcessIdentity,
},
}
impl Adoption {
#[must_use]
pub const fn is_live(&self) -> bool {
matches!(self, Self::Live)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Termination {
Terminated,
AlreadyGone,
RefusedPidRecycled {
current: ProcessIdentity,
},
}
impl ProcessIdentity {
pub fn resolve(pid: u32) -> Result<Self, ProcessError> {
Self::read(pid, LivenessFilter::LiveOnly)
}
fn of_child(pid: u32) -> Result<Self, ProcessError> {
match Self::read(pid, LivenessFilter::IncludeExited) {
Ok(identity) => Ok(identity),
Err(ProcessError::NoSuchProcess { .. }) => Ok(Self {
pid,
start_token: EXITED_BEFORE_IDENTIFIED.to_string(),
}),
Err(other) => Err(other),
}
}
fn read(pid: u32, filter: LivenessFilter) -> Result<Self, ProcessError> {
match sys::start_token(pid, filter) {
Ok(Some(start_token)) => Ok(Self { pid, start_token }),
Ok(None) => Err(ProcessError::NoSuchProcess { pid }),
Err(source) => Err(ProcessError::Identity { pid, source }),
}
}
pub fn of_current_process() -> Result<Self, ProcessError> {
Self::resolve(std::process::id())
}
#[must_use]
pub const fn pid(&self) -> u32 {
self.pid
}
#[must_use]
pub fn start_token(&self) -> &str {
&self.start_token
}
pub fn recheck(&self) -> Result<Adoption, ProcessError> {
match sys::start_token(self.pid, LivenessFilter::LiveOnly) {
Ok(observed) => Ok(self.classify(observed)),
Err(source) => Err(ProcessError::Identity {
pid: self.pid,
source,
}),
}
}
fn classify(&self, observed: Option<String>) -> Adoption {
match observed {
None => Adoption::Gone,
Some(token) if token == self.start_token => Adoption::Live,
Some(token) => Adoption::PidRecycled {
current: Self {
pid: self.pid,
start_token: token,
},
},
}
}
pub fn terminate(&self, grace: Duration) -> Result<Termination, ProcessError> {
match self.recheck()? {
Adoption::Gone => return Ok(Termination::AlreadyGone),
Adoption::PidRecycled { current } => {
return Ok(Termination::RefusedPidRecycled { current });
}
Adoption::Live => {}
}
let requested = sys::request_stop(self.pid).map_err(|source| ProcessError::Control {
pid: self.pid,
source,
})?;
if requested {
let deadline = Instant::now() + grace;
while Instant::now() < deadline {
if matches!(
self.recheck()?,
Adoption::Gone | Adoption::PidRecycled { .. }
) {
return Ok(Termination::Terminated);
}
std::thread::sleep(POLL_INTERVAL);
}
}
match self.recheck()? {
Adoption::Gone => Ok(Termination::AlreadyGone),
Adoption::PidRecycled { current } => Ok(Termination::RefusedPidRecycled { current }),
Adoption::Live => {
sys::force_stop(self.pid).map_err(|source| ProcessError::Control {
pid: self.pid,
source,
})?;
Ok(Termination::Terminated)
}
}
}
}
impl fmt::Display for ProcessIdentity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "pid {} started {}", self.pid, self.start_token)
}
}
const POLL_INTERVAL: Duration = Duration::from_millis(20);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LivenessFilter {
LiveOnly,
IncludeExited,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OutputMode {
#[default]
Discard,
Inherit,
Capture,
}
#[derive(Debug, Clone)]
pub struct SpawnSpec {
program: PathBuf,
args: Vec<OsString>,
envs: Vec<(OsString, OsString)>,
working_dir: Option<PathBuf>,
output: OutputMode,
}
impl SpawnSpec {
pub fn new(program: impl Into<PathBuf>) -> Self {
Self {
program: program.into(),
args: Vec::new(),
envs: Vec::new(),
working_dir: None,
output: OutputMode::Discard,
}
}
#[must_use]
pub fn arg(mut self, arg: impl AsRef<OsStr>) -> Self {
self.args.push(arg.as_ref().to_os_string());
self
}
#[must_use]
pub fn args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
self.args
.extend(args.into_iter().map(|a| a.as_ref().to_os_string()));
self
}
#[must_use]
pub fn env(mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> Self {
self.envs
.push((key.as_ref().to_os_string(), value.as_ref().to_os_string()));
self
}
#[must_use]
pub fn working_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.working_dir = Some(dir.into());
self
}
#[must_use]
pub fn output(mut self, output: OutputMode) -> Self {
self.output = output;
self
}
#[must_use]
pub fn arguments(&self) -> &[OsString] {
&self.args
}
pub fn spawn(&self) -> Result<ChildProcess, ProcessError> {
self.spawn_with_extra_env(None)
}
fn spawn_with_extra_env(
&self,
extra_env: Option<(&OsStr, &OsStr)>,
) -> Result<ChildProcess, ProcessError> {
let mut command = Command::new(&self.program);
#[cfg(unix)]
std::os::unix::process::CommandExt::process_group(&mut command, 0);
command.args(&self.args);
for (key, value) in &self.envs {
command.env(key, value);
}
if let Some((key, value)) = extra_env {
command.env(key, value);
}
if let Some(dir) = &self.working_dir {
command.current_dir(dir);
}
command.stdin(Stdio::null());
let (stdout, stderr) = match self.output {
OutputMode::Discard => (Stdio::null(), Stdio::null()),
OutputMode::Inherit => (Stdio::inherit(), Stdio::inherit()),
OutputMode::Capture => (Stdio::piped(), Stdio::piped()),
};
command.stdout(stdout).stderr(stderr);
let child = command.spawn().map_err(|source| ProcessError::Spawn {
program: self.program.clone(),
source,
})?;
let pid = child.id();
match ProcessIdentity::of_child(pid) {
Ok(identity) => Ok(ChildProcess {
child,
identity,
program: self.program.clone(),
}),
Err(error) => {
let mut child = child;
let _ = child.kill();
let _ = child.wait();
Err(error)
}
}
}
pub fn spawn_with_handoff(
&self,
handoff: &RestrictiveHandoff,
) -> Result<ChildProcess, ProcessError> {
self.reject_exposed_handoff(handoff)?;
self.spawn()
}
pub fn spawn_runner_with_handoff(
&self,
handoff: &RestrictiveHandoff,
) -> Result<ChildProcess, ProcessError> {
self.reject_exposed_handoff(handoff)?;
self.spawn_with_extra_env(Some((
OsStr::new(RUNNER_JIT_CONFIG_ENV),
OsStr::new(handoff.payload.expose_secret()),
)))
}
fn reject_exposed_handoff(&self, handoff: &RestrictiveHandoff) -> Result<(), ProcessError> {
let payload = handoff.payload.expose_secret();
if !payload.is_empty() {
for (index, arg) in self.args.iter().enumerate() {
if os_str_contains(arg, payload) {
return Err(ProcessError::SecretInCommandLine {
program: self.program.clone(),
location: format!("argument {index}"),
});
}
}
for (key, value) in &self.envs {
if os_str_contains(value, payload) {
return Err(ProcessError::SecretInCommandLine {
program: self.program.clone(),
location: format!("environment variable {}", key.to_string_lossy()),
});
}
}
}
Ok(())
}
}
fn os_str_contains(haystack: &OsStr, needle: &str) -> bool {
haystack.to_string_lossy().contains(needle)
}
#[derive(Debug)]
pub struct ChildProcess {
child: Child,
identity: ProcessIdentity,
program: PathBuf,
}
impl ChildProcess {
#[must_use]
pub const fn identity(&self) -> &ProcessIdentity {
&self.identity
}
#[must_use]
pub const fn pid(&self) -> u32 {
self.identity.pid
}
pub fn is_running(&mut self) -> Result<bool, ProcessError> {
Ok(self.try_exit_status()?.is_none())
}
pub fn try_exit_status(&mut self) -> Result<Option<ExitStatus>, ProcessError> {
self.child
.try_wait()
.map_err(|source| ProcessError::Control {
pid: self.identity.pid,
source,
})
}
pub fn wait(&mut self) -> Result<ExitStatus, ProcessError> {
self.child.wait().map_err(|source| ProcessError::Control {
pid: self.identity.pid,
source,
})
}
pub fn wait_for(&mut self, timeout: Duration) -> Result<Option<ExitStatus>, ProcessError> {
let deadline = Instant::now() + timeout;
loop {
if let Some(status) = self.try_exit_status()? {
return Ok(Some(status));
}
if Instant::now() >= deadline {
return Ok(None);
}
std::thread::sleep(POLL_INTERVAL);
}
}
pub fn stop(&mut self, grace: Duration) -> Result<ExitStatus, ProcessError> {
if let Some(status) = self.try_exit_status()? {
return Ok(status);
}
let pid = self.identity.pid;
let requested =
sys::request_stop(pid).map_err(|source| ProcessError::Control { pid, source })?;
if requested && let Some(status) = self.wait_for(grace)? {
return Ok(status);
}
self.child
.kill()
.map_err(|source| ProcessError::Control { pid, source })?;
self.wait()
}
pub fn take_stdout(&mut self) -> Option<std::process::ChildStdout> {
self.child.stdout.take()
}
pub fn take_stderr(&mut self) -> Option<std::process::ChildStderr> {
self.child.stderr.take()
}
#[must_use]
pub fn program(&self) -> &Path {
&self.program
}
}
#[derive(Debug, thiserror::Error)]
pub enum HandoffError {
#[error("cannot create a restrictive handoff file in {}: {source}", directory.display())]
Create {
directory: PathBuf,
#[source]
source: std::io::Error,
},
#[error("cannot write the handoff payload to {}: {source}", path.display())]
Write {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("cannot read the permissions of {}: {source}", path.display())]
Inspect {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("cannot delete the handoff file {}: {source}", path.display())]
Delete {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PermissionsSummary {
pub description: String,
pub readable_by_other_local_users: bool,
}
#[cfg(windows)]
pub(crate) fn current_user_sid() -> std::io::Result<String> {
sys::current_user_sid()
}
pub fn permissions_summary(path: &Path) -> Result<PermissionsSummary, HandoffError> {
sys::describe_permissions(path)
.map(
|(description, readable_by_other_local_users)| PermissionsSummary {
description,
readable_by_other_local_users,
},
)
.map_err(|source| HandoffError::Inspect {
path: path.to_path_buf(),
source,
})
}
#[derive(Debug)]
pub struct RestrictiveHandoff {
path: PathBuf,
payload: SecretString,
deleted: bool,
}
impl RestrictiveHandoff {
pub const NAME_PREFIX: &'static str = "jit-";
pub fn create(directory: &Path, payload: SecretString) -> Result<Self, HandoffError> {
use std::io::Write as _;
let path = directory.join(format!("{}{}.tmp", Self::NAME_PREFIX, uuid::Uuid::new_v4()));
let mut file =
sys::create_restrictive_file(&path).map_err(|source| HandoffError::Create {
directory: directory.to_path_buf(),
source,
})?;
let handoff = Self {
path,
payload,
deleted: false,
};
let write = file
.write_all(handoff.payload.expose_secret().as_bytes())
.and_then(|()| file.flush())
.and_then(|()| file.sync_all());
write.map_err(|source| HandoffError::Write {
path: handoff.path.clone(),
source,
})?;
drop(file);
Ok(handoff)
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
pub fn permissions(&self) -> Result<PermissionsSummary, HandoffError> {
permissions_summary(&self.path)
}
pub fn delete(mut self) -> Result<(), HandoffError> {
self.delete_in_place()
}
fn delete_in_place(&mut self) -> Result<(), HandoffError> {
if self.deleted {
return Ok(());
}
match std::fs::remove_file(&self.path) {
Ok(()) => {
self.deleted = true;
Ok(())
}
Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
self.deleted = true;
Ok(())
}
Err(source) => Err(HandoffError::Delete {
path: self.path.clone(),
source,
}),
}
}
}
impl Drop for RestrictiveHandoff {
fn drop(&mut self) {
if let Err(error) = self.delete_in_place() {
tracing::error!(
event = "handoff_delete_failed",
path = %self.path.display(),
error = %error,
"a JIT handoff file could not be deleted and is still on disk"
);
}
}
}
#[cfg_attr(
not(target_os = "linux"),
allow(dead_code, reason = "parsed on Linux; unit tested on every platform")
)]
#[derive(Debug, Clone, PartialEq, Eq)]
struct ProcStat<'a> {
state: &'a str,
start_ticks: &'a str,
}
#[cfg_attr(
not(target_os = "linux"),
allow(dead_code, reason = "parsed on Linux; unit tested on every platform")
)]
fn parse_proc_stat(stat: &str) -> Option<ProcStat<'_>> {
let after_comm = &stat[stat.rfind(')')? + 1..];
let mut fields = after_comm.split_whitespace();
let state = fields.next()?;
let start_ticks = fields.nth(22 - 4)?;
Some(ProcStat { state, start_ticks })
}
#[cfg(any(target_os = "macos", test))]
const fn probe_failure_means_gone(errno: Option<i32>, no_such_process: i32) -> bool {
matches!(errno, Some(code) if code == no_such_process)
}
#[cfg(windows)]
mod sys {
use std::fs::File;
use std::io;
use std::os::windows::ffi::OsStrExt;
use std::os::windows::io::FromRawHandle;
use std::path::Path;
use windows::Win32::Foundation::{
CloseHandle, ERROR_ACCESS_DENIED, ERROR_INVALID_PARAMETER, ERROR_SUCCESS, FILETIME, HANDLE,
HLOCAL, LocalFree,
};
use windows::Win32::Security::Authorization::{
ConvertSecurityDescriptorToStringSecurityDescriptorW, ConvertSidToStringSidW,
ConvertStringSecurityDescriptorToSecurityDescriptorW, GetNamedSecurityInfoW,
SDDL_REVISION_1, SE_FILE_OBJECT,
};
use windows::Win32::Security::{
DACL_SECURITY_INFORMATION, GetTokenInformation, PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES,
TOKEN_QUERY, TOKEN_USER, TokenUser,
};
use windows::Win32::Storage::FileSystem::{
CREATE_NEW, CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_GENERIC_READ, FILE_GENERIC_WRITE,
FILE_SHARE_NONE,
};
use windows::Win32::System::Threading::{
GetCurrentProcess, GetProcessTimes, OpenProcess, OpenProcessToken,
PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_TERMINATE, TerminateProcess,
};
use windows::core::{PCWSTR, PWSTR};
const fn hresult_from_win32(code: u32) -> i32 {
if code == 0 {
0
} else {
((code & 0x0000_ffff) | 0x8007_0000) as i32
}
}
fn to_wide(path: &Path) -> Vec<u16> {
path.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect()
}
fn filetime_to_u64(time: FILETIME) -> u64 {
(u64::from(time.dwHighDateTime) << 32) | u64::from(time.dwLowDateTime)
}
fn io_error(error: &windows::core::Error) -> io::Error {
io::Error::from_raw_os_error(error.code().0)
}
pub(super) fn start_token(
pid: u32,
filter: super::LivenessFilter,
) -> io::Result<Option<String>> {
let handle = match unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) } {
Ok(handle) => handle,
Err(error) => {
let code = error.code().0;
if code == hresult_from_win32(ERROR_INVALID_PARAMETER.0) {
return Ok(None);
}
if code == hresult_from_win32(ERROR_ACCESS_DENIED.0) {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!("PID {pid} belongs to a process this account may not query"),
));
}
return Err(io_error(&error));
}
};
let mut creation = FILETIME::default();
let mut exit = FILETIME::default();
let mut kernel = FILETIME::default();
let mut user = FILETIME::default();
let times =
unsafe { GetProcessTimes(handle, &mut creation, &mut exit, &mut kernel, &mut user) };
unsafe {
let _ = CloseHandle(handle);
}
times.map_err(|error| io_error(&error))?;
if filetime_to_u64(exit) != 0 && filter == super::LivenessFilter::LiveOnly {
return Ok(None);
}
Ok(Some(format!("windows:{}", filetime_to_u64(creation))))
}
pub(super) fn request_stop(_pid: u32) -> io::Result<bool> {
Ok(false)
}
pub(super) fn force_stop(pid: u32) -> io::Result<()> {
let _ = std::process::Command::new("taskkill")
.args(["/F", "/T", "/PID", &pid.to_string()])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
match unsafe { OpenProcess(PROCESS_TERMINATE, false, pid) } {
Ok(handle) => {
let result = unsafe { TerminateProcess(handle, 1) };
unsafe {
let _ = CloseHandle(handle);
}
if let Err(error) = result {
let code = error.code().0 as u32;
if code != 0x80070005 && code != 0x80070057 {
return Err(io_error(&error));
}
}
Ok(())
}
Err(error) => {
let code = error.code().0 as u32;
if code == 0x80070005 || code == 0x80070057 {
Ok(())
} else {
Err(io_error(&error))
}
}
}
}
pub(crate) fn current_user_sid() -> io::Result<String> {
let mut token = HANDLE(std::ptr::null_mut());
unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) }
.map_err(|error| io_error(&error))?;
let mut needed = 0u32;
let _ = unsafe { GetTokenInformation(token, TokenUser, None, 0, &mut needed) };
let words = (needed as usize).div_ceil(size_of::<usize>()).max(1);
let mut buffer = vec![0usize; words];
let information = unsafe {
GetTokenInformation(
token,
TokenUser,
Some(buffer.as_mut_ptr().cast()),
needed,
&mut needed,
)
};
if let Err(error) = information {
unsafe {
let _ = CloseHandle(token);
}
return Err(io_error(&error));
}
let token_user = unsafe { &*buffer.as_ptr().cast::<TOKEN_USER>() };
let mut sid_string = PWSTR::null();
let converted = unsafe { ConvertSidToStringSidW(token_user.User.Sid, &mut sid_string) };
unsafe {
let _ = CloseHandle(token);
}
converted.map_err(|error| io_error(&error))?;
let text = unsafe { sid_string.to_string() }
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error));
unsafe {
let _ = LocalFree(Some(HLOCAL(sid_string.0.cast())));
}
text
}
pub(super) fn create_restrictive_file(path: &Path) -> io::Result<File> {
let sddl = format!("D:P(A;;FA;;;BA)(A;;FA;;;{})", current_user_sid()?);
let sddl_wide: Vec<u16> = sddl.encode_utf16().chain(std::iter::once(0)).collect();
let mut descriptor = PSECURITY_DESCRIPTOR(std::ptr::null_mut());
unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(
PCWSTR(sddl_wide.as_ptr()),
SDDL_REVISION_1,
&mut descriptor,
None,
)
}
.map_err(|error| io_error(&error))?;
let attributes = SECURITY_ATTRIBUTES {
nLength: u32::try_from(size_of::<SECURITY_ATTRIBUTES>()).unwrap_or(u32::MAX),
lpSecurityDescriptor: descriptor.0,
bInheritHandle: windows::core::BOOL(0),
};
let wide = to_wide(path);
let handle = unsafe {
CreateFileW(
PCWSTR(wide.as_ptr()),
FILE_GENERIC_READ.0 | FILE_GENERIC_WRITE.0,
FILE_SHARE_NONE,
Some(&raw const attributes),
CREATE_NEW,
FILE_ATTRIBUTE_NORMAL,
None,
)
};
unsafe {
let _ = LocalFree(Some(HLOCAL(descriptor.0)));
}
let handle = handle.map_err(|error| io_error(&error))?;
Ok(unsafe { File::from_raw_handle(handle.0) })
}
pub(super) fn describe_permissions(path: &Path) -> io::Result<(String, bool)> {
let wide = to_wide(path);
let mut descriptor = PSECURITY_DESCRIPTOR(std::ptr::null_mut());
let status = unsafe {
GetNamedSecurityInfoW(
PCWSTR(wide.as_ptr()),
SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION,
None,
None,
None,
None,
&mut descriptor,
)
};
if status != ERROR_SUCCESS {
return Err(io::Error::from_raw_os_error(
i32::try_from(status.0).unwrap_or(i32::MAX),
));
}
let mut sddl = PWSTR::null();
let converted = unsafe {
ConvertSecurityDescriptorToStringSecurityDescriptorW(
descriptor,
SDDL_REVISION_1,
DACL_SECURITY_INFORMATION,
&mut sddl,
None,
)
};
let text = match converted {
Ok(()) => {
let text = unsafe { sddl.to_string() }
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error));
unsafe {
let _ = LocalFree(Some(HLOCAL(sddl.0.cast())));
}
text
}
Err(error) => Err(io_error(&error)),
};
unsafe {
let _ = LocalFree(Some(HLOCAL(descriptor.0)));
}
let text = text?;
let readable = dacl_grants_broad_access(&text);
Ok((text, readable))
}
fn dacl_grants_broad_access(sddl: &str) -> bool {
const BROAD: &[&str] = &[
"WD", "S-1-1-0", "AU", "S-1-5-11", "BU", "S-1-5-32-545", "IU", "S-1-5-4", "AN", "S-1-5-7", "WR", "LU", ];
let Some(dacl) = sddl.split("D:").nth(1) else {
return true;
};
let flags: String = dacl.chars().take_while(|c| *c != '(').collect();
if !flags.contains('P') {
return true;
}
for ace in dacl.split('(').skip(1) {
let ace = ace.split(')').next().unwrap_or_default();
let fields: Vec<&str> = ace.split(';').collect();
let (Some(kind), Some(trustee)) = (fields.first(), fields.get(5)) else {
continue;
};
if !kind.starts_with('A') {
continue;
}
if BROAD
.iter()
.any(|broad| trustee.eq_ignore_ascii_case(broad))
{
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::dacl_grants_broad_access;
#[test]
fn a_protected_owner_only_dacl_is_not_broadly_readable() {
assert!(!dacl_grants_broad_access(
"D:P(A;;FA;;;BA)(A;;FA;;;S-1-5-21-1-2-3-1001)"
));
assert!(!dacl_grants_broad_access(
"D:P(A;;FA;;;BA)(A;;FA;;;S-1-5-18)"
));
assert!(!dacl_grants_broad_access(
"D:P(A;;FA;;;SY)(A;;FA;;;BA)(A;;FA;;;S-1-5-21-1-2-3-1001)"
));
}
#[test]
fn an_everyone_ace_is_broadly_readable_in_either_notation() {
assert!(dacl_grants_broad_access("D:P(A;;FA;;;SY)(A;;FR;;;WD)"));
assert!(dacl_grants_broad_access("D:P(A;;FA;;;SY)(A;;FR;;;S-1-1-0)"));
assert!(dacl_grants_broad_access("D:P(A;;FR;;;BU)"));
}
#[test]
fn an_unprotected_dacl_is_broadly_readable_because_it_inherits() {
assert!(dacl_grants_broad_access("D:AI(A;ID;FA;;;SY)"));
}
#[test]
fn a_deny_ace_for_everyone_is_not_a_grant() {
assert!(!dacl_grants_broad_access("D:P(D;;FA;;;WD)(A;;FA;;;SY)"));
}
#[test]
fn a_missing_dacl_is_treated_as_wide_open() {
assert!(dacl_grants_broad_access("O:BAG:BA"));
}
}
}
#[cfg(unix)]
mod sys {
use std::fs::File;
use std::io;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::Path;
pub(super) fn request_stop(pid: u32) -> io::Result<bool> {
let result = unsafe { libc::kill(-(pid as libc::pid_t), libc::SIGTERM) };
if result == 0 {
return Ok(true);
}
let error = io::Error::last_os_error();
if error.raw_os_error() == Some(libc::ESRCH) {
return Ok(true);
}
Err(error)
}
pub(super) fn force_stop(pid: u32) -> io::Result<()> {
let result = unsafe { libc::kill(-(pid as libc::pid_t), libc::SIGKILL) };
if result == 0 {
return Ok(());
}
let error = io::Error::last_os_error();
if error.raw_os_error() == Some(libc::ESRCH) {
return Ok(());
}
Err(error)
}
pub(super) fn create_restrictive_file(path: &Path) -> io::Result<File> {
std::fs::OpenOptions::new()
.read(true)
.write(true)
.create_new(true)
.mode(0o600)
.open(path)
}
pub(super) fn describe_permissions(path: &Path) -> io::Result<(String, bool)> {
let mode = std::fs::metadata(path)?.permissions().mode() & 0o777;
Ok((format!("mode {mode:04o}"), mode & 0o077 != 0))
}
#[cfg(target_os = "linux")]
pub(super) fn start_token(
pid: u32,
filter: super::LivenessFilter,
) -> io::Result<Option<String>> {
let stat = match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
Ok(stat) => stat,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error),
};
let parsed = super::parse_proc_stat(&stat).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("/proc/{pid}/stat is not in the documented format"),
)
})?;
if parsed.state == "Z" && filter == super::LivenessFilter::LiveOnly {
return Ok(None);
}
Ok(Some(format!("linux:{}:{}", boot_id()?, parsed.start_ticks)))
}
#[cfg(target_os = "linux")]
fn boot_id() -> io::Result<String> {
if let Ok(id) = std::fs::read_to_string("/proc/sys/kernel/random/boot_id") {
let id = id.trim();
if !id.is_empty() {
return Ok(id.to_string());
}
}
let stat = std::fs::read_to_string("/proc/stat")?;
stat.lines()
.find_map(|line| line.strip_prefix("btime "))
.map(|value| format!("btime-{}", value.trim()))
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
"neither /proc/sys/kernel/random/boot_id nor /proc/stat btime is readable, \
so a process identity that survives a reboot cannot be formed",
)
})
}
#[cfg(target_os = "macos")]
pub(super) fn start_token(
pid: u32,
filter: super::LivenessFilter,
) -> io::Result<Option<String>> {
let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
let size = i32::try_from(size_of::<libc::proc_bsdinfo>()).unwrap_or(i32::MAX);
let written = unsafe {
libc::proc_pidinfo(
pid as libc::c_int,
libc::PROC_PIDTBSDINFO,
0,
std::ptr::from_mut(&mut info).cast(),
size,
)
};
if written <= 0 {
let error = io::Error::last_os_error();
return if super::probe_failure_means_gone(error.raw_os_error(), libc::ESRCH) {
Ok(None)
} else {
Err(error)
};
}
if written != size {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("proc_pidinfo returned {written} bytes for PID {pid}, expected {size}"),
));
}
if info.pbi_status == libc::SZOMB && filter == super::LivenessFilter::LiveOnly {
return Ok(None);
}
Ok(Some(format!(
"macos:{}.{:06}",
info.pbi_start_tvsec, info.pbi_start_tvusec
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_child_that_is_already_gone_is_still_given_an_identity() {
let identity = ProcessIdentity::of_child(u32::MAX).expect(
"a parent holding the handle must always end up with an identity, even for a child that has already exited",
);
assert_eq!(
identity.classify(None),
Adoption::Gone,
"a PID nobody holds is gone"
);
assert!(
matches!(
identity.classify(Some("macos:1.000000".to_string())),
Adoption::PidRecycled { .. }
),
"and a PID somebody else has taken is recycled -- which is refused, never signalled"
);
}
fn quick_exit() -> SpawnSpec {
if cfg!(windows) {
SpawnSpec::new("cmd").args(["/C", "exit", "0"])
} else {
SpawnSpec::new("true")
}
}
fn long_running() -> SpawnSpec {
if cfg!(windows) {
SpawnSpec::new("ping").args(["-n", "600", "127.0.0.1"])
} else {
SpawnSpec::new("sleep").args(["600"])
}
}
const COARSEST_START_TOKEN_TICK: Duration = Duration::from_millis(10);
fn separate_start_tokens() {
std::thread::sleep(COARSEST_START_TOKEN_TICK * 5 / 2);
}
fn assert_distinguishable(first: &ProcessIdentity, second: &ProcessIdentity) {
assert_ne!(
first.start_token(),
second.start_token(),
"the two children share a start token, so the record synthesised below would be \
the second child's real identity rather than a recycled one. This is the fixture \
colliding inside one start-token tick, not the discriminator failing; lengthen \
`separate_start_tokens` for this platform."
);
}
const REAL_STAT: &str = "1234 (bash) S 1200 1234 1234 34816 1234 4194304 3300 5100 0 0 6 5 8 4 20 0 1 0 987654 12345678 900 18446744073709551615";
#[test]
fn the_proc_stat_parser_reads_the_state_and_the_start_time() {
let parsed = parse_proc_stat(REAL_STAT).expect("a well-formed line parses");
assert_eq!(parsed.state, "S");
assert_eq!(
parsed.start_ticks, "987654",
"field 22 is `starttime`; an off-by-one here silently produces a process identity \
that is stable but wrong, which is worse than one that fails"
);
}
#[test]
fn the_proc_stat_parser_survives_a_command_name_containing_spaces_and_parentheses() {
let hostile = REAL_STAT.replace("(bash)", "(my prog (v2) :) )");
let parsed = parse_proc_stat(&hostile).expect("a hostile comm still parses");
assert_eq!(parsed.state, "S");
assert_eq!(parsed.start_ticks, "987654");
}
#[test]
fn a_naive_whitespace_split_gets_the_hostile_case_wrong() {
let hostile = REAL_STAT.replace("(bash)", "(my prog (v2) :) )");
let naive: Vec<&str> = hostile.split_whitespace().collect();
let naive_start = naive.get(21).copied();
assert_ne!(
naive_start,
Some(parse_proc_stat(&hostile).expect("parses").start_ticks),
"if these agree, the hostile fixture no longer exercises the hazard and the test \
above proves nothing"
);
}
#[test]
fn the_proc_stat_parser_reports_a_zombie() {
let zombie = REAL_STAT.replacen(") S ", ") Z ", 1);
let parsed = parse_proc_stat(&zombie).expect("parses");
assert_eq!(
parsed.state, "Z",
"a zombie holds its PID but is not adoptable; the caller depends on seeing this"
);
}
#[test]
fn the_proc_stat_parser_rejects_a_truncated_line() {
assert_eq!(parse_proc_stat(""), None);
assert_eq!(parse_proc_stat("1234 (bash)"), None);
assert_eq!(parse_proc_stat("1234 (bash) S 1200"), None);
assert_eq!(
parse_proc_stat("no parenthesis here at all"),
None,
"a line with no comm field must be rejected, not indexed into"
);
}
#[test]
fn the_current_process_has_a_stable_identity() {
let first = ProcessIdentity::of_current_process().expect("this process can see itself");
let second = ProcessIdentity::of_current_process().expect("twice");
assert_eq!(
first, second,
"an identity that changes between two reads of the same live process would make \
every journal record unmatchable"
);
assert_eq!(first.pid(), std::process::id());
assert!(
!first.start_token().is_empty(),
"an empty start token would make the identity a bare PID again"
);
assert_eq!(first.recheck().expect("resolvable"), Adoption::Live);
}
#[test]
fn the_start_token_varies_between_processes() {
std::thread::sleep(Duration::from_millis(20));
let mut child = long_running().spawn().expect("the child starts");
let mine = ProcessIdentity::of_current_process().expect("this process can see itself");
assert_ne!(
child.identity().start_token(),
mine.start_token(),
"two different processes must not share a start token"
);
child.stop(Duration::from_secs(5)).expect("the child stops");
}
#[test]
fn a_spawned_child_is_observable_and_terminable() {
let mut child = long_running().spawn().expect("the child starts");
assert!(child.is_running().expect("observable"), "just spawned");
assert_eq!(child.identity().pid(), child.pid());
assert_eq!(
child.identity().recheck().expect("resolvable"),
Adoption::Live,
"a running child must re-resolve to itself"
);
assert_eq!(
child.wait_for(Duration::from_millis(50)).expect("waitable"),
None,
"a long-running child must not be reported as exited"
);
child
.stop(Duration::from_secs(10))
.expect("the child stops");
assert!(!child.is_running().expect("observable"), "after stop");
assert_eq!(
child.identity().recheck().expect("resolvable"),
Adoption::Gone,
"a stopped child's identity must not still resolve as live"
);
}
#[test]
fn a_child_that_exits_on_its_own_is_reported_as_exited() {
let mut child = quick_exit().spawn().expect("the child starts");
let status = child
.wait_for(Duration::from_secs(30))
.expect("waitable")
.expect("a program that exits immediately must be seen to exit");
assert!(status.success(), "{status:?}");
assert!(!child.is_running().expect("observable"));
}
#[test]
fn a_recorded_identity_survives_a_restart_and_rejects_a_recycled_pid() {
let mut victim = long_running().spawn().expect("the first child starts");
let recorded = victim.identity().clone();
let journalled = serde_json::to_string(&recorded).expect("serialisable");
let recovered: ProcessIdentity = serde_json::from_str(&journalled).expect("readable back");
assert_eq!(recovered, recorded);
assert_eq!(
recovered.recheck().expect("resolvable"),
Adoption::Live,
"a journalled identity whose process is still running must be adoptable; e3's \
restart recovery depends on exactly this"
);
separate_start_tokens();
let mut survivor = long_running().spawn().expect("the second child starts");
let survivor_identity = survivor.identity().clone();
assert_ne!(survivor_identity.pid(), recorded.pid());
assert_distinguishable(&recorded, &survivor_identity);
victim
.stop(Duration::from_secs(10))
.expect("the first child stops");
let recycled = ProcessIdentity {
pid: survivor_identity.pid(),
start_token: recorded.start_token().to_string(),
};
match recycled.recheck().expect("resolvable") {
Adoption::PidRecycled { current } => {
assert_eq!(
current, survivor_identity,
"the recycled answer must name whoever actually holds the PID"
);
}
other => panic!(
"a recycled PID must not be adopted, and must be distinguishable from a PID \
nobody holds; got {other:?}"
),
}
survivor
.stop(Duration::from_secs(10))
.expect("the second child stops");
}
#[test]
fn only_no_such_process_means_gone() {
const ESRCH: i32 = 3;
const EPERM: i32 = 1;
assert!(probe_failure_means_gone(Some(ESRCH), ESRCH));
assert!(
!probe_failure_means_gone(Some(0), ESRCH),
"a zero errno is an unexplained failure, not an absent process"
);
assert!(!probe_failure_means_gone(Some(EPERM), ESRCH));
assert!(!probe_failure_means_gone(None, ESRCH));
}
const CFG_ATTR: &str = concat!("#[cfg_", "attr(");
const DEAD_CODE: &str = concat!("dead_", "code");
fn positive_dead_code_conditions(source: &str) -> Vec<String> {
let code: String = source
.lines()
.filter(|line| !line.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n");
let mut offenders = Vec::new();
for block in code.split(CFG_ATTR).skip(1) {
let mut depth = 0usize;
let mut body_end = None;
let mut split_at = None;
for (index, character) in block.char_indices() {
match character {
'(' => depth += 1,
')' => {
if depth == 0 {
body_end = Some(index);
break;
}
depth -= 1;
}
',' if depth == 0 && split_at.is_none() => split_at = Some(index),
_ => {}
}
}
let (Some(body_end), Some(split_at)) = (body_end, split_at) else {
continue;
};
if !block[split_at..body_end].contains(DEAD_CODE) {
continue;
}
let condition = block[..split_at].trim();
if !condition.starts_with("not(") {
offenders.push(condition.to_string());
}
}
offenders
}
#[test]
fn every_dead_code_allowance_names_a_complement() {
let offenders = positive_dead_code_conditions(include_str!("process.rs"));
assert!(
offenders.is_empty(),
"a dead_code allowance must name the complement of its caller's \
cfg rather than one platform, or it says nothing about the legs \
it does not name: {offenders:?}"
);
}
#[test]
fn the_allowance_scan_catches_a_positive_condition() {
let offending = concat!(
"#[cfg_",
"attr(windows, allow(dead_",
"code, reason = \"a reason\"))]\nfn f() {}"
);
assert_eq!(
positive_dead_code_conditions(offending),
vec!["windows".to_string()],
"the walk no longer recognises an allowance, so the scan over this \
file is checking nothing"
);
let complement = concat!(
"#[cfg_",
"attr(not(target_os = \"linux\"), allow(dead_",
"code, reason = \"a reason\"))]\nfn f() {}"
);
assert!(positive_dead_code_conditions(complement).is_empty());
let unrelated = concat!(
"#[cfg_",
"attr(windows, allow(clippy::needless_return))]\nfn f() {}"
);
assert!(positive_dead_code_conditions(unrelated).is_empty());
let commented = concat!(
"// #[cfg_",
"attr(windows, allow(dead_",
"code))]\nfn f() {}"
);
assert!(positive_dead_code_conditions(commented).is_empty());
}
#[test]
fn identical_start_tokens_are_the_same_process_and_differing_ones_are_not() {
let recorded = ProcessIdentity {
pid: 4312,
start_token: "platform:token-a".to_string(),
};
assert_eq!(
recorded.classify(Some("platform:token-a".to_string())),
Adoption::Live,
"an identical token is the same process; a fixture that spawns twice inside one \
tick is therefore asserting against a correct answer"
);
assert_eq!(
recorded.classify(Some("platform:token-b".to_string())),
Adoption::PidRecycled {
current: ProcessIdentity {
pid: 4312,
start_token: "platform:token-b".to_string(),
},
}
);
assert_eq!(recorded.classify(None), Adoption::Gone);
assert!(
recorded
.classify(Some("platform:token-a".to_string()))
.is_live()
);
assert!(
!recorded
.classify(Some("platform:token-b".to_string()))
.is_live()
);
assert!(!recorded.classify(None).is_live());
}
#[test]
fn two_linux_tokens_collide_only_within_one_tick_of_one_boot() {
let boot = "f81d4fae-7dec-11d0-a765-00a0c91e6bf6";
let at = |ticks: u64| ProcessIdentity {
pid: 4312,
start_token: format!("linux:{boot}:{ticks}"),
};
assert_eq!(
at(884_213).classify(Some(at(884_213).start_token)),
Adoption::Live
);
assert!(
!at(884_213)
.classify(Some(at(884_214).start_token))
.is_live()
);
let other_boot = ProcessIdentity {
pid: 4312,
start_token: "linux:6ba7b810-9dad-11d1-80b4-00c04fd430c8:884213".to_string(),
};
assert!(!at(884_213).classify(Some(other_boot.start_token)).is_live());
}
#[test]
fn a_bare_pid_would_have_accepted_the_recycled_record() {
let mut victim = long_running().spawn().expect("the first child starts");
let recorded = victim.identity().clone();
separate_start_tokens();
let mut survivor = long_running().spawn().expect("the second child starts");
let survivor_identity = survivor.identity().clone();
assert_distinguishable(&recorded, &survivor_identity);
victim
.stop(Duration::from_secs(10))
.expect("the first child stops");
let recycled = ProcessIdentity {
pid: survivor_identity.pid(),
start_token: recorded.start_token().to_string(),
};
let bare_pid_says_live = recycled.pid() == survivor_identity.pid();
assert!(
bare_pid_says_live,
"the recycled record must genuinely point at a live process, or the test above is \
not testing recycling at all"
);
assert!(
!recycled.recheck().expect("resolvable").is_live(),
"the start token must reject what a bare PID accepts"
);
survivor
.stop(Duration::from_secs(10))
.expect("the second child stops");
}
#[test]
fn terminating_a_recycled_record_refuses_rather_than_killing_a_stranger() {
let mut victim = long_running().spawn().expect("the first child starts");
let recorded = victim.identity().clone();
separate_start_tokens();
let mut survivor = long_running().spawn().expect("the second child starts");
let survivor_identity = survivor.identity().clone();
assert_distinguishable(&recorded, &survivor_identity);
victim
.stop(Duration::from_secs(10))
.expect("the first child stops");
let recycled = ProcessIdentity {
pid: survivor_identity.pid(),
start_token: recorded.start_token().to_string(),
};
let outcome = recycled
.terminate(Duration::from_secs(1))
.expect("terminable");
assert_eq!(
outcome,
Termination::RefusedPidRecycled {
current: survivor_identity,
},
"terminating a recycled record must refuse; killing a stranger's process is the \
worst outcome this primitive can produce"
);
assert!(
survivor.is_running().expect("observable"),
"the innocent process must still be running"
);
survivor.stop(Duration::from_secs(10)).expect("cleanup");
}
#[test]
fn terminating_by_identity_stops_a_process_this_agent_did_not_spawn_as_a_child() {
let mut child = long_running().spawn().expect("the child starts");
let identity = child.identity().clone();
assert_eq!(
identity
.terminate(Duration::from_secs(10))
.expect("terminable"),
Termination::Terminated
);
let status = child.wait_for(Duration::from_secs(30)).expect("waitable");
assert!(status.is_some(), "the process must actually have stopped");
assert_eq!(
identity
.terminate(Duration::from_secs(1))
.expect("terminable"),
Termination::AlreadyGone,
"terminating an already-dead identity must be a no-op, not an error: recovery runs \
this on every journal entry"
);
}
#[test]
fn resolving_a_pid_nobody_holds_is_a_distinct_error() {
let mut child = quick_exit().spawn().expect("the child starts");
let pid = child.pid();
child.wait().expect("the child exits");
match ProcessIdentity::resolve(pid) {
Err(ProcessError::NoSuchProcess { pid: reported }) => assert_eq!(reported, pid),
Ok(other) => assert_eq!(other.pid(), pid),
Err(other) => panic!("unexpected error: {other}"),
}
}
fn jit_payload() -> SecretString {
SecretString::from(
"eyJhZ2VudCI6ICJydW5uZXItbWFuYWdlciIsICJqaXQiOiAidGhpcy1pcy1ub3QtYS1yZWFsLWNvbmZp\
Zy1idXQtaXQtaXMtdGhlLXJpZ2h0LXNoYXBlLWFuZC1sZW5ndGgifQ=="
.to_string(),
)
}
#[test]
fn a_handoff_file_is_unreadable_by_other_local_users() {
let directory = tempfile::tempdir().expect("a temporary directory");
let payload = jit_payload();
let handoff =
RestrictiveHandoff::create(directory.path(), payload).expect("the file is created");
let contents = std::fs::read_to_string(handoff.path()).expect("this account can read it");
assert_eq!(contents, jit_payload().expose_secret());
let permissions = handoff.permissions().expect("inspectable");
assert!(
!permissions.readable_by_other_local_users,
"the JIT handoff is readable by other local accounts: {}",
permissions.description
);
}
#[test]
fn the_permissions_check_catches_a_world_readable_file() {
let directory = tempfile::tempdir().expect("a temporary directory");
let restrictive = directory.path().join("restrictive");
drop(super::sys::create_restrictive_file(&restrictive).expect("created"));
let restrictive_summary = permissions_summary(&restrictive).expect("inspectable");
assert!(!restrictive_summary.readable_by_other_local_users);
let open = directory.path().join("open");
std::fs::write(&open, b"not a secret").expect("created");
make_world_readable(&open);
let open_summary = permissions_summary(&open).expect("inspectable");
assert!(
open_summary.readable_by_other_local_users,
"a deliberately permissive file was reported as restricted, so the assertion in \
`a_handoff_file_is_unreadable_by_other_local_users` proves nothing. \
restrictive={} permissive={}",
restrictive_summary.description, open_summary.description
);
}
#[cfg(unix)]
fn make_world_readable(path: &Path) {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o644))
.expect("the mode can be widened");
}
#[cfg(windows)]
fn make_world_readable(path: &Path) {
let _ = std::process::Command::new("icacls")
.arg(path)
.args(["/grant", "*S-1-1-0:(R)"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
#[test]
fn a_handoff_file_is_deleted_on_the_success_path() {
let directory = tempfile::tempdir().expect("a temporary directory");
let handoff = RestrictiveHandoff::create(directory.path(), jit_payload()).expect("created");
let path = handoff.path().to_path_buf();
assert!(path.exists());
handoff.delete().expect("deletable");
assert!(!path.exists(), "the handoff outlived its explicit deletion");
}
#[test]
fn a_handoff_file_is_deleted_on_the_failure_path() {
let directory = tempfile::tempdir().expect("a temporary directory");
fn launch_and_fail(directory: &Path) -> Result<PathBuf, ProcessError> {
let handoff = RestrictiveHandoff::create(directory, jit_payload())
.expect("the handoff is created");
let path = handoff.path().to_path_buf();
SpawnSpec::new("a-program-that-does-not-exist-anywhere")
.arg(handoff.path())
.spawn_with_handoff(&handoff)?;
Ok(path)
}
let before = std::fs::read_dir(directory.path())
.expect("readable")
.count();
assert_eq!(before, 0, "the temporary directory should start empty");
let error = launch_and_fail(directory.path()).expect_err("the program does not exist");
assert!(matches!(error, ProcessError::Spawn { .. }), "{error}");
let remaining: Vec<PathBuf> = std::fs::read_dir(directory.path())
.expect("readable")
.filter_map(Result::ok)
.map(|entry| entry.path())
.collect();
assert!(
remaining.is_empty(),
"a failed start left the JIT handoff on disk: {remaining:?}"
);
}
#[test]
fn a_handoff_file_is_deleted_when_a_panic_unwinds_past_it() {
let directory = tempfile::tempdir().expect("a temporary directory");
let root = directory.path().to_path_buf();
let panicked = std::panic::catch_unwind(move || {
let _handoff = RestrictiveHandoff::create(&root, jit_payload()).expect("created");
panic!("something went wrong after the handoff was written");
});
assert!(panicked.is_err());
let remaining: Vec<PathBuf> = std::fs::read_dir(directory.path())
.expect("readable")
.filter_map(Result::ok)
.map(|entry| entry.path())
.collect();
assert!(
remaining.is_empty(),
"a panic left the JIT handoff on disk: {remaining:?}"
);
}
#[test]
fn spawning_refuses_to_put_the_payload_in_an_argument() {
let directory = tempfile::tempdir().expect("a temporary directory");
let handoff = RestrictiveHandoff::create(directory.path(), jit_payload()).expect("created");
let spec = long_running()
.arg("--jit-config")
.arg(jit_payload().expose_secret());
let error = spec
.spawn_with_handoff(&handoff)
.expect_err("the payload must never reach a command line");
match error {
ProcessError::SecretInCommandLine { location, .. } => {
assert!(location.starts_with("argument"), "{location}");
}
other => panic!("expected a refusal, got {other}"),
}
}
#[test]
fn spawning_refuses_to_put_the_payload_in_the_environment() {
let directory = tempfile::tempdir().expect("a temporary directory");
let handoff = RestrictiveHandoff::create(directory.path(), jit_payload()).expect("created");
let spec = long_running().env("ACTIONS_RUNNER_JITCONFIG", jit_payload().expose_secret());
let error = spec
.spawn_with_handoff(&handoff)
.expect_err("the payload must not be inherited through the environment either");
match error {
ProcessError::SecretInCommandLine { location, .. } => {
assert!(location.contains("ACTIONS_RUNNER_JITCONFIG"), "{location}");
}
other => panic!("expected a refusal, got {other}"),
}
}
#[test]
fn runner_handoff_injects_the_supported_secret_input_without_an_argument() {
let directory = tempfile::tempdir().expect("a temporary directory");
let payload = jit_payload();
let payload_length = payload.expose_secret().len();
let handoff = RestrictiveHandoff::create(directory.path(), payload).expect("created");
let spec = runner_jit_input_probe(payload_length);
let rendered: Vec<String> = spec
.arguments()
.iter()
.map(|argument| argument.to_string_lossy().into_owned())
.collect();
assert!(
rendered
.iter()
.all(|argument| !argument.contains(jit_payload().expose_secret())),
"the JIT payload reached the command line: {rendered:?}"
);
assert!(
rendered
.iter()
.all(|argument| argument != "--jit-config-file"),
"the obsolete listener option returned: {rendered:?}"
);
let mut child = spec
.spawn_runner_with_handoff(&handoff)
.expect("the probe starts");
handoff
.delete()
.expect("the handoff is deleted immediately");
let status = child.wait().expect("the probe exits");
assert!(
status.success(),
"the child did not receive the complete {RUNNER_JIT_CONFIG_ENV} input: {status}"
);
}
#[cfg(windows)]
fn runner_jit_input_probe(expected_length: usize) -> SpawnSpec {
SpawnSpec::new("powershell.exe").args([
"-NoProfile".into(),
"-NonInteractive".into(),
"-Command".into(),
format!(
"$value = [Environment]::GetEnvironmentVariable('{RUNNER_JIT_CONFIG_ENV}'); \
if ($null -eq $value -or $value.Length -ne {expected_length}) {{ exit 41 }}"
),
])
}
#[cfg(unix)]
fn runner_jit_input_probe(expected_length: usize) -> SpawnSpec {
SpawnSpec::new("/bin/sh").args([
"-c".to_owned(),
format!("test \"${{#{RUNNER_JIT_CONFIG_ENV}}}\" -eq \"{expected_length}\""),
])
}
#[test]
fn spawning_allows_the_handoff_path_itself() {
let directory = tempfile::tempdir().expect("a temporary directory");
let handoff = RestrictiveHandoff::create(directory.path(), jit_payload()).expect("created");
let spec = long_running().arg("--jit-config-file").arg(handoff.path());
let mut child = spec
.spawn_with_handoff(&handoff)
.expect("passing the path is the supported handoff");
let rendered: Vec<String> = spec
.arguments()
.iter()
.map(|arg| arg.to_string_lossy().into_owned())
.collect();
let payload = jit_payload();
assert!(
rendered
.iter()
.all(|arg| !arg.contains(payload.expose_secret())),
"the payload reached the argument vector: {rendered:?}"
);
assert!(
rendered
.iter()
.any(|arg| arg == &handoff.path().display().to_string()),
"the handoff path should be there: {rendered:?}"
);
child.stop(Duration::from_secs(10)).expect("cleanup");
}
#[test]
fn the_handoff_path_is_unique_per_handoff() {
let directory = tempfile::tempdir().expect("a temporary directory");
let first = RestrictiveHandoff::create(directory.path(), jit_payload()).expect("created");
let second = RestrictiveHandoff::create(directory.path(), jit_payload()).expect("created");
assert_ne!(
first.path(),
second.path(),
"two concurrent attempts must not share a handoff file"
);
}
#[test]
fn the_payload_has_no_debug_or_display_that_reveals_it() {
let payload = jit_payload();
let rendered = format!("{payload:?}");
assert!(
!rendered.contains(payload.expose_secret()),
"SecretString's Debug leaked the payload: {rendered}"
);
let directory = tempfile::tempdir().expect("a temporary directory");
let handoff = RestrictiveHandoff::create(directory.path(), jit_payload()).expect("created");
let rendered = format!("{handoff:?}");
assert!(
!rendered.contains(jit_payload().expose_secret()),
"RestrictiveHandoff's Debug leaked the payload: {rendered}"
);
}
}