Skip to main content

cfgd_core/util/
file_io.rs

1use super::constants::MAX_BACKUP_FILE_SIZE;
2use super::fs_perms::file_permissions_mode;
3use super::hashing::sha256_hex;
4use super::paths::PathDisplayExt;
5
6/// Captured state of a file for backup purposes.
7#[derive(Debug, Clone)]
8pub struct FileState {
9    pub content: Vec<u8>,
10    pub content_hash: String,
11    pub permissions: Option<u32>,
12    pub is_symlink: bool,
13    pub symlink_target: Option<std::path::PathBuf>,
14    /// True if the file exceeded MAX_BACKUP_FILE_SIZE and content was not captured.
15    pub oversized: bool,
16}
17
18/// Atomically write content to a file using temp-file-then-rename.
19///
20/// The temp file is created in the same directory as `target` to guarantee a
21/// same-filesystem rename (atomic on POSIX). Preserves the permissions of an
22/// existing target file if one exists. Creates parent directories as needed.
23///
24/// Returns the SHA256 hex digest of the written content.
25pub fn atomic_write(
26    target: &std::path::Path,
27    content: &[u8],
28) -> std::result::Result<String, std::io::Error> {
29    use std::io::Write;
30
31    let parent = target.parent().unwrap_or(std::path::Path::new("."));
32    std::fs::create_dir_all(parent)?;
33
34    let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
35    tmp.write_all(content)?;
36    tmp.as_file().sync_all()?;
37
38    // Preserve permissions of existing file if present. A perm-set failure
39    // here means the new content gets written with default tempfile perms
40    // (0600 on most filesystems, but NFS/FUSE can differ) — surface so callers
41    // editing security-sensitive files (SSH keys, age keys) see drift.
42    if let Ok(meta) = std::fs::metadata(target)
43        && let Err(e) = tmp.as_file().set_permissions(meta.permissions())
44    {
45        tracing::warn!(
46            target = %target.posix(),
47            error = %e,
48            "atomic_write: failed to restore permissions on temp file before rename",
49        );
50    }
51
52    let hash = sha256_hex(content);
53
54    // persist() does atomic rename on Unix
55    tmp.persist(target).map_err(|e| e.error)?;
56
57    Ok(hash)
58}
59
60/// Atomically write string content to a file.
61pub fn atomic_write_str(
62    target: &std::path::Path,
63    content: &str,
64) -> std::result::Result<String, std::io::Error> {
65    atomic_write(target, content.as_bytes())
66}
67
68/// Capture a file's content and metadata for backup.
69///
70/// Uses `symlink_metadata()` — never follows symlinks. For symlinks, captures
71/// the link target path but not the content. For regular files >10 MB, sets
72/// `oversized: true` and does not capture content.
73///
74/// Returns `None` if the file does not exist.
75pub fn capture_file_state(
76    path: &std::path::Path,
77) -> std::result::Result<Option<FileState>, std::io::Error> {
78    let symlink_meta = match std::fs::symlink_metadata(path) {
79        Ok(m) => m,
80        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
81        Err(e) => return Err(e),
82    };
83
84    if symlink_meta.file_type().is_symlink() {
85        let symlink_target = std::fs::read_link(path)?;
86        return Ok(Some(FileState {
87            content: Vec::new(),
88            content_hash: String::new(),
89            permissions: None,
90            is_symlink: true,
91            symlink_target: Some(symlink_target),
92            oversized: false,
93        }));
94    }
95
96    let permissions = file_permissions_mode(&symlink_meta);
97
98    if symlink_meta.len() > MAX_BACKUP_FILE_SIZE {
99        return Ok(Some(FileState {
100            content: Vec::new(),
101            content_hash: String::new(),
102            permissions,
103            is_symlink: false,
104            symlink_target: None,
105            oversized: true,
106        }));
107    }
108
109    let content = std::fs::read(path)?;
110    let hash = sha256_hex(&content);
111
112    Ok(Some(FileState {
113        content,
114        content_hash: hash,
115        permissions,
116        is_symlink: false,
117        symlink_target: None,
118        oversized: false,
119    }))
120}
121
122/// Like `capture_file_state`, but follows symlinks to capture the resolved
123/// content. For symlinks, `is_symlink` and `symlink_target` are recorded AND
124/// the actual file content behind the symlink is read. This is used for
125/// post-apply snapshots where we need to know both the link target and the
126/// content that was accessible through the symlink at the time of capture.
127///
128/// Returns `None` if the file does not exist (or the symlink is dangling).
129pub fn capture_file_resolved_state(
130    path: &std::path::Path,
131) -> std::result::Result<Option<FileState>, std::io::Error> {
132    let symlink_meta = match std::fs::symlink_metadata(path) {
133        Ok(m) => m,
134        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
135        Err(e) => return Err(e),
136    };
137
138    let is_symlink = symlink_meta.file_type().is_symlink();
139    let symlink_target = if is_symlink {
140        std::fs::read_link(path).ok()
141    } else {
142        None
143    };
144
145    // Read the actual content (following symlinks)
146    let real_meta = match std::fs::metadata(path) {
147        Ok(m) => m,
148        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
149            // Dangling symlink
150            return Ok(None);
151        }
152        Err(e) => return Err(e),
153    };
154
155    let permissions = file_permissions_mode(&real_meta);
156
157    if real_meta.len() > MAX_BACKUP_FILE_SIZE {
158        return Ok(Some(FileState {
159            content: Vec::new(),
160            content_hash: String::new(),
161            permissions,
162            is_symlink,
163            symlink_target,
164            oversized: true,
165        }));
166    }
167
168    let content = std::fs::read(path)?;
169    let hash = sha256_hex(&content);
170
171    Ok(Some(FileState {
172        content,
173        content_hash: hash,
174        permissions,
175        is_symlink,
176        symlink_target,
177        oversized: false,
178    }))
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use std::fs;
185    #[cfg(unix)]
186    use std::os::unix::fs as unix_fs;
187
188    #[test]
189    fn atomic_write_creates_file_and_returns_hash() {
190        let tmp = tempfile::TempDir::new().unwrap();
191        let target = tmp.path().join("out.txt");
192        let hash = atomic_write(&target, b"hello").unwrap();
193        assert_eq!(fs::read_to_string(&target).unwrap(), "hello");
194        assert!(!hash.is_empty());
195        assert_eq!(hash.len(), 64);
196    }
197
198    #[test]
199    fn atomic_write_creates_parent_dirs() {
200        let tmp = tempfile::TempDir::new().unwrap();
201        let target = tmp.path().join("a/b/c/file.txt");
202        atomic_write(&target, b"nested").unwrap();
203        assert_eq!(fs::read_to_string(&target).unwrap(), "nested");
204    }
205
206    #[test]
207    fn atomic_write_str_works() {
208        let tmp = tempfile::TempDir::new().unwrap();
209        let target = tmp.path().join("str.txt");
210        let hash = atomic_write_str(&target, "string content").unwrap();
211        assert_eq!(fs::read_to_string(&target).unwrap(), "string content");
212        assert_eq!(hash.len(), 64);
213    }
214
215    #[cfg(unix)]
216    #[test]
217    fn atomic_write_preserves_permissions() {
218        use std::os::unix::fs::PermissionsExt;
219        let tmp = tempfile::TempDir::new().unwrap();
220        let target = tmp.path().join("perms.txt");
221        fs::write(&target, "old").unwrap();
222        fs::set_permissions(&target, fs::Permissions::from_mode(0o755)).unwrap();
223        atomic_write(&target, b"new").unwrap();
224        let mode = fs::metadata(&target).unwrap().permissions().mode() & 0o777;
225        assert_eq!(mode, 0o755);
226    }
227
228    #[test]
229    fn capture_file_state_regular_file() {
230        let tmp = tempfile::TempDir::new().unwrap();
231        let path = tmp.path().join("file.txt");
232        fs::write(&path, "test content").unwrap();
233        let state = capture_file_state(&path).unwrap().unwrap();
234        assert_eq!(state.content, b"test content");
235        assert!(!state.content_hash.is_empty());
236        assert!(!state.is_symlink);
237        assert!(state.symlink_target.is_none());
238        assert!(!state.oversized);
239    }
240
241    #[test]
242    fn capture_file_state_nonexistent_returns_none() {
243        let path = std::path::Path::new("/no/such/file/abc123");
244        assert!(capture_file_state(path).unwrap().is_none());
245    }
246
247    #[cfg(unix)]
248    #[test]
249    fn capture_file_state_symlink() {
250        let tmp = tempfile::TempDir::new().unwrap();
251        let target = tmp.path().join("target.txt");
252        let link = tmp.path().join("link.txt");
253        fs::write(&target, "target content").unwrap();
254        unix_fs::symlink(&target, &link).unwrap();
255        let state = capture_file_state(&link).unwrap().unwrap();
256        assert!(state.is_symlink);
257        assert_eq!(state.symlink_target.as_deref(), Some(target.as_path()));
258        assert!(state.content.is_empty());
259    }
260
261    #[cfg(unix)]
262    #[test]
263    fn capture_file_resolved_state_follows_symlink() {
264        let tmp = tempfile::TempDir::new().unwrap();
265        let target = tmp.path().join("real.txt");
266        let link = tmp.path().join("sym.txt");
267        fs::write(&target, "resolved").unwrap();
268        unix_fs::symlink(&target, &link).unwrap();
269        let state = capture_file_resolved_state(&link).unwrap().unwrap();
270        assert!(state.is_symlink);
271        assert_eq!(state.symlink_target.as_deref(), Some(target.as_path()));
272        assert_eq!(state.content, b"resolved");
273        assert!(!state.oversized);
274    }
275
276    #[cfg(unix)]
277    #[test]
278    fn capture_file_resolved_state_dangling_symlink_returns_none() {
279        let tmp = tempfile::TempDir::new().unwrap();
280        let link = tmp.path().join("dangling.txt");
281        unix_fs::symlink("/no/such/target", &link).unwrap();
282        assert!(capture_file_resolved_state(&link).unwrap().is_none());
283    }
284
285    #[test]
286    fn capture_file_resolved_state_nonexistent_returns_none() {
287        let path = std::path::Path::new("/no/such/file/xyz");
288        assert!(capture_file_resolved_state(path).unwrap().is_none());
289    }
290}