1use crate::errors::{Result, SandboxError};
4use nix::unistd::Pid;
5use std::fs;
6use std::io::Write;
7use std::path::{Path, PathBuf};
8
9const CGROUP_V2_ROOT: &str = "/sys/fs/cgroup";
10
11#[derive(Debug, Clone, Default)]
13pub struct CgroupConfig {
14 pub memory_limit: Option<u64>,
16 pub cpu_weight: Option<u32>,
18 pub cpu_quota: Option<u64>,
20 pub cpu_period: Option<u64>,
22 pub max_pids: Option<u32>,
24}
25
26impl CgroupConfig {
27 pub fn with_memory(limit: u64) -> Self {
29 Self {
30 memory_limit: Some(limit),
31 ..Default::default()
32 }
33 }
34
35 pub fn with_cpu_quota(quota: u64, period: u64) -> Self {
37 Self {
38 cpu_quota: Some(quota),
39 cpu_period: Some(period),
40 ..Default::default()
41 }
42 }
43
44 pub fn validate(&self) -> Result<()> {
46 if let Some(limit) = self.memory_limit
47 && limit == 0
48 {
49 return Err(SandboxError::InvalidConfig(
50 "Memory limit must be greater than 0".to_string(),
51 ));
52 }
53
54 if let Some(weight) = self.cpu_weight
55 && (!(100..=10000).contains(&weight))
56 {
57 return Err(SandboxError::InvalidConfig(
58 "CPU weight must be between 100-10000".to_string(),
59 ));
60 }
61
62 Ok(())
63 }
64}
65
66pub struct Cgroup {
68 path: PathBuf,
69 pid: Pid,
70}
71
72fn cgroup_root_path() -> PathBuf {
73 std::env::var("SANDBOX_CGROUP_ROOT")
74 .map(PathBuf::from)
75 .unwrap_or_else(|_| PathBuf::from(CGROUP_V2_ROOT))
76}
77
78impl Cgroup {
79 pub fn new(name: &str, pid: Pid) -> Result<Self> {
81 let cgroup_path = cgroup_root_path().join(name);
82
83 fs::create_dir_all(&cgroup_path).map_err(|e| {
85 SandboxError::Cgroup(format!(
86 "Failed to create cgroup directory {}: {}",
87 cgroup_path.display(),
88 e
89 ))
90 })?;
91
92 ensure_controller_files(&cgroup_path)?;
93
94 Ok(Self {
95 path: cgroup_path,
96 pid,
97 })
98 }
99
100 pub fn apply_config(&self, config: &CgroupConfig) -> Result<()> {
102 config.validate()?;
103
104 if let Some(memory) = config.memory_limit {
105 self.set_memory_limit(memory)?;
106 }
107
108 if let Some(weight) = config.cpu_weight {
109 self.set_cpu_weight(weight)?;
110 }
111
112 if let Some(quota) = config.cpu_quota {
113 let period = config.cpu_period.unwrap_or(100000);
114 self.set_cpu_quota(quota, period)?;
115 }
116
117 if let Some(max_pids) = config.max_pids {
118 self.set_max_pids(max_pids)?;
119 }
120
121 Ok(())
122 }
123
124 pub fn add_process(&self, pid: Pid) -> Result<()> {
126 let procs_file = self.path.join("cgroup.procs");
127 let mut file = fs::OpenOptions::new()
128 .write(true)
129 .open(&procs_file)
130 .map_err(|e| {
131 SandboxError::Cgroup(format!("Failed to open {}: {}", procs_file.display(), e))
132 })?;
133
134 write!(file, "{}", pid.as_raw())
135 .map_err(|e| SandboxError::Cgroup(format!("Failed to add process to cgroup: {}", e)))?;
136
137 Ok(())
138 }
139
140 fn set_memory_limit(&self, limit: u64) -> Result<()> {
142 let mem_file = self.path.join("memory.max");
143 self.write_file(&mem_file, &limit.to_string())
144 }
145
146 fn set_cpu_weight(&self, weight: u32) -> Result<()> {
148 let cpu_file = self.path.join("cpu.weight");
149 self.write_file(&cpu_file, &weight.to_string())
150 }
151
152 fn set_cpu_quota(&self, quota: u64, period: u64) -> Result<()> {
154 let quota_file = self.path.join("cpu.max");
155 let quota_str = if quota == u64::MAX {
156 "max".to_string()
157 } else {
158 format!("{} {}", quota, period)
159 };
160 self.write_file("a_file, "a_str)
161 }
162
163 fn set_max_pids(&self, max_pids: u32) -> Result<()> {
165 let pids_file = self.path.join("pids.max");
166 self.write_file(&pids_file, &max_pids.to_string())
167 }
168
169 pub fn get_memory_usage(&self) -> Result<u64> {
171 let mem_file = self.path.join("memory.current");
172 self.read_file_u64(&mem_file)
173 }
174
175 pub fn get_memory_limit(&self) -> Result<u64> {
177 let mem_file = self.path.join("memory.max");
178 self.read_file_u64(&mem_file)
179 }
180
181 pub fn get_cpu_usage(&self) -> Result<u64> {
183 let cpu_file = self.path.join("cpu.stat");
184 let content = fs::read_to_string(&cpu_file).map_err(|e| {
185 SandboxError::Cgroup(format!("Failed to read {}: {}", cpu_file.display(), e))
186 })?;
187
188 for line in content.lines() {
190 if line.starts_with("usage_usec") {
191 let parts: Vec<&str> = line.split_whitespace().collect();
192 if parts.len() >= 2 {
193 return parts[1].parse::<u64>().map_err(|e| {
194 SandboxError::Cgroup(format!("Failed to parse CPU usage: {}", e))
195 });
196 }
197 }
198 }
199
200 Ok(0)
201 }
202
203 pub fn exists(&self) -> bool {
205 self.path.exists()
206 }
207
208 pub fn pid(&self) -> Pid {
210 self.pid
211 }
212
213 pub fn delete(&self) -> Result<()> {
215 if self.exists() {
216 fs::remove_dir(&self.path).map_err(|e| {
217 SandboxError::Cgroup(format!(
218 "Failed to delete cgroup {}: {}",
219 self.path.display(),
220 e
221 ))
222 })?;
223 }
224 Ok(())
225 }
226
227 fn write_file(&self, path: &Path, content: &str) -> Result<()> {
228 let mut file = fs::OpenOptions::new().write(true).open(path).map_err(|e| {
229 SandboxError::Cgroup(format!("Failed to open {}: {}", path.display(), e))
230 })?;
231
232 write!(file, "{}", content).map_err(|e| {
233 SandboxError::Cgroup(format!("Failed to write to {}: {}", path.display(), e))
234 })?;
235
236 Ok(())
237 }
238
239 fn read_file_u64(&self, path: &Path) -> Result<u64> {
240 let content = fs::read_to_string(path).map_err(|e| {
241 SandboxError::Cgroup(format!("Failed to read {}: {}", path.display(), e))
242 })?;
243
244 content
245 .trim()
246 .parse::<u64>()
247 .map_err(|e| SandboxError::Cgroup(format!("Failed to parse value: {}", e)))
248 }
249
250 #[cfg(test)]
251 pub(crate) fn for_testing(path: PathBuf) -> Self {
252 Self {
253 path,
254 pid: Pid::from_raw(0),
255 }
256 }
257}
258
259fn ensure_controller_files(path: &Path) -> Result<()> {
260 let files = [
261 ("memory.max", "max"),
262 ("memory.current", "0"),
263 ("cpu.weight", "100"),
264 ("cpu.max", "max 100000"),
265 ("cpu.stat", "usage_usec 0\n"),
266 ("pids.max", "max"),
267 ("cgroup.procs", ""),
268 ];
269
270 for (name, default_content) in files {
271 let file_path = path.join(name);
272 if !file_path.exists() {
273 fs::write(&file_path, default_content).map_err(|e| {
274 SandboxError::Cgroup(format!("Failed to create {}: {}", file_path.display(), e))
275 })?;
276 }
277 }
278
279 Ok(())
280}
281
282impl Drop for Cgroup {
283 fn drop(&mut self) {
284 let _ = self.delete();
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use std::env;
293 use std::fs;
294 use tempfile::tempdir;
295
296 fn prepare_cgroup_dir() -> (tempfile::TempDir, std::path::PathBuf) {
297 let tmp = tempdir().unwrap();
298 let path = tmp.path().join("cgroup-test");
299 fs::create_dir_all(&path).unwrap();
300 for file in &[
301 "memory.max",
302 "memory.current",
303 "cpu.weight",
304 "cpu.max",
305 "cpu.stat",
306 "pids.max",
307 "cgroup.procs",
308 ] {
309 let file_path = path.join(file);
310 if let Some(parent) = file_path.parent() {
311 fs::create_dir_all(parent).unwrap();
312 }
313 fs::write(&file_path, "0").unwrap();
314 }
315 fs::write(path.join("cpu.stat"), "usage_usec 0\n").unwrap();
316 fs::write(path.join("memory.current"), "0\n").unwrap();
317 (tmp, path)
318 }
319
320 #[test]
321 fn test_cgroup_config_default() {
322 let config = CgroupConfig::default();
323 assert!(config.memory_limit.is_none());
324 assert!(config.cpu_weight.is_none());
325 }
326
327 #[test]
328 fn test_cgroup_config_with_memory() {
329 let config = CgroupConfig::with_memory(100 * 1024 * 1024);
330 assert_eq!(config.memory_limit, Some(100 * 1024 * 1024));
331 }
332
333 #[test]
334 fn test_cgroup_config_with_cpu_quota() {
335 let config = CgroupConfig::with_cpu_quota(50000, 100000);
336 assert_eq!(config.cpu_quota, Some(50000));
337 assert_eq!(config.cpu_period, Some(100000));
338 }
339
340 #[test]
341 fn test_cgroup_config_validate() {
342 let config = CgroupConfig::default();
343 assert!(config.validate().is_ok());
344
345 let bad_config = CgroupConfig {
346 memory_limit: Some(0),
347 ..Default::default()
348 };
349 assert!(bad_config.validate().is_err());
350
351 let bad_cpu_config = CgroupConfig {
352 cpu_weight: Some(50),
353 ..Default::default()
354 };
355 assert!(bad_cpu_config.validate().is_err());
356
357 let good_cpu_config = CgroupConfig {
358 cpu_weight: Some(100),
359 ..Default::default()
360 };
361 assert!(good_cpu_config.validate().is_ok());
362 }
363
364 #[test]
365 fn test_cgroup_path_creation() {
366 let test_path = Path::new(CGROUP_V2_ROOT);
369 if test_path.exists() {
370 let result = Cgroup::new(
372 "sandbox-test-delete-me",
373 Pid::from_raw(std::process::id() as i32),
374 );
375 let _ = result;
377 }
378 }
379
380 #[test]
381 fn test_cgroup_apply_config_writes_files() {
382 let (_tmp, path) = prepare_cgroup_dir();
383 let cgroup = Cgroup::for_testing(path.clone());
384
385 let config = CgroupConfig {
386 memory_limit: Some(2048),
387 cpu_weight: Some(500),
388 cpu_quota: Some(50_000),
389 cpu_period: Some(100_000),
390 max_pids: Some(32),
391 };
392
393 cgroup.apply_config(&config).unwrap();
394
395 assert_eq!(
396 fs::read_to_string(path.join("memory.max")).unwrap().trim(),
397 "2048"
398 );
399 assert_eq!(
400 fs::read_to_string(path.join("cpu.weight")).unwrap().trim(),
401 "500"
402 );
403 assert_eq!(
404 fs::read_to_string(path.join("cpu.max")).unwrap().trim(),
405 "50000 100000"
406 );
407 assert_eq!(
408 fs::read_to_string(path.join("pids.max")).unwrap().trim(),
409 "32"
410 );
411 }
412
413 #[test]
414 fn test_cgroup_add_process_writes_pid() {
415 let (_tmp, path) = prepare_cgroup_dir();
416 let cgroup = Cgroup::for_testing(path.clone());
417
418 cgroup.add_process(Pid::from_raw(1234)).unwrap();
419 assert_eq!(
420 fs::read_to_string(path.join("cgroup.procs")).unwrap(),
421 "1234"
422 );
423 }
424
425 #[test]
426 fn test_cgroup_resource_readers() {
427 let (_tmp, path) = prepare_cgroup_dir();
428 fs::write(path.join("memory.current"), "4096").unwrap();
429 fs::write(path.join("cpu.stat"), "usage_usec 900\n").unwrap();
430 let cgroup = Cgroup::for_testing(path.clone());
431
432 assert_eq!(cgroup.get_memory_usage().unwrap(), 4096);
433 assert_eq!(cgroup.get_cpu_usage().unwrap(), 900);
434 }
435
436 #[test]
437 fn test_cgroup_delete_removes_directory() {
438 let (tmp, path) = prepare_cgroup_dir();
439 let cgroup = Cgroup::for_testing(path.clone());
440 assert!(path.exists());
441 for entry in fs::read_dir(&path).unwrap() {
442 let entry = entry.unwrap();
443 if entry.path().is_file() {
444 fs::remove_file(entry.path()).unwrap();
445 }
446 }
447 cgroup.delete().unwrap();
448 assert!(!path.exists());
449 drop(tmp);
450 }
451
452 #[test]
453 fn test_cgroup_new_uses_env_override() {
454 let tmp = tempdir().unwrap();
455 let prev = env::var("SANDBOX_CGROUP_ROOT").ok();
456 unsafe {
457 env::set_var("SANDBOX_CGROUP_ROOT", tmp.path());
458 }
459
460 let cg = Cgroup::new("env-test", Pid::from_raw(0)).unwrap();
461 assert!(cg.exists());
462 assert!(tmp.path().join("env-test").exists());
463
464 if let Some(value) = prev {
465 unsafe {
466 env::set_var("SANDBOX_CGROUP_ROOT", value);
467 }
468 } else {
469 unsafe {
470 env::remove_var("SANDBOX_CGROUP_ROOT");
471 }
472 }
473 }
474}