1use std::net::IpAddr;
2#[cfg(any(target_os = "macos", target_os = "linux"))]
3use std::process::Command;
4use std::str::FromStr;
5
6use anyhow::{
7 Result,
8 anyhow,
9};
10use tracing::{
11 debug,
12 error,
13 info,
14 warn,
15};
16
17#[cfg(any(target_os = "macos", target_os = "linux"))]
18pub fn execute_command(cmd: &str, args: &[&str]) -> anyhow::Result<()> {
19 let status = Command::new(cmd)
20 .args(args)
21 .status()
22 .map_err(|e| anyhow!("failed to spawn `{}`: {}", cmd, e))?;
23 if status.success() {
24 Ok(())
25 } else {
26 Err(anyhow!("`{}` exited with {}", cmd, status))
27 }
28}
29
30pub fn is_loopback_address(addr: &str) -> bool {
31 if let Ok(ip) = IpAddr::from_str(addr) {
32 return ip.is_loopback();
33 }
34 false
35}
36
37pub fn is_default_loopback_address(addr: &str) -> bool {
38 addr == "127.0.0.1"
39}
40
41pub fn is_custom_loopback_address(addr: &str) -> bool {
42 is_loopback_address(addr) && !is_default_loopback_address(addr)
43}
44
45pub async fn ensure_loopback_address(addr: &str) -> Result<()> {
46 if !is_loopback_address(addr) {
47 return Ok(());
48 }
49
50 if addr == "127.0.0.1" {
51 return Ok(());
52 }
53
54 debug!("Ensuring loopback address {} is configured", addr);
55
56 if is_address_accessible(addr).await {
57 debug!("Loopback address {} is already accessible", addr);
58 return Ok(());
59 }
60
61 info!("Configuring loopback address: {}", addr);
62
63 let helper_result = configure_loopback_with_helper(addr).await;
64
65 if helper_result.is_ok() {
66 debug!(
67 "Successfully configured loopback address via helper: {}",
68 addr
69 );
70
71 if is_address_accessible(addr).await {
72 debug!("Verified loopback address {} is now accessible", addr);
73 return Ok(());
74 } else {
75 warn!(
76 "Helper claimed success but address {} is still not accessible",
77 addr
78 );
79 }
80 } else if let Err(e) = helper_result {
81 warn!("Failed to configure loopback address with helper: {}", e);
82 }
83
84 debug!("Falling back to traditional methods for configuring loopback address");
85
86 #[cfg(target_os = "macos")]
87 {
88 info!("Using macOS-specific method for loopback configuration");
89 configure_loopback_macos(addr)?;
90 }
91
92 #[cfg(target_os = "linux")]
93 {
94 info!("Using Linux-specific method for loopback configuration");
95 configure_loopback_linux(addr)?;
96 }
97
98 #[cfg(target_os = "windows")]
99 {
100 info!("Using Windows-specific method for loopback configuration");
101 configure_loopback_windows(addr)?;
102 }
103
104 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows",)))]
105 {
106 return Err(anyhow!(
107 "Loopback address configuration not supported on this platform"
108 ));
109 }
110
111 if is_address_accessible(addr).await {
112 debug!("Successfully configured loopback address: {}", addr);
113 Ok(())
114 } else {
115 Err(anyhow!(
116 "Failed to configure loopback address {} after all attempts",
117 addr
118 ))
119 }
120}
121
122async fn configure_loopback_with_helper(addr: &str) -> Result<()> {
123 debug!("Checking if helper is available");
124
125 let app_id = "com.kftray.app".to_string();
126
127 let socket_path = kftray_helper::communication::get_default_socket_path()?;
128
129 if !kftray_helper::client::socket_comm::is_socket_available(&socket_path) {
130 return Err(anyhow!("Helper service is not available"));
131 }
132
133 debug!("Helper service is available, proceeding with configuration");
134
135 let command = kftray_helper::messages::RequestCommand::Network(
136 kftray_helper::messages::NetworkCommand::Add {
137 address: addr.to_string(),
138 },
139 );
140
141 match kftray_helper::client::socket_comm::send_request(&socket_path, &app_id, command) {
142 Ok(response) => match response.result {
143 kftray_helper::messages::RequestResult::Success => {
144 debug!(
145 "Successfully configured loopback address with helper: {}",
146 addr
147 );
148 Ok(())
149 }
150 kftray_helper::messages::RequestResult::Error(e) => {
151 error!("Helper failed to add loopback address: {}", e);
152 Err(anyhow!(
153 "Helper failed to configure loopback address: {}",
154 e
155 ))
156 }
157 other => Err(anyhow!(
158 "Unexpected helper response to loopback add: {:?}",
159 other
160 )),
161 },
162 Err(e) => {
163 error!("Helper failed to add loopback address: {}", e);
164 Err(anyhow!(
165 "Helper failed to configure loopback address: {}",
166 e
167 ))
168 }
169 }
170}
171
172async fn remove_loopback_with_helper(addr: &str) -> Result<()> {
173 debug!("Checking if helper is available for address removal");
174
175 let app_id = "com.kftray.app".to_string();
176
177 let socket_path = kftray_helper::communication::get_default_socket_path()?;
178
179 if !kftray_helper::client::socket_comm::is_socket_available(&socket_path) {
180 return Err(anyhow!("Helper service is not available"));
181 }
182
183 debug!("Helper service is available, proceeding with address removal");
184
185 let command = kftray_helper::messages::RequestCommand::Network(
186 kftray_helper::messages::NetworkCommand::Remove {
187 address: addr.to_string(),
188 },
189 );
190
191 match kftray_helper::client::socket_comm::send_request(&socket_path, &app_id, command) {
192 Ok(response) => match response.result {
193 kftray_helper::messages::RequestResult::Success => {
194 debug!(
195 "Successfully removed loopback address with helper: {}",
196 addr
197 );
198 Ok(())
199 }
200 kftray_helper::messages::RequestResult::Error(e) => {
201 error!("Helper failed to remove loopback address: {}", e);
202 Err(anyhow!("Helper failed to remove loopback address: {}", e))
203 }
204 other => Err(anyhow!(
205 "Unexpected helper response to loopback removal: {:?}",
206 other
207 )),
208 },
209 Err(e) => {
210 error!("Helper failed to remove loopback address: {}", e);
211 Err(anyhow!("Helper failed to remove loopback address: {}", e))
212 }
213 }
214}
215
216#[cfg(any(target_os = "macos", all(test, unix)))]
225fn helper_is_unavailable(error: &str) -> bool {
226 error.contains("Helper service is not available")
227 || error.contains("Connection refused")
228 || error.contains("No such file or directory")
229}
230
231#[derive(Debug, Clone, PartialEq, Eq)]
238pub enum LoopbackRelease {
239 Released,
241 AlreadyAbsent,
243 PrivilegeUnavailable(String),
247 Failed(String),
250}
251
252pub async fn remove_loopback_address(addr: &str) -> Result<LoopbackRelease> {
255 if !is_loopback_address(addr) {
256 return Ok(LoopbackRelease::AlreadyAbsent);
257 }
258
259 if is_default_loopback_address(addr) || addr == "::1" {
260 return Ok(LoopbackRelease::AlreadyAbsent);
261 }
262 #[cfg(target_os = "windows")]
263 {
264 if let Ok(IpAddr::V4(v4)) = addr.parse::<IpAddr>()
269 && v4.octets()[0] == 127
270 {
271 debug!(
272 "No interface alias is ever configured for {addr} on Windows; nothing to remove"
273 );
274
275 return Ok(LoopbackRelease::AlreadyAbsent);
276 }
277 }
278
279 debug!("Removing loopback address: {}", addr);
280
281 let helper_result = remove_loopback_with_helper(addr).await;
283
284 if helper_result.is_ok() {
285 debug!("Successfully removed loopback address via helper: {}", addr);
286 return Ok(LoopbackRelease::Released);
287 } else if let Err(e) = &helper_result {
288 warn!("Failed to remove loopback address with helper: {}", e);
289 }
290
291 #[cfg(target_os = "macos")]
294 {
295 if !macos_alias_exists(addr)? {
303 debug!("No loopback alias for {addr}; nothing to remove");
304
305 return Ok(LoopbackRelease::AlreadyAbsent);
306 }
307
308 let message = helper_result.unwrap_err().to_string();
315 if helper_is_unavailable(&message) {
316 warn!(
317 "Could not remove loopback address {} via helper, and the osascript fallback would block.",
318 addr
319 );
320
321 return Ok(LoopbackRelease::PrivilegeUnavailable(format!(
322 "Loopback address {addr} is still configured: removing it needs the helper"
323 )));
324 }
325
326 warn!("Helper failed to remove loopback address {addr}: {message}");
327
328 Ok(LoopbackRelease::Failed(format!(
329 "Loopback address {addr} is still configured: {message}"
330 )))
331 }
332
333 #[cfg(target_os = "linux")]
334 {
335 info!("Using Linux-specific method for loopback removal");
336 if !linux_alias_exists(addr)? {
343 debug!("No explicit loopback alias for {addr}; nothing to remove");
344
345 return Ok(LoopbackRelease::AlreadyAbsent);
346 }
347 if unsafe { libc::geteuid() } == 0 {
348 match execute_command("ip", &["addr", "del", addr, "dev", "lo"]) {
349 Ok(()) => Ok(LoopbackRelease::Released),
350 Err(e) => Ok(resolve_failed_removal(e.to_string(), || {
351 linux_alias_exists(addr)
352 })),
353 }
354 } else {
355 Ok(classify_sudo_ip_addr_del(
361 addr,
362 run_sudo_ip_addr_del(addr),
363 || linux_alias_exists(addr),
364 ))
365 }
366 }
367
368 #[cfg(target_os = "windows")]
369 {
370 info!("Using Windows-specific method for loopback removal");
371 Ok(LoopbackRelease::Failed(format!(
377 "Loopback address {addr} is still configured: removing it needs the helper"
378 )))
379 }
380
381 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows",)))]
382 {
383 Ok(LoopbackRelease::Failed(
384 "Loopback address removal not supported on this platform".to_string(),
385 ))
386 }
387}
388
389pub async fn is_address_accessible(addr: &str) -> bool {
390 let socket_addr = format!("{addr}:0");
391 tokio::net::TcpListener::bind(socket_addr).await.is_ok()
392}
393
394#[cfg(target_os = "macos")]
395fn configure_loopback_macos(addr: &str) -> Result<()> {
396 let check_output = Command::new("ifconfig")
397 .args(["lo0"])
398 .output()
399 .map_err(|e| anyhow!("Failed to check loopback interface: {}", e))?;
400
401 let output_str = String::from_utf8_lossy(&check_output.stdout);
402 if output_str.contains(addr) {
403 debug!("Loopback address {} is already configured on lo0", addr);
404 return Ok(());
405 }
406
407 debug!("Trying to add loopback address alias with osascript");
408 let script =
409 format!(r#"do shell script "ifconfig lo0 alias {addr}" with administrator privileges"#);
410
411 let result = Command::new("osascript").args(["-e", &script]).output();
412
413 match result {
414 Ok(output) if output.status.success() => {
415 debug!("Successfully added loopback address using osascript with admin privileges");
416 Ok(())
417 }
418 Ok(output) => {
419 let stderr = String::from_utf8_lossy(&output.stderr);
420 debug!(
421 "osascript command failed with exit code {}: {}",
422 output.status.code().unwrap_or(-1),
423 stderr
424 );
425
426 if stderr.contains("User cancelled")
427 || stderr.contains("user cancelled")
428 || stderr.contains("cancelled")
429 || stderr.contains("User canceled")
430 || stderr.contains("canceled")
431 || stderr.contains("(-128)")
432 || output.status.code() == Some(1)
433 {
434 Err(anyhow!("User cancelled loopback address configuration"))
435 } else {
436 Err(anyhow!("Failed to configure loopback address: {}", stderr))
437 }
438 }
439 Err(e) => Err(anyhow!("Failed to execute osascript: {}", e)),
440 }
441}
442
443#[cfg(target_os = "macos")]
449fn macos_alias_exists(addr: &str) -> Result<bool> {
450 let output = Command::new("/sbin/ifconfig")
451 .arg("lo0")
452 .output()
453 .map_err(|error| anyhow!("Failed to query loopback aliases: {error}"))?;
454 if !output.status.success() {
455 return Err(anyhow!(
456 "Failed to query loopback aliases: {}",
457 String::from_utf8_lossy(&output.stderr).trim()
458 ));
459 }
460
461 Ok(ifconfig_lists_address(
462 &String::from_utf8_lossy(&output.stdout),
463 addr,
464 ))
465}
466
467#[cfg(target_os = "macos")]
469fn ifconfig_lists_address(output: &str, addr: &str) -> bool {
470 output
471 .lines()
472 .filter_map(|line| {
473 let mut fields = line.split_whitespace();
474 matches!(fields.next(), Some("inet" | "inet6")).then(|| fields.next())?
475 })
476 .any(|configured| configured == addr)
477}
478
479#[cfg(target_os = "linux")]
480fn linux_alias_exists(addr: &str) -> Result<bool> {
481 let output = Command::new("ip")
482 .args(["-o", "addr", "show", "dev", "lo"])
483 .output()
484 .map_err(|error| anyhow!("Failed to query loopback aliases: {error}"))?;
485 if !output.status.success() {
486 return Err(anyhow!(
487 "Failed to query loopback aliases: {}",
488 String::from_utf8_lossy(&output.stderr).trim()
489 ));
490 }
491
492 Ok(String::from_utf8_lossy(&output.stdout)
493 .split_whitespace()
494 .any(|field| field == addr || field.starts_with(&format!("{addr}/"))))
495}
496
497#[cfg(any(target_os = "linux", all(test, unix)))]
509fn is_sudo_privilege_refusal(exit_code: Option<i32>, stderr: &str) -> bool {
510 exit_code == Some(1) && (stderr.contains("a password is required") || stderr.contains("sudo:"))
511}
512
513#[cfg(target_os = "linux")]
514fn run_sudo_ip_addr_del(addr: &str) -> std::io::Result<std::process::Output> {
515 Command::new("sudo")
516 .args(["-n", "ip", "addr", "del", addr, "dev", "lo"])
517 .output()
518}
519
520#[cfg(any(target_os = "linux", all(test, unix)))]
530fn classify_sudo_ip_addr_del(
531 addr: &str, spawned: std::io::Result<std::process::Output>,
532 alias_present: impl FnOnce() -> Result<bool>,
533) -> LoopbackRelease {
534 let output = match spawned {
535 Ok(output) => output,
536 Err(error) => {
537 return LoopbackRelease::PrivilegeUnavailable(format!(
538 "Removing loopback address {addr} needs a privileged `ip addr del`, and `sudo` \
539 could not be executed: {error}"
540 ));
541 }
542 };
543 if output.status.success() {
544 return LoopbackRelease::Released;
545 }
546 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
547 if is_sudo_privilege_refusal(output.status.code(), &stderr) {
548 return LoopbackRelease::PrivilegeUnavailable(format!(
549 "Removing loopback address {addr} needs a privileged `ip addr del`, and `sudo -n` \
550 refused it (a password may be required): {stderr}"
551 ));
552 }
553 resolve_failed_removal(
554 format!("`sudo -n ip addr del` failed: {stderr}"),
555 alias_present,
556 )
557}
558
559#[cfg(any(target_os = "linux", all(test, unix)))]
564fn resolve_failed_removal(
565 error_message: String, alias_present: impl FnOnce() -> Result<bool>,
566) -> LoopbackRelease {
567 match alias_present() {
568 Ok(false) => LoopbackRelease::Released,
569 _ => LoopbackRelease::Failed(error_message),
570 }
571}
572
573#[cfg(target_os = "linux")]
574fn configure_loopback_linux(addr: &str) -> Result<()> {
575 if unsafe { libc::geteuid() } == 0 {
576 execute_command("ip", &["addr", "add", addr, "dev", "lo"])?;
577
578 execute_command(
579 "ip",
580 &["route", "add", &format!("{}/32", addr), "dev", "lo"],
581 )
582 .or_else(|e| {
583 debug!("Route might already exist: {}", e);
584 Ok(())
585 })
586 } else {
587 execute_command("/usr/bin/pkexec", &["ip", "addr", "add", addr, "dev", "lo"])
588 .or_else(|_| execute_command("sudo", &["ip", "addr", "add", addr, "dev", "lo"]))?;
589
590 execute_command(
591 "/usr/bin/pkexec",
592 &["ip", "route", "add", &format!("{}/32", addr), "dev", "lo"],
593 )
594 .or_else(|_| {
595 execute_command(
596 "sudo",
597 &["ip", "route", "add", &format!("{}/32", addr), "dev", "lo"],
598 )
599 })
600 .or_else(|e| {
601 debug!("Route might already exist: {}", e);
602 Ok(())
603 })
604 }
605}
606
607#[cfg(target_os = "windows")]
608fn configure_loopback_windows(_addr: &str) -> Result<()> {
609 Ok(())
610}
611
612#[cfg(test)]
613mod tests {
614 use super::*;
615
616 #[test]
617 fn test_is_loopback_address() {
618 assert!(is_loopback_address("127.0.0.1"));
619 assert!(is_loopback_address("127.0.0.2"));
620 assert!(is_loopback_address("127.255.255.255"));
621 assert!(!is_loopback_address("192.168.1.1"));
622 assert!(!is_loopback_address("10.0.0.1"));
623 assert!(!is_loopback_address("invalid-ip"));
624 }
625
626 #[cfg(target_os = "macos")]
627 #[test]
628 fn a_configured_alias_is_matched_whole() {
629 let output = "lo0: flags=8049<UP,LOOPBACK,RUNNING,MULTICAST> mtu 16384\n\
630 \toptions=1203<RXCSUM,TXCSUM,TXSTATUS,SW_TIMESTAMP>\n\
631 \tinet 127.0.0.1 netmask 0xff000000\n\
632 \tinet6 ::1 prefixlen 128\n\
633 \tinet 127.0.0.10 netmask 0xff000000\n";
634
635 assert!(ifconfig_lists_address(output, "127.0.0.1"));
636 assert!(ifconfig_lists_address(output, "127.0.0.10"));
637 assert!(ifconfig_lists_address(output, "::1"));
638 assert!(!ifconfig_lists_address(output, "127.0.0.2"));
641 assert!(!ifconfig_lists_address(output, "127.0.0."));
642 assert!(!ifconfig_lists_address(output, "0xff000000"));
644 }
645
646 #[test]
647 fn test_is_default_loopback_address() {
648 assert!(is_default_loopback_address("127.0.0.1"));
649 assert!(!is_default_loopback_address("127.0.0.2"));
650 assert!(!is_default_loopback_address("127.255.255.255"));
651 assert!(!is_default_loopback_address("192.168.1.1"));
652 assert!(!is_default_loopback_address("10.0.0.1"));
653 assert!(!is_default_loopback_address("invalid-ip"));
654 }
655
656 #[test]
657 fn test_is_custom_loopback_address() {
658 assert!(!is_custom_loopback_address("127.0.0.1"));
659 assert!(is_custom_loopback_address("127.0.0.2"));
660 assert!(is_custom_loopback_address("127.0.0.5"));
661 assert!(is_custom_loopback_address("127.255.255.255"));
662 assert!(!is_custom_loopback_address("192.168.1.1"));
663 assert!(!is_custom_loopback_address("10.0.0.1"));
664 assert!(!is_custom_loopback_address("invalid-ip"));
665 }
666
667 #[tokio::test]
668 async fn test_ensure_loopback_address_non_loopback() {
669 assert!(ensure_loopback_address("192.168.1.1").await.is_ok());
670 }
671
672 #[tokio::test]
673 async fn test_ensure_loopback_address_default() {
674 assert!(ensure_loopback_address("127.0.0.1").await.is_ok());
675 }
676
677 #[tokio::test]
678 async fn test_remove_loopback_address_non_loopback_is_already_absent() {
679 assert_eq!(
680 remove_loopback_address("192.168.1.1").await.unwrap(),
681 LoopbackRelease::AlreadyAbsent
682 );
683 }
684
685 #[tokio::test]
686 async fn test_remove_loopback_address_default_is_already_absent() {
687 assert_eq!(
688 remove_loopback_address("127.0.0.1").await.unwrap(),
689 LoopbackRelease::AlreadyAbsent
690 );
691 }
692
693 #[tokio::test]
694 async fn test_remove_loopback_address_ipv6_default_is_already_absent() {
695 assert_eq!(
696 remove_loopback_address("::1").await.unwrap(),
697 LoopbackRelease::AlreadyAbsent
698 );
699 }
700
701 #[cfg(unix)]
702 #[test]
703 fn a_non_interactive_sudo_password_refusal_is_a_privilege_refusal() {
704 assert!(is_sudo_privilege_refusal(
705 Some(1),
706 "sudo: a password is required"
707 ));
708 }
709
710 #[cfg(unix)]
711 #[test]
712 fn any_sudo_prefixed_stderr_at_exit_one_is_a_privilege_refusal() {
713 assert!(is_sudo_privilege_refusal(
716 Some(1),
717 "sudo: no tty present and no askpass program specified"
718 ));
719 }
720
721 #[cfg(unix)]
722 #[test]
723 fn ip_failing_on_its_own_is_not_a_privilege_refusal() {
724 assert!(!is_sudo_privilege_refusal(
727 Some(1),
728 "Cannot find device \"lo\""
729 ));
730 assert!(!is_sudo_privilege_refusal(Some(2), ""));
731 }
732
733 #[cfg(unix)]
734 fn exited(code: i32, stderr: &[u8]) -> std::process::Output {
735 use std::os::unix::process::ExitStatusExt;
736
737 std::process::Output {
738 status: std::process::ExitStatus::from_raw(code << 8),
739 stdout: Vec::new(),
740 stderr: stderr.to_vec(),
741 }
742 }
743
744 #[cfg(unix)]
745 #[test]
746 fn a_successful_sudo_ip_addr_del_is_released() {
747 assert_eq!(
748 classify_sudo_ip_addr_del("127.0.0.5", Ok(exited(0, b"")), || Ok(true)),
749 LoopbackRelease::Released
750 );
751 }
752
753 #[cfg(unix)]
754 #[test]
755 fn a_sudo_credentials_refusal_is_privilege_unavailable() {
756 let output = exited(1, b"sudo: a password is required\n");
757 match classify_sudo_ip_addr_del("127.0.0.5", Ok(output), || Ok(true)) {
758 LoopbackRelease::PrivilegeUnavailable(message) => {
759 assert!(message.contains("a password may be required"));
760 }
761 other => panic!("expected PrivilegeUnavailable, got {other:?}"),
762 }
763 }
764
765 #[cfg(unix)]
766 #[test]
767 fn sudo_failing_to_execute_is_privilege_unavailable() {
768 let error = std::io::Error::new(std::io::ErrorKind::NotFound, "sudo not found");
769 match classify_sudo_ip_addr_del("127.0.0.5", Err(error), || Ok(true)) {
770 LoopbackRelease::PrivilegeUnavailable(message) => {
771 assert!(message.contains("could not be executed"));
772 }
773 other => panic!("expected PrivilegeUnavailable, got {other:?}"),
774 }
775 }
776
777 #[cfg(unix)]
778 #[test]
779 fn ip_addr_del_failing_with_the_alias_still_present_is_reported_as_failed() {
780 let output = exited(2, b"RTNETLINK answers: Operation not permitted");
781 match classify_sudo_ip_addr_del("127.0.0.253", Ok(output), || Ok(true)) {
782 LoopbackRelease::Failed(message) => {
783 assert!(message.contains("sudo -n ip addr del"));
784 }
785 other => panic!("expected Failed, got {other:?}"),
786 }
787 }
788
789 #[cfg(unix)]
790 #[test]
791 fn ip_addr_del_failing_after_the_alias_is_gone_is_released() {
792 let output = exited(2, b"RTNETLINK answers: Cannot assign requested address");
793 assert_eq!(
794 classify_sudo_ip_addr_del("127.0.0.253", Ok(output), || Ok(false)),
795 LoopbackRelease::Released
796 );
797 }
798
799 #[cfg(unix)]
800 #[test]
801 fn an_unanswered_alias_query_keeps_the_release_retryable() {
802 let output = exited(2, b"RTNETLINK answers: Operation not permitted");
803 assert!(matches!(
804 classify_sudo_ip_addr_del("127.0.0.253", Ok(output), || { Err(anyhow!("ip missing")) }),
805 LoopbackRelease::Failed(_)
806 ));
807 }
808
809 #[cfg(unix)]
810 #[test]
811 fn resolve_failed_removal_reports_released_when_alias_already_gone() {
812 assert_eq!(
813 resolve_failed_removal(
814 "`ip addr del` exited with exit status: 1".to_string(),
815 || { Ok(false) }
816 ),
817 LoopbackRelease::Released
818 );
819 }
820
821 #[cfg(unix)]
822 #[test]
823 fn resolve_failed_removal_keeps_failure_when_alias_still_present() {
824 assert!(matches!(
825 resolve_failed_removal(
826 "`ip addr del` exited with exit status: 1".to_string(),
827 || { Ok(true) }
828 ),
829 LoopbackRelease::Failed(_)
830 ));
831 }
832
833 #[cfg(unix)]
834 #[test]
835 fn helper_unavailable_from_the_availability_check_is_privilege_unavailable() {
836 assert!(helper_is_unavailable("Helper service is not available"));
837 }
838
839 #[cfg(unix)]
840 #[test]
841 fn a_refused_connection_is_privilege_unavailable() {
842 assert!(helper_is_unavailable(
843 "Helper failed to remove loopback address: Communication error: Failed to connect to Unix socket: Connection refused (os error 61)"
844 ));
845 }
846
847 #[cfg(unix)]
848 #[test]
849 fn a_missing_socket_file_is_privilege_unavailable() {
850 assert!(helper_is_unavailable(
851 "Helper failed to remove loopback address: Communication error: Failed to connect to Unix socket: No such file or directory (os error 2)"
852 ));
853 }
854
855 #[cfg(unix)]
856 #[test]
857 fn a_transient_write_failure_is_not_privilege_unavailable() {
858 assert!(!helper_is_unavailable(
859 "Helper failed to remove loopback address: Communication error: Failed to write request: Broken pipe (os error 32)"
860 ));
861 }
862
863 #[cfg(target_os = "linux")]
864 fn serve_one_helper_reply(
865 reply: fn(String) -> kftray_helper::messages::HelperResponse,
866 ) -> (
867 tempfile::TempDir,
868 kftray_commons::test_utils::EnvVarGuard,
869 std::thread::JoinHandle<()>,
870 ) {
871 use std::io::Write;
872 use std::os::unix::net::UnixListener;
873
874 let dir = tempfile::tempdir().unwrap();
875 let socket = dir.path().join("kftray-helper.sock");
876 let listener = UnixListener::bind(&socket).unwrap();
877 let env =
878 kftray_commons::test_utils::EnvVarGuard::set("SOCKET_PATH", socket.to_str().unwrap());
879
880 let server = std::thread::spawn(move || {
881 drop(listener.accept().unwrap());
882 let (mut stream, _) = listener.accept().unwrap();
883 let request: kftray_helper::messages::HelperRequest =
884 serde_json::Deserializer::from_reader(&mut stream)
885 .into_iter()
886 .next()
887 .unwrap()
888 .unwrap();
889 let response = reply(request.request_id);
890 stream
891 .write_all(&serde_json::to_vec(&response).unwrap())
892 .unwrap();
893 });
894
895 (dir, env, server)
896 }
897
898 #[cfg(target_os = "linux")]
899 fn helper_error(request_id: String) -> kftray_helper::messages::HelperResponse {
900 kftray_helper::messages::HelperResponse::error(
901 request_id,
902 "Error: RTNETLINK answers: Operation not permitted",
903 )
904 }
905
906 #[cfg(target_os = "linux")]
907 #[tokio::test]
908 async fn a_helper_error_reply_to_remove_is_not_a_removal() {
909 let (_dir, _env, server) = serve_one_helper_reply(helper_error);
910
911 let outcome = remove_loopback_with_helper("127.0.0.200").await;
912 server.join().unwrap();
913
914 let error = outcome.unwrap_err().to_string();
915 assert!(error.contains("Operation not permitted"), "{error}");
916 }
917
918 #[cfg(target_os = "linux")]
919 #[tokio::test]
920 async fn a_helper_error_reply_to_add_is_not_a_configuration() {
921 let (_dir, _env, server) = serve_one_helper_reply(helper_error);
922
923 let outcome = configure_loopback_with_helper("127.0.0.200").await;
924 server.join().unwrap();
925
926 let error = outcome.unwrap_err().to_string();
927 assert!(error.contains("Operation not permitted"), "{error}");
928 }
929
930 #[cfg(target_os = "linux")]
931 #[tokio::test]
932 async fn a_helper_success_reply_to_remove_is_a_removal() {
933 let (_dir, _env, server) =
934 serve_one_helper_reply(kftray_helper::messages::HelperResponse::success);
935
936 let outcome = remove_loopback_with_helper("127.0.0.200").await;
937 server.join().unwrap();
938
939 assert!(outcome.is_ok(), "{outcome:?}");
940 }
941
942 #[cfg(target_os = "windows")]
943 #[tokio::test]
944 async fn a_127_range_address_is_already_absent_without_calling_the_helper() {
945 assert_eq!(
950 remove_loopback_address("127.0.0.9").await.unwrap(),
951 LoopbackRelease::AlreadyAbsent
952 );
953 }
954
955 #[cfg(target_os = "windows")]
956 #[tokio::test]
957 async fn ipv6_default_loopback_is_already_absent_without_calling_the_helper() {
958 assert_eq!(
962 remove_loopback_address("::1").await.unwrap(),
963 LoopbackRelease::AlreadyAbsent
964 );
965 }
966
967 #[cfg(test)]
968 #[cfg(target_os = "windows")]
969 #[tokio::test]
970 async fn test_configure_loopback_windows() {
971 assert!(configure_loopback_windows("127.0.0.2").is_ok());
972 }
973}