1use std::fs::{self, File};
18use std::io;
19use std::path::{Path, PathBuf};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Overwrite {
24 Forbid,
27 Replace,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum Refused {
36 Appeared,
39 ReadOnly,
40 Directory,
41 NotAFile,
42}
43
44impl Refused {
45 pub fn of(error: &io::Error) -> Option<Self> {
47 error.get_ref()?.downcast_ref::<Self>().copied()
48 }
49
50 fn kind(self) -> io::ErrorKind {
51 match self {
52 Self::Appeared => io::ErrorKind::AlreadyExists,
53 Self::ReadOnly => io::ErrorKind::PermissionDenied,
54 Self::Directory => io::ErrorKind::IsADirectory,
55 Self::NotAFile => io::ErrorKind::InvalidInput,
56 }
57 }
58}
59
60impl std::fmt::Display for Refused {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 f.write_str(match self {
63 Self::Appeared => "a file appeared there during the export and was left as it was",
64 Self::ReadOnly => "the file is read-only",
65 Self::Directory => "it is a directory",
66 Self::NotAFile => "it is not a regular file",
67 })
68 }
69}
70
71impl std::error::Error for Refused {}
72
73impl From<Refused> for io::Error {
74 fn from(refused: Refused) -> Self {
75 io::Error::new(refused.kind(), refused)
76 }
77}
78
79#[derive(Debug)]
81pub struct OutputFile {
82 temp: tempfile::NamedTempFile,
83 target: PathBuf,
86 overwrite: Overwrite,
87}
88
89impl OutputFile {
90 pub fn create(path: &Path, overwrite: Overwrite) -> io::Result<Self> {
94 let target = resolve_link(path)?;
95 replaceable(&target, overwrite)?;
96 let dir = match target.parent() {
97 Some(dir) if !dir.as_os_str().is_empty() => dir,
98 _ => Path::new("."),
99 };
100 let temp = tempfile::Builder::new()
104 .prefix(".datui-")
105 .suffix(&temp_suffix(&target))
106 .tempfile_in(dir)?;
107 Ok(Self {
108 temp,
109 target,
110 overwrite,
111 })
112 }
113
114 pub fn file(&mut self) -> &mut File {
116 self.temp.as_file_mut()
117 }
118
119 pub fn path(&self) -> &Path {
122 self.temp.path()
123 }
124
125 pub fn commit(self) -> io::Result<()> {
137 let Self {
138 temp,
139 target,
140 overwrite,
141 } = self;
142 temp.as_file().sync_all()?;
143 match overwrite {
144 Overwrite::Replace => {
145 let existing = replaceable(&target, Overwrite::Replace)?;
146 set_final_permissions(&temp, existing.as_ref())?;
147 temp.persist(&target).map_err(|e| e.error)?;
148 Ok(())
149 }
150 Overwrite::Forbid => {
151 set_final_permissions(&temp, None)?;
152 Self::persist_new(temp, &target)
153 }
154 }
155 }
156
157 fn persist_new(temp: tempfile::NamedTempFile, target: &Path) -> io::Result<()> {
163 match temp.persist_noclobber(target) {
164 Ok(_) => Ok(()),
165 Err(e) if e.error.kind() == io::ErrorKind::AlreadyExists => {
166 Err(Refused::Appeared.into())
167 }
168 Err(e) => {
169 if fs::symlink_metadata(target).is_ok() {
170 return Err(Refused::Appeared.into());
171 }
172 e.file.persist(target).map(drop).map_err(|e| e.error)
173 }
174 }
175 }
176}
177
178fn resolve_link(path: &Path) -> io::Result<PathBuf> {
181 let mut path = path.to_path_buf();
182 for _ in 0..40 {
184 match fs::symlink_metadata(&path) {
185 Ok(meta) if meta.file_type().is_symlink() => {
186 let link = fs::read_link(&path)?;
187 path = match path.parent() {
188 Some(dir) => dir.join(link),
189 None => link,
190 };
191 }
192 _ => return Ok(path),
193 }
194 }
195 Err(io::Error::other(format!(
196 "{} is a loop of symbolic links",
197 path.display()
198 )))
199}
200
201fn replaceable(target: &Path, overwrite: Overwrite) -> io::Result<Option<fs::Permissions>> {
206 let meta = match fs::metadata(target) {
207 Ok(meta) => meta,
208 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
209 Err(e) => return Err(e),
210 };
211 if overwrite == Overwrite::Forbid {
212 return Err(Refused::Appeared.into());
213 }
214 if meta.is_dir() {
215 return Err(Refused::Directory.into());
216 }
217 if !meta.is_file() {
218 return Err(Refused::NotAFile.into());
219 }
220 if meta.permissions().readonly() {
221 return Err(Refused::ReadOnly.into());
222 }
223 fs::OpenOptions::new().write(true).open(target)?;
227 Ok(Some(meta.permissions()))
228}
229
230fn temp_suffix(target: &Path) -> String {
233 let name = target
234 .file_name()
235 .map(|n| n.to_string_lossy().into_owned())
236 .unwrap_or_default();
237 if name.len() <= 200 {
238 return format!("-{name}");
239 }
240 match target.extension() {
241 Some(ext) if ext.len() <= 32 => format!(".{}", ext.to_string_lossy()),
242 _ => String::new(),
243 }
244}
245
246#[cfg(unix)]
249fn set_final_permissions(
250 temp: &tempfile::NamedTempFile,
251 existing: Option<&fs::Permissions>,
252) -> io::Result<()> {
253 use std::os::unix::fs::PermissionsExt;
254 let permissions = match existing {
255 Some(permissions) => permissions.clone(),
256 None => fs::Permissions::from_mode(fresh_mode(temp.path())?),
257 };
258 temp.as_file().set_permissions(permissions)
259}
260
261#[cfg(not(unix))]
264fn set_final_permissions(
265 _temp: &tempfile::NamedTempFile,
266 _existing: Option<&fs::Permissions>,
267) -> io::Result<()> {
268 Ok(())
269}
270
271#[cfg(unix)]
275fn fresh_mode(temp: &Path) -> io::Result<u32> {
276 use std::os::unix::fs::PermissionsExt;
277 let dir = match temp.parent() {
278 Some(dir) if !dir.as_os_str().is_empty() => dir,
279 _ => Path::new("."),
280 };
281 let probe = tempfile::Builder::new()
282 .prefix(".datui-mode-")
283 .permissions(fs::Permissions::from_mode(0o666))
284 .tempfile_in(dir)?;
285 Ok(probe.as_file().metadata()?.permissions().mode() & 0o777)
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291 use std::io::Write;
292
293 fn leftovers(dir: &Path, keep: &[&str]) -> Vec<String> {
295 fs::read_dir(dir)
296 .unwrap()
297 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
298 .filter(|name| !keep.contains(&name.as_str()))
299 .collect()
300 }
301
302 #[test]
303 fn a_commit_writes_a_new_file() {
304 let dir = tempfile::tempdir().unwrap();
305 let path = dir.path().join("out.csv");
306 let mut out = OutputFile::create(&path, Overwrite::Forbid).unwrap();
307 out.file().write_all(b"a\n1\n").unwrap();
308 assert!(!path.exists(), "nothing lands before the commit");
309 out.commit().unwrap();
310 assert_eq!(fs::read(&path).unwrap(), b"a\n1\n");
311 assert!(leftovers(dir.path(), &["out.csv"]).is_empty());
312 }
313
314 #[test]
315 fn an_uncommitted_new_file_leaves_nothing() {
316 let dir = tempfile::tempdir().unwrap();
317 let path = dir.path().join("out.csv");
318 let mut out = OutputFile::create(&path, Overwrite::Forbid).unwrap();
319 out.file().write_all(b"partial").unwrap();
320 drop(out);
321 assert!(leftovers(dir.path(), &[]).is_empty());
322 }
323
324 #[test]
325 fn an_uncommitted_replacement_keeps_the_old_bytes() {
326 let dir = tempfile::tempdir().unwrap();
327 let path = dir.path().join("out.csv");
328 fs::write(&path, b"old").unwrap();
329 let mut out = OutputFile::create(&path, Overwrite::Replace).unwrap();
330 out.file().write_all(b"new and partial").unwrap();
331 drop(out);
332 assert_eq!(fs::read(&path).unwrap(), b"old");
333 assert!(leftovers(dir.path(), &["out.csv"]).is_empty());
334 }
335
336 #[test]
339 fn a_panic_while_writing_keeps_the_old_file() {
340 let dir = tempfile::tempdir().unwrap();
341 let path = dir.path().join("out.csv");
342 fs::write(&path, b"old").unwrap();
343 let result = std::panic::catch_unwind(|| {
344 let mut out = OutputFile::create(&path, Overwrite::Replace).unwrap();
345 out.file().write_all(b"new and partial").unwrap();
346 panic!("the writer failed");
347 });
348 assert!(result.is_err());
349 assert_eq!(fs::read(&path).unwrap(), b"old");
350 assert!(leftovers(dir.path(), &["out.csv"]).is_empty());
351 }
352
353 #[test]
354 fn an_approved_replacement_lands() {
355 let dir = tempfile::tempdir().unwrap();
356 let path = dir.path().join("out.csv");
357 fs::write(&path, b"old").unwrap();
358 let mut out = OutputFile::create(&path, Overwrite::Replace).unwrap();
359 out.file().write_all(b"new").unwrap();
360 out.commit().unwrap();
361 assert_eq!(fs::read(&path).unwrap(), b"new");
362 assert!(leftovers(dir.path(), &["out.csv"]).is_empty());
363 }
364
365 #[test]
366 fn an_existing_file_is_not_replaced_without_approval() {
367 let dir = tempfile::tempdir().unwrap();
368 let path = dir.path().join("out.csv");
369 fs::write(&path, b"old").unwrap();
370 let err = OutputFile::create(&path, Overwrite::Forbid).unwrap_err();
371 assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
372 assert_eq!(fs::read(&path).unwrap(), b"old");
373 assert!(leftovers(dir.path(), &["out.csv"]).is_empty());
374 }
375
376 #[test]
379 fn a_file_that_appears_before_the_commit_is_left_alone() {
380 let dir = tempfile::tempdir().unwrap();
381 let path = dir.path().join("out.csv");
382 let mut out = OutputFile::create(&path, Overwrite::Forbid).unwrap();
383 out.file().write_all(b"ours").unwrap();
384 fs::write(&path, b"theirs").unwrap();
385 let err = out.commit().unwrap_err();
386 assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
387 assert_eq!(fs::read(&path).unwrap(), b"theirs");
388 assert!(leftovers(dir.path(), &["out.csv"]).is_empty());
389 }
390
391 #[test]
394 fn an_approved_replacement_of_a_vanished_file_creates_it() {
395 let dir = tempfile::tempdir().unwrap();
396 let path = dir.path().join("out.csv");
397 fs::write(&path, b"old").unwrap();
398 let mut out = OutputFile::create(&path, Overwrite::Replace).unwrap();
399 out.file().write_all(b"new").unwrap();
400 fs::remove_file(&path).unwrap();
401 out.commit().unwrap();
402 assert_eq!(fs::read(&path).unwrap(), b"new");
403 }
404
405 #[test]
406 fn a_read_only_file_is_refused_and_kept() {
407 let dir = tempfile::tempdir().unwrap();
408 let path = dir.path().join("out.csv");
409 fs::write(&path, b"old").unwrap();
410 let writable = fs::metadata(&path).unwrap().permissions();
411 let mut read_only = writable.clone();
412 read_only.set_readonly(true);
413 fs::set_permissions(&path, read_only).unwrap();
414 let err = OutputFile::create(&path, Overwrite::Replace).unwrap_err();
415 assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
416 assert_eq!(fs::read(&path).unwrap(), b"old");
417 assert!(fs::metadata(&path).unwrap().permissions().readonly());
418 assert!(leftovers(dir.path(), &["out.csv"]).is_empty());
419 fs::set_permissions(&path, writable).unwrap();
421 }
422
423 #[test]
424 fn a_directory_is_refused() {
425 let dir = tempfile::tempdir().unwrap();
426 let path = dir.path().join("out.csv");
427 fs::create_dir(&path).unwrap();
428 let err = OutputFile::create(&path, Overwrite::Replace).unwrap_err();
429 assert_eq!(err.kind(), io::ErrorKind::IsADirectory);
430 assert!(path.is_dir());
431 }
432
433 #[test]
434 fn a_missing_directory_fails_before_any_work() {
435 let dir = tempfile::tempdir().unwrap();
436 let path = dir.path().join("missing").join("out.csv");
437 let err = OutputFile::create(&path, Overwrite::Forbid).unwrap_err();
438 assert_eq!(err.kind(), io::ErrorKind::NotFound);
439 }
440
441 #[test]
444 fn the_temporary_path_keeps_the_extension() {
445 let dir = tempfile::tempdir().unwrap();
446 let path = dir.path().join("chart.png");
447 let out = OutputFile::create(&path, Overwrite::Forbid).unwrap();
448 assert_eq!(out.path().extension().unwrap(), "png");
449 assert_eq!(out.path().parent(), Some(dir.path()));
450 fs::write(out.path(), b"png bytes").unwrap();
451 out.commit().unwrap();
452 assert_eq!(fs::read(&path).unwrap(), b"png bytes");
453 }
454
455 #[test]
456 fn a_long_name_keeps_its_extension() {
457 let name = format!("{}.parquet", "x".repeat(240));
458 assert_eq!(temp_suffix(Path::new(&name)), ".parquet");
459 assert_eq!(temp_suffix(Path::new("out.csv")), "-out.csv");
460 }
461
462 #[cfg(unix)]
463 mod unix {
464 use super::*;
465 use std::os::unix::fs::PermissionsExt;
466
467 fn mode(path: &Path) -> u32 {
468 fs::metadata(path).unwrap().permissions().mode() & 0o7777
469 }
470
471 #[test]
472 fn a_replacement_keeps_the_old_mode() {
473 let dir = tempfile::tempdir().unwrap();
474 let path = dir.path().join("out.csv");
475 fs::write(&path, b"old").unwrap();
476 fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
477 let mut out = OutputFile::create(&path, Overwrite::Replace).unwrap();
478 out.file().write_all(b"new").unwrap();
479 out.commit().unwrap();
480 assert_eq!(mode(&path), 0o640);
481 }
482
483 #[test]
484 fn a_failed_replacement_keeps_the_old_mode() {
485 let dir = tempfile::tempdir().unwrap();
486 let path = dir.path().join("out.csv");
487 fs::write(&path, b"old").unwrap();
488 fs::set_permissions(&path, fs::Permissions::from_mode(0o604)).unwrap();
489 let out = OutputFile::create(&path, Overwrite::Replace).unwrap();
490 drop(out);
491 assert_eq!(mode(&path), 0o604);
492 assert_eq!(fs::read(&path).unwrap(), b"old");
493 }
494
495 #[test]
499 fn a_file_we_may_not_write_is_refused() {
500 let dir = tempfile::tempdir().unwrap();
501 let path = dir.path().join("out.csv");
502 fs::write(&path, b"old").unwrap();
503 fs::set_permissions(&path, fs::Permissions::from_mode(0o060)).unwrap();
504 if fs::OpenOptions::new().write(true).open(&path).is_ok() {
505 return; }
507 let err = OutputFile::create(&path, Overwrite::Replace).unwrap_err();
508 assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
509 assert_eq!(mode(&path), 0o060);
510 assert!(leftovers(dir.path(), &["out.csv"]).is_empty());
511 }
512
513 #[test]
515 fn a_new_file_is_private_until_it_lands() {
516 let dir = tempfile::tempdir().unwrap();
517 let path = dir.path().join("out.csv");
518 let out = OutputFile::create(&path, Overwrite::Forbid).unwrap();
519 assert_eq!(mode(out.path()), 0o600);
520 let plain = dir.path().join("plain");
521 File::create(&plain).unwrap();
522 out.commit().unwrap();
523 assert_eq!(mode(&path), mode(&plain));
524 }
525
526 #[test]
530 fn a_symlink_is_written_through() {
531 let dir = tempfile::tempdir().unwrap();
532 let data = dir.path().join("data");
533 let links = dir.path().join("links");
534 fs::create_dir(&data).unwrap();
535 fs::create_dir(&links).unwrap();
536 let real = data.join("real.csv");
537 let link = links.join("link.csv");
538 fs::write(&real, b"old").unwrap();
539 std::os::unix::fs::symlink("../data/real.csv", &link).unwrap();
540 let mut out = OutputFile::create(&link, Overwrite::Replace).unwrap();
541 assert_eq!(
542 out.path().parent().unwrap().canonicalize().unwrap(),
543 data.canonicalize().unwrap()
544 );
545 out.file().write_all(b"new").unwrap();
546 out.commit().unwrap();
547 assert!(leftovers(&data, &["real.csv"]).is_empty());
548 assert!(leftovers(&links, &["link.csv"]).is_empty());
549 assert!(
550 fs::symlink_metadata(&link)
551 .unwrap()
552 .file_type()
553 .is_symlink()
554 );
555 assert_eq!(fs::read(&real).unwrap(), b"new");
556 }
557 }
558}