1use std::fmt;
2use std::fs;
3use std::io::{self, Write};
4use std::path::{Path, PathBuf};
5
6use tempfile::NamedTempFile;
7
8use crate::configobj::{ConfigDocument, ConfigError};
9
10use super::document::EditedConfig;
11
12#[derive(Debug, Clone)]
13pub struct ConfigFile {
14 path: PathBuf,
15 original: Option<Vec<u8>>,
16 document: ConfigDocument,
17}
18
19impl ConfigFile {
20 pub fn load(path: impl Into<PathBuf>, fallback: &str) -> Result<Self, ConfigFileError> {
21 let path = path.into();
22 let (original, source) = match fs::read(&path) {
23 Ok(bytes) => {
24 let source = String::from_utf8(bytes.clone()).map_err(ConfigFileError::Encoding)?;
25 (Some(bytes), source)
26 }
27 Err(error) if error.kind() == io::ErrorKind::NotFound => (None, fallback.to_string()),
28 Err(source) => {
29 return Err(ConfigFileError::Io {
30 operation: ConfigFileOperation::Read,
31 source,
32 })
33 }
34 };
35 let document = ConfigDocument::parse(&source).map_err(ConfigFileError::Syntax)?;
36 Ok(Self {
37 path,
38 original,
39 document,
40 })
41 }
42
43 pub fn path(&self) -> &Path {
44 &self.path
45 }
46
47 pub fn document(&self) -> &ConfigDocument {
48 &self.document
49 }
50
51 pub fn is_materialized(&self) -> bool {
52 self.original.is_some()
53 }
54
55 pub fn write(&self, edited: &EditedConfig) -> Result<ConfigWriteReceipt, ConfigFileError> {
56 if edited.original() != self.document.source() {
57 return Err(ConfigFileError::CandidateSourceMismatch);
58 }
59 let parent = self.path.parent().ok_or(ConfigFileError::MissingParent)?;
60 fs::create_dir_all(parent).map_err(|source| ConfigFileError::Io {
61 operation: ConfigFileOperation::CreateDirectory,
62 source,
63 })?;
64 let current = match fs::read(&self.path) {
65 Ok(bytes) => Some(bytes),
66 Err(error) if error.kind() == io::ErrorKind::NotFound => None,
67 Err(source) => {
68 return Err(ConfigFileError::Io {
69 operation: ConfigFileOperation::ReadBeforeWrite,
70 source,
71 })
72 }
73 };
74 if current != self.original {
75 return Err(ConfigFileError::ConcurrentModification);
76 }
77 let permissions = current
78 .as_ref()
79 .and_then(|_| fs::metadata(&self.path).ok())
80 .map(|metadata| metadata.permissions());
81 let backup = match current.as_ref() {
82 Some(bytes) => {
83 let backup = backup_path(&self.path);
84 atomic_write(&backup, bytes, permissions.clone())?;
85 Some(backup)
86 }
87 None => None,
88 };
89 let installed = edited.candidate().as_bytes().to_vec();
90 atomic_write(&self.path, &installed, permissions)?;
91 Ok(ConfigWriteReceipt {
92 path: self.path.clone(),
93 backup,
94 previous: current,
95 installed,
96 })
97 }
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct ConfigWriteReceipt {
102 path: PathBuf,
103 backup: Option<PathBuf>,
104 previous: Option<Vec<u8>>,
105 installed: Vec<u8>,
106}
107
108impl ConfigWriteReceipt {
109 pub fn path(&self) -> &Path {
110 &self.path
111 }
112
113 pub fn backup(&self) -> Option<&Path> {
114 self.backup.as_deref()
115 }
116
117 pub const fn created(&self) -> bool {
118 self.previous.is_none()
119 }
120
121 pub fn rollback(self) -> Result<(), ConfigFileError> {
122 let current = match fs::read(&self.path) {
123 Ok(bytes) => Some(bytes),
124 Err(error) if error.kind() == io::ErrorKind::NotFound => None,
125 Err(source) => {
126 return Err(ConfigFileError::Io {
127 operation: ConfigFileOperation::ReadBeforeRollback,
128 source,
129 })
130 }
131 };
132 if current.as_deref() != Some(self.installed.as_slice()) {
133 return Err(ConfigFileError::ConcurrentModification);
134 }
135 let parent = self.path.parent().ok_or(ConfigFileError::MissingParent)?;
136 match self.previous {
137 Some(previous) => {
138 let permissions = fs::metadata(&self.path)
139 .ok()
140 .map(|metadata| metadata.permissions());
141 atomic_write(&self.path, &previous, permissions)
142 }
143 None => {
144 fs::remove_file(&self.path).map_err(|source| ConfigFileError::Io {
145 operation: ConfigFileOperation::Remove,
146 source,
147 })?;
148 sync_parent(parent)
149 }
150 }
151 }
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum ConfigFileOperation {
156 Read,
157 CreateDirectory,
158 ReadBeforeWrite,
159 ReadBeforeRollback,
160 CreateTemporary,
161 SetPermissions,
162 WriteTemporary,
163 Persist,
164 Remove,
165 SyncDirectory,
166}
167
168impl fmt::Display for ConfigFileOperation {
169 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
170 formatter.write_str(match self {
171 Self::Read => "read configuration",
172 Self::CreateDirectory => "create configuration directory",
173 Self::ReadBeforeWrite => "re-read configuration before writing",
174 Self::ReadBeforeRollback => "re-read configuration before restoring it",
175 Self::CreateTemporary => "create temporary configuration",
176 Self::SetPermissions => "set configuration permissions",
177 Self::WriteTemporary => "write temporary configuration",
178 Self::Persist => "replace configuration",
179 Self::Remove => "remove newly created configuration",
180 Self::SyncDirectory => "sync configuration directory",
181 })
182 }
183}
184
185#[derive(Debug)]
186pub enum ConfigFileError {
187 Io {
188 operation: ConfigFileOperation,
189 source: io::Error,
190 },
191 Encoding(std::string::FromUtf8Error),
192 Syntax(ConfigError),
193 MissingParent,
194 CandidateSourceMismatch,
195 ConcurrentModification,
196}
197
198impl fmt::Display for ConfigFileError {
199 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
200 match self {
201 Self::Io { operation, source } => write!(formatter, "could not {operation}: {source}"),
202 Self::Encoding(_) => formatter.write_str("configuration is not valid UTF-8"),
203 Self::Syntax(error) => error.fmt(formatter),
204 Self::MissingParent => {
205 formatter.write_str("configuration path has no parent directory")
206 }
207 Self::CandidateSourceMismatch => {
208 formatter.write_str("edited configuration does not belong to the loaded source")
209 }
210 Self::ConcurrentModification => formatter
211 .write_str("configuration changed after it was loaded; no file was overwritten"),
212 }
213 }
214}
215
216impl std::error::Error for ConfigFileError {
217 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
218 match self {
219 Self::Io { source, .. } => Some(source),
220 Self::Encoding(source) => Some(source),
221 Self::Syntax(source) => Some(source),
222 Self::MissingParent | Self::CandidateSourceMismatch | Self::ConcurrentModification => {
223 None
224 }
225 }
226 }
227}
228
229fn backup_path(path: &Path) -> PathBuf {
230 let file_name = path.file_name().unwrap_or_default().to_string_lossy();
231 path.with_file_name(format!("{file_name}.prns-backup"))
232}
233
234fn atomic_write(
235 path: &Path,
236 bytes: &[u8],
237 permissions: Option<fs::Permissions>,
238) -> Result<(), ConfigFileError> {
239 let parent = path.parent().ok_or(ConfigFileError::MissingParent)?;
240 let mut temporary = NamedTempFile::new_in(parent).map_err(|source| ConfigFileError::Io {
241 operation: ConfigFileOperation::CreateTemporary,
242 source,
243 })?;
244 if let Some(permissions) = permissions {
245 temporary
246 .as_file()
247 .set_permissions(permissions)
248 .map_err(|source| ConfigFileError::Io {
249 operation: ConfigFileOperation::SetPermissions,
250 source,
251 })?;
252 } else {
253 protect_new_file(temporary.as_file())?;
254 }
255 temporary
256 .write_all(bytes)
257 .and_then(|()| temporary.as_file().sync_all())
258 .map_err(|source| ConfigFileError::Io {
259 operation: ConfigFileOperation::WriteTemporary,
260 source,
261 })?;
262 temporary
263 .persist(path)
264 .map_err(|error| ConfigFileError::Io {
265 operation: ConfigFileOperation::Persist,
266 source: error.error,
267 })?;
268 sync_parent(parent)
269}
270
271#[cfg(unix)]
272fn protect_new_file(file: &fs::File) -> Result<(), ConfigFileError> {
273 use std::os::unix::fs::PermissionsExt;
274 file.set_permissions(fs::Permissions::from_mode(0o600))
275 .map_err(|source| ConfigFileError::Io {
276 operation: ConfigFileOperation::SetPermissions,
277 source,
278 })
279}
280
281#[cfg(not(unix))]
282fn protect_new_file(_file: &fs::File) -> Result<(), ConfigFileError> {
283 Ok(())
284}
285
286#[cfg(unix)]
287fn sync_parent(parent: &Path) -> Result<(), ConfigFileError> {
288 fs::File::open(parent)
289 .and_then(|directory| directory.sync_all())
290 .map_err(|source| ConfigFileError::Io {
291 operation: ConfigFileOperation::SyncDirectory,
292 source,
293 })
294}
295
296#[cfg(not(unix))]
297fn sync_parent(_parent: &Path) -> Result<(), ConfigFileError> {
298 Ok(())
299}