Skip to main content

nd_300/actions/fix/
action.rs

1//! Action type system and registry for the diagnostic-driven fix loop.
2//!
3//! Every fix primitive is wrapped as an [`Action`]. The triage layer picks
4//! actions whose `targets` intersect the current failure set; the loop runner
5//! calls `Action::apply` and records the [`ActionOutcome`].
6
7use std::time::Duration;
8
9use crate::actions::flush_dns_platform;
10use crate::config::Config;
11
12use super::adapters;
13use super::arp;
14use super::cmd::CmdOutcome;
15use super::dhcp;
16use super::dns::{self, DnsProvider};
17use super::session::{RestoreOp, RestoreRegistry};
18use super::stages;
19use super::vpn;
20
21/// Identifier for one of the user-mode core diagnostics. An [`Action`]'s
22/// `targets` field declares which of these failure categories it can address.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub enum DiagnosticKey {
25    Adapters,
26    Interfaces,
27    Gateway,
28    Dns,
29    PublicIp,
30    Latency,
31    Ports,
32    Speed,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum Cost {
37    Cheap,
38    Medium,
39    Expensive,
40}
41
42impl Cost {
43    pub fn rank(self) -> u8 {
44        match self {
45            Cost::Cheap => 0,
46            Cost::Medium => 1,
47            Cost::Expensive => 2,
48        }
49    }
50}
51
52#[derive(Debug, Clone)]
53pub enum Risk {
54    Low,
55    Medium,
56    /// High-risk actions REQUIRE an attached [`RiskExplanation`] by
57    /// construction. There is no way to reach the loop's apply path on a
58    /// High-risk action without a complete plain-language explanation that the
59    /// user can read before approving.
60    High(RiskExplanation),
61}
62
63impl Risk {
64    pub fn is_high(&self) -> bool {
65        matches!(self, Risk::High(_))
66    }
67    pub fn rank(&self) -> u8 {
68        match self {
69            Risk::Low => 0,
70            Risk::Medium => 1,
71            Risk::High(_) => 2,
72        }
73    }
74}
75
76#[derive(Debug, Clone, Copy)]
77pub enum Reversibility {
78    /// Fully reversible without user action.
79    FullyReversible,
80    /// Requires a reboot to fully revert (e.g. Winsock reset).
81    RebootToFullyRevert,
82    /// Cannot be undone (e.g. delete Wi-Fi profile — passphrase is gone).
83    NotReversible,
84}
85
86impl Reversibility {
87    pub fn label(self) -> &'static str {
88        match self {
89            Reversibility::FullyReversible => "fully reversible",
90            Reversibility::RebootToFullyRevert => "requires reboot to fully revert",
91            Reversibility::NotReversible => "not reversible",
92        }
93    }
94}
95
96/// Plain-English explanation rendered before any High-risk action runs. Every
97/// field is mandatory — there is no way to construct a High-risk
98/// [`Action`] without a complete explanation.
99#[derive(Debug, Clone)]
100pub struct RiskExplanation {
101    /// One-line headline of what the action does (e.g. "Reset Windows
102    /// networking stack").
103    pub what: &'static str,
104    /// 1–3 sentence explanation of why this action exists and what kind of
105    /// problem it fixes. Written for non-technical readers.
106    pub why: &'static str,
107    /// Concrete consequences the user should expect, one per bullet.
108    pub side_effects: &'static [&'static str],
109    /// How recoverable the action is.
110    pub reversible: Reversibility,
111    /// Human-readable estimate of how long the user will be disrupted.
112    pub typical_duration: &'static str,
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
116pub enum ActionId {
117    FlushDns,
118    SetDnsCloudflare,
119    SetDnsAutomatic,
120    FlushArp,
121    RestartNetworkServices,
122    RenewDhcp,
123    DisableConsumerVpns,
124    BounceInterface,
125    /// Platform-specific deep stack reset (Windows: Winsock+TCPIP+IPv6; macOS:
126    /// fail-closed gated service cycle, never deletion; Linux: nmcli
127    /// guarded deep recovery. Destructive profile deletion/recreation paths
128    /// that cannot be restored are explicitly unavailable.
129    DeepStackReset,
130}
131
132#[derive(Debug, Clone)]
133pub struct Action {
134    pub id: ActionId,
135    /// Short label used in status lines (e.g. "Flush the DNS cache"). Should
136    /// read naturally to a non-technical user.
137    pub label: &'static str,
138    /// One-sentence "why I'm trying this" rendered before the action runs.
139    pub one_line_why: &'static str,
140    /// Failure categories this action can address. Used by triage to match
141    /// actions to current failures.
142    pub targets: &'static [DiagnosticKey],
143    pub cost: Cost,
144    pub risk: Risk,
145    /// Per-session attempt cap. Once reached, the action is removed from
146    /// future plans even if its targets keep failing — prevents the loop from
147    /// retrying a step that didn't help.
148    pub max_attempts: u8,
149    /// Wait after applying this action before the next iteration's
150    /// diagnostics run. DHCP renew and interface bounce need ~10s to settle;
151    /// a DNS flush only ~1s.
152    pub stabilization: Duration,
153}
154
155#[derive(Debug, Clone)]
156pub struct ActionOutcome {
157    pub ok: bool,
158    pub message: String,
159    /// Captured subprocess invocations from inside this action. Currently
160    /// populated only by actions that build their own commands; legacy
161    /// helpers that return `Result<String, String>` leave this empty. The
162    /// `message` field always carries a human-readable summary either way.
163    pub cmd_outcomes: Vec<CmdOutcome>,
164    /// Set by actions that change the network surface enough that the loop
165    /// should re-probe immediately rather than apply additional actions in
166    /// the same iteration (e.g. interface bounce, deep stack reset).
167    pub fatal_environment_change: bool,
168}
169
170impl ActionOutcome {
171    pub fn ok(msg: impl Into<String>) -> Self {
172        Self {
173            ok: true,
174            message: msg.into(),
175            cmd_outcomes: Vec::new(),
176            fatal_environment_change: false,
177        }
178    }
179    pub fn fail(msg: impl Into<String>) -> Self {
180        Self {
181            ok: false,
182            message: msg.into(),
183            cmd_outcomes: Vec::new(),
184            fatal_environment_change: false,
185        }
186    }
187    pub fn from_result(r: Result<String, String>) -> Self {
188        match r {
189            Ok(msg) => Self::ok(msg),
190            Err(msg) => Self::fail(msg),
191        }
192    }
193    pub fn with_fatal_env_change(mut self) -> Self {
194        self.fatal_environment_change = true;
195        self
196    }
197}
198
199impl Action {
200    /// Run this action against the current system. Always returns an
201    /// `ActionOutcome` — apply failures are encoded inside it, never
202    /// propagated as Rust errors.
203    ///
204    /// `restore` is the run's [`RestoreRegistry`]: destructive actions
205    /// (VPN disable, interface bounce, deep stack reset) register the inverse
206    /// op here *before* mutating state, so any abort (Ctrl-C / timeout / panic)
207    /// can roll the change back. Non-destructive actions (flush / DNS / DHCP /
208    /// service restart) ignore it.
209    pub async fn apply(&self, config: &Config, restore: &RestoreRegistry) -> ActionOutcome {
210        match self.id {
211            ActionId::FlushDns => apply_flush_dns().await,
212            ActionId::SetDnsCloudflare => apply_set_dns(DnsProvider::Cloudflare, restore).await,
213            ActionId::SetDnsAutomatic => apply_set_dns(DnsProvider::Automatic, restore).await,
214            ActionId::FlushArp => apply_flush_arp().await,
215            ActionId::RestartNetworkServices => apply_restart_services().await,
216            ActionId::RenewDhcp => apply_renew_dhcp().await,
217            ActionId::DisableConsumerVpns => apply_disable_consumer_vpns(config, restore).await,
218            ActionId::BounceInterface => apply_bounce_interface(restore).await,
219            ActionId::DeepStackReset => apply_deep_stack_reset(config, restore).await,
220        }
221    }
222}
223
224// ── Apply implementations ───────────────────────────────────────────────────
225
226async fn apply_flush_dns() -> ActionOutcome {
227    ActionOutcome::from_result(flush_dns_platform().await)
228}
229
230async fn apply_set_dns(provider: DnsProvider, restore: &RestoreRegistry) -> ActionOutcome {
231    let iface = match adapters::detect_default_interface().await {
232        Some(i) => i,
233        None => return ActionOutcome::fail("Could not detect a default network interface"),
234    };
235    let service_name = service_name_for(&iface).await;
236    #[cfg(target_os = "macos")]
237    {
238        let snapshot = match dns::capture_macos_dns_snapshot(&service_name).await {
239            Ok(snapshot) => std::sync::Arc::new(snapshot),
240            Err(error) => {
241                return ActionOutcome::fail(format!(
242                    "Refused to change DNS because the exact prior DNS/search-domain state could not be captured: {error}"
243                ));
244            }
245        };
246        let token = restore
247            .register(RestoreOp::RestoreMacosDns(std::sync::Arc::clone(&snapshot)))
248            .await;
249        let result = dns::set_dns_servers(&iface, &service_name, provider).await;
250        match result {
251            Ok(message) => {
252                restore.mark_resolved(token).await;
253                ActionOutcome::ok(message)
254            }
255            Err(error) => match dns::restore_macos_dns_snapshot(&snapshot).await {
256                Ok(()) => {
257                    restore.mark_resolved(token).await;
258                    ActionOutcome::fail(format!(
259                        "{error}; exact prior DNS/search-domain state restored"
260                    ))
261                }
262                Err(restore_error) => ActionOutcome::fail(format!(
263                    "{error}; exact DNS/search-domain restoration also failed ({restore_error}); nd300 will retry during cleanup"
264                )),
265            },
266        }
267    }
268
269    #[cfg(not(target_os = "macos"))]
270    {
271        let _ = restore;
272        ActionOutcome::from_result(dns::set_dns_servers(&iface, &service_name, provider).await)
273    }
274}
275
276async fn apply_flush_arp() -> ActionOutcome {
277    ActionOutcome::from_result(arp::flush_arp().await)
278}
279
280async fn apply_restart_services() -> ActionOutcome {
281    ActionOutcome::from_result(stages::restart_services().await)
282}
283
284async fn apply_renew_dhcp() -> ActionOutcome {
285    if let Some(iface) = adapters::detect_default_interface().await {
286        ActionOutcome::from_result(adapters::renew_dhcp_on_interface(&iface).await)
287    } else {
288        ActionOutcome::from_result(dhcp::renew_dhcp().await)
289    }
290}
291
292async fn apply_disable_consumer_vpns(config: &Config, restore: &RestoreRegistry) -> ActionOutcome {
293    if !crate::actions::is_interactive(config) {
294        return ActionOutcome::fail(
295            "Skipped: disabling VPNs requires an interactive session so they can be re-enabled safely.",
296        );
297    }
298
299    let batch = vpn::detect_and_disable(config, restore).await;
300    if batch.disabled.is_empty() && batch.uncertain_attempts == 0 {
301        return ActionOutcome::ok("No consumer VPNs were active");
302    }
303    if batch.disabled.is_empty() {
304        let cleanup_failures = restore.drain().await;
305        let cleanup = if cleanup_failures.is_empty() {
306            "conservative reconnect cleanup completed".to_string()
307        } else {
308            format!(
309                "reconnect cleanup needs attention: {}",
310                cleanup_failures.join("; ")
311            )
312        };
313        return ActionOutcome::fail(format!(
314            "Could not confirm {} VPN disconnect attempt(s); {}",
315            batch.uncertain_attempts, cleanup
316        ))
317        .with_fatal_env_change();
318    }
319
320    let disabled: Vec<vpn::DisabledVpn> =
321        batch.disabled.iter().map(|item| item.vpn.clone()).collect();
322
323    let names: Vec<String> = disabled.iter().map(|v| v.name.clone()).collect();
324
325    // Normal path: offer to re-enable now (default No keeps them off for the
326    // re-probe). Resolve an inverse only after the user's explicit leave-off
327    // choice or positive provider/OS readback of the requested state. Any
328    // uncertain reconnect stays registered so terminal drain can retry it.
329    let dispositions = vpn::offer_reenable(&disabled, config).await;
330    let mut reconnect_pending = 0_usize;
331    for (item, disposition) in batch.disabled.into_iter().zip(dispositions) {
332        if disposition.resolves_restore() {
333            restore.mark_resolved(item.restore_token).await;
334        } else {
335            reconnect_pending += 1;
336        }
337    }
338
339    let cleanup_pending = batch.uncertain_attempts + reconnect_pending;
340    let uncertain = if cleanup_pending == 0 {
341        String::new()
342    } else {
343        let cleanup_failures = restore.drain().await;
344        let cleanup = if cleanup_failures.is_empty() {
345            "reconnect cleanup completed".to_string()
346        } else {
347            format!(
348                "reconnect cleanup needs attention: {}",
349                cleanup_failures.join("; ")
350            )
351        };
352        format!(
353            "; {} uncertain/failed reconnect state(s), {}",
354            cleanup_pending, cleanup
355        )
356    };
357    ActionOutcome::ok(format!(
358        "Disabled consumer VPNs: {}{}",
359        names.join(", "),
360        uncertain
361    ))
362    .with_fatal_env_change()
363}
364
365async fn apply_bounce_interface(restore: &RestoreRegistry) -> ActionOutcome {
366    let iface = match adapters::detect_default_interface().await {
367        Some(i) => i,
368        None => return ActionOutcome::fail("Could not detect a default network interface"),
369    };
370
371    #[cfg(target_os = "macos")]
372    if stages::detect_macos_service(&iface).await.is_none() {
373        return ActionOutcome::fail(format!(
374            "Refused to bounce macOS interface {} because it is not an unambiguous physical network service. Tunnel/virtual interfaces such as utun are never bounced.",
375            iface
376        ));
377    }
378
379    // Register the re-enable BEFORE disabling, so an interrupt between disable
380    // and re-enable still brings the adapter back up via the drain.
381    let token = restore
382        .register(RestoreOp::ReEnableInterface {
383            iface: iface.clone(),
384        })
385        .await;
386
387    if let Err(e) = stages::disable_interface(&iface).await {
388        // The subprocess may have taken effect before returning non-zero or
389        // timing out. Keep the idempotent re-enable op pending for the terminal
390        // drain instead of assuming the adapter stayed up, and attempt that
391        // cleanup immediately rather than waiting through more repair steps.
392        let cleanup_failures = restore.drain().await;
393        let cleanup = if cleanup_failures.is_empty() {
394            "conservative re-enable cleanup completed".to_string()
395        } else {
396            format!(
397                "re-enable cleanup needs attention: {}",
398                cleanup_failures.join("; ")
399            )
400        };
401        return ActionOutcome::fail(format!(
402            "Disable {} did not complete cleanly: {}. {}.",
403            iface, e, cleanup
404        ))
405        .with_fatal_env_change();
406    }
407
408    tokio::time::sleep(Duration::from_secs(3)).await;
409
410    // Re-enable with one retry. Leaving an adapter disabled is far worse than a
411    // slow retry, so mirror the legacy 2s-wait retry that the old Stage 2 had.
412    if let Err(first_err) = stages::restore_interface(&iface).await {
413        tokio::time::sleep(Duration::from_secs(2)).await;
414        if let Err(retry_err) = stages::restore_interface(&iface).await {
415            // Still down. Keep the restore registered and immediately invoke
416            // the drain for another bounded idempotent re-enable attempt.
417            let cleanup_failures = restore.drain().await;
418            let cleanup = if cleanup_failures.is_empty() {
419                "cleanup re-enabled the adapter".to_string()
420            } else {
421                format!(
422                    "cleanup still needs attention: {}",
423                    cleanup_failures.join("; ")
424                )
425            };
426            let cmd_hint = reenable_command_hint(&iface);
427            return ActionOutcome::fail(format!(
428                "Network adapter \"{}\" re-enable failed twice ({}; retry: {}); {}. \
429                 If you still have no connection, run: {}",
430                iface, first_err, retry_err, cleanup, cmd_hint
431            ))
432            .with_fatal_env_change();
433        }
434    }
435
436    // Adapter is back up — the action restored it itself.
437    restore.mark_resolved(token).await;
438    ActionOutcome::ok(format!("{} bounced (disable → 3s wait → re-enable)", iface))
439        .with_fatal_env_change()
440}
441
442/// Platform-specific manual command to bring an interface back up, for the
443/// loud failure message when an automated re-enable fails twice.
444fn reenable_command_hint(iface: &str) -> String {
445    #[cfg(windows)]
446    {
447        format!("netsh interface set interface \"{}\" enabled", iface)
448    }
449    #[cfg(target_os = "macos")]
450    {
451        format!(
452            "networksetup -setairportpower {} on  (Wi-Fi)  or  ifconfig {} up  (wired)",
453            iface, iface
454        )
455    }
456    #[cfg(target_os = "linux")]
457    {
458        format!("sudo ip link set {} up", iface)
459    }
460}
461
462async fn apply_deep_stack_reset(config: &Config, restore: &RestoreRegistry) -> ActionOutcome {
463    #[cfg(target_os = "macos")]
464    let saved_ssid = None;
465    #[cfg(not(target_os = "macos"))]
466    let saved_ssid = super::wifi::capture_current_ssid().await;
467    match stages::platform_stage3(config, &saved_ssid, restore).await {
468        Ok(steps) => {
469            if steps.is_empty() {
470                ActionOutcome::fail("Stack reset attempted but no steps succeeded")
471                    .with_fatal_env_change()
472            } else {
473                ActionOutcome::ok(format!("Stack reset: {}", steps.join("; ")))
474                    .with_fatal_env_change()
475            }
476        }
477        Err(e) => ActionOutcome::fail(e).with_fatal_env_change(),
478    }
479}
480
481#[cfg(target_os = "macos")]
482async fn service_name_for(iface: &str) -> String {
483    // For macOS, fall back to the iface name itself if the lookup fails —
484    // networksetup will reject unknown service names cleanly.
485    if let Some(svc) = stages::detect_macos_service(iface).await {
486        svc
487    } else {
488        iface.to_string()
489    }
490}
491
492#[cfg(not(target_os = "macos"))]
493async fn service_name_for(iface: &str) -> String {
494    iface.to_string()
495}
496
497// ── Registry ────────────────────────────────────────────────────────────────
498
499/// Returns every [`Action`] available on the current platform. Order is not
500/// significant — [`super::triage::build_plan`] sorts by cost / risk /
501/// effectiveness when assembling each iteration's plan.
502pub fn all_actions() -> Vec<Action> {
503    let mut actions = vec![
504        Action {
505            id: ActionId::FlushDns,
506            label: "Flush the DNS cache",
507            one_line_why: "Clears stale DNS records that often cause resolution failures.",
508            targets: &[DiagnosticKey::Dns],
509            cost: Cost::Cheap,
510            risk: Risk::Low,
511            max_attempts: 2,
512            stabilization: Duration::from_secs(1),
513        },
514        Action {
515            id: ActionId::SetDnsCloudflare,
516            label: "Switch DNS to Cloudflare (1.1.1.1)",
517            one_line_why: "Bypasses a broken or filtered DNS server provided by your network.",
518            targets: &[DiagnosticKey::Dns],
519            cost: Cost::Cheap,
520            risk: Risk::Low,
521            max_attempts: 1,
522            stabilization: Duration::from_secs(2),
523        },
524        Action {
525            id: ActionId::SetDnsAutomatic,
526            label: "Reset DNS to your router's defaults (DHCP)",
527            one_line_why: "Removes any custom DNS servers and lets your router choose.",
528            targets: &[DiagnosticKey::Dns],
529            cost: Cost::Cheap,
530            risk: Risk::Low,
531            max_attempts: 1,
532            stabilization: Duration::from_secs(2),
533        },
534        Action {
535            id: ActionId::FlushArp,
536            label: "Flush the ARP cache",
537            one_line_why: "Clears stale gateway entries that block traffic to your router.",
538            targets: &[DiagnosticKey::Gateway, DiagnosticKey::Latency],
539            cost: Cost::Cheap,
540            risk: Risk::Low,
541            max_attempts: 1,
542            stabilization: Duration::from_secs(1),
543        },
544        Action {
545            id: ActionId::RestartNetworkServices,
546            label: "Restart networking services",
547            one_line_why: "Brings the OS-level DNS / DHCP services back to a clean state.",
548            targets: &[
549                DiagnosticKey::Dns,
550                DiagnosticKey::Gateway,
551                DiagnosticKey::PublicIp,
552            ],
553            cost: Cost::Medium,
554            risk: Risk::Low,
555            max_attempts: 1,
556            stabilization: Duration::from_secs(3),
557        },
558        Action {
559            id: ActionId::RenewDhcp,
560            label: "Renew the DHCP lease",
561            one_line_why: "Asks your router for a fresh IP address and gateway.",
562            targets: &[
563                DiagnosticKey::Gateway,
564                DiagnosticKey::PublicIp,
565                DiagnosticKey::Adapters,
566                DiagnosticKey::Interfaces,
567            ],
568            cost: Cost::Medium,
569            risk: Risk::Low,
570            max_attempts: 1,
571            stabilization: Duration::from_secs(8),
572        },
573        Action {
574            id: ActionId::DisableConsumerVpns,
575            label: "Temporarily disable consumer VPNs",
576            one_line_why: "Some consumer VPNs (NordVPN, ExpressVPN, Tailscale, etc.) interfere with diagnostics. Enterprise VPNs are never auto-disabled.",
577            targets: &[
578                DiagnosticKey::PublicIp,
579                DiagnosticKey::Latency,
580                DiagnosticKey::Dns,
581            ],
582            cost: Cost::Medium,
583            risk: Risk::Medium,
584            max_attempts: 1,
585            stabilization: Duration::from_secs(2),
586        },
587        Action {
588            id: ActionId::BounceInterface,
589            label: "Restart your network adapter (disable → re-enable)",
590            one_line_why: "Forces the adapter to reset its link, re-associate Wi-Fi, and re-DHCP.",
591            targets: &[
592                DiagnosticKey::Adapters,
593                DiagnosticKey::Interfaces,
594                DiagnosticKey::Gateway,
595                DiagnosticKey::Dns,
596                DiagnosticKey::PublicIp,
597                DiagnosticKey::Latency,
598            ],
599            cost: Cost::Expensive,
600            risk: Risk::Medium,
601            max_attempts: 1,
602            stabilization: Duration::from_secs(10),
603        },
604    ];
605
606    // Deep stack reset — High risk, requires Y/N. Wording is per-platform.
607    let deep_reset_explanation = make_deep_reset_explanation();
608    actions.push(Action {
609        id: ActionId::DeepStackReset,
610        label: deep_reset_explanation.what,
611        one_line_why: "Last-resort recovery when nothing else worked.",
612        targets: &[
613            DiagnosticKey::Dns,
614            DiagnosticKey::Gateway,
615            DiagnosticKey::PublicIp,
616            DiagnosticKey::Adapters,
617            DiagnosticKey::Interfaces,
618        ],
619        cost: Cost::Expensive,
620        risk: Risk::High(deep_reset_explanation),
621        max_attempts: 1,
622        stabilization: Duration::from_secs(15),
623    });
624
625    actions
626}
627
628#[cfg(windows)]
629fn make_deep_reset_explanation() -> RiskExplanation {
630    RiskExplanation {
631        what: "Reset Windows networking stack",
632        why: "This rebuilds Windows' TCP/IP, Winsock, and IPv6 catalogs from scratch — the standard fix when simpler steps haven't recovered the connection.",
633        side_effects: &[
634            "You will lose internet for ~10–15 seconds.",
635            "Open VPN sessions and SSH connections will drop.",
636            "A reboot is recommended afterward; nd300 will remind you at the end.",
637        ],
638        reversible: Reversibility::RebootToFullyRevert,
639        typical_duration: "10–15 seconds",
640    }
641}
642
643#[cfg(target_os = "macos")]
644fn make_deep_reset_explanation() -> RiskExplanation {
645    RiskExplanation {
646        what: "Safely cycle the macOS network service",
647        why: "The former reset deleted and recreated the active network service and could damage Wi-Fi configuration. That implementation has been removed. Its replacement only disables and re-enables an exactly-mapped physical service, preserves resolver state, and remains unavailable until disposable-Mac validation is complete.",
648        side_effects: &[
649            "The service-deletion reset will never run.",
650            "This build refuses the replacement cycle until it passes destructive testing on a disposable Mac.",
651            "Use the lower-risk adapter restart or System Settings while this safeguard is active.",
652        ],
653        reversible: Reversibility::FullyReversible,
654        typical_duration: "Unavailable until disposable-Mac validation",
655    }
656}
657
658#[cfg(target_os = "linux")]
659fn make_deep_reset_explanation() -> RiskExplanation {
660    RiskExplanation {
661        what: "Attempt guarded Linux deep network recovery",
662        why: "ND300 no longer deletes and recreates NetworkManager profiles because an interruption could leave the connection permanently missing. NetworkManager-managed connections are refused; the remaining fallback only applies to legacy dhcpcd/wpa_supplicant systems.",
663        side_effects: &[
664            "NetworkManager-managed connections will not be altered.",
665            "On legacy dhcpcd systems, the network service may restart briefly.",
666            "A requested Wi-Fi reconnect may append a wpa_supplicant entry and is not automatically reversible.",
667        ],
668        reversible: Reversibility::NotReversible,
669        typical_duration: "Unavailable for NetworkManager; about 10–20 seconds on the legacy fallback",
670    }
671}
672
673#[cfg(test)]
674mod tests {
675    use crate::config::Config;
676
677    use super::*;
678
679    #[tokio::test]
680    async fn json_mode_does_not_disable_consumer_vpns() {
681        // The JSON path early-returns before touching the registry, so a fresh
682        // empty registry is sufficient and the assertion is unchanged.
683        let outcome =
684            apply_disable_consumer_vpns(&Config::new().with_json(), &RestoreRegistry::new()).await;
685
686        assert!(!outcome.ok);
687        assert!(
688            outcome.message.contains("requires an interactive session"),
689            "unexpected outcome: {:?}",
690            outcome
691        );
692    }
693
694    #[test]
695    fn macos_action_sources_contain_no_service_deletion_or_credential_replay() {
696        let stages = include_str!("stages.rs");
697        let wifi = include_str!("wifi.rs");
698        let dns = include_str!("dns.rs");
699        let standalone_dns = include_str!("../dns.rs");
700        for forbidden in [
701            concat!("-remove", "networkservice"),
702            concat!("-create", "networkservice"),
703            concat!("find-generic", "-password"),
704            concat!("Apple80211", ".framework"),
705            concat!("-setairport", "network"),
706        ] {
707            assert!(
708                !stages.contains(forbidden) && !wifi.contains(forbidden),
709                "forbidden destructive macOS action dependency returned: {forbidden}"
710            );
711        }
712        for forbidden in [
713            "install_cmd",
714            "nextdns deactivate",
715            concat!("find-generic", "-password"),
716        ] {
717            assert!(
718                !dns.contains(forbidden) && !standalone_dns.contains(forbidden),
719                "forbidden non-transactional macOS DNS mutation returned: {forbidden}"
720            );
721        }
722    }
723
724    #[test]
725    fn linux_action_sources_contain_no_networkmanager_profile_deletion() {
726        let stages = include_str!("stages.rs");
727        let forbidden = concat!("\"connection\"", ", \"delete\"");
728        assert!(
729            !stages.contains(forbidden),
730            "unguarded NetworkManager profile deletion returned"
731        );
732        assert!(
733            stages.contains("Linux deep reset is safely unavailable while NetworkManager"),
734            "fail-closed NetworkManager recovery guidance is missing"
735        );
736    }
737}