Skip to main content

hypha/config/
files.rs

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(&current, 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    // fsync the directory so the rename itself survives a crash.
400    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
410/// $CMN_HOME/hypha/
411pub fn hypha_dir() -> PathBuf {
412    crate::site::get_cmn_home().join("hypha")
413}