Skip to main content

what_stack/
process.rs

1//! Process-name based stack detection.
2//!
3//! Process detection is deliberately exact: `node` maps to `Node.js`, while
4//! unrelated names such as `node-exporter` do not. Two narrow relaxations
5//! cover real process tables: runtime version suffixes (`python3.12`,
6//! `php-fpm8.2`) and titled processes truncated by Linux (`next-server (v1`,
7//! `gunicorn: maste`).
8
9use crate::ecosystem::Ecosystem as E;
10use crate::text::strip_suffix_ignore_ascii_case;
11use crate::{StackKind, StackLabel, labels};
12
13/// One process rule: every executable name of one label, the label, and the
14/// ecosystem whose project config may refine the label.
15pub type ProcessRule = (&'static [&'static str], StackLabel, E);
16
17/// Known process names mapped to stack labels, one row per label.
18///
19/// Linear scan with `eq_ignore_ascii_case` avoids allocating a lowercase
20/// `String` on every lookup.
21pub const PROCESS_RULES: &[ProcessRule] = &[
22    (&["node", "nodejs"], labels::NODE, E::Node),
23    (&["python", "python3", "pythonw"], labels::PYTHON, E::Python),
24    (&["ruby"], labels::RUBY, E::Ruby),
25    (&["java", "javaw"], labels::JAVA, E::Jvm),
26    (&["go"], labels::GO, E::Go),
27    (&["deno"], labels::DENO, E::Deno),
28    (&["bun"], labels::BUN, E::Node),
29    (&["dotnet"], labels::DOTNET, E::DotNet),
30    (&["php", "php-fpm"], labels::PHP, E::Php),
31    (&["perl"], labels::PERL, E::Other),
32    (&["cargo", "rustc"], labels::RUST, E::Rust),
33    // On Windows the BEAM runs as `erl.exe` or `werl.exe`.
34    (
35        &["erlang", "beam.smp", "erl", "werl"],
36        labels::ERLANG,
37        E::Beam,
38    ),
39    (&["elixir"], labels::ELIXIR, E::Beam),
40    (&["dart"], labels::DART, E::Other),
41    (&["swift"], labels::SWIFT, E::Other),
42    (&["postgres", "postgresql"], labels::POSTGRESQL, E::Other),
43    (&["mysqld", "mysql"], labels::MYSQL, E::Other),
44    (&["mariadbd", "mariadb"], labels::MARIADB, E::Other),
45    (&["mongod", "mongos"], labels::MONGODB, E::Other),
46    (&["redis-server", "redis"], labels::REDIS, E::Other),
47    (&["valkey-server", "valkey"], labels::VALKEY, E::Other),
48    (&["memcached"], labels::MEMCACHED, E::Other),
49    (&["clickhouse-server"], labels::CLICKHOUSE, E::Other),
50    (&["cockroach"], labels::COCKROACHDB, E::Other),
51    (&["sqlservr"], labels::SQL_SERVER, E::Other),
52    (&["nginx"], labels::NGINX, E::Other),
53    (&["apache2", "httpd"], labels::APACHE, E::Other),
54    (&["caddy"], labels::CADDY, E::Other),
55    (&["traefik"], labels::TRAEFIK, E::Other),
56    (&["envoy"], labels::ENVOY, E::Other),
57    (&["haproxy"], labels::HAPROXY, E::Other),
58    (&["w3wp"], labels::IIS, E::Other),
59    (&["gunicorn"], labels::GUNICORN, E::Python),
60    (&["uvicorn"], labels::UVICORN, E::Python),
61    (&["puma"], labels::PUMA, E::Ruby),
62    (&["elasticsearch"], labels::ELASTICSEARCH, E::Other),
63    (&["opensearch"], labels::OPENSEARCH, E::Other),
64    (&["rabbitmq-server"], labels::RABBITMQ, E::Other),
65    (&["kafka"], labels::KAFKA, E::Other),
66    (&["webpack"], labels::WEBPACK, E::Node),
67    (&["vite"], labels::VITE, E::Node),
68    (&["next-server"], labels::NEXT_JS, E::Node),
69    (&["nuxt"], labels::NUXT, E::Node),
70    (&["hugo"], labels::HUGO, E::Other),
71    (&["jekyll"], labels::JEKYLL, E::Ruby),
72    (&["flask"], labels::FLASK, E::Python),
73    (&["rails"], labels::RAILS, E::Ruby),
74    (&["gradle"], labels::JAVA_GRADLE, E::Jvm),
75    (&["mvn"], labels::JAVA_MAVEN, E::Jvm),
76];
77
78/// Detect a stack label from a process executable name.
79///
80/// Matching is ASCII case-insensitive. A trailing Windows `.exe` suffix is
81/// ignored before matching, so `NGINX.EXE` and `nginx` produce the same label.
82///
83/// Two relaxations apply when the exact name is unknown:
84///
85/// - A trailing version made of digits and dots is ignored for runtime names,
86///   so `python3.12`, `php8.2`, `php-fpm8.2`, `ruby3.2`, and `node20` match.
87/// - Processes that set a title which Linux truncates to 15 characters match
88///   on the word before the first space or colon: `next-server (v1` is
89///   `Next.js`, `puma 6.4.2 (tc` and `puma: cluster w` are `Puma`, and
90///   `gunicorn: maste` is `Gunicorn`.
91///
92/// # Examples
93///
94/// ```
95/// use what_stack::detect_from_process;
96///
97/// use what_stack::StackKind;
98///
99/// let python = detect_from_process("python3").expect("known runtime");
100/// assert_eq!(python, "Python");
101/// assert_eq!(python.kind(), StackKind::Runtime);
102/// assert_eq!(detect_from_process("python3.12").expect("versioned runtime"), "Python");
103/// assert_eq!(detect_from_process("next-server (v1").expect("titled"), "Next.js");
104/// assert_eq!(detect_from_process("gunicorn: maste").expect("titled"), "Gunicorn");
105/// assert_eq!(detect_from_process("NGINX.EXE").expect("known server"), "Nginx");
106/// assert_eq!(detect_from_process("node-exporter"), None);
107/// ```
108///
109/// # Limits
110///
111/// This function does not inspect command-line arguments or project files.
112/// Use [`StackDetector`](crate::StackDetector) when process names should be
113/// combined with project config and image metadata.
114#[must_use]
115pub fn detect_from_process(process_name: &str) -> Option<StackLabel> {
116    find_process_rule(process_name).map(|(_, label, _)| label.clone())
117}
118
119/// Detect a stack label from a process name, falling back to an executable name.
120///
121/// The process-table name is tried first. When it is unknown (for example
122/// because the operating system truncated it), `exe_name` is tried with the
123/// same rules as [`detect_from_process`].
124///
125/// # Examples
126///
127/// ```
128/// use what_stack::detect_from_process_names;
129///
130/// assert_eq!(
131///     detect_from_process_names("redis-serv", Some("redis-server")).expect("known"),
132///     "Redis",
133/// );
134/// assert_eq!(detect_from_process_names("node", None).expect("known"), "Node.js");
135/// assert_eq!(detect_from_process_names("helper", Some("helper.exe")), None);
136/// ```
137#[must_use]
138pub fn detect_from_process_names(process_name: &str, exe_name: Option<&str>) -> Option<StackLabel> {
139    find_process_rule_by_names(process_name, exe_name).map(|(_, label, _)| label.clone())
140}
141
142/// Process names whose processes set a title that starts with the name and a
143/// space or colon (`puma 6.4.2 (tcp://...)`, `gunicorn: master [app]` from
144/// `setproctitle`). Linux truncates the title to 15 characters in the process
145/// table.
146const TITLED_PROCESSES: &[&str] = &["next-server", "puma", "gunicorn"];
147
148/// Same lookup as [`detect_from_process_names`], returning the whole rule.
149pub fn find_process_rule_by_names(
150    process_name: &str,
151    exe_name: Option<&str>,
152) -> Option<&'static ProcessRule> {
153    find_process_rule(process_name).or_else(|| exe_name.and_then(find_process_rule))
154}
155
156fn find_process_rule(process_name: &str) -> Option<&'static ProcessRule> {
157    let name = strip_windows_exe_suffix(process_name);
158
159    find_exact(name)
160        .or_else(|| find_titled(name))
161        .or_else(|| find_versioned_runtime(name))
162}
163
164fn find_exact(name: &str) -> Option<&'static ProcessRule> {
165    PROCESS_RULES
166        .iter()
167        .find(|(names, _, _)| names.iter().any(|key| name.eq_ignore_ascii_case(key)))
168}
169
170fn find_titled(name: &str) -> Option<&'static ProcessRule> {
171    let (head, _) = name.split_once([' ', ':'])?;
172    TITLED_PROCESSES
173        .iter()
174        .any(|titled| head.eq_ignore_ascii_case(titled))
175        .then(|| find_exact(head))
176        .flatten()
177}
178
179/// Match `python3.12` as `python`. Only runtime rules accept a version suffix,
180/// and the suffix must start with a digit.
181fn find_versioned_runtime(name: &str) -> Option<&'static ProcessRule> {
182    let base = name.trim_end_matches(|c: char| c.is_ascii_digit() || c == '.');
183    let version = name.get(base.len()..)?;
184
185    if base.is_empty() || !version.starts_with(|c: char| c.is_ascii_digit()) {
186        return None;
187    }
188
189    find_exact(base).filter(|(_, label, _)| label.kind() == StackKind::Runtime)
190}
191
192fn strip_windows_exe_suffix(process_name: &str) -> &str {
193    strip_suffix_ignore_ascii_case(process_name, ".exe").unwrap_or(process_name)
194}