1#![allow(dead_code)]
2use std::env;
10use std::fs;
11use std::path::{Path, PathBuf};
12use std::sync::OnceLock;
13
14pub 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
24pub 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
34static WAVE_VERSION: OnceLock<String> = OnceLock::new();
37static BUILD_TIME: OnceLock<String> = OnceLock::new();
38
39pub fn set_version(version: &str) {
41 let _ = WAVE_VERSION.set(version.to_string());
42}
43
44pub fn set_build_time(time: &str) {
46 let _ = BUILD_TIME.set(time.to_string());
47}
48
49pub fn get_version() -> &'static str {
51 WAVE_VERSION.get().map_or("0.0.0", |v| v.as_str())
52}
53
54pub fn get_build_time() -> &'static str {
56 BUILD_TIME.get().map_or("0", |v| v.as_str())
57}
58
59pub fn get_home_dir() -> PathBuf {
63 dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
64}
65
66pub 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
77pub fn migrate_legacy_data_dir() {
80 let new_dir = get_wave_data_dir();
81 if new_dir.exists() {
82 return; }
84 let old_dir = get_home_dir().join(".waveterm");
85 if !old_dir.exists() {
86 return; }
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
100fn 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
120pub 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
131pub fn get_wave_db_dir() -> PathBuf {
133 get_wave_data_dir().join(WAVE_DB_DIR)
134}
135
136pub fn get_wave_app_path() -> Option<PathBuf> {
138 env::var(WAVE_APP_PATH_ENV).ok().map(PathBuf::from)
139}
140
141pub fn get_wave_app_bin_path() -> Option<PathBuf> {
143 get_wave_app_path().map(|p| p.join("bin"))
144}
145
146pub fn get_domain_socket_name() -> PathBuf {
148 get_wave_data_dir().join(DOMAIN_SOCKET_BASE_NAME)
149}
150
151pub fn get_wave_lock_file() -> PathBuf {
153 get_wave_data_dir().join(WAVE_LOCK_FILE)
154}
155
156pub 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
166pub fn ensure_wave_data_dir() -> Result<(), String> {
168 ensure_dir(&get_wave_data_dir())
169}
170
171pub fn ensure_wave_db_dir() -> Result<(), String> {
173 ensure_dir(&get_wave_db_dir())
174}
175
176pub fn ensure_wave_config_dir() -> Result<(), String> {
178 ensure_dir(&get_wave_config_dir())
179}
180
181pub fn ensure_wave_presets_dir() -> Result<(), String> {
183 ensure_dir(&get_wave_config_dir().join("presets"))
184}
185
186pub struct WaveLock {
190 #[allow(dead_code)]
191 file: fs::File,
192}
193
194impl WaveLock {
195 #[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 #[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
237pub fn is_dev_mode() -> bool {
241 env::var(WAVE_DEV_ENV)
242 .map(|v| !v.is_empty())
243 .unwrap_or(false)
244}
245
246pub fn expand_home_dir(path: &str) -> Result<PathBuf, String> {
249 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
266pub fn expand_home_dir_safe(path: &str) -> PathBuf {
268 expand_home_dir(path).unwrap_or_else(|_| PathBuf::from(path))
269}
270
271pub fn msys_to_windows_path(path: &str) -> String {
287 #[cfg(windows)]
288 {
289 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('/', "\\"); return format!("{drive}:{}", if rest.is_empty() { "\\" } else { &rest });
301 }
302 }
303 let _ = path; path.to_string()
305}
306
307pub 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
320pub 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 return Err(format!(
363 "no existing ancestor found under base: {}",
364 final_path.display()
365 ));
366 }
367 },
368 }
369 }
370}
371
372fn 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
384pub 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 if matches!(relative.chars().next(), Some('/') | Some('\\')) {
411 return Err(format!("safe_join_within_base: rooted path not allowed: {relative}"));
412 }
413 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 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
453pub 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
464pub fn client_arch() -> String {
468 format!("{}/{}", std::env::consts::OS, std::env::consts::ARCH)
469}
470
471pub fn get_system_summary() -> String {
473 format!(
474 "{} {} ({})",
475 std::env::consts::OS,
476 std::env::consts::ARCH,
477 whoami::distro()
478 )
479}
480
481pub 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#[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 assert!(!get_version().is_empty());
515 }
516
517 #[test]
518 fn test_wave_data_dir_default() {
519 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 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 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 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 let base = PathBuf::from("C:\\agent");
603 let p = safe_join_within_base(&base, ".claude\\commands\\startup.md").unwrap();
604 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 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 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 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 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 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 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 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 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 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 assert_eq!(msys_to_windows_path("C:\\Users\\asafe"), "C:\\Users\\asafe");
729 assert_eq!(msys_to_windows_path("/usr/bin"), "/usr/bin");
731 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 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 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 let _ = is_dev_mode(); }
797
798 #[test]
799 fn test_ensure_dir_existing() {
800 assert!(ensure_dir(Path::new("/tmp")).is_ok());
802 }
803}