Skip to main content

nucleus/checkpoint/
metadata.rs

1use crate::container::{ContainerState, ContainerStateManager};
2use crate::error::{NucleusError, Result};
3use serde::{Deserialize, Serialize};
4use std::fs;
5use std::fs::OpenOptions;
6use std::io::Write;
7use std::os::unix::fs::OpenOptionsExt;
8use std::path::Path;
9use std::time::SystemTime;
10
11/// Metadata stored alongside checkpoint images
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct CheckpointMetadata {
14    /// Container ID
15    pub container_id: String,
16
17    /// Container name
18    pub container_name: String,
19
20    /// Original PID
21    pub original_pid: u32,
22
23    /// Command that was running
24    pub command: Vec<String>,
25
26    /// Timestamp of checkpoint
27    pub checkpoint_at: u64,
28
29    /// Nucleus version
30    pub version: String,
31
32    /// Whether container was using gVisor
33    pub using_gvisor: bool,
34
35    /// Whether container was rootless
36    pub rootless: bool,
37}
38
39impl CheckpointMetadata {
40    /// Create metadata from current container state
41    pub fn from_state(state: &ContainerState) -> Self {
42        let checkpoint_at = SystemTime::now()
43            .duration_since(SystemTime::UNIX_EPOCH)
44            .unwrap_or_default()
45            .as_secs();
46
47        Self {
48            container_id: state.id.clone(),
49            container_name: state.name.clone(),
50            original_pid: state.pid,
51            command: state.command.clone(),
52            checkpoint_at,
53            version: env!("CARGO_PKG_VERSION").to_string(),
54            using_gvisor: state.using_gvisor,
55            rootless: state.rootless,
56        }
57    }
58
59    /// Save metadata to checkpoint directory
60    pub fn save(&self, dir: &Path) -> Result<()> {
61        let path = dir.join("metadata.json");
62        let tmp_path = dir.join("metadata.json.tmp");
63        let json = serde_json::to_string_pretty(self).map_err(|e| {
64            NucleusError::CheckpointError(format!("Failed to serialize metadata: {}", e))
65        })?;
66
67        if tmp_path.exists() {
68            let meta = fs::symlink_metadata(&tmp_path).map_err(|e| {
69                NucleusError::CheckpointError(format!(
70                    "Failed to inspect temp metadata file {:?}: {}",
71                    tmp_path, e
72                ))
73            })?;
74            if meta.file_type().is_symlink() {
75                return Err(NucleusError::CheckpointError(format!(
76                    "Refusing symlink temp metadata file {:?}",
77                    tmp_path
78                )));
79            }
80            fs::remove_file(&tmp_path).map_err(|e| {
81                NucleusError::CheckpointError(format!(
82                    "Failed to remove stale temp metadata file {:?}: {}",
83                    tmp_path, e
84                ))
85            })?;
86        }
87
88        let mut file = OpenOptions::new()
89            .create_new(true)
90            .write(true)
91            .mode(0o600)
92            .custom_flags(libc::O_NOFOLLOW)
93            .open(&tmp_path)
94            .map_err(|e| {
95                NucleusError::CheckpointError(format!(
96                    "Failed to open temp metadata file {:?}: {}",
97                    tmp_path, e
98                ))
99            })?;
100
101        file.write_all(json.as_bytes()).map_err(|e| {
102            NucleusError::CheckpointError(format!(
103                "Failed to write metadata file {:?}: {}",
104                tmp_path, e
105            ))
106        })?;
107        file.sync_all().map_err(|e| {
108            NucleusError::CheckpointError(format!(
109                "Failed to sync metadata file {:?}: {}",
110                tmp_path, e
111            ))
112        })?;
113
114        fs::rename(&tmp_path, &path).map_err(|e| {
115            NucleusError::CheckpointError(format!(
116                "Failed to atomically replace metadata file {:?}: {}",
117                path, e
118            ))
119        })?;
120        Ok(())
121    }
122
123    /// Load metadata from checkpoint directory
124    pub fn load(dir: &Path) -> Result<Self> {
125        let path = dir.join("metadata.json");
126        let json = ContainerStateManager::read_file_nofollow(&path).map_err(|e| {
127            NucleusError::CheckpointError(format!("Failed to read metadata {:?}: {}", path, e))
128        })?;
129        let metadata: Self = serde_json::from_str(&json).map_err(|e| {
130            NucleusError::CheckpointError(format!("Failed to parse metadata: {}", e))
131        })?;
132        Ok(metadata)
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use std::os::unix::fs as unix_fs;
140
141    #[test]
142    fn test_save_rejects_symlink_target() {
143        // BUG-11: CheckpointMetadata::save must use O_NOFOLLOW to prevent
144        // symlink attacks. Verify by creating a symlink at the temp file path
145        // and confirming save() refuses to follow it.
146        let dir = tempfile::tempdir().unwrap();
147        let attacker_target = dir.path().join("attacker-owned-file");
148        std::fs::write(&attacker_target, "").unwrap();
149
150        // Pre-create the symlink where save() will write its temp file
151        let symlink_path = dir.path().join("metadata.json.tmp");
152        unix_fs::symlink(&attacker_target, &symlink_path).unwrap();
153
154        let metadata = CheckpointMetadata {
155            container_id: "test-id".to_string(),
156            container_name: "test".to_string(),
157            original_pid: 1,
158            command: vec!["/bin/sh".to_string()],
159            checkpoint_at: 0,
160            version: "0.0.0".to_string(),
161            using_gvisor: false,
162            rootless: false,
163        };
164
165        let result = metadata.save(dir.path());
166        assert!(
167            result.is_err(),
168            "save() must reject symlink at temp file path (O_NOFOLLOW / symlink check)"
169        );
170    }
171}