arkhe_forge_platform/process_protection/linux.rs
1//! Linux process protection — `mlockall` + `PR_SET_DUMPABLE` +
2//! `PR_SET_PTRACER` with an advisory `yama.ptrace_scope` probe.
3//!
4//! This file opts into `unsafe_code` scoped to the libc FFI calls; every
5//! `unsafe { ... }` block carries a SAFETY note that justifies the call
6//! arguments.
7
8#![allow(unsafe_code)]
9
10use super::{ProcessProtection, ProtectionError};
11
12/// Linux impl.
13pub struct LinuxProcessProtection;
14
15/// Read `errno` after a failing libc call.
16///
17/// SAFETY: `libc::__errno_location` returns a pointer to a thread-local
18/// `int` owned by the libc runtime; dereferencing it is always defined for
19/// the current thread.
20fn last_errno() -> i32 {
21 unsafe { *libc::__errno_location() }
22}
23
24impl ProcessProtection for LinuxProcessProtection {
25 fn lock_memory(&self) -> Result<(), ProtectionError> {
26 // SAFETY: `mlockall` takes a bitmask of integer constants and has no
27 // pointer arguments. `MCL_CURRENT | MCL_FUTURE` is a valid flag set
28 // per `man 2 mlockall`.
29 let rc = unsafe { libc::mlockall(libc::MCL_CURRENT | libc::MCL_FUTURE) };
30 if rc == 0 {
31 Ok(())
32 } else {
33 Err(ProtectionError::SyscallFailed {
34 op: "mlockall",
35 code: last_errno(),
36 })
37 }
38 }
39
40 fn disable_core_dump(&self) -> Result<(), ProtectionError> {
41 // SAFETY: `prctl(PR_SET_DUMPABLE, 0, ...)` takes `c_ulong` integer
42 // arguments; the fifth `0` fills the unused slot per `man 2 prctl`.
43 let rc = unsafe {
44 libc::prctl(
45 libc::PR_SET_DUMPABLE,
46 0 as libc::c_ulong,
47 0 as libc::c_ulong,
48 0 as libc::c_ulong,
49 0 as libc::c_ulong,
50 )
51 };
52 if rc == 0 {
53 Ok(())
54 } else {
55 Err(ProtectionError::SyscallFailed {
56 op: "prctl(PR_SET_DUMPABLE)",
57 code: last_errno(),
58 })
59 }
60 }
61
62 fn disable_ptrace(&self) -> Result<(), ProtectionError> {
63 // First: detect whether a tracer is **already attached** via
64 // `/proc/self/status` `TracerPid`. If non-zero we surface a
65 // hard error so callers cannot misread `.is_ok()` as "no
66 // debugger" — `disable_ptrace` would otherwise return Ok
67 // even with an attacker process holding ptrace control.
68 if let Ok(status) = std::fs::read_to_string("/proc/self/status") {
69 for line in status.lines() {
70 if let Some(rest) = line.strip_prefix("TracerPid:") {
71 if rest.trim() != "0" {
72 return Err(ProtectionError::DebuggerAttached(
73 "TracerPid != 0 in /proc/self/status",
74 ));
75 }
76 break;
77 }
78 }
79 }
80
81 // SAFETY: `prctl(PR_SET_PTRACER, 0, ...)` sets the process-wide
82 // ptracer pid to 0 (= no process may ptrace-attach). Pure integer
83 // call, no pointers.
84 let rc = unsafe {
85 libc::prctl(
86 libc::PR_SET_PTRACER,
87 0 as libc::c_ulong,
88 0 as libc::c_ulong,
89 0 as libc::c_ulong,
90 0 as libc::c_ulong,
91 )
92 };
93 if rc != 0 {
94 return Err(ProtectionError::SyscallFailed {
95 op: "prctl(PR_SET_PTRACER)",
96 code: last_errno(),
97 });
98 }
99
100 // Advisory: `/proc/sys/kernel/yama/ptrace_scope` is a system-wide
101 // knob. Anything other than `2` (admin-only) means some ptracer
102 // parent could still attach. We surface a stderr warning but do not
103 // reject — a per-process API cannot enforce a system-wide setting.
104 // (Distinct from the TracerPid check above, which is a present-
105 // tense attach detection rather than a system policy advisory.)
106 match std::fs::read_to_string("/proc/sys/kernel/yama/ptrace_scope") {
107 Ok(content) => {
108 let scope = content.trim();
109 if scope != "2" {
110 eprintln!(
111 "arkhe-forge-platform: yama.ptrace_scope={scope} (want 2); \
112 per-process ptrace protection cannot cover this gap."
113 );
114 }
115 }
116 Err(_) => {
117 // Yama LSM not active on this kernel — advisory only.
118 eprintln!(
119 "arkhe-forge-platform: /proc/sys/kernel/yama/ptrace_scope unreadable; \
120 yama LSM likely disabled."
121 );
122 }
123 }
124
125 Ok(())
126 }
127}
128
129#[cfg(test)]
130#[allow(clippy::panic, clippy::unwrap_used)]
131mod tests {
132 use super::*;
133
134 #[test]
135 fn apply_all_returns_ok_or_specific_error() {
136 let proto = LinuxProcessProtection;
137 match proto.apply_all() {
138 Ok(()) => {}
139 Err(ProtectionError::SyscallFailed { op, code }) => {
140 // Container / unprivileged user environments may produce
141 // EPERM/ENOMEM — returning a specific error is itself
142 // evidence the implementation is complete.
143 eprintln!("apply_all reported SyscallFailed op={op} code={code}");
144 }
145 Err(ProtectionError::DebuggerAttached(reason)) => {
146 // m6 — running under cargo-test with `lldb` / `rr` etc.
147 // would surface this. Test ack only; CI normally won't.
148 eprintln!("apply_all reported DebuggerAttached: {reason}");
149 }
150 Err(other) => panic!("unexpected error variant: {other:?}"),
151 }
152 }
153
154 #[test]
155 fn disable_core_dump_either_succeeds_or_reports_errno() {
156 let proto = LinuxProcessProtection;
157 match proto.disable_core_dump() {
158 Ok(()) => {}
159 Err(ProtectionError::SyscallFailed { op, code }) => {
160 assert_eq!(op, "prctl(PR_SET_DUMPABLE)");
161 assert!(code != 0, "errno should be non-zero on failure");
162 }
163 Err(other) => panic!("unexpected error variant: {other:?}"),
164 }
165 }
166
167 #[test]
168 fn disable_ptrace_signals_attach_explicitly() {
169 // m6 — disable_ptrace must surface `DebuggerAttached` rather
170 // than silently returning Ok when a tracer is present.
171 // Outside a debugger the call returns Ok; the variant exists
172 // so callers can fail-close on attach.
173 let proto = LinuxProcessProtection;
174 match proto.disable_ptrace() {
175 Ok(()) => {}
176 Err(ProtectionError::DebuggerAttached(_)) => {}
177 Err(ProtectionError::SyscallFailed { op, .. }) => {
178 assert_eq!(op, "prctl(PR_SET_PTRACER)");
179 }
180 Err(other) => panic!("unexpected error variant: {other:?}"),
181 }
182 }
183}