Skip to main content

blit_webserver/
config.rs

1use axum::extract::ws::{Message, WebSocket};
2use futures_util::SinkExt;
3use std::collections::HashMap;
4use std::path::PathBuf;
5use std::sync::{Arc, RwLock};
6use tokio::sync::broadcast;
7
8pub struct ConfigState {
9    pub tx: broadcast::Sender<String>,
10}
11
12impl Default for ConfigState {
13    fn default() -> Self {
14        Self::new()
15    }
16}
17
18impl ConfigState {
19    pub fn new() -> Self {
20        let (tx, _) = broadcast::channel::<String>(64);
21        spawn_watcher(tx.clone());
22        Self { tx }
23    }
24}
25
26fn blit_config_dir() -> PathBuf {
27    #[cfg(unix)]
28    let base = std::env::var("XDG_CONFIG_HOME")
29        .map(PathBuf::from)
30        .unwrap_or_else(|_| {
31            let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
32            PathBuf::from(home).join(".config")
33        });
34    #[cfg(windows)]
35    let base = std::env::var("APPDATA")
36        .map(PathBuf::from)
37        .unwrap_or_else(|_| PathBuf::from(r"C:\ProgramData"));
38    base.join("blit")
39}
40
41pub fn config_path() -> PathBuf {
42    if let Ok(p) = std::env::var("BLIT_CONFIG") {
43        return PathBuf::from(p);
44    }
45    blit_config_dir().join("blit.conf")
46}
47
48pub fn remotes_path() -> PathBuf {
49    if let Ok(p) = std::env::var("BLIT_REMOTES") {
50        return PathBuf::from(p);
51    }
52    blit_config_dir().join("blit.remotes")
53}
54
55/// Resolve the local blit server IPC socket path.
56///
57/// Checks `BLIT_SOCK` first (explicit override), then probes well-known
58/// paths with existence checks so we find a running server regardless of
59/// which fallback it used at startup.
60#[cfg(unix)]
61pub fn default_local_socket() -> String {
62    if let Ok(p) = std::env::var("BLIT_SOCK") {
63        return p;
64    }
65    if let Ok(dir) = std::env::var("TMPDIR") {
66        let p = format!("{dir}/blit.sock");
67        if std::path::Path::new(&p).exists() {
68            return p;
69        }
70    }
71    if let Ok(user) = std::env::var("USER") {
72        let p = format!("/tmp/blit-{user}.sock");
73        if std::path::Path::new(&p).exists() {
74            return p;
75        }
76        let sys = format!("/run/blit/{user}.sock");
77        if std::path::Path::new(&sys).exists() {
78            return sys;
79        }
80    }
81    if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") {
82        return format!("{dir}/blit.sock");
83    }
84    "/tmp/blit.sock".into()
85}
86
87/// Resolve the local blit server IPC pipe path (Windows).
88#[cfg(windows)]
89pub fn default_local_socket() -> String {
90    if let Ok(p) = std::env::var("BLIT_SOCK") {
91        return p;
92    }
93    let user = std::env::var("USERNAME").unwrap_or_else(|_| "default".into());
94    format!(r"\\.\pipe\blit-{user}")
95}
96
97/// Acquire an exclusive cross-process lock for the config directory.
98/// Returns a `File` whose lifetime holds the lock (released on drop).
99/// On non-Unix platforms this is a no-op that returns `None`.
100fn lock_config_dir() -> Option<std::fs::File> {
101    #[cfg(unix)]
102    {
103        use std::os::unix::fs::OpenOptionsExt;
104        let dir = blit_config_dir();
105        let _ = std::fs::create_dir_all(&dir);
106        let lock_path = dir.join("blit.lock");
107        if let Ok(f) = std::fs::OpenOptions::new()
108            .write(true)
109            .create(true)
110            .truncate(false)
111            .mode(0o600)
112            .open(&lock_path)
113        {
114            // Block until we get the lock.
115            use std::os::unix::io::AsRawFd;
116            if unsafe { libc::flock(f.as_raw_fd(), libc::LOCK_EX) } == 0 {
117                return Some(f);
118            }
119        }
120        None
121    }
122    #[cfg(not(unix))]
123    {
124        None
125    }
126}
127
128pub fn read_config() -> HashMap<String, String> {
129    let path = config_path();
130    let contents = match std::fs::read_to_string(&path) {
131        Ok(c) => c,
132        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return HashMap::new(),
133        Err(e) => {
134            eprintln!("blit: could not read {}: {e}", path.display());
135            return HashMap::new();
136        }
137    };
138    parse_config_str(&contents)
139}
140
141/// Read `blit.remotes` and return ordered `(name, uri)` pairs.
142/// If the file does not exist, provisions it with `local = local` (0600).
143pub fn read_remotes() -> Vec<(String, String)> {
144    let path = remotes_path();
145    let contents = match std::fs::read_to_string(&path) {
146        Ok(c) => c,
147        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
148            let default = vec![("local".to_string(), "local".to_string())];
149            write_remotes(&default);
150            return default;
151        }
152        Err(e) => {
153            eprintln!("blit: could not read {}: {e}", path.display());
154            return vec![];
155        }
156    };
157    parse_remotes_str(&contents)
158}
159
160/// Atomically read-modify-write `blit.conf` under an exclusive flock.
161pub fn modify_config(f: impl FnOnce(&mut HashMap<String, String>)) {
162    let _lock = lock_config_dir();
163    let mut map = read_config();
164    f(&mut map);
165    write_config(&map);
166}
167
168/// Atomically read-modify-write `blit.remotes` under an exclusive flock.
169pub fn modify_remotes(f: impl FnOnce(&mut Vec<(String, String)>)) {
170    let _lock = lock_config_dir();
171    let mut entries = read_remotes();
172    f(&mut entries);
173    write_remotes(&entries);
174}
175
176/// Parse `blit.remotes` content into ordered `(name, uri)` pairs.
177/// Format: `name = uri` lines; `#` comments; blank lines ignored.
178/// Duplicate names: last wins (same as blit.conf).
179pub fn parse_remotes_str(contents: &str) -> Vec<(String, String)> {
180    // Use an index map to preserve insertion order while allowing last-wins
181    // for duplicates, without pulling in an extra dependency.
182    let mut order: Vec<String> = Vec::new();
183    let mut map: HashMap<String, String> = HashMap::new();
184    for line in contents.lines() {
185        let line = line.trim();
186        if line.is_empty() || line.starts_with('#') {
187            continue;
188        }
189        if let Some((k, v)) = line.split_once('=') {
190            let k = k.trim().to_string();
191            let v = v.trim().to_string();
192            if !k.is_empty() && !v.is_empty() {
193                if !map.contains_key(&k) {
194                    order.push(k.clone());
195                }
196                map.insert(k, v);
197            }
198        }
199    }
200    order
201        .into_iter()
202        .map(|k| {
203            let v = map.remove(&k).unwrap();
204            (k, v)
205        })
206        .collect()
207}
208
209fn serialize_remotes(entries: &[(String, String)]) -> String {
210    let mut out = String::new();
211    for (k, v) in entries {
212        out.push_str(k);
213        out.push_str(" = ");
214        out.push_str(v);
215        out.push('\n');
216    }
217    out
218}
219
220/// Write `blit.remotes` atomically with mode 0o600 (owner read/write only).
221pub fn write_remotes(entries: &[(String, String)]) {
222    let path = remotes_path();
223    if let Some(parent) = path.parent() {
224        let _ = std::fs::create_dir_all(parent);
225    }
226    let contents = serialize_remotes(entries);
227    write_secret_file(&path, &contents);
228}
229
230/// Write a file with mode 0o600 (owner-only).  On Unix this is done by
231/// writing to a temp file with the right mode, then atomically renaming.
232/// On Windows we just write normally (ACLs are handled separately if needed).
233fn write_secret_file(path: &PathBuf, contents: &str) {
234    #[cfg(unix)]
235    {
236        use std::os::unix::fs::OpenOptionsExt;
237        // Write to a sibling temp file with a unique name (pid + counter)
238        // so concurrent writers don't clobber each other's temp files.
239        use std::sync::atomic::{AtomicU32, Ordering};
240        static COUNTER: AtomicU32 = AtomicU32::new(0);
241        let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
242        let pid = std::process::id();
243        let tmp = path.with_extension(format!("tmp.{pid}.{seq}"));
244        let result = std::fs::OpenOptions::new()
245            .write(true)
246            .create(true)
247            .truncate(true)
248            .mode(0o600)
249            .open(&tmp)
250            .and_then(|mut f| {
251                use std::io::Write;
252                f.write_all(contents.as_bytes())
253            });
254        if result.is_ok() {
255            let _ = std::fs::rename(&tmp, path);
256        } else {
257            let _ = std::fs::remove_file(&tmp);
258        }
259    }
260    #[cfg(not(unix))]
261    {
262        let _ = std::fs::write(path, contents);
263    }
264}
265
266fn serialize_config_str(map: &HashMap<String, String>) -> String {
267    let mut lines: Vec<String> = map.iter().map(|(k, v)| format!("{k} = {v}")).collect();
268    lines.sort();
269    lines.push(String::new());
270    lines.join("\n")
271}
272
273pub fn write_config(map: &HashMap<String, String>) {
274    let path = config_path();
275    if let Some(parent) = path.parent() {
276        let _ = std::fs::create_dir_all(parent);
277    }
278    write_secret_file(&path, &serialize_config_str(map));
279}
280
281/// Watches a single file in its parent directory and calls `on_change`
282/// whenever the file is modified.  Skips access (read) events.
283fn spawn_file_watcher<F>(path: PathBuf, label: &'static str, on_change: F)
284where
285    F: Fn() + Send + 'static,
286{
287    use notify::{RecursiveMode, Watcher};
288
289    if let Some(parent) = path.parent() {
290        let _ = std::fs::create_dir_all(parent);
291    }
292
293    let watch_dir = path.parent().unwrap_or(&path).to_path_buf();
294    let file_name = path.file_name().map(|n| n.to_os_string());
295
296    std::thread::Builder::new()
297        .name(format!("{label}-watcher"))
298        .spawn(move || {
299            let (ntx, nrx) = std::sync::mpsc::channel();
300            let mut watcher = match notify::recommended_watcher(ntx) {
301                Ok(w) => w,
302                Err(e) => {
303                    eprintln!("blit: {label} watcher failed: {e}");
304                    return;
305                }
306            };
307            if let Err(e) = watcher.watch(&watch_dir, RecursiveMode::NonRecursive) {
308                eprintln!("blit: {label} watch failed: {e}");
309                return;
310            }
311            loop {
312                match nrx.recv() {
313                    Ok(Ok(event)) => {
314                        if matches!(event.kind, notify::EventKind::Access(_)) {
315                            continue;
316                        }
317                        let matches = file_name.as_ref().is_none_or(|name| {
318                            event.paths.iter().any(|p| p.file_name() == Some(name))
319                        });
320                        if matches {
321                            on_change();
322                        }
323                    }
324                    Ok(Err(_)) => continue,
325                    Err(_) => break,
326                }
327            }
328        })
329        .expect("failed to spawn file-watcher thread");
330}
331
332fn spawn_watcher(tx: broadcast::Sender<String>) {
333    let path = config_path();
334    spawn_file_watcher(path, "config", move || {
335        let map = read_config();
336        for (k, v) in &map {
337            let _ = tx.send(format!("{k}={v}"));
338        }
339        let _ = tx.send("ready".into());
340    });
341}
342
343// ---------------------------------------------------------------------------
344// RemotesState — live-reloading blit.remotes with 0o600 permissions
345// ---------------------------------------------------------------------------
346
347/// Manages `blit.remotes`: reads/writes the file, watches for external
348/// changes, and broadcasts the serialised contents to all subscribers.
349///
350/// The broadcast value is the raw file text (same as what `read_remotes`
351/// would parse), sent as a single string so receivers can re-parse it.
352/// The config WebSocket handler prefixes it with `"remotes:"`.
353#[derive(Clone)]
354pub struct RemotesState {
355    inner: Arc<RemotesInner>,
356}
357
358struct RemotesInner {
359    /// Cached current contents (raw file text, normalized).
360    contents: RwLock<String>,
361    tx: broadcast::Sender<String>,
362}
363
364impl RemotesState {
365    /// Full persistent mode: reads `blit.remotes`, watches it for changes.
366    pub fn new() -> Self {
367        let (tx, _) = broadcast::channel(64);
368        let inner = Arc::new(RemotesInner {
369            contents: RwLock::new(serialize_remotes(&read_remotes())),
370            tx,
371        });
372        let watcher_inner = inner.clone();
373        spawn_file_watcher(remotes_path(), "remotes", move || {
374            // Read directly — do not auto-provision. The file may be
375            // intentionally empty (user removed all remotes).
376            let text = std::fs::read_to_string(remotes_path()).unwrap_or_default();
377            *watcher_inner.contents.write().unwrap() = text.clone();
378            let _ = watcher_inner.tx.send(text);
379        });
380        Self { inner }
381    }
382
383    /// Ephemeral mode: starts with the given text, no file I/O, no watcher.
384    /// Used by `blit open` to advertise the session's destinations to the
385    /// browser without touching `blit.remotes`.
386    pub fn ephemeral(initial: String) -> Self {
387        let (tx, _) = broadcast::channel(64);
388        Self {
389            inner: Arc::new(RemotesInner {
390                contents: RwLock::new(initial),
391                tx,
392            }),
393        }
394    }
395
396    /// Returns the current serialized remotes contents.
397    pub fn get(&self) -> String {
398        self.inner.contents.read().unwrap().clone()
399    }
400
401    /// Overwrite `blit.remotes` with `entries` and broadcast the change.
402    pub fn set(&self, entries: &[(String, String)]) {
403        write_remotes(entries);
404        let text = serialize_remotes(entries);
405        *self.inner.contents.write().unwrap() = text.clone();
406        let _ = self.inner.tx.send(text);
407    }
408
409    /// Atomically read-modify-write `blit.remotes` under an exclusive flock,
410    /// then update the in-memory cache and broadcast.
411    pub fn modify(&self, f: impl FnOnce(&mut Vec<(String, String)>)) {
412        let _lock = lock_config_dir();
413        let mut entries = parse_remotes_str(&self.get());
414        f(&mut entries);
415        self.set(&entries);
416    }
417
418    pub fn subscribe(&self) -> broadcast::Receiver<String> {
419        self.inner.tx.subscribe()
420    }
421}
422
423impl Default for RemotesState {
424    fn default() -> Self {
425        Self::new()
426    }
427}
428
429fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
430    if a.len() != b.len() {
431        return false;
432    }
433    let mut diff = 0u8;
434    for (x, y) in a.iter().zip(b.iter()) {
435        diff |= x ^ y;
436    }
437    std::hint::black_box(diff) == 0
438}
439
440fn parse_config_str(contents: &str) -> HashMap<String, String> {
441    let mut map = HashMap::new();
442    for line in contents.lines() {
443        let line = line.trim();
444        if line.is_empty() || line.starts_with('#') {
445            continue;
446        }
447        if let Some((k, v)) = line.split_once('=') {
448            map.insert(k.trim().to_string(), v.trim().to_string());
449        }
450    }
451    map
452}
453
454/// Handle the `/config` WebSocket connection.
455///
456/// Protocol (server → client, after auth):
457///   1. `"ok"` — authentication accepted.
458///   2. `"remotes:<text>"` — sent immediately (and re-sent on any change to
459///      `blit.remotes`).  `<text>` is the raw `blit.remotes` file contents:
460///      `name = uri` lines.  Empty string if the file does not exist.
461///   3. Zero or more `"key=value"` messages — current browser settings.
462///   4. `"ready"` — end of initial burst; live updates follow.
463///
464/// After `"ready"`, the server pushes:
465///   - `"remotes:<text>"` when `blit.remotes` changes.
466///   - `"key=value"` when `blit.conf` changes.
467///
468/// The client may send:
469///   - `"set key value"` — persist a browser setting.
470///   - `"remotes-add name uri"` — add or update a remote; name must not
471///     contain `=` or whitespace; uri must be non-empty.
472///   - `"remotes-remove name"` — remove a remote by name.
473///   - `"remotes-set-default name"` — write `target = name` to `blit.conf`
474///     (or remove the key if name is empty or `"local"`).  The updated
475///     `target` value is then broadcast to all config-WS clients as a
476///     normal `"target=value"` message via the config-file watcher.
477///   - `"remotes-reorder name1 name2 …"` — reorder remotes to match the
478///     supplied name sequence; any names not listed are appended at the end
479///     in their original relative order.
480pub async fn handle_config_ws(
481    mut ws: WebSocket,
482    token: &str,
483    config: &ConfigState,
484    remotes: Option<&RemotesState>,
485    remotes_transform: Option<fn(&str) -> String>,
486    extra_init: &[String],
487) {
488    let authed = loop {
489        match ws.recv().await {
490            Some(Ok(Message::Text(pass))) => {
491                if constant_time_eq(pass.trim().as_bytes(), token.as_bytes()) {
492                    let _ = ws.send(Message::Text("ok".into())).await;
493                    break true;
494                } else {
495                    let _ = ws.close().await;
496                    break false;
497                }
498            }
499            Some(Ok(Message::Ping(d))) => {
500                let _ = ws.send(Message::Pong(d)).await;
501            }
502            _ => break false,
503        }
504    };
505    if !authed {
506        return;
507    }
508
509    // Subscribe before reading the snapshot so we can't miss a concurrent write.
510    let mut remotes_rx = remotes.map(|r| r.subscribe());
511
512    // Send the current remotes snapshot (even if empty — client can rely on
513    // always receiving this message after "ok").
514    let remotes_text = remotes.map(|r| r.get()).unwrap_or_default();
515    let remotes_text = remotes_transform
516        .map(|f| f(&remotes_text))
517        .unwrap_or(remotes_text);
518    if ws
519        .send(Message::Text(format!("remotes:{remotes_text}").into()))
520        .await
521        .is_err()
522    {
523        return;
524    }
525
526    let map = read_config();
527    for (k, v) in &map {
528        if ws
529            .send(Message::Text(format!("{k}={v}").into()))
530            .await
531            .is_err()
532        {
533            return;
534        }
535    }
536    for msg in extra_init {
537        if ws.send(Message::Text(msg.clone().into())).await.is_err() {
538            return;
539        }
540    }
541    if ws.send(Message::Text("ready".into())).await.is_err() {
542        return;
543    }
544
545    let mut config_rx = config.tx.subscribe();
546
547    loop {
548        // Build the select! arms dynamically based on whether we have a
549        // destinations receiver.  We can't use an Option inside select!
550        // directly, so we use a never-resolving future as a stand-in.
551        tokio::select! {
552            msg = ws.recv() => {
553                match msg {
554                    Some(Ok(Message::Text(text))) => {
555                        let text = text.trim();
556                        if let Some(rest) = text.strip_prefix("set ")
557                            && let Some((k, v)) = rest.split_once(' ') {
558                                let k = k.trim().replace(['\n', '\r'], "");
559                                let v = v.trim().replace(['\n', '\r'], "");
560                                if k.is_empty() { continue; }
561                                modify_config(|map| {
562                                    if v.is_empty() {
563                                        map.remove(&k);
564                                    } else {
565                                        map.insert(k, v);
566                                    }
567                                });
568                        } else if let Some(rest) = text.strip_prefix("remotes-add ") {
569                            // "remotes-add <name> <uri>" — name is first whitespace-delimited
570                            // word, uri is the remainder after a single space.
571                            if let Some((raw_name, raw_uri)) = rest.split_once(' ') {
572                                let name = raw_name.trim().replace(['\n', '\r'], "");
573                                let uri = raw_uri.trim().replace(['\n', '\r'], "");
574                                if !name.is_empty()
575                                    && !name.contains('=')
576                                    && !uri.is_empty()
577                                    && let Some(r) = remotes
578                                {
579                                    r.modify(|entries| {
580                                        if let Some(pos) = entries.iter().position(|(n, _)| n == &name) {
581                                            entries[pos].1 = uri;
582                                        } else {
583                                            entries.push((name, uri));
584                                        }
585                                    });
586                                }
587                            }
588                        } else if let Some(name) = text.strip_prefix("remotes-remove ") {
589                            let name = name.trim().replace(['\n', '\r'], "");
590                            if !name.is_empty()
591                                && let Some(r) = remotes
592                            {
593                                r.modify(|entries| {
594                                    entries.retain(|(n, _)| n != &name);
595                                });
596                            }
597                        } else if let Some(name) = text.strip_prefix("remotes-set-default ") {
598                            // Write blit.target = <name> to blit.conf (or remove it for local/empty).
599                            let name = name.trim().replace(['\n', '\r'], "");
600                            modify_config(|map| {
601                                if name.is_empty() || name == "local" {
602                                    map.remove("blit.target");
603                                } else {
604                                    map.insert("blit.target".into(), name);
605                                }
606                            });
607                        } else if let Some(rest) = text.strip_prefix("remotes-reorder ") {
608                            // "remotes-reorder name1 name2 …" — reorder entries to match
609                            // the supplied sequence; unlisted entries are appended at end.
610                            if let Some(r) = remotes {
611                                let desired: Vec<String> = rest
612                                    .split_whitespace()
613                                    .map(|s| s.replace(['\n', '\r'], ""))
614                                    .filter(|s| !s.is_empty())
615                                    .collect();
616                                if !desired.is_empty() {
617                                    r.modify(|entries| {
618                                        let map: std::collections::HashMap<&str, &str> = entries
619                                            .iter()
620                                            .map(|(n, u)| (n.as_str(), u.as_str()))
621                                            .collect();
622                                        let mut reordered: Vec<(String, String)> = desired
623                                            .iter()
624                                            .filter_map(|n| {
625                                                map.get(n.as_str())
626                                                    .map(|u| (n.clone(), u.to_string()))
627                                            })
628                                            .collect();
629                                        let desired_set: std::collections::HashSet<&str> =
630                                            desired.iter().map(|s| s.as_str()).collect();
631                                        for (n, u) in entries.iter() {
632                                            if !desired_set.contains(n.as_str()) {
633                                                reordered.push((n.clone(), u.clone()));
634                                            }
635                                        }
636                                        *entries = reordered;
637                                    });
638                                }
639                            }
640                        }
641                    }
642                    Some(Ok(Message::Close(_))) | None => break,
643                    Some(Err(_)) => break,
644                    _ => continue,
645                }
646            }
647            broadcast = config_rx.recv() => {
648                match broadcast {
649                    Ok(line) => {
650                        if ws.send(Message::Text(line.into())).await.is_err() {
651                            break;
652                        }
653                    }
654                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
655                    Err(_) => break,
656                }
657            }
658            remotes_update = async {
659                match remotes_rx.as_mut() {
660                    Some(rx) => rx.recv().await,
661                    None => std::future::pending().await,
662                }
663            } => {
664                match remotes_update {
665                    Ok(text) => {
666                        let text = remotes_transform
667                            .map(|f| f(&text))
668                            .unwrap_or(text);
669                        if ws
670                            .send(Message::Text(format!("remotes:{text}").into()))
671                            .await
672                            .is_err()
673                        {
674                            break;
675                        }
676                    }
677                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
678                        // Missed some intermediate updates — send current snapshot.
679                        if let Some(r) = remotes {
680                            let text = r.get();
681                            let text = remotes_transform
682                                .map(|f| f(&text))
683                                .unwrap_or(text);
684                            if ws
685                                .send(Message::Text(format!("remotes:{text}").into()))
686                                .await
687                                .is_err()
688                            {
689                                break;
690                            }
691                        }
692                    }
693                    Err(_) => break,
694                }
695            }
696        }
697    }
698}
699
700#[cfg(test)]
701mod tests {
702    use super::*;
703
704    // ── constant_time_eq ──
705
706    #[test]
707    fn ct_eq_equal_slices() {
708        assert!(constant_time_eq(b"hello", b"hello"));
709    }
710
711    #[test]
712    fn ct_eq_different_slices() {
713        assert!(!constant_time_eq(b"hello", b"world"));
714    }
715
716    #[test]
717    fn ct_eq_different_lengths() {
718        assert!(!constant_time_eq(b"short", b"longer"));
719    }
720
721    #[test]
722    fn ct_eq_empty_slices() {
723        assert!(constant_time_eq(b"", b""));
724    }
725
726    #[test]
727    fn ct_eq_single_bit_diff() {
728        assert!(!constant_time_eq(b"\x00", b"\x01"));
729    }
730
731    #[test]
732    fn ct_eq_one_empty_one_not() {
733        assert!(!constant_time_eq(b"", b"x"));
734    }
735
736    // ── parse_config_str ──
737
738    #[test]
739    fn parse_empty_string() {
740        let map = parse_config_str("");
741        assert!(map.is_empty());
742    }
743
744    #[test]
745    fn parse_comments_and_blanks() {
746        let map = parse_config_str("# comment\n\n  # another\n");
747        assert!(map.is_empty());
748    }
749
750    #[test]
751    fn parse_key_value() {
752        let map = parse_config_str("font = Menlo\ntheme = dark\n");
753        assert_eq!(map.get("font").unwrap(), "Menlo");
754        assert_eq!(map.get("theme").unwrap(), "dark");
755    }
756
757    #[test]
758    fn parse_trims_whitespace() {
759        let map = parse_config_str("  key  =  value  ");
760        assert_eq!(map.get("key").unwrap(), "value");
761    }
762
763    #[test]
764    fn parse_line_without_equals() {
765        let map = parse_config_str("no-equals-here\nkey=val");
766        assert_eq!(map.len(), 1);
767        assert_eq!(map.get("key").unwrap(), "val");
768    }
769
770    #[test]
771    fn parse_equals_in_value() {
772        let map = parse_config_str("cmd = a=b=c");
773        assert_eq!(map.get("cmd").unwrap(), "a=b=c");
774    }
775
776    #[test]
777    fn parse_duplicate_keys_last_wins() {
778        let map = parse_config_str("key = first\nkey = second");
779        assert_eq!(map.get("key").unwrap(), "second");
780    }
781
782    #[test]
783    fn parse_mixed_content() {
784        let input = "# header\nfont = FiraCode\n\n# size\nsize = 14\ntheme=light";
785        let map = parse_config_str(input);
786        assert_eq!(map.len(), 3);
787        assert_eq!(map.get("font").unwrap(), "FiraCode");
788        assert_eq!(map.get("size").unwrap(), "14");
789        assert_eq!(map.get("theme").unwrap(), "light");
790    }
791
792    // ── write_config round-trip ──
793
794    #[test]
795    fn serialize_config_produces_sorted_output() {
796        let mut map: HashMap<String, String> = HashMap::new();
797        map.insert("z".into(), "last".into());
798        map.insert("a".into(), "first".into());
799        let output = serialize_config_str(&map);
800        assert!(output.starts_with("a = first"));
801        assert!(output.contains("z = last"));
802    }
803
804    #[test]
805    fn round_trip_parse_serialize() {
806        let input = "alpha = 1\nbeta = 2\ngamma = 3";
807        let map = parse_config_str(input);
808        let serialized = serialize_config_str(&map);
809        let reparsed = parse_config_str(&serialized);
810        assert_eq!(map, reparsed);
811    }
812
813    // ── RemotesState mutations (remotes-add / remotes-remove) ──
814
815    #[test]
816    fn remotes_add_new_entry() {
817        let state = RemotesState::ephemeral(String::new());
818        let mut entries = parse_remotes_str(&state.get());
819        entries.push(("rabbit".to_string(), "ssh:rabbit".to_string()));
820        state.set(&entries);
821        let got = parse_remotes_str(&state.get());
822        assert_eq!(got.len(), 1);
823        assert_eq!(got[0], ("rabbit".to_string(), "ssh:rabbit".to_string()));
824    }
825
826    #[test]
827    fn remotes_add_updates_existing() {
828        let initial = "rabbit = ssh:rabbit\n";
829        let state = RemotesState::ephemeral(initial.to_string());
830        let mut entries = parse_remotes_str(&state.get());
831        if let Some(pos) = entries.iter().position(|(n, _)| n == "rabbit") {
832            entries[pos].1 = "tcp:rabbit:3264".to_string();
833        }
834        state.set(&entries);
835        let got = parse_remotes_str(&state.get());
836        assert_eq!(got.len(), 1);
837        assert_eq!(got[0].1, "tcp:rabbit:3264");
838    }
839
840    #[test]
841    fn remotes_remove_existing() {
842        let initial = "rabbit = ssh:rabbit\nhound = ssh:hound\n";
843        let state = RemotesState::ephemeral(initial.to_string());
844        let mut entries = parse_remotes_str(&state.get());
845        entries.retain(|(n, _)| n != "rabbit");
846        state.set(&entries);
847        let got = parse_remotes_str(&state.get());
848        assert_eq!(got.len(), 1);
849        assert_eq!(got[0].0, "hound");
850    }
851
852    #[test]
853    fn remotes_remove_nonexistent_is_noop() {
854        let initial = "rabbit = ssh:rabbit\n";
855        let state = RemotesState::ephemeral(initial.to_string());
856        let mut entries = parse_remotes_str(&state.get());
857        let before = entries.len();
858        entries.retain(|(n, _)| n != "does-not-exist");
859        assert_eq!(entries.len(), before);
860    }
861
862    #[test]
863    fn remotes_add_rejects_empty_name() {
864        // Simulate the validation in handle_config_ws: empty name is rejected.
865        let name = "";
866        assert!(name.is_empty() || name.contains('='));
867    }
868
869    #[test]
870    fn remotes_add_rejects_name_with_equals() {
871        let name = "foo=bar";
872        assert!(name.contains('='));
873    }
874
875    // ── set-default writes blit.target key to blit.conf ──
876
877    #[test]
878    fn set_default_inserts_target_key() {
879        let mut map = parse_config_str("font = Mono\n");
880        map.insert("blit.target".into(), "rabbit".into());
881        let serialized = serialize_config_str(&map);
882        let reparsed = parse_config_str(&serialized);
883        assert_eq!(
884            reparsed.get("blit.target").map(|s| s.as_str()),
885            Some("rabbit")
886        );
887        assert_eq!(reparsed.get("font").map(|s| s.as_str()), Some("Mono"));
888    }
889
890    #[test]
891    fn set_default_local_removes_target_key() {
892        let mut map = parse_config_str("blit.target = rabbit\nfont = Mono\n");
893        // "local" or empty → remove the key
894        map.remove("blit.target");
895        let serialized = serialize_config_str(&map);
896        let reparsed = parse_config_str(&serialized);
897        assert!(!reparsed.contains_key("blit.target"));
898        assert_eq!(reparsed.get("font").map(|s| s.as_str()), Some("Mono"));
899    }
900}