datui_lib/export/
output_file.rs1use std::fs::{self, File};
10use std::io;
11use std::path::{Path, PathBuf};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum Overwrite {
16 Forbid,
19 Replace,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Refused {
28 Appeared,
31 ReadOnly,
32 Directory,
33 NotAFile,
34}
35
36impl Refused {
37 pub fn of(error: &io::Error) -> Option<Self> {
39 error.get_ref()?.downcast_ref::<Self>().copied()
40 }
41
42 fn kind(self) -> io::ErrorKind {
43 match self {
44 Self::Appeared => io::ErrorKind::AlreadyExists,
45 Self::ReadOnly => io::ErrorKind::PermissionDenied,
46 Self::Directory => io::ErrorKind::IsADirectory,
47 Self::NotAFile => io::ErrorKind::InvalidInput,
48 }
49 }
50}
51
52impl std::fmt::Display for Refused {
53 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54 f.write_str(match self {
55 Self::Appeared => "a file appeared there during the export and was left as it was",
56 Self::ReadOnly => "the file is read-only",
57 Self::Directory => "it is a directory",
58 Self::NotAFile => "it is not a regular file",
59 })
60 }
61}
62
63impl std::error::Error for Refused {}
64
65impl From<Refused> for io::Error {
66 fn from(refused: Refused) -> Self {
67 io::Error::new(refused.kind(), refused)
68 }
69}
70
71#[derive(Debug)]
73pub struct OutputFile {
74 temp: tempfile::NamedTempFile,
75 target: PathBuf,
78 overwrite: Overwrite,
79}
80
81impl OutputFile {
82 pub fn create(path: &Path, overwrite: Overwrite) -> io::Result<Self> {
86 let target = resolve_link(path)?;
87 replaceable(&target, overwrite)?;
88 let dir = match target.parent() {
89 Some(dir) if !dir.as_os_str().is_empty() => dir,
90 _ => Path::new("."),
91 };
92 let temp = tempfile::Builder::new()
96 .prefix(".datui-")
97 .suffix(&temp_suffix(&target))
98 .tempfile_in(dir)?;
99 Ok(Self {
100 temp,
101 target,
102 overwrite,
103 })
104 }
105
106 pub fn file(&mut self) -> &mut File {
108 self.temp.as_file_mut()
109 }
110
111 pub fn path(&self) -> &Path {
114 self.temp.path()
115 }
116
117 pub fn commit(self) -> io::Result<()> {
123 let Self {
124 temp,
125 target,
126 overwrite,
127 } = self;
128 temp.as_file().sync_all()?;
129 match overwrite {
130 Overwrite::Replace => {
131 let existing = replaceable(&target, Overwrite::Replace)?;
132 set_final_permissions(&temp, existing.as_ref())?;
133 temp.persist(&target).map_err(|e| e.error)?;
134 Ok(())
135 }
136 Overwrite::Forbid => {
137 set_final_permissions(&temp, None)?;
138 Self::persist_new(temp, &target)
139 }
140 }
141 }
142
143 fn persist_new(temp: tempfile::NamedTempFile, target: &Path) -> io::Result<()> {
147 match temp.persist_noclobber(target) {
148 Ok(_) => Ok(()),
149 Err(e) if e.error.kind() == io::ErrorKind::AlreadyExists => {
150 Err(Refused::Appeared.into())
151 }
152 Err(e) => {
153 if fs::symlink_metadata(target).is_ok() {
154 return Err(Refused::Appeared.into());
155 }
156 e.file.persist(target).map(drop).map_err(|e| e.error)
157 }
158 }
159 }
160}
161
162fn resolve_link(path: &Path) -> io::Result<PathBuf> {
165 let mut path = path.to_path_buf();
166 for _ in 0..40 {
168 match fs::symlink_metadata(&path) {
169 Ok(meta) if meta.file_type().is_symlink() => {
170 let link = fs::read_link(&path)?;
171 path = match path.parent() {
172 Some(dir) => dir.join(link),
173 None => link,
174 };
175 }
176 _ => return Ok(path),
177 }
178 }
179 Err(io::Error::other(format!(
180 "{} is a loop of symbolic links",
181 path.display()
182 )))
183}
184
185fn replaceable(target: &Path, overwrite: Overwrite) -> io::Result<Option<fs::Permissions>> {
190 let meta = match fs::metadata(target) {
191 Ok(meta) => meta,
192 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
193 Err(e) => return Err(e),
194 };
195 if overwrite == Overwrite::Forbid {
196 return Err(Refused::Appeared.into());
197 }
198 if meta.is_dir() {
199 return Err(Refused::Directory.into());
200 }
201 if !meta.is_file() {
202 return Err(Refused::NotAFile.into());
203 }
204 if meta.permissions().readonly() {
205 return Err(Refused::ReadOnly.into());
206 }
207 fs::OpenOptions::new().write(true).open(target)?;
211 Ok(Some(meta.permissions()))
212}
213
214fn temp_suffix(target: &Path) -> String {
217 let name = target
218 .file_name()
219 .map(|n| n.to_string_lossy().into_owned())
220 .unwrap_or_default();
221 if name.len() <= 200 {
222 return format!("-{name}");
223 }
224 match target.extension() {
225 Some(ext) if ext.len() <= 32 => format!(".{}", ext.to_string_lossy()),
226 _ => String::new(),
227 }
228}
229
230#[cfg(unix)]
233fn set_final_permissions(
234 temp: &tempfile::NamedTempFile,
235 existing: Option<&fs::Permissions>,
236) -> io::Result<()> {
237 use std::os::unix::fs::PermissionsExt;
238 let permissions = match existing {
239 Some(permissions) => permissions.clone(),
240 None => fs::Permissions::from_mode(fresh_mode(temp.path())?),
241 };
242 temp.as_file().set_permissions(permissions)
243}
244
245#[cfg(not(unix))]
248fn set_final_permissions(
249 _temp: &tempfile::NamedTempFile,
250 _existing: Option<&fs::Permissions>,
251) -> io::Result<()> {
252 Ok(())
253}
254
255#[cfg(unix)]
259fn fresh_mode(temp: &Path) -> io::Result<u32> {
260 use std::os::unix::fs::PermissionsExt;
261 let dir = match temp.parent() {
262 Some(dir) if !dir.as_os_str().is_empty() => dir,
263 _ => Path::new("."),
264 };
265 let probe = tempfile::Builder::new()
266 .prefix(".datui-mode-")
267 .permissions(fs::Permissions::from_mode(0o666))
268 .tempfile_in(dir)?;
269 Ok(probe.as_file().metadata()?.permissions().mode() & 0o777)
270}
271
272#[cfg(test)]
273mod tests;