agentmux_srv\backend/
base.rs

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