solti_exec/utils/limits.rs
1//! # Limits: POSIX rlimit-based resource limits for subprocess runners.
2//!
3//! [`RlimitConfig`] applies classic POSIX process limits to child processes spawned by subprocess runners.
4//!
5//! **Unix:**
6//! - Limits are applied inside a `pre_exec` hook (between `fork()` and `execve()`)
7//! - Each limit uses `getrlimit` → clamp → `setrlimit` (two syscalls)
8//! - Hard limit is never touched: only the soft limit is lowered/raised within bounds
9//! - Zero heap allocation in the child (closure captures only `Copy` types)
10//!
11//! **Other platforms:** `tracing::warn` and no-op.
12//!
13//! ## Also
14//!
15//! - [`SubprocessBackendConfig`](crate::subprocess::SubprocessBackendConfig) builder that consumes `RlimitConfig`.
16//! - [`CgroupLimits`](super::CgroupLimits) complementary cgroup v2 limits.
17//!
18//! ## What happens when a subprocess spawns
19//! ```text
20//! parent process
21//! │
22//! fork()
23//! │
24//! ┌──────────────────┼───────────────────┐
25//! │ child process │
26//! │ │
27//! │ ┌── pre_exec hook ───────────────┐ │
28//! │ │ for each configured limit: │ │
29//! │ │ 1. getrlimit(resource) │ │
30//! │ │ 2. clamp value ≤ hard limit │ │
31//! │ │ 3. setrlimit(soft, hard) │ │
32//! │ └────────────────────────────────┘ │
33//! │ │
34//! │ execve("echo", ["hello"]) │
35//! │ (runs with restricted limits) │
36//! └──────────────────────────────────────┘
37//! ```
38//!
39//! ## How attach_rlimits works
40//! ```text
41//! attach_rlimits(&mut cmd, &config)
42//! ├──► config.is_empty()? → return early, no hook
43//! │
44//! ├──► Unix:
45//! │ └──► install pre_exec closure on Command
46//! │ └──► captures: 2 × Option<u64> + 1 × bool (all Copy)
47//! │
48//! │ pre_exec:
49//! │ ├──► max_open_files → apply_rlimit(NOFILE, value)
50//! │ ├──► max_file_size_bytes → apply_rlimit(FSIZE, value)
51//! │ └──► disable_core_dumps → apply_rlimit(CORE, 0)
52//! │
53//! └──► non-Unix:
54//! └──► warn!("rlimits ignored on {os}")
55//! ```
56//!
57//! ## apply_rlimit: soft limit clamping
58//! ```text
59//! apply_rlimit(resource, requested_value)
60//! │
61//! ├──► getrlimit() → read current { soft, hard }
62//! │
63//! ├──► hard == INFINITY?
64//! │ └──► new_soft = requested (no ceiling)
65//! │
66//! ├──► requested > hard?
67//! │ └──► new_soft = hard (clamp — can't exceed hard without root)
68//! │
69//! ├──► requested ≤ hard?
70//! │ └──► new_soft = requested
71//! │
72//! └──► setrlimit(new_soft, hard) ← hard is NEVER modified
73//! ```
74//!
75//! ## Configuration
76//!
77//! | Field | Resource | If it fails |
78//! |------------------------|-----------------|------------------|
79//! | `max_open_files` | `RLIMIT_NOFILE` | **aborts spawn** |
80//! | `max_file_size_bytes` | `RLIMIT_FSIZE` | **aborts spawn** |
81//! | `disable_core_dumps` | `RLIMIT_CORE` | **aborts spawn** |
82//!
83//! ## Async-signal safety
84//!
85//! Everything inside the `pre_exec` closure runs **between `fork()` and `execve()`**.
86//!
87//! | What we call | Why it's safe |
88//! |------------------------------|--------------------------------------------|
89//! | `getrlimit()` / `setrlimit()`| direct syscalls |
90//! | `libc::write(STDERR)` | async-signal-safe per POSIX |
91//! | `io::Error::last_os_error()` | reads `errno`, no heap (Rust ≥ 1.74) |
92//!
93//! The closure captures **only `Copy` types** (2 × `Option<u64>` + 1 × `bool`).
94//!
95//! ## Rules
96//! - Requested value exceeding hard limit is **silently clamped** (not an error)
97//! - Non-Unix: all knobs are no-op, warning emitted via `tracing::warn`
98//! - All rlimit failures are **fatal** (return `Err`, aborting spawn)
99//! - Hard limit is **never modified** - only the soft limit changes
100//! - `RlimitConfig::is_empty()` → no hook installed, zero overhead
101use tokio::process::Command;
102
103#[cfg(not(unix))]
104use tracing::warn;
105
106/// Declarative rlimit-based config.
107#[derive(Debug, Clone, Default)]
108pub struct RlimitConfig {
109 /// Maximum number of open file descriptors (`RLIMIT_NOFILE`).
110 ///
111 /// Typical values:
112 /// - `Some(1024)` for "normal" processes
113 /// - `Some(4096)`/`8192` for IO-heavy tasks
114 /// - `None` leaves the OS / parent limits unchanged.
115 pub max_open_files: Option<u64>,
116 /// Maximum size of created files in bytes (`RLIMIT_FSIZE`).
117 ///
118 /// When the process attempts to grow a file beyond this limit, the kernel typically delivers `SIGXFSZ` and the process terminates.
119 /// `None` leaves the OS / parent limits unchanged.
120 pub max_file_size_bytes: Option<u64>,
121 /// Disable core dumps (`RLIMIT_CORE = 0`) when set to `true`.
122 ///
123 /// This prevents large core files from being written for failing tasks.
124 /// When `false`, the OS default / inherited core limit is preserved.
125 pub disable_core_dumps: bool,
126}
127
128impl RlimitConfig {
129 /// Returns `true` if no explicit limits are configured.
130 #[inline]
131 pub fn is_empty(&self) -> bool {
132 self.max_open_files.is_none()
133 && self.max_file_size_bytes.is_none()
134 && !self.disable_core_dumps
135 }
136}
137
138/// Attach `rlimit`-based process limits to a `tokio::process::Command`.
139pub fn attach_rlimits(cmd: &mut Command, config: &RlimitConfig) {
140 if config.is_empty() {
141 return;
142 }
143
144 #[cfg(unix)]
145 {
146 unix_impl::attach_rlimits(cmd, config);
147 }
148 #[cfg(not(unix))]
149 {
150 warn!(
151 ?config,
152 "rlimit-based process limits requested on a non-Unix OS; limits will be ignored"
153 );
154 }
155}
156
157#[cfg(unix)]
158mod unix_impl {
159 use super::RlimitConfig;
160 use crate::utils::log::{pre_exec_log, pre_exec_log_errno};
161
162 use std::io;
163
164 use tokio::process::Command;
165
166 /// Caller (`attach_rlimits`) already checked `!config.is_empty()`.
167 pub fn attach_rlimits(cmd: &mut Command, config: &RlimitConfig) {
168 let max_file_size_bytes = config.max_file_size_bytes;
169 let disable_core_dumps = config.disable_core_dumps;
170 let max_open_files = config.max_open_files;
171
172 // SAFETY:
173 // The pre_exec closure runs between fork() and execve() in the child process.
174 // It only calls setrlimit/getrlimit (async-signal-safe syscalls) and pre_exec_log (raw libc::write to stderr).
175 // Error paths use io::Error::last_os_error() which stores errno inline without heap allocation (Rust >= 1.74).
176 unsafe {
177 cmd.pre_exec(move || {
178 if let Some(nofile) = max_open_files
179 && let Err(e) = apply_rlimit(NOFILE, nofile)
180 {
181 pre_exec_log(b"solti-exec: failed to set RLIMIT_NOFILE: ");
182 if let Some(code) = e.raw_os_error() {
183 pre_exec_log_errno(code);
184 }
185 return Err(e);
186 }
187 if let Some(fsize) = max_file_size_bytes
188 && let Err(e) = apply_rlimit(FSIZE, fsize)
189 {
190 pre_exec_log(b"solti-exec: failed to set RLIMIT_FSIZE: ");
191 if let Some(code) = e.raw_os_error() {
192 pre_exec_log_errno(code);
193 }
194 return Err(e);
195 }
196 if disable_core_dumps && let Err(e) = apply_rlimit(CORE, 0) {
197 pre_exec_log(b"solti-exec: failed to set RLIMIT_CORE: ");
198 if let Some(code) = e.raw_os_error() {
199 pre_exec_log_errno(code);
200 }
201 return Err(e);
202 }
203 Ok(())
204 });
205 }
206 }
207
208 /// Resource type accepted by `getrlimit`/`setrlimit`.
209 ///
210 /// On Linux/Android it's `__rlimit_resource_t` (enum), elsewhere `c_int`.
211 #[cfg(any(target_os = "linux", target_os = "android"))]
212 type RlimitResource = libc::__rlimit_resource_t;
213 #[cfg(not(any(target_os = "linux", target_os = "android")))]
214 type RlimitResource = libc::c_int;
215
216 const NOFILE: RlimitResource = libc::RLIMIT_NOFILE as RlimitResource;
217 const FSIZE: RlimitResource = libc::RLIMIT_FSIZE as RlimitResource;
218 const CORE: RlimitResource = libc::RLIMIT_CORE as RlimitResource;
219
220 /// Apply rlimit: set the soft limit to `value`, keep the hard limit unchanged.
221 ///
222 /// If `value` exceeds the current hard limit (and hard != INFINITY), the soft limit is clamped to the hard limit
223 /// an unprivileged process cannot raise its own hard limit.
224 fn apply_rlimit(resource: RlimitResource, value: u64) -> io::Result<()> {
225 let mut current = libc::rlimit {
226 rlim_cur: 0,
227 rlim_max: 0,
228 };
229
230 // SAFETY:
231 // `current` is a valid stack-local rlimit struct, passed by pointer.
232 if unsafe { libc::getrlimit(resource, &mut current) } != 0 {
233 return Err(io::Error::last_os_error());
234 }
235
236 let requested = value as libc::rlim_t;
237
238 // Clamp to hard limit: unprivileged processes cannot raise it.
239 let new_soft = if current.rlim_max == libc::RLIM_INFINITY {
240 requested
241 } else if requested > current.rlim_max {
242 current.rlim_max
243 } else {
244 requested
245 };
246
247 let rlim = libc::rlimit {
248 rlim_cur: new_soft,
249 rlim_max: current.rlim_max,
250 };
251
252 // SAFETY:
253 // `rlim` is a valid stack-local rlimit struct, passed by pointer.
254 if unsafe { libc::setrlimit(resource, &rlim) } != 0 {
255 Err(io::Error::last_os_error())
256 } else {
257 Ok(())
258 }
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 #[test]
267 fn empty_config_is_noop() {
268 let config = RlimitConfig::default();
269 assert!(config.is_empty());
270
271 let mut cmd = Command::new("sh");
272 attach_rlimits(&mut cmd, &config);
273 }
274
275 #[cfg(unix)]
276 #[test]
277 fn non_empty_config_attaches_pre_exec_hook() {
278 let config = RlimitConfig {
279 max_open_files: Some(1024),
280 max_file_size_bytes: Some(10 * 1024 * 1024),
281 disable_core_dumps: true,
282 };
283
284 let mut cmd = Command::new("sh");
285 attach_rlimits(&mut cmd, &config);
286 }
287
288 #[cfg(not(unix))]
289 #[test]
290 fn non_empty_config_is_ignored_on_non_unix() {
291 let config = RlimitConfig {
292 max_open_files: Some(512),
293 max_file_size_bytes: None,
294 disable_core_dumps: true,
295 };
296
297 let mut cmd = Command::new("sh");
298 attach_rlimits(&mut cmd, &config);
299 }
300
301 #[cfg(unix)]
302 #[tokio::test]
303 async fn rlimits_can_be_applied() {
304 let config = RlimitConfig {
305 max_open_files: Some(512),
306 max_file_size_bytes: Some(1024 * 1024),
307 disable_core_dumps: true,
308 };
309
310 let mut cmd = Command::new("sh");
311 cmd.arg("-c").arg("ulimit -a");
312 attach_rlimits(&mut cmd, &config);
313
314 let result = cmd.status().await;
315 assert!(result.is_ok(), "rlimits should be applied successfully");
316 assert!(result.unwrap().success());
317 }
318}