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/// After a lexical join, verify that no symlinked ancestor of the
272/// candidate path escapes `base_canonical`. The lexical helper alone
273/// can't catch this: if `<base>/.claude` is a symlink to `/tmp/outside`
274/// and the caller writes `<base>/.claude/commands/startup.md`, the
275/// final write follows the symlink and lands outside the working dir.
276///
277/// Walks from `final_path` upward and canonicalizes the deepest
278/// existing ancestor — that resolution dereferences any symlink in
279/// the chain. If the resolved path stays under `base_canonical`, no
280/// symlink in the existing portion escapes. Non-existent components
281/// are safe by definition (nothing to resolve, nothing to follow).
282///
283/// Returns Ok(()) if safe, Err with a human-readable message otherwise.
284///
285/// Caller invariant: the directory rooted at `base_canonical` must
286/// exist (so the upward walk is guaranteed to find it before reaching
287/// the filesystem root). `writeagentconfig` satisfies this — the
288/// working dir is created via `allocate_agent_workdir` or `mkdir_p`
289/// immediately before this is called.
290pub fn verify_no_symlink_escape(
291    final_path: &Path,
292    base_canonical: &Path,
293) -> Result<(), String> {
294    let mut p = final_path;
295    loop {
296        match p.canonicalize() {
297            Ok(canonical) => {
298                if !canonical.starts_with(base_canonical) {
299                    return Err(format!(
300                        "symlinked ancestor escapes working dir: {} → {}",
301                        p.display(),
302                        canonical.display()
303                    ));
304                }
305                return Ok(());
306            }
307            Err(_) => match p.parent() {
308                Some(parent) if !parent.as_os_str().is_empty() => p = parent,
309                _ => {
310                    // Walked past every existing ancestor without finding
311                    // one inside `base_canonical`. This violates the
312                    // caller invariant (base should exist) — fail closed.
313                    return Err(format!(
314                        "no existing ancestor found under base: {}",
315                        final_path.display()
316                    ));
317                }
318            },
319        }
320    }
321}
322
323/// True if `s` starts with `<ASCII letter>:` — a Windows drive-letter
324/// prefix. Both rooted (`C:\foo`) and drive-relative (`C:foo`) forms
325/// match. Used by `safe_join_within_base` to reject paths that would
326/// rebase off the working directory under Windows path semantics.
327fn has_drive_letter_prefix(s: &str) -> bool {
328    let mut chars = s.chars();
329    matches!(
330        (chars.next(), chars.next()),
331        (Some(c), Some(':')) if c.is_ascii_alphabetic()
332    )
333}
334
335/// Lexically join `relative` onto `base` while guaranteeing the result
336/// stays inside `base`. No filesystem access — does not require either
337/// path to exist, which matters on Windows where `Path::canonicalize`
338/// adds the `\\?\` UNC prefix and breaks naive `starts_with` checks
339/// against not-yet-created files.
340///
341/// The relative path:
342/// - must be non-empty
343/// - must NOT be absolute (rooted, drive-prefixed, or starting with `/`/`\`)
344/// - must NOT contain a `..` component
345/// - may contain `.` components (silently dropped)
346/// - is treated as forward- or back-slash separated; both are accepted
347///
348/// Returns `base.join(<cleaned>)` on success.
349pub fn safe_join_within_base(base: &Path, relative: &str) -> Result<PathBuf, String> {
350    if relative.is_empty() {
351        return Err("safe_join_within_base: empty relative path".into());
352    }
353    let rel_path = Path::new(relative);
354    if rel_path.is_absolute() {
355        return Err(format!("safe_join_within_base: absolute path not allowed: {relative}"));
356    }
357    // On Windows `Path::is_absolute` covers drive-letter and UNC roots,
358    // but a leading `/` or `\` (without drive) is technically "rooted but
359    // not absolute". Reject those too — they'd resolve onto the current
360    // drive's root, escaping `base`.
361    if matches!(relative.chars().next(), Some('/') | Some('\\')) {
362        return Err(format!("safe_join_within_base: rooted path not allowed: {relative}"));
363    }
364    // Windows drive-letter prefix (e.g. `C:foo`, `D:\bar`). `is_absolute()`
365    // requires both prefix AND root, so a drive-relative path like
366    // `C:payload.txt` slips through unless we reject it explicitly.
367    // `Path::join` would otherwise replace the base with the drive's CWD
368    // on Windows, escaping the working directory.
369    if has_drive_letter_prefix(relative) {
370        return Err(format!(
371            "safe_join_within_base: drive-letter prefix not allowed: {relative}"
372        ));
373    }
374    let mut cleaned = PathBuf::new();
375    for segment in relative.split(['/', '\\']) {
376        match segment {
377            "" | "." => continue,
378            ".." => {
379                return Err(format!(
380                    "safe_join_within_base: '..' traversal not allowed: {relative}"
381                ));
382            }
383            // A drive-letter prefix on any segment (not just the first
384            // one) is rejected — `PathBuf::push("C:foo")` on Windows
385            // discards the accumulated path and rebases on the C drive's
386            // CWD, so a nested input like `safe/C:payload.txt` would
387            // escape `base` despite the upfront whole-string guard.
388            other if has_drive_letter_prefix(other) => {
389                return Err(format!(
390                    "safe_join_within_base: drive-letter prefix in segment {other:?}: {relative}"
391                ));
392            }
393            other => cleaned.push(other),
394        }
395    }
396    if cleaned.as_os_str().is_empty() {
397        return Err(format!(
398            "safe_join_within_base: relative path resolves to empty: {relative}"
399        ));
400    }
401    Ok(base.join(cleaned))
402}
403
404/// Replace the home directory prefix with `~`.
405pub fn replace_home_dir(path: &str) -> String {
406    let home = get_home_dir();
407    let home_str = home.to_string_lossy();
408    if path.starts_with(home_str.as_ref()) {
409        format!("~{}", &path[home_str.len()..])
410    } else {
411        path.to_string()
412    }
413}
414
415// ---- Platform detection ----
416
417/// Get the client architecture string ("os/arch").
418pub fn client_arch() -> String {
419    format!("{}/{}", std::env::consts::OS, std::env::consts::ARCH)
420}
421
422/// Get a system summary string.
423pub fn get_system_summary() -> String {
424    format!(
425        "{} {} ({})",
426        std::env::consts::OS,
427        std::env::consts::ARCH,
428        whoami::distro()
429    )
430}
431
432/// Determine the system language.
433pub fn determine_lang() -> String {
434    env::var("LANG")
435        .or_else(|_| env::var("LC_ALL"))
436        .or_else(|_| env::var("LANGUAGE"))
437        .unwrap_or_else(|_| "en_US".to_string())
438}
439
440// ---- Tests ----
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    #[test]
447    fn test_env_var_constants() {
448        assert_eq!(WAVE_CONFIG_HOME_ENV, "AGENTMUX_CONFIG_HOME");
449        assert_eq!(WAVE_DATA_HOME_ENV, "AGENTMUX_DATA_HOME");
450        assert_eq!(WAVE_DEV_ENV, "AGENTMUX_DEV");
451    }
452
453    #[test]
454    fn test_file_constants() {
455        assert_eq!(WAVE_LOCK_FILE, "wave.lock");
456        assert_eq!(DOMAIN_SOCKET_BASE_NAME, "wave.sock");
457        assert_eq!(WAVE_DB_DIR, "db");
458        assert_eq!(CONFIG_DIR, "config");
459    }
460
461    #[test]
462    fn test_version_management() {
463        // Version defaults to "0.0.0" before being set
464        // Can't test set_version since OnceLock can only be set once per process
465        assert!(!get_version().is_empty());
466    }
467
468    #[test]
469    fn test_wave_data_dir_default() {
470        // When env var is not set, should be ~/.agentmux
471        let dir = get_wave_data_dir();
472        assert!(dir.to_string_lossy().contains(".agentmux") || dir.to_string_lossy().contains("AGENTMUX"));
473    }
474
475    #[test]
476    fn test_wave_db_dir() {
477        let db_dir = get_wave_db_dir();
478        assert!(db_dir.to_string_lossy().ends_with("db"));
479    }
480
481    #[test]
482    fn test_wave_config_dir() {
483        let config_dir = get_wave_config_dir();
484        assert!(config_dir.to_string_lossy().contains("config") || config_dir.to_string_lossy().contains("AGENTMUX"));
485    }
486
487    #[test]
488    fn test_domain_socket_name() {
489        let sock = get_domain_socket_name();
490        assert!(sock.to_string_lossy().ends_with("wave.sock"));
491    }
492
493    #[test]
494    fn test_lock_file_path() {
495        let lock = get_wave_lock_file();
496        assert!(lock.to_string_lossy().ends_with("wave.lock"));
497    }
498
499    #[test]
500    fn test_expand_home_dir_tilde() {
501        let path = expand_home_dir("~/docs").unwrap();
502        assert!(path.to_string_lossy().contains("docs"));
503        assert!(!path.to_string_lossy().starts_with('~'));
504    }
505
506    #[test]
507    fn test_expand_home_dir_tilde_only() {
508        let path = expand_home_dir("~").unwrap();
509        assert!(!path.to_string_lossy().starts_with('~'));
510    }
511
512    #[test]
513    fn test_expand_home_dir_no_tilde() {
514        let path = expand_home_dir("/etc/hosts").unwrap();
515        assert_eq!(path, PathBuf::from("/etc/hosts"));
516    }
517
518    #[test]
519    fn test_expand_home_dir_traversal() {
520        // ~user should fail (path traversal)
521        let result = expand_home_dir("~otheruser/docs");
522        assert!(result.is_err());
523    }
524
525    #[test]
526    fn test_expand_home_dir_dotdot_traversal() {
527        // .. components should fail
528        assert!(expand_home_dir("~/../../etc/passwd").is_err());
529        assert!(expand_home_dir("../../../root").is_err());
530        assert!(expand_home_dir("/tmp/../etc/shadow").is_err());
531        // But normal dots are fine
532        assert!(expand_home_dir("~/.config").is_ok());
533        assert!(expand_home_dir("/tmp/.hidden").is_ok());
534    }
535
536    #[test]
537    fn test_safe_join_within_base_simple() {
538        let base = PathBuf::from("/home/user/agent");
539        let p = safe_join_within_base(&base, "CLAUDE.md").unwrap();
540        assert_eq!(p, PathBuf::from("/home/user/agent/CLAUDE.md"));
541    }
542
543    #[test]
544    fn test_safe_join_within_base_nested() {
545        let base = PathBuf::from("/home/user/agent");
546        let p = safe_join_within_base(&base, ".claude/commands/startup.md").unwrap();
547        assert_eq!(p, PathBuf::from("/home/user/agent/.claude/commands/startup.md"));
548    }
549
550    #[test]
551    fn test_safe_join_within_base_backslash_separator() {
552        // Frontends running on Windows may send `\\` separators; accept them.
553        let base = PathBuf::from("C:\\agent");
554        let p = safe_join_within_base(&base, ".claude\\commands\\startup.md").unwrap();
555        // The cleaned components are joined as native segments — final path
556        // should contain all three trailing pieces in order.
557        let s = p.to_string_lossy();
558        assert!(s.contains(".claude"));
559        assert!(s.contains("commands"));
560        assert!(s.contains("startup.md"));
561    }
562
563    #[test]
564    fn test_safe_join_within_base_dot_segments_skipped() {
565        let base = PathBuf::from("/base");
566        let p = safe_join_within_base(&base, "./a/./b").unwrap();
567        assert_eq!(p, PathBuf::from("/base/a/b"));
568    }
569
570    #[test]
571    fn test_safe_join_within_base_rejects_dotdot() {
572        let base = PathBuf::from("/base");
573        assert!(safe_join_within_base(&base, "..").is_err());
574        assert!(safe_join_within_base(&base, "../etc").is_err());
575        assert!(safe_join_within_base(&base, "a/../b").is_err());
576        assert!(safe_join_within_base(&base, "a/b/..").is_err());
577    }
578
579    #[test]
580    fn test_safe_join_within_base_rejects_absolute() {
581        let base = PathBuf::from("/base");
582        assert!(safe_join_within_base(&base, "/etc/passwd").is_err());
583        assert!(safe_join_within_base(&base, "\\Windows\\System32").is_err());
584        // Bare leading slash on a relative-looking path is also rooted.
585        assert!(safe_join_within_base(&base, "/foo").is_err());
586        #[cfg(windows)]
587        assert!(safe_join_within_base(&base, "C:\\Windows").is_err());
588    }
589
590    #[test]
591    fn test_safe_join_within_base_rejects_drive_letter_prefix() {
592        // Drive-relative (no root) — Path::is_absolute() returns false on
593        // Windows, but Path::join would still replace base with the
594        // drive's CWD. Must be rejected on every platform so the helper
595        // behaves consistently regardless of where the validation runs.
596        let base = PathBuf::from("/base");
597        assert!(safe_join_within_base(&base, "C:foo").is_err());
598        assert!(safe_join_within_base(&base, "D:payload.txt").is_err());
599        assert!(safe_join_within_base(&base, "z:relative\\file").is_err());
600        // Rooted drive paths also rejected (already covered by is_absolute
601        // on Windows; on Unix the drive-letter check catches them).
602        assert!(safe_join_within_base(&base, "C:\\Windows\\System32").is_err());
603    }
604
605    #[test]
606    fn test_safe_join_within_base_rejects_drive_letter_in_inner_segment() {
607        // The whole-string drive-letter guard misses nested cases like
608        // `safe/C:payload.txt` because the prefix isn't at position 0.
609        // `PathBuf::push` on Windows would still rebase on a drive when
610        // it sees `C:payload.txt`, so the per-segment guard inside the
611        // loop must also reject these.
612        let base = PathBuf::from("/base");
613        assert!(safe_join_within_base(&base, "safe/C:payload.txt").is_err());
614        assert!(safe_join_within_base(&base, "a/b/D:malicious").is_err());
615        assert!(safe_join_within_base(&base, "subdir\\E:nested").is_err());
616        // First segment is also covered by the inner check (defense in
617        // depth — both upfront + per-segment guards reject it).
618        assert!(safe_join_within_base(&base, "C:foo/bar").is_err());
619    }
620
621    #[test]
622    fn test_verify_no_symlink_escape_existing_inside_base() {
623        // If the existing ancestor is the base itself, canonicalize
624        // succeeds and starts_with passes. Use the OS temp dir as a
625        // base that's guaranteed to exist on every CI/dev box.
626        let base = std::env::temp_dir();
627        let canonical_base = base.canonicalize().unwrap();
628        let target = base.join("nonexistent-subdir").join("file.md");
629        assert!(verify_no_symlink_escape(&target, &canonical_base).is_ok());
630    }
631
632    #[test]
633    #[cfg(unix)]
634    fn test_verify_no_symlink_escape_rejects_symlinked_ancestor() {
635        // Build a temp base, create a symlink under it pointing OUTSIDE
636        // the base, and verify the helper rejects writes through it.
637        // Unix-only because std::os::unix::fs::symlink is the simplest
638        // way to set this up; the cross-platform behavior is identical.
639        use std::os::unix::fs::symlink;
640        let tmp = std::env::temp_dir();
641        let base = tmp.join("agentmux-symlink-test-base");
642        let outside = tmp.join("agentmux-symlink-test-outside");
643        let _ = std::fs::remove_dir_all(&base);
644        let _ = std::fs::remove_dir_all(&outside);
645        std::fs::create_dir_all(&base).unwrap();
646        std::fs::create_dir_all(&outside).unwrap();
647        let canonical_base = base.canonicalize().unwrap();
648        symlink(&outside, base.join(".claude")).unwrap();
649        let target = base.join(".claude").join("commands").join("startup.md");
650        assert!(verify_no_symlink_escape(&target, &canonical_base).is_err());
651        // Cleanup
652        let _ = std::fs::remove_dir_all(&base);
653        let _ = std::fs::remove_dir_all(&outside);
654    }
655
656    #[test]
657    fn test_has_drive_letter_prefix() {
658        assert!(has_drive_letter_prefix("C:foo"));
659        assert!(has_drive_letter_prefix("c:bar"));
660        assert!(has_drive_letter_prefix("Z:\\baz"));
661        assert!(!has_drive_letter_prefix(":foo"));
662        assert!(!has_drive_letter_prefix("foo"));
663        assert!(!has_drive_letter_prefix("12:not-a-drive"));
664        assert!(!has_drive_letter_prefix(""));
665        // Multibyte first char: the check requires ASCII letter, so
666        // non-ASCII letters do NOT match (they wouldn't be valid drive
667        // letters on Windows anyway).
668        assert!(!has_drive_letter_prefix("Ω:foo"));
669    }
670
671    #[test]
672    fn test_safe_join_within_base_rejects_empty() {
673        let base = PathBuf::from("/base");
674        assert!(safe_join_within_base(&base, "").is_err());
675        // After stripping `.` segments, an effectively-empty path also fails.
676        assert!(safe_join_within_base(&base, "./.").is_err());
677    }
678
679    #[test]
680    fn test_expand_home_dir_safe() {
681        let path = expand_home_dir_safe("~/test");
682        assert!(!path.to_string_lossy().starts_with('~'));
683
684        // On error, returns original
685        let path = expand_home_dir_safe("~otheruser/test");
686        assert_eq!(path, PathBuf::from("~otheruser/test"));
687    }
688
689    #[test]
690    fn test_replace_home_dir() {
691        let home = get_home_dir();
692        let path = format!("{}/documents/file.txt", home.display());
693        let replaced = replace_home_dir(&path);
694        assert!(replaced.starts_with('~'));
695        assert!(replaced.contains("documents/file.txt"));
696    }
697
698    #[test]
699    fn test_replace_home_dir_no_match() {
700        let result = replace_home_dir("/etc/hosts");
701        assert_eq!(result, "/etc/hosts");
702    }
703
704    #[test]
705    fn test_client_arch() {
706        let arch = client_arch();
707        assert!(arch.contains('/'));
708    }
709
710    #[test]
711    fn test_system_summary() {
712        let summary = get_system_summary();
713        assert!(!summary.is_empty());
714    }
715
716    #[test]
717    fn test_determine_lang() {
718        let lang = determine_lang();
719        assert!(!lang.is_empty());
720    }
721
722    #[test]
723    fn test_is_dev_mode() {
724        // Default should be false in test environment (unless set)
725        let _ = is_dev_mode(); // Just verify it doesn't panic
726    }
727
728    #[test]
729    fn test_ensure_dir_existing() {
730        // /tmp should already exist
731        assert!(ensure_dir(Path::new("/tmp")).is_ok());
732    }
733}