cfgd_core/util/
file_io.rs1use super::constants::MAX_BACKUP_FILE_SIZE;
2use super::fs_perms::file_permissions_mode;
3use super::hashing::sha256_hex;
4use super::paths::PathDisplayExt;
5
6#[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 pub oversized: bool,
16}
17
18pub 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 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 tmp.persist(target).map_err(|e| e.error)?;
56
57 Ok(hash)
58}
59
60pub 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
68pub 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
122pub 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 let real_meta = match std::fs::metadata(path) {
147 Ok(m) => m,
148 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
149 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}