Skip to main content

agentmux_srv/backend/
base.rs

1#![allow(dead_code)]
2// Copyright 2025, Command Line Inc.
3// Copyright 2025-2026, AgentMux Corp.
4// SPDX-License-Identifier: Apache-2.0
5
6//! AgentMux base utilities: directory management, lock files, environment, platform detection.
7//! Port of Go's pkg/base/.
8
9
10use std::env;
11use std::fs;
12use std::path::{Path, PathBuf};
13use std::sync::OnceLock;
14
15// ---- Environment variable names ----
16
17pub const MUX_CONFIG_HOME_ENV: &str = "AGENTMUX_CONFIG_HOME";
18pub const MUX_DATA_HOME_ENV: &str = "AGENTMUX_DATA_HOME";
19pub const MUX_APP_PATH_ENV: &str = "AGENTMUX_APP_PATH";
20pub const MUX_DEV_ENV: &str = "AGENTMUX_DEV";
21pub const MUX_DEV_VITE_ENV: &str = "AGENTMUX_DEV_VITE";
22pub const MUX_JWT_TOKEN_ENV: &str = "AGENTMUX_JWT";
23pub const MUX_SWAP_TOKEN_ENV: &str = "AGENTMUX_SWAPTOKEN";
24
25// ---- File/directory constants ----
26
27pub const MUX_LOCK_FILE: &str = "wave.lock";
28pub const DOMAIN_SOCKET_BASE_NAME: &str = "wave.sock";
29pub const REMOTE_DOMAIN_SOCKET_BASE_NAME: &str = "wave-remote.sock";
30pub const MUX_DB_DIR: &str = "db";
31pub const CONFIG_DIR: &str = "config";
32pub const REMOTE_MUX_HOME_DIR_NAME: &str = ".agentmux";
33pub const REMOTE_FULL_DOMAIN_SOCKET_PATH: &str = "~/.agentmux/wave-remote.sock";
34
35// ---- Version info (set at startup) ----
36
37static MUX_VERSION: OnceLock<String> = OnceLock::new();
38static BUILD_TIME: OnceLock<String> = OnceLock::new();
39
40/// Set the application version (called once at startup).
41pub fn set_version(version: &str) {
42    let _ = MUX_VERSION.set(version.to_string());
43}
44
45/// Set the build time (called once at startup).
46pub fn set_build_time(time: &str) {
47    let _ = BUILD_TIME.set(time.to_string());
48}
49
50/// Get the application version.
51pub fn get_version() -> &'static str {
52    MUX_VERSION.get().map_or("0.0.0", |v| v.as_str())
53}
54
55/// Get the build time.
56pub fn get_build_time() -> &'static str {
57    BUILD_TIME.get().map_or("0", |v| v.as_str())
58}
59
60// ---- Directory paths ----
61
62/// Get the user's home directory.
63pub fn get_home_dir() -> PathBuf {
64    dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
65}
66
67/// Get the AgentMux root directory (`~/.agentmux`).
68///
69/// Delegates to `agentmux_common::data_paths::agentmux_root`, which is the
70/// single resolver for this path. This function used to compute it itself,
71/// reading `AGENTMUX_DATA_HOME` where the common crate read
72/// `AGENTMUX_HOME_OVERRIDE` — two resolvers, two env vars, and two different
73/// answers if either was set. Both are now honoured by the one resolver, so
74/// an existing install finds its data exactly where it did before.
75/// A10 of docs/analysis/TRACKING_ARCHITECTURE_REFACTOR_A1_A15_2026_06_18.md.
76///
77/// The old local copy fell back to `PathBuf::from("/")` when `home_dir()`
78/// returned `None`, which meant a host with no resolvable home silently
79/// wrote to `/.agentmux`. That fallback is deliberately NOT preserved: the
80/// resolver returns `Err`, and rather than inventing a path this keeps the
81/// pre-existing shape of the API by falling back to the same `~/.agentmux`
82/// expression only when a home DOES exist. If it does not, writing to the
83/// filesystem root is never the right answer.
84pub fn get_mux_data_dir() -> PathBuf {
85    match agentmux_common::data_paths::agentmux_root() {
86        Ok(p) => p,
87        Err(e) => {
88            tracing::error!(
89                "cannot resolve the AgentMux root ({e}); falling back to a relative .agentmux rather than /"
90            );
91            PathBuf::from(".agentmux")
92        }
93    }
94}
95
96/// Migrate data from `~/.waveterm` to `~/.agentmux` if needed.
97/// Called once at startup. No-op if `~/.agentmux` already exists.
98pub fn migrate_legacy_data_dir() {
99    let new_dir = get_mux_data_dir();
100    if new_dir.exists() {
101        return; // already migrated or freshly created
102    }
103    let old_dir = get_home_dir().join(".waveterm");
104    if !old_dir.exists() {
105        return; // nothing to migrate
106    }
107    tracing::info!(
108        "Migrating data directory from {} to {}",
109        old_dir.display(),
110        new_dir.display()
111    );
112    if let Err(e) = copy_dir_all(&old_dir, &new_dir) {
113        tracing::warn!("Data migration failed (continuing with empty dir): {}", e);
114    } else {
115        tracing::info!("Migration complete");
116    }
117}
118
119/// Recursively copy a directory tree.
120fn copy_dir_all(src: &Path, dst: &Path) -> Result<(), String> {
121    fs::create_dir_all(dst)
122        .map_err(|e| format!("cannot create {}: {}", dst.display(), e))?;
123    for entry in fs::read_dir(src)
124        .map_err(|e| format!("cannot read {}: {}", src.display(), e))?
125    {
126        let entry = entry.map_err(|e| format!("read_dir entry error: {}", e))?;
127        let src_path = entry.path();
128        let dst_path = dst.join(entry.file_name());
129        if src_path.is_dir() {
130            copy_dir_all(&src_path, &dst_path)?;
131        } else {
132            fs::copy(&src_path, &dst_path)
133                .map_err(|e| format!("copy {} → {}: {}", src_path.display(), dst_path.display(), e))?;
134        }
135    }
136    Ok(())
137}
138
139/// Get the AgentMux config directory.
140/// Uses `AGENTMUX_CONFIG_HOME` env var, or defaults to `~/.agentmux/config`.
141pub fn get_mux_config_dir() -> PathBuf {
142    if let Some(dir) = env::var_os(MUX_CONFIG_HOME_ENV) {
143        if !dir.is_empty() {
144            return PathBuf::from(dir);
145        }
146    }
147    get_mux_data_dir().join(CONFIG_DIR)
148}
149
150/// Get the AgentMux DB directory (`~/.agentmux/db`).
151pub fn get_mux_db_dir() -> PathBuf {
152    get_mux_data_dir().join(MUX_DB_DIR)
153}
154
155/// Get the AgentMux app path from env.
156pub fn get_mux_app_path() -> Option<PathBuf> {
157    env::var(MUX_APP_PATH_ENV).ok().map(PathBuf::from)
158}
159
160/// Get the AgentMux app bin path.
161pub fn get_mux_app_bin_path() -> Option<PathBuf> {
162    get_mux_app_path().map(|p| p.join("bin"))
163}
164
165/// Get the domain socket path.
166pub fn get_domain_socket_name() -> PathBuf {
167    get_mux_data_dir().join(DOMAIN_SOCKET_BASE_NAME)
168}
169
170/// Get the AgentMux lock file path.
171pub fn get_mux_lock_file() -> PathBuf {
172    get_mux_data_dir().join(MUX_LOCK_FILE)
173}
174
175// ---- Directory creation ----
176
177/// Ensure a directory exists with the given permissions.
178pub fn ensure_dir(dir: &Path) -> Result<(), String> {
179    if dir.exists() {
180        return Ok(());
181    }
182    fs::create_dir_all(dir).map_err(|e| format!("cannot create directory {}: {}", dir.display(), e))
183}
184
185/// Ensure the AgentMux data directory exists.
186pub fn ensure_mux_data_dir() -> Result<(), String> {
187    ensure_dir(&get_mux_data_dir())
188}
189
190/// Ensure the AgentMux DB directory exists.
191pub fn ensure_mux_db_dir() -> Result<(), String> {
192    ensure_dir(&get_mux_db_dir())
193}
194
195/// Ensure the AgentMux config directory exists.
196pub fn ensure_mux_config_dir() -> Result<(), String> {
197    ensure_dir(&get_mux_config_dir())
198}
199
200/// Ensure the AgentMux presets directory exists.
201pub fn ensure_mux_presets_dir() -> Result<(), String> {
202    ensure_dir(&get_mux_config_dir().join("presets"))
203}
204
205// ---- Lock file ----
206
207/// File-based lock for single-instance enforcement.
208pub struct MuxLock {
209    #[allow(dead_code)]
210    file: fs::File,
211}
212
213impl MuxLock {
214    /// Acquire an exclusive lock on the AgentMux lock file.
215    /// Returns error if another instance is already running.
216    pub fn acquire() -> Result<Self, String> {
217        Self::acquire_at(&get_mux_lock_file())
218    }
219
220    /// Acquire an exclusive lock on `lock_path` (created if missing).
221    #[cfg(unix)]
222    pub fn acquire_at(lock_path: &Path) -> Result<Self, String> {
223        use std::os::unix::io::AsRawFd;
224
225        ensure_dir(lock_path.parent().unwrap_or(Path::new("/")))?;
226
227        let file = fs::OpenOptions::new()
228            .create(true)
229            .write(true)
230            .truncate(false)
231            .open(lock_path)
232            .map_err(|e| format!("cannot open lock file {}: {}", lock_path.display(), e))?;
233
234        let fd = file.as_raw_fd();
235        let result = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
236        if result != 0 {
237            return Err("another AgentMux instance is already running".to_string());
238        }
239
240        Ok(MuxLock { file })
241    }
242
243    /// Non-Unix fallback: just check the file can be created (no exclusion).
244    #[cfg(not(unix))]
245    pub fn acquire_at(lock_path: &Path) -> Result<Self, String> {
246        ensure_dir(lock_path.parent().unwrap_or(Path::new("/")))?;
247
248        let file = fs::OpenOptions::new()
249            .create(true)
250            .write(true)
251            .truncate(true)
252            .open(lock_path)
253            .map_err(|e| format!("cannot open lock file {}: {}", lock_path.display(), e))?;
254
255        Ok(MuxLock { file })
256    }
257}
258
259/// File srv locks in the directory holding its databases.
260pub const SRV_DATA_LOCK_FILE: &str = "srv.lock";
261
262/// Data-dir locks this process holds, kept for its lifetime.
263static DATA_DIR_LOCKS: std::sync::Mutex<Vec<(PathBuf, MuxLock)>> = std::sync::Mutex::new(Vec::new());
264
265/// Take srv's exclusive lock on `data_dir` (`<data_dir>/srv.lock`): one srv per
266/// set of databases, however each was started — launcher, host or `--headless`
267/// (SPEC_SRV_HEADLESS_MODE §3.2; Codex P1 on #3893). Idempotent within a
268/// process, so headless setup can take it early and bootstrap again later.
269/// Advisory `flock` on Unix; not exclusive elsewhere (see `MuxLock`).
270pub fn acquire_data_dir_lock(data_dir: &Path) -> Result<(), String> {
271    let mut held = DATA_DIR_LOCKS.lock().unwrap_or_else(|e| e.into_inner());
272    if held.iter().any(|(p, _)| p == data_dir) {
273        return Ok(());
274    }
275    let lock_path = data_dir.join(SRV_DATA_LOCK_FILE);
276    let mut lock = MuxLock::acquire_at(&lock_path).map_err(|_| {
277        format!(
278            "another agentmux-srv is already using {} (lock {})",
279            data_dir.display(),
280            lock_path.display()
281        )
282    })?;
283    // Record who holds it, so a launcher that finds the lock held by an srv
284    // its own launcher left behind can stop that srv instead of failing to
285    // start (crates/launcher/src/stale_srv.rs). Best effort: the lock itself
286    // is the flock, not the contents.
287    {
288        use std::io::{Seek, Write};
289        let _ = lock.file.set_len(0);
290        let _ = lock.file.seek(std::io::SeekFrom::Start(0));
291        let _ = write!(lock.file, "{}", std::process::id());
292    }
293    held.push((data_dir.to_path_buf(), lock));
294    Ok(())
295}
296
297// ---- Environment helpers ----
298
299/// Check if AgentMux is in dev mode.
300pub fn is_dev_mode() -> bool {
301    env::var(MUX_DEV_ENV)
302        .map(|v| !v.is_empty())
303        .unwrap_or(false)
304}
305
306/// Expand `~` at the start of a path to the home directory.
307/// Rejects `~user/` and paths containing `..` components (path traversal).
308pub fn expand_home_dir(path: &str) -> Result<PathBuf, String> {
309    // Reject paths with .. components regardless of ~ prefix
310    if path.split(['/', '\\']).any(|c| c == "..") {
311        return Err(format!("cannot expand path: '..' traversal in {}", path));
312    }
313
314    if let Some(rest) = path.strip_prefix('~') {
315        let home = get_home_dir();
316        if rest.is_empty() || rest.starts_with('/') || rest.starts_with('\\') {
317            Ok(home.join(rest.trim_start_matches(['/', '\\'])))
318        } else {
319            Err(format!("cannot expand ~: invalid ~user in {}", path))
320        }
321    } else {
322        Ok(PathBuf::from(path))
323    }
324}
325
326/// Safe version of expand_home_dir that returns the original on error.
327pub fn expand_home_dir_safe(path: &str) -> PathBuf {
328    expand_home_dir(path).unwrap_or_else(|_| PathBuf::from(path))
329}
330
331/// Convert an MSYS / Git-Bash POSIX drive path to a native Windows path.
332///
333/// Agents on Windows typically run inside a bash shell (MSYS2 / Git Bash),
334/// so they naturally emit paths like `/c/Users/user/project` or the WSL
335/// form `/mnt/c/Users/user/project`. Passing one of those straight to
336/// `std::process::Command::current_dir` makes `CreateProcess` fail with
337/// `os error 267` (ERROR_DIRECTORY, "The directory name is invalid").
338///
339/// Recognised forms (case-insensitive drive letter):
340/// - `/c`            → `C:\`
341/// - `/c/Users/x`    → `C:\Users\x`
342/// - `/mnt/c/Users/x`→ `C:\Users\x`
343///
344/// Anything that isn't a POSIX drive path is returned unchanged. This is a
345/// no-op on non-Windows targets (the input is already a valid POSIX path).
346pub fn msys_to_windows_path(path: &str) -> String {
347    #[cfg(windows)]
348    {
349        // Strip an optional WSL `/mnt` prefix so both `/c/...` and
350        // `/mnt/c/...` reduce to the same `/<drive>/<rest>` shape.
351        let stripped = path.strip_prefix("/mnt").unwrap_or(path);
352        let bytes = stripped.as_bytes();
353        let is_drive_path = bytes.len() >= 2
354            && bytes[0] == b'/'
355            && (bytes[1] as char).is_ascii_alphabetic()
356            && (bytes.len() == 2 || bytes[2] == b'/');
357        if is_drive_path {
358            let drive = (bytes[1] as char).to_ascii_uppercase();
359            let rest = stripped[2..].replace('/', "\\"); // "" or "\Users\x"
360            return format!("{drive}:{}", if rest.is_empty() { "\\" } else { &rest });
361        }
362    }
363    let _ = path; // silence unused on non-windows
364    path.to_string()
365}
366
367/// Normalize a user/agent-supplied working directory for use with
368/// `Command::current_dir`: first convert MSYS/Git-Bash POSIX drive paths to
369/// native Windows form (see [`msys_to_windows_path`]), then expand a leading
370/// `~`. Returns `None` for an empty/whitespace input.
371pub fn normalize_working_dir(raw: &str) -> Option<String> {
372    let raw = raw.trim();
373    if raw.is_empty() {
374        return None;
375    }
376    let win = msys_to_windows_path(raw);
377    Some(expand_home_dir_safe(&win).to_string_lossy().into_owned())
378}
379
380/// After a lexical join, verify that no symlinked ancestor of the
381/// candidate path escapes `base_canonical`. The lexical helper alone
382/// can't catch this: if `<base>/.claude` is a symlink to `/tmp/outside`
383/// and the caller writes `<base>/.claude/commands/startup.md`, the
384/// final write follows the symlink and lands outside the working dir.
385///
386/// Walks from `final_path` upward and canonicalizes the deepest
387/// existing ancestor — that resolution dereferences any symlink in
388/// the chain. If the resolved path stays under `base_canonical`, no
389/// symlink in the existing portion escapes. Non-existent components
390/// are safe by definition (nothing to resolve, nothing to follow).
391///
392/// Returns Ok(()) if safe, Err with a human-readable message otherwise.
393///
394/// Caller invariant: the directory rooted at `base_canonical` must
395/// exist (so the upward walk is guaranteed to find it before reaching
396/// the filesystem root). `writeagentconfig` satisfies this — the
397/// working dir is created via `allocate_agent_workdir` or `mkdir_p`
398/// immediately before this is called.
399pub fn verify_no_symlink_escape(
400    final_path: &Path,
401    base_canonical: &Path,
402) -> Result<(), String> {
403    let mut p = final_path;
404    loop {
405        match p.canonicalize() {
406            Ok(canonical) => {
407                if !canonical.starts_with(base_canonical) {
408                    return Err(format!(
409                        "symlinked ancestor escapes working dir: {} → {}",
410                        p.display(),
411                        canonical.display()
412                    ));
413                }
414                return Ok(());
415            }
416            Err(_) => match p.parent() {
417                Some(parent) if !parent.as_os_str().is_empty() => p = parent,
418                _ => {
419                    // Walked past every existing ancestor without finding
420                    // one inside `base_canonical`. This violates the
421                    // caller invariant (base should exist) — fail closed.
422                    return Err(format!(
423                        "no existing ancestor found under base: {}",
424                        final_path.display()
425                    ));
426                }
427            },
428        }
429    }
430}
431
432/// True if `s` starts with `<ASCII letter>:` — a Windows drive-letter
433/// prefix. Both rooted (`C:\foo`) and drive-relative (`C:foo`) forms
434/// match. Used by `safe_join_within_base` to reject paths that would
435/// rebase off the working directory under Windows path semantics.
436fn has_drive_letter_prefix(s: &str) -> bool {
437    let mut chars = s.chars();
438    matches!(
439        (chars.next(), chars.next()),
440        (Some(c), Some(':')) if c.is_ascii_alphabetic()
441    )
442}
443
444/// Lexically join `relative` onto `base` while guaranteeing the result
445/// stays inside `base`. No filesystem access — does not require either
446/// path to exist, which matters on Windows where `Path::canonicalize`
447/// adds the `\\?\` UNC prefix and breaks naive `starts_with` checks
448/// against not-yet-created files.
449///
450/// The relative path:
451/// - must be non-empty
452/// - must NOT be absolute (rooted, drive-prefixed, or starting with `/`/`\`)
453/// - must NOT contain a `..` component
454/// - may contain `.` components (silently dropped)
455/// - is treated as forward- or back-slash separated; both are accepted
456///
457/// Returns `base.join(<cleaned>)` on success.
458pub fn safe_join_within_base(base: &Path, relative: &str) -> Result<PathBuf, String> {
459    if relative.is_empty() {
460        return Err("safe_join_within_base: empty relative path".into());
461    }
462    let rel_path = Path::new(relative);
463    if rel_path.is_absolute() {
464        return Err(format!("safe_join_within_base: absolute path not allowed: {relative}"));
465    }
466    // On Windows `Path::is_absolute` covers drive-letter and UNC roots,
467    // but a leading `/` or `\` (without drive) is technically "rooted but
468    // not absolute". Reject those too — they'd resolve onto the current
469    // drive's root, escaping `base`.
470    if matches!(relative.chars().next(), Some('/') | Some('\\')) {
471        return Err(format!("safe_join_within_base: rooted path not allowed: {relative}"));
472    }
473    // Windows drive-letter prefix (e.g. `C:foo`, `D:\bar`). `is_absolute()`
474    // requires both prefix AND root, so a drive-relative path like
475    // `C:payload.txt` slips through unless we reject it explicitly.
476    // `Path::join` would otherwise replace the base with the drive's CWD
477    // on Windows, escaping the working directory.
478    if has_drive_letter_prefix(relative) {
479        return Err(format!(
480            "safe_join_within_base: drive-letter prefix not allowed: {relative}"
481        ));
482    }
483    let mut cleaned = PathBuf::new();
484    for segment in relative.split(['/', '\\']) {
485        match segment {
486            "" | "." => continue,
487            ".." => {
488                return Err(format!(
489                    "safe_join_within_base: '..' traversal not allowed: {relative}"
490                ));
491            }
492            // A drive-letter prefix on any segment (not just the first
493            // one) is rejected — `PathBuf::push("C:foo")` on Windows
494            // discards the accumulated path and rebases on the C drive's
495            // CWD, so a nested input like `safe/C:payload.txt` would
496            // escape `base` despite the upfront whole-string guard.
497            other if has_drive_letter_prefix(other) => {
498                return Err(format!(
499                    "safe_join_within_base: drive-letter prefix in segment {other:?}: {relative}"
500                ));
501            }
502            other => cleaned.push(other),
503        }
504    }
505    if cleaned.as_os_str().is_empty() {
506        return Err(format!(
507            "safe_join_within_base: relative path resolves to empty: {relative}"
508        ));
509    }
510    Ok(base.join(cleaned))
511}
512
513/// Replace the home directory prefix with `~`.
514pub fn replace_home_dir(path: &str) -> String {
515    let home = get_home_dir();
516    let home_str = home.to_string_lossy();
517    if path.starts_with(home_str.as_ref()) {
518        format!("~{}", &path[home_str.len()..])
519    } else {
520        path.to_string()
521    }
522}
523
524// ---- Platform detection ----
525
526/// Get the client architecture string ("os/arch").
527pub fn client_arch() -> String {
528    format!("{}/{}", std::env::consts::OS, std::env::consts::ARCH)
529}
530
531/// Get a system summary string.
532pub fn get_system_summary() -> String {
533    format!(
534        "{} {} ({})",
535        std::env::consts::OS,
536        std::env::consts::ARCH,
537        whoami::distro()
538    )
539}
540
541/// Determine the system language.
542pub fn determine_lang() -> String {
543    env::var("LANG")
544        .or_else(|_| env::var("LC_ALL"))
545        .or_else(|_| env::var("LANGUAGE"))
546        .unwrap_or_else(|_| "en_US".to_string())
547}
548
549// ---- Tests ----
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    #[test]
556    fn test_env_var_constants() {
557        assert_eq!(MUX_CONFIG_HOME_ENV, "AGENTMUX_CONFIG_HOME");
558        assert_eq!(MUX_DATA_HOME_ENV, "AGENTMUX_DATA_HOME");
559        assert_eq!(MUX_DEV_ENV, "AGENTMUX_DEV");
560    }
561
562    #[test]
563    fn test_file_constants() {
564        assert_eq!(MUX_LOCK_FILE, "wave.lock");
565        assert_eq!(DOMAIN_SOCKET_BASE_NAME, "wave.sock");
566        assert_eq!(MUX_DB_DIR, "db");
567        assert_eq!(CONFIG_DIR, "config");
568    }
569
570    #[test]
571    fn test_version_management() {
572        // Version defaults to "0.0.0" before being set
573        // Can't test set_version since OnceLock can only be set once per process
574        assert!(!get_version().is_empty());
575    }
576
577    #[test]
578    fn test_mux_data_dir_default() {
579        // `agentmux_root()` returns AGENTMUX_HOME_OVERRIDE verbatim, and other
580        // test modules (m0020/m0021 backfills, agent_resolve, registry paths)
581        // set it to a tempdir whose path contains neither ".agentmux" nor
582        // "AGENTMUX". Without this lock the assertion below depends on whether
583        // one of them happens to be mid-test — latent since those tests were
584        // written, and reliably hit once another env-setting module was added.
585        // Same crate-wide guard they take; a local mutex would not help.
586        let _guard = crate::test_support::ISOLATED_AUTH_ENV_LOCK
587            .lock()
588            .unwrap_or_else(|e| e.into_inner());
589        // When env var is not set, should be ~/.agentmux
590        let dir = get_mux_data_dir();
591        assert!(dir.to_string_lossy().contains(".agentmux") || dir.to_string_lossy().contains("AGENTMUX"));
592    }
593
594    #[test]
595    fn test_mux_db_dir() {
596        let db_dir = get_mux_db_dir();
597        assert!(db_dir.to_string_lossy().ends_with("db"));
598    }
599
600    #[test]
601    fn test_mux_config_dir() {
602        let config_dir = get_mux_config_dir();
603        assert!(config_dir.to_string_lossy().contains("config") || config_dir.to_string_lossy().contains("AGENTMUX"));
604    }
605
606    #[test]
607    fn test_domain_socket_name() {
608        let sock = get_domain_socket_name();
609        assert!(sock.to_string_lossy().ends_with("wave.sock"));
610    }
611
612    #[test]
613    fn test_lock_file_path() {
614        let lock = get_mux_lock_file();
615        assert!(lock.to_string_lossy().ends_with("wave.lock"));
616    }
617
618    #[test]
619    fn test_expand_home_dir_tilde() {
620        let path = expand_home_dir("~/docs").unwrap();
621        assert!(path.to_string_lossy().contains("docs"));
622        assert!(!path.to_string_lossy().starts_with('~'));
623    }
624
625    #[test]
626    fn test_expand_home_dir_tilde_only() {
627        let path = expand_home_dir("~").unwrap();
628        assert!(!path.to_string_lossy().starts_with('~'));
629    }
630
631    #[test]
632    fn test_expand_home_dir_no_tilde() {
633        let path = expand_home_dir("/etc/hosts").unwrap();
634        assert_eq!(path, PathBuf::from("/etc/hosts"));
635    }
636
637    #[test]
638    fn test_expand_home_dir_traversal() {
639        // ~user should fail (path traversal)
640        let result = expand_home_dir("~otheruser/docs");
641        assert!(result.is_err());
642    }
643
644    #[test]
645    fn test_expand_home_dir_dotdot_traversal() {
646        // .. components should fail
647        assert!(expand_home_dir("~/../../etc/passwd").is_err());
648        assert!(expand_home_dir("../../../root").is_err());
649        assert!(expand_home_dir("/tmp/../etc/shadow").is_err());
650        // But normal dots are fine
651        assert!(expand_home_dir("~/.config").is_ok());
652        assert!(expand_home_dir("/tmp/.hidden").is_ok());
653    }
654
655    #[test]
656    fn test_safe_join_within_base_simple() {
657        let base = PathBuf::from("/home/user/agent");
658        let p = safe_join_within_base(&base, "CLAUDE.md").unwrap();
659        assert_eq!(p, PathBuf::from("/home/user/agent/CLAUDE.md"));
660    }
661
662    #[test]
663    fn test_safe_join_within_base_nested() {
664        let base = PathBuf::from("/home/user/agent");
665        let p = safe_join_within_base(&base, ".claude/commands/startup.md").unwrap();
666        assert_eq!(p, PathBuf::from("/home/user/agent/.claude/commands/startup.md"));
667    }
668
669    #[test]
670    fn test_safe_join_within_base_backslash_separator() {
671        // Frontends running on Windows may send `\\` separators; accept them.
672        let base = PathBuf::from("C:\\agent");
673        let p = safe_join_within_base(&base, ".claude\\commands\\startup.md").unwrap();
674        // The cleaned components are joined as native segments — final path
675        // should contain all three trailing pieces in order.
676        let s = p.to_string_lossy();
677        assert!(s.contains(".claude"));
678        assert!(s.contains("commands"));
679        assert!(s.contains("startup.md"));
680    }
681
682    #[test]
683    fn test_safe_join_within_base_dot_segments_skipped() {
684        let base = PathBuf::from("/base");
685        let p = safe_join_within_base(&base, "./a/./b").unwrap();
686        assert_eq!(p, PathBuf::from("/base/a/b"));
687    }
688
689    #[test]
690    fn test_safe_join_within_base_rejects_dotdot() {
691        let base = PathBuf::from("/base");
692        assert!(safe_join_within_base(&base, "..").is_err());
693        assert!(safe_join_within_base(&base, "../etc").is_err());
694        assert!(safe_join_within_base(&base, "a/../b").is_err());
695        assert!(safe_join_within_base(&base, "a/b/..").is_err());
696    }
697
698    #[test]
699    fn test_safe_join_within_base_rejects_absolute() {
700        let base = PathBuf::from("/base");
701        assert!(safe_join_within_base(&base, "/etc/passwd").is_err());
702        assert!(safe_join_within_base(&base, "\\Windows\\System32").is_err());
703        // Bare leading slash on a relative-looking path is also rooted.
704        assert!(safe_join_within_base(&base, "/foo").is_err());
705        #[cfg(windows)]
706        assert!(safe_join_within_base(&base, "C:\\Windows").is_err());
707    }
708
709    #[test]
710    fn test_safe_join_within_base_rejects_drive_letter_prefix() {
711        // Drive-relative (no root) — Path::is_absolute() returns false on
712        // Windows, but Path::join would still replace base with the
713        // drive's CWD. Must be rejected on every platform so the helper
714        // behaves consistently regardless of where the validation runs.
715        let base = PathBuf::from("/base");
716        assert!(safe_join_within_base(&base, "C:foo").is_err());
717        assert!(safe_join_within_base(&base, "D:payload.txt").is_err());
718        assert!(safe_join_within_base(&base, "z:relative\\file").is_err());
719        // Rooted drive paths also rejected (already covered by is_absolute
720        // on Windows; on Unix the drive-letter check catches them).
721        assert!(safe_join_within_base(&base, "C:\\Windows\\System32").is_err());
722    }
723
724    #[test]
725    fn test_safe_join_within_base_rejects_drive_letter_in_inner_segment() {
726        // The whole-string drive-letter guard misses nested cases like
727        // `safe/C:payload.txt` because the prefix isn't at position 0.
728        // `PathBuf::push` on Windows would still rebase on a drive when
729        // it sees `C:payload.txt`, so the per-segment guard inside the
730        // loop must also reject these.
731        let base = PathBuf::from("/base");
732        assert!(safe_join_within_base(&base, "safe/C:payload.txt").is_err());
733        assert!(safe_join_within_base(&base, "a/b/D:malicious").is_err());
734        assert!(safe_join_within_base(&base, "subdir\\E:nested").is_err());
735        // First segment is also covered by the inner check (defense in
736        // depth — both upfront + per-segment guards reject it).
737        assert!(safe_join_within_base(&base, "C:foo/bar").is_err());
738    }
739
740    #[test]
741    fn test_verify_no_symlink_escape_existing_inside_base() {
742        // If the existing ancestor is the base itself, canonicalize
743        // succeeds and starts_with passes. Use the OS temp dir as a
744        // base that's guaranteed to exist on every CI/dev box.
745        let base = std::env::temp_dir();
746        let canonical_base = base.canonicalize().unwrap();
747        let target = base.join("nonexistent-subdir").join("file.md");
748        assert!(verify_no_symlink_escape(&target, &canonical_base).is_ok());
749    }
750
751    #[test]
752    #[cfg(unix)]
753    fn test_verify_no_symlink_escape_rejects_symlinked_ancestor() {
754        // Build a temp base, create a symlink under it pointing OUTSIDE
755        // the base, and verify the helper rejects writes through it.
756        // Unix-only because std::os::unix::fs::symlink is the simplest
757        // way to set this up; the cross-platform behavior is identical.
758        use std::os::unix::fs::symlink;
759        let tmp = std::env::temp_dir();
760        let base = tmp.join("agentmux-symlink-test-base");
761        let outside = tmp.join("agentmux-symlink-test-outside");
762        let _ = std::fs::remove_dir_all(&base);
763        let _ = std::fs::remove_dir_all(&outside);
764        std::fs::create_dir_all(&base).unwrap();
765        std::fs::create_dir_all(&outside).unwrap();
766        let canonical_base = base.canonicalize().unwrap();
767        symlink(&outside, base.join(".claude")).unwrap();
768        let target = base.join(".claude").join("commands").join("startup.md");
769        assert!(verify_no_symlink_escape(&target, &canonical_base).is_err());
770        // Cleanup
771        let _ = std::fs::remove_dir_all(&base);
772        let _ = std::fs::remove_dir_all(&outside);
773    }
774
775    #[test]
776    fn test_has_drive_letter_prefix() {
777        assert!(has_drive_letter_prefix("C:foo"));
778        assert!(has_drive_letter_prefix("c:bar"));
779        assert!(has_drive_letter_prefix("Z:\\baz"));
780        assert!(!has_drive_letter_prefix(":foo"));
781        assert!(!has_drive_letter_prefix("foo"));
782        assert!(!has_drive_letter_prefix("12:not-a-drive"));
783        assert!(!has_drive_letter_prefix(""));
784        // Multibyte first char: the check requires ASCII letter, so
785        // non-ASCII letters do NOT match (they wouldn't be valid drive
786        // letters on Windows anyway).
787        assert!(!has_drive_letter_prefix("Ω:foo"));
788    }
789
790    #[test]
791    #[cfg(windows)]
792    fn test_msys_to_windows_path() {
793        assert_eq!(msys_to_windows_path("/c/Users/user/p"), "C:\\Users\\user\\p");
794        assert_eq!(msys_to_windows_path("/mnt/c/Users/user/p"), "C:\\Users\\user\\p");
795        assert_eq!(msys_to_windows_path("/d/work"), "D:\\work");
796        assert_eq!(msys_to_windows_path("/c"), "C:\\");
797        // Already-native paths pass through untouched.
798        assert_eq!(msys_to_windows_path("C:\\Users\\user"), "C:\\Users\\user");
799        // Not a drive path: leading `/usr` is not `/<letter>/`.
800        assert_eq!(msys_to_windows_path("/usr/bin"), "/usr/bin");
801        // `/ab/...` — second segment is multi-char, not a drive.
802        assert_eq!(msys_to_windows_path("/ab/c"), "/ab/c");
803    }
804
805    #[test]
806    fn test_normalize_working_dir_empty() {
807        assert_eq!(normalize_working_dir(""), None);
808        assert_eq!(normalize_working_dir("   "), None);
809    }
810
811    #[test]
812    fn test_safe_join_within_base_rejects_empty() {
813        let base = PathBuf::from("/base");
814        assert!(safe_join_within_base(&base, "").is_err());
815        // After stripping `.` segments, an effectively-empty path also fails.
816        assert!(safe_join_within_base(&base, "./.").is_err());
817    }
818
819    #[test]
820    fn test_expand_home_dir_safe() {
821        let path = expand_home_dir_safe("~/test");
822        assert!(!path.to_string_lossy().starts_with('~'));
823
824        // On error, returns original
825        let path = expand_home_dir_safe("~otheruser/test");
826        assert_eq!(path, PathBuf::from("~otheruser/test"));
827    }
828
829    #[test]
830    fn test_replace_home_dir() {
831        let home = get_home_dir();
832        let path = format!("{}/documents/file.txt", home.display());
833        let replaced = replace_home_dir(&path);
834        assert!(replaced.starts_with('~'));
835        assert!(replaced.contains("documents/file.txt"));
836    }
837
838    #[test]
839    fn test_replace_home_dir_no_match() {
840        let result = replace_home_dir("/etc/hosts");
841        assert_eq!(result, "/etc/hosts");
842    }
843
844    #[test]
845    fn test_client_arch() {
846        let arch = client_arch();
847        assert!(arch.contains('/'));
848    }
849
850    #[test]
851    fn test_system_summary() {
852        let summary = get_system_summary();
853        assert!(!summary.is_empty());
854    }
855
856    #[test]
857    fn test_determine_lang() {
858        let lang = determine_lang();
859        assert!(!lang.is_empty());
860    }
861
862    #[test]
863    fn test_is_dev_mode() {
864        // Default should be false in test environment (unless set)
865        let _ = is_dev_mode(); // Just verify it doesn't panic
866    }
867
868    #[test]
869    fn test_ensure_dir_existing() {
870        // /tmp should already exist
871        assert!(ensure_dir(Path::new("/tmp")).is_ok());
872    }
873}