|
|
|
@@ -0,0 +1,900 @@
|
|
|
|
|
//! The `proctree` command inspects process trees.
|
|
|
|
|
#![allow(clippy::multiple_crate_versions)]
|
|
|
|
|
|
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
|
|
|
use std::ffi::OsString;
|
|
|
|
|
use std::fmt::Write as _;
|
|
|
|
|
use std::process::{Command, Stdio};
|
|
|
|
|
use std::time::Duration;
|
|
|
|
|
|
|
|
|
|
use common::{
|
|
|
|
|
CliError, CommonArgs, ExitCode, RenderMode, map_result_count, parse_color_choice,
|
|
|
|
|
parse_format_choice, print_json, print_quick_help_error, print_structured,
|
|
|
|
|
};
|
|
|
|
|
use lexopt::prelude::{Long, Short, Value as ArgValue};
|
|
|
|
|
use regex_lite::{Regex, RegexBuilder};
|
|
|
|
|
use serde::Serialize;
|
|
|
|
|
use windowsupport::{ProcessDescriptor, sleep_for, snapshot_processes};
|
|
|
|
|
|
|
|
|
|
const HELP: &str = "\
|
|
|
|
|
Inspect Windows process trees with compact AI-friendly output.
|
|
|
|
|
|
|
|
|
|
Windows only.
|
|
|
|
|
|
|
|
|
|
Usage:
|
|
|
|
|
proctree [OPTIONS] system
|
|
|
|
|
proctree [OPTIONS] root <PID>
|
|
|
|
|
proctree [OPTIONS] run -- <COMMAND...>
|
|
|
|
|
|
|
|
|
|
Options:
|
|
|
|
|
--format <FORMAT> Structured output format: text, json, toon
|
|
|
|
|
--json Shortcut for --format json
|
|
|
|
|
--toon Shortcut for --format toon
|
|
|
|
|
--color <WHEN> Control ANSI color output: auto, never
|
|
|
|
|
--quiet Suppress non-essential status output
|
|
|
|
|
--match <REGEX> Filter by image name or command line
|
|
|
|
|
--max-depth <COUNT> Optional maximum depth to emit
|
|
|
|
|
--include-cmdline Include command-line text in compact output
|
|
|
|
|
--orphans Highlight descendants still alive after the root exits
|
|
|
|
|
-h, --help Show this help text
|
|
|
|
|
-V, --version Show the command version
|
|
|
|
|
|
|
|
|
|
Examples:
|
|
|
|
|
proctree system
|
|
|
|
|
proctree root 1234 --json | ConvertFrom-Json
|
|
|
|
|
proctree run -- pwsh -NoProfile -Command \"npm test\"
|
|
|
|
|
proctree system --match 'pwsh|Mercury' --include-cmdline
|
|
|
|
|
";
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
struct Cli {
|
|
|
|
|
common: CommonArgs,
|
|
|
|
|
mode: Mode,
|
|
|
|
|
match_pattern: Option<Regex>,
|
|
|
|
|
max_depth: Option<usize>,
|
|
|
|
|
include_cmdline: bool,
|
|
|
|
|
orphans: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
enum Mode {
|
|
|
|
|
System,
|
|
|
|
|
Root(u32),
|
|
|
|
|
Run(Vec<OsString>),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
enum ParseOutcome {
|
|
|
|
|
Help,
|
|
|
|
|
Version,
|
|
|
|
|
Run,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
|
|
|
struct ProcessTreeReport {
|
|
|
|
|
root_pid: Option<u32>,
|
|
|
|
|
message: Option<String>,
|
|
|
|
|
nodes: Vec<ProcessNode>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
|
|
|
struct ProcessNode {
|
|
|
|
|
pid: u32,
|
|
|
|
|
parent_pid: Option<u32>,
|
|
|
|
|
depth: usize,
|
|
|
|
|
image_name: String,
|
|
|
|
|
exe: Option<String>,
|
|
|
|
|
command_line: Vec<String>,
|
|
|
|
|
command_line_preview: String,
|
|
|
|
|
start_time_unix: u64,
|
|
|
|
|
run_time_seconds: u64,
|
|
|
|
|
run_time_display: String,
|
|
|
|
|
orphan: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Default)]
|
|
|
|
|
struct RunObservation {
|
|
|
|
|
root_pid: u32,
|
|
|
|
|
observed_processes: HashMap<u32, ProcessDescriptor>,
|
|
|
|
|
observed_descendants: HashSet<u32>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Parses CLI arguments and returns a process exit code.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn main_entry() -> i32 {
|
|
|
|
|
match parse_cli_from(std::env::args_os()) {
|
|
|
|
|
Ok((ParseOutcome::Help, _)) => {
|
|
|
|
|
print!("{HELP}");
|
|
|
|
|
ExitCode::Success.as_i32()
|
|
|
|
|
}
|
|
|
|
|
Ok((ParseOutcome::Version, _)) => {
|
|
|
|
|
println!("proctree {}", env!("CARGO_PKG_VERSION"));
|
|
|
|
|
ExitCode::Success.as_i32()
|
|
|
|
|
}
|
|
|
|
|
Ok((ParseOutcome::Run, cli)) => match run(&cli) {
|
|
|
|
|
Ok(code) => code.as_i32(),
|
|
|
|
|
Err(error) => {
|
|
|
|
|
print_quick_help_error(&error, HELP);
|
|
|
|
|
error.exit_code().as_i32()
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
Err(error) => {
|
|
|
|
|
print_quick_help_error(&error, HELP);
|
|
|
|
|
error.exit_code().as_i32()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_cli_from<I, T>(args: I) -> Result<(ParseOutcome, Cli), CliError>
|
|
|
|
|
where
|
|
|
|
|
I: IntoIterator<Item = T>,
|
|
|
|
|
T: Into<OsString>,
|
|
|
|
|
{
|
|
|
|
|
let mut parser = lexopt::Parser::from_iter(args);
|
|
|
|
|
let mut common = CommonArgs::default();
|
|
|
|
|
let mut mode = None::<Mode>;
|
|
|
|
|
let mut match_pattern = None::<Regex>;
|
|
|
|
|
let mut max_depth = None::<usize>;
|
|
|
|
|
let mut include_cmdline = false;
|
|
|
|
|
let mut orphans = false;
|
|
|
|
|
let mut collecting_run = false;
|
|
|
|
|
let mut run_args = Vec::<OsString>::new();
|
|
|
|
|
|
|
|
|
|
while let Some(argument) = parser
|
|
|
|
|
.next()
|
|
|
|
|
.map_err(|error| CliError::usage(error.to_string()))?
|
|
|
|
|
{
|
|
|
|
|
if collecting_run {
|
|
|
|
|
if let ArgValue(value) = argument {
|
|
|
|
|
run_args.push(value);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
return Err(CliError::usage(
|
|
|
|
|
"run only accepts command arguments after --",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
match argument {
|
|
|
|
|
Long("help") | Short('h') => return Ok((ParseOutcome::Help, empty_cli(common))),
|
|
|
|
|
Long("version") | Short('V') => {
|
|
|
|
|
return Ok((ParseOutcome::Version, empty_cli(common)));
|
|
|
|
|
}
|
|
|
|
|
Long("json") => common.set_render_mode(RenderMode::Json),
|
|
|
|
|
Long("toon") => common.set_render_mode(RenderMode::Toon),
|
|
|
|
|
Long("format") => {
|
|
|
|
|
let value = parser_value_string(&mut parser, "--format")?;
|
|
|
|
|
common.set_render_mode(parse_format_choice(&value)?);
|
|
|
|
|
}
|
|
|
|
|
Long("color") => {
|
|
|
|
|
common.color = parse_color_choice(&parser_value_string(&mut parser, "--color")?)?;
|
|
|
|
|
}
|
|
|
|
|
Long("quiet") => common.quiet = true,
|
|
|
|
|
Long("match") => {
|
|
|
|
|
match_pattern = Some(parse_regex(&parser_value_string(&mut parser, "--match")?)?);
|
|
|
|
|
}
|
|
|
|
|
Long("max-depth") => {
|
|
|
|
|
max_depth = Some(parse_usize_flag(
|
|
|
|
|
"--max-depth",
|
|
|
|
|
&parser_value_string(&mut parser, "--max-depth")?,
|
|
|
|
|
)?);
|
|
|
|
|
}
|
|
|
|
|
Long("include-cmdline") => include_cmdline = true,
|
|
|
|
|
Long("orphans") => orphans = true,
|
|
|
|
|
ArgValue(value) => {
|
|
|
|
|
if matches!(mode, Some(Mode::Run(_))) {
|
|
|
|
|
collecting_run = true;
|
|
|
|
|
run_args.push(value);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let text = arg_to_string(value, "subcommand")?;
|
|
|
|
|
if mode.is_none() {
|
|
|
|
|
mode = Some(parse_mode_token(&text, &mut parser)?);
|
|
|
|
|
} else {
|
|
|
|
|
return Err(CliError::usage(
|
|
|
|
|
"unexpected positional argument; use --help to see available options",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_ => {
|
|
|
|
|
return Err(CliError::usage(
|
|
|
|
|
"unsupported argument; use --help to see available options",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mode = match mode {
|
|
|
|
|
Some(Mode::Run(_)) => {
|
|
|
|
|
if run_args.is_empty() {
|
|
|
|
|
return Err(CliError::usage("run requires a command after --"));
|
|
|
|
|
}
|
|
|
|
|
Mode::Run(run_args)
|
|
|
|
|
}
|
|
|
|
|
Some(mode) => mode,
|
|
|
|
|
None => {
|
|
|
|
|
return Err(CliError::usage(
|
|
|
|
|
"provide a subcommand: system, root, or run",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Ok((
|
|
|
|
|
ParseOutcome::Run,
|
|
|
|
|
Cli {
|
|
|
|
|
common,
|
|
|
|
|
mode,
|
|
|
|
|
match_pattern,
|
|
|
|
|
max_depth,
|
|
|
|
|
include_cmdline,
|
|
|
|
|
orphans,
|
|
|
|
|
},
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fn empty_cli(common: CommonArgs) -> Cli {
|
|
|
|
|
Cli {
|
|
|
|
|
common,
|
|
|
|
|
mode: Mode::System,
|
|
|
|
|
match_pattern: None,
|
|
|
|
|
max_depth: None,
|
|
|
|
|
include_cmdline: false,
|
|
|
|
|
orphans: false,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn arg_to_string(value: OsString, context: &str) -> Result<String, CliError> {
|
|
|
|
|
value.into_string().map_err(|invalid| {
|
|
|
|
|
CliError::usage(format!(
|
|
|
|
|
"{context} expects UTF-8 text, got '{}'",
|
|
|
|
|
invalid.to_string_lossy()
|
|
|
|
|
))
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_mode_token(text: &str, parser: &mut lexopt::Parser) -> Result<Mode, CliError> {
|
|
|
|
|
match text {
|
|
|
|
|
"system" => Ok(Mode::System),
|
|
|
|
|
"root" => {
|
|
|
|
|
let pid = parser_value_string(parser, "root pid")?
|
|
|
|
|
.parse::<u32>()
|
|
|
|
|
.map_err(|error| CliError::usage(format!("invalid root pid: {error}")))?;
|
|
|
|
|
Ok(Mode::Root(pid))
|
|
|
|
|
}
|
|
|
|
|
"run" => Ok(Mode::Run(Vec::new())),
|
|
|
|
|
_ => Err(CliError::usage(
|
|
|
|
|
"unknown subcommand; expected system, root, or run",
|
|
|
|
|
)),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result<String, CliError> {
|
|
|
|
|
let value = parser
|
|
|
|
|
.value()
|
|
|
|
|
.map_err(|error| CliError::usage(error.to_string()))?;
|
|
|
|
|
value.into_string().map_err(|invalid| {
|
|
|
|
|
CliError::usage(format!(
|
|
|
|
|
"{flag} expects UTF-8 text, got '{}'",
|
|
|
|
|
invalid.to_string_lossy()
|
|
|
|
|
))
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_regex(value: &str) -> Result<Regex, CliError> {
|
|
|
|
|
RegexBuilder::new(value)
|
|
|
|
|
.case_insensitive(true)
|
|
|
|
|
.build()
|
|
|
|
|
.map_err(|error| CliError::usage(format!("invalid --match regex '{value}': {error}")))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_usize_flag(flag: &str, value: &str) -> Result<usize, CliError> {
|
|
|
|
|
value
|
|
|
|
|
.parse::<usize>()
|
|
|
|
|
.map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}")))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn run(cli: &Cli) -> Result<ExitCode, CliError> {
|
|
|
|
|
let report = match &cli.mode {
|
|
|
|
|
Mode::System => build_system_report(cli),
|
|
|
|
|
Mode::Root(pid) => build_root_report(cli, *pid, false),
|
|
|
|
|
Mode::Run(command) => build_run_report(cli, command)?,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
match cli.common.render_mode() {
|
|
|
|
|
RenderMode::Json => print_json(&report)?,
|
|
|
|
|
RenderMode::Toon => print_structured(&report, RenderMode::Toon)?,
|
|
|
|
|
RenderMode::Text => print!("{}", render_report(&report, cli.include_cmdline)),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(map_result_count(report.nodes.len()))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_system_report(cli: &Cli) -> ProcessTreeReport {
|
|
|
|
|
let processes = filter_processes(snapshot_processes(), cli.match_pattern.as_ref());
|
|
|
|
|
let nodes = flatten_system_nodes(&processes, cli.max_depth);
|
|
|
|
|
ProcessTreeReport {
|
|
|
|
|
root_pid: None,
|
|
|
|
|
message: None,
|
|
|
|
|
nodes,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_root_report(cli: &Cli, root_pid: u32, orphan_mode: bool) -> ProcessTreeReport {
|
|
|
|
|
let processes = filter_processes(snapshot_processes(), cli.match_pattern.as_ref());
|
|
|
|
|
let nodes = flatten_subtree_nodes(&processes, root_pid, cli.max_depth, orphan_mode);
|
|
|
|
|
ProcessTreeReport {
|
|
|
|
|
root_pid: Some(root_pid),
|
|
|
|
|
message: nodes
|
|
|
|
|
.is_empty()
|
|
|
|
|
.then(|| format!("root process {root_pid} not found at snapshot time")),
|
|
|
|
|
nodes,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_run_report(cli: &Cli, command: &[OsString]) -> Result<ProcessTreeReport, CliError> {
|
|
|
|
|
let Some(program) = command.first() else {
|
|
|
|
|
return Err(CliError::usage("run requires a command"));
|
|
|
|
|
};
|
|
|
|
|
let mut child = Command::new(program)
|
|
|
|
|
.args(&command[1..])
|
|
|
|
|
.stdin(Stdio::null())
|
|
|
|
|
.stdout(Stdio::null())
|
|
|
|
|
.stderr(Stdio::null())
|
|
|
|
|
.spawn()
|
|
|
|
|
.map_err(|error| CliError::runtime(format!("failed to launch command: {error}")))?;
|
|
|
|
|
let root_pid = child.id();
|
|
|
|
|
|
|
|
|
|
let mut observation = RunObservation::new(root_pid);
|
|
|
|
|
loop {
|
|
|
|
|
let snapshot = snapshot_processes();
|
|
|
|
|
observation.record(&snapshot);
|
|
|
|
|
if child
|
|
|
|
|
.try_wait()
|
|
|
|
|
.map_err(|error| CliError::runtime(format!("failed to poll command: {error}")))?
|
|
|
|
|
.is_some()
|
|
|
|
|
{
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
sleep_for(Duration::from_millis(75));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut final_snapshot = snapshot_processes();
|
|
|
|
|
observation.record(&final_snapshot);
|
|
|
|
|
if cli.orphans {
|
|
|
|
|
sleep_for(Duration::from_millis(150));
|
|
|
|
|
final_snapshot = snapshot_processes();
|
|
|
|
|
observation.record(&final_snapshot);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(observation.build_report(cli, &final_snapshot))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl RunObservation {
|
|
|
|
|
fn new(root_pid: u32) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
root_pid,
|
|
|
|
|
observed_processes: HashMap::new(),
|
|
|
|
|
observed_descendants: HashSet::new(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn record(&mut self, processes: &[ProcessDescriptor]) {
|
|
|
|
|
let process_map = processes
|
|
|
|
|
.iter()
|
|
|
|
|
.cloned()
|
|
|
|
|
.map(|process| (process.pid, process))
|
|
|
|
|
.collect::<HashMap<_, _>>();
|
|
|
|
|
if let Some(root) = process_map.get(&self.root_pid) {
|
|
|
|
|
self.observed_processes.insert(self.root_pid, root.clone());
|
|
|
|
|
}
|
|
|
|
|
let children = build_children_map(processes);
|
|
|
|
|
let descendants = collect_descendant_ids(&children, self.root_pid);
|
|
|
|
|
for pid in descendants.iter().copied() {
|
|
|
|
|
if let Some(process) = process_map.get(&pid) {
|
|
|
|
|
self.observed_processes.insert(pid, process.clone());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
self.observed_descendants.extend(descendants);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_report(&self, cli: &Cli, live_processes: &[ProcessDescriptor]) -> ProcessTreeReport {
|
|
|
|
|
let historical = self
|
|
|
|
|
.observed_processes
|
|
|
|
|
.values()
|
|
|
|
|
.cloned()
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
let mut nodes = flatten_subtree_nodes(&historical, self.root_pid, cli.max_depth, false);
|
|
|
|
|
if let Some(pattern) = cli.match_pattern.as_ref() {
|
|
|
|
|
nodes.retain(|node| {
|
|
|
|
|
pattern.is_match(&node.image_name)
|
|
|
|
|
|| node.command_line.iter().any(|arg| pattern.is_match(arg))
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if cli.orphans {
|
|
|
|
|
let live_pids = live_processes
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|process| process.pid)
|
|
|
|
|
.collect::<HashSet<_>>();
|
|
|
|
|
for node in &mut nodes {
|
|
|
|
|
node.orphan = node.pid != self.root_pid
|
|
|
|
|
&& self.observed_descendants.contains(&node.pid)
|
|
|
|
|
&& live_pids.contains(&node.pid);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
ProcessTreeReport {
|
|
|
|
|
root_pid: Some(self.root_pid),
|
|
|
|
|
message: nodes
|
|
|
|
|
.is_empty()
|
|
|
|
|
.then(|| format!("root process {} exited before snapshot", self.root_pid)),
|
|
|
|
|
nodes,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn filter_processes(
|
|
|
|
|
processes: Vec<ProcessDescriptor>,
|
|
|
|
|
pattern: Option<&Regex>,
|
|
|
|
|
) -> Vec<ProcessDescriptor> {
|
|
|
|
|
if let Some(pattern) = pattern {
|
|
|
|
|
processes
|
|
|
|
|
.into_iter()
|
|
|
|
|
.filter(|process| {
|
|
|
|
|
pattern.is_match(&process.image_name)
|
|
|
|
|
|| process.command_line.iter().any(|arg| pattern.is_match(arg))
|
|
|
|
|
})
|
|
|
|
|
.collect()
|
|
|
|
|
} else {
|
|
|
|
|
processes
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn flatten_system_nodes(
|
|
|
|
|
processes: &[ProcessDescriptor],
|
|
|
|
|
max_depth: Option<usize>,
|
|
|
|
|
) -> Vec<ProcessNode> {
|
|
|
|
|
let children = build_children_map(processes);
|
|
|
|
|
let process_map = processes
|
|
|
|
|
.iter()
|
|
|
|
|
.cloned()
|
|
|
|
|
.map(|process| (process.pid, process))
|
|
|
|
|
.collect::<HashMap<_, _>>();
|
|
|
|
|
let mut roots = processes
|
|
|
|
|
.iter()
|
|
|
|
|
.filter(|process| {
|
|
|
|
|
process
|
|
|
|
|
.parent_pid
|
|
|
|
|
.is_none_or(|parent| !process_map.contains_key(&parent))
|
|
|
|
|
})
|
|
|
|
|
.map(|process| process.pid)
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
roots.sort_unstable();
|
|
|
|
|
|
|
|
|
|
let mut nodes = Vec::new();
|
|
|
|
|
for root in roots {
|
|
|
|
|
collect_tree_nodes(
|
|
|
|
|
root,
|
|
|
|
|
0,
|
|
|
|
|
&children,
|
|
|
|
|
&process_map,
|
|
|
|
|
max_depth,
|
|
|
|
|
false,
|
|
|
|
|
&mut nodes,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
nodes
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn flatten_subtree_nodes(
|
|
|
|
|
processes: &[ProcessDescriptor],
|
|
|
|
|
root_pid: u32,
|
|
|
|
|
max_depth: Option<usize>,
|
|
|
|
|
orphan_mode: bool,
|
|
|
|
|
) -> Vec<ProcessNode> {
|
|
|
|
|
let children = build_children_map(processes);
|
|
|
|
|
let process_map = processes
|
|
|
|
|
.iter()
|
|
|
|
|
.cloned()
|
|
|
|
|
.map(|process| (process.pid, process))
|
|
|
|
|
.collect::<HashMap<_, _>>();
|
|
|
|
|
let mut nodes = Vec::new();
|
|
|
|
|
if process_map.contains_key(&root_pid) {
|
|
|
|
|
collect_tree_nodes(
|
|
|
|
|
root_pid,
|
|
|
|
|
0,
|
|
|
|
|
&children,
|
|
|
|
|
&process_map,
|
|
|
|
|
max_depth,
|
|
|
|
|
orphan_mode,
|
|
|
|
|
&mut nodes,
|
|
|
|
|
);
|
|
|
|
|
} else if orphan_mode {
|
|
|
|
|
let descendants = collect_descendant_ids(&children, root_pid);
|
|
|
|
|
let mut orphan_pids = descendants.into_iter().collect::<Vec<_>>();
|
|
|
|
|
orphan_pids.sort_unstable();
|
|
|
|
|
for pid in orphan_pids {
|
|
|
|
|
if let Some(process) = process_map.get(&pid) {
|
|
|
|
|
nodes.push(ProcessNode {
|
|
|
|
|
pid: process.pid,
|
|
|
|
|
parent_pid: process.parent_pid,
|
|
|
|
|
depth: 0,
|
|
|
|
|
image_name: process.image_name.clone(),
|
|
|
|
|
exe: process.exe.clone(),
|
|
|
|
|
command_line: process.command_line.clone(),
|
|
|
|
|
command_line_preview: compact_command_line(&process.command_line),
|
|
|
|
|
start_time_unix: process.start_time_unix,
|
|
|
|
|
run_time_seconds: process.run_time_seconds,
|
|
|
|
|
run_time_display: format_run_time(process.run_time_seconds),
|
|
|
|
|
orphan: true,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
nodes
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_children_map(processes: &[ProcessDescriptor]) -> HashMap<u32, Vec<u32>> {
|
|
|
|
|
let mut map = HashMap::<u32, Vec<u32>>::new();
|
|
|
|
|
for process in processes {
|
|
|
|
|
if let Some(parent) = process.parent_pid {
|
|
|
|
|
map.entry(parent).or_default().push(process.pid);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for children in map.values_mut() {
|
|
|
|
|
children.sort_unstable();
|
|
|
|
|
}
|
|
|
|
|
map
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn collect_tree_nodes(
|
|
|
|
|
pid: u32,
|
|
|
|
|
depth: usize,
|
|
|
|
|
children: &HashMap<u32, Vec<u32>>,
|
|
|
|
|
process_map: &HashMap<u32, ProcessDescriptor>,
|
|
|
|
|
max_depth: Option<usize>,
|
|
|
|
|
orphan: bool,
|
|
|
|
|
nodes: &mut Vec<ProcessNode>,
|
|
|
|
|
) {
|
|
|
|
|
if max_depth.is_some_and(|max_depth| depth > max_depth) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let Some(process) = process_map.get(&pid) else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
nodes.push(ProcessNode {
|
|
|
|
|
pid: process.pid,
|
|
|
|
|
parent_pid: process.parent_pid,
|
|
|
|
|
depth,
|
|
|
|
|
image_name: process.image_name.clone(),
|
|
|
|
|
exe: process.exe.clone(),
|
|
|
|
|
command_line: process.command_line.clone(),
|
|
|
|
|
command_line_preview: compact_command_line(&process.command_line),
|
|
|
|
|
start_time_unix: process.start_time_unix,
|
|
|
|
|
run_time_seconds: process.run_time_seconds,
|
|
|
|
|
run_time_display: format_run_time(process.run_time_seconds),
|
|
|
|
|
orphan,
|
|
|
|
|
});
|
|
|
|
|
if let Some(child_pids) = children.get(&pid) {
|
|
|
|
|
for child in child_pids {
|
|
|
|
|
collect_tree_nodes(
|
|
|
|
|
*child,
|
|
|
|
|
depth + 1,
|
|
|
|
|
children,
|
|
|
|
|
process_map,
|
|
|
|
|
max_depth,
|
|
|
|
|
orphan,
|
|
|
|
|
nodes,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn collect_descendant_ids(children: &HashMap<u32, Vec<u32>>, root_pid: u32) -> HashSet<u32> {
|
|
|
|
|
let mut seen = HashSet::new();
|
|
|
|
|
let mut stack = Vec::new();
|
|
|
|
|
if let Some(root_children) = children.get(&root_pid) {
|
|
|
|
|
stack.extend(root_children.iter().copied());
|
|
|
|
|
}
|
|
|
|
|
while let Some(pid) = stack.pop() {
|
|
|
|
|
if seen.insert(pid)
|
|
|
|
|
&& let Some(next) = children.get(&pid)
|
|
|
|
|
{
|
|
|
|
|
stack.extend(next.iter().copied());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
seen
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn render_report(report: &ProcessTreeReport, include_cmdline: bool) -> String {
|
|
|
|
|
let mut rendered = String::new();
|
|
|
|
|
if let Some(root_pid) = report.root_pid {
|
|
|
|
|
writeln!(rendered, "root_pid={root_pid} nodes={}", report.nodes.len())
|
|
|
|
|
.expect("writing to a String cannot fail");
|
|
|
|
|
} else {
|
|
|
|
|
writeln!(rendered, "system nodes={}", report.nodes.len())
|
|
|
|
|
.expect("writing to a String cannot fail");
|
|
|
|
|
}
|
|
|
|
|
if let Some(message) = &report.message {
|
|
|
|
|
writeln!(rendered, "message={message}").expect("writing to a String cannot fail");
|
|
|
|
|
}
|
|
|
|
|
for node in &report.nodes {
|
|
|
|
|
let indent = " ".repeat(node.depth);
|
|
|
|
|
let orphan = if node.orphan { " orphan" } else { "" };
|
|
|
|
|
if include_cmdline && !node.command_line.is_empty() {
|
|
|
|
|
writeln!(
|
|
|
|
|
rendered,
|
|
|
|
|
"{indent}pid={} image={} runtime={}{orphan} cmd={}",
|
|
|
|
|
node.pid,
|
|
|
|
|
node.image_name,
|
|
|
|
|
node.run_time_display,
|
|
|
|
|
compact_command_line(&node.command_line)
|
|
|
|
|
)
|
|
|
|
|
.expect("writing to a String cannot fail");
|
|
|
|
|
} else {
|
|
|
|
|
writeln!(
|
|
|
|
|
rendered,
|
|
|
|
|
"{indent}pid={} image={} runtime={}{orphan}",
|
|
|
|
|
node.pid, node.image_name, node.run_time_display
|
|
|
|
|
)
|
|
|
|
|
.expect("writing to a String cannot fail");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
rendered
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn compact_command_line(arguments: &[String]) -> String {
|
|
|
|
|
const MAX_COMMAND_LINE_CHARS: usize = 120;
|
|
|
|
|
|
|
|
|
|
let rendered = join_command_line(&sanitize_command_line(arguments));
|
|
|
|
|
let character_count = rendered.chars().count();
|
|
|
|
|
if character_count <= MAX_COMMAND_LINE_CHARS {
|
|
|
|
|
return rendered;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let visible = MAX_COMMAND_LINE_CHARS.saturating_sub(3);
|
|
|
|
|
let prefix = rendered.chars().take(visible).collect::<String>();
|
|
|
|
|
format!("{prefix}...")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn join_command_line(arguments: &[String]) -> String {
|
|
|
|
|
let mut rendered = String::new();
|
|
|
|
|
for argument in arguments {
|
|
|
|
|
if !rendered.is_empty() {
|
|
|
|
|
rendered.push(' ');
|
|
|
|
|
}
|
|
|
|
|
rendered.push_str(argument);
|
|
|
|
|
}
|
|
|
|
|
rendered
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn sanitize_command_line(arguments: &[String]) -> Vec<String> {
|
|
|
|
|
let mut sanitized = Vec::with_capacity(arguments.len());
|
|
|
|
|
let mut index = 0_usize;
|
|
|
|
|
while index < arguments.len() {
|
|
|
|
|
let value = &arguments[index];
|
|
|
|
|
if value.eq_ignore_ascii_case("-encodedcommand")
|
|
|
|
|
|| value.eq_ignore_ascii_case("-ec")
|
|
|
|
|
|| value.eq_ignore_ascii_case("/encodedcommand")
|
|
|
|
|
{
|
|
|
|
|
sanitized.push(value.clone());
|
|
|
|
|
if let Some(payload) = arguments.get(index + 1) {
|
|
|
|
|
sanitized.push(format!("<encoded:{} chars>", payload.chars().count()));
|
|
|
|
|
index += 2;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
sanitized.push(value.clone());
|
|
|
|
|
index += 1;
|
|
|
|
|
}
|
|
|
|
|
sanitized
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn format_run_time(run_time_seconds: u64) -> String {
|
|
|
|
|
if run_time_seconds == 0 {
|
|
|
|
|
"<1s".to_string()
|
|
|
|
|
} else {
|
|
|
|
|
format!("{run_time_seconds}s")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
fn sample_processes() -> Vec<ProcessDescriptor> {
|
|
|
|
|
vec![
|
|
|
|
|
ProcessDescriptor {
|
|
|
|
|
pid: 1,
|
|
|
|
|
parent_pid: None,
|
|
|
|
|
image_name: "root.exe".to_string(),
|
|
|
|
|
exe: Some("C:\\root.exe".to_string()),
|
|
|
|
|
command_line: vec!["root".to_string()],
|
|
|
|
|
start_time_unix: 0,
|
|
|
|
|
run_time_seconds: 10,
|
|
|
|
|
},
|
|
|
|
|
ProcessDescriptor {
|
|
|
|
|
pid: 10,
|
|
|
|
|
parent_pid: Some(1),
|
|
|
|
|
image_name: "child.exe".to_string(),
|
|
|
|
|
exe: None,
|
|
|
|
|
command_line: vec!["child".to_string(), "--watch".to_string()],
|
|
|
|
|
start_time_unix: 1,
|
|
|
|
|
run_time_seconds: 5,
|
|
|
|
|
},
|
|
|
|
|
ProcessDescriptor {
|
|
|
|
|
pid: 11,
|
|
|
|
|
parent_pid: Some(10),
|
|
|
|
|
image_name: "grand.exe".to_string(),
|
|
|
|
|
exe: None,
|
|
|
|
|
command_line: vec!["grand".to_string()],
|
|
|
|
|
start_time_unix: 2,
|
|
|
|
|
run_time_seconds: 1,
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parser_accepts_run_mode_with_common_flags_after_subcommand() {
|
|
|
|
|
let (_, cli) = parse_cli_from([
|
|
|
|
|
"proctree",
|
|
|
|
|
"run",
|
|
|
|
|
"--json",
|
|
|
|
|
"--max-depth",
|
|
|
|
|
"2",
|
|
|
|
|
"--",
|
|
|
|
|
"pwsh",
|
|
|
|
|
"-NoProfile",
|
|
|
|
|
])
|
|
|
|
|
.expect("cli");
|
|
|
|
|
|
|
|
|
|
assert!(cli.common.json);
|
|
|
|
|
assert_eq!(cli.max_depth, Some(2));
|
|
|
|
|
assert!(matches!(cli.mode, Mode::Run(_)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn flatten_subtree_marks_orphans() {
|
|
|
|
|
let processes = sample_processes().into_iter().skip(1).collect::<Vec<_>>();
|
|
|
|
|
|
|
|
|
|
let nodes = flatten_subtree_nodes(&processes, 1, None, true);
|
|
|
|
|
assert_eq!(nodes.len(), 2);
|
|
|
|
|
assert!(nodes.iter().all(|node| node.orphan));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn help_and_root_modes_parse_cleanly() {
|
|
|
|
|
let (outcome, _) = parse_cli_from(["proctree", "--help"]).expect("help");
|
|
|
|
|
assert_eq!(outcome, ParseOutcome::Help);
|
|
|
|
|
|
|
|
|
|
let (_, cli) = parse_cli_from([
|
|
|
|
|
"proctree",
|
|
|
|
|
"--match",
|
|
|
|
|
"child",
|
|
|
|
|
"--include-cmdline",
|
|
|
|
|
"root",
|
|
|
|
|
"10",
|
|
|
|
|
])
|
|
|
|
|
.expect("root");
|
|
|
|
|
assert!(cli.include_cmdline);
|
|
|
|
|
assert!(cli.match_pattern.is_some());
|
|
|
|
|
assert!(matches!(cli.mode, Mode::Root(10)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn helper_parsers_and_filters_cover_error_paths() {
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
parse_regex("["),
|
|
|
|
|
Err(CliError::Usage(message)) if message.contains("invalid --match regex")
|
|
|
|
|
));
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
parse_usize_flag("--max-depth", "bad"),
|
|
|
|
|
Err(CliError::Usage(message)) if message.contains("invalid --max-depth")
|
|
|
|
|
));
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
parse_mode_token("wat", &mut lexopt::Parser::from_iter(["proctree"])),
|
|
|
|
|
Err(CliError::Usage(message)) if message.contains("unknown subcommand")
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
let regex = parse_regex("watch").expect("regex");
|
|
|
|
|
let filtered = filter_processes(sample_processes(), Some(®ex));
|
|
|
|
|
assert_eq!(filtered.len(), 1);
|
|
|
|
|
assert_eq!(filtered[0].pid, 10);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn system_and_root_rendering_stay_compact() {
|
|
|
|
|
let processes = sample_processes();
|
|
|
|
|
let system_nodes = flatten_system_nodes(&processes, Some(1));
|
|
|
|
|
let root_nodes = flatten_subtree_nodes(&processes, 1, Some(2), false);
|
|
|
|
|
let report = ProcessTreeReport {
|
|
|
|
|
root_pid: Some(1),
|
|
|
|
|
message: None,
|
|
|
|
|
nodes: root_nodes,
|
|
|
|
|
};
|
|
|
|
|
let text = render_report(&report, true);
|
|
|
|
|
|
|
|
|
|
assert_eq!(system_nodes.len(), 2);
|
|
|
|
|
assert!(text.contains("root_pid=1"));
|
|
|
|
|
assert!(text.contains("cmd=child --watch"));
|
|
|
|
|
assert!(text.contains("runtime=5s"));
|
|
|
|
|
assert_eq!(format_run_time(0), "<1s");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn compact_command_line_truncates_long_output() {
|
|
|
|
|
let command = compact_command_line(&[
|
|
|
|
|
"pwsh".to_string(),
|
|
|
|
|
"-EncodedCommand".to_string(),
|
|
|
|
|
"A".repeat(200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
assert_eq!(command, "pwsh -EncodedCommand <encoded:200 chars>");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn children_maps_and_descendants_are_stable() {
|
|
|
|
|
let processes = sample_processes();
|
|
|
|
|
let children = build_children_map(&processes);
|
|
|
|
|
let descendants = collect_descendant_ids(&children, 1);
|
|
|
|
|
|
|
|
|
|
assert_eq!(children.get(&1), Some(&vec![10]));
|
|
|
|
|
assert!(descendants.contains(&10));
|
|
|
|
|
assert!(descendants.contains(&11));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn run_observation_preserves_seen_descendants_after_root_exits() {
|
|
|
|
|
let root = ProcessDescriptor {
|
|
|
|
|
pid: 1,
|
|
|
|
|
parent_pid: None,
|
|
|
|
|
image_name: "root.exe".to_string(),
|
|
|
|
|
exe: None,
|
|
|
|
|
command_line: vec!["root".to_string()],
|
|
|
|
|
start_time_unix: 0,
|
|
|
|
|
run_time_seconds: 1,
|
|
|
|
|
};
|
|
|
|
|
let child = ProcessDescriptor {
|
|
|
|
|
pid: 10,
|
|
|
|
|
parent_pid: Some(1),
|
|
|
|
|
image_name: "child.exe".to_string(),
|
|
|
|
|
exe: None,
|
|
|
|
|
command_line: vec!["child".to_string()],
|
|
|
|
|
start_time_unix: 0,
|
|
|
|
|
run_time_seconds: 1,
|
|
|
|
|
};
|
|
|
|
|
let detached_child = ProcessDescriptor {
|
|
|
|
|
parent_pid: None,
|
|
|
|
|
..child.clone()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let mut observation = RunObservation::new(1);
|
|
|
|
|
observation.record(&[root, child]);
|
|
|
|
|
observation.record(std::slice::from_ref(&detached_child));
|
|
|
|
|
|
|
|
|
|
let cli = Cli {
|
|
|
|
|
common: CommonArgs::default(),
|
|
|
|
|
mode: Mode::System,
|
|
|
|
|
match_pattern: None,
|
|
|
|
|
max_depth: None,
|
|
|
|
|
include_cmdline: false,
|
|
|
|
|
orphans: true,
|
|
|
|
|
};
|
|
|
|
|
let report = observation.build_report(&cli, &[detached_child]);
|
|
|
|
|
assert_eq!(report.root_pid, Some(1));
|
|
|
|
|
assert_eq!(report.message, None);
|
|
|
|
|
assert_eq!(report.nodes.len(), 2);
|
|
|
|
|
assert!(report.nodes.iter().any(|node| node.pid == 1));
|
|
|
|
|
assert!(
|
|
|
|
|
report
|
|
|
|
|
.nodes
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|node| node.pid == 10)
|
|
|
|
|
.is_some_and(|node| node.orphan)
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
report
|
|
|
|
|
.nodes
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|node| node.pid == 10)
|
|
|
|
|
.is_some_and(|node| node.command_line_preview == "child")
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|