Skip to main content

nucleus/container/
state.rs

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/// OCI-compliant container status
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "lowercase")]
15pub enum OciStatus {
16    /// Container is being created
17    Creating,
18    /// Container has been created but not started
19    Created,
20    /// Container process is running
21    Running,
22    /// Container process has stopped
23    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/// Container state tracking information
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct ContainerState {
40    /// Container ID (unique 32 hex chars, 128-bit)
41    pub id: String,
42
43    /// Container name (user-supplied or same as ID)
44    pub name: String,
45
46    /// PID of the container process
47    pub pid: u32,
48
49    /// Command being executed
50    pub command: Vec<String>,
51
52    /// Start time (Unix timestamp)
53    pub started_at: u64,
54
55    /// Memory limit in bytes (None = unlimited)
56    pub memory_limit: Option<u64>,
57
58    /// CPU limit in millicores (None = unlimited)
59    pub cpu_limit: Option<u64>,
60
61    /// Whether using gVisor runtime
62    pub using_gvisor: bool,
63
64    /// Whether using rootless mode
65    pub rootless: bool,
66
67    /// cgroup path
68    pub cgroup_path: Option<String>,
69
70    /// Desired topology config hash associated with this container, if any.
71    #[serde(default)]
72    pub config_hash: Option<u64>,
73
74    /// UID of the user who created this container
75    #[serde(default)]
76    pub creator_uid: u32,
77
78    /// Process start time in clock ticks (from /proc/`<pid>`/stat field 22)
79    /// Used to detect PID reuse in is_running()
80    #[serde(default)]
81    pub start_ticks: u64,
82
83    /// OCI container status
84    #[serde(default = "default_oci_status")]
85    pub status: OciStatus,
86
87    /// OCI bundle path
88    #[serde(default)]
89    pub bundle_path: Option<String>,
90
91    /// OCI annotations
92    #[serde(default)]
93    pub annotations: HashMap<String, String>,
94}
95
96fn default_oci_status() -> OciStatus {
97    OciStatus::Stopped
98}
99
100/// Parameters for creating a new `ContainerState`.
101pub 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    /// Create a new container state from the given parameters.
115    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    /// Read the start time in clock ticks from /proc/<pid>/stat (field 22)
144    ///
145    /// BUG-09: After fork, /proc/<pid>/stat may not be immediately available.
146    /// Retry a few times with short sleeps to avoid returning 0 and breaking
147    /// PID-reuse detection in is_running().
148    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    /// Parse start time (field 22) from /proc/<pid>/stat content
164    fn parse_start_ticks(content: &str) -> Option<u64> {
165        // Field 2 (comm) is in parens and may contain spaces; find last ')'
166        let after_comm = content.rfind(')')?;
167        // After ')' we have fields 3..N; field 22 is index 19 (22 - 3 = 19)
168        // Use nth() instead of collecting into a Vec to avoid a heap allocation
169        // on every liveness check.
170        content[after_comm + 2..]
171            .split_whitespace()
172            .nth(19)?
173            .parse()
174            .ok()
175    }
176
177    /// Check if the container process is still running
178    ///
179    /// Cross-checks PID start time to detect PID reuse after process exit.
180    /// Also returns false if the OCI status is `Stopped`.
181    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                    // PID existence alone is insufficient because the PID may have
190                    // been recycled since this state was recorded.
191                    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    /// Return OCI runtime state as a JSON value
202    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    /// Get uptime in seconds
221    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
230/// Container state manager
231///
232/// Manages persistent state of running containers
233pub struct ContainerStateManager {
234    state_dir: PathBuf,
235}
236
237impl ContainerStateManager {
238    /// Create a state manager rooted at an explicit directory, falling back to
239    /// default candidates if `root` is `None`.
240    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    /// Create a new state manager
248    ///
249    /// Creates the state directory if it doesn't exist
250    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    /// Create a state manager rooted at an explicit directory.
272    pub fn with_state_dir(state_dir: PathBuf) -> Result<Self> {
273        Self::reject_symlink_path(&state_dir)?;
274
275        // Create state directory if it doesn't exist (idempotent)
276        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    /// Get ordered default state directory candidates.
382    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            // Final fallback for restricted sandboxes where standard runtime/home
403            // paths are mounted read-only. Use a private directory under /tmp
404            // with O_NOFOLLOW semantics to prevent symlink attacks.
405            let uid = nix::unistd::Uid::effective().as_raw();
406            let fallback = PathBuf::from(format!("/tmp/nucleus-{}", uid));
407            // Only add the /tmp fallback if it either doesn't exist yet
408            // (will be created later) or passes symlink/ownership checks.
409            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    /// Validate a container ID for safe filesystem use
449    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    /// Return the path to the exec FIFO used for two-phase create/start.
475    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    /// Resolve a container reference by exact ID, name, or ID prefix
481    pub fn resolve_container(&self, reference: &str) -> Result<ContainerState> {
482        let states = self.list_states()?;
483
484        // Try exact ID match
485        if let Some(state) = states.iter().find(|s| s.id == reference) {
486            return Ok(state.clone());
487        }
488
489        // Try exact name match (must be unambiguous)
490        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        // Try ID prefix match
504        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    /// Save container state
521    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        // O_NOFOLLOW prevents TOCTOU symlink attacks: if an attacker replaces
529        // the temp path with a symlink between check and open, the open fails
530        // instead of following the symlink to an attacker-controlled location.
531        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    /// Read a file with O_NOFOLLOW to prevent symlink attacks.
564    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    /// Load container state
578    ///
579    /// Opens with O_NOFOLLOW to prevent symlink-based TOCTOU attacks.
580    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    /// Delete container state
595    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                // Already deleted — idempotent (handles TOCTOU race)
604                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    /// List all container states
618    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                // Use O_NOFOLLOW to prevent symlink attacks, consistent with
636                // load_state/save_state. Without this, a symlink in the state
637                // directory could be used as a file-read oracle.
638                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    /// List only running containers
656    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    /// Clean up stale state files (for containers that are no longer running)
662    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        // Exact ID
813        let resolved = mgr.resolve_container("abc123def456").unwrap();
814        assert_eq!(resolved.id, "abc123def456");
815
816        // Name
817        let resolved = mgr.resolve_container("mycontainer").unwrap();
818        assert_eq!(resolved.id, "abc123def456");
819
820        // ID prefix
821        let resolved = mgr.resolve_container("abc123").unwrap();
822        assert_eq!(resolved.id, "abc123def456");
823
824        // Not found
825        assert!(mgr.resolve_container("nonexistent").is_err());
826    }
827
828    #[test]
829    fn test_load_state_rejects_symlink() {
830        // H-3: O_NOFOLLOW must prevent loading state through a symlink
831        let (mgr, temp_dir) = temp_state_manager();
832
833        // Create a real state file
834        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        // Create a symlink pointing to the real state file
848        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        // Loading through the symlink ID must fail (O_NOFOLLOW)
853        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        // list_states must use O_NOFOLLOW, so symlinked state files are skipped
860        // rather than followed (which would be a file-read oracle).
861        let (mgr, temp_dir) = temp_state_manager();
862
863        // Create a real state file
864        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        // Create a symlink masquerading as a state file
878        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        // list_states should only return the real file, not follow the symlink
883        let states = mgr.list_states().unwrap();
884        // The symlink should fail to open with O_NOFOLLOW, leaving only the real state
885        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        // H-3: O_NOFOLLOW on save must prevent writing through a symlink
892        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        // Pre-create a symlink at the temp path to simulate an attack
907        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        // save_state should fail because O_NOFOLLOW rejects the symlink
912        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        // BUG-04: When start_ticks=0 (failed to read), is_running() must return
922        // false to avoid PID reuse false positives, not fall back to existence check
923        let mut state = ContainerState::new(ContainerStateParams {
924            id: "test".to_string(),
925            name: "test".to_string(),
926            pid: std::process::id(), // our PID exists in /proc
927            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        // Force start_ticks to 0 to simulate failed read
935        state.start_ticks = 0;
936        // With BUG-04 present, this returns true (falls back to existence check)
937        // After fix, must return false
938        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        // BUG-09: read_start_ticks must retry when /proc/<pid>/stat is temporarily
947        // unavailable after fork, instead of immediately returning 0.
948        // Verify by calling with our own PID (should succeed) and a non-existent
949        // PID (should return 0 after retries, not panic).
950        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        // Non-existent PID should gracefully return 0 (after retries)
956        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        // BUG-16: delete_state must not fail if file was already deleted (TOCTOU)
966        let (mgr, _temp_dir) = temp_state_manager();
967        // Delete a state that doesn't exist — should succeed (idempotent)
968        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}