Skip to main content

nucleus/container/
state.rs

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/// OCI-compliant container status
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "lowercase")]
17pub enum OciStatus {
18    /// Container is being created
19    Creating,
20    /// Container has been created but not started
21    Created,
22    /// Container process is running
23    Running,
24    /// Container process has stopped
25    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/// Container state tracking information
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct ContainerState {
42    /// Container ID (unique 32 hex chars, 128-bit)
43    pub id: String,
44
45    /// Container name (user-supplied or same as ID)
46    pub name: String,
47
48    /// PID of the container process
49    pub pid: u32,
50
51    /// Command being executed
52    pub command: Vec<String>,
53
54    /// Environment variables supplied at container launch.
55    #[serde(default)]
56    pub environment: BTreeMap<String, String>,
57
58    /// Working directory supplied at container launch.
59    #[serde(default = "default_workdir")]
60    pub workdir: String,
61
62    /// Start time (Unix timestamp)
63    pub started_at: u64,
64
65    /// Memory limit in bytes (None = unlimited)
66    pub memory_limit: Option<u64>,
67
68    /// CPU limit in millicores (None = unlimited)
69    pub cpu_limit: Option<u64>,
70
71    /// Whether using gVisor runtime
72    pub using_gvisor: bool,
73
74    /// Whether using rootless mode
75    pub rootless: bool,
76
77    /// cgroup path
78    pub cgroup_path: Option<String>,
79
80    /// Desired topology config hash associated with this container, if any.
81    #[serde(default)]
82    pub config_hash: Option<u64>,
83
84    /// UID of the user who created this container
85    #[serde(default)]
86    pub creator_uid: u32,
87
88    /// Effective uid of the workload process inside the container.
89    #[serde(default)]
90    pub process_uid: u32,
91
92    /// Effective gid of the workload process inside the container.
93    #[serde(default)]
94    pub process_gid: u32,
95
96    /// Supplementary gids of the workload process inside the container.
97    #[serde(default)]
98    pub additional_gids: Vec<u32>,
99
100    /// Process start time in clock ticks (from /proc/`<pid>`/stat field 22)
101    /// Used to detect PID reuse in is_running()
102    #[serde(default)]
103    pub start_ticks: u64,
104
105    /// OCI container status
106    #[serde(default = "default_oci_status")]
107    pub status: OciStatus,
108
109    /// OCI bundle path
110    #[serde(default)]
111    pub bundle_path: Option<String>,
112
113    /// OCI annotations
114    #[serde(default)]
115    pub annotations: HashMap<String, String>,
116
117    /// Rootfs path used to launch the container, if any.
118    #[serde(default)]
119    pub rootfs_path: Option<String>,
120
121    /// Rootfs mount mode used at launch.
122    #[serde(default)]
123    pub rootfs_mode: RootfsMode,
124
125    /// Persistent overlayfs upperdir for image commit, if rootfs_mode=overlay.
126    #[serde(default)]
127    pub rootfs_upperdir: Option<String>,
128
129    /// Persistent overlayfs workdir paired with rootfs_upperdir.
130    #[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
142/// Parameters for creating a new `ContainerState`.
143pub 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    /// Create a new container state from the given parameters.
160    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    /// Read the start time in clock ticks from /proc/<pid>/stat (field 22)
198    ///
199    /// BUG-09: After fork, /proc/<pid>/stat may not be immediately available.
200    /// Retry a few times with short sleeps to avoid returning 0 and breaking
201    /// PID-reuse detection in is_running().
202    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    /// Parse start time (field 22) from /proc/<pid>/stat content
218    fn parse_start_ticks(content: &str) -> Option<u64> {
219        // Field 2 (comm) is in parens and may contain spaces; find last ')'
220        let after_comm = content.rfind(')')?;
221        // After ')' we have fields 3..N; field 22 is index 19 (22 - 3 = 19)
222        // Use nth() instead of collecting into a Vec to avoid a heap allocation
223        // on every liveness check.
224        content[after_comm + 2..]
225            .split_whitespace()
226            .nth(19)?
227            .parse()
228            .ok()
229    }
230
231    /// Check if the container process is still running
232    ///
233    /// Cross-checks PID start time to detect PID reuse after process exit.
234    /// Also returns false if the OCI status is `Stopped`.
235    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                    // PID existence alone is insufficient because the PID may have
244                    // been recycled since this state was recorded.
245                    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    /// Return OCI runtime state as a JSON value
256    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    /// Get uptime in seconds
275    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
284/// Container state manager
285///
286/// Manages persistent state of running containers
287pub struct ContainerStateManager {
288    state_dir: PathBuf,
289}
290
291impl ContainerStateManager {
292    /// Create a state manager rooted at an explicit directory, falling back to
293    /// default candidates if `root` is `None`.
294    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    /// Create a new state manager
302    ///
303    /// Creates the state directory if it doesn't exist
304    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    /// Create a state manager rooted at an explicit directory.
326    pub fn with_state_dir(state_dir: PathBuf) -> Result<Self> {
327        Self::reject_symlink_path(&state_dir)?;
328
329        // Create state directory if it doesn't exist (idempotent)
330        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    /// Get ordered default state directory candidates.
436    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            // Final fallback for restricted sandboxes where standard runtime/home
457            // paths are mounted read-only. Use a private directory under /tmp
458            // with O_NOFOLLOW semantics to prevent symlink attacks.
459            //
460            // We avoid the TOCTOU pattern of .exists() then create_dir_all()
461            // by attempting mkdir atomically and validating the result.
462            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                    // We created it – it's ours with correct ownership.
467                    true
468                }
469                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
470                    // Already exists: validate it is not a symlink and is owned by us.
471                    // symlink_metadata (lstat) does not follow symlinks.
472                    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                    // Cannot create (e.g. /tmp read-only) – skip this candidate.
501                    false
502                }
503            };
504            if fallback_ok {
505                candidates.push(fallback);
506            }
507
508            candidates
509        }
510    }
511
512    /// Validate a container ID for safe filesystem use
513    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    /// Return the path to the exec FIFO used for two-phase create/start.
539    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    /// Return the persistent overlayfs state directory for a container.
545    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    /// Resolve a container reference by exact ID, name, or ID prefix
553    pub fn resolve_container(&self, reference: &str) -> Result<ContainerState> {
554        let states = self.list_states()?;
555
556        // Try exact ID match
557        if let Some(state) = states.iter().find(|s| s.id == reference) {
558            return Ok(state.clone());
559        }
560
561        // Try exact name match (must be unambiguous)
562        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        // Try ID prefix match
576        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    /// Save container state
593    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        // O_NOFOLLOW prevents TOCTOU symlink attacks: if an attacker replaces
601        // the temp path with a symlink between check and open, the open fails
602        // instead of following the symlink to an attacker-controlled location.
603        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    /// Read a file with O_NOFOLLOW to prevent symlink attacks.
636    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    /// Load container state
650    ///
651    /// Opens with O_NOFOLLOW to prevent symlink-based TOCTOU attacks.
652    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    /// Delete container state
667    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                // Already deleted – idempotent (handles TOCTOU race)
676                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    /// List all container states
690    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                // Use O_NOFOLLOW to prevent symlink attacks, consistent with
708                // load_state/save_state. Without this, a symlink in the state
709                // directory could be used as a file-read oracle.
710                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    /// List only running containers
728    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    /// Clean up stale state files (for containers that are no longer running)
734    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        // Exact ID
903        let resolved = mgr.resolve_container("abc123def456").unwrap();
904        assert_eq!(resolved.id, "abc123def456");
905
906        // Name
907        let resolved = mgr.resolve_container("mycontainer").unwrap();
908        assert_eq!(resolved.id, "abc123def456");
909
910        // ID prefix
911        let resolved = mgr.resolve_container("abc123").unwrap();
912        assert_eq!(resolved.id, "abc123def456");
913
914        // Not found
915        assert!(mgr.resolve_container("nonexistent").is_err());
916    }
917
918    #[test]
919    fn test_load_state_rejects_symlink() {
920        // H-3: O_NOFOLLOW must prevent loading state through a symlink
921        let (mgr, temp_dir) = temp_state_manager();
922
923        // Create a real state file
924        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        // Create a symlink pointing to the real state file
941        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        // Loading through the symlink ID must fail (O_NOFOLLOW)
946        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        // list_states must use O_NOFOLLOW, so symlinked state files are skipped
953        // rather than followed (which would be a file-read oracle).
954        let (mgr, temp_dir) = temp_state_manager();
955
956        // Create a real state file
957        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        // Create a symlink masquerading as a state file
974        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        // list_states should only return the real file, not follow the symlink
979        let states = mgr.list_states().unwrap();
980        // The symlink should fail to open with O_NOFOLLOW, leaving only the real state
981        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        // H-3: O_NOFOLLOW on save must prevent writing through a symlink
988        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        // Pre-create a symlink at the temp path to simulate an attack
1006        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        // save_state should fail because O_NOFOLLOW rejects the symlink
1011        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        // BUG-04: When start_ticks=0 (failed to read), is_running() must return
1021        // false to avoid PID reuse false positives, not fall back to existence check
1022        let mut state = ContainerState::new(ContainerStateParams {
1023            id: "test".to_string(),
1024            name: "test".to_string(),
1025            pid: std::process::id(), // our PID exists in /proc
1026            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        // Force start_ticks to 0 to simulate failed read
1037        state.start_ticks = 0;
1038        // With BUG-04 present, this returns true (falls back to existence check)
1039        // After fix, must return false
1040        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        // BUG-09: read_start_ticks must retry when /proc/<pid>/stat is temporarily
1049        // unavailable after fork, instead of immediately returning 0.
1050        // Verify by calling with our own PID (should succeed) and a non-existent
1051        // PID (should return 0 after retries, not panic).
1052        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        // Non-existent PID should gracefully return 0 (after retries)
1058        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        // BUG-16: delete_state must not fail if file was already deleted (TOCTOU)
1068        let (mgr, _temp_dir) = temp_state_manager();
1069        // Delete a state that doesn't exist – should succeed (idempotent)
1070        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}