codeswarm_adapters/
settings.rs1use std::fs::{self, File, OpenOptions};
9use std::io::{self, Write};
10use std::path::{Path, PathBuf};
11use std::sync::atomic::{AtomicU64, Ordering};
12
13use serde_json::{Map, Value};
14
15static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
16
17pub fn read_object(path: impl AsRef<Path>) -> io::Result<Map<String, Value>> {
19 let path = path.as_ref();
20 let raw = match fs::read_to_string(path) {
21 Ok(raw) => raw,
22 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Map::new()),
23 Err(error) => return Err(error),
24 };
25 let value = serde_json::from_str::<Value>(&raw).map_err(|error| {
26 io::Error::new(
27 io::ErrorKind::InvalidData,
28 format!("settings file is not valid JSON: {error}"),
29 )
30 })?;
31 value.as_object().cloned().ok_or_else(|| {
32 io::Error::new(
33 io::ErrorKind::InvalidData,
34 "settings file must contain a JSON object",
35 )
36 })
37}
38
39pub fn update<F>(path: impl AsRef<Path>, edit: F) -> io::Result<()>
41where
42 F: FnOnce(&mut Map<String, Value>),
43{
44 let path = path.as_ref();
45 let settings = read_object(path)?;
46 let mut updated = settings;
47 edit(&mut updated);
48 atomic_write(path, &Value::Object(updated))
49}
50
51pub fn atomic_write(path: &Path, value: &Value) -> io::Result<()> {
53 let Some(parent) = path.parent() else {
54 return Err(io::Error::new(
55 io::ErrorKind::InvalidInput,
56 "settings path has no parent directory",
57 ));
58 };
59 create_private_directory(parent)?;
60
61 let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
62 let temporary = PathBuf::from(format!(
63 ".{}.tmp.{}.{}",
64 path.file_name()
65 .and_then(|name| name.to_str())
66 .unwrap_or("settings"),
67 std::process::id(),
68 counter
69 ));
70 let temporary = parent.join(temporary);
71 let result = (|| {
72 let mut options = OpenOptions::new();
73 options.create_new(true).write(true);
74 #[cfg(unix)]
75 {
76 use std::os::unix::fs::OpenOptionsExt;
77 options.mode(0o600);
78 }
79 let mut file = options.open(&temporary)?;
80 set_private_file(&file)?;
81 let encoded = serde_json::to_vec_pretty(value).map_err(io::Error::other)?;
82 file.write_all(&encoded)?;
83 file.write_all(b"\n")?;
84 file.sync_all()?;
85
86 #[cfg(unix)]
89 if let Ok(metadata) = fs::metadata(path) {
90 use std::os::unix::fs::PermissionsExt;
91 file.set_permissions(fs::Permissions::from_mode(
92 metadata.permissions().mode() & 0o777,
93 ))?;
94 file.sync_all()?;
95 }
96 drop(file);
97 fs::rename(&temporary, path)?;
98 sync_directory(parent)?;
99 Ok(())
100 })();
101 if result.is_err() {
102 let _ = fs::remove_file(&temporary);
103 }
104 result
105}
106
107fn create_private_directory(path: &Path) -> io::Result<()> {
108 #[cfg(unix)]
109 {
110 use std::os::unix::fs::DirBuilderExt;
111 fs::DirBuilder::new()
112 .recursive(true)
113 .mode(0o700)
114 .create(path)?;
115 }
116 #[cfg(not(unix))]
117 fs::create_dir_all(path)?;
118 Ok(())
119}
120
121fn set_private_file(file: &File) -> io::Result<()> {
122 #[cfg(unix)]
123 {
124 use std::os::unix::fs::PermissionsExt;
125 file.set_permissions(fs::Permissions::from_mode(0o600))?;
126 }
127 Ok(())
128}
129
130fn sync_directory(path: &Path) -> io::Result<()> {
131 #[cfg(unix)]
132 {
133 File::open(path)?.sync_all()?;
134 }
135 Ok(())
136}
137
138#[cfg(test)]
139mod tests {
140 use std::fs;
141 use std::os::unix::fs::PermissionsExt;
142
143 use super::{read_object, update};
144
145 #[test]
146 fn update_preserves_unknown_values_and_repairs_intermediate_objects() {
147 let directory = tempfile_directory();
148 let path = directory.join("codeswarm.json");
149 fs::write(
150 &path,
151 r#"{"other":{"keep":true},"ui":"legacy","broken":42}"#,
152 )
153 .expect("write");
154 update(&path, |settings| {
155 let ui = settings
156 .entry("ui")
157 .or_insert_with(|| serde_json::json!({}));
158 if !ui.is_object() {
159 *ui = serde_json::json!({});
160 }
161 ui["follow_output"] = serde_json::Value::Bool(true);
162 })
163 .expect("update");
164 let value = read_object(&path).expect("read");
165 assert_eq!(value["other"]["keep"], true);
166 assert_eq!(value["ui"]["follow_output"], true);
167 assert_eq!(value["broken"], 42);
168 assert_eq!(
171 fs::metadata(&path).expect("metadata").permissions().mode() & 0o777,
172 0o644
173 );
174 assert!(
175 fs::read_dir(&directory)
176 .expect("directory")
177 .all(|entry| entry.expect("entry").file_name() != ".codeswarm.json.tmp")
178 );
179 fs::remove_dir_all(directory).expect("cleanup");
180 }
181
182 #[test]
183 fn settings_preserve_existing_parent_permissions_and_create_private_directories() {
184 let directory = tempfile_directory();
185 fs::set_permissions(&directory, fs::Permissions::from_mode(0o755)).unwrap();
186 update(directory.join("existing.json"), |_| {}).unwrap();
187 assert_eq!(
188 fs::metadata(&directory).unwrap().permissions().mode() & 0o777,
189 0o755
190 );
191 let nested = directory.join("new");
192 let path = nested.join("settings.json");
193 update(&path, |_| {}).unwrap();
194 assert_eq!(
195 fs::metadata(nested).unwrap().permissions().mode() & 0o777,
196 0o700
197 );
198 assert_eq!(
199 fs::metadata(path).unwrap().permissions().mode() & 0o777,
200 0o600
201 );
202 fs::remove_dir_all(directory).unwrap();
203 }
204
205 #[test]
206 fn malformed_settings_are_not_overwritten() {
207 let directory = tempfile_directory();
208 let path = directory.join("codeswarm.json");
209 fs::write(&path, "not json").expect("write");
210 assert!(update(&path, |_| {}).is_err());
211 assert_eq!(fs::read_to_string(&path).expect("read"), "not json");
212 fs::remove_dir_all(directory).expect("cleanup");
213 }
214
215 fn tempfile_directory() -> std::path::PathBuf {
216 let unique = std::time::SystemTime::now()
217 .duration_since(std::time::UNIX_EPOCH)
218 .expect("clock")
219 .as_nanos();
220 let path = std::env::temp_dir().join(format!(
221 "codeswarm-settings-{}-{unique}",
222 std::process::id()
223 ));
224 fs::create_dir_all(&path).expect("directory");
225 path
226 }
227}