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 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 return Err(format!(
314 "no existing ancestor found under base: {}",
315 final_path.display()
316 ));
317 }
318 },
319 }
320 }
321}
322
323fn 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
335pub 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 if matches!(relative.chars().next(), Some('/') | Some('\\')) {
362 return Err(format!("safe_join_within_base: rooted path not allowed: {relative}"));
363 }
364 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 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
404pub 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
415pub fn client_arch() -> String {
419 format!("{}/{}", std::env::consts::OS, std::env::consts::ARCH)
420}
421
422pub fn get_system_summary() -> String {
424 format!(
425 "{} {} ({})",
426 std::env::consts::OS,
427 std::env::consts::ARCH,
428 whoami::distro()
429 )
430}
431
432pub 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#[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 assert!(!get_version().is_empty());
466 }
467
468 #[test]
469 fn test_wave_data_dir_default() {
470 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 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 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 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 let base = PathBuf::from("C:\\agent");
554 let p = safe_join_within_base(&base, ".claude\\commands\\startup.md").unwrap();
555 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 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 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 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 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 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 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 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 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 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 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 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 let _ = is_dev_mode(); }
727
728 #[test]
729 fn test_ensure_dir_existing() {
730 assert!(ensure_dir(Path::new("/tmp")).is_ok());
732 }
733}