Skip to main content

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}