1use crate::error::{NucleusError, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::fs;
5use std::fs::OpenOptions;
6use std::io::Write;
7use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
8use std::path::{Path, PathBuf};
9use std::time::SystemTime;
10use tracing::{debug, info, warn};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "lowercase")]
15pub enum OciStatus {
16 Creating,
18 Created,
20 Running,
22 Stopped,
24}
25
26impl std::fmt::Display for OciStatus {
27 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28 match self {
29 OciStatus::Creating => write!(f, "creating"),
30 OciStatus::Created => write!(f, "created"),
31 OciStatus::Running => write!(f, "running"),
32 OciStatus::Stopped => write!(f, "stopped"),
33 }
34 }
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct ContainerState {
40 pub id: String,
42
43 pub name: String,
45
46 pub pid: u32,
48
49 pub command: Vec<String>,
51
52 pub started_at: u64,
54
55 pub memory_limit: Option<u64>,
57
58 pub cpu_limit: Option<u64>,
60
61 pub using_gvisor: bool,
63
64 pub rootless: bool,
66
67 pub cgroup_path: Option<String>,
69
70 #[serde(default)]
72 pub config_hash: Option<u64>,
73
74 #[serde(default)]
76 pub creator_uid: u32,
77
78 #[serde(default)]
81 pub start_ticks: u64,
82
83 #[serde(default = "default_oci_status")]
85 pub status: OciStatus,
86
87 #[serde(default)]
89 pub bundle_path: Option<String>,
90
91 #[serde(default)]
93 pub annotations: HashMap<String, String>,
94}
95
96fn default_oci_status() -> OciStatus {
97 OciStatus::Stopped
98}
99
100pub struct ContainerStateParams {
102 pub id: String,
103 pub name: String,
104 pub pid: u32,
105 pub command: Vec<String>,
106 pub memory_limit: Option<u64>,
107 pub cpu_limit: Option<u64>,
108 pub using_gvisor: bool,
109 pub rootless: bool,
110 pub cgroup_path: Option<String>,
111}
112
113impl ContainerState {
114 pub fn new(params: ContainerStateParams) -> Self {
116 let started_at = SystemTime::now()
117 .duration_since(SystemTime::UNIX_EPOCH)
118 .unwrap_or_default()
119 .as_secs();
120
121 let start_ticks = Self::read_start_ticks(params.pid);
122
123 Self {
124 id: params.id,
125 name: params.name,
126 pid: params.pid,
127 command: params.command,
128 started_at,
129 memory_limit: params.memory_limit,
130 cpu_limit: params.cpu_limit,
131 using_gvisor: params.using_gvisor,
132 rootless: params.rootless,
133 cgroup_path: params.cgroup_path,
134 config_hash: None,
135 creator_uid: nix::unistd::Uid::effective().as_raw(),
136 start_ticks,
137 status: OciStatus::Creating,
138 bundle_path: None,
139 annotations: HashMap::new(),
140 }
141 }
142
143 fn read_start_ticks(pid: u32) -> u64 {
149 let stat_path = format!("/proc/{}/stat", pid);
150 for attempt in 0..5 {
151 if let Ok(content) = std::fs::read_to_string(&stat_path) {
152 if let Some(ticks) = Self::parse_start_ticks(&content) {
153 return ticks;
154 }
155 }
156 if attempt < 4 {
157 std::thread::sleep(std::time::Duration::from_millis(1));
158 }
159 }
160 0
161 }
162
163 fn parse_start_ticks(content: &str) -> Option<u64> {
165 let after_comm = content.rfind(')')?;
167 content[after_comm + 2..]
171 .split_whitespace()
172 .nth(19)?
173 .parse()
174 .ok()
175 }
176
177 pub fn is_running(&self) -> bool {
182 if self.status == OciStatus::Stopped {
183 return false;
184 }
185 let stat_path = format!("/proc/{}/stat", self.pid);
186 match std::fs::read_to_string(&stat_path) {
187 Ok(content) => {
188 if self.start_ticks == 0 {
189 return false;
192 }
193 Self::parse_start_ticks(&content)
194 .map(|ticks| ticks == self.start_ticks)
195 .unwrap_or(false)
196 }
197 Err(_) => false,
198 }
199 }
200
201 pub fn oci_state(&self) -> serde_json::Value {
203 let live_status = match self.status {
204 OciStatus::Running if !self.is_running() => "stopped",
205 OciStatus::Creating => "creating",
206 OciStatus::Created => "created",
207 OciStatus::Running => "running",
208 OciStatus::Stopped => "stopped",
209 };
210 serde_json::json!({
211 "ociVersion": "1.0.2",
212 "id": self.id,
213 "status": live_status,
214 "pid": if live_status == "stopped" { 0 } else { self.pid },
215 "bundle": self.bundle_path.as_deref().unwrap_or(""),
216 "annotations": self.annotations,
217 })
218 }
219
220 pub fn uptime(&self) -> u64 {
222 let now = SystemTime::now()
223 .duration_since(SystemTime::UNIX_EPOCH)
224 .unwrap_or_default()
225 .as_secs();
226 now.saturating_sub(self.started_at)
227 }
228}
229
230pub struct ContainerStateManager {
234 state_dir: PathBuf,
235}
236
237impl ContainerStateManager {
238 pub fn new_with_root(root: Option<PathBuf>) -> Result<Self> {
241 if let Some(root) = root {
242 return Self::with_state_dir(root);
243 }
244 Self::new()
245 }
246
247 pub fn new() -> Result<Self> {
251 let mut last_error = None;
252 for candidate in Self::default_state_dir_candidates() {
253 match Self::with_state_dir(candidate.clone()) {
254 Ok(manager) => return Ok(manager),
255 Err(err) => {
256 debug!(
257 path = ?candidate,
258 error = %err,
259 "State directory candidate unavailable, trying next fallback"
260 );
261 last_error = Some(err);
262 }
263 }
264 }
265
266 Err(last_error.unwrap_or_else(|| {
267 NucleusError::ConfigError("No usable state directory candidates found".to_string())
268 }))
269 }
270
271 pub fn with_state_dir(state_dir: PathBuf) -> Result<Self> {
273 Self::reject_symlink_path(&state_dir)?;
274
275 fs::create_dir_all(&state_dir).map_err(|e| {
277 NucleusError::ConfigError(format!(
278 "Failed to create state directory {:?}: {}",
279 state_dir, e
280 ))
281 })?;
282 Self::reject_symlink_path(&state_dir)?;
283 Self::ensure_secure_state_dir_permissions(&state_dir)?;
284 Self::ensure_state_dir_writable(&state_dir)?;
285
286 Ok(Self { state_dir })
287 }
288
289 fn reject_symlink_path(state_dir: &Path) -> Result<()> {
290 match fs::symlink_metadata(state_dir) {
291 Ok(metadata) if metadata.file_type().is_symlink() => {
292 Err(NucleusError::ConfigError(format!(
293 "Refusing symlink state directory path {:?}; use a real directory",
294 state_dir
295 )))
296 }
297 Ok(_) | Err(_) => Ok(()),
298 }
299 }
300
301 fn ensure_secure_state_dir_permissions(state_dir: &Path) -> Result<()> {
302 match fs::set_permissions(state_dir, fs::Permissions::from_mode(0o700)) {
303 Ok(()) => Ok(()),
304 Err(e)
305 if matches!(
306 e.raw_os_error(),
307 Some(libc::EROFS) | Some(libc::EPERM) | Some(libc::EACCES)
308 ) =>
309 {
310 let metadata = fs::metadata(state_dir).map_err(|meta_err| {
311 NucleusError::ConfigError(format!(
312 "Failed to secure state directory permissions {:?}: {} (and could not \
313 inspect existing permissions: {})",
314 state_dir, e, meta_err
315 ))
316 })?;
317
318 let mode = metadata.permissions().mode() & 0o777;
319 let owner = metadata.uid();
320 let current_uid = nix::unistd::Uid::effective().as_raw();
321 let is_owner_ok = owner == current_uid || nix::unistd::Uid::effective().is_root();
322 let is_mode_ok = mode & 0o077 == 0;
323
324 if is_owner_ok && is_mode_ok {
325 debug!(
326 path = ?state_dir,
327 mode = format!("{:o}", mode),
328 owner,
329 "State directory already has secure permissions; skipping chmod failure"
330 );
331 Ok(())
332 } else {
333 Err(NucleusError::ConfigError(format!(
334 "Failed to secure state directory permissions {:?}: {} (existing mode \
335 {:o}, owner uid {})",
336 state_dir, e, mode, owner
337 )))
338 }
339 }
340 Err(e) => Err(NucleusError::ConfigError(format!(
341 "Failed to secure state directory permissions {:?}: {}",
342 state_dir, e
343 ))),
344 }
345 }
346
347 fn ensure_state_dir_writable(state_dir: &Path) -> Result<()> {
348 let probe_name = format!(
349 ".nucleus-write-test-{}-{}",
350 std::process::id(),
351 SystemTime::now()
352 .duration_since(SystemTime::UNIX_EPOCH)
353 .unwrap_or_default()
354 .as_nanos()
355 );
356 let probe_path = state_dir.join(probe_name);
357
358 let file = OpenOptions::new()
359 .write(true)
360 .create_new(true)
361 .mode(0o600)
362 .open(&probe_path)
363 .map_err(|e| {
364 NucleusError::ConfigError(format!(
365 "State directory {:?} is not writable: {}",
366 state_dir, e
367 ))
368 })?;
369 drop(file);
370
371 fs::remove_file(&probe_path).map_err(|e| {
372 NucleusError::ConfigError(format!(
373 "Failed to cleanup state directory probe {:?}: {}",
374 probe_path, e
375 ))
376 })?;
377
378 Ok(())
379 }
380
381 fn default_state_dir_candidates() -> Vec<PathBuf> {
383 if let Some(path) = std::env::var_os("NUCLEUS_STATE_DIR").filter(|p| !p.is_empty()) {
384 return vec![PathBuf::from(path)];
385 }
386
387 if nix::unistd::Uid::effective().is_root() {
388 vec![PathBuf::from("/var/run/nucleus")]
389 } else {
390 let mut candidates = Vec::new();
391
392 if let Some(dir) = dirs::runtime_dir() {
393 candidates.push(dir.join("nucleus"));
394 }
395 if let Some(dir) = dirs::data_local_dir() {
396 candidates.push(dir.join("nucleus"));
397 }
398 if let Some(dir) = dirs::home_dir() {
399 candidates.push(dir.join(".nucleus"));
400 }
401
402 let uid = nix::unistd::Uid::effective().as_raw();
406 let fallback = PathBuf::from(format!("/tmp/nucleus-{}", uid));
407 let fallback_ok = if fallback.exists() {
410 match std::fs::symlink_metadata(&fallback) {
411 Ok(meta) => {
412 use std::os::unix::fs::MetadataExt;
413 if meta.file_type().is_symlink() {
414 tracing::warn!(
415 "Skipping {} — it is a symlink (possible attack)",
416 fallback.display()
417 );
418 false
419 } else if meta.uid() != uid {
420 tracing::warn!(
421 "Skipping {} — owned by UID {} not {}",
422 fallback.display(), meta.uid(), uid
423 );
424 false
425 } else {
426 true
427 }
428 }
429 Err(e) => {
430 tracing::warn!(
431 "Skipping {} — cannot stat: {}",
432 fallback.display(), e
433 );
434 false
435 }
436 }
437 } else {
438 true
439 };
440 if fallback_ok {
441 candidates.push(fallback);
442 }
443
444 candidates
445 }
446 }
447
448 fn validate_container_id(container_id: &str) -> Result<()> {
450 if container_id.is_empty() {
451 return Err(NucleusError::ConfigError(
452 "Container ID cannot be empty".to_string(),
453 ));
454 }
455
456 if !container_id
457 .chars()
458 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
459 {
460 return Err(NucleusError::ConfigError(format!(
461 "Invalid container ID (allowed: a-zA-Z0-9_-): {}",
462 container_id
463 )));
464 }
465
466 Ok(())
467 }
468
469 fn state_file_path(&self, container_id: &str) -> Result<PathBuf> {
470 Self::validate_container_id(container_id)?;
471 Ok(self.state_dir.join(format!("{}.json", container_id)))
472 }
473
474 pub fn exec_fifo_path(&self, container_id: &str) -> Result<PathBuf> {
476 Self::validate_container_id(container_id)?;
477 Ok(self.state_dir.join(format!("{}.exec", container_id)))
478 }
479
480 pub fn resolve_container(&self, reference: &str) -> Result<ContainerState> {
482 let states = self.list_states()?;
483
484 if let Some(state) = states.iter().find(|s| s.id == reference) {
486 return Ok(state.clone());
487 }
488
489 let name_matches: Vec<&ContainerState> =
491 states.iter().filter(|s| s.name == reference).collect();
492 match name_matches.len() {
493 1 => return Ok(name_matches[0].clone()),
494 n if n > 1 => {
495 return Err(NucleusError::AmbiguousContainer(format!(
496 "Name '{}' matches {} containers; use container ID instead",
497 reference, n
498 )))
499 }
500 _ => {}
501 }
502
503 let prefix_matches: Vec<&ContainerState> = states
505 .iter()
506 .filter(|s| s.id.starts_with(reference))
507 .collect();
508
509 match prefix_matches.len() {
510 0 => Err(NucleusError::ContainerNotFound(reference.to_string())),
511 1 => Ok(prefix_matches[0].clone()),
512 _ => Err(NucleusError::AmbiguousContainer(format!(
513 "'{}' matches {} containers",
514 reference,
515 prefix_matches.len()
516 ))),
517 }
518 }
519
520 pub fn save_state(&self, state: &ContainerState) -> Result<()> {
522 let path = self.state_file_path(&state.id)?;
523 let tmp_path = self.state_dir.join(format!("{}.json.tmp", state.id));
524 let json = serde_json::to_string_pretty(state).map_err(|e| {
525 NucleusError::ConfigError(format!("Failed to serialize container state: {}", e))
526 })?;
527
528 let mut file = OpenOptions::new()
532 .create(true)
533 .truncate(true)
534 .write(true)
535 .mode(0o600)
536 .custom_flags(libc::O_NOFOLLOW)
537 .open(&tmp_path)
538 .map_err(|e| {
539 NucleusError::ConfigError(format!(
540 "Failed to open temp state file {:?}: {}",
541 tmp_path, e
542 ))
543 })?;
544
545 file.write_all(json.as_bytes()).map_err(|e| {
546 NucleusError::ConfigError(format!("Failed to write state file {:?}: {}", tmp_path, e))
547 })?;
548 file.sync_all().map_err(|e| {
549 NucleusError::ConfigError(format!("Failed to sync state file {:?}: {}", tmp_path, e))
550 })?;
551
552 fs::rename(&tmp_path, &path).map_err(|e| {
553 NucleusError::ConfigError(format!(
554 "Failed to atomically replace state file {:?}: {}",
555 path, e
556 ))
557 })?;
558
559 debug!("Saved container state: {}", state.id);
560 Ok(())
561 }
562
563 pub fn read_file_nofollow(
565 path: &std::path::Path,
566 ) -> std::result::Result<String, std::io::Error> {
567 use std::io::Read;
568 let file = OpenOptions::new()
569 .read(true)
570 .custom_flags(libc::O_NOFOLLOW)
571 .open(path)?;
572 let mut buf = String::new();
573 std::io::BufReader::new(file).read_to_string(&mut buf)?;
574 Ok(buf)
575 }
576
577 pub fn load_state(&self, container_id: &str) -> Result<ContainerState> {
581 let path = self.state_file_path(container_id)?;
582
583 let json = Self::read_file_nofollow(&path).map_err(|e| {
584 NucleusError::ConfigError(format!("Failed to read state file {:?}: {}", path, e))
585 })?;
586
587 let state = serde_json::from_str(&json).map_err(|e| {
588 NucleusError::ConfigError(format!("Failed to parse container state: {}", e))
589 })?;
590
591 Ok(state)
592 }
593
594 pub fn delete_state(&self, container_id: &str) -> Result<()> {
596 let path = self.state_file_path(container_id)?;
597
598 match fs::remove_file(&path) {
599 Ok(()) => {
600 debug!("Deleted container state: {}", container_id);
601 }
602 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
603 debug!("Container state already deleted: {}", container_id);
605 }
606 Err(e) => {
607 return Err(NucleusError::ConfigError(format!(
608 "Failed to delete state file {:?}: {}",
609 path, e
610 )));
611 }
612 }
613
614 Ok(())
615 }
616
617 pub fn list_states(&self) -> Result<Vec<ContainerState>> {
619 let mut states = Vec::new();
620
621 let entries = fs::read_dir(&self.state_dir).map_err(|e| {
622 NucleusError::ConfigError(format!(
623 "Failed to read state directory {:?}: {}",
624 self.state_dir, e
625 ))
626 })?;
627
628 for entry in entries {
629 let entry = entry.map_err(|e| {
630 NucleusError::ConfigError(format!("Failed to read directory entry: {}", e))
631 })?;
632
633 let path = entry.path();
634 if path.extension().and_then(|s| s.to_str()) == Some("json") {
635 match Self::read_file_nofollow(&path) {
639 Ok(json) => match serde_json::from_str::<ContainerState>(&json) {
640 Ok(state) => states.push(state),
641 Err(e) => {
642 warn!("Failed to parse state file {:?}: {}", path, e);
643 }
644 },
645 Err(e) => {
646 warn!("Failed to read state file {:?}: {}", path, e);
647 }
648 }
649 }
650 }
651
652 Ok(states)
653 }
654
655 pub fn list_running(&self) -> Result<Vec<ContainerState>> {
657 let states = self.list_states()?;
658 Ok(states.into_iter().filter(|s| s.is_running()).collect())
659 }
660
661 pub fn cleanup_stale(&self) -> Result<()> {
663 let states = self.list_states()?;
664
665 for state in states {
666 if !state.is_running() {
667 info!(
668 "Cleaning up stale state for container {} (PID {})",
669 state.id, state.pid
670 );
671 self.delete_state(&state.id)?;
672 }
673 }
674
675 Ok(())
676 }
677}
678
679#[cfg(test)]
680mod tests {
681 use super::*;
682 use tempfile::TempDir;
683
684 fn temp_state_manager() -> (ContainerStateManager, TempDir) {
685 let temp_dir = TempDir::new().unwrap();
686 let mgr = ContainerStateManager {
687 state_dir: temp_dir.path().to_path_buf(),
688 };
689 (mgr, temp_dir)
690 }
691
692 #[test]
693 fn test_container_state_new() {
694 let state = ContainerState::new(ContainerStateParams {
695 id: "test".to_string(),
696 name: "test".to_string(),
697 pid: 1234,
698 command: vec!["/bin/sh".to_string()],
699 memory_limit: Some(512 * 1024 * 1024),
700 cpu_limit: Some(2000),
701 using_gvisor: false,
702 rootless: false,
703 cgroup_path: Some("/sys/fs/cgroup/nucleus-test".to_string()),
704 });
705
706 assert_eq!(state.id, "test");
707 assert_eq!(state.pid, 1234);
708 assert_eq!(state.memory_limit, Some(512 * 1024 * 1024));
709 assert_eq!(state.cpu_limit, Some(2000));
710 assert_eq!(state.creator_uid, nix::unistd::Uid::effective().as_raw());
711 }
712
713 #[test]
714 fn test_save_and_load_state() {
715 let (mgr, _temp_dir) = temp_state_manager();
716
717 let state = ContainerState::new(ContainerStateParams {
718 id: "test".to_string(),
719 name: "test".to_string(),
720 pid: 1234,
721 command: vec!["/bin/sh".to_string()],
722 memory_limit: Some(512 * 1024 * 1024),
723 cpu_limit: None,
724 using_gvisor: false,
725 rootless: false,
726 cgroup_path: None,
727 });
728
729 mgr.save_state(&state).unwrap();
730
731 let loaded = mgr.load_state("test").unwrap();
732 assert_eq!(loaded.id, state.id);
733 assert_eq!(loaded.pid, state.pid);
734 assert_eq!(loaded.command, state.command);
735 }
736
737 #[test]
738 fn test_delete_state() {
739 let (mgr, _temp_dir) = temp_state_manager();
740
741 let state = ContainerState::new(ContainerStateParams {
742 id: "test".to_string(),
743 name: "test".to_string(),
744 pid: 1234,
745 command: vec!["/bin/sh".to_string()],
746 memory_limit: None,
747 cpu_limit: None,
748 using_gvisor: false,
749 rootless: false,
750 cgroup_path: None,
751 });
752
753 mgr.save_state(&state).unwrap();
754 assert!(mgr.load_state("test").is_ok());
755
756 mgr.delete_state("test").unwrap();
757 assert!(mgr.load_state("test").is_err());
758 }
759
760 #[test]
761 fn test_list_states() {
762 let (mgr, _temp_dir) = temp_state_manager();
763
764 let state1 = ContainerState::new(ContainerStateParams {
765 id: "test1".to_string(),
766 name: "test1".to_string(),
767 pid: 1234,
768 command: vec!["/bin/sh".to_string()],
769 memory_limit: None,
770 cpu_limit: None,
771 using_gvisor: false,
772 rootless: false,
773 cgroup_path: None,
774 });
775
776 let state2 = ContainerState::new(ContainerStateParams {
777 id: "test2".to_string(),
778 name: "test2".to_string(),
779 pid: 5678,
780 command: vec!["/bin/bash".to_string()],
781 memory_limit: None,
782 cpu_limit: None,
783 using_gvisor: false,
784 rootless: false,
785 cgroup_path: None,
786 });
787
788 mgr.save_state(&state1).unwrap();
789 mgr.save_state(&state2).unwrap();
790
791 let states = mgr.list_states().unwrap();
792 assert_eq!(states.len(), 2);
793 }
794
795 #[test]
796 fn test_resolve_container_by_id() {
797 let (mgr, _temp_dir) = temp_state_manager();
798
799 let state = ContainerState::new(ContainerStateParams {
800 id: "abc123def456".to_string(),
801 name: "mycontainer".to_string(),
802 pid: 1234,
803 command: vec!["/bin/sh".to_string()],
804 memory_limit: None,
805 cpu_limit: None,
806 using_gvisor: false,
807 rootless: false,
808 cgroup_path: None,
809 });
810 mgr.save_state(&state).unwrap();
811
812 let resolved = mgr.resolve_container("abc123def456").unwrap();
814 assert_eq!(resolved.id, "abc123def456");
815
816 let resolved = mgr.resolve_container("mycontainer").unwrap();
818 assert_eq!(resolved.id, "abc123def456");
819
820 let resolved = mgr.resolve_container("abc123").unwrap();
822 assert_eq!(resolved.id, "abc123def456");
823
824 assert!(mgr.resolve_container("nonexistent").is_err());
826 }
827
828 #[test]
829 fn test_load_state_rejects_symlink() {
830 let (mgr, temp_dir) = temp_state_manager();
832
833 let state = ContainerState::new(ContainerStateParams {
835 id: "real".to_string(),
836 name: "real".to_string(),
837 pid: 1234,
838 command: vec!["/bin/sh".to_string()],
839 memory_limit: None,
840 cpu_limit: None,
841 using_gvisor: false,
842 rootless: false,
843 cgroup_path: None,
844 });
845 mgr.save_state(&state).unwrap();
846
847 let symlink_path = temp_dir.path().join("symlinked.json");
849 let real_path = temp_dir.path().join("real.json");
850 std::os::unix::fs::symlink(&real_path, &symlink_path).unwrap();
851
852 let result = mgr.load_state("symlinked");
854 assert!(result.is_err(), "load_state must reject symlinks");
855 }
856
857 #[test]
858 fn test_list_states_ignores_symlinks() {
859 let (mgr, temp_dir) = temp_state_manager();
862
863 let state = ContainerState::new(ContainerStateParams {
865 id: "real123456789012345678".to_string(),
866 name: "real".to_string(),
867 pid: 1234,
868 command: vec!["/bin/sh".to_string()],
869 memory_limit: None,
870 cpu_limit: None,
871 using_gvisor: false,
872 rootless: false,
873 cgroup_path: None,
874 });
875 mgr.save_state(&state).unwrap();
876
877 let real_path = temp_dir.path().join("real123456789012345678.json");
879 let symlink_path = temp_dir.path().join("evil.json");
880 std::os::unix::fs::symlink(&real_path, &symlink_path).unwrap();
881
882 let states = mgr.list_states().unwrap();
884 assert_eq!(states.len(), 1, "symlinked state file must be skipped");
886 assert_eq!(states[0].id, "real123456789012345678");
887 }
888
889 #[test]
890 fn test_save_state_rejects_symlink_tmp() {
891 let (mgr, temp_dir) = temp_state_manager();
893
894 let state = ContainerState::new(ContainerStateParams {
895 id: "target".to_string(),
896 name: "target".to_string(),
897 pid: 1234,
898 command: vec!["/bin/sh".to_string()],
899 memory_limit: None,
900 cpu_limit: None,
901 using_gvisor: false,
902 rootless: false,
903 cgroup_path: None,
904 });
905
906 let tmp_path = temp_dir.path().join("target.json.tmp");
908 let evil_path = temp_dir.path().join("evil");
909 std::os::unix::fs::symlink(&evil_path, &tmp_path).unwrap();
910
911 let result = mgr.save_state(&state);
913 assert!(
914 result.is_err(),
915 "save_state must reject symlinks at tmp path"
916 );
917 }
918
919 #[test]
920 fn test_is_running_returns_false_when_start_ticks_is_zero() {
921 let mut state = ContainerState::new(ContainerStateParams {
924 id: "test".to_string(),
925 name: "test".to_string(),
926 pid: std::process::id(), command: vec!["/bin/sh".to_string()],
928 memory_limit: None,
929 cpu_limit: None,
930 using_gvisor: false,
931 rootless: false,
932 cgroup_path: None,
933 });
934 state.start_ticks = 0;
936 assert!(
939 !state.is_running(),
940 "is_running() must return false when start_ticks=0 (cannot verify PID identity)"
941 );
942 }
943
944 #[test]
945 fn test_read_start_ticks_retries_on_failure() {
946 let own_ticks = ContainerState::read_start_ticks(std::process::id());
951 assert!(
952 own_ticks > 0,
953 "read_start_ticks must return non-zero for a live process"
954 );
955 let bogus_ticks = ContainerState::read_start_ticks(u32::MAX);
957 assert_eq!(
958 bogus_ticks, 0,
959 "read_start_ticks must return 0 for non-existent PID"
960 );
961 }
962
963 #[test]
964 fn test_delete_state_handles_already_deleted() {
965 let (mgr, _temp_dir) = temp_state_manager();
967 let result = mgr.delete_state("nonexistent-id");
969 assert!(
970 result.is_ok(),
971 "delete_state must be idempotent for missing files"
972 );
973 }
974}