Skip to main content

nap_core/server/
config.rs

1// SPDX-FileCopyrightText: 2026 Digital Creations
2// SPDX-License-Identifier: MIT
3//! Lore server configuration generation
4//!
5//! Generates local.toml configuration for Lore server with platform-independent paths
6//! and recommended persistent defaults for NAP-managed deployments.
7
8use anyhow::{Context, Result};
9use rand::RngCore;
10use std::fs;
11use std::io::Write;
12use std::path::Path;
13
14/// Generate Lore server configuration for local deployment
15///
16/// Creates a complete local.toml configuration file with:
17/// - Persistent stores under NAP home directory
18/// - Persistent certificates
19/// - Recommended defaults for local development
20pub fn generate_local_config(nap_home: &Path) -> Result<ConfigFiles> {
21    let lore_config_dir = nap_home.join("lore").join("config");
22    let local_toml_path = lore_config_dir.join("local.toml");
23
24    fs::create_dir_all(&lore_config_dir).context("Failed to create Lore config directory")?;
25
26    // Only regenerate if missing
27    if local_toml_path.exists() {
28        let content = fs::read_to_string(&local_toml_path)?;
29        let mut config: toml::Value = toml::from_str(&content)?;
30        if let Some(http) = config
31            .get_mut("server")
32            .and_then(|v| v.get_mut("http"))
33            .and_then(toml::Value::as_table_mut)
34            && http.get("enabled").and_then(toml::Value::as_bool) != Some(false)
35            && !http.contains_key("presigned_url_hmac_key")
36        {
37            http.insert("presigned_url_hmac_key".into(), new_presign_key().into());
38            let mut file = tempfile::NamedTempFile::new_in(&lore_config_dir)?;
39            file.write_all(toml::to_string_pretty(&config)?.as_bytes())?;
40            file.persist(&local_toml_path)?;
41        }
42        tracing::info!("Lore config already exists at {:?}", local_toml_path);
43        return Ok(ConfigFiles {
44            config_path: local_toml_path,
45            config_dir: lore_config_dir,
46        });
47    }
48
49    tracing::info!("Generating Lore server configuration");
50
51    let config = generate_config_toml(nap_home);
52    let mut options = fs::OpenOptions::new();
53    options.write(true).create_new(true);
54    #[cfg(unix)]
55    {
56        use std::os::unix::fs::OpenOptionsExt;
57        options.mode(0o600);
58    }
59    let mut file = options
60        .open(&local_toml_path)
61        .context("Failed to create Lore configuration file")?;
62    file.write_all(config.as_bytes())
63        .context("Failed to write Lore configuration file")?;
64
65    tracing::info!("Lore configuration generated at {:?}", local_toml_path);
66
67    Ok(ConfigFiles {
68        config_path: local_toml_path,
69        config_dir: lore_config_dir,
70    })
71}
72
73fn new_presign_key() -> String {
74    let mut key = [0u8; 32];
75    rand::rng().fill_bytes(&mut key);
76    hex::encode(key)
77}
78
79/// Generate the TOML configuration content
80fn generate_config_toml(nap_home: &Path) -> String {
81    let immutable_path = nap_home.join("lore").join("store").join("immutable");
82    let mutable_path = nap_home.join("lore").join("store").join("mutable");
83    let _cert_path = nap_home.join("lore").join("certs").join("cert.pem");
84    let _key_path = nap_home.join("lore").join("certs").join("key.pem");
85    let presign_key = new_presign_key();
86    let immutable_path_toml = toml_quote(&immutable_path);
87    let mutable_path_toml = toml_quote(&mutable_path);
88
89    format!(
90        r#"
91# =============================================================================
92# Lore Server Configuration (Generated by NAP SDK)
93# =============================================================================
94# This configuration is automatically generated by the NAP SDK.
95# Manual modifications may be overwritten.
96
97[server]
98connection_close_timeout_seconds = 5
99runtime_shutdown_timeout_seconds = 25
100
101# Public facing QUIC server settings
102[server.quic]
103enabled = true
104host = "127.0.0.1"
105port = 41337
106verify_client_certs = false
107idle_timeout = 30_000
108keep_alive = 500
109max_bidi_streams = 8
110num_listeners = 10
111transport_bits_per_second = 1_073_741_824  # 1 gbit/s
112transport_rtt = 1  # 1 ms for local development (was 100ms)
113handler_timeout_seconds = 50
114
115# gRPC server settings
116[server.grpc]
117enabled = true
118host = "127.0.0.1"
119port = 41337
120request_handler_timeout_seconds = 50
121verify_client_certs = false
122
123# HTTP server settings
124[server.http]
125enabled = true
126host = "127.0.0.1"
127port = 41339
128max_file_size = 10_485_760  # 10MB
129request_timeout_seconds = 300
130request_body_timeout_seconds = 3600
131available_interval_seconds = 30
132available_timeout_seconds = 5
133store_health_check = false
134presigned_url_hmac_key = "{}"
135
136# =============================================================================
137# Store Configuration
138# =============================================================================
139
140[immutable_store]
141mode = "local"
142
143[immutable_store.local]
144path = "{}"
145flush_delay_seconds = 0
146
147[mutable_store]
148mode = "local"
149
150[mutable_store.local]
151path = "{}"
152flush_delay_seconds = 0
153
154[lock_store]
155mode = "local"
156
157# =============================================================================
158# Tokio Runtime Configuration
159# =============================================================================
160
161[tokio]
162max_blocking_threads = 512
163
164# =============================================================================
165# Telemetry Configuration
166# =============================================================================
167
168[telemetry.logger]
169enable_otlp = false
170format = "json"
171output = "stdout"
172
173[telemetry.metrics]
174export_interval_millis = 30000
175sample_interval_millis = 10000
176
177[telemetry.traces]
178sample_rate = 0.05
179sample_rate_low_tier = 0.001
180
181# =============================================================================
182# Notification Configuration
183# =============================================================================
184
185[notification]
186mode = "local"
187
188# =============================================================================
189# Other Features Configuration
190# =============================================================================
191
192[feature]
193history_step_size = 100
194"#,
195        presign_key, immutable_path_toml, mutable_path_toml
196    )
197}
198
199/// Encode a filesystem path as a TOML basic string. Windows paths contain
200/// backslashes, which must be escaped or generated configuration is invalid.
201fn toml_quote(path: &Path) -> String {
202    path.to_string_lossy()
203        .replace('\\', "\\\\")
204        .replace('"', "\\\"")
205        .replace('\n', "\\n")
206        .replace('\r', "\\r")
207        .replace('\t', "\\t")
208}
209
210/// Paths to generated configuration files
211#[derive(Debug, Clone)]
212pub struct ConfigFiles {
213    pub config_path: std::path::PathBuf,
214    pub config_dir: std::path::PathBuf,
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use tempfile::TempDir;
221
222    #[test]
223    fn upgrade_adds_one_stable_key_and_preserves_existing_settings() {
224        let dir = TempDir::new().unwrap();
225        let config = dir.path().join("lore/config");
226        fs::create_dir_all(&config).unwrap();
227        let path = config.join("local.toml");
228        fs::write(
229            &path,
230            "[server.http]\nenabled = true\nport = 4242\n[custom]\nvalue = 7\n",
231        )
232        .unwrap();
233        generate_local_config(dir.path()).unwrap();
234        let first = fs::read_to_string(&path).unwrap();
235        generate_local_config(dir.path()).unwrap();
236        assert_eq!(fs::read_to_string(&path).unwrap(), first);
237        assert!(first.contains("port = 4242"));
238        assert!(first.contains("value = 7"));
239        let doc: toml::Value = toml::from_str(&first).unwrap();
240        assert_eq!(
241            doc["server"]["http"]["presigned_url_hmac_key"]
242                .as_str()
243                .unwrap()
244                .len(),
245            64
246        );
247    }
248
249    #[test]
250    fn test_generate_local_config() {
251        let temp_dir = TempDir::new().unwrap();
252        let nap_home = temp_dir.path();
253
254        let files = generate_local_config(nap_home).unwrap();
255
256        assert!(files.config_path.exists());
257        assert!(files.config_dir.exists());
258
259        // Verify configuration content
260        let content = fs::read_to_string(&files.config_path).unwrap();
261        assert!(content.contains("[server.quic]"));
262        assert!(content.contains("port = 41337"));
263        assert!(content.contains("[immutable_store.local]"));
264        assert!(content.contains("[mutable_store.local]"));
265        let metadata = fs::metadata(&files.config_path).unwrap();
266        #[cfg(unix)]
267        {
268            use std::os::unix::fs::PermissionsExt;
269            assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
270        }
271
272        // Verify we can regenerate without error (should skip if exists)
273        let files2 = generate_local_config(nap_home).unwrap();
274        assert_eq!(files.config_path, files2.config_path);
275    }
276
277    #[test]
278    fn test_config_toml_content() {
279        let temp_dir = TempDir::new().unwrap();
280        let nap_home = temp_dir.path();
281
282        let config = generate_config_toml(nap_home);
283
284        // Verify key configuration sections
285        assert!(config.contains("[server.quic]"));
286        assert!(config.contains("port = 41337"));
287        assert!(config.contains("[server.grpc]"));
288        assert!(config.contains("[server.http]"));
289        assert!(config.contains("port = 41339"));
290        let key = config
291            .lines()
292            .find_map(|line| {
293                line.strip_prefix("presigned_url_hmac_key = \"")?
294                    .strip_suffix('"')
295            })
296            .unwrap();
297        assert_eq!(key.len(), 64);
298        assert!(key.bytes().all(|byte| byte.is_ascii_hexdigit()));
299        assert!(config.contains("[immutable_store]"));
300        assert!(config.contains("mode = \"local\""));
301        assert!(config.contains("[mutable_store]"));
302        assert!(config.contains("[lock_store]"));
303        assert!(config.contains("[telemetry.logger]"));
304        assert!(config.contains("[telemetry.metrics]"));
305        assert!(config.contains("[notification]"));
306        assert!(config.contains("[feature]"));
307
308        // Verify paths round-trip through TOML decoding. Comparing the raw
309        // rendered text is incorrect on Windows because backslashes must be
310        // escaped in TOML basic strings.
311        let parsed: toml::Value = toml::from_str(&config).unwrap();
312        assert_eq!(
313            parsed["immutable_store"]["local"]["path"].as_str(),
314            nap_home
315                .join("lore")
316                .join("store")
317                .join("immutable")
318                .to_str()
319        );
320        assert_eq!(
321            parsed["mutable_store"]["local"]["path"].as_str(),
322            nap_home.join("lore").join("store").join("mutable").to_str()
323        );
324
325        // Windows paths must remain valid TOML (notably `\\U` is a Unicode
326        // escape in TOML and must be encoded as `\\\\U`).
327        let windows_path =
328            Path::new(r"C:\Users\RUNNER~1\AppData\Local\Temp\.tmp123\lore\store\immutable");
329        let windows_config = config.replace(
330            &toml_quote(&nap_home.join("lore").join("store").join("immutable")),
331            &toml_quote(windows_path),
332        );
333        toml::from_str::<toml::Value>(&windows_config).unwrap();
334    }
335}