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