Merge branch 'master' into dependabot/cargo/fancy-regex-0.19.0

This commit is contained in:
Naoya Hatta
2026-09-07 12:18:23 +09:00
committed by GitHub
36 changed files with 3475 additions and 612 deletions
+3
View File
@@ -23,6 +23,9 @@ jobs:
- os: windows-latest
rust: stable
target: x86_64-pc-windows-msvc
- os: windows-11-arm
rust: stable
target: aarch64-pc-windows-msvc
runs-on: ${{ matrix.os }}
steps:
+4 -1
View File
@@ -10,7 +10,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, ubuntu-24.04-arm, macOS-latest, windows-latest]
os: [ubuntu-latest, ubuntu-24.04-arm, macOS-latest, windows-latest, windows-11-arm]
rust: [stable]
runs-on: ${{ matrix.os }}
@@ -52,6 +52,9 @@ jobs:
- name: Build for Windows
if: matrix.os == 'windows-latest'
run: make release_win
- name: Build for Windows ARM64
if: matrix.os == 'windows-11-arm'
run: make release_win_arm64
- name: Release
uses: softprops/action-gh-release@v1
with:
+15
View File
@@ -2,6 +2,21 @@
## [Unreleased](https://github.com/dalance/procs/compare/v0.14.12...Unreleased) - ReleaseDate
* [Added] Arch column for Windows (process image architecture)
* [Added] Real Command column for Windows (command line arguments). This column was printing pure file name, which is now moved to FileName column.
* [Added] Priority column for Windows (user-mode priority class)
* [Added] RtPriority column for Windows (kernel base priority)
* [Added] Threads column for Windows (thread count)
* [Added] Session column for Windows (session ID)
* [Added] State column for Windows (scheduler state derived from thread states)
* [Added] Env column for Windows (environment variables)
* [Added] RecvBytes and SendBytes columns for Windows (network I/O rate; needs Windows 11 or later)
* [Added] WorkDir column for Windows (current working directory)
* [Added] `--thread` support for Windows
* [Changed] Group and Gid columns for Windows read the process token only when the column is displayed
* [Fixed] ReadBytes / WriteBytes divided by a mis-scaled interval (seconds added to milliseconds)
* [Fixed] Fix invalid JSON output when a column is skipped by --only or --tree
## [v0.14.12](https://github.com/dalance/procs/compare/v0.14.11...v0.14.12) - 2026-06-25
* [Added] Add fancy regex fallback for advanced filters [#913](https://github.com/dalance/procs/pull/913)
Generated
+5 -5
View File
@@ -1235,9 +1235,9 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "mach2"
version = "0.6.0"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b"
checksum = "b0d28e293f2b8c9d2b2d1c0193bd3c1cbdc0d2cf396888dc01162f7cf9d6c3f3"
[[package]]
name = "memchr"
@@ -1593,7 +1593,7 @@ dependencies = [
"toml",
"unicode-width",
"uzers",
"which 8.0.5",
"which 8.0.6",
"windows-sys 0.61.2",
]
@@ -2523,9 +2523,9 @@ dependencies = [
[[package]]
name = "which"
version = "8.0.5"
version = "8.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f3ef584124b911bcc3875c2f1472e80f24361ceb789bd1c62b3e9a3df9ff43c"
checksum = "bae2f2b2b816647a1cab1acc91f5bd20812d53cb344382635ec2181940c8034f"
dependencies = [
"libc",
]
+2 -2
View File
@@ -75,10 +75,10 @@ errno = "0.3"
pager = "0.16"
uzers = "0.12"
which = "8"
mach2 = "0.6.0"
mach2 = "0.7.0"
[target.'cfg(target_os = "windows")'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Networking_WinSock", "Win32_NetworkManagement_IpHelper", "Win32_Security", "Win32_System_Diagnostics_ToolHelp", "Win32_System_ProcessStatus", "Win32_System_Threading"] }
windows-sys = { version = "0.61", features = ["Wdk_System_SystemInformation", "Wdk_System_SystemServices", "Wdk_System_Threading", "Win32_Foundation", "Win32_Networking_WinSock", "Win32_NetworkManagement_IpHelper", "Win32_Security", "Win32_System_Diagnostics_Debug", "Win32_System_Kernel", "Win32_System_Threading", "Win32_System_WindowsProgramming"] }
[target.'cfg(target_os = "freebsd")'.dependencies]
bsd-kvm = "0.1.5"
+6 -1
View File
@@ -7,7 +7,7 @@ BIN_NAME = procs
export LONG_VERSION
.PHONY: all test clean release_lnx release_win release_mac
.PHONY: all test clean release_lnx release_win release_win_arm64 release_mac
all: test
@@ -33,6 +33,11 @@ release_win:
mv -v target/x86_64-pc-windows-msvc/release/${BIN_NAME}.exe ./
7z a ${BIN_NAME}-v${VERSION}-x86_64-windows.zip ${BIN_NAME}.exe
release_win_arm64:
cargo build --locked --release --target=aarch64-pc-windows-msvc
mv -v target/aarch64-pc-windows-msvc/release/${BIN_NAME}.exe ./
7z a ${BIN_NAME}-v${VERSION}-aarch64-windows.zip ${BIN_NAME}.exe
release_mac:
cargo build --locked --release --target=x86_64-apple-darwin
cargo build --locked --release --target=aarch64-apple-darwin
+10 -8
View File
@@ -434,7 +434,7 @@ The first `[[columns]]` is shown at left side, and the last is shown at right si
| procs `kind` | `ps` STANDARD FORMAT | Description | Linux | macOS | Windows | FreeBSD |
| -------------------- | --------------------- | --------------------------------------------- | ----- | ----- | ------- | ------- |
| Arch | -not supported- | Architecture of binary (macOS specific) | | o | | |
| Arch | -not supported- | Architecture of binary | | o | o | |
| Ccgroup | -not supported- | Control group by compressed format | o | | | |
| Cgroup | cgroup | Control group | o | | | |
| Command | args | Command with all arguments | o | o | o | o |
@@ -445,7 +445,7 @@ The first `[[columns]]` is shown at left side, and the last is shown at right si
| ElapsedTime | -not supported- | Elapsed time | o | o | o | o |
| Env | `e` output modifier | Environment variables | o | | | o |
| Esp | esp | Stack pointer | o | | | |
| FileName | comm | File name | o | | | o |
| FileName | comm | File name | o | | o | o |
| Gid | egid | Group ID | o | o | o | o |
| GidFs | fgid | File system group ID | o | | | |
| GidReal | rgid | Real group ID | o | o | | o |
@@ -466,10 +466,12 @@ The first `[[columns]]` is shown at left side, and the last is shown at right si
| Priority | pri | Priority | o | o | o | o |
| Processor | psr | Currently assigned processor | o | | | o |
| ReadBytes | -not supported- | Read bytes from storage | o | o | o | o |
| RtPriority | rtprio | Real-time priority | o | | | |
| RecvBytes | -not supported- | Received bytes per second (Windows 11) | | | o | |
| RtPriority | rtprio | Kernel base priority (Windows) / real-time priority (Linux) | o | | o | |
| SecContext | label | Security context | o | | | |
| SendBytes | -not supported- | Sent bytes per second (Windows 11) | | | o | |
| Separator | -not supported- | Show `\|` for column separation | o | o | o | o |
| Session | sid | Session ID | o | o | | o |
| Session | sid | Session ID | o | o | o | o |
| ShdPnd | pending | Pending signal mask for process | o | | | o |
| SigBlk | blocked | Blocked signal mask | o | | | o |
| SigCgt | caught | Caught signal mask | o | | | o |
@@ -478,9 +480,9 @@ The first `[[columns]]` is shown at left side, and the last is shown at right si
| Slot | -not supported- | Slot for `--insert` option | o | o | o | o |
| Ssb | -not supported- | Speculative store bypass status | o | | | |
| StartTime | start_time | Starting time | o | o | o | o |
| State | s | Process state | o | o | | o |
| TcpPort | -not supported- | Bound TCP ports | o | o | | |
| Threads | nlwp | Thread count | o | o | | o |
| State | s | Process state | o | o | o | o |
| TcpPort | -not supported- | Bound TCP ports | o | o | o | |
| Threads | nlwp | Thread count | o | o | o | o |
| TreeSlot | -not supported- | Slot for tree column | o | o | o | o |
| Tty | tty | Controlling TTY | o | o | | o |
| UdpPort | -not supported- | Bound UDP ports | o | o | | |
@@ -511,7 +513,7 @@ The first `[[columns]]` is shown at left side, and the last is shown at right si
| VmTotal | -not supported- | Total virtual memory size | *[^*] | o | *[^*] | *[^*] |
| VoluntaryContextSw | -not supported- | Voluntary context switch count | o | | | o |
| Wchan | wchan | Process sleeping kernel function | o | | | o |
| WorkDir | -not supported- | Current working directory | o | | | |
| WorkDir | -not supported- | Current working directory | o | | o | |
| WriteByte | -not supported- | Write bytes to storage | o | o | o | o |
[^*]: Alias for VmRss on these platforms
+134 -3
View File
@@ -3,9 +3,13 @@ use crate::{column_default, Column};
use std::cmp;
use std::collections::HashMap;
#[cfg(target_os = "macos")]
const CTL_MAXNAME: i32 = 12;
#[cfg(target_os = "macos")]
const P_TRANSLATED: i32 = 131072;
#[cfg(target_os = "macos")]
const CPU_TYPE_X86_64: i32 = 16777223;
#[cfg(target_os = "macos")]
const CPU_TYPE_ARM64: i32 = 16777228;
pub struct Arch {
@@ -16,7 +20,6 @@ pub struct Arch {
width: usize,
}
#[cfg(target_os = "macos")]
impl Arch {
pub fn new(header: Option<String>) -> Self {
let header = header.unwrap_or_else(|| String::from("Arch"));
@@ -31,13 +34,12 @@ impl Arch {
}
}
#[cfg(target_os = "macos")]
impl Column for Arch {
fn add(&mut self, proc: &ProcessInfo) {
let pid = proc.pid;
let arch = arch_from_pid(pid);
let fmt_content = format!("{}", arch);
let fmt_content = arch.to_string();
let raw_content = fmt_content.clone();
self.fmt_contents.insert(proc.pid, fmt_content);
@@ -47,6 +49,116 @@ impl Column for Arch {
column_default!(String, false);
}
// ---------------------------------------------------------------------------
// Windows
// ---------------------------------------------------------------------------
/// `IMAGE_FILE_MACHINE_*` constants.
///
/// <https://learn.microsoft.com/zh-cn/windows/win32/sysinfo/image-file-machine-constants>
#[cfg(target_os = "windows")]
mod machine {
pub const UNKNOWN: u16 = 0x0000;
pub const ALPHA: u16 = 0x0184;
/// Same value as `IMAGE_FILE_MACHINE_AXP64`.
pub const ALPHA64: u16 = 0x0284;
pub const AM33: u16 = 0x01d3;
pub const AMD64: u16 = 0x8664;
pub const ARM: u16 = 0x01c0;
pub const ARM64: u16 = 0xaa64;
/// x86_64 code that runs on ARM64 through emulation.
pub const ARM64EC: u16 = 0xa641;
/// Image loadable both as ARM64 and as ARM64EC.
pub const ARM64X: u16 = 0xa64e;
/// ARMv7 / Thumb-2; `IMAGE_FILE_MACHINE_ARMV7` shares this value.
pub const ARMNT: u16 = 0x01c4;
pub const CEE: u16 = 0xc0ee;
pub const CEF: u16 = 0x0cef;
pub const EBC: u16 = 0x0ebc;
pub const I386: u16 = 0x014c;
pub const IA64: u16 = 0x0200;
pub const M32R: u16 = 0x9041;
pub const MIPS16: u16 = 0x0266;
pub const MIPSFPU: u16 = 0x0366;
pub const MIPSFPU16: u16 = 0x0466;
pub const POWERPC: u16 = 0x01f0;
pub const POWERPCFP: u16 = 0x01f1;
pub const R3000: u16 = 0x0162;
pub const R4000: u16 = 0x0166;
pub const R10000: u16 = 0x0168;
pub const SH3: u16 = 0x01a2;
pub const SH3DSP: u16 = 0x01a3;
pub const SH3E: u16 = 0x01a4;
pub const SH4: u16 = 0x01a6;
pub const SH5: u16 = 0x01a8;
pub const THUMB: u16 = 0x01c2;
pub const TRICORE: u16 = 0x0520;
pub const WCEMIPSV2: u16 = 0x0169;
}
/// Reports the architecture of the image `pid` was started from.
///
/// WOW64 processes report their own architecture, not the host's: a 32-bit
/// process on an x86_64 machine yields `x86`.
#[cfg(target_os = "windows")]
pub fn arch_from_pid(pid: i32) -> &'static str {
use windows_sys::Win32::Foundation::{CloseHandle, FALSE, HANDLE};
use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION};
// 0 is the idle process and has no image; negative pids are not real.
if pid <= 0 {
return "unknown";
}
// SAFETY: `pid` is only handed to `OpenProcess`, and the handle it hands
// back is closed before this function returns.
let handle: HANDLE = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid as u32) };
if handle.is_null() {
return "unknown";
}
let arch = crate::process::process_image_machine(handle)
.map(arch_from_machine)
.unwrap_or("unknown");
unsafe { CloseHandle(handle) };
arch
}
/// Names an `IMAGE_FILE_MACHINE_*` value the way the rest of the crate spells
/// architectures (`x86_64`, `arm64`, ...).
#[cfg(target_os = "windows")]
fn arch_from_machine(machine: u16) -> &'static str {
use machine::*;
match machine {
UNKNOWN => "unknown",
I386 => "x86",
AMD64 => "x86_64",
ARM | ARMNT | THUMB => "arm",
ARM64 => "arm64",
ARM64EC => "arm64ec",
ARM64X => "arm64x",
IA64 => "ia64",
EBC => "ebc",
ALPHA | ALPHA64 => "alpha",
MIPS16 | MIPSFPU | MIPSFPU16 | R3000 | R4000 | R10000 | WCEMIPSV2 => "mips",
POWERPC | POWERPCFP => "ppc",
SH3 | SH3DSP | SH3E | SH4 | SH5 => "sh",
M32R => "m32r",
AM33 => "am33",
TRICORE => "tricore",
CEF => "cef",
// Pure IL assembly: no architecture of its own.
CEE => "msil",
_ => "unknown",
}
}
// ---------------------------------------------------------------------------
// macOS
// ---------------------------------------------------------------------------
#[cfg(target_os = "macos")]
pub fn arch_from_pid(pid: i32) -> &'static str {
use {
@@ -98,3 +210,22 @@ pub fn arch_from_pid(pid: i32) -> &'static str {
"unknown"
}
#[cfg(all(test, target_os = "windows"))]
mod tests {
use super::{arch_from_machine, machine};
#[test]
fn machine_names() {
assert_eq!(arch_from_machine(machine::AMD64), "x86_64");
assert_eq!(arch_from_machine(machine::I386), "x86");
assert_eq!(arch_from_machine(machine::ARM64), "arm64");
assert_eq!(arch_from_machine(machine::ARM64EC), "arm64ec");
// ARMv7 and Thumb-2 are both 32-bit ARM.
assert_eq!(arch_from_machine(machine::ARMNT), "arm");
assert_eq!(arch_from_machine(machine::THUMB), "arm");
// No image, and a value outside the table.
assert_eq!(arch_from_machine(machine::UNKNOWN), "unknown");
assert_eq!(arch_from_machine(0x1234), "unknown");
}
}
+13 -3
View File
@@ -1,4 +1,5 @@
use crate::process::ProcessInfo;
use crate::util::sanitize_control_chars;
use crate::{column_default, Column};
use std::cmp;
use std::collections::HashMap;
@@ -39,7 +40,6 @@ impl Column for Command {
})
.collect::<String>();
cmd.pop();
cmd = cmd.replace(['\n', '\t'], " ");
cmd
} else {
format!("[{}]", proc.curr_proc.stat().comm)
@@ -47,6 +47,7 @@ impl Column for Command {
} else {
proc.curr_proc.stat().comm.clone()
};
let fmt_content = sanitize_control_chars(&fmt_content);
let raw_content = fmt_content.clone();
self.fmt_contents.insert(proc.pid, fmt_content);
@@ -71,7 +72,6 @@ impl Column for Command {
})
.collect::<String>();
cmd.pop();
cmd = cmd.replace(['\n', '\t'], " ");
cmd
} else {
String::from("")
@@ -79,6 +79,7 @@ impl Column for Command {
} else {
String::from("")
};
let fmt_content = sanitize_control_chars(&fmt_content);
let raw_content = fmt_content.clone();
self.fmt_contents.insert(proc.pid, fmt_content);
@@ -91,7 +92,15 @@ impl Column for Command {
#[cfg(target_os = "windows")]
impl Column for Command {
fn add(&mut self, proc: &ProcessInfo) {
let fmt_content = proc.command.clone();
// Show the command line when present, otherwise fall back to the image
// name (e.g. System, Idle, or protected processes with no command line).
let fmt_content = proc
.command
.as_ref()
.filter(|c| !c.is_empty())
.cloned()
.unwrap_or_else(|| proc.file_name.clone());
let fmt_content = sanitize_control_chars(&fmt_content);
let raw_content = fmt_content.clone();
self.fmt_contents.insert(proc.pid, fmt_content);
@@ -120,6 +129,7 @@ impl Column for Command {
x
};
let fmt_content = command;
let fmt_content = sanitize_control_chars(&fmt_content);
let raw_content = fmt_content.clone();
self.fmt_contents.insert(proc.pid, fmt_content);
+86 -21
View File
@@ -94,33 +94,51 @@ impl Docker {
}
}
/// Extract a Docker container ID from a cgroup path.
///
/// The container's cgroup can be nested at an arbitrary depth: rootful Docker
/// puts it directly under `/system.slice`, while rootless Docker nests it under
/// the invoking user's slice. Matching a path *component* rather than a prefix
/// covers both without enumerating every hierarchy shape.
#[cfg(any(target_os = "linux", target_os = "android"))]
fn container_id_from_cgroup(cgroup_path: &str) -> Option<&str> {
fn is_container_id(s: &str) -> bool {
s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
}
let mut components = cgroup_path.split('/').filter(|x| !x.is_empty()).peekable();
while let Some(component) = components.next() {
// cgroup v1: `.../docker/<id>`
if component == "docker" {
if let Some(id) = components.peek().copied().filter(|x| is_container_id(x)) {
return Some(id);
}
continue;
}
// cgroup v2 with systemd: `.../docker-<id>.scope`
if let Some(id) = component
.strip_prefix("docker-")
.and_then(|x| x.strip_suffix(".scope"))
.filter(|x| is_container_id(x))
{
return Some(id);
}
}
None
}
#[cfg(any(target_os = "linux", target_os = "android"))]
impl Column for Docker {
fn add(&mut self, proc: &ProcessInfo) {
let fmt_content = if let Ok(cgroups) = proc.curr_proc.cgroups() {
let mut ret = String::new();
for cgroup in cgroups {
let cgroup_name = cgroup.pathname.clone();
if cgroup_name.starts_with("/docker") {
let container_id = cgroup_name.replace("/docker/", "");
if let Some(name) = self.containers.get(&container_id) {
ret = name.to_string();
break;
} else {
ret = String::from("?");
break;
}
} else if cgroup_name.starts_with("/system.slice/docker-") {
let container_id = cgroup_name
.replace("/system.slice/docker-", "")
.replace(".scope", "");
if let Some(name) = self.containers.get(&container_id) {
ret = name.to_string();
break;
} else {
ret = String::from("?");
break;
}
if let Some(container_id) = container_id_from_cgroup(&cgroup.pathname) {
ret = match self.containers.get(container_id) {
Some(name) => name.to_string(),
None => String::from("?"),
};
break;
}
}
ret
@@ -160,3 +178,50 @@ impl Column for Docker {
column_default!(String, false);
}
#[cfg(all(test, any(target_os = "linux", target_os = "android")))]
mod tests {
use super::container_id_from_cgroup;
const ID: &str = "b9fc2a3d3e1c4f5a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f809a1b2c3";
#[test]
fn cgroup_v1() {
let path = format!("/docker/{ID}");
assert_eq!(container_id_from_cgroup(&path), Some(ID));
}
#[test]
fn cgroup_v2_rootful() {
let path = format!("/system.slice/docker-{ID}.scope");
assert_eq!(container_id_from_cgroup(&path), Some(ID));
}
#[test]
fn cgroup_v2_rootless() {
let path = format!(
"/user.slice/user-1000.slice/[email protected]/user.slice/docker-{ID}.scope"
);
assert_eq!(container_id_from_cgroup(&path), Some(ID));
}
#[test]
fn non_container_cgroups() {
for path in [
"/",
"/init.scope",
"/system.slice/docker.service",
"/system.slice/containerd.service",
"/user.slice/user-1000.slice/[email protected]/app.slice/docker-desktop.scope",
"/docker/not-a-container-id",
] {
assert_eq!(container_id_from_cgroup(path), None, "path: {path}");
}
}
#[test]
fn id_must_be_a_whole_component() {
let path = format!("/system.slice/prefix-docker-{ID}.scope");
assert_eq!(container_id_from_cgroup(&path), None);
}
}
+159
View File
@@ -4,6 +4,9 @@ use std::cmp;
use std::collections::HashMap;
use std::path::PathBuf;
#[cfg(target_os = "windows")]
use windows_sys::Win32::Foundation::HANDLE;
pub struct Env {
header: String,
unit: String,
@@ -68,3 +71,159 @@ impl Column for Env {
column_default!(String, false);
}
#[cfg(target_os = "windows")]
impl Column for Env {
fn add(&mut self, proc: &ProcessInfo) {
let fmt_content = env_of(proc.pid).unwrap_or_default();
let raw_content = fmt_content.clone();
self.fmt_contents.insert(proc.pid, fmt_content);
self.raw_contents.insert(proc.pid, raw_content);
}
column_default!(String, false);
}
/// Reads the environment block of `pid` from its PEB.
///
/// The PEB address comes from `NtQueryInformationProcess` with
/// `ProcessBasicInformation`; the PEB, the process parameters it points at
/// and the environment block are then read with `ReadProcessMemory`. Needs
/// `PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ`, so protected
/// processes (e.g. PPL) yield `None`.
#[cfg(target_os = "windows")]
fn env_of(pid: i32) -> Option<String> {
use windows_sys::Win32::Foundation::{CloseHandle, FALSE, HANDLE};
use windows_sys::Win32::System::Threading::{
OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_VM_READ,
};
// 0 is the idle process and has no PEB; negative pids are not real.
if pid <= 0 {
return None;
}
// SAFETY: `pid` is only handed to `OpenProcess`, and the handle it returns
// is closed before this function returns.
let handle: HANDLE = unsafe {
OpenProcess(
PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ,
FALSE,
pid as u32,
)
};
if handle.is_null() {
return None;
}
let env = read_env(handle);
unsafe { CloseHandle(handle) };
env
}
/// Reads the environment block out of the PEB of `handle` and formats it
/// like the Linux column (`KEY="VALUE" ` per entry).
#[cfg(target_os = "windows")]
fn read_env(handle: HANDLE) -> Option<String> {
use crate::process::{
process_peb_address, read_process_memory, PEB_PREFIX,
RTL_USER_PROCESS_PARAMETERS_PREFIX,
};
use std::mem::{offset_of, size_of};
// The PEB address lives in the target's address space.
let peb = process_peb_address(handle)?;
// Read the leading PEB fields. `ProcessParameters` sits within the first
// page, so a single read covers it.
let mut peb_buf = [0u64; 0x100 / size_of::<u64>()];
read_process_memory(handle, peb, &mut peb_buf)?;
// SAFETY: `peb_buf` is 8-byte aligned and `ProcessParameters` is a
// pointer-sized field at a pointer-aligned offset, so the read is aligned.
let params_addr = unsafe {
peb_buf
.as_ptr()
.cast::<u8>()
.add(offset_of!(PEB_PREFIX, ProcessParameters))
.cast::<usize>()
.read()
};
// A 32-bit address from a 64-bit reader means a WOW64 process, whose
// process parameters use the 32-bit layout; refuse rather than misparse.
if params_addr == 0 || (size_of::<usize>() == 8 && params_addr >> 32 == 0) {
return None;
}
// Read the leading process-parameters fields. `Environment` sits within
// the first page, but `EnvironmentSize` sits at 0x3F0 (64-bit) / 0x290
// (32-bit), so the buffer must be sized to cover it.
let mut params_buf = [0u64; 0x400 / size_of::<u64>()];
read_process_memory(handle, params_addr, &mut params_buf)?;
// SAFETY: `params_buf` is 8-byte aligned and `Environment` is a
// pointer-sized field at a pointer-aligned offset, so the read is aligned.
let env_addr = unsafe {
params_buf
.as_ptr()
.cast::<u8>()
.add(offset_of!(RTL_USER_PROCESS_PARAMETERS_PREFIX, Environment))
.cast::<usize>()
.read()
};
if env_addr == 0 || (size_of::<usize>() == 8 && env_addr >> 32 == 0) {
return None;
}
// `EnvironmentSize` is the byte size of the block, including the final
// double-NUL terminator. It is a `ULONG_PTR`, so it is pointer-sized.
let env_size = unsafe {
params_buf
.as_ptr()
.cast::<u8>()
.add(offset_of!(RTL_USER_PROCESS_PARAMETERS_PREFIX, EnvironmentSize))
.cast::<usize>()
.read()
};
if env_size == 0 || env_size > MAX_ENV_BYTES {
return None;
}
// The block lives in the target's address space, so it needs its own read.
let mut chars = vec![0u16; env_size.div_ceil(2)];
read_process_memory(handle, env_addr, &mut chars)?;
Some(format_env_block(&chars))
}
/// Guard against a nonsensical `EnvironmentSize` turning into a huge
/// allocation. Real environment blocks are capped well below this.
#[cfg(target_os = "windows")]
const MAX_ENV_BYTES: usize = 64 * 1024;
/// Formats a raw environment block like the Linux column:
/// `KEY="VALUE" ` for each entry, with embedded quotes escaped.
#[cfg(target_os = "windows")]
fn format_env_block(block: &[u16]) -> String {
let mut out = String::new();
let mut start = 0usize;
for i in 0..block.len() {
if block[i] == 0 {
if i == start {
break; // double NUL: end of block
}
let entry = String::from_utf16_lossy(&block[start..i]);
if let Some(eq) = entry.find('=') {
let (key, value) = entry.split_at(eq);
out.push_str(&format!(
"{}=\"{}\" ",
key,
value[1..].replace('"', "\\\"")
));
}
start = i + 1;
}
}
out
}
+13
View File
@@ -38,6 +38,19 @@ impl Column for FileName {
column_default!(String, false);
}
#[cfg(target_os = "windows")]
impl Column for FileName {
fn add(&mut self, proc: &ProcessInfo) {
let raw_content = proc.file_name.clone();
let fmt_content = raw_content.clone();
self.fmt_contents.insert(proc.pid, fmt_content);
self.raw_contents.insert(proc.pid, raw_content);
}
column_default!(String, false);
}
#[cfg(target_os = "freebsd")]
impl Column for FileName {
fn add(&mut self, proc: &ProcessInfo) {
+13 -13
View File
@@ -1,7 +1,7 @@
use crate::process::ProcessInfo;
#[cfg(target_os = "windows")]
use crate::util::format_sid;
use crate::{column_default, Column};
#[cfg(target_os = "windows")]
use crate::columns::group::{groups_of, primary_group};
use std::cmp;
use std::collections::HashMap;
@@ -64,17 +64,17 @@ impl Column for Gid {
#[cfg(target_os = "windows")]
impl Column for Gid {
fn add(&mut self, proc: &ProcessInfo) {
let mut sid = &proc.groups[0].sid;
let mut kind = u64::MAX;
for g in &proc.groups {
if g.sid.len() > 3 && g.sid[1] == 5 && g.sid[2] == 32 && kind > g.sid[3] {
sid = &g.sid;
kind = g.sid[3];
}
}
let fmt_content = format_sid(sid, self.abbr_sid);
let raw_content = sid[sid.len() - 1] as u32;
// Same on-demand lookup as `Group`: a process whose token could not be
// opened - a protected process, or any process when procs is not
// elevated - has no group list and renders as nothing.
let (fmt_content, raw_content) = match groups_of(proc.pid).as_deref().and_then(primary_group)
{
Some(sid) => (
sid.format(self.abbr_sid),
sid.sub_authorities().last().copied().unwrap_or(0),
),
None => (String::new(), 0),
};
self.fmt_contents.insert(proc.pid, fmt_content);
self.raw_contents.insert(proc.pid, raw_content);
+94 -15
View File
@@ -1,6 +1,6 @@
use crate::process::ProcessInfo;
#[cfg(target_os = "windows")]
use crate::util::format_sid;
use crate::process::SID_MAX;
#[cfg(not(target_os = "windows"))]
use crate::util::USERS_CACHE;
use crate::{column_default, Column};
@@ -78,20 +78,16 @@ impl Column for Group {
#[cfg(target_os = "windows")]
impl Column for Group {
fn add(&mut self, proc: &ProcessInfo) {
let mut sid_name = &proc.groups[0];
let mut kind = u64::MAX;
for g in &proc.groups {
if g.sid.len() > 3 && g.sid[1] == 5 && g.sid[2] == 32 && kind > g.sid[3] {
sid_name = g;
kind = g.sid[3];
}
}
let fmt_content = if let Some(name) = &sid_name.name {
name.clone()
} else {
format_sid(&sid_name.sid, self.abbr_sid)
};
// The token is opened here rather than while collecting, so a run
// that does not show this column never pays for it.
let fmt_content = groups_of(proc.pid)
.as_deref()
.and_then(primary_group)
.map(|sid| {
sid.display_name()
.unwrap_or_else(|| sid.format(self.abbr_sid))
})
.unwrap_or_default();
let raw_content = fmt_content.clone();
self.fmt_contents.insert(proc.pid, fmt_content);
@@ -101,6 +97,89 @@ impl Column for Group {
column_default!(String, false);
}
/// The token groups of `pid`, or `None` when its token cannot be opened - a
/// protected process, or any process when procs is not elevated.
///
/// Reading the groups costs an `OpenProcess` and two token calls per process,
/// so it is done here, lazily, instead of during collection: only a run that
/// displays a group column pays for it, and only for the pids it displays.
/// A thread row carries a thread id rather than a pid, so its lookup fails
/// and it renders as empty - the same thing `Arch` and `WorkDir` do.
#[cfg(target_os = "windows")]
pub fn groups_of(pid: i32) -> Option<Vec<SID_MAX>> {
use std::mem::zeroed;
use windows_sys::Win32::Foundation::{CloseHandle, FALSE, HANDLE};
use windows_sys::Win32::Security::{TOKEN_GROUPS, TOKEN_QUERY, TokenGroups};
use windows_sys::Win32::System::Threading::{
OpenProcess, OpenProcessToken, PROCESS_QUERY_INFORMATION,
};
// 0 is the idle process, which owns no token; negative pids are not real.
if pid <= 0 {
return None;
}
// SAFETY: `pid` is only handed to `OpenProcess`, and the handle it hands
// back is closed before this function returns.
let handle: HANDLE = unsafe { OpenProcess(PROCESS_QUERY_INFORMATION, FALSE, pid as u32) };
if handle.is_null() {
return None;
}
let mut token: HANDLE = unsafe { zeroed() };
let ret = unsafe { OpenProcessToken(handle, TOKEN_QUERY, &mut token) };
if ret == 0 {
unsafe { CloseHandle(handle) };
return None;
}
// The SID pointers live inside this buffer, so it has to outlive them.
let buf = crate::process::token_information(token, TokenGroups);
unsafe {
CloseHandle(token);
CloseHandle(handle);
}
let buf = buf?;
let mut groups = Vec::new();
#[allow(clippy::cast_ptr_alignment)]
let token_groups = buf.as_ptr() as *const TOKEN_GROUPS;
// SAFETY: `token_information` returns only when `GetTokenInformation`
// succeeded, so `buf` holds a `TOKEN_GROUPS` followed by `GroupCount`
// `SID_AND_ATTRIBUTES`. Every SID is copied into an owned `SID_MAX`
// before `buf` is dropped, so no pointer outlives the buffer.
unsafe {
let sa = (*token_groups).Groups.as_ptr();
for i in 0..(*token_groups).GroupCount {
let psid = (*sa.offset(i as isize)).Sid;
groups.push(SID_MAX::from_psid(psid));
}
}
Some(groups)
}
/// The group a process is attributed to: the lowest-numbered well-known
/// builtin group (`S-1-5-32-x`), or the primary group of the token when it
/// carries no builtin group at all.
#[cfg(target_os = "windows")]
pub fn primary_group(groups: &[SID_MAX]) -> Option<&SID_MAX> {
let mut sid = groups.first()?;
let mut kind = u64::MAX;
for g in groups {
let subs = g.sub_authorities();
if g.authority() == 5
&& subs.first() == Some(&32)
&& u64::from(subs.get(1).copied().unwrap_or(u32::MAX)) < kind
{
sid = g;
kind = u64::from(subs[1]);
}
}
Some(sid)
}
#[cfg(target_os = "freebsd")]
impl Column for Group {
fn add(&mut self, proc: &ProcessInfo) {
+93
View File
@@ -1,7 +1,10 @@
pub mod arch;
pub mod command;
pub mod cpu_time;
pub mod elapsed_time;
pub mod empty;
pub mod env;
pub mod file_name;
pub mod gid;
pub mod group;
pub mod maj_flt;
@@ -9,10 +12,15 @@ pub mod multi_slot;
pub mod pid;
pub mod ppid;
pub mod priority;
pub mod rt_priority;
pub mod read_bytes;
pub mod recv_bytes;
pub mod separator;
pub mod send_bytes;
pub mod session;
pub mod slot;
pub mod start_time;
pub mod state;
pub mod tcp_port;
pub mod threads;
pub mod tree;
@@ -27,12 +35,16 @@ pub mod vm_pin;
pub mod vm_rss;
pub mod vm_size;
pub mod vm_swap;
pub mod work_dir;
pub mod write_bytes;
pub use self::arch::Arch;
pub use self::command::Command;
pub use self::cpu_time::CpuTime;
pub use self::elapsed_time::ElapsedTime;
pub use self::empty::Empty;
pub use self::env::Env;
pub use self::file_name::FileName;
pub use self::gid::Gid;
pub use self::group::Group;
pub use self::maj_flt::MajFlt;
@@ -40,10 +52,15 @@ pub use self::multi_slot::MultiSlot;
pub use self::pid::Pid;
pub use self::ppid::Ppid;
pub use self::priority::Priority;
pub use self::rt_priority::RtPriority;
pub use self::read_bytes::ReadBytes;
pub use self::recv_bytes::RecvBytes;
pub use self::separator::Separator;
pub use self::send_bytes::SendBytes;
pub use self::session::Session;
pub use self::slot::Slot;
pub use self::start_time::StartTime;
pub use self::state::State;
pub use self::tcp_port::TcpPort;
pub use self::threads::Threads;
pub use self::tree::Tree;
@@ -58,6 +75,7 @@ pub use self::vm_pin::VmPin;
pub use self::vm_rss::VmRss;
pub use self::vm_size::VmSize;
pub use self::vm_swap::VmSwap;
pub use self::work_dir::WorkDir;
pub use self::write_bytes::WriteBytes;
use crate::column::Column;
@@ -72,10 +90,13 @@ use std::path::PathBuf;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum ConfigColumnKind {
Arch,
Command,
CpuTime,
ElapsedTime,
Empty,
Env,
FileName,
Gid,
Group,
MajFlt,
@@ -84,9 +105,14 @@ pub enum ConfigColumnKind {
Ppid,
Priority,
ReadBytes,
RecvBytes,
RtPriority,
SendBytes,
Separator,
Session,
Slot,
StartTime,
State,
TcpPort,
Threads,
Tree,
@@ -101,6 +127,7 @@ pub enum ConfigColumnKind {
VmRss,
VmSize,
VmSwap,
WorkDir,
WriteBytes,
}
@@ -118,10 +145,13 @@ pub fn gen_column(
_procfs: Option<PathBuf>,
) -> Box<dyn Column> {
match kind {
ConfigColumnKind::Arch => Box::new(Arch::new(header)),
ConfigColumnKind::Command => Box::new(Command::new(header)),
ConfigColumnKind::CpuTime => Box::new(CpuTime::new(header)),
ConfigColumnKind::ElapsedTime => Box::new(ElapsedTime::new(header)),
ConfigColumnKind::Empty => Box::new(Empty::new()),
ConfigColumnKind::Env => Box::new(Env::new(header, _procfs)),
ConfigColumnKind::FileName => Box::new(FileName::new(header)),
ConfigColumnKind::Gid => Box::new(Gid::new(header, abbr_sid)),
ConfigColumnKind::Group => Box::new(Group::new(header, abbr_sid)),
ConfigColumnKind::MajFlt => Box::new(MajFlt::new(header)),
@@ -130,9 +160,14 @@ pub fn gen_column(
ConfigColumnKind::Ppid => Box::new(Ppid::new(header)),
ConfigColumnKind::Priority => Box::new(Priority::new(header)),
ConfigColumnKind::ReadBytes => Box::new(ReadBytes::new(header)),
ConfigColumnKind::RecvBytes => Box::new(RecvBytes::new(header)),
ConfigColumnKind::RtPriority => Box::new(RtPriority::new(header)),
ConfigColumnKind::SendBytes => Box::new(SendBytes::new(header)),
ConfigColumnKind::Separator => Box::new(Separator::new(separator)),
ConfigColumnKind::Session => Box::new(Session::new(header)),
ConfigColumnKind::Slot => Box::new(Slot::new()),
ConfigColumnKind::StartTime => Box::new(StartTime::new(header)),
ConfigColumnKind::State => Box::new(State::new(header)),
ConfigColumnKind::TcpPort => Box::new(TcpPort::new(header)),
ConfigColumnKind::Threads => Box::new(Threads::new(header)),
ConfigColumnKind::Tree => Box::new(Tree::new(tree_symbols)),
@@ -147,6 +182,7 @@ pub fn gen_column(
ConfigColumnKind::VmRss => Box::new(VmRss::new(header)),
ConfigColumnKind::VmSize => Box::new(VmSize::new(header)),
ConfigColumnKind::VmSwap => Box::new(VmSwap::new(header)),
ConfigColumnKind::WorkDir => Box::new(WorkDir::new(header, _procfs)),
ConfigColumnKind::WriteBytes => Box::new(WriteBytes::new(header)),
}
}
@@ -158,6 +194,10 @@ pub fn gen_column(
pub static KIND_LIST: Lazy<BTreeMap<ConfigColumnKind, (&'static str, &'static str)>> =
Lazy::new(|| {
[
(
ConfigColumnKind::Arch,
("Arch", "Architecture of the process image"),
),
(
ConfigColumnKind::Command,
("Command", "Command with all arguments"),
@@ -171,6 +211,14 @@ pub static KIND_LIST: Lazy<BTreeMap<ConfigColumnKind, (&'static str, &'static st
("ElapsedTime", "Elapsed time"),
),
(ConfigColumnKind::Empty, ("Empty", "Empty")),
(
ConfigColumnKind::Env,
("Env", "Environment variables"),
),
(
ConfigColumnKind::FileName,
("FileName", "File name of the process image"),
),
(ConfigColumnKind::Gid, ("Gid", "Group ID")),
(ConfigColumnKind::Group, ("Group", "Group name")),
(
@@ -188,15 +236,32 @@ pub static KIND_LIST: Lazy<BTreeMap<ConfigColumnKind, (&'static str, &'static st
ConfigColumnKind::ReadBytes,
("ReadBytes", "Read bytes from storage"),
),
(
ConfigColumnKind::RecvBytes,
("RecvBytes", "Received bytes per second (Windows 11)"),
),
(
ConfigColumnKind::RtPriority,
("RtPriority", "Kernel base priority (0-31)"),
),
(
ConfigColumnKind::SendBytes,
("SendBytes", "Sent bytes per second (Windows 11)"),
),
(
ConfigColumnKind::Separator,
("Separator", "Show | for column separation"),
),
(
ConfigColumnKind::Session,
("Session", "Session ID"),
),
(
ConfigColumnKind::Slot,
("Slot", "Slot for `--insert` option"),
),
(ConfigColumnKind::StartTime, ("StartTime", "Starting time")),
(ConfigColumnKind::State, ("State", "Process state")),
(ConfigColumnKind::TcpPort, ("TcpPort", "Bound TCP ports")),
(ConfigColumnKind::Threads, ("Threads", "Thread count")),
(
@@ -222,6 +287,10 @@ pub static KIND_LIST: Lazy<BTreeMap<ConfigColumnKind, (&'static str, &'static st
ConfigColumnKind::VmSwap,
("VmSwap", "Swapped-out virtual memory size"),
),
(
ConfigColumnKind::WorkDir,
("WorkDir", "Current working directory"),
),
(
ConfigColumnKind::WriteBytes,
("WriteBytes", "Write bytes to storage"),
@@ -354,6 +423,18 @@ numeric_search = false
nonnumeric_search = false
align = "Right"
[[columns]]
kind = "RecvBytes"
style = "ByUnit"
numeric_search = false
nonnumeric_search = false
align = "Right"
[[columns]]
kind = "SendBytes"
style = "ByUnit"
numeric_search = false
nonnumeric_search = false
align = "Right"
[[columns]]
kind = "Slot"
style = "ByUnit"
numeric_search = false
@@ -412,6 +493,12 @@ style = "BrightYellow"
kind = "Empty"
style = "BrightYellow"
[[columns]]
kind = "Env"
style = "BrightYellow"
[[columns]]
kind = "FileName"
style = "White"
[[columns]]
kind = "Gid"
style = "White"
[[columns]]
@@ -439,6 +526,9 @@ style = "Cyan"
kind = "Separator"
style = "White"
[[columns]]
kind = "Session"
style = "White"
[[columns]]
kind = "StartTime"
style = "White"
[[columns]]
@@ -483,4 +573,7 @@ style = "ByUnit"
[[columns]]
kind = "WriteBytes"
style = "White"
[[columns]]
kind = "RtPriority"
style = "White"
"#;
+83 -9
View File
@@ -54,15 +54,23 @@ impl Column for Priority {
#[cfg(target_os = "windows")]
impl Column for Priority {
fn add(&mut self, proc: &ProcessInfo) {
let raw_content = i64::from(proc.priority);
let fmt_content = match raw_content {
0x0020 => String::from("Normal"),
0x0040 => String::from("Idle"),
0x0080 => String::from("High"),
0x0100 => String::from("Realtime"),
0x4000 => String::from("BelowNormal"),
0x8000 => String::from("AboveNormal"),
_ => String::from("Unknown"),
// User-mode priority class - what the user configured via Task Manager,
// `STARTUPINFO`, or `SetPriorityClass`. It is queried only when this
// column is actually displayed: `add` runs once per process for the
// processes the column is shown for. The kernel Base Priority lives in
// `proc.priority` and is surfaced by the `RtPriority` column.
let (raw_content, fmt_content) = match priority_class_of(proc.pid) {
Some(class) => (class as i64, priority_class_name(class).to_string()),
// Protected processes (e.g. PPL) refuse the open, so fall back to
// the kernel Base Priority, which is always available. The class
// derived from it is only an approximation.
None => {
let class = priority_class_from_base(proc.priority);
(
class as i64,
format!("{} *", priority_class_name(class)),
)
},
};
self.fmt_contents.insert(proc.pid, fmt_content);
@@ -72,6 +80,72 @@ impl Column for Priority {
column_default!(i64, true);
}
#[cfg(target_os = "windows")]
fn priority_class_of(pid: i32) -> Option<u32> {
use windows_sys::Win32::Foundation::{CloseHandle, FALSE, HANDLE};
use windows_sys::Win32::System::Threading::{
GetPriorityClass, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
};
if pid <= 0 {
return None;
}
// SAFETY: `pid` is only handed to `OpenProcess`, and the handle it returns
// is closed before this function returns.
let handle: HANDLE = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid as u32) };
if handle.is_null() {
return None;
}
let class = unsafe { GetPriorityClass(handle) };
unsafe { CloseHandle(handle) };
// A return of 0 means the call failed.
if class == 0 { None } else { Some(class) }
}
#[cfg(target_os = "windows")]
fn priority_class_name(class: u32) -> &'static str {
use windows_sys::Win32::System::Threading::{
ABOVE_NORMAL_PRIORITY_CLASS, BELOW_NORMAL_PRIORITY_CLASS, HIGH_PRIORITY_CLASS,
IDLE_PRIORITY_CLASS, NORMAL_PRIORITY_CLASS, REALTIME_PRIORITY_CLASS,
};
match class {
IDLE_PRIORITY_CLASS => "Idle",
BELOW_NORMAL_PRIORITY_CLASS => "BelowNormal",
NORMAL_PRIORITY_CLASS => "Normal",
ABOVE_NORMAL_PRIORITY_CLASS => "AboveNormal",
HIGH_PRIORITY_CLASS => "High",
REALTIME_PRIORITY_CLASS => "Realtime",
_ => "Unknown",
}
}
/// Approximate a priority class from the kernel Base Priority when
/// `OpenProcess` is unavailable (e.g. protected processes). The Base Priority
/// is the class's base value plus any thread-level delta, so the mapping is
/// only an approximation and the caller wraps the result in parentheses.
#[cfg(target_os = "windows")]
fn priority_class_from_base(base: i32) -> u32 {
use windows_sys::Win32::System::Threading::{
ABOVE_NORMAL_PRIORITY_CLASS, BELOW_NORMAL_PRIORITY_CLASS, HIGH_PRIORITY_CLASS,
IDLE_PRIORITY_CLASS, NORMAL_PRIORITY_CLASS, REALTIME_PRIORITY_CLASS,
};
// Base priorities: Idle=4, BelowNormal=6, Normal=8, AboveNormal=10,
// High=13, Realtime=24. Pick the class whose base value is closest.
match base {
b if b <= ((4 + 6) / 2) as i32 => IDLE_PRIORITY_CLASS,
b if b <= ((6 + 8) / 2) as i32 => BELOW_NORMAL_PRIORITY_CLASS,
b if b <= ((8 + 10) / 2) as i32 => NORMAL_PRIORITY_CLASS,
b if b <= ((10 + 13) / 2) as i32 => ABOVE_NORMAL_PRIORITY_CLASS,
b if b <= ((13 + 24) / 2) as i32 => HIGH_PRIORITY_CLASS,
_ => REALTIME_PRIORITY_CLASS,
}
}
#[cfg(target_os = "freebsd")]
impl Column for Priority {
fn add(&mut self, proc: &ProcessInfo) {
+4 -4
View File
@@ -32,7 +32,7 @@ impl Column for ReadBytes {
let (fmt_content, raw_content) = if let Some(curr_io) = proc.curr_io
&& let Some(prev_io) = proc.prev_io
{
let interval_ms = proc.interval.as_secs() + u64::from(proc.interval.subsec_millis());
let interval_ms = proc.interval.as_secs() * 1000 + u64::from(proc.interval.subsec_millis());
let io = (curr_io.read_bytes - prev_io.read_bytes) * 1000 / interval_ms;
(bytify(io), io)
} else {
@@ -50,7 +50,7 @@ impl Column for ReadBytes {
impl Column for ReadBytes {
fn add(&mut self, proc: &ProcessInfo) {
let (fmt_content, raw_content) = if proc.curr_res.is_some() && proc.prev_res.is_some() {
let interval_ms = proc.interval.as_secs() + u64::from(proc.interval.subsec_millis());
let interval_ms = proc.interval.as_secs() * 1000 + u64::from(proc.interval.subsec_millis());
let io = (proc.curr_res.as_ref().unwrap().ri_diskio_bytesread
- proc.prev_res.as_ref().unwrap().ri_diskio_bytesread)
* 1000
@@ -70,7 +70,7 @@ impl Column for ReadBytes {
#[cfg(target_os = "windows")]
impl Column for ReadBytes {
fn add(&mut self, proc: &ProcessInfo) {
let interval_ms = proc.interval.as_secs() + u64::from(proc.interval.subsec_millis());
let interval_ms = proc.interval.as_secs() * 1000 + u64::from(proc.interval.subsec_millis());
let io = (proc.disk_info.curr_read - proc.disk_info.prev_read) * 1000 / interval_ms;
let raw_content = io;
@@ -88,7 +88,7 @@ impl Column for ReadBytes {
fn add(&mut self, proc: &ProcessInfo) {
// io block size: 128KB
let block_size = 128 * 1024;
let interval_ms = proc.interval.as_secs() + u64::from(proc.interval.subsec_millis());
let interval_ms = proc.interval.as_secs() * 1000 + u64::from(proc.interval.subsec_millis());
let io = (proc.curr_proc.info.rusage.inblock as u64
- proc.prev_proc.info.rusage.inblock as u64)
* block_size
+55
View File
@@ -0,0 +1,55 @@
use crate::process::ProcessInfo;
use crate::util::bytify;
use crate::{Column, column_default};
use std::cmp;
use std::collections::HashMap;
/// Bytes received per second.
///
/// Windows only: the counters come from `ProcessNetworkIoInformation`
/// (`PROCESS_NETWORK_COUNTERS::BytesIn`), so the column is declared by
/// `os_windows.rs` alone. It reads a rate, which needs the two samples
/// `collect_proc` takes; a process that could not be opened, or a Windows
/// older than 11, reports zero.
pub struct RecvBytes {
header: String,
unit: String,
fmt_contents: HashMap<i32, String>,
raw_contents: HashMap<i32, u64>,
width: usize,
}
impl RecvBytes {
pub fn new(header: Option<String>) -> Self {
let header = header.unwrap_or_else(|| String::from("Recv"));
let unit = String::from("[B/s]");
Self {
fmt_contents: HashMap::new(),
raw_contents: HashMap::new(),
width: 0,
header,
unit,
}
}
}
impl Column for RecvBytes {
fn add(&mut self, proc: &ProcessInfo) {
let interval_ms =
(proc.interval.as_secs() * 1000 + u64::from(proc.interval.subsec_millis())).max(1);
let io = proc
.net_info
.curr_recv
.saturating_sub(proc.net_info.prev_recv)
* 1000
/ interval_ms;
let raw_content = io;
let fmt_content = bytify(raw_content);
self.fmt_contents.insert(proc.pid, fmt_content);
self.raw_contents.insert(proc.pid, raw_content);
}
column_default!(u64, true);
}
+17
View File
@@ -25,6 +25,7 @@ impl RtPriority {
}
}
#[cfg(any(target_os = "linux", target_os = "android"))]
impl Column for RtPriority {
fn add(&mut self, proc: &ProcessInfo) {
let raw_content = proc.curr_proc.stat().rt_priority.unwrap_or_default();
@@ -40,3 +41,19 @@ impl Column for RtPriority {
column_default!(u32, true);
}
#[cfg(target_os = "windows")]
impl Column for RtPriority {
fn add(&mut self, proc: &ProcessInfo) {
// Windows has nothing like the POSIX real-time priority. This column
// shows the kernel Base Priority (0-31), the scheduling priority the
// kernel actually uses - the "real" priority of the process.
let raw_content = proc.priority as u32;
let fmt_content = format!("{}", raw_content);
self.fmt_contents.insert(proc.pid, fmt_content);
self.raw_contents.insert(proc.pid, raw_content);
}
column_default!(u32, true);
}
+55
View File
@@ -0,0 +1,55 @@
use crate::process::ProcessInfo;
use crate::util::bytify;
use crate::{Column, column_default};
use std::cmp;
use std::collections::HashMap;
/// Bytes sent per second.
///
/// Windows only: the counters come from `ProcessNetworkIoInformation`
/// (`PROCESS_NETWORK_COUNTERS::BytesOut`), so the column is declared by
/// `os_windows.rs` alone. It reads a rate, which needs the two samples
/// `collect_proc` takes; a process that could not be opened, or a Windows
/// older than 11, reports zero.
pub struct SendBytes {
header: String,
unit: String,
fmt_contents: HashMap<i32, String>,
raw_contents: HashMap<i32, u64>,
width: usize,
}
impl SendBytes {
pub fn new(header: Option<String>) -> Self {
let header = header.unwrap_or_else(|| String::from("Send"));
let unit = String::from("[B/s]");
Self {
fmt_contents: HashMap::new(),
raw_contents: HashMap::new(),
width: 0,
header,
unit,
}
}
}
impl Column for SendBytes {
fn add(&mut self, proc: &ProcessInfo) {
let interval_ms =
(proc.interval.as_secs() * 1000 + u64::from(proc.interval.subsec_millis())).max(1);
let io = proc
.net_info
.curr_send
.saturating_sub(proc.net_info.prev_send)
* 1000
/ interval_ms;
let raw_content = io;
let fmt_content = bytify(raw_content);
self.fmt_contents.insert(proc.pid, fmt_content);
self.raw_contents.insert(proc.pid, raw_content);
}
column_default!(u64, true);
}
+13
View File
@@ -59,6 +59,19 @@ impl Column for Session {
column_default!(i32, true);
}
#[cfg(target_os = "windows")]
impl Column for Session {
fn add(&mut self, proc: &ProcessInfo) {
let raw_content = proc.session;
let fmt_content = format!("{}", raw_content);
self.fmt_contents.insert(proc.pid, fmt_content);
self.raw_contents.insert(proc.pid, raw_content);
}
column_default!(i32, true);
}
#[cfg(target_os = "freebsd")]
impl Column for Session {
fn add(&mut self, proc: &ProcessInfo) {
+64
View File
@@ -3,6 +3,9 @@ use crate::{column_default, Column};
use std::cmp;
use std::collections::HashMap;
#[cfg(target_os = "windows")]
use crate::process::{thread_state, wait_reason, ThreadState};
pub struct State {
header: String,
unit: String,
@@ -150,3 +153,64 @@ impl Column for State {
column_default!(String, false);
}
// ---------------------------------------------------------------------------
// Windows
//
// There is no per-process state to read: `SYSTEM_PROCESS_INFORMATION` only
// counts the threads, while `SYSTEM_THREAD_INFORMATION` is what carries the
// scheduler state. A row is therefore described by the threads behind it.
// ---------------------------------------------------------------------------
/// The letter `ps` prints for one thread's state.
///
/// `WaitReason` is only read while the thread is in `WAITING`: it is a leftover
/// from the last wait otherwise, and a thread that is runnable again still
/// carries the reason it sleeps on.
#[cfg(target_os = "windows")]
fn thread_state_char(state: ThreadState) -> char {
use thread_state::*;
match state.state {
// Runnable, or on its way there: `TRANSITION` only means the kernel
// stack is still being brought in.
INITIALIZED | READY | RUNNING | STANDBY | TRANSITION | DEFERRED_READY => 'R',
WAITING | GATE_WAIT | WAITING_FOR_PROCESS_IN_SWAP => match state.wait_reason {
wait_reason::SUSPENDED | wait_reason::WR_SUSPENDED => 'T',
_ => 'S',
},
TERMINATED => 'Z',
_ => '?',
}
}
/// The state letter of one row.
///
/// `state` is the most active thread's raw state, reduced by the snapshot
/// layer; `thread_count` is the `NumberOfThreads` the snapshot reported. It
/// is what separates a process that has no threads left from one whose
/// records simply could not be read.
#[cfg(target_os = "windows")]
fn state_char(state: Option<ThreadState>, thread_count: i32) -> char {
match state {
// No thread left at all: the process has terminated and is only still
// listed because handles to it remain open.
None if thread_count <= 0 => 'Z',
// Threads were claimed but none could be read: not enough to call it.
None => '?',
Some(state) => thread_state_char(state),
}
}
#[cfg(target_os = "windows")]
impl Column for State {
fn add(&mut self, proc: &ProcessInfo) {
let fmt_content = String::from(state_char(proc.state, proc.thread));
let raw_content = fmt_content.clone();
self.fmt_contents.insert(proc.pid, fmt_content);
self.raw_contents.insert(proc.pid, raw_content);
}
column_default!(String, false);
}
+9 -4
View File
@@ -1,6 +1,4 @@
use crate::process::ProcessInfo;
#[cfg(target_os = "windows")]
use crate::util::format_sid;
use crate::{column_default, Column};
use std::cmp;
use std::collections::HashMap;
@@ -64,8 +62,15 @@ impl Column for Uid {
#[cfg(target_os = "windows")]
impl Column for Uid {
fn add(&mut self, proc: &ProcessInfo) {
let fmt_content = format_sid(&proc.user.sid, self.abbr_sid);
let raw_content = proc.user.sid[proc.user.sid.len() - 1] as u32;
let (fmt_content, raw_content) = if let Some(sid) = &proc.user {
let subs = sid.sub_authorities();
(
sid.format(self.abbr_sid),
subs.last().copied().unwrap_or(0),
)
} else {
(String::new(), 0)
};
self.fmt_contents.insert(proc.pid, fmt_content);
self.raw_contents.insert(proc.pid, raw_content);
+7 -6
View File
@@ -5,9 +5,7 @@ use procfs::{Current, Meminfo, WithCurrentSystemInfo};
use std::cmp;
use std::collections::HashMap;
#[cfg(target_os = "windows")]
use std::mem::{size_of, zeroed};
#[cfg(target_os = "windows")]
use windows_sys::Win32::System::ProcessStatus::{GetPerformanceInfo, PERFORMANCE_INFORMATION};
use windows_sys::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX};
pub struct UsageMem {
header: String,
@@ -64,11 +62,14 @@ fn get_mem_total() -> u64 {
#[cfg(target_os = "windows")]
fn get_mem_total() -> u64 {
let mut info: PERFORMANCE_INFORMATION = unsafe { zeroed() };
let ret = unsafe { GetPerformanceInfo(&mut info, size_of::<PERFORMANCE_INFORMATION>() as u32) };
let mut info = MEMORYSTATUSEX {
dwLength: std::mem::size_of::<MEMORYSTATUSEX>() as u32,
..Default::default()
};
let ret = unsafe { GlobalMemoryStatusEx(&mut info) };
if ret != 0 {
info.PhysicalTotal as u64 * info.PageSize as u64
info.ullTotalPhys
} else {
0
}
+4 -5
View File
@@ -1,6 +1,4 @@
use crate::process::ProcessInfo;
#[cfg(target_os = "windows")]
use crate::util::format_sid;
#[cfg(not(target_os = "windows"))]
use crate::util::USERS_CACHE;
use crate::{column_default, Column};
@@ -74,10 +72,11 @@ impl Column for User {
#[cfg(target_os = "windows")]
impl Column for User {
fn add(&mut self, proc: &ProcessInfo) {
let fmt_content = if let Some(name) = &proc.user.name {
name.clone()
let fmt_content = if let Some(sid) = &proc.user {
sid.display_name()
.unwrap_or_else(|| sid.format(self.abbr_sid))
} else {
format_sid(&proc.user.sid, self.abbr_sid)
String::new()
};
let raw_content = fmt_content.clone();
+130
View File
@@ -4,12 +4,16 @@ use std::cmp;
use std::collections::HashMap;
use std::path::PathBuf;
#[cfg(target_os = "windows")]
use windows_sys::Win32::Foundation::HANDLE;
pub struct WorkDir {
header: String,
unit: String,
fmt_contents: HashMap<i32, String>,
raw_contents: HashMap<i32, String>,
width: usize,
#[allow(dead_code)]
procfs: Option<PathBuf>,
}
@@ -48,3 +52,129 @@ impl Column for WorkDir {
column_default!(String, false);
}
#[cfg(target_os = "windows")]
impl Column for WorkDir {
fn add(&mut self, proc: &ProcessInfo) {
let fmt_content = work_dir_of(proc.pid).unwrap_or_default();
let raw_content = fmt_content.clone();
self.fmt_contents.insert(proc.pid, fmt_content);
self.raw_contents.insert(proc.pid, raw_content);
}
column_default!(String, false);
}
/// Reads the current working directory of `pid` from its PEB.
///
/// The PEB address comes from `NtQueryInformationProcess` with
/// `ProcessBasicInformation`; the PEB and the process parameters it points at
/// are then read with `ReadProcessMemory`. Needs
/// `PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ`, so protected
/// processes (e.g. PPL) yield `None`.
#[cfg(target_os = "windows")]
fn work_dir_of(pid: i32) -> Option<String> {
use windows_sys::Win32::Foundation::{CloseHandle, FALSE, HANDLE};
use windows_sys::Win32::System::Threading::{
OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_VM_READ,
};
// 0 is the idle process and has no PEB; negative pids are not real.
if pid <= 0 {
return None;
}
// SAFETY: `pid` is only handed to `OpenProcess`, and the handle it returns
// is closed before this function returns.
let handle: HANDLE = unsafe {
OpenProcess(
PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ,
FALSE,
pid as u32,
)
};
if handle.is_null() {
return None;
}
let dir = read_work_dir(handle);
unsafe { CloseHandle(handle) };
dir
}
/// Reads the working directory out of the PEB of `handle`.
#[cfg(target_os = "windows")]
fn read_work_dir(handle: HANDLE) -> Option<String> {
use windows_sys::Win32::Foundation::UNICODE_STRING;
use crate::process::{
process_peb_address, read_process_memory, PEB_PREFIX,
RTL_USER_PROCESS_PARAMETERS_PREFIX,
};
use std::mem::{offset_of, size_of};
use std::ptr;
// The PEB address lives in the target's address space.
let peb = process_peb_address(handle)?;
// Read the leading PEB fields. `ProcessParameters` sits within the first
// page, so a single read covers it.
let mut peb_buf = [0u64; 0x100 / size_of::<u64>()];
read_process_memory(handle, peb, &mut peb_buf)?;
// SAFETY: `peb_buf` is 8-byte aligned and `ProcessParameters` is a
// pointer-sized field at a pointer-aligned offset, so the read is aligned.
let params_addr = unsafe {
peb_buf
.as_ptr()
.cast::<u8>()
.add(offset_of!(PEB_PREFIX, ProcessParameters))
.cast::<usize>()
.read()
};
// A 32-bit address from a 64-bit reader means a WOW64 process, whose
// process parameters use the 32-bit layout; refuse rather than misparse.
if params_addr == 0 || (size_of::<usize>() == 8 && params_addr >> 32 == 0) {
return None;
}
// Read the leading process-parameters fields; `CurrentDirectory` sits
// within the first page.
let mut params_buf = [0u64; 0x100 / size_of::<u64>()];
read_process_memory(handle, params_addr, &mut params_buf)?;
// SAFETY: `params_buf` is 8-byte aligned and `CurrentDirectory` is at an
// 8-byte-aligned offset, so the `UNICODE_STRING` read is aligned.
let dos_path: UNICODE_STRING = unsafe {
ptr::read(
params_buf
.as_ptr()
.cast::<u8>()
.add(offset_of!(RTL_USER_PROCESS_PARAMETERS_PREFIX, CurrentDirectory))
.cast::<UNICODE_STRING>(),
)
};
if dos_path.Buffer.is_null() || dos_path.Length == 0 {
return None;
}
let bytes = dos_path.Length as usize;
if bytes > MAX_WORK_DIR_BYTES {
return None;
}
// The string lives in the target's address space, so it needs its own read.
let mut chars = vec![0u16; bytes / 2];
read_process_memory(handle, dos_path.Buffer as usize, &mut chars)?;
Some(
String::from_utf16_lossy(&chars)
.trim_end_matches('\0')
.to_owned(),
)
}
/// Guard against a nonsensical `UNICODE_STRING::Length` turning into a huge
/// allocation. Real working directories are capped well below this.
#[cfg(target_os = "windows")]
const MAX_WORK_DIR_BYTES: usize = 64 * 1024;
+4 -4
View File
@@ -32,7 +32,7 @@ impl Column for WriteBytes {
let (fmt_content, raw_content) = if let Some(curr_io) = proc.curr_io
&& let Some(prev_io) = proc.prev_io
{
let interval_ms = proc.interval.as_secs() + u64::from(proc.interval.subsec_millis());
let interval_ms = proc.interval.as_secs() * 1000 + u64::from(proc.interval.subsec_millis());
let io = (curr_io.write_bytes - prev_io.write_bytes) * 1000 / interval_ms;
(bytify(io), io)
} else {
@@ -50,7 +50,7 @@ impl Column for WriteBytes {
impl Column for WriteBytes {
fn add(&mut self, proc: &ProcessInfo) {
let (fmt_content, raw_content) = if proc.curr_res.is_some() && proc.prev_res.is_some() {
let interval_ms = proc.interval.as_secs() + u64::from(proc.interval.subsec_millis());
let interval_ms = proc.interval.as_secs() * 1000 + u64::from(proc.interval.subsec_millis());
let io = (proc.curr_res.as_ref().unwrap().ri_diskio_byteswritten
- proc.prev_res.as_ref().unwrap().ri_diskio_byteswritten)
* 1000
@@ -70,7 +70,7 @@ impl Column for WriteBytes {
#[cfg(target_os = "windows")]
impl Column for WriteBytes {
fn add(&mut self, proc: &ProcessInfo) {
let interval_ms = proc.interval.as_secs() + u64::from(proc.interval.subsec_millis());
let interval_ms = proc.interval.as_secs() * 1000 + u64::from(proc.interval.subsec_millis());
let io = (proc.disk_info.curr_write - proc.disk_info.prev_write) * 1000 / interval_ms;
let raw_content = io;
@@ -88,7 +88,7 @@ impl Column for WriteBytes {
fn add(&mut self, proc: &ProcessInfo) {
// io block size: 128KB
let block_size = 128 * 1024;
let interval_ms = proc.interval.as_secs() + u64::from(proc.interval.subsec_millis());
let interval_ms = proc.interval.as_secs() * 1000 + u64::from(proc.interval.subsec_millis());
let io = (proc.curr_proc.info.rusage.oublock as u64
- proc.prev_proc.info.rusage.oublock as u64)
* block_size
+1 -1
View File
@@ -188,7 +188,7 @@ fn run_list() {
let mut width = 0;
let mut list = Vec::new();
let mut desc = HashMap::new();
for (_, (v, d)) in KIND_LIST.iter() {
for (v, d) in KIND_LIST.values() {
list.push(v);
desc.insert(v, d);
width = cmp::max(width, UnicodeWidthStr::width(*v));
+2
View File
@@ -5,6 +5,8 @@ pub mod linux;
#[cfg(target_os = "macos")]
pub mod macos;
#[cfg(target_os = "windows")]
mod ntapi;
#[cfg(target_os = "windows")]
pub mod windows;
#[cfg(target_os = "freebsd")]
+5 -1
View File
@@ -15,7 +15,7 @@ pub struct ProcessInfo {
pub fn collect_proc(
interval: Duration,
_with_thread: bool,
_show_kthreads: bool,
show_kthreads: bool,
_procfs_path: &Option<PathBuf>,
) -> Vec<ProcessInfo> {
let mut base_procs = HashMap::new();
@@ -24,6 +24,10 @@ pub fn collect_proc(
let kvm = Kvm::open(None, Some("/dev/null"), Access::ReadOnly);
if let Ok(mut kvm) = kvm {
for proc in kvm.get_process(KernProc::Proc, 0) {
if !show_kthreads && proc.info.flag & 0x00000004 /* P_KPROC */ != 0 {
continue;
}
let time = Instant::now();
base_procs.insert(proc.info.pid, (proc.clone(), time));
}
+7 -2
View File
@@ -1,6 +1,6 @@
use procfs::ProcError;
use procfs::ProcessCGroup;
use procfs::process::{FDInfo, Io, Process, Stat, Status, TasksIter};
use procfs::process::{FDInfo, Io, Process, Stat, StatFlags, Status, TasksIter};
use std::collections::HashMap;
use std::path::PathBuf;
use std::thread;
@@ -136,7 +136,12 @@ pub fn collect_proc(
let interval = curr_time - prev_time;
let ppid = curr_stat.ppid;
if !show_kthreads && (ppid == 2 || pid == 2) {
if !show_kthreads
&& curr_stat
.flags()
.unwrap_or(StatFlags::empty())
.contains(StatFlags::PF_KTHREAD)
{
continue;
}
+5 -1
View File
@@ -30,7 +30,7 @@ pub struct ProcessInfo {
pub fn collect_proc(
interval: Duration,
_with_thread: bool,
_show_kthreads: bool,
show_kthreads: bool,
_procfs_path: &Option<PathBuf>,
) -> Vec<ProcessInfo> {
let mut base_procs = Vec::new();
@@ -40,6 +40,10 @@ pub fn collect_proc(
if let Ok(procs) = pids_by_type(ProcFilter::All) {
for p in procs {
if let Ok(task) = pidinfo::<TaskAllInfo>(p as i32, 0) {
if !show_kthreads && task.pbsd.pbi_flags & 1 /* PROC_FLAG_SYSTEM */ != 0 {
continue;
}
let res = pidrusage::<RUsageInfoV2>(p as i32).ok();
let time = Instant::now();
base_procs.push((p as i32, task, res, time));
+1709
View File
File diff suppressed because it is too large Load Diff
+584 -475
View File
File diff suppressed because it is too large Load Diff
+29 -16
View File
@@ -223,6 +223,15 @@ pub fn truncate(s: &'_ str, width: usize) -> Cow<'_, str> {
}
}
/// Replace control characters with spaces.
///
/// Process command lines are fully attacker-controlled, so any ESC/CSI/OSC
/// sequence they contain would otherwise be interpreted by the terminal of
/// whoever runs procs.
pub fn sanitize_control_chars(s: &str) -> String {
s.replace(|c: char| char::is_control(c), " ")
}
/// Trim trailing whitespace from a string that may contain ANSI escape sequences.
/// Unlike str::trim_end(), this correctly handles ANSI codes at the end of the string
/// that would otherwise prevent trimming of trailing whitespace.
@@ -282,22 +291,6 @@ pub unsafe fn get_sys_value(
) == 0
}
#[cfg(target_os = "windows")]
pub fn format_sid(sid: &[u64], abbr: bool) -> String {
let mut ret = format!("S-{}-{}-{}", sid[0], sid[1], sid[2]);
if sid.len() > 3 {
if abbr {
ret = format!("{}-...-{}", ret, sid[sid.len() - 1]);
} else {
for s in sid.iter().skip(3) {
ret = format!("{}-{}", ret, s);
}
}
}
ret
}
pub fn bytify(x: u64) -> String {
let byte = Byte::from_u64(x);
let byte = byte.get_appropriate_unit(UnitType::Binary);
@@ -383,3 +376,23 @@ pub fn process_new(
procfs::process::Process::new(pid)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sanitize_control_chars() {
// OSC 52 clipboard-write sequence embedded in a crafted argv0
let injected = "\u{1b}]52;c;bWFya2Vy\u{7}sleep\t60\nrm -rf /";
let sanitized = sanitize_control_chars(injected);
assert_eq!(sanitized, " ]52;c;bWFya2Vy sleep 60 rm -rf /");
assert!(!sanitized.chars().any(char::is_control));
// CSI sequences are neutralized too
assert_eq!(sanitize_control_chars("\u{1b}[2J\u{1b}[H"), " [2J [H");
// Normal command lines are untouched
assert_eq!(sanitize_control_chars("/bin/sleep 60"), "/bin/sleep 60");
}
}
+38 -12
View File
@@ -604,18 +604,13 @@ impl View {
let len_pid = self.visible_pids.len();
for (i, pid) in self.visible_pids.iter().enumerate() {
let mut line = "{".to_string();
let len_column = self.columns.len();
for (j, c) in self.columns.iter().enumerate() {
if c.visible && c.kind != ConfigColumnKind::Separator {
let text = c.column.display_json(*pid);
line.push_str(&text);
if j != len_column - 1 {
line.push_str(", ");
}
}
}
line.push('}');
let fields: Vec<String> = self
.columns
.iter()
.filter(|c| c.visible && c.kind != ConfigColumnKind::Separator)
.map(|c| c.column.display_json(*pid))
.collect();
let mut line = json_object(&fields);
if i != len_pid - 1 {
line.push(',');
}
@@ -775,3 +770,34 @@ impl View {
current
}
}
/// Builds one JSON object row. Columns without a JSON representation
/// (e.g. Tree) yield an empty string and must not produce a separator.
fn json_object(fields: &[String]) -> String {
let fields: Vec<&str> = fields
.iter()
.map(String::as_str)
.filter(|x| !x.is_empty())
.collect();
format!("{{{}}}", fields.join(", "))
}
#[cfg(test)]
mod tests {
use super::json_object;
#[test]
fn json_object_skips_columns_without_json() {
assert_eq!(json_object(&[]), "{}");
assert_eq!(json_object(&[r#""PID": 1"#.into()]), r#"{"PID": 1}"#);
// a hidden (--only) or Tree column contributes nothing, not a comma
assert_eq!(
json_object(&["".into(), r#""PID": 1"#.into(), "".into()]),
r#"{"PID": 1}"#
);
assert_eq!(
json_object(&[r#""PID": 1"#.into(), r#""CPU": 0"#.into()]),
r#"{"PID": 1, "CPU": 0}"#
);
}
}