nvpn 4.1.13

CLI and daemon for Nostr VPN private mesh networks
    #[derive(Default)]
    struct FakeLinuxNetworkCleanupActions {
        events: Vec<&'static str>,
        forwarding_failures_remaining: usize,
        ipv4_restore_unblocked_by_forwarding_cleanup: bool,
        ipv4_restore_failures_remaining: usize,
        ipv4_restore_pending: bool,
        ipv6_restore_pending: bool,
        fail_route_cache_flush: bool,
    }

    impl super::LinuxNetworkCleanupActions for FakeLinuxNetworkCleanupActions {
        fn cleanup_endpoint_bypass_routes(&mut self) -> anyhow::Result<()> {
            self.events.push("endpoint");
            Ok(())
        }

        fn cleanup_forwarding_and_wireguard(&mut self) -> anyhow::Result<()> {
            self.events.push("forwarding-wireguard");
            if self.forwarding_failures_remaining > 0 {
                self.forwarding_failures_remaining -= 1;
                return Err(anyhow::anyhow!("synthetic forwarding cleanup failure"));
            }
            if self.ipv4_restore_unblocked_by_forwarding_cleanup {
                self.ipv4_restore_failures_remaining = 0;
            }
            Ok(())
        }

        fn restore_original_ipv4_default(&mut self) {
            self.events.push("restore-ipv4");
            if self.ipv4_restore_failures_remaining > 0 {
                self.ipv4_restore_failures_remaining -= 1;
            } else {
                self.ipv4_restore_pending = false;
            }
        }

        fn restore_original_ipv6_default(&mut self) {
            self.events.push("restore-ipv6");
            self.ipv6_restore_pending = false;
        }

        fn ipv4_default_restore_pending(&self) -> bool {
            self.ipv4_restore_pending
        }

        fn ipv6_default_restore_pending(&self) -> bool {
            self.ipv6_restore_pending
        }

        fn wait_before_default_restore_retry(&mut self) {
            self.events.push("wait-default-restore");
        }

        fn flush_route_cache(&mut self) -> anyhow::Result<()> {
            self.events.push("flush-route-cache");
            if self.fail_route_cache_flush {
                Err(anyhow::anyhow!("synthetic route cache flush failure"))
            } else {
                Ok(())
            }
        }
    }

    #[cfg(target_os = "linux")]
    static LINUX_PENDING_CLEANUP_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

    #[test]
    fn linux_cleanup_restores_defaults_after_forwarding_cleanup_exhausts_retries() {
        let mut actions = FakeLinuxNetworkCleanupActions {
            forwarding_failures_remaining: 3,
            ipv4_restore_failures_remaining: 1,
            ipv4_restore_pending: true,
            ipv6_restore_pending: true,
            fail_route_cache_flush: true,
            ..FakeLinuxNetworkCleanupActions::default()
        };

        let error = super::cleanup_linux_network_state_with_actions(&mut actions)
            .expect_err("forwarding and route-cache failures remain reportable");

        assert_eq!(
            actions.events,
            vec![
                "endpoint",
                "restore-ipv4",
                "restore-ipv6",
                "forwarding-wireguard",
                "forwarding-wireguard",
                "forwarding-wireguard",
                "restore-ipv4",
                "flush-route-cache",
            ]
        );
        assert!(!actions.ipv4_restore_pending);
        assert!(!actions.ipv6_restore_pending);
        let message = format!("{error:#}");
        assert!(message.contains("forwarding/WireGuard cleanup failed after three attempts"));
        assert!(message.contains("synthetic route cache flush failure"));
    }

    #[test]
    fn linux_cleanup_rechecks_default_after_forwarding_restores_alternate() {
        let mut actions = FakeLinuxNetworkCleanupActions {
            ipv4_restore_unblocked_by_forwarding_cleanup: true,
            ipv4_restore_failures_remaining: usize::MAX,
            ipv4_restore_pending: true,
            ..FakeLinuxNetworkCleanupActions::default()
        };

        super::cleanup_linux_network_state_with_actions(&mut actions)
            .expect("post-WireGuard secondary default completes saved-route cleanup");

        assert_eq!(
            actions.events,
            vec![
                "endpoint",
                "restore-ipv4",
                "restore-ipv6",
                "forwarding-wireguard",
                "restore-ipv4",
                "flush-route-cache",
            ]
        );
        assert!(!actions.ipv4_restore_pending);
    }

    #[test]
    fn linux_cleanup_bounds_default_restore_retries() {
        let mut actions = FakeLinuxNetworkCleanupActions {
            ipv4_restore_failures_remaining: usize::MAX,
            ipv4_restore_pending: true,
            ..FakeLinuxNetworkCleanupActions::default()
        };

        let error = super::cleanup_linux_network_state_with_actions(&mut actions)
            .expect_err("persistent default-route failure remains reportable");

        assert_eq!(
            actions
                .events
                .iter()
                .filter(|event| **event == "restore-ipv4")
                .count(),
            super::LINUX_NETWORK_CLEANUP_ATTEMPTS + 1
        );
        assert_eq!(
            actions
                .events
                .iter()
                .filter(|event| **event == "wait-default-restore")
                .count(),
            super::LINUX_NETWORK_CLEANUP_ATTEMPTS - 1
        );
        assert!(actions.ipv4_restore_pending);
        assert!(
            format!("{error:#}").contains("failed to restore original IPv4 default route")
        );
    }

    #[cfg(target_os = "linux")]
    #[test]
    fn linux_failed_replacement_start_cleanup_survives_process_boundary() {
        let _guard = LINUX_PENDING_CLEANUP_TEST_LOCK
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        let prior = super::take_pending_linux_network_cleanup_state();
        assert!(
            prior.is_none(),
            "failed-start persistence test requires an empty in-process registry"
        );

        let expected_default = "default via 192.0.2.1 dev eth0 metric 100".to_string();
        super::replace_pending_linux_network_cleanup_state(Some(
            crate::LinuxNetworkCleanupState {
                iface: "nvpn-start-failure".to_string(),
                original_default_route: Some(expected_default.clone()),
                ..crate::LinuxNetworkCleanupState::default()
            },
        ));

        let nonce = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .expect("clock is after epoch")
            .as_nanos();
        let directory =
            std::env::temp_dir().join(format!("nvpn-linux-start-cleanup-test-{nonce}"));
        std::fs::create_dir_all(&directory).expect("create temp directory");
        let config_path = directory.join("config.toml");
        let error = crate::persist_fips_private_tunnel_start_result(
            &config_path,
            Err::<(), _>(anyhow::anyhow!("synthetic replacement start failure")),
        )
        .expect_err("replacement start remains failed after persisting cleanup ownership");
        assert!(
            format!("{error:#}").contains("synthetic replacement start failure"),
            "the original replacement-start error must remain visible"
        );

        let pending = super::take_pending_linux_network_cleanup_state();
        assert!(
            pending.is_some(),
            "the failed start must retain in-process ownership until process exit"
        );
        let cleanup_path = crate::daemon_network_cleanup_file_path(&config_path);
        let saved = crate::read_daemon_network_cleanup_state(&cleanup_path)
            .expect("read cleanup ownership")
            .expect("cleanup ownership survives process-boundary readback");
        assert_eq!(saved.iface, "nvpn-start-failure");
        assert_eq!(saved.original_default_route, Some(expected_default));

        let _ = std::fs::remove_dir_all(directory);
    }

    #[cfg(target_os = "linux")]
    #[test]
    fn linux_failed_stop_persists_only_remaining_cleanup_ownership() {
        let _guard = LINUX_PENDING_CLEANUP_TEST_LOCK
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        let prior = super::take_pending_linux_network_cleanup_state();
        assert!(
            prior.is_none(),
            "failed-stop persistence test requires an empty in-process registry"
        );

        let nonce = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .expect("clock is after epoch")
            .as_nanos();
        let directory =
            std::env::temp_dir().join(format!("nvpn-linux-stop-cleanup-test-{nonce}"));
        std::fs::create_dir_all(&directory).expect("create temp directory");
        let config_path = directory.join("config.toml");
        let cleanup_path = crate::daemon_network_cleanup_file_path(&config_path);
        let stale_default = "default via 192.0.2.1 dev eth0 metric 100".to_string();
        crate::write_daemon_network_cleanup_state(
            &cleanup_path,
            &crate::LinuxNetworkCleanupState {
                iface: "nvpn-stop-failure".to_string(),
                original_default_route: Some(stale_default),
                ..crate::LinuxNetworkCleanupState::default()
            },
        )
        .expect("persist pre-stop cleanup ownership");

        let cleanup_error = Err(anyhow::anyhow!("synthetic partial cleanup failure"));
        super::record_linux_stop_cleanup_ownership(
            &cleanup_error,
            Some(crate::LinuxNetworkCleanupState {
                iface: "nvpn-stop-failure".to_string(),
                exit_node_runtime: crate::LinuxExitNodeRuntime {
                    ipv4_forward_was_enabled: Some(false),
                    ..crate::LinuxExitNodeRuntime::default()
                },
                ..crate::LinuxNetworkCleanupState::default()
            }),
        );
        crate::persist_fips_daemon_network_cleanup_state(&config_path, None)
            .expect("replace pre-stop ownership with remaining obligations");

        let saved = crate::read_daemon_network_cleanup_state(&cleanup_path)
            .expect("read remaining cleanup ownership")
            .expect("remaining cleanup ownership exists");
        assert_eq!(saved.iface, "nvpn-stop-failure");
        assert!(
            saved.original_default_route.is_none(),
            "a successfully restored default route must not be replayed after restart"
        );
        assert_eq!(
            saved.exit_node_runtime.ipv4_forward_was_enabled,
            Some(false),
            "the cleanup obligation that actually remains must stay persisted"
        );

        super::record_linux_stop_cleanup_ownership(&cleanup_error, None);
        crate::persist_fips_daemon_network_cleanup_state(&config_path, None)
            .expect("remove pre-stop ownership when no cleanup obligation remains");
        assert!(
            super::pending_linux_network_cleanup_state().is_none(),
            "an error without remaining ownership must not block the next start"
        );
        assert!(
            !cleanup_path.exists(),
            "an error without remaining ownership must remove the stale pre-stop file"
        );

        super::take_pending_linux_network_cleanup_state();
        let _ = std::fs::remove_dir_all(directory);
    }