1use std::path::{Path, PathBuf};
2
3use agent_first_data::document::{Document, DocumentError, DocumentFile, Format, Value};
4
5use super::HyphaConfig;
6
7const CONFIG_MAX_BYTES: u64 = 1024 * 1024;
8
9const CONFIG_KEYS: &[&str] = &[
10 "cache.path",
11 "cache.cmn_ttl_s",
12 "cache.key_trust_ttl_s",
13 "cache.key_trust_refresh_mode",
14 "cache.key_trust_synapse_witness_mode",
15 "cache.spore_max_download_bytes",
16 "cache.spore_max_extract_bytes",
17 "cache.spore_max_extract_files",
18 "cache.spore_max_extract_file_bytes",
19 "cache.spore_reject_path_components",
20 "cache.clock_skew_tolerance_s",
21 "cache.require_domain_first_key",
22 "defaults.synapse_domain",
23 "defaults.domain",
24 "defaults.taste.synapse_domain",
25 "defaults.taste.domain",
26];
27
28impl HyphaConfig {
29 pub fn load() -> Result<Self, crate::sink::HyphaError> {
30 use crate::sink::HyphaError;
31
32 let path = config_path();
33 match std::fs::symlink_metadata(&path) {
34 Ok(_) => {}
35 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
36 return Ok(Self::default());
37 }
38 Err(error) => {
39 return Err(HyphaError::with_hint(
40 "config_read_failed",
41 format!("Failed to inspect {}: {}", path.display(), error),
42 "fix the file permissions and retry",
43 ));
44 }
45 }
46
47 let doc = DocumentFile::open_capped(&path, Some(Format::Toml), CONFIG_MAX_BYTES)
48 .map_err(|error| config_load_error(&path, &error))?;
49 agent_first_data::document::from_value(doc.value(), "")
50 .map_err(|error| config_load_error(&path, &error))
51 }
52
53 pub fn save(&self) -> Result<(), crate::sink::HyphaError> {
54 use crate::sink::HyphaError;
55
56 let path = config_path();
57 if let Some(parent) = path.parent() {
58 std::fs::create_dir_all(parent).map_err(|e| {
59 HyphaError::new(
60 "config_save_failed",
61 format!("Failed to create config directory: {}", e),
62 )
63 })?;
64 }
65
66 let desired_source = toml::to_string_pretty(self).map_err(|e| {
67 HyphaError::new(
68 "config_save_failed",
69 format!("Failed to serialize config: {}", e),
70 )
71 })?;
72 let desired = Document::parse(&desired_source, Format::Toml).map_err(|error| {
73 config_document_error("config_save_failed", &path, "prepare configuration", &error)
74 })?;
75 let default_source = toml::to_string_pretty(&Self::default()).map_err(|error| {
76 HyphaError::new(
77 "config_save_failed",
78 format!("Failed to serialize default config: {error}"),
79 )
80 })?;
81 let defaults = Document::parse(&default_source, Format::Toml).map_err(|error| {
82 config_document_error(
83 "config_save_failed",
84 &path,
85 "prepare default configuration",
86 &error,
87 )
88 })?;
89
90 match std::fs::symlink_metadata(&path) {
91 Ok(_) => save_existing_config(&path, &desired, &defaults),
92 Err(error) if error.kind() == std::io::ErrorKind::NotFound => write_text_file_atomic(
93 &path,
94 desired.source(),
95 0o600,
96 "config_save_failed",
97 "config.toml",
98 ),
99 Err(error) => Err(HyphaError::with_hint(
100 "config_save_failed",
101 format!("Failed to inspect {}: {}", path.display(), error),
102 "fix the file permissions and retry",
103 )),
104 }
105 }
106}
107
108fn save_existing_config(
109 path: &Path,
110 desired: &Document,
111 defaults: &Document,
112) -> Result<(), crate::sink::HyphaError> {
113 let mut current = DocumentFile::open_capped(path, Some(Format::Toml), CONFIG_MAX_BYTES)
114 .map_err(|error| config_load_error(path, &error))?;
115 let mut changed = false;
116 let mut collection_updates = Vec::new();
117
118 for key in CONFIG_KEYS {
119 let desired_value = optional_value(desired, key).map_err(|error| {
120 config_document_error(
121 "config_save_failed",
122 path,
123 "read prepared configuration",
124 &error,
125 )
126 })?;
127 let default_value = optional_value(defaults, key).map_err(|error| {
128 config_document_error(
129 "config_save_failed",
130 path,
131 "read default configuration",
132 &error,
133 )
134 })?;
135 let current_value = optional_value(¤t, key).map_err(|error| {
136 config_document_error(
137 "config_save_failed",
138 path,
139 "read existing configuration",
140 &error,
141 )
142 })?;
143
144 match (desired_value, current_value) {
145 (Some(desired_value), Some(current_value)) if desired_value == current_value => {}
146 (Some(desired_value), None) if Some(&desired_value) == default_value.as_ref() => {}
147 (Some(desired_value), _) => {
148 if desired_value.is_array() || desired_value.is_object() {
149 collection_updates.push((*key, desired_value));
150 } else {
151 current.set(key, desired_value).map_err(|error| {
152 config_document_error(
153 "config_save_failed",
154 path,
155 "stage configuration update",
156 &error,
157 )
158 })?;
159 }
160 changed = true;
161 }
162 (None, Some(_)) => {
163 current.unset(key).map_err(|error| {
164 config_document_error(
165 "config_save_failed",
166 path,
167 "stage configuration removal",
168 &error,
169 )
170 })?;
171 changed = true;
172 }
173 (None, None) => {}
174 }
175 }
176
177 if !changed {
178 return Ok(());
179 }
180
181 if collection_updates.is_empty() {
182 agent_first_data::document::from_value::<HyphaConfig>(current.value(), "").map_err(
183 |error| {
184 config_document_error(
185 "config_save_failed",
186 path,
187 "validate updated configuration",
188 &error,
189 )
190 },
191 )?;
192 return current.save().map_err(|error| {
193 config_document_error("config_save_failed", path, "commit configuration", &error)
194 });
195 }
196
197 current.ensure_mutable("set").map_err(|error| {
198 config_document_error(
199 "config_save_failed",
200 path,
201 "preflight collection update",
202 &error,
203 )
204 })?;
205 let mut source = current.source().to_string();
206 for (key, value) in collection_updates {
207 source = set_toml_collection(&source, key, &value).map_err(|error| {
208 crate::sink::HyphaError::new(
209 "config_save_failed",
210 format!(
211 "Failed to stage collection update at {}: {}",
212 path.display(),
213 error
214 ),
215 )
216 })?;
217 }
218 let validated = Document::parse(&source, Format::Toml).map_err(|error| {
219 config_document_error(
220 "config_save_failed",
221 path,
222 "validate updated configuration",
223 &error,
224 )
225 })?;
226 agent_first_data::document::from_value::<HyphaConfig>(validated.value(), "").map_err(
227 |error| {
228 config_document_error(
229 "config_save_failed",
230 path,
231 "validate updated configuration types",
232 &error,
233 )
234 },
235 )?;
236 write_text_file_atomic(
237 path,
238 validated.source(),
239 0o600,
240 "config_save_failed",
241 "config.toml",
242 )
243}
244
245fn set_toml_collection(source: &str, key: &str, value: &Value) -> Result<String, &'static str> {
246 if key != "cache.spore_reject_path_components" {
247 return Err("no dedicated editor is registered for this collection field");
248 }
249 let items = value
250 .as_array()
251 .ok_or("spore_reject_path_components must be an array")?;
252 let mut array = toml_edit::Array::new();
253 for item in items {
254 let item = item
255 .as_str()
256 .ok_or("spore_reject_path_components entries must be strings")?;
257 array.push(item);
258 }
259
260 let mut document = source
261 .parse::<toml_edit::DocumentMut>()
262 .map_err(|_| "existing TOML could not be parsed by the collection editor")?;
263 if document.get("cache").is_none() {
264 document.insert("cache", toml_edit::Item::Table(toml_edit::Table::new()));
265 }
266 let cache = document
267 .get_mut("cache")
268 .and_then(toml_edit::Item::as_table_like_mut)
269 .ok_or("cache must be a TOML table")?;
270 let mut replacement = toml_edit::value(array);
271 if let Some(decor) = cache
272 .get("spore_reject_path_components")
273 .and_then(toml_edit::Item::as_value)
274 .map(|value| value.decor().clone())
275 {
276 if let Some(value) = replacement.as_value_mut() {
277 *value.decor_mut() = decor;
278 }
279 }
280 cache.insert("spore_reject_path_components", replacement);
281 Ok(document.to_string())
282}
283
284fn optional_value(doc: &Document, key: &str) -> Result<Option<Value>, DocumentError> {
285 match doc.value_at(key) {
286 Ok(value) => Ok(Some(value)),
287 Err(error) if error.code() == "document_path_not_found" => Ok(None),
288 Err(error) => Err(error),
289 }
290}
291
292fn config_load_error(path: &Path, error: &DocumentError) -> crate::sink::HyphaError {
293 let code = match error.code() {
294 "document_parse_failed" | "document_type_mismatch" => "config_parse_failed",
295 _ => "config_read_failed",
296 };
297 config_document_error(code, path, "load configuration", error)
298}
299
300fn config_document_error(
301 code: &str,
302 path: &Path,
303 operation: &str,
304 error: &DocumentError,
305) -> crate::sink::HyphaError {
306 crate::sink::HyphaError::with_hint(
307 code,
308 format!(
309 "Failed to {} at {}: {} ({})",
310 operation,
311 path.display(),
312 error.redacted_message(),
313 error.code()
314 ),
315 "fix the configuration file or its permissions and retry",
316 )
317}
318
319pub(super) fn write_text_file_atomic(
320 path: &Path,
321 content: &str,
322 mode: u32,
323 error_code: &str,
324 file_label: &str,
325) -> Result<(), crate::sink::HyphaError> {
326 use crate::sink::HyphaError;
327 use std::io::Write;
328
329 let parent = path.parent().ok_or_else(|| {
330 HyphaError::new(
331 error_code,
332 format!("Failed to determine parent directory for {}", file_label),
333 )
334 })?;
335 let file_name = path
336 .file_name()
337 .and_then(|name| name.to_str())
338 .unwrap_or("tmp");
339 let tmp_path = parent.join(format!(
340 ".{}.tmp.{}.{}",
341 file_name,
342 std::process::id(),
343 crate::time::now_epoch_ms()
344 ));
345
346 #[cfg(unix)]
347 let mut file = {
348 use std::os::unix::fs::OpenOptionsExt;
349
350 std::fs::OpenOptions::new()
351 .create_new(true)
352 .write(true)
353 .mode(mode)
354 .open(&tmp_path)
355 .map_err(|e| {
356 HyphaError::new(
357 error_code,
358 format!("Failed to create temp {}: {}", file_label, e),
359 )
360 })?
361 };
362
363 #[cfg(not(unix))]
364 let mut file = std::fs::OpenOptions::new()
365 .create_new(true)
366 .write(true)
367 .open(&tmp_path)
368 .map_err(|e| {
369 HyphaError::new(
370 error_code,
371 format!("Failed to create temp {}: {}", file_label, e),
372 )
373 })?;
374
375 if let Err(e) = file.write_all(content.as_bytes()) {
376 let _ = std::fs::remove_file(&tmp_path);
377 return Err(HyphaError::new(
378 error_code,
379 format!("Failed to write temp {}: {}", file_label, e),
380 ));
381 }
382 if let Err(e) = file.sync_all() {
383 let _ = std::fs::remove_file(&tmp_path);
384 return Err(HyphaError::new(
385 error_code,
386 format!("Failed to sync temp {}: {}", file_label, e),
387 ));
388 }
389 drop(file);
390
391 std::fs::rename(&tmp_path, path).map_err(|e| {
392 let _ = std::fs::remove_file(&tmp_path);
393 HyphaError::new(
394 error_code,
395 format!("Failed to replace {}: {}", file_label, e),
396 )
397 })?;
398
399 if let Ok(handle) = std::fs::File::open(parent) {
401 let _ = handle.sync_all();
402 }
403 Ok(())
404}
405
406pub fn config_path() -> PathBuf {
407 hypha_dir().join("config.toml")
408}
409
410pub fn hypha_dir() -> PathBuf {
412 crate::site::get_cmn_home().join("hypha")
413}