ktstr/flock/
holder.rs

1//! [`super::HolderInfo`] construction + rendering for diagnostics.
2//!
3//! Two roles:
4//!
5//!  - [`holder_info_for_pid`] — read `/proc/{pid}/cmdline`, shape it
6//!    for a [`super::HolderInfo`] (lossy UTF-8, `\0 → space`, truncate
7//!    to [`CMDLINE_MAX_CHARS`] with `…` suffix). Missing /
8//!    permission-denied / racing /proc entries — and an empty
9//!    cmdline (kernel threads) — fall back to
10//!    `"<cmdline unavailable>"` so the PID still surfaces.
11//!  - [`format_holder_list`] — render a `&[HolderInfo]` for inclusion
12//!    in an operator-facing error string. Empty list yields the
13//!    [`NO_HOLDERS_RECORDED`] sentinel; non-empty renders one
14//!    `pid={pid} cmd={cmdline}` line per holder, newline-separated
15//!    and two-space-indented.
16
17use super::HolderInfo;
18
19/// Cmdline truncation limit: 100 chars, so a rendered holder line
20/// stays single-line. Referenced by `HolderInfo`'s doc in
21/// [`super::HolderInfo`].
22const CMDLINE_MAX_CHARS: usize = 100;
23
24/// Diagnostic text for lock-holder error messages when /proc/locks
25/// lists no PID against the lockfile inode. Centralized so every
26/// caller renders the empty-holders case with the same string.
27/// Non-empty so log-scrapers can key on it without accidentally
28/// matching a blank field.
29pub(crate) const NO_HOLDERS_RECORDED: &str = "<none recorded>";
30
31/// Read and shape `/proc/{pid}/cmdline` for a [`HolderInfo`].
32/// `\0` → ` `, lossy UTF-8, truncated to [`CMDLINE_MAX_CHARS`] with
33/// `…` suffix on overflow. Missing / racing / permission-denied on
34/// `/proc/{pid}/cmdline` — and a successfully-read but empty cmdline
35/// (kernel threads) — produce `"<cmdline unavailable>"`; the pid
36/// still carries diagnostic value even without the command.
37pub(super) fn holder_info_for_pid(pid: u32) -> HolderInfo {
38    let raw = match std::fs::read(format!("/proc/{pid}/cmdline")) {
39        Ok(bytes) => bytes,
40        Err(_) => {
41            return HolderInfo {
42                pid,
43                cmdline: "<cmdline unavailable>".to_string(),
44            };
45        }
46    };
47    // Kernel writes argv joined with \0 and terminated by \0. Lossy
48    // decode handles non-UTF-8 argv bytes (rare — most binaries use
49    // UTF-8 args, but the kernel does not enforce it).
50    let text: String = String::from_utf8_lossy(&raw)
51        .chars()
52        .map(|c| if c == '\0' { ' ' } else { c })
53        .collect::<String>()
54        .trim_end()
55        .to_string();
56    let truncated = if text.chars().count() > CMDLINE_MAX_CHARS {
57        let head: String = text.chars().take(CMDLINE_MAX_CHARS).collect();
58        format!("{head}…")
59    } else if text.is_empty() {
60        "<cmdline unavailable>".to_string()
61    } else {
62        text
63    };
64    HolderInfo {
65        pid,
66        cmdline: truncated,
67    }
68}
69
70/// Format a [`HolderInfo`] slice for inclusion in user-facing error
71/// strings. Empty slice yields the `NO_HOLDERS_RECORDED` sentinel so the
72/// diagnostic is unambiguous — a stale lockfile whose holder has
73/// exited presents as empty, and the error should say so rather than
74/// print a misleading blank. Non-empty renders one
75/// `pid={pid} cmd={cmdline}` line per holder, newline-separated and
76/// indented two spaces, so a multi-holder error stays readable when
77/// embedded in a wrapping anyhow chain; the prior comma-joined form
78/// ran every holder into a single wide line that terminals wrapped
79/// arbitrarily mid-cmdline.
80pub fn format_holder_list(holders: &[HolderInfo]) -> String {
81    if holders.is_empty() {
82        NO_HOLDERS_RECORDED.to_string()
83    } else {
84        holders
85            .iter()
86            .map(|h| format!("  pid={} cmd={}", h.pid, h.cmdline))
87            .collect::<Vec<_>>()
88            .join("\n")
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    // ---------------------------------------------------------------
97    // format_holder_list — rendering contract
98    // ---------------------------------------------------------------
99
100    /// Empty slice yields the sentinel [`NO_HOLDERS_RECORDED`] so
101    /// log-scrapers have a stable key.
102    #[test]
103    fn format_holder_list_empty_yields_sentinel() {
104        assert_eq!(format_holder_list(&[]), NO_HOLDERS_RECORDED);
105    }
106
107    /// Single holder renders with the `  pid={pid} cmd={cmdline}`
108    /// shape. Two-space indent is load-bearing — a future revert
109    /// to comma-join would break terminal rendering on
110    /// multi-holder lockfiles.
111    #[test]
112    fn format_holder_list_single_holder() {
113        let holders = [HolderInfo {
114            pid: 12345,
115            cmdline: "cargo build".to_string(),
116        }];
117        assert_eq!(format_holder_list(&holders), "  pid=12345 cmd=cargo build");
118    }
119
120    /// Multiple holders newline-separated (not comma-joined). The
121    /// previous shape was `", "` — this test pins the newline.
122    #[test]
123    fn format_holder_list_multiple_newline_separated() {
124        let holders = [
125            HolderInfo {
126                pid: 1,
127                cmdline: "a".to_string(),
128            },
129            HolderInfo {
130                pid: 2,
131                cmdline: "b".to_string(),
132            },
133        ];
134        let out = format_holder_list(&holders);
135        assert!(out.contains("\n"), "must contain newline: {out}");
136        assert!(!out.contains(", "), "must NOT contain comma-space: {out}");
137        assert_eq!(out, "  pid=1 cmd=a\n  pid=2 cmd=b");
138    }
139
140    /// [`HolderInfo`] serializes with `pid` and `cmdline` as
141    /// snake_case keys — stable JSON contract for `ktstr locks
142    /// --json` downstream consumers. A future refactor that
143    /// rename_all = "camelCase" (or drops the derive) would
144    /// silently break shell-script consumers that `jq .[].pid`;
145    /// this test pins the key names so that regression fails the
146    /// build.
147    #[test]
148    fn holder_info_json_keys_are_snake_case() {
149        let holder = HolderInfo {
150            pid: 123,
151            cmdline: "bash".to_string(),
152        };
153        let val = serde_json::to_value(&holder).expect("serialize");
154        // Pin both keys exist + have expected types.
155        assert_eq!(val["pid"], serde_json::json!(123));
156        assert_eq!(val["cmdline"], serde_json::json!("bash"));
157        // Negative: no camelCase variants slipped in.
158        assert!(
159            val.get("cmdLine").is_none(),
160            "camelCase cmdLine must not appear: {val}",
161        );
162    }
163}