#[cfg(unix)]
use crate::util::UnwrapPoison;
#[cfg(not(unix))]
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
#[cfg(not(unix))]
use std::sync::Arc;
#[cfg(not(unix))]
use std::sync::atomic::{AtomicBool, Ordering};
#[derive(Clone, Copy)]
pub(super) enum RunOwner {
Agent,
Service,
}
#[cfg(unix)]
#[derive(Clone)]
pub(super) struct Tree {
pids: std::sync::Arc<std::sync::Mutex<Vec<u32>>>,
}
#[cfg(unix)]
impl Tree {
pub(super) fn new(_owner: RunOwner) -> Self {
Self {
pids: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
}
}
pub(super) fn attach(&mut self, pid: u32) {
self.pids.lock().unwrap_poison().push(pid);
}
pub(super) fn terminate(&self) -> bool {
for pid in self.pids.lock().unwrap_poison().iter() {
super::kill_process_group(*pid, libc::SIGKILL);
}
true
}
#[expect(
clippy::unused_self,
reason = "the shared call shape consumes the tree; unix has nothing to keep"
)]
pub(super) fn retain_after_completion(self) {}
}
#[cfg(not(unix))]
#[derive(Clone)]
pub(super) struct Tree {
job: Option<Arc<Job>>,
}
#[cfg(not(unix))]
impl Tree {
pub(super) fn new(owner: RunOwner) -> Self {
let job = match owner {
RunOwner::Service => None,
RunOwner::Agent => match Job::create() {
Ok(job) => Some(Arc::new(job)),
Err(e) => {
tracing::warn!(
err = %e,
"cannot create a job object for this command — it runs without \
process-tree containment, as commands did before containment"
);
None
}
},
};
Self { job }
}
pub(super) fn attach(&mut self, pid: u32) {
let Some(job) = &self.job else {
return;
};
if let Err(e) = job.assign(pid) {
tracing::warn!(
pid,
err = %e,
"cannot put this command's process tree under containment — it may \
have finished before the assignment, and anything it left running \
may outlive it, as it did before containment"
);
self.job = None;
}
}
pub(super) fn terminate(&self) -> bool {
let Some(job) = &self.job else {
return false;
};
job.terminate()
}
pub(super) fn retain_after_completion(self) {
let Some(job) = self.job else {
return;
};
if !job.holds_processes() {
return;
}
std::mem::forget(job);
}
}
#[cfg(not(unix))]
struct Job {
handle: OwnedHandle,
ended: AtomicBool,
}
#[cfg(not(unix))]
impl Job {
fn create() -> std::io::Result<Self> {
use windows_sys::Win32::System::JobObjects::{
CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
SetInformationJobObject,
};
let handle = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
if handle == 0 {
return Err(std::io::Error::last_os_error());
}
let job = Self {
handle: unsafe { OwnedHandle::from_raw_handle(handle as _) },
ended: AtomicBool::new(false),
};
let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() };
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
let configured = unsafe {
SetInformationJobObject(
job.raw(),
JobObjectExtendedLimitInformation,
std::ptr::from_ref(&limits).cast(),
u32::try_from(std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>())
.expect("job limits fit in u32"),
)
};
if configured == 0 {
return Err(std::io::Error::last_os_error());
}
Ok(job)
}
fn raw(&self) -> windows_sys::Win32::Foundation::HANDLE {
self.handle.as_raw_handle() as windows_sys::Win32::Foundation::HANDLE
}
fn assign(&self, pid: u32) -> std::io::Result<()> {
use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject;
use windows_sys::Win32::System::Threading::{
OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE,
};
let process = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) };
if process == 0 {
return Err(std::io::Error::last_os_error());
}
let assigned = unsafe { AssignProcessToJobObject(self.raw(), process) };
let failure = std::io::Error::last_os_error();
unsafe { CloseHandle(process) };
if assigned == 0 {
return Err(failure);
}
Ok(())
}
fn terminate(&self) -> bool {
use windows_sys::Win32::System::JobObjects::TerminateJobObject;
if self.ended.load(Ordering::SeqCst) {
return true;
}
let terminated = unsafe { TerminateJobObject(self.raw(), 1) };
if terminated == 0 {
tracing::warn!(
err = %std::io::Error::last_os_error(),
"TerminateJobObject failed — this command's processes may survive"
);
} else {
self.ended.store(true, Ordering::SeqCst);
}
terminated != 0
}
fn holds_processes(&self) -> bool {
use windows_sys::Win32::System::JobObjects::{
JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, JobObjectBasicAccountingInformation,
QueryInformationJobObject,
};
let mut accounting: JOBOBJECT_BASIC_ACCOUNTING_INFORMATION = unsafe { std::mem::zeroed() };
let queried = unsafe {
QueryInformationJobObject(
self.raw(),
JobObjectBasicAccountingInformation,
std::ptr::from_mut(&mut accounting).cast(),
u32::try_from(std::mem::size_of::<JOBOBJECT_BASIC_ACCOUNTING_INFORMATION>())
.expect("job accounting fits in u32"),
std::ptr::null_mut(),
)
};
queried == 0 || accounting.ActiveProcesses > 0
}
}
#[cfg(test)]
mod tests {
const TEST_ONLY_FILES: [&str; 2] = ["src/util/test.rs", "src/db/store_lock_check.rs"];
#[derive(Clone, Copy, PartialEq, Eq)]
enum Gate {
Yes,
No,
Unknown,
}
fn gate_atom(predicate: &str) -> Gate {
let p = predicate.trim();
if p == "windows" {
return Gate::Yes;
}
if p == "unix" || p == "test" {
return Gate::No;
}
if p.starts_with("target_os") || p.starts_with("target_family") {
return if p.contains("\"windows\"") {
Gate::Yes
} else {
Gate::No
};
}
Gate::Unknown
}
fn gate_bound<'a>(predicate: &'a str, name: &str) -> Option<&'a str> {
predicate
.strip_prefix(name)?
.strip_prefix('(')?
.strip_suffix(')')
}
fn gate_args(predicate: &str) -> Vec<&str> {
let mut args = Vec::new();
let mut depth = 0_usize;
let mut quoted = false;
let mut start = 0_usize;
for (i, c) in predicate.char_indices() {
match c {
'"' => quoted = !quoted,
'(' if !quoted => depth += 1,
')' if !quoted => depth = depth.saturating_sub(1),
',' if !quoted && depth == 0 => {
args.push(&predicate[start..i]);
start = i + 1;
}
_ => {}
}
}
args.push(&predicate[start..]);
args.into_iter().map(str::trim).collect()
}
fn gate(predicate: &str) -> Gate {
let p = predicate.trim();
if let Some(inner) = gate_bound(p, "not") {
return match gate(inner) {
Gate::Yes => Gate::No,
Gate::No => Gate::Yes,
Gate::Unknown => Gate::Unknown,
};
}
if let Some(inner) = gate_bound(p, "all") {
let mut result = Gate::Yes;
for arg in gate_args(inner) {
match gate(arg) {
Gate::No => return Gate::No,
Gate::Unknown => result = Gate::Unknown,
Gate::Yes => {}
}
}
return result;
}
if let Some(inner) = gate_bound(p, "any") {
let mut result = Gate::No;
for arg in gate_args(inner) {
match gate(arg) {
Gate::Yes => return Gate::Yes,
Gate::Unknown => result = Gate::Unknown,
Gate::No => {}
}
}
return result;
}
gate_atom(p)
}
const WINDOWLESS_FLAG: &str = "CREATE_NO_WINDOW";
const FORBIDDEN_FLAGS: [&str; 2] = ["CREATE_NEW_CONSOLE", "DETACHED_PROCESS"];
const SPAWN_SITES: usize = 15;
fn indent(line: &str) -> usize {
line.len() - line.trim_start().len()
}
fn cfg_predicate(line: &str) -> Option<&str> {
let rest = line.trim_start().strip_prefix("#[cfg(")?;
Some(&rest[..rest.rfind(')')?])
}
fn gated_item_end(lines: &[&str], at: usize) -> usize {
let gate_indent = indent(lines[at]);
let mut opener = at + 1;
while opener < lines.len()
&& (lines[opener].trim().is_empty()
|| lines[opener].trim_start().starts_with("//")
|| lines[opener].trim_start().starts_with("#["))
{
opener += 1;
}
let mut braced = false;
for start in opener..lines.len() {
if indent(lines[start]) < gate_indent {
break;
}
let text = lines[start].trim_end();
if text.ends_with('{') {
braced = true;
opener = start;
break;
}
if text.ends_with('}') || text.ends_with(';') {
return start + 1;
}
let Some(next) = lines.get(start + 1) else {
break;
};
if next.trim().is_empty()
|| indent(next) < gate_indent
|| next.trim_start().starts_with("//")
{
break;
}
}
if braced {
for (end, line) in lines.iter().enumerate().skip(opener + 1) {
if indent(line) == gate_indent && line.trim_start().starts_with('}') {
return end + 1;
}
}
}
let mut end = opener + 1;
while end < lines.len()
&& (lines[end].trim().is_empty() || indent(lines[end]) > gate_indent)
{
end += 1;
}
end
}
fn skipped_lines(lines: &[&str]) -> Vec<bool> {
let mut skipped: Vec<bool> = lines
.iter()
.map(|l| l.trim_start().starts_with("//"))
.collect();
for (i, text) in lines.iter().enumerate() {
if cfg_predicate(text).is_some_and(|p| gate(p) == Gate::No) {
skipped[i..gated_item_end(lines, i)].fill(true);
}
}
skipped
}
fn fn_name(line: &str) -> Option<String> {
let name: String = line
.split_once("fn ")?
.1
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
.collect();
(!name.is_empty()).then_some(name)
}
fn enclosing_fn(lines: &[&str], at: usize) -> Option<(String, usize, usize)> {
let start = (0..at).rev().find(|&i| {
!lines[i].trim_start().starts_with("//")
&& indent(lines[i]) < indent(lines[at])
&& fn_name(lines[i]).is_some()
})?;
let mut end = at + 1;
while end < lines.len()
&& (lines[end].trim().is_empty() || indent(lines[end]) > indent(lines[start]))
{
end += 1;
}
Some((fn_name(lines[start])?, start, end))
}
fn applies_windowless_flag(body: &str) -> bool {
let mut rest = body;
while let Some(at) = rest.find("creation_flags(") {
let call = &rest[at..];
let args = &call[..call.find(')').unwrap_or(call.len())];
if args.contains(WINDOWLESS_FLAG) {
return true;
}
rest = &call["creation_flags(".len()..];
}
false
}
fn inventoried_sites() -> Vec<String> {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(file!());
let source = std::fs::read_to_string(&path).expect("read this module");
let section = source
.lines()
.skip_while(|l| !l.contains(" # No console window (windows)"))
.skip(1)
.take_while(|l| !l.trim_start().starts_with("//! # "))
.collect::<Vec<_>>()
.join("\n");
assert!(
!section.is_empty(),
"the module docs' window-guarantee section is gone"
);
section
.split('`')
.skip(1)
.step_by(2)
.map(|token| token.rsplit("::").next().unwrap_or(token))
.map(|token| token.trim_end_matches("()"))
.filter(|token| {
!token.is_empty() && token.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
})
.map(str::to_string)
.collect()
}
#[test]
fn every_production_spawn_is_windowless() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let inventory = inventoried_sites();
let mut sites = 0_usize;
let mut violations: Vec<String> = Vec::new();
for file in crate::util::test::rs_files_under(&root.join("src")) {
let rel = crate::util::test::rel_source_path(root, &file);
if TEST_ONLY_FILES.contains(&rel.as_str()) {
continue;
}
let content = std::fs::read_to_string(&file).expect("read source file");
let lines: Vec<&str> = content.lines().collect();
let skipped = skipped_lines(&lines);
for (i, text) in lines.iter().enumerate() {
if skipped[i] {
continue;
}
let starts_process = text.contains("Command::new");
let reserved = FORBIDDEN_FLAGS.iter().find(|f| text.contains(**f)).copied();
if !starts_process && reserved.is_none() {
continue;
}
let Some((name, _, end)) = enclosing_fn(&lines, i) else {
violations.push(format!(
"{rel}:{}: a process is created outside any function",
i + 1
));
continue;
};
if starts_process {
sites += 1;
if !applies_windowless_flag(&lines[i..end].join("\n")) {
violations.push(format!(
"{rel}:{} `{name}` starts a process without {WINDOWLESS_FLAG}",
i + 1
));
} else if !inventory.iter().any(|listed| listed == &name) {
violations.push(format!(
"{rel}:{} `{name}` applies {WINDOWLESS_FLAG} but the module docs' \
covered-site inventory does not name it",
i + 1
));
}
}
if let Some(flag) = reserved {
violations.push(format!(
"{rel}:{} `{name}` uses {flag} — see the module docs' window guarantee",
i + 1
));
}
}
}
assert_eq!(
sites, SPAWN_SITES,
"the spawn sweep saw {sites} sites, not the {SPAWN_SITES} the module docs \
inventory lists — a new site needs the flag and a name in that list, and a \
scan that stopped reading the tree would find fewer"
);
assert!(
violations.is_empty(),
"the service must never put a console window on the screen:\n{violations:#?}"
);
}
#[test]
fn the_binary_is_built_as_a_windowed_image() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/main.rs");
let source = std::fs::read_to_string(&path).expect("read the binary's crate root");
let attribute = r#"#![cfg_attr(not(test), windows_subsystem = "windows")]"#;
assert!(
source.contains(attribute),
"{} must carry {attribute} — a Windows launch of the product is \
console-less because of it, where the test harness keeps its console",
path.display()
);
}
}