Skip to main content

solti_exec/subprocess/
backend.rs

1//! # Backend: OS/kernel subprocess hardening.
2//!
3//! [`SubprocessBackendConfig`] collects rlimits, cgroup v2, security, and logging settings applied to every subprocess spawned by a runner.
4
5use tokio::process::Command;
6use tracing::trace;
7
8use crate::ExecError::InvalidRunnerConfig;
9use crate::subprocess::logger::LogConfig;
10use crate::utils::{CgroupLimits, RlimitConfig, SecurityConfig};
11use crate::utils::{attach_cgroup, attach_rlimits, attach_security};
12
13/// Low-level OS/kernel configuration for subprocess execution.
14///
15/// Controls resource limits, security policies, and isolation mechanisms.
16/// All fields are optional — if not specified, the subprocess inherits parent process settings.
17///
18/// ## Also
19///
20/// - [`SubprocessRunner`](super::SubprocessRunner) runner that consumes this config.
21/// - [`RlimitConfig`](crate::utils::RlimitConfig) POSIX rlimit knobs.
22/// - [`CgroupLimits`](crate::utils::CgroupLimits) cgroup v2 knobs.
23/// - [`SecurityConfig`](crate::utils::SecurityConfig) capabilities / seccomp.
24/// - [`LogConfig`](super::LogConfig) stdout/stderr log settings.
25#[derive(Debug, Clone, Default)]
26pub struct SubprocessBackendConfig {
27    /// POSIX rlimit-based resource limits.
28    rlimits: Option<RlimitConfig>,
29    /// Linux cgroup v2 resource limits.
30    cgroups: Option<CgroupLimits>,
31    /// Security hardening.
32    security: Option<SecurityConfig>,
33    /// Subprocess output logging configuration.
34    logger: LogConfig,
35}
36
37impl SubprocessBackendConfig {
38    /// Create an empty backend config (no limits).
39    pub fn new() -> Self {
40        Self::default()
41    }
42
43    /// Set rlimits.
44    pub fn with_rlimits(mut self, rlimits: RlimitConfig) -> Self {
45        self.rlimits = Some(rlimits);
46        self
47    }
48
49    /// Set cgroup limits.
50    pub fn with_cgroups(mut self, cgroups: CgroupLimits) -> Self {
51        self.cgroups = Some(cgroups);
52        self
53    }
54
55    /// Set security hardening.
56    pub fn with_security(mut self, security: SecurityConfig) -> Self {
57        self.security = Some(security);
58        self
59    }
60
61    /// Set logger configuration.
62    pub fn with_logger(mut self, config: LogConfig) -> Self {
63        self.logger = config;
64        self
65    }
66
67    /// Get log configuration.
68    pub(crate) fn log_config(&self) -> &LogConfig {
69        &self.logger
70    }
71
72    /// Check if any backend features are configured.
73    pub(crate) fn is_empty(&self) -> bool {
74        self.rlimits.is_none() && self.cgroups.is_none() && self.security.is_none()
75    }
76
77    /// Validate the configuration.
78    pub(crate) fn validate(&self) -> Result<(), crate::ExecError> {
79        if let Some(cgroups) = &self.cgroups {
80            if let Some(cpu) = &cgroups.cpu {
81                if cpu.period == 0 {
82                    return Err(InvalidRunnerConfig(
83                        "cgroups.cpu.period cannot be zero".into(),
84                    ));
85                }
86                if let Some(q) = cpu.quota
87                    && q == 0
88                {
89                    return Err(InvalidRunnerConfig(
90                        "cgroups.cpu.quota cannot be zero (process would get no CPU)".into(),
91                    ));
92                }
93                if let Some(q) = cpu.quota
94                    && q > cpu.period
95                {
96                    return Err(InvalidRunnerConfig(
97                        "cgroups.cpu.quota exceeds period (>100% of one core)".into(),
98                    ));
99                }
100            }
101            if let Some(mem) = cgroups.memory
102                && mem == 0
103            {
104                return Err(InvalidRunnerConfig("cgroups.memory cannot be zero".into()));
105            }
106            if let Some(pids) = cgroups.pids
107                && pids == 0
108            {
109                return Err(InvalidRunnerConfig("cgroups.pids cannot be zero".into()));
110            }
111        }
112        if let Some(rlimits) = &self.rlimits
113            && let Some(fsize) = rlimits.max_file_size_bytes
114            && fsize == 0
115        {
116            return Err(InvalidRunnerConfig(
117                "rlimits.max_file_size_bytes cannot be zero".into(),
118            ));
119        }
120        if self.logger.max_line_length == 0 {
121            return Err(InvalidRunnerConfig(
122                "log_config.max_line_length cannot be zero".into(),
123            ));
124        }
125        Ok(())
126    }
127
128    /// Check if cgroup limits are configured.
129    pub(crate) fn has_cgroups(&self) -> bool {
130        self.cgroups.is_some()
131    }
132
133    /// Prepare cgroup directory and write limit files (before spawn).
134    ///
135    /// Must be called before `apply_to_command`. Returns `Ok(true)` if a cgroup was created successfully.
136    /// Runs in normal async context (safe to use std::fs).
137    pub(crate) fn prepare_cgroups(&self, cgroup_name: &str) -> Result<bool, crate::ExecError> {
138        if let Some(cgroups) = &self.cgroups {
139            trace!(
140                "subprocess backend: preparing cgroup: {:?} (group={})",
141                cgroups, cgroup_name
142            );
143            crate::utils::prepare_cgroup(cgroup_name, cgroups)
144        } else {
145            Ok(false)
146        }
147    }
148
149    /// Apply all configured backend features to a `tokio::process::Command`.
150    ///
151    /// This method mutates the command by attaching pre_exec hooks for:
152    /// - cgroups (join only — directory must be created via [`prepare_cgroups`] first)
153    /// - security policies
154    /// - rlimits
155    ///
156    /// Call this immediately before spawning the subprocess.
157    pub(crate) fn apply_to_command(
158        &self,
159        cmd: &mut Command,
160        cgroup_name: &str,
161    ) -> Result<(), crate::ExecError> {
162        if self.is_empty() {
163            trace!("subprocess backend: nothing to apply (empty config)");
164            return Ok(());
165        }
166
167        if let Some(rlimits) = &self.rlimits {
168            trace!("subprocess backend: attaching rlimits: {:?}", rlimits);
169            attach_rlimits(cmd, rlimits);
170        }
171        if let Some(cgroups) = &self.cgroups {
172            trace!(
173                "subprocess backend: attaching cgroup join hook (group={})",
174                cgroup_name
175            );
176            attach_cgroup(cmd, cgroup_name, cgroups)?;
177        }
178        if let Some(security) = &self.security {
179            trace!(
180                "subprocess backend: attaching security config: {:?}",
181                security
182            );
183            attach_security(cmd, security);
184        }
185        Ok(())
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use crate::utils::CpuMax;
193
194    #[test]
195    fn valid_cpu_config_passes() {
196        let cfg = SubprocessBackendConfig::new().with_cgroups(CgroupLimits {
197            cpu: Some(CpuMax {
198                quota: Some(50_000),
199                period: 100_000,
200            }),
201            ..Default::default()
202        });
203        assert!(cfg.validate().is_ok());
204    }
205
206    #[test]
207    fn cpu_period_zero_rejected() {
208        let cfg = SubprocessBackendConfig::new().with_cgroups(CgroupLimits {
209            cpu: Some(CpuMax {
210                quota: Some(50_000),
211                period: 0,
212            }),
213            ..Default::default()
214        });
215        let err = cfg.validate().unwrap_err().to_string();
216        assert!(err.contains("period"), "expected period error, got: {err}");
217    }
218
219    #[test]
220    fn cpu_quota_zero_rejected() {
221        let cfg = SubprocessBackendConfig::new().with_cgroups(CgroupLimits {
222            cpu: Some(CpuMax {
223                quota: Some(0),
224                period: 100_000,
225            }),
226            ..Default::default()
227        });
228        let err = cfg.validate().unwrap_err().to_string();
229        assert!(err.contains("quota"), "expected quota error, got: {err}");
230    }
231
232    #[test]
233    fn cpu_quota_exceeds_period_rejected() {
234        let cfg = SubprocessBackendConfig::new().with_cgroups(CgroupLimits {
235            cpu: Some(CpuMax {
236                quota: Some(200_000),
237                period: 100_000,
238            }),
239            ..Default::default()
240        });
241        let err = cfg.validate().unwrap_err().to_string();
242        assert!(
243            err.contains("exceeds period"),
244            "expected exceeds error, got: {err}"
245        );
246    }
247
248    #[test]
249    fn cpu_unlimited_quota_passes() {
250        let cfg = SubprocessBackendConfig::new().with_cgroups(CgroupLimits {
251            cpu: Some(CpuMax {
252                quota: None,
253                period: 100_000,
254            }),
255            ..Default::default()
256        });
257        assert!(cfg.validate().is_ok());
258    }
259}