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