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    let mut diff = (a.len() ^ b.len()) as u8;
431    for i in 0..a.len().min(b.len()) {
432        diff |= a[i] ^ b[i];
433    }
434    std::hint::black_box(diff) == 0
435}
436
437fn parse_config_str(contents: &str) -> HashMap<String, String> {
438    let mut map = HashMap::new();
439    for line in contents.lines() {
440        let line = line.trim();
441        if line.is_empty() || line.starts_with('#') {
442            continue;
443        }
444        if let Some((k, v)) = line.split_once('=') {
445            map.insert(k.trim().to_string(), v.trim().to_string());
446        }
447    }
448    map
449}
450
451/// Handle the `/config` WebSocket connection.
452///
453/// Protocol (server → client, after auth):
454///   1. `"ok"` — authentication accepted.
455///   2. `"remotes:<text>"` — sent immediately (and re-sent on any change to
456///      `blit.remotes`).  `<text>` is the raw `blit.remotes` file contents:
457///      `name = uri` lines.  Empty string if the file does not exist.
458///   3. Zero or more `"key=value"` messages — current browser settings.
459///   4. `"ready"` — end of initial burst; live updates follow.
460///
461/// After `"ready"`, the server pushes:
462///   - `"remotes:<text>"` when `blit.remotes` changes.
463///   - `"key=value"` when `blit.conf` changes.
464///
465/// The client may send:
466///   - `"set key value"` — persist a browser setting.
467///   - `"remotes-add name uri"` — add or update a remote; name must not
468///     contain `=` or whitespace; uri must be non-empty.
469///   - `"remotes-remove name"` — remove a remote by name.
470///   - `"remotes-set-default name"` — write `target = name` to `blit.conf`
471///     (or remove the key if name is empty or `"local"`).  The updated
472///     `target` value is then broadcast to all config-WS clients as a
473///     normal `"target=value"` message via the config-file watcher.
474///   - `"remotes-reorder name1 name2 …"` — reorder remotes to match the
475///     supplied name sequence; any names not listed are appended at the end
476///     in their original relative order.
477pub async fn handle_config_ws(
478    mut ws: WebSocket,
479    token: &str,
480    config: &ConfigState,
481    remotes: Option<&RemotesState>,
482    remotes_transform: Option<fn(&str) -> String>,
483    extra_init: &[String],
484) {
485    let authed = loop {
486        match ws.recv().await {
487            Some(Ok(Message::Text(pass))) => {
488                if constant_time_eq(pass.trim().as_bytes(), token.as_bytes()) {
489                    let _ = ws.send(Message::Text("ok".into())).await;
490                    break true;
491                } else {
492                    let _ = ws.close().await;
493                    break false;
494                }
495            }
496            Some(Ok(Message::Ping(d))) => {
497                let _ = ws.send(Message::Pong(d)).await;
498            }
499            _ => break false,
500        }
501    };
502    if !authed {
503        return;
504    }
505
506    // Subscribe before reading the snapshot so we can't miss a concurrent write.
507    let mut remotes_rx = remotes.map(|r| r.subscribe());
508
509    // Send the current remotes snapshot (even if empty — client can rely on
510    // always receiving this message after "ok").
511    let remotes_text = remotes.map(|r| r.get()).unwrap_or_default();
512    let remotes_text = remotes_transform
513        .map(|f| f(&remotes_text))
514        .unwrap_or(remotes_text);
515    if ws
516        .send(Message::Text(format!("remotes:{remotes_text}").into()))
517        .await
518        .is_err()
519    {
520        return;
521    }
522
523    let map = read_config();
524    for (k, v) in &map {
525        if ws
526            .send(Message::Text(format!("{k}={v}").into()))
527            .await
528            .is_err()
529        {
530            return;
531        }
532    }
533    for msg in extra_init {
534        if ws.send(Message::Text(msg.clone().into())).await.is_err() {
535            return;
536        }
537    }
538    if ws.send(Message::Text("ready".into())).await.is_err() {
539        return;
540    }
541
542    let mut config_rx = config.tx.subscribe();
543
544    loop {
545        // Build the select! arms dynamically based on whether we have a
546        // destinations receiver.  We can't use an Option inside select!
547        // directly, so we use a never-resolving future as a stand-in.
548        tokio::select! {
549            msg = ws.recv() => {
550                match msg {
551                    Some(Ok(Message::Text(text))) => {
552                        let text = text.trim();
553                        if let Some(rest) = text.strip_prefix("set ")
554                            && let Some((k, v)) = rest.split_once(' ') {
555                                let k = k.trim().replace(['\n', '\r'], "");
556                                let v = v.trim().replace(['\n', '\r'], "");
557                                if k.is_empty() { continue; }
558                                modify_config(|map| {
559                                    if v.is_empty() {
560                                        map.remove(&k);
561                                    } else {
562                                        map.insert(k, v);
563                                    }
564                                });
565                        } else if let Some(rest) = text.strip_prefix("remotes-add ") {
566                            // "remotes-add <name> <uri>" — name is first whitespace-delimited
567                            // word, uri is the remainder after a single space.
568                            if let Some((raw_name, raw_uri)) = rest.split_once(' ') {
569                                let name = raw_name.trim().replace(['\n', '\r'], "");
570                                let uri = raw_uri.trim().replace(['\n', '\r'], "");
571                                if !name.is_empty()
572                                    && !name.contains('=')
573                                    && !uri.is_empty()
574                                    && let Some(r) = remotes
575                                {
576                                    r.modify(|entries| {
577                                        if let Some(pos) = entries.iter().position(|(n, _)| n == &name) {
578                                            entries[pos].1 = uri;
579                                        } else {
580                                            entries.push((name, uri));
581                                        }
582                                    });
583                                }
584                            }
585                        } else if let Some(name) = text.strip_prefix("remotes-remove ") {
586                            let name = name.trim().replace(['\n', '\r'], "");
587                            if !name.is_empty()
588                                && let Some(r) = remotes
589                            {
590                                r.modify(|entries| {
591                                    entries.retain(|(n, _)| n != &name);
592                                });
593                            }
594                        } else if let Some(name) = text.strip_prefix("remotes-set-default ") {
595                            // Write blit.target = <name> to blit.conf (or remove it for local/empty).
596                            let name = name.trim().replace(['\n', '\r'], "");
597                            modify_config(|map| {
598                                if name.is_empty() || name == "local" {
599                                    map.remove("blit.target");
600                                } else {
601                                    map.insert("blit.target".into(), name);
602                                }
603                            });
604                        } else if let Some(rest) = text.strip_prefix("remotes-reorder ") {
605                            // "remotes-reorder name1 name2 …" — reorder entries to match
606                            // the supplied sequence; unlisted entries are appended at end.
607                            if let Some(r) = remotes {
608                                let desired: Vec<String> = rest
609                                    .split_whitespace()
610                                    .map(|s| s.replace(['\n', '\r'], ""))
611                                    .filter(|s| !s.is_empty())
612                                    .collect();
613                                if !desired.is_empty() {
614                                    r.modify(|entries| {
615                                        let map: std::collections::HashMap<&str, &str> = entries
616                                            .iter()
617                                            .map(|(n, u)| (n.as_str(), u.as_str()))
618                                            .collect();
619                                        let mut reordered: Vec<(String, String)> = desired
620                                            .iter()
621                                            .filter_map(|n| {
622                                                map.get(n.as_str())
623                                                    .map(|u| (n.clone(), u.to_string()))
624                                            })
625                                            .collect();
626                                        let desired_set: std::collections::HashSet<&str> =
627                                            desired.iter().map(|s| s.as_str()).collect();
628                                        for (n, u) in entries.iter() {
629                                            if !desired_set.contains(n.as_str()) {
630                                                reordered.push((n.clone(), u.clone()));
631                                            }
632                                        }
633                                        *entries = reordered;
634                                    });
635                                }
636                            }
637                        }
638                    }
639                    Some(Ok(Message::Close(_))) | None => break,
640                    Some(Err(_)) => break,
641                    _ => continue,
642                }
643            }
644            broadcast = config_rx.recv() => {
645                match broadcast {
646                    Ok(line) => {
647                        if ws.send(Message::Text(line.into())).await.is_err() {
648                            break;
649                        }
650                    }
651                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
652                    Err(_) => break,
653                }
654            }
655            remotes_update = async {
656                match remotes_rx.as_mut() {
657                    Some(rx) => rx.recv().await,
658                    None => std::future::pending().await,
659                }
660            } => {
661                match remotes_update {
662                    Ok(text) => {
663                        let text = remotes_transform
664                            .map(|f| f(&text))
665                            .unwrap_or(text);
666                        if ws
667                            .send(Message::Text(format!("remotes:{text}").into()))
668                            .await
669                            .is_err()
670                        {
671                            break;
672                        }
673                    }
674                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
675                        // Missed some intermediate updates — send current snapshot.
676                        if let Some(r) = remotes {
677                            let text = r.get();
678                            let text = remotes_transform
679                                .map(|f| f(&text))
680                                .unwrap_or(text);
681                            if ws
682                                .send(Message::Text(format!("remotes:{text}").into()))
683                                .await
684                                .is_err()
685                            {
686                                break;
687                            }
688                        }
689                    }
690                    Err(_) => break,
691                }
692            }
693        }
694    }
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700
701    // ── constant_time_eq ──
702
703    #[test]
704    fn ct_eq_equal_slices() {
705        assert!(constant_time_eq(b"hello", b"hello"));
706    }
707
708    #[test]
709    fn ct_eq_different_slices() {
710        assert!(!constant_time_eq(b"hello", b"world"));
711    }
712
713    #[test]
714    fn ct_eq_different_lengths() {
715        assert!(!constant_time_eq(b"short", b"longer"));
716    }
717
718    #[test]
719    fn ct_eq_empty_slices() {
720        assert!(constant_time_eq(b"", b""));
721    }
722
723    #[test]
724    fn ct_eq_single_bit_diff() {
725        assert!(!constant_time_eq(b"\x00", b"\x01"));
726    }
727
728    #[test]
729    fn ct_eq_one_empty_one_not() {
730        assert!(!constant_time_eq(b"", b"x"));
731    }
732
733    // ── parse_config_str ──
734
735    #[test]
736    fn parse_empty_string() {
737        let map = parse_config_str("");
738        assert!(map.is_empty());
739    }
740
741    #[test]
742    fn parse_comments_and_blanks() {
743        let map = parse_config_str("# comment\n\n  # another\n");
744        assert!(map.is_empty());
745    }
746
747    #[test]
748    fn parse_key_value() {
749        let map = parse_config_str("font = Menlo\ntheme = dark\n");
750        assert_eq!(map.get("font").unwrap(), "Menlo");
751        assert_eq!(map.get("theme").unwrap(), "dark");
752    }
753
754    #[test]
755    fn parse_trims_whitespace() {
756        let map = parse_config_str("  key  =  value  ");
757        assert_eq!(map.get("key").unwrap(), "value");
758    }
759
760    #[test]
761    fn parse_line_without_equals() {
762        let map = parse_config_str("no-equals-here\nkey=val");
763        assert_eq!(map.len(), 1);
764        assert_eq!(map.get("key").unwrap(), "val");
765    }
766
767    #[test]
768    fn parse_equals_in_value() {
769        let map = parse_config_str("cmd = a=b=c");
770        assert_eq!(map.get("cmd").unwrap(), "a=b=c");
771    }
772
773    #[test]
774    fn parse_duplicate_keys_last_wins() {
775        let map = parse_config_str("key = first\nkey = second");
776        assert_eq!(map.get("key").unwrap(), "second");
777    }
778
779    #[test]
780    fn parse_mixed_content() {
781        let input = "# header\nfont = FiraCode\n\n# size\nsize = 14\ntheme=light";
782        let map = parse_config_str(input);
783        assert_eq!(map.len(), 3);
784        assert_eq!(map.get("font").unwrap(), "FiraCode");
785        assert_eq!(map.get("size").unwrap(), "14");
786        assert_eq!(map.get("theme").unwrap(), "light");
787    }
788
789    // ── write_config round-trip ──
790
791    #[test]
792    fn serialize_config_produces_sorted_output() {
793        let mut map: HashMap<String, String> = HashMap::new();
794        map.insert("z".into(), "last".into());
795        map.insert("a".into(), "first".into());
796        let output = serialize_config_str(&map);
797        assert!(output.starts_with("a = first"));
798        assert!(output.contains("z = last"));
799    }
800
801    #[test]
802    fn round_trip_parse_serialize() {
803        let input = "alpha = 1\nbeta = 2\ngamma = 3";
804        let map = parse_config_str(input);
805        let serialized = serialize_config_str(&map);
806        let reparsed = parse_config_str(&serialized);
807        assert_eq!(map, reparsed);
808    }
809
810    // ── RemotesState mutations (remotes-add / remotes-remove) ──
811
812    #[test]
813    fn remotes_add_new_entry() {
814        let state = RemotesState::ephemeral(String::new());
815        let mut entries = parse_remotes_str(&state.get());
816        entries.push(("rabbit".to_string(), "ssh:rabbit".to_string()));
817        state.set(&entries);
818        let got = parse_remotes_str(&state.get());
819        assert_eq!(got.len(), 1);
820        assert_eq!(got[0], ("rabbit".to_string(), "ssh:rabbit".to_string()));
821    }
822
823    #[test]
824    fn remotes_add_updates_existing() {
825        let initial = "rabbit = ssh:rabbit\n";
826        let state = RemotesState::ephemeral(initial.to_string());
827        let mut entries = parse_remotes_str(&state.get());
828        if let Some(pos) = entries.iter().position(|(n, _)| n == "rabbit") {
829            entries[pos].1 = "tcp:rabbit:3264".to_string();
830        }
831        state.set(&entries);
832        let got = parse_remotes_str(&state.get());
833        assert_eq!(got.len(), 1);
834        assert_eq!(got[0].1, "tcp:rabbit:3264");
835    }
836
837    #[test]
838    fn remotes_remove_existing() {
839        let initial = "rabbit = ssh:rabbit\nhound = ssh:hound\n";
840        let state = RemotesState::ephemeral(initial.to_string());
841        let mut entries = parse_remotes_str(&state.get());
842        entries.retain(|(n, _)| n != "rabbit");
843        state.set(&entries);
844        let got = parse_remotes_str(&state.get());
845        assert_eq!(got.len(), 1);
846        assert_eq!(got[0].0, "hound");
847    }
848
849    #[test]
850    fn remotes_remove_nonexistent_is_noop() {
851        let initial = "rabbit = ssh:rabbit\n";
852        let state = RemotesState::ephemeral(initial.to_string());
853        let mut entries = parse_remotes_str(&state.get());
854        let before = entries.len();
855        entries.retain(|(n, _)| n != "does-not-exist");
856        assert_eq!(entries.len(), before);
857    }
858
859    #[test]
860    fn remotes_add_rejects_empty_name() {
861        // Simulate the validation in handle_config_ws: empty name is rejected.
862        let name = "";
863        assert!(name.is_empty() || name.contains('='));
864    }
865
866    #[test]
867    fn remotes_add_rejects_name_with_equals() {
868        let name = "foo=bar";
869        assert!(name.contains('='));
870    }
871
872    // ── set-default writes blit.target key to blit.conf ──
873
874    #[test]
875    fn set_default_inserts_target_key() {
876        let mut map = parse_config_str("font = Mono\n");
877        map.insert("blit.target".into(), "rabbit".into());
878        let serialized = serialize_config_str(&map);
879        let reparsed = parse_config_str(&serialized);
880        assert_eq!(
881            reparsed.get("blit.target").map(|s| s.as_str()),
882            Some("rabbit")
883        );
884        assert_eq!(reparsed.get("font").map(|s| s.as_str()), Some("Mono"));
885    }
886
887    #[test]
888    fn set_default_local_removes_target_key() {
889        let mut map = parse_config_str("blit.target = rabbit\nfont = Mono\n");
890        // "local" or empty → remove the key
891        map.remove("blit.target");
892        let serialized = serialize_config_str(&map);
893        let reparsed = parse_config_str(&serialized);
894        assert!(!reparsed.contains_key("blit.target"));
895        assert_eq!(reparsed.get("font").map(|s| s.as_str()), Some("Mono"));
896    }
897}