Skip to main content

pwr_server/
config.rs

1use serde::{Deserialize, Serialize};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5// ---------------------------------------------------------------------------
6// XDG / system path resolution
7// ---------------------------------------------------------------------------
8
9/// System-wide config directory.
10pub fn system_config_dir() -> PathBuf {
11    PathBuf::from("/etc/pwr")
12}
13
14/// System-wide data (storage) directory.
15pub fn system_data_dir() -> PathBuf {
16    PathBuf::from("/srv/pwr/projects")
17}
18
19/// Per-user XDG config directory (`~/.config/pwr`).
20pub fn user_config_dir() -> PathBuf {
21    dirs::config_dir()
22        .unwrap_or_else(|| PathBuf::from("~/.config"))
23        .join("pwr")
24}
25
26/// Per-user XDG data directory (`~/.local/share/pwr`).
27pub fn user_data_dir() -> PathBuf {
28    dirs::data_dir()
29        .unwrap_or_else(|| PathBuf::from("~/.local/share"))
30        .join("pwr")
31}
32
33/// Per-user XDG runtime directory (`$XDG_RUNTIME_DIR/pwr`, falls back
34/// to `~/.cache/pwr`).
35pub fn user_runtime_dir() -> PathBuf {
36    dirs::runtime_dir()
37        .unwrap_or_else(|| dirs::cache_dir().unwrap_or_else(|| PathBuf::from("~/.cache")))
38        .join("pwr")
39}
40
41/// Check whether the target directory (or its parent) already exists and
42/// is writable by the current process, or doesn't exist but its parent is
43/// writable (so we can create it).
44pub fn is_path_writable(path: &Path) -> bool {
45    // If the path exists, check it directly.
46    if path.exists() {
47        return fs::metadata(path)
48            .map(|m| !m.permissions().readonly())
49            .unwrap_or(false);
50    }
51
52    // Otherwise walk up to the nearest existing ancestor.
53    let mut ancestor = path.to_path_buf();
54    loop {
55        if !ancestor.pop() {
56            return false; // hit the filesystem root without finding anything
57        }
58        if ancestor.exists() {
59            return fs::metadata(&ancestor)
60                .map(|m| !m.permissions().readonly())
61                .unwrap_or(false);
62        }
63    }
64}
65
66/// Resolve the appropriate config base directory.
67///
68/// Returns the system path (`/etc/pwr`) if it already exists or is
69/// writable; otherwise falls back to the per-user XDG config directory.
70pub fn resolve_config_base() -> PathBuf {
71    let system = system_config_dir();
72    if is_path_writable(&system) {
73        system
74    } else {
75        user_config_dir()
76    }
77}
78
79/// Server-side configuration, typically stored at `/etc/pwr/server.toml`
80/// or `~/.config/pwr/server.toml`.
81///
82/// Controls the TLS listener, project storage backend, authentication,
83/// and operational limits for the pwr-server daemon.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct ServerConfig {
86    /// Schema version for forward compatibility.
87    pub version: u32,
88
89    /// IP address to bind the TCP listener to (e.g., "0.0.0.0" for all
90    /// interfaces, or "127.0.0.1" for local-only access).
91    #[serde(default = "default_listen_address")]
92    pub listen_address: String,
93
94    /// TCP port to listen on. Defaults to 9742, an unprivileged port
95    /// not assigned by IANA.
96    #[serde(default = "default_port")]
97    pub listen_port: u16,
98
99    /// Filesystem path where project archives are stored.
100    /// The server creates per-project subdirectories under this root.
101    #[serde(default = "default_storage_path")]
102    pub storage_base_path: PathBuf,
103
104    /// Maximum size in gigabytes allowed for a single project archive.
105    /// Archives exceeding this limit are rejected before transfer begins.
106    #[serde(default = "default_max_size_gb")]
107    pub max_project_size_gb: u64,
108
109    /// Path to the PEM-encoded TLS certificate file.
110    /// Generated during `pwr-server init` if not provided.
111    pub tls_cert_path: PathBuf,
112
113    /// Path to the PEM-encoded TLS private key file.
114    /// Must be readable only by the server process (mode 0o600).
115    pub tls_key_path: PathBuf,
116
117    /// Hex-encoded 256-bit pre-shared key for client authentication.
118    /// Must match the value in the client's config.toml.
119    pub auth_token: String,
120
121    /// Maximum number of concurrent client connections.
122    /// Additional connections are accepted but immediately closed
123    /// with a rate-limit error.
124    #[serde(default = "default_max_connections")]
125    pub max_connections: usize,
126
127    /// Idle timeout in seconds for authenticated connections.
128    /// Connections with no activity for this duration are closed.
129    #[serde(default = "default_idle_timeout")]
130    pub idle_timeout_secs: u64,
131}
132
133fn default_listen_address() -> String {
134    "0.0.0.0".into()
135}
136
137fn default_port() -> u16 {
138    9742
139}
140
141fn default_storage_path() -> PathBuf {
142    PathBuf::from("/srv/pwr/projects")
143}
144
145fn default_max_size_gb() -> u64 {
146    500
147}
148
149fn default_max_connections() -> usize {
150    32
151}
152
153fn default_idle_timeout() -> u64 {
154    300
155}
156
157impl Default for ServerConfig {
158    fn default() -> Self {
159        Self {
160            version: 1,
161            listen_address: default_listen_address(),
162            listen_port: default_port(),
163            storage_base_path: default_storage_path(),
164            max_project_size_gb: default_max_size_gb(),
165            tls_cert_path: PathBuf::from("/etc/pwr/server.crt"),
166            tls_key_path: PathBuf::from("/etc/pwr/server.key"),
167            auth_token: String::new(),
168            max_connections: default_max_connections(),
169            idle_timeout_secs: default_idle_timeout(),
170        }
171    }
172}
173
174impl ServerConfig {
175    /// Validate the configuration for common mistakes.
176    ///
177    /// Checks that required paths exist (or can be created), the port
178    /// is in the valid range, the auth token is non-empty, and size
179    /// limits are reasonable.
180    pub fn validate(&self) -> Result<(), String> {
181        if self.listen_port == 0 {
182            return Err("listen_port must be non-zero".into());
183        }
184        if self.auth_token.is_empty() {
185            return Err("auth_token must not be empty".into());
186        }
187        if self.max_project_size_gb == 0 {
188            return Err("max_project_size_gb must be positive".into());
189        }
190        if self.max_connections == 0 {
191            return Err("max_connections must be positive".into());
192        }
193        Ok(())
194    }
195
196    /// Return the bind address as "address:port".
197    pub fn bind_addr(&self) -> String {
198        format!("{}:{}", self.listen_address, self.listen_port)
199    }
200
201    /// Return the path where a specific project's data is stored.
202    pub fn project_dir(&self, uuid: &uuid::Uuid) -> PathBuf {
203        self.storage_base_path.join(uuid.to_string())
204    }
205
206    /// Return the path to the project registry index file.
207    pub fn registry_path(&self) -> PathBuf {
208        self.storage_base_path.join("registry.json")
209    }
210}
211
212/// Search for the server config file in standard locations.
213///
214/// Checks, in order:
215/// 1. The path specified by the `--config` CLI argument (if provided)
216/// 2. `./server.toml` (current working directory, for development)
217/// 3. `~/.config/pwr/server.toml` (user-local install)
218/// 4. `/etc/pwr/server.toml` (system-wide install)
219pub fn find_config(explicit_path: Option<&Path>) -> Option<PathBuf> {
220    if let Some(p) = explicit_path {
221        if p.exists() {
222            return Some(p.to_path_buf());
223        }
224    }
225
226    let candidates = [
227        PathBuf::from("server.toml"),
228        dirs::config_dir()
229            .unwrap_or_else(|| PathBuf::from("~/.config"))
230            .join("pwr")
231            .join("server.toml"),
232        PathBuf::from("/etc/pwr/server.toml"),
233    ];
234
235    for candidate in &candidates {
236        if candidate.exists() {
237            return Some(candidate.clone());
238        }
239    }
240
241    None
242}
243
244/// Load the server configuration from a specific path.
245pub fn load_config(path: &Path) -> Result<ServerConfig, String> {
246    let contents = fs::read_to_string(path)
247        .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
248
249    let config: ServerConfig = toml::from_str(&contents)
250        .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
251
252    config.validate()?;
253
254    Ok(config)
255}
256
257/// Save a server configuration to disk, creating parent directories
258/// as needed.
259pub fn save_config(config: &ServerConfig, path: &Path) -> Result<(), String> {
260    if let Some(parent) = path.parent() {
261        fs::create_dir_all(parent)
262            .map_err(|e| format!("Failed to create {}: {}", parent.display(), e))?;
263    }
264
265    let contents = toml::to_string_pretty(config)
266        .map_err(|e| format!("Failed to serialize config: {}", e))?;
267
268    fs::write(path, &contents)
269        .map_err(|e| format!("Failed to write {}: {}", path.display(), e))?;
270
271    Ok(())
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn test_default_config_passes_validation() {
280        let mut cfg = ServerConfig::default();
281        cfg.auth_token = "test-token-0123456789abcdef".into();
282        assert!(cfg.validate().is_ok());
283    }
284
285    #[test]
286    fn test_empty_auth_token_fails_validation() {
287        let cfg = ServerConfig::default();
288        assert!(cfg.validate().is_err());
289    }
290
291    #[test]
292    fn test_zero_port_fails_validation() {
293        let mut cfg = ServerConfig::default();
294        cfg.listen_port = 0;
295        cfg.auth_token = "test".into();
296        assert!(cfg.validate().is_err());
297    }
298
299    #[test]
300    fn test_bind_addr_format() {
301        let mut cfg = ServerConfig::default();
302        cfg.listen_address = "192.168.1.100".into();
303        cfg.listen_port = 8443;
304        assert_eq!(cfg.bind_addr(), "192.168.1.100:8443");
305    }
306
307    #[test]
308    fn test_project_dir() {
309        let cfg = ServerConfig::default();
310        let uuid = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
311        let dir = cfg.project_dir(&uuid);
312        assert!(dir.to_string_lossy().contains("550e8400"));
313    }
314
315    #[test]
316    fn test_registry_path() {
317        let cfg = ServerConfig::default();
318        assert!(cfg
319            .registry_path()
320            .to_string_lossy()
321            .ends_with("registry.json"));
322    }
323
324    #[test]
325    fn test_save_and_load_round_trip() {
326        let tmp = tempfile::TempDir::new().unwrap();
327        let path = tmp.path().join("server.toml");
328
329        let mut cfg = ServerConfig::default();
330        cfg.auth_token = "roundtrip-test-token".into();
331        cfg.listen_port = 12345;
332
333        save_config(&cfg, &path).unwrap();
334        let loaded = load_config(&path).unwrap();
335
336        assert_eq!(loaded.listen_port, 12345);
337        assert_eq!(loaded.auth_token, "roundtrip-test-token");
338        assert_eq!(loaded.version, 1);
339    }
340}