Skip to main content

muster/adapter/config/
yaml.rs

1use std::{
2    fs,
3    io::{ErrorKind, Write},
4    path::{Path, PathBuf},
5};
6
7use directories::ProjectDirs;
8use getset::Getters;
9use serde::Serialize;
10use typed_builder::TypedBuilder;
11
12use crate::domain::{
13    config::{ConfigError, WorkspaceConfig},
14    port::ConfigSource,
15};
16
17/// Application directory used to locate the platform config directory.
18const APP_DIR: &str = "muster";
19
20/// Path to `filename` inside muster's config directory, when one can be resolved
21/// (`~/.config/muster/<filename>` on Linux). Shared by the project registry and
22/// the settings store.
23pub(crate) fn config_dir_path(filename: &str) -> Option<PathBuf> {
24    ProjectDirs::from("", "", APP_DIR).map(|dirs| dirs.config_dir().join(filename))
25}
26
27/// Path to `filename` inside muster's platform state directory, falling back to
28/// local data on platforms without a distinct state location.
29pub(crate) fn state_dir_path(filename: &str) -> Option<PathBuf> {
30    ProjectDirs::from("", "", APP_DIR).map(|dirs| {
31        dirs.state_dir()
32            .unwrap_or_else(|| dirs.data_local_dir())
33            .join(filename)
34    })
35}
36
37/// Reads and parses a `muster.yml`-style workspace config from `path`. Shared by
38/// the single-file config source and the project registry.
39///
40/// # Errors
41/// Returns a `ConfigError` if the file cannot be read or is not valid config.
42pub(crate) fn load_workspace(path: &Path) -> Result<WorkspaceConfig, ConfigError> {
43    let raw = fs::read_to_string(path).map_err(|source| ConfigError::Read {
44        path: path.to_path_buf(),
45        source,
46    })?;
47    let config: WorkspaceConfig = serde_yaml_ng::from_str(&raw)?;
48    config.validate()?;
49    Ok(config)
50}
51
52/// Writes `value` to `path` only when nothing exists there. The complete
53/// contents land in an exclusively created, unpredictably named staging file
54/// and are published with a no-replace hard link, so a concurrent creation is
55/// never clobbered, a dangling symlink is refused, and an interrupted write
56/// can never leave a truncated destination blocking a later attempt. Where
57/// the filesystem has no hard links, publication falls back to an exclusive
58/// direct write. Returns whether the file was created.
59///
60/// # Errors
61/// Returns a `ConfigError` when serialization, the write, or the publish
62/// fails.
63pub(crate) fn create_config_new<T: Serialize>(path: &Path, value: &T) -> Result<bool, ConfigError> {
64    if let Some(parent) = path.parent()
65        && !parent.as_os_str().is_empty()
66    {
67        fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
68            path: parent.to_path_buf(),
69            source,
70        })?;
71    }
72    let raw = serde_yaml_ng::to_string(value)?;
73    let staging = write_staging(path, &raw)?;
74    let published = publish_new(&staging, path, &raw);
75    let _ = fs::remove_file(&staging);
76    published
77}
78
79/// Publishes `staging` at `path` without ever replacing an existing file: a
80/// hard link where the filesystem supports one, else an exclusive direct
81/// write of `raw`. The fallback removes its partial file when the write
82/// fails; only a hard kill mid-write can leave one behind there.
83fn publish_new(staging: &Path, path: &Path, raw: &str) -> Result<bool, ConfigError> {
84    match fs::hard_link(staging, path) {
85        Ok(()) => return Ok(true),
86        Err(source) if source.kind() == ErrorKind::AlreadyExists => return Ok(false),
87        // Any other failure (e.g. a filesystem without hard links) falls
88        // through to the exclusive direct write.
89        Err(_) => {},
90    }
91    let mut file = match fs::OpenOptions::new()
92        .write(true)
93        .create_new(true)
94        .open(path)
95    {
96        Ok(file) => file,
97        Err(source) if source.kind() == ErrorKind::AlreadyExists => return Ok(false),
98        Err(source) => {
99            return Err(ConfigError::Write {
100                path: path.to_path_buf(),
101                source,
102            });
103        },
104    };
105    file.write_all(raw.as_bytes()).map_err(|source| {
106        let _ = fs::remove_file(path);
107        ConfigError::Write {
108            path: path.to_path_buf(),
109            source,
110        }
111    })?;
112    Ok(true)
113}
114
115/// Creates an exclusive staging file beside `path` and writes `raw` into it.
116/// The exclusive open never follows a pre-planted symlink, and the
117/// unpredictable name defeats guessing the path in shared directories.
118fn write_staging(path: &Path, raw: &str) -> Result<PathBuf, ConfigError> {
119    let staging = staging_path(path);
120    let mut file = fs::OpenOptions::new()
121        .write(true)
122        .create_new(true)
123        .open(&staging)
124        .map_err(|source| ConfigError::Write {
125            path: staging.clone(),
126            source,
127        })?;
128    file.write_all(raw.as_bytes()).map_err(|source| {
129        let _ = fs::remove_file(&staging);
130        ConfigError::Write {
131            path: staging.clone(),
132            source,
133        }
134    })?;
135    Ok(staging)
136}
137
138/// An unpredictable sibling staging path for `path`.
139fn staging_path(path: &Path) -> PathBuf {
140    let mut name = path.file_name().unwrap_or_default().to_os_string();
141    name.push(format!(".{}{STAGING_SUFFIX}", uuid::Uuid::new_v4()));
142    path.with_file_name(name)
143}
144
145/// Serializes `value` to YAML and writes it to `path`, creating any missing
146/// parent directories first. The write is atomic: it lands in a sibling
147/// temporary file that is then renamed over the destination, so a crash, full
148/// disk, or short write can never truncate an existing valid file.
149///
150/// An existing symlink is followed so its target is rewritten rather than
151/// replaced with a regular file. A parentless relative path (e.g. `muster.yml`)
152/// writes into the current directory.
153///
154/// # Errors
155/// Returns a `ConfigError::Write` if a directory, temp file, or rename fails.
156pub(crate) fn write_config<T: Serialize>(path: &Path, value: &T) -> Result<(), ConfigError> {
157    let dest = resolve_destination(path);
158    if let Some(parent) = dest.parent()
159        && !parent.as_os_str().is_empty()
160    {
161        fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
162            path: parent.to_path_buf(),
163            source,
164        })?;
165    }
166    let raw = serde_yaml_ng::to_string(value)?;
167    let staging = write_staging(&dest, &raw)?;
168    // A user-restricted destination (e.g. chmod 600) keeps its mode across
169    // the staged rename.
170    if let Ok(existing) = fs::metadata(&dest) {
171        let _ = fs::set_permissions(&staging, existing.permissions());
172    }
173    fs::rename(&staging, &dest).map_err(|source| {
174        let _ = fs::remove_file(&staging);
175        ConfigError::Write {
176            path: dest.clone(),
177            source,
178        }
179    })
180}
181
182/// Most symlink links followed before giving up on a destination chain.
183const MAX_CONFIG_SYMLINKS: usize = 40;
184
185/// The final write target behind any chain of symlinks, so replacing a config
186/// rewrites the linked file instead of destroying the link - even when the
187/// chain ends at a target that does not exist yet (a dotfiles-managed config
188/// whose file is first created through the link).
189fn resolve_destination(path: &Path) -> PathBuf {
190    let mut dest = path.to_path_buf();
191    for _ in 0..MAX_CONFIG_SYMLINKS {
192        match fs::read_link(&dest) {
193            Ok(target) if target.is_absolute() => dest = target,
194            Ok(target) => {
195                dest = dest
196                    .parent()
197                    .map(|parent| parent.join(&target))
198                    .unwrap_or(target);
199            },
200            Err(_) => break,
201        }
202    }
203    dest.canonicalize().unwrap_or(dest)
204}
205
206/// Suffix marking a staging file awaiting publication. Staging lives beside
207/// its destination so the rename stays on one filesystem and is atomic.
208const STAGING_SUFFIX: &str = ".tmp";
209
210/// A [`ConfigSource`] that loads a `muster.yml`-style file from disk.
211#[derive(Clone, Debug, Getters, TypedBuilder)]
212#[getset(get = "pub")]
213pub struct YamlConfigSource {
214    /// Path to the YAML config file.
215    path: PathBuf,
216}
217
218impl ConfigSource for YamlConfigSource {
219    fn load(&self) -> Result<WorkspaceConfig, ConfigError> {
220        load_workspace(&self.path)
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    /// The exclusive create writes once and never clobbers an existing file.
229    #[test]
230    fn create_config_new_refuses_to_overwrite() {
231        let dir = std::env::temp_dir().join(format!("muster-create-{}", std::process::id()));
232        let path = dir.join("muster.yml");
233        std::fs::create_dir_all(&dir).unwrap();
234
235        assert!(create_config_new(&path, &"first").unwrap(), "first create");
236        assert!(
237            !create_config_new(&path, &"second").unwrap(),
238            "existing file wins"
239        );
240        assert_eq!(std::fs::read_to_string(&path).unwrap().trim(), "first");
241        let residue = std::fs::read_dir(&dir)
242            .unwrap()
243            .filter_map(Result::ok)
244            .filter(|entry| entry.file_name().to_string_lossy().ends_with(".tmp"))
245            .count();
246        assert_eq!(residue, 0, "no temporary files survive either outcome");
247        std::fs::remove_dir_all(dir).unwrap();
248    }
249
250    /// A dangling symlink at the destination is refused, not followed.
251    #[cfg(unix)]
252    #[test]
253    fn create_config_new_refuses_a_dangling_symlink_destination() {
254        use std::os::unix::fs::symlink;
255
256        let dir = std::env::temp_dir().join(format!("muster-create-link-{}", std::process::id()));
257        let path = dir.join("muster.yml");
258        std::fs::create_dir_all(&dir).unwrap();
259        symlink(dir.join("missing.yml"), &path).unwrap();
260
261        assert!(
262            !create_config_new(&path, &"content").unwrap(),
263            "an occupied path, even a dangling symlink, is never replaced"
264        );
265        assert!(
266            !dir.join("missing.yml").exists(),
267            "nothing was written through the symlink"
268        );
269        std::fs::remove_dir_all(dir).unwrap();
270    }
271
272    /// Writing through a dangling symlink creates the link's target instead
273    /// of replacing the link with a regular file.
274    #[cfg(unix)]
275    #[test]
276    fn write_config_preserves_a_dangling_symlink_destination() {
277        use std::os::unix::fs::symlink;
278
279        let dir = std::env::temp_dir().join(format!("muster-dangle-{}", std::process::id()));
280        let target = dir.join("dotfiles").join("projects.yml");
281        let link = dir.join("projects.yml");
282        std::fs::create_dir_all(dir.join("dotfiles")).unwrap();
283        symlink(&target, &link).unwrap();
284
285        write_config(&link, &"content").unwrap();
286
287        assert!(
288            std::fs::symlink_metadata(&link)
289                .unwrap()
290                .file_type()
291                .is_symlink(),
292            "the link survives"
293        );
294        assert_eq!(
295            std::fs::read_to_string(&target).unwrap().trim(),
296            "content",
297            "the write landed at the link target"
298        );
299        std::fs::remove_dir_all(dir).unwrap();
300    }
301
302    /// A restricted destination keeps its permission bits across a rewrite.
303    #[cfg(unix)]
304    #[test]
305    fn write_config_preserves_restrictive_permissions() {
306        use std::os::unix::fs::PermissionsExt;
307
308        /// The user-only mode that must survive the staged rename.
309        const PRIVATE_MODE: u32 = 0o600;
310        let dir = std::env::temp_dir().join(format!("muster-mode-{}", std::process::id()));
311        let path = dir.join("projects.yml");
312        std::fs::create_dir_all(&dir).unwrap();
313        std::fs::write(&path, "old").unwrap();
314        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(PRIVATE_MODE)).unwrap();
315
316        write_config(&path, &"new").unwrap();
317
318        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
319        assert_eq!(mode, PRIVATE_MODE, "mode survives the rewrite");
320        std::fs::remove_dir_all(dir).unwrap();
321    }
322
323    fn example_config() -> PathBuf {
324        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/muster.yml")
325    }
326
327    #[test]
328    fn loads_the_example_config() {
329        let source = YamlConfigSource::builder().path(example_config()).build();
330        let config = source.load().unwrap();
331        assert!(!config.to_processes().is_empty());
332    }
333
334    #[test]
335    fn missing_file_is_a_read_error() {
336        let source = YamlConfigSource::builder()
337            .path(PathBuf::from("/nonexistent/muster.yml"))
338            .build();
339        assert!(matches!(source.load(), Err(ConfigError::Read { .. })));
340    }
341}