Skip to main content

prns_config/editing/
store.rs

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}