astrid_core/groups/
io_impl.rs1use 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#[derive(Debug, Default, Serialize)]
22struct GroupsFileOwned {
23 #[serde(skip_serializing_if = "HashMap::is_empty")]
24 groups: HashMap<String, Group>,
25}
26
27impl GroupConfig {
28 pub fn save(&self, home: &AstridHome) -> GroupConfigResult<()> {
40 self.save_to_path(&Self::path_for(home))
41 }
42
43 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 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 let dir = tempdir().unwrap();
209 let dir_path = dir.path().join("groups.toml"); 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}