Skip to main content

astrid_core/groups/
io_impl.rs

1//! Atomic on-disk persistence for [`GroupConfig`] (issue #672, Layer 6).
2//!
3//! Mirrors [`crate::profile::io_impl`]: a tempfile with `0o600` is written
4//! next to the target, then renamed atomically. A failed rename cleans up
5//! the tempfile so secret-adjacent state never leaks. Only custom groups
6//! are serialized — built-ins are baked in and rebuilt on load.
7
8use std::collections::HashMap;
9use std::fs;
10use std::io;
11use std::path::Path;
12use std::sync::atomic::{AtomicU64, Ordering};
13
14use serde::Serialize;
15
16use super::{Group, GroupConfig, GroupConfigError, GroupConfigResult, is_builtin};
17use crate::dirs::AstridHome;
18
19/// Wire shape for the on-disk `groups.toml` file — mirrors the private
20/// `GroupsFile` loader struct but owns the data so we can serialize it.
21#[derive(Debug, Default, Serialize)]
22struct GroupsFileOwned {
23    #[serde(skip_serializing_if = "HashMap::is_empty")]
24    groups: HashMap<String, Group>,
25}
26
27impl GroupConfig {
28    /// Save the config's **custom** groups to `home`'s `etc/groups.toml`,
29    /// creating `etc/` if needed.
30    ///
31    /// Built-in groups are never serialized — they are baked into
32    /// [`GroupConfig::builtin_only`] and rebuilt on load. The result is
33    /// idempotent: loading the written file back yields the same in-memory
34    /// config.
35    ///
36    /// # Errors
37    ///
38    /// See [`Self::save_to_path`].
39    pub fn save(&self, home: &AstridHome) -> GroupConfigResult<()> {
40        self.save_to_path(&Self::path_for(home))
41    }
42
43    /// Save to an explicit path. See [`Self::save`] for semantics.
44    ///
45    /// # Errors
46    ///
47    /// - [`GroupConfigError::Io`] on filesystem failure (parent create,
48    ///   tempfile open/write, rename).
49    /// - `GroupConfigError::Parse` never — serialization is infallible
50    ///   for the shape we produce.
51    pub fn save_to_path(&self, path: &Path) -> GroupConfigResult<()> {
52        let mut custom = HashMap::new();
53        for (name, group) in &self.groups {
54            if is_builtin(name) {
55                continue;
56            }
57            custom.insert(name.clone(), group.clone());
58        }
59        let file = GroupsFileOwned { groups: custom };
60        let content = toml::to_string_pretty(&file).map_err(|e| {
61            GroupConfigError::Io(io::Error::other(format!(
62                "failed to serialize groups.toml: {e}"
63            )))
64        })?;
65        write_atomic(path, content.as_bytes())
66    }
67}
68
69static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
70
71fn write_atomic(path: &Path, data: &[u8]) -> GroupConfigResult<()> {
72    let parent = path.parent().ok_or_else(|| {
73        GroupConfigError::Io(io::Error::new(
74            io::ErrorKind::InvalidInput,
75            "groups path has no parent directory",
76        ))
77    })?;
78    fs::create_dir_all(parent)?;
79
80    #[cfg(unix)]
81    {
82        use std::io::Write;
83        use std::os::unix::fs::OpenOptionsExt;
84
85        let seq = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
86        let tmp_path = path.with_extension(format!("toml.tmp.{}.{seq}", std::process::id()));
87        let mut f = fs::OpenOptions::new()
88            .write(true)
89            .create(true)
90            .truncate(true)
91            .mode(0o600)
92            .open(&tmp_path)?;
93        f.write_all(data)?;
94        f.sync_all()?;
95        drop(f);
96
97        if let Err(e) = fs::rename(&tmp_path, path) {
98            let _ = fs::remove_file(&tmp_path);
99            return Err(GroupConfigError::Io(e));
100        }
101    }
102
103    #[cfg(not(unix))]
104    {
105        fs::write(path, data)?;
106    }
107
108    Ok(())
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    use crate::groups::{BUILTIN_ADMIN, BUILTIN_AGENT, BUILTIN_RESTRICTED, Group, GroupConfig};
116
117    use tempfile::tempdir;
118
119    fn custom_group(caps: &[&str]) -> Group {
120        Group {
121            capabilities: caps.iter().map(|s| (*s).to_string()).collect(),
122            description: None,
123            unsafe_admin: false,
124        }
125    }
126
127    #[test]
128    fn save_then_load_roundtrips_custom_groups() {
129        let dir = tempdir().unwrap();
130        let path = dir.path().join("groups.toml");
131
132        let base = GroupConfig::builtin_only();
133        let with_ops = base
134            .insert_custom_group("ops".to_string(), custom_group(&["capsule:install"]))
135            .unwrap();
136
137        with_ops.save_to_path(&path).unwrap();
138        let loaded = GroupConfig::load_from_path(&path).unwrap();
139        assert!(loaded.get("ops").is_some());
140        assert_eq!(
141            loaded.get("ops").unwrap().capabilities,
142            vec!["capsule:install".to_string()]
143        );
144        // Built-ins are still there after load.
145        assert!(loaded.get(BUILTIN_ADMIN).is_some());
146        assert!(loaded.get(BUILTIN_AGENT).is_some());
147        assert!(loaded.get(BUILTIN_RESTRICTED).is_some());
148    }
149
150    #[test]
151    fn save_does_not_persist_builtins_to_disk() {
152        let dir = tempdir().unwrap();
153        let path = dir.path().join("groups.toml");
154
155        GroupConfig::builtin_only().save_to_path(&path).unwrap();
156        let raw = fs::read_to_string(&path).unwrap();
157        assert!(!raw.contains("[groups.admin]"));
158        assert!(!raw.contains("[groups.agent]"));
159        assert!(!raw.contains("[groups.restricted]"));
160    }
161
162    #[cfg(unix)]
163    #[test]
164    fn save_writes_mode_0600() {
165        use std::os::unix::fs::PermissionsExt;
166
167        let dir = tempdir().unwrap();
168        let path = dir.path().join("groups.toml");
169        GroupConfig::builtin_only().save_to_path(&path).unwrap();
170        let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
171        assert_eq!(mode, 0o600);
172    }
173
174    #[cfg(unix)]
175    #[test]
176    fn save_does_not_leave_temp_file_on_success() {
177        let dir = tempdir().unwrap();
178        let path = dir.path().join("groups.toml");
179        GroupConfig::builtin_only().save_to_path(&path).unwrap();
180        let entries: Vec<_> = fs::read_dir(dir.path())
181            .unwrap()
182            .filter_map(Result::ok)
183            .map(|e| e.file_name().to_string_lossy().into_owned())
184            .collect();
185        assert!(entries.contains(&"groups.toml".to_string()));
186        assert!(
187            !entries.iter().any(|n| n.contains(".tmp.")),
188            "temp files should be renamed away: {entries:?}"
189        );
190    }
191
192    #[test]
193    fn save_creates_parent_directory_if_missing() {
194        let dir = tempdir().unwrap();
195        let nested = dir.path().join("a").join("b");
196        let path = nested.join("groups.toml");
197        assert!(!nested.exists());
198        GroupConfig::builtin_only().save_to_path(&path).unwrap();
199        assert!(path.exists());
200    }
201
202    #[cfg(unix)]
203    #[test]
204    fn save_atomic_rename_failure_cleans_up_tempfile() {
205        // Target path is a directory — rename(file, dir) fails. The tempfile
206        // must be removed on the error path so no secret-adjacent stale
207        // tempfile is left behind.
208        let dir = tempdir().unwrap();
209        let dir_path = dir.path().join("groups.toml"); // we'll make it a directory
210        fs::create_dir(&dir_path).unwrap();
211
212        let err = GroupConfig::builtin_only().save_to_path(&dir_path);
213        assert!(err.is_err());
214
215        let entries: Vec<_> = fs::read_dir(dir.path())
216            .unwrap()
217            .filter_map(Result::ok)
218            .map(|e| e.file_name().to_string_lossy().into_owned())
219            .collect();
220        assert!(
221            !entries.iter().any(|n| n.contains(".tmp.")),
222            "failed rename must not leave temp file behind: {entries:?}"
223        );
224    }
225}